diff --git a/assets/css/my-wordpress-woocommerce.css b/assets/css/my-wordpress-woocommerce.css
index d56dea9e9..9176f7529 100644
--- a/assets/css/my-wordpress-woocommerce.css
+++ b/assets/css/my-wordpress-woocommerce.css
@@ -142,6 +142,177 @@
margin-inline-end: 4px;
}
+/* ----- Product catalog workspace ----- */
+
+.os-woo-panel--product {
+ container-name: woo-product-panel;
+ container-type: inline-size;
+ padding: 16px;
+ border-radius: 14px;
+ background:
+ radial-gradient(
+ circle at 100% 0,
+ color-mix( in srgb, var( --os-ui-accent, #2271b1 ) 9%, transparent ),
+ transparent 42%
+ ),
+ var( --os-ui-bg-subtle, rgba( 0, 0, 0, 0.02 ) );
+}
+
+.os-woo-panel--product .os-woo-panel__title {
+ margin-block-end: 12px;
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ text-transform: none;
+ color: var( --os-ui-fg, #1e1e1e );
+}
+
+.os-woo-panel__row--product-snapshot {
+ display: block;
+ grid-column: 1 / -1;
+}
+
+.os-woo-panel__product-snapshot {
+ display: grid;
+ grid-template-columns: repeat( 3, minmax( 0, 1fr ) );
+ gap: 8px;
+ margin: 0 0 7px;
+}
+
+.os-woo-panel__product-metric {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 4px;
+ min-height: 60px;
+ padding: 9px 10px;
+ box-sizing: border-box;
+ border: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.1 ) );
+ border-radius: 9px;
+ background: var( --os-ui-surface, var( --os-window-bg, #fff ) );
+}
+
+.os-woo-panel__product-metric small {
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.os-woo-panel__product-metric strong {
+ font-size: 14px;
+ font-variant-numeric: tabular-nums;
+}
+
+.os-woo-product-tile .os-file-tile__visual {
+ position: relative;
+ border-radius: 12px;
+ box-shadow: 0 10px 22px rgba( 0, 0, 0, 0.15 );
+}
+
+.os-woo-product-tile__meta {
+ position: absolute;
+ inset-inline: 7px;
+ inset-block-end: 7px;
+ display: flex;
+ align-items: flex-start;
+ flex-direction: column;
+ justify-content: flex-end;
+ gap: 3px;
+ z-index: 1;
+}
+
+.os-woo-product-tile__meta::before {
+ content: '';
+ position: absolute;
+ inset: -28px -7px -7px;
+ z-index: -1;
+ border-radius: 0 0 10px 10px;
+ background: linear-gradient( to top, rgba( 7, 8, 13, 0.88 ), transparent );
+ pointer-events: none;
+}
+
+.os-woo-product-tile__category {
+ order: -1;
+ max-width: 100%;
+ overflow: hidden;
+ color: #fff;
+ font-size: 9px;
+ font-weight: 650;
+ text-overflow: ellipsis;
+ text-shadow: 0 1px 5px rgba( 0, 0, 0, 0.9 );
+ white-space: nowrap;
+}
+
+.os-woo-product-create-action {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ margin-block-start: 16px;
+ padding: 13px 14px;
+ border: 1px solid
+ color-mix( in srgb, var( --os-ui-accent, #2271b1 ) 34%, transparent );
+ border-radius: 12px;
+ background: color-mix( in srgb, var( --os-ui-accent, #2271b1 ) 8%, transparent );
+}
+
+.os-woo-product-create-action strong,
+.os-woo-product-create-action small {
+ display: block;
+}
+
+.os-woo-product-create-action small {
+ margin-block-start: 2px;
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 10px;
+}
+
+/* My WordPress opens at a compact desktop width, leaving roughly 200px
+ * for the preview pane. Reflow against the panel itself instead of the
+ * browser viewport so widening the window restores the three-up view. */
+@container woo-product-panel ( max-width: 420px ) {
+ .os-woo-panel__rows {
+ grid-template-columns: minmax( 0, 1fr );
+ gap: 3px;
+ }
+
+ .os-woo-panel__row--product-snapshot {
+ grid-column: 1;
+ }
+
+ .os-woo-panel__product-snapshot {
+ grid-template-columns: minmax( 0, 1fr );
+ gap: 6px;
+ }
+
+ .os-woo-panel__product-metric {
+ align-items: center;
+ flex-direction: row;
+ justify-content: space-between;
+ min-height: 0;
+ padding: 9px 10px;
+ }
+
+ .os-woo-panel--product .os-woo-panel__label {
+ margin-block-start: 7px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ }
+
+ .os-woo-product-create-action {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .os-woo-product-create-action os-button::part( button ) {
+ width: 100%;
+ }
+}
+
/* ----- Tile badges ----- */
/* Out-of-stock (and low-stock / sale) products carry a corner ribbon
@@ -509,3 +680,1135 @@ os-ribbon.os-woo-ribbon {
margin-block-start: auto;
padding-block-start: 4px;
}
+
+/* ===================================================================
+ * Product Studio
+ * =================================================================== */
+
+/* ----- Woo folder launcher ----- */
+
+.os-woo-product-studio-launcher {
+ display: grid;
+ grid-template-columns: auto minmax( 0, 1fr ) auto;
+ align-items: center;
+ gap: 14px;
+ margin: 12px 16px 0;
+ padding: 14px 16px;
+ border: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.12 ) );
+ border-radius: 12px;
+ background:
+ linear-gradient(
+ 120deg,
+ color-mix( in srgb, var( --os-ui-accent, #2271b1 ) 12%, transparent ),
+ transparent 52%
+ ),
+ var( --os-ui-surface, var( --os-window-bg, #fff ) );
+ box-shadow: 0 10px 24px rgba( 0, 0, 0, 0.08 );
+ color: var( --os-ui-fg, #1e1e1e );
+}
+
+.os-woo-product-studio-launcher > .dashicons {
+ display: grid;
+ place-items: center;
+ width: 42px;
+ height: 42px;
+ border-radius: 12px;
+ background: var( --os-ui-accent, #2271b1 );
+ color: var( --os-ui-accent-contrast, #fff );
+ font-size: 21px;
+ line-height: 1;
+}
+
+.os-woo-product-studio-launcher__eyebrow {
+ margin-block-end: 2px;
+ font-size: 10px;
+ font-weight: 750;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var( --os-ui-accent, #2271b1 );
+}
+
+.os-woo-product-studio-launcher h3,
+.os-woo-product-studio-launcher p {
+ margin: 0;
+}
+
+.os-woo-product-studio-launcher h3 {
+ font-size: 15px;
+ line-height: 1.3;
+}
+
+.os-woo-product-studio-launcher p {
+ margin-block-start: 3px;
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+/* ----- Window shell ----- */
+
+/* Native-window geometry is restored from desktop state. Clamp this
+ * large canvas to the live viewport as a final guard when the browser
+ * is narrowed after a previous wide-screen session. The bottom inset
+ * keeps the floating dock from covering the sticky action bar. */
+#wp-window-desktop-mode-woo-product-studio {
+ max-inline-size: calc( 100vw - 24px );
+ max-block-size: calc( 100vh - 100px );
+ max-block-size: calc( 100dvh - 100px );
+}
+
+.os-woo-product-studio {
+ container-type: inline-size;
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+ height: 100%;
+ box-sizing: border-box;
+ overflow: hidden;
+ background: var( --os-window-bg, #f6f7f7 );
+ color: var( --os-ui-fg, #1e1e1e );
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.os-woo-product-studio__loading,
+.os-woo-product-studio__load-error {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ min-height: 320px;
+ padding: 32px;
+ box-sizing: border-box;
+ text-align: center;
+}
+
+.os-woo-product-studio__loading os-spinner {
+ --os-ui-spinner-size: 44px;
+}
+
+.os-woo-product-studio__load-error > .dashicons {
+ width: 48px;
+ height: 48px;
+ margin-block-end: 12px;
+ color: var( --os-ui-warning-fg, #996800 );
+ font-size: 48px;
+}
+
+.os-woo-product-studio__load-error h2,
+.os-woo-product-studio__load-error p {
+ margin: 0;
+}
+
+.os-woo-product-studio__load-error p {
+ max-width: 480px;
+ margin: 8px 0 18px;
+ color: var( --os-ui-fg-muted, #646970 );
+}
+
+.os-woo-product-studio__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 11px 20px 10px;
+ background: var( --os-ui-surface, var( --os-window-bg, #fff ) );
+}
+
+.os-woo-product-studio__header h1 {
+ margin: 1px 0 0;
+ font-size: clamp( 17px, 1.8cqi, 21px );
+ font-weight: 650;
+ line-height: 1.18;
+ letter-spacing: -0.025em;
+}
+
+.os-woo-product-studio__eyebrow {
+ font-size: 10px;
+ font-weight: 750;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var( --os-ui-accent, #2271b1 );
+}
+
+.os-woo-product-studio__progress {
+ --os-ui-progress-height: 3px;
+ --os-ui-progress-track-bg: var( --os-ui-border, rgba( 0, 0, 0, 0.08 ) );
+ display: block;
+ padding: 0;
+ box-sizing: border-box;
+ background: var( --os-ui-bg-subtle, rgba( 0, 0, 0, 0.02 ) );
+}
+
+.os-woo-product-studio__progress::part( header ) {
+ display: none;
+}
+
+.os-woo-product-studio__progress::part( track ) {
+ border-radius: 0;
+}
+
+.os-woo-product-studio__workbench {
+ display: grid;
+ grid-template-columns: minmax( 430px, 1fr ) minmax( 320px, 390px );
+ flex: 1;
+ min-height: 0;
+ background: var( --os-ui-bg-subtle, rgba( 0, 0, 0, 0.02 ) );
+}
+
+/* ----- Step rail ----- */
+
+.os-woo-product-studio__steps {
+ display: flex;
+ gap: 8px;
+ min-width: 0;
+ padding: 8px 18px;
+ box-sizing: border-box;
+ border-block-start: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.08 ) );
+ border-block-end: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.1 ) );
+ background: var( --os-ui-bg-subtle, rgba( 0, 0, 0, 0.02 ) );
+}
+
+os-button.os-woo-product-studio__step-button {
+ display: block;
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+os-button.os-woo-product-studio__step-button::part( button ) {
+ display: grid;
+ grid-template-columns: 28px minmax( 0, 1fr );
+ align-items: center;
+ gap: 9px;
+ width: 100%;
+ min-height: 48px;
+ padding: 8px 9px;
+ border-color: transparent;
+ border-radius: 9px;
+ text-align: start;
+}
+
+os-button.os-woo-product-studio__step-button[ aria-current='step' ]::part( button ) {
+ border-color: var( --os-ui-accent, #2271b1 );
+ background:
+ linear-gradient(
+ 100deg,
+ color-mix( in srgb, var( --os-ui-accent, #2271b1 ) 14%, transparent ),
+ color-mix( in srgb, var( --os-ui-accent, #2271b1 ) 4%, transparent )
+ );
+ box-shadow: inset 0 -2px 0 var( --os-ui-accent, #2271b1 );
+}
+
+.os-woo-product-studio__step-number {
+ display: grid;
+ place-items: center;
+ width: 26px;
+ height: 26px;
+ border: 1px solid var( --os-ui-border-strong, rgba( 0, 0, 0, 0.24 ) );
+ border-radius: 50%;
+ font-size: 11px;
+ font-weight: 750;
+ font-variant-numeric: tabular-nums;
+}
+
+.os-woo-product-studio__step-button[ aria-current='step' ]
+ .os-woo-product-studio__step-number {
+ border-color: var( --os-ui-accent, #2271b1 );
+ background: var( --os-ui-accent, #2271b1 );
+ color: var( --os-ui-accent-contrast, #fff );
+}
+
+.os-woo-product-studio__step-button.is-complete
+ .os-woo-product-studio__step-number {
+ border-color: var( --os-ui-success-fg, #1a7f37 );
+ color: var( --os-ui-success-fg, #1a7f37 );
+}
+
+.os-woo-product-studio__step-button strong,
+.os-woo-product-studio__step-button small {
+ display: block;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.os-woo-product-studio__step-button strong {
+ font-size: 12px;
+ line-height: 1.25;
+}
+
+.os-woo-product-studio__step-button small {
+ margin-block-start: 2px;
+ font-size: 10px;
+ font-weight: 450;
+ color: var( --os-ui-fg-muted, #646970 );
+}
+
+/* ----- Stage ----- */
+
+.os-woo-product-studio__stage {
+ min-width: 0;
+ min-height: 0;
+ padding: clamp( 20px, 2.5cqi, 32px );
+ box-sizing: border-box;
+ overflow: auto;
+ background:
+ radial-gradient(
+ circle at 90% 0,
+ color-mix( in srgb, var( --os-ui-accent, #2271b1 ) 5%, transparent ),
+ transparent 36%
+ ),
+ var( --os-ui-surface, var( --os-window-bg, #fff ) );
+ scrollbar-gutter: stable;
+}
+
+.os-woo-product-studio__stage > * + * {
+ margin-block-start: 20px;
+}
+
+.os-woo-product-studio__stage-heading {
+ max-width: 650px;
+ margin-block-end: 18px;
+}
+
+.os-woo-product-studio__stage-heading h2,
+.os-woo-product-studio__stage-heading p {
+ margin: 0;
+}
+
+.os-woo-product-studio__stage-heading h2 {
+ margin-block-start: 5px;
+ font-size: clamp( 23px, 2.4cqi, 30px );
+ font-weight: 650;
+ line-height: 1.14;
+ letter-spacing: -0.03em;
+}
+
+.os-woo-product-studio__stage-heading p {
+ max-width: 590px;
+ margin-block-start: 9px;
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.os-woo-product-studio__editor-panel {
+ padding: 18px;
+ border: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.1 ) );
+ border-radius: 14px;
+ background: color-mix(
+ in srgb,
+ var( --os-ui-bg-subtle, rgba( 0, 0, 0, 0.03 ) ) 78%,
+ transparent
+ );
+ box-shadow: 0 14px 34px rgba( 0, 0, 0, 0.06 );
+}
+
+.os-woo-product-studio__editor-panel > * + * {
+ margin-block-start: 16px;
+}
+
+.os-woo-product-studio__editor-panel-heading {
+ padding-block-end: 13px;
+ border-block-end: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.08 ) );
+}
+
+.os-woo-product-studio__editor-panel-heading h3,
+.os-woo-product-studio__editor-panel-heading p {
+ margin: 0;
+}
+
+.os-woo-product-studio__editor-panel-heading h3 {
+ font-size: 14px;
+ font-weight: 700;
+}
+
+.os-woo-product-studio__editor-panel-heading p {
+ margin-block-start: 2px;
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 11px;
+}
+
+.os-woo-product-studio__readiness {
+ display: grid;
+ grid-template-columns: 38px minmax( 0, 1fr );
+ align-items: center;
+ gap: 12px;
+ min-height: 66px;
+ padding: 12px 14px;
+ box-sizing: border-box;
+ border: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.1 ) );
+ border-radius: 12px;
+ background: var( --os-ui-bg-subtle, rgba( 0, 0, 0, 0.025 ) );
+}
+
+.os-woo-product-studio__readiness > .dashicons {
+ display: grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: color-mix( in srgb, currentColor 12%, transparent );
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 18px;
+}
+
+.os-woo-product-studio__readiness.is-ready {
+ border-color: color-mix( in srgb, var( --os-ui-success-fg, #1a7f37 ) 34%, transparent );
+ background: color-mix( in srgb, var( --os-ui-success-fg, #1a7f37 ) 7%, transparent );
+}
+
+.os-woo-product-studio__readiness.is-ready > .dashicons {
+ color: var( --os-ui-success-fg, #1a7f37 );
+}
+
+.os-woo-product-studio__readiness.is-progress > .dashicons {
+ color: var( --os-ui-info-fg, var( --os-ui-accent, #2271b1 ) );
+}
+
+.os-woo-product-studio__readiness.is-guidance > .dashicons {
+ color: var( --os-ui-warning-fg, #996800 );
+}
+
+.os-woo-product-studio__readiness strong,
+.os-woo-product-studio__readiness small {
+ display: block;
+}
+
+.os-woo-product-studio__readiness strong {
+ font-size: 12px;
+}
+
+.os-woo-product-studio__readiness small {
+ margin-block-start: 2px;
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 10px;
+ line-height: 1.4;
+}
+
+.os-woo-product-studio__kind-grid {
+ display: grid;
+ grid-template-columns: repeat( 2, minmax( 0, 1fr ) );
+ gap: 10px;
+}
+
+.os-woo-product-studio__kind-grid os-card {
+ display: grid;
+ grid-template-columns: 34px minmax( 0, 1fr );
+ align-items: center;
+ gap: 10px;
+ --os-ui-card-padding: 13px;
+ --os-ui-card-radius: 10px;
+}
+
+.os-woo-product-studio__kind-icon {
+ display: grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ border-radius: 9px;
+ background: var( --os-ui-bg-subtle, rgba( 0, 0, 0, 0.05 ) );
+ color: var( --os-ui-accent, #2271b1 );
+ font-size: 17px;
+}
+
+.os-woo-product-studio__kind-grid strong,
+.os-woo-product-studio__kind-grid small {
+ display: block;
+}
+
+.os-woo-product-studio__kind-grid strong {
+ font-size: 12px;
+}
+
+.os-woo-product-studio__kind-grid small {
+ margin-block-start: 2px;
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 10px;
+ line-height: 1.35;
+}
+
+.os-woo-product-studio__field-grid {
+ display: grid;
+ grid-template-columns: repeat( 2, minmax( 0, 1fr ) );
+ gap: 18px 14px;
+}
+
+.os-woo-product-studio__field-grid > :last-child {
+ grid-column: 1 / -1;
+}
+
+.os-woo-product-studio__field-wrap {
+ min-width: 0;
+}
+
+.os-woo-product-studio__field-hint {
+ margin: 5px 2px 0;
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 10px;
+ line-height: 1.4;
+}
+
+.os-woo-product-studio__subsection,
+.os-woo-product-studio__categories {
+ padding-block-start: 16px;
+ border-block-start: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.1 ) );
+}
+
+.os-woo-product-studio__subsection h3,
+.os-woo-product-studio__categories h3,
+.os-woo-product-studio__categories p {
+ margin: 0;
+}
+
+.os-woo-product-studio__subsection h3,
+.os-woo-product-studio__categories h3 {
+ font-size: 13px;
+}
+
+.os-woo-product-studio__subsection os-checkbox-label {
+ display: block;
+ margin: 9px 0 14px;
+}
+
+/* ----- Image + categories ----- */
+
+.os-woo-product-studio__image-picker {
+ display: grid;
+ grid-template-columns: auto minmax( 0, 1fr ) auto;
+ align-items: center;
+ gap: 6px 13px;
+ min-height: 124px;
+ padding: 18px;
+ box-sizing: border-box;
+ border: 1px dashed var( --os-ui-border-strong, rgba( 0, 0, 0, 0.28 ) );
+ border-radius: 12px;
+ background: var( --os-ui-bg-subtle, rgba( 0, 0, 0, 0.025 ) );
+ transition:
+ border-color 160ms ease,
+ background 160ms ease,
+ transform 160ms ease;
+}
+
+.os-woo-product-studio__image-picker.is-dragging {
+ border-color: var( --os-ui-accent, #2271b1 );
+ background: color-mix( in srgb, var( --os-ui-accent, #2271b1 ) 10%, transparent );
+ transform: translateY( -1px );
+}
+
+.os-woo-product-studio__image-picker > .dashicons {
+ grid-row: 1 / span 2;
+ display: grid;
+ place-items: center;
+ width: 44px;
+ height: 44px;
+ border-radius: 12px;
+ background: var( --os-ui-surface, var( --os-window-bg, #fff ) );
+ box-shadow: 0 4px 14px rgba( 0, 0, 0, 0.08 );
+ color: var( --os-ui-accent, #2271b1 );
+ font-size: 21px;
+}
+
+.os-woo-product-studio__image-picker > strong {
+ align-self: end;
+ font-size: 13px;
+ overflow-wrap: anywhere;
+}
+
+.os-woo-product-studio__image-picker > span:not( .dashicons ) {
+ align-self: start;
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 10px;
+}
+
+.os-woo-product-studio__image-picker os-button {
+ grid-row: 1 / span 2;
+}
+
+.os-woo-product-studio__image-picker os-button + os-button {
+ grid-column: 2;
+ grid-row: 3;
+ justify-self: start;
+}
+
+.os-woo-product-studio__file-input {
+ position: fixed;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.os-woo-product-studio__categories > p {
+ margin-block-start: 3px;
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 11px;
+}
+
+.os-woo-product-studio__category-list {
+ display: grid;
+ grid-template-columns: repeat( 2, minmax( 0, 1fr ) );
+ gap: 2px 8px;
+ max-height: 180px;
+ margin-block-start: 12px;
+ padding: 8px;
+ box-sizing: border-box;
+ overflow: auto;
+ border: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.1 ) );
+ border-radius: 9px;
+ background: var( --os-ui-bg-subtle, rgba( 0, 0, 0, 0.02 ) );
+}
+
+.os-woo-product-studio__category-list os-checkbox-label {
+ min-width: 0;
+ padding: 5px 7px;
+ padding-inline-start: calc( 7px + var( --os-woo-category-depth, 0 ) * 12px );
+ border-radius: 6px;
+ font-size: 11px;
+}
+
+.os-woo-product-studio__category-list os-checkbox-label:hover {
+ background: color-mix( in srgb, var( --os-ui-accent, #2271b1 ) 8%, transparent );
+}
+
+.os-woo-product-studio__category-empty {
+ padding: 12px;
+ border-radius: 8px;
+ background: var( --os-ui-bg-subtle, rgba( 0, 0, 0, 0.03 ) );
+}
+
+/* ----- Review ----- */
+
+.os-woo-product-studio__review-grid {
+ display: grid;
+ grid-template-columns: repeat( 2, minmax( 0, 1fr ) );
+ gap: 10px;
+}
+
+.os-woo-product-studio__review-row {
+ display: grid;
+ grid-template-columns: 28px minmax( 0, 1fr );
+ align-items: center;
+ gap: 10px;
+ min-height: 66px;
+ padding: 11px 12px;
+ box-sizing: border-box;
+ border: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.1 ) );
+ border-radius: 9px;
+ background: var( --os-ui-bg-subtle, rgba( 0, 0, 0, 0.02 ) );
+}
+
+.os-woo-product-studio__review-row > .dashicons {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: color-mix( in srgb, var( --os-ui-success-fg, #1a7f37 ) 12%, transparent );
+ color: var( --os-ui-success-fg, #1a7f37 );
+}
+
+.os-woo-product-studio__review-row > .dashicons-marker {
+ background: color-mix( in srgb, var( --os-ui-warning-fg, #996800 ) 12%, transparent );
+ color: var( --os-ui-warning-fg, #996800 );
+}
+
+.os-woo-product-studio__review-row small,
+.os-woo-product-studio__review-row strong {
+ display: block;
+}
+
+.os-woo-product-studio__review-row small {
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ color: var( --os-ui-fg-muted, #646970 );
+}
+
+.os-woo-product-studio__review-row strong {
+ margin-block-start: 2px;
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+
+.os-woo-product-studio__error {
+ margin-block-end: 16px;
+}
+
+/* ----- Live preview ----- */
+
+.os-woo-product-studio__preview {
+ min-width: 0;
+ min-height: 0;
+ padding: 18px;
+ box-sizing: border-box;
+ overflow: auto;
+ border-inline-start: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.1 ) );
+ background:
+ radial-gradient(
+ circle at 90% 0,
+ color-mix( in srgb, var( --os-ui-accent, #2271b1 ) 10%, transparent ),
+ transparent 38%
+ ),
+ var( --os-ui-bg-subtle, #f0f0f1 );
+}
+
+.os-woo-product-studio__preview-header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+ margin-block-end: 10px;
+}
+
+.os-woo-product-studio__preview-label {
+ margin: 0;
+ font-size: 9px;
+ font-weight: 750;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: var( --os-ui-fg-muted, #646970 );
+}
+
+.os-woo-product-studio__preview-header > span {
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 9px;
+}
+
+.os-woo-product-studio__preview-frame {
+ padding: 9px;
+ border: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.12 ) );
+ border-radius: 16px;
+ background: color-mix(
+ in srgb,
+ var( --os-ui-surface, var( --os-window-bg, #fff ) ) 84%,
+ transparent
+ );
+ box-shadow: 0 20px 46px rgba( 0, 0, 0, 0.12 );
+}
+
+.os-woo-product-studio__store-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 3px 4px 10px;
+}
+
+.os-woo-product-studio__store-bar > span {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+ font-size: 10px;
+}
+
+.os-woo-product-studio__store-bar .dashicons {
+ width: 16px;
+ height: 16px;
+ color: var( --os-ui-accent, #2271b1 );
+ font-size: 16px;
+}
+
+.os-woo-product-studio__product-card {
+ width: 100%;
+ max-width: 330px;
+ margin: 0 auto;
+ overflow: hidden;
+ border: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.1 ) );
+ border-radius: 11px;
+ background: var( --os-ui-surface, var( --os-window-bg, #fff ) );
+ box-shadow: 0 12px 30px rgba( 0, 0, 0, 0.11 );
+}
+
+.os-woo-product-studio__product-media {
+ position: relative;
+ aspect-ratio: 4 / 3;
+ overflow: hidden;
+ background: #111;
+}
+
+.os-woo-product-studio__product-media img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.os-woo-product-studio__product-media.is-placeholder img {
+ opacity: 0.78;
+ filter: saturate( 0.82 );
+}
+
+.os-woo-product-studio__product-media.is-placeholder::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient( to top, rgba( 0, 0, 0, 0.7 ), transparent 44% );
+ pointer-events: none;
+}
+
+.os-woo-product-studio__preview-badge {
+ position: absolute;
+ inset-block-start: 12px;
+ inset-inline-start: 12px;
+ z-index: 1;
+}
+
+.os-woo-product-studio__image-prompt {
+ position: absolute;
+ inset-inline: 16px;
+ inset-block-end: 15px;
+ z-index: 1;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 650;
+ text-align: center;
+ text-shadow: 0 1px 8px rgba( 0, 0, 0, 0.8 );
+}
+
+.os-woo-product-studio__product-copy {
+ padding: 17px 18px 16px;
+}
+
+.os-woo-product-studio__product-copy h3,
+.os-woo-product-studio__product-copy p {
+ margin: 0;
+}
+
+.os-woo-product-studio__product-copy h3 {
+ font-size: 18px;
+ line-height: 1.22;
+ letter-spacing: -0.018em;
+ overflow-wrap: anywhere;
+}
+
+.os-woo-product-studio__product-copy p {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ overflow: hidden;
+ min-height: 2.9em;
+ margin-block-start: 9px;
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+.os-woo-product-studio__preview-price {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ margin-block-start: 8px;
+}
+
+.os-woo-product-studio__preview-price s {
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 11px;
+}
+
+.os-woo-product-studio__preview-price strong {
+ font-size: 17px;
+ font-variant-numeric: tabular-nums;
+}
+
+.os-woo-product-studio__preview-categories {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ margin-block-start: 12px;
+}
+
+.os-woo-product-studio__preview-categories span {
+ padding: 2px 7px;
+ border-radius: 999px;
+ background: var( --os-ui-bg-subtle, rgba( 0, 0, 0, 0.05 ) );
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 9px;
+ font-weight: 650;
+}
+
+.os-woo-product-studio__preview-stock {
+ margin-block-start: 12px;
+ padding-block-start: 10px;
+ border-block-start: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.08 ) );
+ color: var( --os-ui-success-fg, #1a7f37 );
+ font-size: 10px;
+ font-weight: 650;
+}
+
+.os-woo-product-studio__preview-stock::before {
+ content: '';
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ margin-inline-end: 6px;
+ border-radius: 50%;
+ background: currentColor;
+ vertical-align: 1px;
+}
+
+/* ----- Footer ----- */
+
+.os-woo-product-studio__footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 66px;
+ padding: 11px 20px;
+ box-sizing: border-box;
+ border-block-start: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.1 ) );
+ background: var( --os-ui-surface, var( --os-window-bg, #fff ) );
+ box-shadow: 0 -8px 22px rgba( 0, 0, 0, 0.04 );
+}
+
+.os-woo-product-studio__footer-leading,
+.os-woo-product-studio__footer-trailing {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+/*
+ * Maximized windows fill `.os-area`'s border box while the floating
+ * bottom dock sits over its final ~80px. Pull Product Studio's actions
+ * into that reserved safe area so Save / Continue never land behind
+ * the dock. Fullscreen windows do not carry `--maximized`, and hide the
+ * dock, so they keep the tighter footer above.
+ */
+.os-window--maximized .os-woo-product-studio__footer {
+ min-height: 140px;
+ padding-block-end: 84px;
+}
+
+.os-window--maximized .os-woo-product-studio.is-complete {
+ padding-block-end: 80px;
+}
+
+/* ----- Completion ----- */
+
+.os-woo-product-studio.is-complete {
+ overflow: auto;
+}
+
+.os-woo-product-studio__success {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ width: min( 680px, calc( 100% - 48px ) );
+ margin: 0 auto;
+ padding: 42px 24px;
+ box-sizing: border-box;
+ text-align: center;
+}
+
+.os-woo-product-studio__success > .dashicons {
+ display: grid;
+ place-items: center;
+ width: 58px;
+ height: 58px;
+ margin-block-end: 16px;
+ border-radius: 50%;
+ background: color-mix( in srgb, var( --os-ui-success-fg, #1a7f37 ) 14%, transparent );
+ color: var( --os-ui-success-fg, #1a7f37 );
+ font-size: 34px;
+}
+
+.os-woo-product-studio__success h2,
+.os-woo-product-studio__success p {
+ margin: 0;
+}
+
+.os-woo-product-studio__success h2 {
+ margin-block-start: 6px;
+ font-size: clamp( 27px, 4cqi, 40px );
+ line-height: 1.1;
+ letter-spacing: -0.035em;
+}
+
+.os-woo-product-studio__success > p {
+ max-width: 560px;
+ margin-block-start: 12px;
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.os-woo-product-studio__success-product {
+ display: grid;
+ grid-template-columns: 72px minmax( 0, 1fr );
+ align-items: center;
+ gap: 14px;
+ width: min( 420px, 100% );
+ margin-block-start: 24px;
+ padding: 10px;
+ box-sizing: border-box;
+ border: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.1 ) );
+ border-radius: 12px;
+ background: var( --os-ui-surface, var( --os-window-bg, #fff ) );
+ box-shadow: 0 12px 30px rgba( 0, 0, 0, 0.1 );
+ text-align: start;
+}
+
+.os-woo-product-studio__success-product img {
+ display: block;
+ width: 72px;
+ height: 86px;
+ border-radius: 8px;
+ object-fit: cover;
+ background: #111;
+}
+
+.os-woo-product-studio__success-product strong,
+.os-woo-product-studio__success-product span {
+ display: block;
+}
+
+.os-woo-product-studio__success-product strong {
+ font-size: 15px;
+ overflow-wrap: anywhere;
+}
+
+.os-woo-product-studio__success-product span {
+ margin-block-start: 4px;
+ color: var( --os-ui-fg-muted, #646970 );
+ font-size: 12px;
+}
+
+.os-woo-product-studio__success-actions {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-block-start: 24px;
+}
+
+/* ----- Responsive window states ----- */
+
+@container ( max-width: 940px ) {
+ .os-woo-product-studio__workbench {
+ grid-template-columns: minmax( 0, 1fr ) minmax( 280px, 320px );
+ }
+
+ .os-woo-product-studio__steps {
+ gap: 6px;
+ padding: 9px 12px;
+ }
+
+ os-button.os-woo-product-studio__step-button {
+ min-width: 0;
+ }
+
+ os-button.os-woo-product-studio__step-button::part( button ) {
+ min-height: 44px;
+ padding: 6px 8px;
+ }
+
+}
+
+@container ( max-width: 710px ) {
+ .os-woo-product-studio__header {
+ padding-inline: 18px;
+ }
+
+ .os-woo-product-studio__progress {
+ padding-inline: 0;
+ }
+
+ .os-woo-product-studio__workbench {
+ grid-template-columns: minmax( 0, 1fr );
+ grid-template-rows: auto auto;
+ overflow: auto;
+ }
+
+ .os-woo-product-studio__steps {
+ min-height: 64px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ }
+
+ os-button.os-woo-product-studio__step-button {
+ flex: 0 0 150px;
+ }
+
+ .os-woo-product-studio__stage {
+ grid-column: 1;
+ grid-row: 1;
+ min-height: 460px;
+ overflow: visible;
+ }
+
+ .os-woo-product-studio__preview {
+ grid-column: 1;
+ grid-row: 2;
+ min-height: 580px;
+ overflow: visible;
+ border-inline-start: 0;
+ border-block-start: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.1 ) );
+ }
+
+ .os-woo-product-studio__product-card {
+ max-width: 360px;
+ }
+
+ .os-woo-product-studio__kind-grid,
+ .os-woo-product-studio__field-grid,
+ .os-woo-product-studio__review-grid,
+ .os-woo-product-studio__category-list {
+ grid-template-columns: minmax( 0, 1fr );
+ }
+
+ .os-woo-product-studio__field-grid > :last-child {
+ grid-column: auto;
+ }
+
+ .os-woo-product-studio__footer {
+ position: sticky;
+ inset-block-end: 0;
+ }
+}
+
+@media ( max-width: 720px ) {
+ .os-woo-product-studio-launcher {
+ grid-template-columns: auto minmax( 0, 1fr );
+ }
+
+ .os-woo-product-studio-launcher os-button {
+ grid-column: 1 / -1;
+ justify-self: stretch;
+ }
+
+ .os-woo-product-studio-launcher os-button::part( button ) {
+ width: 100%;
+ min-height: 44px;
+ }
+}
+
+@media ( max-width: 520px ) {
+ .os-woo-panel__product-snapshot {
+ grid-template-columns: minmax( 0, 1fr );
+ }
+
+ .os-woo-product-create-action {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .os-woo-product-create-action os-button::part( button ) {
+ width: 100%;
+ }
+}
+
+@media ( prefers-reduced-motion: reduce ) {
+ .os-woo-product-studio__image-picker {
+ transition: none;
+ }
+}
diff --git a/assets/images/woo-product-studio-placeholder.webp b/assets/images/woo-product-studio-placeholder.webp
new file mode 100644
index 000000000..19cee04e8
Binary files /dev/null and b/assets/images/woo-product-studio-placeholder.webp differ
diff --git a/docs/architecture.md b/docs/architecture.md
index dfbbc009a..964d55f66 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -495,6 +495,27 @@ fighting over `orderby` is exactly how this went wrong once already:
WooCommerce Blocks hooks `rest_product_query` at 10 and ends with
`array_merge( $args, …, $orderby_query )`, which is why ours runs at 99.
+**Product creation.** The Woo folder's Product Studio launcher and the
+**Add New Product** desktop shortcut open a dedicated native window rather
+than dropping the merchant into the classic product editor. Its four-stage
+draft stays browser-side, with a same-tab
+session snapshot that recovers text, pricing, stock, and categories after an
+accidental close or reload. File objects never enter browser storage, so a
+recovered draft asks the merchant to reselect its image. Save draft or Publish
+posts one nonce-authenticated multipart request to
+`desktop-mode/v1/woocommerce/products`. The route validates the product
+capabilities and image independently, ingests the image through WordPress
+media, and persists a standard simple product through `WC_Product_Simple`.
+Each draft carries a UUID stored as private, user-scoped product metadata; a
+retry after a lost response returns the already-created product instead of
+creating a duplicate or uploading the image twice.
+The browser never receives a WooCommerce consumer key; cookie authentication
+and the REST nonce cover the in-dashboard request, while WooCommerce's CRUD
+layer remains the source of truth for prices, stock, categories, SKU
+uniqueness, and product hooks. A companion read route at
+`woocommerce/product-studio` supplies live categories, currency formatting,
+upload limits, and publish capability for the current user.
+
**Ownership attribution.** `registered_post_type` / `registered_taxonomy`
fire during `init`, where `get_plugins()` does not yet exist — Core
loads `wp-admin/includes/plugin.php` at `wp-admin/admin.php:102`, after
diff --git a/docs/hooks-reference.md b/docs/hooks-reference.md
index 3de02df43..e868c9f33 100644
--- a/docs/hooks-reference.md
+++ b/docs/hooks-reference.md
@@ -3417,6 +3417,51 @@ types are submenu entries under the WooCommerce menu, so they carry no
`menu_icon` of their own and would otherwise fall back to the generic
post pin.
+#### Product Studio
+
+The Woo folder's **Create product** action and the **Add New Product**
+desktop shortcut open a four-stage native
+window backed by WooCommerce's product CRUD API. The window keeps an
+unsaved draft and live storefront preview in the browser. It snapshots the
+serializable fields in same-tab session storage so an accidental close or
+reload is recoverable; product images must be reselected because file objects
+are never stored. Saving sends a nonce-authenticated multipart request to `POST
+desktop-mode/v1/woocommerce/products`. Product-type capabilities gate
+creation and publishing separately; image uploads additionally require
+`upload_files`. Every draft also sends a UUID `requestId`. The server stores it
+as private, user-scoped product metadata and returns the existing product when
+the same request is retried, preventing duplicate products after a lost
+network response.
+
+```php
+apply_filters( 'openstation_my_wordpress_woo_product_studio_template_html', string $html ): string
+```
+
+The Product Studio window's static template before it is emitted. Keep
+`data-os-woo-product-studio-root` intact so the Woo integration bundle
+can mount the flow. The result passes through
+`openstation_kses_native_window_template()`.
+
+```php
+apply_filters( 'openstation_my_wordpress_woo_product_studio_window_args', array $args ): array
+```
+
+The `openstation_register_window()` args for the Product Studio window:
+title, icon, dimensions, shared Woo integration script/style handles,
+and `placement => 'none'`.
+
+```php
+apply_filters( 'openstation_my_wordpress_woo_product_studio_icon_args', array $args ): array
+```
+
+The `openstation_register_icon()` args for the **Add New Product**
+desktop shortcut: title, Dashicon, Product Studio window target, and
+desktop position. The shortcut is not pinned, so the user can place or
+hide it through the normal desktop-item controls.
+
+All three filters are active only when WooCommerce is loaded and the viewer
+can edit products.
+
#### Customers
The **Customers** section renders through the built-in `user` entity
@@ -4594,6 +4639,28 @@ row and definition meta.
- **Param** `array $args` — sanitized creation fields `{ name, role, description, instructions, abilities }`.
- **Param** `int $actor_id` — user who created the agent.
+### `openstation_pre_agents_generate_blueprint` — Experimental *(filter)*
+
+Short-circuits the model call behind `POST /agents/generate`. Return
+an agent-definition array (`{ name, description, instructions, role,
+abilities }`) or a `WP_Error`; return `null` to use the configured Core
+AI Client. A returned definition still passes through the live role
+and ability-catalogue validation before an agent is created.
+
+- **Param** `array|WP_Error|null $generated`
+- **Param** `string $brief` — the administrator's natural-language brief.
+- **Param** `array $context` — assignable `roles` and available `abilities`.
+
+### `openstation_agents_blueprint_instructions` — Experimental *(filter)*
+
+Filters the system instruction used to turn the administrator's brief
+into an agent definition. The dynamic role and ability catalogues are
+sent separately in the user prompt and the structured response is
+validated server-side.
+
+- **Param** `string $instructions`
+- **Param** `array $context` — assignable `roles` and available `abilities`.
+
### `openstation_agent_updated` — Experimental *(action)*
Fires once per `openstation_agent_update()` call that changed at
diff --git a/includes/my-wordpress/bootstrap.php b/includes/my-wordpress/bootstrap.php
index 2faec1b80..5c9935ec5 100644
--- a/includes/my-wordpress/bootstrap.php
+++ b/includes/my-wordpress/bootstrap.php
@@ -32,6 +32,7 @@
// Third-party integrations. Each file is inert unless its plugin is
// active, so requiring them unconditionally costs one include.
require_once __DIR__ . '/integrations/woocommerce.php';
+require_once __DIR__ . '/integrations/woocommerce-product-studio.php';
require_once __DIR__ . '/integrations/woocommerce-customers.php';
require_once __DIR__ . '/integrations/woocommerce-customer-window.php';
require_once __DIR__ . '/integrations/woocommerce-relations.php';
diff --git a/includes/my-wordpress/integrations/woocommerce-product-studio.php b/includes/my-wordpress/integrations/woocommerce-product-studio.php
new file mode 100644
index 000000000..ca594f239
--- /dev/null
+++ b/includes/my-wordpress/integrations/woocommerce-product-studio.php
@@ -0,0 +1,778 @@
+cap->edit_posts )
+ ? $products->cap->edit_posts
+ : 'edit_products';
+
+ if ( ! current_user_can( $cap ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_forbidden',
+ __( 'Sorry, you are not allowed to create products.', 'desktop-mode' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+}
+
+/**
+ * Whether the current user may publish products.
+ *
+ * @return bool
+ */
+function openstation_my_wordpress_woo_product_studio_can_publish() {
+ $products = get_post_type_object( 'product' );
+ $cap = $products instanceof WP_Post_Type && ! empty( $products->cap->publish_posts )
+ ? $products->cap->publish_posts
+ : 'publish_products';
+
+ return current_user_can( $cap );
+}
+
+/**
+ * Product Studio bootstrap data.
+ *
+ * @return WP_REST_Response
+ */
+function openstation_my_wordpress_woo_product_studio_bootstrap() {
+ $terms = get_terms(
+ array(
+ 'taxonomy' => 'product_cat',
+ 'hide_empty' => false,
+ 'orderby' => 'name',
+ 'order' => 'ASC',
+ )
+ );
+
+ $categories = array();
+ if ( ! is_wp_error( $terms ) ) {
+ foreach ( $terms as $term ) {
+ $categories[] = array(
+ 'id' => (int) $term->term_id,
+ 'name' => (string) $term->name,
+ 'parent' => (int) $term->parent,
+ 'count' => (int) $term->count,
+ );
+ }
+ }
+
+ return rest_ensure_response(
+ array(
+ 'categories' => $categories,
+ 'currencyCode' => get_woocommerce_currency(),
+ // WooCommerce returns symbols HTML-entity encoded (for example
+ // `$`). REST clients assign this value with `textContent`, so
+ // send the actual glyph instead of leaking the entity into the UI.
+ 'currencySymbol' => html_entity_decode(
+ (string) get_woocommerce_currency_symbol(),
+ ENT_QUOTES | ENT_HTML5,
+ 'UTF-8'
+ ),
+ 'priceDecimals' => wc_get_price_decimals(),
+ 'canPublish' => openstation_my_wordpress_woo_product_studio_can_publish(),
+ 'maxImageBytes' => wp_max_upload_size(),
+ 'maxImageLabel' => size_format( wp_max_upload_size() ),
+ 'placeholderUrl' => OPENSTATION_URL . 'assets/images/woo-product-studio-placeholder.webp',
+ )
+ );
+}
+
+/**
+ * Parse a boolean-ish multipart request value.
+ *
+ * @param mixed $value Raw request value.
+ * @return bool
+ */
+function openstation_my_wordpress_woo_product_studio_bool( $value ) {
+ return in_array( $value, array( true, 1, '1', 'true', 'yes', 'on' ), true );
+}
+
+/**
+ * Validate the idempotency key carried by a product-creation request.
+ *
+ * @param mixed $raw Raw request value.
+ * @return string|WP_Error
+ */
+function openstation_my_wordpress_woo_product_studio_request_id( $raw ) {
+ $request_id = sanitize_text_field( (string) $raw );
+ if ( ! preg_match( '/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $request_id ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_request_id_invalid',
+ __( 'The product request could not be identified safely. Reload Product Studio and try again.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return strtolower( $request_id );
+}
+
+/**
+ * Acquire an atomic, short-lived lock for one user's creation request.
+ *
+ * WordPress option names are unique, so `add_option()` lets only one
+ * concurrent request enter the media/product write section. A stale
+ * lock is recoverable after five minutes if PHP exited unexpectedly.
+ *
+ * @param string $request_id Validated request id.
+ * @return string|WP_Error Lock option name on success.
+ */
+function openstation_my_wordpress_woo_product_studio_acquire_lock( $request_id ) {
+ $lock_name = '_openstation_woo_product_studio_lock_' . hash(
+ 'sha256',
+ get_current_user_id() . ':' . $request_id
+ );
+ $now = time();
+ if ( add_option( $lock_name, $now, '', 'no' ) ) {
+ return $lock_name;
+ }
+
+ $started = (int) get_option( $lock_name, 0 );
+ if ( $started > 0 && ( $now - $started ) > 5 * MINUTE_IN_SECONDS ) {
+ delete_option( $lock_name );
+ if ( add_option( $lock_name, $now, '', 'no' ) ) {
+ return $lock_name;
+ }
+ }
+
+ return new WP_Error(
+ 'openstation_woo_product_request_in_progress',
+ __( 'This product is already being created. Wait a moment, then try again.', 'desktop-mode' ),
+ array( 'status' => 409 )
+ );
+}
+
+/**
+ * Release a Product Studio creation lock.
+ *
+ * @param string $lock_name Lock option name.
+ * @return void
+ */
+function openstation_my_wordpress_woo_product_studio_release_lock( $lock_name ) {
+ delete_option( $lock_name );
+}
+
+/**
+ * Find a product already created for this user and request id.
+ *
+ * @param string $request_id Validated request id.
+ * @return WC_Product|false
+ */
+function openstation_my_wordpress_woo_product_studio_existing_product( $request_id ) {
+ $product_ids = get_posts(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'any',
+ 'posts_per_page' => 1,
+ 'orderby' => 'ID',
+ 'order' => 'DESC',
+ 'fields' => 'ids',
+ 'no_found_rows' => true,
+ 'update_post_meta_cache' => false,
+ 'update_post_term_cache' => false,
+ 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one exact, user-scoped retry lookup.
+ 'relation' => 'AND',
+ array(
+ 'key' => '_openstation_product_studio_request_id',
+ 'value' => $request_id,
+ ),
+ array(
+ 'key' => '_openstation_product_studio_user_id',
+ 'value' => get_current_user_id(),
+ 'compare' => '=',
+ 'type' => 'NUMERIC',
+ ),
+ ),
+ )
+ );
+ if ( empty( $product_ids[0] ) ) {
+ return false;
+ }
+
+ $product = wc_get_product( (int) $product_ids[0] );
+ return $product instanceof WC_Product ? $product : false;
+}
+
+/**
+ * Build the stable API response for a created or replayed product.
+ *
+ * @param WC_Product $product Product instance.
+ * @param int $status HTTP status.
+ * @param bool $replayed Whether an earlier request created it.
+ * @return WP_REST_Response
+ */
+function openstation_my_wordpress_woo_product_studio_product_response( $product, $status = 201, $replayed = false ) {
+ $product_id = (int) $product->get_id();
+ $image_id = (int) $product->get_image_id();
+ $response = new WP_REST_Response(
+ array(
+ 'id' => $product_id,
+ 'name' => $product->get_name(),
+ 'status' => $product->get_status(),
+ 'price' => html_entity_decode( wp_strip_all_tags( wc_price( $product->get_price() ) ), ENT_QUOTES, get_bloginfo( 'charset' ) ),
+ 'editUrl' => (string) get_edit_post_link( $product_id, 'raw' ),
+ 'viewUrl' => 'publish' === $product->get_status() ? (string) get_permalink( $product_id ) : '',
+ 'thumbnail' => $image_id ? (string) wp_get_attachment_image_url( $image_id, 'medium' ) : '',
+ 'replayed' => (bool) $replayed,
+ ),
+ $status
+ );
+ if ( $replayed ) {
+ $response->header( 'X-OpenStation-Idempotent-Replay', 'true' );
+ }
+
+ return $response;
+}
+
+/**
+ * Parse selected product category ids from a multipart request.
+ *
+ * @param mixed $raw Raw JSON string or array.
+ * @return int[]|WP_Error
+ */
+function openstation_my_wordpress_woo_product_studio_categories( $raw ) {
+ if ( is_string( $raw ) ) {
+ if ( '' === trim( $raw ) ) {
+ $raw = array();
+ } else {
+ $decoded = json_decode( $raw, true );
+ if ( ! is_array( $decoded ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_categories_invalid',
+ __( 'The selected product categories are invalid.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+ $raw = $decoded;
+ }
+ }
+ if ( ! is_array( $raw ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_categories_invalid',
+ __( 'The selected product categories are invalid.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ foreach ( $raw as $value ) {
+ if (
+ ( ! is_int( $value ) && ! ( is_string( $value ) && ctype_digit( $value ) ) ) ||
+ (int) $value < 1
+ ) {
+ return new WP_Error(
+ 'openstation_woo_product_categories_invalid',
+ __( 'The selected product categories are invalid.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+ $ids = array_values( array_unique( array_filter( array_map( 'absint', $raw ) ) ) );
+ foreach ( $ids as $id ) {
+ if ( ! term_exists( $id, 'product_cat' ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_category_missing',
+ __( 'One of the selected product categories no longer exists.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ return $ids;
+}
+
+/**
+ * Validate and ingest an optional product image.
+ *
+ * @param WP_REST_Request $request REST request.
+ * @param string $title Attachment title fallback.
+ * @return int|WP_Error Attachment id, or zero when no image was sent.
+ */
+function openstation_my_wordpress_woo_product_studio_image( WP_REST_Request $request, $title ) {
+ $files = $request->get_file_params();
+ $file = isset( $files['image'] ) && is_array( $files['image'] ) ? $files['image'] : null;
+ if ( ! $file || UPLOAD_ERR_NO_FILE === (int) ( $file['error'] ?? UPLOAD_ERR_NO_FILE ) ) {
+ return 0;
+ }
+
+ if ( ! current_user_can( 'upload_files' ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_image_forbidden',
+ __( 'Sorry, you are not allowed to upload product images.', 'desktop-mode' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ if ( UPLOAD_ERR_OK !== (int) ( $file['error'] ?? UPLOAD_ERR_NO_FILE ) || empty( $file['tmp_name'] ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_image_upload_failed',
+ __( 'The product image could not be uploaded.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $file_size = (int) ( $file['size'] ?? 0 );
+ if ( $file_size <= 0 ) {
+ return new WP_Error(
+ 'openstation_woo_product_image_empty',
+ __( 'The product image is empty.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( $file_size > wp_max_upload_size() ) {
+ return new WP_Error(
+ 'openstation_woo_product_image_too_large',
+ __( 'The product image is larger than this site allows.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $checked = wp_check_filetype_and_ext(
+ (string) $file['tmp_name'],
+ sanitize_file_name( (string) ( $file['name'] ?? '' ) )
+ );
+
+ $allowed_types = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp' );
+ if ( empty( $checked['type'] ) || ! in_array( $checked['type'], $allowed_types, true ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_image_invalid',
+ __( 'Choose a valid JPEG, PNG, GIF, or WebP product image.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+ require_once ABSPATH . 'wp-admin/includes/media.php';
+
+ $attachment_id = media_handle_sideload( $file, 0, $title );
+ if ( is_wp_error( $attachment_id ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_image_upload_failed',
+ $attachment_id->get_error_message(),
+ array( 'status' => 400 )
+ );
+ }
+
+ update_post_meta( $attachment_id, '_wp_attachment_image_alt', $title );
+ return (int) $attachment_id;
+}
+
+/**
+ * Create a simple WooCommerce product from the guided flow.
+ *
+ * @param WP_REST_Request $request REST request.
+ * @return WP_REST_Response|WP_Error
+ * @throws RuntimeException Internally when WooCommerce returns no product id; converted to `WP_Error` before returning.
+ */
+function openstation_my_wordpress_woo_product_studio_create( WP_REST_Request $request ) {
+ if ( ! class_exists( 'WC_Product_Simple' ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_api_unavailable',
+ __( 'WooCommerce product creation is unavailable.', 'desktop-mode' ),
+ array( 'status' => 503 )
+ );
+ }
+ $request_id = openstation_my_wordpress_woo_product_studio_request_id( $request->get_param( 'requestId' ) );
+ if ( is_wp_error( $request_id ) ) {
+ return $request_id;
+ }
+ $existing = openstation_my_wordpress_woo_product_studio_existing_product( $request_id );
+ if ( $existing ) {
+ return openstation_my_wordpress_woo_product_studio_product_response( $existing, 200, true );
+ }
+
+ $name = sanitize_text_field( (string) $request->get_param( 'name' ) );
+ $description = wp_kses_post( (string) $request->get_param( 'description' ) );
+ $short_description = wp_kses_post( (string) $request->get_param( 'shortDescription' ) );
+ $status = sanitize_key( (string) $request->get_param( 'status' ) );
+ $sku = sanitize_text_field( (string) $request->get_param( 'sku' ) );
+ $regular_raw = trim( (string) $request->get_param( 'regularPrice' ) );
+ $sale_raw = trim( (string) $request->get_param( 'salePrice' ) );
+ $regular_price = '' === $regular_raw ? '' : wc_format_decimal( $regular_raw, wc_get_price_decimals() );
+ $sale_price = '' === $sale_raw ? '' : wc_format_decimal( $sale_raw, wc_get_price_decimals() );
+
+ if ( '' === $name ) {
+ return new WP_Error(
+ 'openstation_woo_product_name_required',
+ __( 'Give the product a name before saving it.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+ $decimal_pattern = '/^(?:\d+|\d*\.\d+)$/D';
+ if ( '' !== $regular_raw && ! preg_match( $decimal_pattern, $regular_raw ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_regular_price_invalid',
+ __( 'Enter a valid regular price.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+ if ( '' !== $sale_raw && ! preg_match( $decimal_pattern, $sale_raw ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_sale_price_invalid',
+ __( 'The sale price must be lower than the regular price.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+ if ( ! in_array( $status, array( 'draft', 'publish' ), true ) ) {
+ $status = 'draft';
+ }
+ if ( 'publish' === $status && ! openstation_my_wordpress_woo_product_studio_can_publish() ) {
+ return new WP_Error(
+ 'openstation_woo_product_publish_forbidden',
+ __( 'Sorry, you are not allowed to publish products.', 'desktop-mode' ),
+ array( 'status' => 403 )
+ );
+ }
+ if ( 'publish' === $status && '' === $regular_price ) {
+ return new WP_Error(
+ 'openstation_woo_product_price_required',
+ __( 'Set a regular price before publishing the product.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+ if ( '' !== $sale_price && ( '' === $regular_price || (float) $sale_price >= (float) $regular_price ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_sale_price_invalid',
+ __( 'The sale price must be lower than the regular price.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+ if ( '' !== $sku && function_exists( 'wc_product_has_unique_sku' ) && ! wc_product_has_unique_sku( 0, $sku ) ) {
+ return new WP_Error(
+ 'openstation_woo_product_sku_exists',
+ __( 'That SKU is already used by another product.', 'desktop-mode' ),
+ array( 'status' => 409 )
+ );
+ }
+
+ $manage_stock = openstation_my_wordpress_woo_product_studio_bool( $request->get_param( 'manageStock' ) );
+ $virtual = openstation_my_wordpress_woo_product_studio_bool( $request->get_param( 'virtual' ) );
+ $stock_quantity_raw = trim( (string) $request->get_param( 'stockQuantity' ) );
+ if (
+ ! $virtual &&
+ $manage_stock &&
+ (
+ '' === $stock_quantity_raw ||
+ ! ctype_digit( $stock_quantity_raw ) ||
+ (float) $stock_quantity_raw > PHP_INT_MAX
+ )
+ ) {
+ return new WP_Error(
+ 'openstation_woo_product_stock_quantity_invalid',
+ __( 'Enter a whole-number stock quantity of zero or more.', 'desktop-mode' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $stock_status = sanitize_key( (string) $request->get_param( 'stockStatus' ) );
+ $stock_status = in_array( $stock_status, array( 'instock', 'outofstock', 'onbackorder' ), true )
+ ? $stock_status
+ : 'instock';
+ $stock_quantity = '' === $stock_quantity_raw ? 0 : (int) $stock_quantity_raw;
+ if ( $virtual ) {
+ $manage_stock = false;
+ } elseif ( $manage_stock ) {
+ $stock_status = $stock_quantity > 0 ? 'instock' : 'outofstock';
+ }
+
+ $categories = openstation_my_wordpress_woo_product_studio_categories( $request->get_param( 'categoryIds' ) );
+ if ( is_wp_error( $categories ) ) {
+ return $categories;
+ }
+ $lock_name = openstation_my_wordpress_woo_product_studio_acquire_lock( $request_id );
+ if ( is_wp_error( $lock_name ) ) {
+ return $lock_name;
+ }
+ $image_id = 0;
+ $product = null;
+ try {
+ $existing = openstation_my_wordpress_woo_product_studio_existing_product( $request_id );
+ if ( $existing ) {
+ return openstation_my_wordpress_woo_product_studio_product_response( $existing, 200, true );
+ }
+ $image_id = openstation_my_wordpress_woo_product_studio_image( $request, $name );
+ if ( is_wp_error( $image_id ) ) {
+ return $image_id;
+ }
+ $product = new WC_Product_Simple();
+ $product->set_name( $name );
+ $product->set_status( $status );
+ $product->set_description( $description );
+ $product->set_short_description( $short_description );
+ $product->set_regular_price( $regular_price );
+ $product->set_sale_price( $sale_price );
+ $product->set_price( '' !== $sale_price ? $sale_price : $regular_price );
+ $product->set_sku( $sku );
+ $product->set_virtual( $virtual );
+ $product->set_manage_stock( $manage_stock );
+ if ( $manage_stock ) {
+ $product->set_stock_quantity( $stock_quantity );
+ }
+ $product->set_stock_status( $stock_status );
+ $product->set_category_ids( $categories );
+ if ( $image_id ) {
+ $product->set_image_id( $image_id );
+ }
+ $product->update_meta_data( '_openstation_product_studio_request_id', $request_id );
+ $product->update_meta_data( '_openstation_product_studio_user_id', get_current_user_id() );
+
+ $product_id = $product->save();
+ if ( ! $product_id ) {
+ throw new RuntimeException( 'WooCommerce returned an empty product id.' );
+ }
+
+ if ( $image_id ) {
+ wp_update_post(
+ array(
+ 'ID' => $image_id,
+ 'post_parent' => $product_id,
+ )
+ );
+ }
+ } catch ( Throwable $error ) {
+ $saved_product = openstation_my_wordpress_woo_product_studio_existing_product( $request_id );
+ if ( $saved_product ) {
+ if ( $image_id ) {
+ wp_update_post(
+ array(
+ 'ID' => $image_id,
+ 'post_parent' => $saved_product->get_id(),
+ )
+ );
+ }
+ return openstation_my_wordpress_woo_product_studio_product_response( $saved_product, 201, true );
+ }
+ if ( $image_id ) {
+ wp_delete_attachment( $image_id, true );
+ }
+ $message = $error instanceof WC_Data_Exception
+ ? $error->getMessage()
+ : __( 'WooCommerce could not save the product.', 'desktop-mode' );
+
+ return new WP_Error(
+ 'openstation_woo_product_save_failed',
+ $message,
+ array( 'status' => 400 )
+ );
+ } finally {
+ openstation_my_wordpress_woo_product_studio_release_lock( $lock_name );
+ }
+
+ return openstation_my_wordpress_woo_product_studio_product_response( $product );
+}
+
+/**
+ * Register the Product Studio REST routes.
+ *
+ * @return void
+ */
+function openstation_my_wordpress_woo_product_studio_register_routes() {
+ if ( ! openstation_my_wordpress_woo_active() ) {
+ return;
+ }
+
+ register_rest_route(
+ 'desktop-mode/v1',
+ '/woocommerce/product-studio',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => 'openstation_my_wordpress_woo_product_studio_bootstrap',
+ 'permission_callback' => 'openstation_my_wordpress_woo_product_studio_permission',
+ )
+ );
+
+ register_rest_route(
+ 'desktop-mode/v1',
+ '/woocommerce/products',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => 'openstation_my_wordpress_woo_product_studio_create',
+ 'permission_callback' => 'openstation_my_wordpress_woo_product_studio_permission',
+ 'args' => array(
+ 'requestId' => array(
+ 'description' => __( 'Unique identifier used to make retries safe.', 'desktop-mode' ),
+ 'type' => 'string',
+ 'required' => true,
+ 'pattern' => '^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89aAbB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$',
+ ),
+ 'name' => array(
+ 'description' => __( 'Product name.', 'desktop-mode' ),
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'shortDescription' => array(
+ 'description' => __( 'Short storefront description.', 'desktop-mode' ),
+ 'type' => 'string',
+ ),
+ 'description' => array(
+ 'description' => __( 'Full product description.', 'desktop-mode' ),
+ 'type' => 'string',
+ ),
+ 'regularPrice' => array(
+ 'description' => __( 'Regular product price.', 'desktop-mode' ),
+ 'type' => 'string',
+ ),
+ 'salePrice' => array(
+ 'description' => __( 'Optional sale price.', 'desktop-mode' ),
+ 'type' => 'string',
+ ),
+ 'sku' => array(
+ 'description' => __( 'Optional unique stock-keeping unit.', 'desktop-mode' ),
+ 'type' => 'string',
+ ),
+ 'virtual' => array(
+ 'description' => __( 'Whether the product needs no shipping.', 'desktop-mode' ),
+ 'type' => 'string',
+ ),
+ 'manageStock' => array(
+ 'description' => __( 'Whether WooCommerce tracks stock quantity.', 'desktop-mode' ),
+ 'type' => 'string',
+ ),
+ 'stockQuantity' => array(
+ 'description' => __( 'Whole-number stock quantity.', 'desktop-mode' ),
+ 'type' => 'string',
+ ),
+ 'stockStatus' => array(
+ 'description' => __( 'WooCommerce stock status.', 'desktop-mode' ),
+ 'type' => 'string',
+ 'enum' => array( 'instock', 'outofstock', 'onbackorder' ),
+ ),
+ 'categoryIds' => array(
+ 'description' => __( 'JSON-encoded product category ids.', 'desktop-mode' ),
+ 'type' => 'string',
+ ),
+ 'status' => array(
+ 'description' => __( 'Requested product status.', 'desktop-mode' ),
+ 'type' => 'string',
+ 'enum' => array( 'draft', 'publish' ),
+ ),
+ ),
+ )
+ );
+}
+add_action( 'rest_api_init', 'openstation_my_wordpress_woo_product_studio_register_routes' );
+
+/**
+ * Render the Product Studio mount point.
+ *
+ * @return void
+ */
+function openstation_my_wordpress_woo_product_studio_template() {
+ ob_start();
+ ?>
+
+ __( 'Product Studio', 'desktop-mode' ),
+ 'icon' => 'dashicons-products',
+ 'template' => 'openstation_my_wordpress_woo_product_studio_template',
+ 'script' => 'os-my-wordpress-woocommerce',
+ 'style' => 'os-my-wordpress-woocommerce',
+ 'width' => 1180,
+ 'height' => 680,
+ 'min_width' => 680,
+ 'min_height' => 520,
+ 'placement' => 'none',
+ );
+
+ /**
+ * Filter the Product Studio window's registration args.
+ *
+ * **Status: Experimental**
+ *
+ * @param array $args `openstation_register_window()` args.
+ */
+ $args = (array) apply_filters( 'openstation_my_wordpress_woo_product_studio_window_args', $args );
+
+ $registered = openstation_register_window( 'desktop-mode-woo-product-studio', $args );
+ if ( is_wp_error( $registered ) ) {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ error_log( '[openstation] Product Studio window registration failed: ' . $registered->get_error_message() );
+ return;
+ }
+
+ if ( ! function_exists( 'openstation_register_icon' ) ) {
+ return;
+ }
+
+ $icon_args = array(
+ 'title' => __( 'Add New Product', 'desktop-mode' ),
+ 'icon' => 'dashicons-plus-alt2',
+ 'window' => 'desktop-mode-woo-product-studio',
+ 'position' => 20,
+ );
+
+ /**
+ * Filter the Product Studio desktop shortcut's registration args.
+ *
+ * **Status: Experimental**
+ *
+ * @param array $icon_args `openstation_register_icon()` args.
+ */
+ $icon_args = (array) apply_filters( 'openstation_my_wordpress_woo_product_studio_icon_args', $icon_args );
+
+ $icon_registered = openstation_register_icon( 'desktop-mode-woo-product-studio', $icon_args );
+ if ( is_wp_error( $icon_registered ) ) {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ error_log( '[openstation] Product Studio icon registration failed: ' . $icon_registered->get_error_message() );
+ }
+}
+add_action( 'init', 'openstation_my_wordpress_woo_product_studio_register_window', 26 );
diff --git a/includes/my-wordpress/integrations/woocommerce.php b/includes/my-wordpress/integrations/woocommerce.php
index 48d9fb21c..728588c1b 100644
--- a/includes/my-wordpress/integrations/woocommerce.php
+++ b/includes/my-wordpress/integrations/woocommerce.php
@@ -1968,6 +1968,8 @@ function openstation_my_wordpress_woo_enqueue() {
'restRoot' => esc_url_raw( rest_url( 'desktop-mode/v1/woocommerce/' ) ),
'restNonce' => wp_create_nonce( 'wp_rest' ),
'canOrders' => true === openstation_my_wordpress_woo_orders_permission(),
+ 'canCreateProducts' => function_exists( 'openstation_my_wordpress_woo_product_studio_permission' )
+ && true === openstation_my_wordpress_woo_product_studio_permission(),
'canCustomers' => true === openstation_my_wordpress_woo_customers_permission(),
'orderBands' => openstation_my_wordpress_woo_order_bands(),
'productBands' => openstation_my_wordpress_woo_product_bands(),
diff --git a/src/my-wordpress/integrations/woocommerce-product-studio.ts b/src/my-wordpress/integrations/woocommerce-product-studio.ts
new file mode 100644
index 000000000..cf7b73ec3
--- /dev/null
+++ b/src/my-wordpress/integrations/woocommerce-product-studio.ts
@@ -0,0 +1,1830 @@
+/**
+ * WooCommerce Product Studio.
+ *
+ * A four-stage product-creation flow that lives in its own native
+ * OpenStation window. The browser owns the draft and live preview;
+ * WooCommerce owns the saved product through the nonce-authenticated
+ * server route in `woocommerce-product-studio.php`.
+ */
+
+import { addAction } from '../../hooks';
+import { __, sprintf } from '../../i18n';
+import { trackedFetch } from '../../tracked-fetch';
+import '../../ui/components/os-badge/os-badge';
+import '../../ui/components/os-button/os-button';
+import '../../ui/components/os-card/os-card';
+import '../../ui/components/os-checkbox-label/os-checkbox-label';
+import '../../ui/components/os-notice/os-notice';
+import '../../ui/components/os-progress-bar/os-progress-bar';
+import '../../ui/components/os-spinner/os-spinner';
+import '../../ui/components/os-text-field/os-text-field';
+import '../../ui/components/os-textarea/os-textarea';
+
+const WINDOW_ID = 'desktop-mode-woo-product-studio';
+const WOO_GROUP_ID = 'plugin:woocommerce';
+const DRAFT_STORAGE_KEY = 'openstation/woocommerce-product-studio-draft:v1';
+const REQUEST_ID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
+
+type ProductKind = 'physical' | 'virtual';
+type ProductStatus = 'draft' | 'publish';
+type StepId = 'basics' | 'commerce' | 'presentation' | 'review';
+
+interface WooProductStudioConfig {
+ restRoot: string;
+ restNonce: string;
+ canCreateProducts?: boolean;
+}
+
+interface ProductCategory {
+ id: number;
+ name: string;
+ parent: number;
+ count: number;
+}
+
+interface ProductStudioBootstrap {
+ categories: ProductCategory[];
+ currencyCode: string;
+ currencySymbol: string;
+ priceDecimals: number;
+ canPublish: boolean;
+ maxImageBytes: number;
+ maxImageLabel: string;
+ placeholderUrl: string;
+}
+
+export interface ProductDraft {
+ name: string;
+ shortDescription: string;
+ description: string;
+ kind: ProductKind;
+ regularPrice: string;
+ salePrice: string;
+ sku: string;
+ manageStock: boolean;
+ stockQuantity: string;
+ stockStatus: 'instock' | 'outofstock' | 'onbackorder';
+ categoryIds: number[];
+ imageFile: File | null;
+ imagePreviewUrl: string;
+}
+
+interface ProductResult {
+ id: number;
+ name: string;
+ status: ProductStatus;
+ price: string;
+ editUrl: string;
+ viewUrl: string;
+ thumbnail: string;
+ replayed?: boolean;
+}
+
+interface StudioRuntime {
+ root: HTMLElement;
+ bootstrap: ProductStudioBootstrap;
+ draft: ProductDraft;
+ stepIndex: number;
+ busy: boolean;
+ submitMode: ProductStatus | null;
+ error: string;
+ objectUrl: string;
+ requestSignal: AbortSignal;
+ stage: HTMLElement;
+ preview: HTMLElement;
+ progress: HTMLElement;
+ stepRail: HTMLElement;
+ statusBadge: HTMLElement;
+ footer: HTMLElement;
+ result: ProductResult | null;
+ requestId: string;
+ recoveredDraft: boolean;
+ recoveryNeedsImage: boolean;
+}
+
+interface StoredProductDraft {
+ version: 1;
+ requestId: string;
+ stepIndex: number;
+ hadImage: boolean;
+ draft: Omit< ProductDraft, 'imageFile' | 'imagePreviewUrl' >;
+}
+
+interface GroupExtrasPayload {
+ container: HTMLElement;
+ groupId: string;
+}
+
+const STEPS: Array< { id: StepId; label: string; kicker: string } > = [
+ {
+ id: 'basics',
+ label: __( 'The product', 'desktop-mode' ),
+ kicker: __( 'Name and story', 'desktop-mode' ),
+ },
+ {
+ id: 'commerce',
+ label: __( 'The offer', 'desktop-mode' ),
+ kicker: __( 'Price and stock', 'desktop-mode' ),
+ },
+ {
+ id: 'presentation',
+ label: __( 'The shelf', 'desktop-mode' ),
+ kicker: __( 'Image and categories', 'desktop-mode' ),
+ },
+ {
+ id: 'review',
+ label: __( 'Launch', 'desktop-mode' ),
+ kicker: __( 'Review and publish', 'desktop-mode' ),
+ },
+];
+
+function emptyDraft(): ProductDraft {
+ return {
+ name: '',
+ shortDescription: '',
+ description: '',
+ kind: 'physical',
+ regularPrice: '',
+ salePrice: '',
+ sku: '',
+ manageStock: false,
+ stockQuantity: '0',
+ stockStatus: 'instock',
+ categoryIds: [],
+ imageFile: null,
+ imagePreviewUrl: '',
+ };
+}
+
+function createRequestId(): string {
+ if ( typeof crypto.randomUUID === 'function' ) {
+ return crypto.randomUUID();
+ }
+ const bytes = new Uint8Array( 16 );
+ crypto.getRandomValues( bytes );
+ bytes[ 6 ] = 64 + ( bytes[ 6 ] % 16 );
+ bytes[ 8 ] = 128 + ( bytes[ 8 ] % 64 );
+ const hex = Array.from( bytes, ( byte ) => byte.toString( 16 ).padStart( 2, '0' ) );
+ return `${ hex.slice( 0, 4 ).join( '' ) }-${ hex.slice( 4, 6 ).join( '' ) }-${ hex.slice( 6, 8 ).join( '' ) }-${ hex.slice( 8, 10 ).join( '' ) }-${ hex.slice( 10 ).join( '' ) }`;
+}
+
+function hasDraftContent( draft: ProductDraft ): boolean {
+ return Boolean(
+ draft.name.trim() ||
+ draft.shortDescription.trim() ||
+ draft.description.trim() ||
+ draft.regularPrice.trim() ||
+ draft.salePrice.trim() ||
+ draft.sku.trim() ||
+ draft.kind !== 'physical' ||
+ draft.manageStock ||
+ draft.stockQuantity !== '0' ||
+ draft.stockStatus !== 'instock' ||
+ draft.categoryIds.length ||
+ draft.imageFile,
+ );
+}
+
+function clearStoredDraft(): void {
+ try {
+ sessionStorage.removeItem( DRAFT_STORAGE_KEY );
+ } catch {
+ // Storage may be unavailable in hardened or private browser contexts.
+ }
+}
+
+function persistDraft( runtime: StudioRuntime ): void {
+ if ( runtime.result || ! hasDraftContent( runtime.draft ) ) {
+ clearStoredDraft();
+ return;
+ }
+ const draft: StoredProductDraft[ 'draft' ] = {
+ name: runtime.draft.name,
+ shortDescription: runtime.draft.shortDescription,
+ description: runtime.draft.description,
+ kind: runtime.draft.kind,
+ regularPrice: runtime.draft.regularPrice,
+ salePrice: runtime.draft.salePrice,
+ sku: runtime.draft.sku,
+ manageStock: runtime.draft.manageStock,
+ stockQuantity: runtime.draft.stockQuantity,
+ stockStatus: runtime.draft.stockStatus,
+ categoryIds: [ ...runtime.draft.categoryIds ],
+ };
+ const stored: StoredProductDraft = {
+ version: 1,
+ requestId: runtime.requestId,
+ stepIndex: runtime.stepIndex,
+ hadImage: Boolean( runtime.draft.imageFile ),
+ draft,
+ };
+ try {
+ sessionStorage.setItem( DRAFT_STORAGE_KEY, JSON.stringify( stored ) );
+ } catch {
+ // The flow remains fully usable when storage is disabled or full.
+ }
+}
+
+function isRecord( value: unknown ): value is Record< string, unknown > {
+ return Boolean( value ) && typeof value === 'object' && ! Array.isArray( value );
+}
+
+function restoreDraft(
+ bootstrap: ProductStudioBootstrap,
+): { draft: ProductDraft; requestId: string; stepIndex: number; hadImage: boolean } | null {
+ let parsed: unknown;
+ try {
+ const raw = sessionStorage.getItem( DRAFT_STORAGE_KEY );
+ if ( ! raw ) {
+ return null;
+ }
+ parsed = JSON.parse( raw );
+ } catch {
+ clearStoredDraft();
+ return null;
+ }
+ if ( ! isRecord( parsed ) || parsed.version !== 1 || ! isRecord( parsed.draft ) ) {
+ clearStoredDraft();
+ return null;
+ }
+
+ const source = parsed.draft;
+ const text = ( key: string ): string => typeof source[ key ] === 'string' ? source[ key ] : '';
+ const allowedCategories = new Set( bootstrap.categories.map( ( item ) => item.id ) );
+ const categoryIds = Array.isArray( source.categoryIds )
+ ? source.categoryIds.filter(
+ ( id ): id is number => Number.isInteger( id ) && allowedCategories.has( id ),
+ )
+ : [];
+ const kind: ProductKind = source.kind === 'virtual' ? 'virtual' : 'physical';
+ const stockStatus = [ 'instock', 'outofstock', 'onbackorder' ].includes(
+ String( source.stockStatus ),
+ )
+ ? source.stockStatus as ProductDraft[ 'stockStatus' ]
+ : 'instock';
+ const draft: ProductDraft = {
+ name: text( 'name' ),
+ shortDescription: text( 'shortDescription' ),
+ description: text( 'description' ),
+ kind,
+ regularPrice: text( 'regularPrice' ),
+ salePrice: text( 'salePrice' ),
+ sku: text( 'sku' ),
+ manageStock: kind === 'physical' && source.manageStock === true,
+ stockQuantity: text( 'stockQuantity' ) || '0',
+ stockStatus,
+ categoryIds,
+ imageFile: null,
+ imagePreviewUrl: '',
+ };
+ if ( ! hasDraftContent( draft ) && parsed.hadImage !== true ) {
+ clearStoredDraft();
+ return null;
+ }
+ return {
+ draft,
+ requestId: typeof parsed.requestId === 'string' && REQUEST_ID_PATTERN.test( parsed.requestId )
+ ? parsed.requestId
+ : createRequestId(),
+ stepIndex: Number.isInteger( parsed.stepIndex )
+ ? Math.max( 0, Math.min( STEPS.length - 1, Number( parsed.stepIndex ) ) )
+ : 0,
+ hadImage: parsed.hadImage === true,
+ };
+}
+
+function getConfig(): WooProductStudioConfig | null {
+ const cfg = (
+ window as unknown as {
+ openStationWooConfig?: WooProductStudioConfig;
+ }
+ ).openStationWooConfig;
+ return cfg && typeof cfg.restRoot === 'string' ? cfg : null;
+}
+
+/**
+ * Validate the draft for a single step or the final publish action.
+ * Exported so the workflow contract remains unit-testable without a
+ * WooCommerce runtime.
+ */
+export function validateProductDraft(
+ draft: ProductDraft,
+ step: StepId | 'draft-save' | 'publish',
+): Record< string, string > {
+ const errors: Record< string, string > = {};
+ const checksBasics = step === 'basics' || step === 'draft-save' || step === 'publish';
+ const checksCommerce = step === 'commerce' || step === 'publish';
+ const decimalPattern = /^(?:\d+|\d*\.\d+)$/;
+
+ if ( checksBasics && ! draft.name.trim() ) {
+ errors.name = __( 'Give the product a name.', 'desktop-mode' );
+ }
+
+ if ( checksCommerce ) {
+ const regular = Number( draft.regularPrice );
+ const sale = draft.salePrice.trim() === '' ? null : Number( draft.salePrice );
+ if (
+ draft.regularPrice.trim() === '' ||
+ ! decimalPattern.test( draft.regularPrice.trim() ) ||
+ ! Number.isFinite( regular ) ||
+ regular < 0
+ ) {
+ errors.regularPrice = __( 'Enter a valid regular price.', 'desktop-mode' );
+ }
+ if (
+ sale !== null &&
+ (
+ ! decimalPattern.test( draft.salePrice.trim() ) ||
+ ! Number.isFinite( sale ) ||
+ sale < 0 ||
+ sale >= regular
+ )
+ ) {
+ errors.salePrice = __( 'Sale price must be lower than the regular price.', 'desktop-mode' );
+ }
+ if (
+ draft.kind === 'physical' &&
+ draft.manageStock &&
+ (
+ ! /^\d+$/.test( draft.stockQuantity.trim() ) ||
+ ! Number.isSafeInteger( Number( draft.stockQuantity ) )
+ )
+ ) {
+ errors.stockQuantity = __( 'Enter a whole-number stock quantity of zero or more.', 'desktop-mode' );
+ }
+ }
+
+ return errors;
+}
+
+function firstErrorMessage( errors: Record< string, string > ): string {
+ return Object.values( errors )[ 0 ] ?? '';
+}
+
+async function fetchBootstrap( signal: AbortSignal ): Promise< ProductStudioBootstrap > {
+ const cfg = getConfig();
+ if ( ! cfg ) {
+ throw new Error( __( 'WooCommerce Product Studio is unavailable.', 'desktop-mode' ) );
+ }
+ const response = await trackedFetch(
+ `${ cfg.restRoot }product-studio`,
+ {
+ method: 'GET',
+ credentials: 'same-origin',
+ signal,
+ headers: {
+ 'X-WP-Nonce': cfg.restNonce,
+ Accept: 'application/json',
+ },
+ },
+ {
+ windowId: WINDOW_ID,
+ source: 'desktop-mode/woocommerce-product-studio',
+ },
+ );
+ if ( ! response.ok ) {
+ const body = ( await response.json().catch( () => null ) ) as
+ | { message?: string }
+ | null;
+ throw new Error(
+ body?.message || __( 'Could not prepare Product Studio.', 'desktop-mode' ),
+ );
+ }
+ return ( await response.json() ) as ProductStudioBootstrap;
+}
+
+function field(
+ tag: 'os-text-field' | 'os-textarea',
+ label: string,
+ value: string,
+ onChange: ( nextValue: string ) => void,
+ opts: {
+ placeholder?: string;
+ required?: boolean;
+ invalid?: boolean;
+ fullWidth?: boolean;
+ type?: string;
+ rows?: number;
+ hint?: string;
+ } = {},
+): HTMLElement {
+ const el = document.createElement( tag ) as HTMLElement & { value?: string };
+ el.setAttribute( 'label', label );
+ el.setAttribute( 'value', value );
+ if ( opts.placeholder ) {
+ el.setAttribute( 'placeholder', opts.placeholder );
+ }
+ if ( opts.required ) {
+ el.setAttribute( 'required', '' );
+ }
+ if ( opts.invalid ) {
+ el.setAttribute( 'invalid', '' );
+ }
+ if ( opts.fullWidth !== false ) {
+ el.setAttribute( 'full-width', '' );
+ }
+ if ( opts.type && tag === 'os-text-field' ) {
+ el.setAttribute( 'type', opts.type );
+ }
+ if ( opts.rows && tag === 'os-textarea' ) {
+ el.setAttribute( 'rows', String( opts.rows ) );
+ }
+ el.addEventListener( 'os-input-change', ( event: Event ) => {
+ const detail = ( event as CustomEvent< { value?: string } > ).detail;
+ onChange( String( detail?.value ?? '' ) );
+ } );
+ if ( opts.hint ) {
+ const wrap = document.createElement( 'div' );
+ wrap.className = 'os-woo-product-studio__field-wrap';
+ wrap.appendChild( el );
+ const hint = document.createElement( 'p' );
+ hint.className = 'os-woo-product-studio__field-hint';
+ hint.textContent = opts.hint;
+ wrap.appendChild( hint );
+ return wrap;
+ }
+ return el;
+}
+
+function button(
+ label: string,
+ variant: 'holo' | 'primary' | 'secondary' | 'ghost' | 'link',
+ onClick: () => void,
+): HTMLElement {
+ const el = document.createElement( 'os-button' );
+ el.setAttribute( 'variant', variant );
+ el.textContent = label;
+ el.addEventListener( 'click', onClick );
+ return el;
+}
+
+function heading( eyebrow: string, title: string, body: string ): HTMLElement {
+ const host = document.createElement( 'header' );
+ host.className = 'os-woo-product-studio__stage-heading';
+ const overline = document.createElement( 'div' );
+ overline.className = 'os-woo-product-studio__eyebrow';
+ overline.textContent = eyebrow;
+ const h = document.createElement( 'h2' );
+ h.textContent = title;
+ const p = document.createElement( 'p' );
+ p.textContent = body;
+ host.append( overline, h, p );
+ return host;
+}
+
+function editorPanel(
+ title: string,
+ body: string,
+ ...children: HTMLElement[]
+): HTMLElement {
+ const panel = document.createElement( 'section' );
+ panel.className = 'os-woo-product-studio__editor-panel';
+ const header = document.createElement( 'header' );
+ header.className = 'os-woo-product-studio__editor-panel-heading';
+ const h = document.createElement( 'h3' );
+ h.textContent = title;
+ const p = document.createElement( 'p' );
+ p.textContent = body;
+ header.append( h, p );
+ panel.append( header, ...children );
+ return panel;
+}
+
+function readinessCue( runtime: StudioRuntime ): HTMLElement {
+ const cue = document.createElement( 'div' );
+ cue.className = 'os-woo-product-studio__readiness';
+ cue.setAttribute( 'role', 'status' );
+ cue.setAttribute( 'aria-live', 'polite' );
+
+ const icon = document.createElement( 'span' );
+ icon.setAttribute( 'aria-hidden', 'true' );
+ const copy = document.createElement( 'span' );
+ const title = document.createElement( 'strong' );
+ const body = document.createElement( 'small' );
+ const step = STEPS[ runtime.stepIndex ].id;
+
+ if ( step === 'basics' ) {
+ if ( runtime.draft.name.trim() && runtime.draft.shortDescription.trim() ) {
+ cue.classList.add( 'is-ready' );
+ icon.className = 'dashicons dashicons-saved';
+ title.textContent = __( 'The story is taking shape', 'desktop-mode' );
+ body.textContent = __( 'Shoppers will see a clear name and promise in the preview.', 'desktop-mode' );
+ } else if ( runtime.draft.name.trim() ) {
+ cue.classList.add( 'is-progress' );
+ icon.className = 'dashicons dashicons-lightbulb';
+ title.textContent = __( 'The name is in place', 'desktop-mode' );
+ body.textContent = __( 'Add a one-line promise to make the preview more persuasive.', 'desktop-mode' );
+ } else {
+ cue.classList.add( 'is-guidance' );
+ icon.className = 'dashicons dashicons-edit-page';
+ title.textContent = __( 'Start with a clear product name', 'desktop-mode' );
+ body.textContent = __( 'Use the name a shopper would expect to find in your catalog.', 'desktop-mode' );
+ }
+ } else if ( step === 'commerce' ) {
+ const errors = validateProductDraft( runtime.draft, 'commerce' );
+ if ( Object.keys( errors ).length === 0 ) {
+ cue.classList.add( 'is-ready' );
+ icon.className = 'dashicons dashicons-money-alt';
+ title.textContent = __( 'The offer is ready', 'desktop-mode' );
+ body.textContent = __( 'Pricing and inventory are internally consistent.', 'desktop-mode' );
+ } else {
+ cue.classList.add( 'is-guidance' );
+ icon.className = 'dashicons dashicons-tag';
+ title.textContent = __( 'A valid price unlocks publishing', 'desktop-mode' );
+ body.textContent = firstErrorMessage( errors );
+ }
+ } else if ( runtime.draft.imageFile && runtime.draft.categoryIds.length > 0 ) {
+ cue.classList.add( 'is-ready' );
+ icon.className = 'dashicons dashicons-images-alt2';
+ title.textContent = __( 'The shelf presentation is ready', 'desktop-mode' );
+ body.textContent = __( 'The product has a custom image and a useful category.', 'desktop-mode' );
+ } else {
+ cue.classList.add( 'is-progress' );
+ icon.className = 'dashicons dashicons-format-image';
+ title.textContent = runtime.draft.imageFile
+ ? __( 'The image is ready', 'desktop-mode' )
+ : __( 'A placeholder keeps the draft moving', 'desktop-mode' );
+ body.textContent = runtime.draft.categoryIds.length > 0
+ ? __( 'The selected category will help shoppers find it.', 'desktop-mode' )
+ : __( 'Choose a category now, or let WooCommerce use the store default.', 'desktop-mode' );
+ }
+
+ copy.append( title, body );
+ cue.append( icon, copy );
+ return cue;
+}
+
+function refreshReadinessCue( runtime: StudioRuntime ): void {
+ const current = runtime.stage.querySelector( '.os-woo-product-studio__readiness' );
+ current?.replaceWith( readinessCue( runtime ) );
+}
+
+function markDirty( runtime: StudioRuntime ): void {
+ if ( runtime.result ) {
+ return;
+ }
+ runtime.statusBadge.setAttribute( 'tone', 'neutral' );
+ runtime.statusBadge.textContent = __( 'Unsaved', 'desktop-mode' );
+ runtime.recoveredDraft = false;
+ persistDraft( runtime );
+}
+
+function updateDraft(
+ runtime: StudioRuntime,
+ key: keyof ProductDraft,
+ value: ProductDraft[ keyof ProductDraft ],
+): void {
+ ( runtime.draft as unknown as Record< string, unknown > )[ key ] = value;
+ markDirty( runtime );
+ renderPreview( runtime );
+ refreshReadinessCue( runtime );
+}
+
+function formatPrice( runtime: StudioRuntime, raw: string ): string {
+ const value = Number( raw );
+ if ( raw.trim() === '' || ! Number.isFinite( value ) ) {
+ return sprintf(
+ /* translators: %s: store currency symbol. */
+ __( '%s0.00', 'desktop-mode' ),
+ runtime.bootstrap.currencySymbol,
+ );
+ }
+ try {
+ return new Intl.NumberFormat( undefined, {
+ style: 'currency',
+ currency: runtime.bootstrap.currencyCode,
+ minimumFractionDigits: runtime.bootstrap.priceDecimals,
+ maximumFractionDigits: runtime.bootstrap.priceDecimals,
+ } ).format( value );
+ } catch {
+ return `${ runtime.bootstrap.currencySymbol }${ value.toFixed(
+ runtime.bootstrap.priceDecimals,
+ ) }`;
+ }
+}
+
+function selectedCategories( runtime: StudioRuntime ): ProductCategory[] {
+ const chosen = new Set( runtime.draft.categoryIds );
+ return runtime.bootstrap.categories.filter( ( item ) => chosen.has( item.id ) );
+}
+
+function renderPreview( runtime: StudioRuntime ): void {
+ const { draft } = runtime;
+ runtime.preview.replaceChildren();
+
+ const previewHeader = document.createElement( 'div' );
+ previewHeader.className = 'os-woo-product-studio__preview-header';
+ const label = document.createElement( 'div' );
+ label.className = 'os-woo-product-studio__preview-label';
+ label.textContent = __( 'Live storefront preview', 'desktop-mode' );
+ const helper = document.createElement( 'span' );
+ helper.textContent = __( 'Updates as you type', 'desktop-mode' );
+ previewHeader.append( label, helper );
+
+ const frame = document.createElement( 'div' );
+ frame.className = 'os-woo-product-studio__preview-frame';
+ const storeBar = document.createElement( 'div' );
+ storeBar.className = 'os-woo-product-studio__store-bar';
+ const store = document.createElement( 'span' );
+ const storeIcon = document.createElement( 'span' );
+ storeIcon.className = 'dashicons dashicons-store';
+ storeIcon.setAttribute( 'aria-hidden', 'true' );
+ const storeLabel = document.createElement( 'strong' );
+ storeLabel.textContent = __( 'Your storefront', 'desktop-mode' );
+ store.append( storeIcon, storeLabel );
+ const live = document.createElement( 'os-badge' );
+ live.setAttribute( 'tone', 'info' );
+ live.textContent = __( 'Live', 'desktop-mode' );
+ storeBar.append( store, live );
+
+ const card = document.createElement( 'article' );
+ card.className = 'os-woo-product-studio__product-card';
+
+ const media = document.createElement( 'div' );
+ media.className = 'os-woo-product-studio__product-media';
+ const image = document.createElement( 'img' );
+ image.src = draft.imagePreviewUrl || runtime.bootstrap.placeholderUrl;
+ image.alt = draft.imagePreviewUrl
+ ? draft.name || __( 'Selected product image', 'desktop-mode' )
+ : '';
+ if ( ! draft.imagePreviewUrl ) {
+ media.classList.add( 'is-placeholder' );
+ }
+ media.appendChild( image );
+
+ const state = document.createElement( 'os-badge' );
+ state.setAttribute( 'tone', draft.salePrice ? 'success' : 'neutral' );
+ state.className = 'os-woo-product-studio__preview-badge';
+ state.textContent = draft.salePrice
+ ? __( 'On sale', 'desktop-mode' )
+ : __( 'Preview', 'desktop-mode' );
+ media.appendChild( state );
+
+ if ( ! draft.imagePreviewUrl ) {
+ const prompt = document.createElement( 'span' );
+ prompt.className = 'os-woo-product-studio__image-prompt';
+ prompt.textContent = __( 'Your product image lands here', 'desktop-mode' );
+ media.appendChild( prompt );
+ }
+
+ const copy = document.createElement( 'div' );
+ copy.className = 'os-woo-product-studio__product-copy';
+ const name = document.createElement( 'h3' );
+ name.textContent = draft.name.trim() || __( 'Your product name', 'desktop-mode' );
+ const description = document.createElement( 'p' );
+ description.textContent =
+ draft.shortDescription.trim() ||
+ __( 'A short, specific promise helps shoppers decide.', 'desktop-mode' );
+
+ const price = document.createElement( 'div' );
+ price.className = 'os-woo-product-studio__preview-price';
+ if ( draft.salePrice.trim() ) {
+ const was = document.createElement( 's' );
+ was.textContent = formatPrice( runtime, draft.regularPrice );
+ price.appendChild( was );
+ }
+ const current = document.createElement( 'strong' );
+ current.textContent = formatPrice(
+ runtime,
+ draft.salePrice.trim() || draft.regularPrice,
+ );
+ price.appendChild( current );
+
+ const categories = document.createElement( 'div' );
+ categories.className = 'os-woo-product-studio__preview-categories';
+ for ( const category of selectedCategories( runtime ).slice( 0, 3 ) ) {
+ const chip = document.createElement( 'span' );
+ chip.textContent = category.name;
+ categories.appendChild( chip );
+ }
+
+ const stock = document.createElement( 'div' );
+ stock.className = 'os-woo-product-studio__preview-stock';
+ if ( draft.kind === 'virtual' ) {
+ stock.textContent = __( 'Delivered without shipping', 'desktop-mode' );
+ } else if ( draft.manageStock ) {
+ stock.textContent = sprintf(
+ /* translators: %s: units available. */
+ __( '%s ready to sell', 'desktop-mode' ),
+ draft.stockQuantity || '0',
+ );
+ } else {
+ stock.textContent = __( 'In stock', 'desktop-mode' );
+ }
+
+ copy.append( name, price, description );
+ if ( categories.childElementCount > 0 ) {
+ copy.appendChild( categories );
+ }
+ copy.appendChild( stock );
+ card.append( media, copy );
+ frame.append( storeBar, card );
+ runtime.preview.append( previewHeader, frame );
+}
+
+function renderKindCards( runtime: StudioRuntime ): HTMLElement {
+ const grid = document.createElement( 'div' );
+ grid.className = 'os-woo-product-studio__kind-grid';
+
+ const choices: Array< {
+ id: ProductKind;
+ icon: string;
+ title: string;
+ body: string;
+ } > = [
+ {
+ id: 'physical',
+ icon: 'dashicons-archive',
+ title: __( 'Physical product', 'desktop-mode' ),
+ body: __( 'Something you stock, pack, or ship.', 'desktop-mode' ),
+ },
+ {
+ id: 'virtual',
+ icon: 'dashicons-cloud',
+ title: __( 'Virtual product', 'desktop-mode' ),
+ body: __( 'A service or item with no shipping step.', 'desktop-mode' ),
+ },
+ ];
+
+ for ( const choice of choices ) {
+ const card = document.createElement( 'os-card' );
+ card.setAttribute( 'interactive', '' );
+ card.setAttribute( 'aria-label', choice.title );
+ card.dataset.kind = choice.id;
+ if ( runtime.draft.kind === choice.id ) {
+ card.setAttribute( 'selected', '' );
+ }
+ const icon = document.createElement( 'span' );
+ icon.className = `dashicons ${ choice.icon } os-woo-product-studio__kind-icon`;
+ icon.setAttribute( 'aria-hidden', 'true' );
+ const copy = document.createElement( 'span' );
+ const title = document.createElement( 'strong' );
+ title.textContent = choice.title;
+ const body = document.createElement( 'small' );
+ body.textContent = choice.body;
+ copy.append( title, body );
+ card.append( icon, copy );
+ card.addEventListener( 'os-card-click', () => {
+ updateDraft( runtime, 'kind', choice.id );
+ renderStage( runtime );
+ } );
+ grid.appendChild( card );
+ }
+ return grid;
+}
+
+function renderBasics( runtime: StudioRuntime ): void {
+ const errors = validateProductDraft( runtime.draft, 'basics' );
+ runtime.stage.append(
+ heading(
+ __( 'Step 1 of 4', 'desktop-mode' ),
+ __( 'The product', 'desktop-mode' ),
+ __( 'Tell shoppers what makes it special in language they will understand.', 'desktop-mode' ),
+ ),
+ editorPanel(
+ __( 'Product story', 'desktop-mode' ),
+ __( 'These are the details shoppers notice first.', 'desktop-mode' ),
+ renderKindCards( runtime ),
+ field(
+ 'os-text-field',
+ __( 'Product name', 'desktop-mode' ),
+ runtime.draft.name,
+ ( value ) => updateDraft( runtime, 'name', value ),
+ {
+ placeholder: __( 'e.g. Signal Desk Lamp', 'desktop-mode' ),
+ required: true,
+ invalid: Boolean( runtime.error && errors.name ),
+ },
+ ),
+ field(
+ 'os-text-field',
+ __( 'One-line promise', 'desktop-mode' ),
+ runtime.draft.shortDescription,
+ ( value ) => updateDraft( runtime, 'shortDescription', value ),
+ {
+ placeholder: __( 'What will a shopper understand in five seconds?', 'desktop-mode' ),
+ hint: __( 'This appears beside the price on many storefronts.', 'desktop-mode' ),
+ },
+ ),
+ field(
+ 'os-textarea',
+ __( 'Full description', 'desktop-mode' ),
+ runtime.draft.description,
+ ( value ) => updateDraft( runtime, 'description', value ),
+ {
+ placeholder: __( 'Explain the details, materials, fit, or outcome.', 'desktop-mode' ),
+ rows: 7,
+ },
+ ),
+ ),
+ readinessCue( runtime ),
+ );
+}
+
+function renderCommerce( runtime: StudioRuntime ): void {
+ const errors = validateProductDraft( runtime.draft, 'commerce' );
+ runtime.stage.appendChild(
+ heading(
+ __( 'Step 2 of 4', 'desktop-mode' ),
+ __( 'The offer', 'desktop-mode' ),
+ __( 'Set the price shoppers see and decide how WooCommerce should handle inventory.', 'desktop-mode' ),
+ ),
+ );
+ const grid = document.createElement( 'div' );
+ grid.className = 'os-woo-product-studio__field-grid';
+ grid.append(
+ field(
+ 'os-text-field',
+ __( 'Regular price', 'desktop-mode' ),
+ runtime.draft.regularPrice,
+ ( value ) => updateDraft( runtime, 'regularPrice', value ),
+ {
+ type: 'number',
+ placeholder: '0.00',
+ required: true,
+ invalid: Boolean( runtime.error && errors.regularPrice ),
+ fullWidth: false,
+ hint: runtime.bootstrap.currencyCode,
+ },
+ ),
+ field(
+ 'os-text-field',
+ __( 'Sale price', 'desktop-mode' ),
+ runtime.draft.salePrice,
+ ( value ) => updateDraft( runtime, 'salePrice', value ),
+ {
+ type: 'number',
+ placeholder: __( 'Optional', 'desktop-mode' ),
+ invalid: Boolean( runtime.error && errors.salePrice ),
+ fullWidth: false,
+ hint: __( 'Leave blank when it is not on sale.', 'desktop-mode' ),
+ },
+ ),
+ field(
+ 'os-text-field',
+ __( 'SKU', 'desktop-mode' ),
+ runtime.draft.sku,
+ ( value ) => updateDraft( runtime, 'sku', value ),
+ {
+ placeholder: __( 'Optional internal code', 'desktop-mode' ),
+ hint: __( 'Product Studio checks uniqueness when you save.', 'desktop-mode' ),
+ },
+ ),
+ );
+ const panel = editorPanel(
+ __( 'Pricing and identity', 'desktop-mode' ),
+ __( 'A price is required to publish. The SKU and sale price stay optional.', 'desktop-mode' ),
+ grid,
+ );
+ runtime.stage.appendChild( panel );
+
+ if ( runtime.draft.kind === 'physical' ) {
+ const inventory = document.createElement( 'section' );
+ inventory.className = 'os-woo-product-studio__subsection';
+ const h = document.createElement( 'h3' );
+ h.textContent = __( 'Inventory', 'desktop-mode' );
+ const toggle = document.createElement( 'os-checkbox-label' );
+ toggle.setAttribute( 'label', __( 'Track the quantity on hand', 'desktop-mode' ) );
+ if ( runtime.draft.manageStock ) {
+ toggle.setAttribute( 'checked', '' );
+ }
+ toggle.addEventListener( 'os-checkbox-change', ( event: Event ) => {
+ const checked = Boolean(
+ ( event as CustomEvent< { checked?: boolean } > ).detail?.checked,
+ );
+ updateDraft( runtime, 'manageStock', checked );
+ renderStage( runtime );
+ } );
+ inventory.append( h, toggle );
+ if ( runtime.draft.manageStock ) {
+ inventory.appendChild(
+ field(
+ 'os-text-field',
+ __( 'Quantity available', 'desktop-mode' ),
+ runtime.draft.stockQuantity,
+ ( value ) => updateDraft( runtime, 'stockQuantity', value ),
+ {
+ type: 'number',
+ placeholder: '0',
+ invalid: Boolean( runtime.error && errors.stockQuantity ),
+ },
+ ),
+ );
+ }
+ panel.appendChild( inventory );
+ }
+ runtime.stage.appendChild( readinessCue( runtime ) );
+}
+
+function setProductImage( runtime: StudioRuntime, file: File ): void {
+ runtime.error = '';
+ const allowedTypes = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ];
+ if ( ! allowedTypes.includes( file.type ) ) {
+ runtime.error = __( 'Choose a JPEG, PNG, GIF, or WebP image.', 'desktop-mode' );
+ renderStage( runtime );
+ return;
+ }
+ if ( file.size <= 0 ) {
+ runtime.error = __( 'Choose an image that is not empty.', 'desktop-mode' );
+ renderStage( runtime );
+ return;
+ }
+ if ( file.size > runtime.bootstrap.maxImageBytes ) {
+ runtime.error = sprintf(
+ /* translators: %s: site upload limit. */
+ __( 'Choose an image smaller than %s.', 'desktop-mode' ),
+ runtime.bootstrap.maxImageLabel,
+ );
+ renderStage( runtime );
+ return;
+ }
+ if ( runtime.objectUrl ) {
+ URL.revokeObjectURL( runtime.objectUrl );
+ }
+ runtime.objectUrl = URL.createObjectURL( file );
+ runtime.draft.imageFile = file;
+ runtime.draft.imagePreviewUrl = runtime.objectUrl;
+ runtime.recoveryNeedsImage = false;
+ markDirty( runtime );
+ renderStage( runtime );
+ renderPreview( runtime );
+}
+
+function renderImagePicker( runtime: StudioRuntime ): HTMLElement {
+ const host = document.createElement( 'div' );
+ host.className = 'os-woo-product-studio__image-picker';
+ if ( runtime.draft.imagePreviewUrl ) {
+ host.classList.add( 'has-image' );
+ }
+
+ const input = document.createElement( 'input' );
+ input.type = 'file';
+ input.accept = 'image/jpeg,image/png,image/gif,image/webp';
+ input.className = 'os-woo-product-studio__file-input';
+ input.hidden = true;
+ input.tabIndex = -1;
+
+ const icon = document.createElement( 'span' );
+ icon.className = 'dashicons dashicons-format-image';
+ icon.setAttribute( 'aria-hidden', 'true' );
+ const title = document.createElement( 'strong' );
+ title.textContent = runtime.draft.imageFile
+ ? runtime.draft.imageFile.name
+ : __( 'Add the product image', 'desktop-mode' );
+ const body = document.createElement( 'span' );
+ body.textContent = sprintf(
+ /* translators: %s: site upload limit. */
+ __( 'Drop an image here, or choose one. Maximum %s.', 'desktop-mode' ),
+ runtime.bootstrap.maxImageLabel,
+ );
+ const choose = button(
+ runtime.draft.imageFile
+ ? __( 'Replace image', 'desktop-mode' )
+ : __( 'Choose image', 'desktop-mode' ),
+ 'secondary',
+ () => input.click(),
+ );
+
+ host.append( input, icon, title, body, choose );
+ if ( runtime.draft.imageFile ) {
+ host.appendChild(
+ button( __( 'Remove', 'desktop-mode' ), 'link', () => {
+ if ( runtime.objectUrl ) {
+ URL.revokeObjectURL( runtime.objectUrl );
+ }
+ runtime.objectUrl = '';
+ runtime.draft.imageFile = null;
+ runtime.draft.imagePreviewUrl = '';
+ markDirty( runtime );
+ renderStage( runtime );
+ renderPreview( runtime );
+ } ),
+ );
+ }
+
+ input.addEventListener( 'change', () => {
+ const file = input.files?.[ 0 ];
+ if ( file ) {
+ setProductImage( runtime, file );
+ }
+ } );
+ for ( const type of [ 'dragenter', 'dragover' ] ) {
+ host.addEventListener( type, ( event ) => {
+ event.preventDefault();
+ host.classList.add( 'is-dragging' );
+ } );
+ }
+ for ( const type of [ 'dragleave', 'drop' ] ) {
+ host.addEventListener( type, ( event ) => {
+ event.preventDefault();
+ host.classList.remove( 'is-dragging' );
+ } );
+ }
+ host.addEventListener( 'drop', ( event: DragEvent ) => {
+ const file = event.dataTransfer?.files?.[ 0 ];
+ if ( file ) {
+ setProductImage( runtime, file );
+ }
+ } );
+ return host;
+}
+
+function categoryDepth(
+ category: ProductCategory,
+ byId: Map< number, ProductCategory >,
+): number {
+ let depth = 0;
+ let parent = category.parent;
+ const seen = new Set< number >();
+ while ( parent && byId.has( parent ) && ! seen.has( parent ) && depth < 4 ) {
+ seen.add( parent );
+ depth++;
+ parent = byId.get( parent )?.parent ?? 0;
+ }
+ return depth;
+}
+
+function renderCategories( runtime: StudioRuntime ): HTMLElement {
+ const section = document.createElement( 'section' );
+ section.className = 'os-woo-product-studio__categories';
+ const title = document.createElement( 'h3' );
+ title.textContent = __( 'Categories', 'desktop-mode' );
+ const helper = document.createElement( 'p' );
+ helper.textContent = __( 'Choose every shelf where this product belongs.', 'desktop-mode' );
+ section.append( title, helper );
+
+ if ( runtime.bootstrap.categories.length === 0 ) {
+ const empty = document.createElement( 'p' );
+ empty.className = 'os-woo-product-studio__category-empty';
+ empty.textContent = __( 'No product categories exist yet. WooCommerce will use the store default.', 'desktop-mode' );
+ section.appendChild( empty );
+ return section;
+ }
+
+ const list = document.createElement( 'div' );
+ list.className = 'os-woo-product-studio__category-list';
+ const byId = new Map( runtime.bootstrap.categories.map( ( item ) => [ item.id, item ] ) );
+ for ( const category of runtime.bootstrap.categories ) {
+ const row = document.createElement( 'os-checkbox-label' );
+ row.setAttribute( 'label', category.name );
+ row.style.setProperty( '--os-woo-category-depth', String( categoryDepth( category, byId ) ) );
+ if ( runtime.draft.categoryIds.includes( category.id ) ) {
+ row.setAttribute( 'checked', '' );
+ }
+ row.addEventListener( 'os-checkbox-change', ( event: Event ) => {
+ const checked = Boolean(
+ ( event as CustomEvent< { checked?: boolean } > ).detail?.checked,
+ );
+ const ids = new Set( runtime.draft.categoryIds );
+ if ( checked ) {
+ ids.add( category.id );
+ } else {
+ ids.delete( category.id );
+ }
+ updateDraft( runtime, 'categoryIds', Array.from( ids ) );
+ } );
+ list.appendChild( row );
+ }
+ section.appendChild( list );
+ return section;
+}
+
+function renderPresentation( runtime: StudioRuntime ): void {
+ runtime.stage.append(
+ heading(
+ __( 'Step 3 of 4', 'desktop-mode' ),
+ __( 'The shelf', 'desktop-mode' ),
+ __( 'Give shoppers a strong visual and a useful way to find the product.', 'desktop-mode' ),
+ ),
+ editorPanel(
+ __( 'Shelf presentation', 'desktop-mode' ),
+ __( 'A strong image and category make the product recognizable at a glance.', 'desktop-mode' ),
+ renderImagePicker( runtime ),
+ renderCategories( runtime ),
+ ),
+ readinessCue( runtime ),
+ );
+}
+
+function reviewRow( label: string, value: string, ready = true ): HTMLElement {
+ const row = document.createElement( 'div' );
+ row.className = 'os-woo-product-studio__review-row';
+ const icon = document.createElement( 'span' );
+ icon.className = `dashicons ${ ready ? 'dashicons-yes-alt' : 'dashicons-marker' }`;
+ icon.setAttribute( 'aria-hidden', 'true' );
+ const copy = document.createElement( 'span' );
+ const l = document.createElement( 'small' );
+ l.textContent = label;
+ const v = document.createElement( 'strong' );
+ v.textContent = value;
+ copy.append( l, v );
+ row.append( icon, copy );
+ return row;
+}
+
+function renderReview( runtime: StudioRuntime ): void {
+ const publishErrors = validateProductDraft( runtime.draft, 'publish' );
+ runtime.stage.appendChild(
+ heading(
+ __( 'Step 4 of 4', 'desktop-mode' ),
+ __( 'Launch with confidence', 'desktop-mode' ),
+ __( 'Product Studio will create a standard simple WooCommerce product. You can still refine advanced details in the full editor.', 'desktop-mode' ),
+ ),
+ );
+
+ const notice = document.createElement( 'os-notice' );
+ notice.setAttribute( 'not-dismissible', '' );
+ if ( Object.keys( publishErrors ).length === 0 ) {
+ notice.setAttribute( 'tone', 'success' );
+ notice.setAttribute( 'icon', 'dashicons-yes-alt' );
+ notice.textContent = runtime.bootstrap.canPublish
+ ? __( 'Everything required to publish is ready.', 'desktop-mode' )
+ : __( 'Everything required to submit this product as a draft is ready.', 'desktop-mode' );
+ } else {
+ notice.setAttribute( 'tone', 'warning' );
+ notice.setAttribute( 'icon', 'dashicons-warning' );
+ notice.textContent = firstErrorMessage( publishErrors );
+ }
+ runtime.stage.appendChild( notice );
+
+ const rows = document.createElement( 'div' );
+ rows.className = 'os-woo-product-studio__review-grid';
+ rows.append(
+ reviewRow(
+ __( 'Product', 'desktop-mode' ),
+ runtime.draft.name || __( 'Name still needed', 'desktop-mode' ),
+ Boolean( runtime.draft.name.trim() ),
+ ),
+ reviewRow(
+ __( 'Offer', 'desktop-mode' ),
+ runtime.draft.regularPrice
+ ? formatPrice( runtime, runtime.draft.salePrice || runtime.draft.regularPrice )
+ : __( 'Price still needed', 'desktop-mode' ),
+ Boolean( runtime.draft.regularPrice.trim() ),
+ ),
+ reviewRow(
+ __( 'Image', 'desktop-mode' ),
+ runtime.draft.imageFile?.name || __( 'Using the store placeholder', 'desktop-mode' ),
+ Boolean( runtime.draft.imageFile ),
+ ),
+ reviewRow(
+ __( 'Categories', 'desktop-mode' ),
+ selectedCategories( runtime )
+ .map( ( item ) => item.name )
+ .join( ', ' ) || __( 'Store default', 'desktop-mode' ),
+ true,
+ ),
+ );
+ runtime.stage.appendChild(
+ editorPanel(
+ __( 'Launch checklist', 'desktop-mode' ),
+ __( 'A final scan of the details WooCommerce will save.', 'desktop-mode' ),
+ rows,
+ ),
+ );
+}
+
+function renderError( runtime: StudioRuntime ): void {
+ const previous = runtime.stage.querySelector( '.os-woo-product-studio__error' );
+ previous?.remove();
+ if ( ! runtime.error ) {
+ return;
+ }
+ const notice = document.createElement( 'os-notice' );
+ notice.className = 'os-woo-product-studio__error';
+ notice.setAttribute( 'tone', 'error' );
+ notice.setAttribute( 'icon', 'dashicons-warning' );
+ notice.setAttribute( 'not-dismissible', '' );
+ notice.setAttribute( 'role', 'alert' );
+ notice.textContent = runtime.error;
+ runtime.stage.prepend( notice );
+}
+
+function renderRecoveryNotice( runtime: StudioRuntime ): void {
+ if ( ! runtime.recoveryNeedsImage ) {
+ return;
+ }
+ const notice = document.createElement( 'os-notice' );
+ notice.className = 'os-woo-product-studio__recovery-notice';
+ notice.setAttribute( 'tone', 'warning' );
+ notice.setAttribute( 'icon', 'dashicons-format-image' );
+ notice.setAttribute( 'not-dismissible', '' );
+ notice.setAttribute( 'role', 'status' );
+ notice.textContent = __( 'Draft recovered. Choose the product image again before saving.', 'desktop-mode' );
+ runtime.stage.prepend( notice );
+}
+
+function renderStage( runtime: StudioRuntime ): void {
+ runtime.stage.replaceChildren();
+ const id = STEPS[ runtime.stepIndex ].id;
+ if ( id === 'basics' ) {
+ renderBasics( runtime );
+ } else if ( id === 'commerce' ) {
+ renderCommerce( runtime );
+ } else if ( id === 'presentation' ) {
+ renderPresentation( runtime );
+ } else {
+ renderReview( runtime );
+ }
+ renderRecoveryNotice( runtime );
+ renderError( runtime );
+ renderNavigation( runtime );
+}
+
+function renderStepRail( runtime: StudioRuntime ): void {
+ runtime.stepRail.replaceChildren();
+ STEPS.forEach( ( step, index ) => {
+ const item = document.createElement( 'os-button' );
+ item.setAttribute( 'variant', 'ghost' );
+ item.className = 'os-woo-product-studio__step-button';
+ item.setAttribute( 'aria-current', index === runtime.stepIndex ? 'step' : 'false' );
+ if ( index < runtime.stepIndex ) {
+ item.classList.add( 'is-complete' );
+ }
+ const number = document.createElement( 'span' );
+ number.className = 'os-woo-product-studio__step-number';
+ number.textContent = index < runtime.stepIndex ? '✓' : String( index + 1 );
+ const copy = document.createElement( 'span' );
+ const label = document.createElement( 'strong' );
+ label.textContent = step.label;
+ const kicker = document.createElement( 'small' );
+ kicker.textContent = step.kicker;
+ copy.append( label, kicker );
+ item.append( number, copy );
+ item.addEventListener( 'click', () => {
+ if ( runtime.busy ) {
+ return;
+ }
+ goToStep( runtime, index );
+ } );
+ runtime.stepRail.appendChild( item );
+ } );
+}
+
+function updateProgress( runtime: StudioRuntime ): void {
+ runtime.progress.setAttribute( 'value', String( ( runtime.stepIndex + 1 ) * 25 ) );
+ runtime.progress.setAttribute(
+ 'label',
+ sprintf(
+ /* translators: 1: current step, 2: total steps. */
+ __( 'Step %1$d of %2$d', 'desktop-mode' ),
+ runtime.stepIndex + 1,
+ STEPS.length,
+ ),
+ );
+}
+
+function focusStageFeedback( runtime: StudioRuntime, hasError = false ): void {
+ const target = hasError
+ ? runtime.stage.querySelector< HTMLElement >( '.os-woo-product-studio__error' )
+ : runtime.stage.querySelector< HTMLElement >( 'h2' );
+ if ( ! target ) {
+ return;
+ }
+ target.tabIndex = -1;
+ target.focus( { preventScroll: true } );
+}
+
+function goToStep( runtime: StudioRuntime, index: number, preserveError = false ): void {
+ if ( ! preserveError ) {
+ runtime.error = '';
+ }
+ runtime.stepIndex = Math.max( 0, Math.min( STEPS.length - 1, index ) );
+ renderStage( runtime );
+ renderStepRail( runtime );
+ updateProgress( runtime );
+ runtime.stage.scrollTop = 0;
+ persistDraft( runtime );
+ focusStageFeedback( runtime, preserveError && Boolean( runtime.error ) );
+}
+
+function validateCurrentStep( runtime: StudioRuntime ): boolean {
+ const id = STEPS[ runtime.stepIndex ].id;
+ const errors = validateProductDraft( runtime.draft, id );
+ if ( Object.keys( errors ).length === 0 ) {
+ return true;
+ }
+ runtime.error = firstErrorMessage( errors );
+ renderStage( runtime );
+ focusStageFeedback( runtime, true );
+ return false;
+}
+
+function productFormData(
+ draft: ProductDraft,
+ status: ProductStatus,
+ requestId: string,
+): FormData {
+ const data = new FormData();
+ data.set( 'requestId', requestId );
+ data.set( 'name', draft.name.trim() );
+ data.set( 'shortDescription', draft.shortDescription.trim() );
+ data.set( 'description', draft.description.trim() );
+ data.set( 'regularPrice', draft.regularPrice.trim() );
+ data.set( 'salePrice', draft.salePrice.trim() );
+ data.set( 'sku', draft.sku.trim() );
+ data.set( 'virtual', draft.kind === 'virtual' ? '1' : '0' );
+ data.set( 'manageStock', draft.kind === 'physical' && draft.manageStock ? '1' : '0' );
+ data.set( 'stockQuantity', draft.stockQuantity || '0' );
+ data.set( 'stockStatus', draft.stockStatus );
+ data.set( 'categoryIds', JSON.stringify( draft.categoryIds ) );
+ data.set( 'status', status );
+ if ( draft.imageFile ) {
+ data.set( 'image', draft.imageFile, draft.imageFile.name );
+ }
+ return data;
+}
+
+function announceCreatedProduct( result: ProductResult ): void {
+ const broadcast = (
+ window.wp as
+ | { os?: { broadcast?: ( topic: string, payload: unknown ) => void } }
+ | undefined
+ )?.os?.broadcast;
+ if ( typeof broadcast === 'function' ) {
+ broadcast( 'os.product.changed', {
+ source: 'product-studio',
+ action: 'created',
+ ids: [ result.id ],
+ } );
+ }
+}
+
+async function submitProduct(
+ runtime: StudioRuntime,
+ status: ProductStatus,
+): Promise< void > {
+ if ( runtime.busy ) {
+ return;
+ }
+ const validation = validateProductDraft(
+ runtime.draft,
+ status === 'draft' ? 'draft-save' : 'publish',
+ );
+ if ( Object.keys( validation ).length > 0 ) {
+ runtime.error = firstErrorMessage( validation );
+ if ( validation.name ) {
+ goToStep( runtime, 0, true );
+ } else if ( validation.regularPrice || validation.salePrice || validation.stockQuantity ) {
+ goToStep( runtime, 1, true );
+ } else {
+ renderStage( runtime );
+ focusStageFeedback( runtime, true );
+ }
+ return;
+ }
+
+ const cfg = getConfig();
+ if ( ! cfg ) {
+ runtime.error = __( 'WooCommerce Product Studio is unavailable.', 'desktop-mode' );
+ renderStage( runtime );
+ return;
+ }
+
+ runtime.busy = true;
+ runtime.submitMode = status;
+ runtime.error = '';
+ renderNavigation( runtime );
+ runtime.statusBadge.setAttribute( 'tone', 'info' );
+ runtime.statusBadge.textContent = status === 'publish'
+ ? __( 'Publishing…', 'desktop-mode' )
+ : __( 'Saving…', 'desktop-mode' );
+
+ try {
+ const response = await trackedFetch(
+ `${ cfg.restRoot }products`,
+ {
+ method: 'POST',
+ credentials: 'same-origin',
+ signal: runtime.requestSignal,
+ headers: {
+ 'X-WP-Nonce': cfg.restNonce,
+ Accept: 'application/json',
+ },
+ body: productFormData( runtime.draft, status, runtime.requestId ),
+ },
+ {
+ windowId: WINDOW_ID,
+ source: 'desktop-mode/woocommerce-product-studio',
+ },
+ );
+ const body = ( await response.json().catch( () => null ) ) as
+ | ProductResult
+ | { message?: string }
+ | null;
+ if ( ! response.ok ) {
+ throw new Error(
+ ( body as { message?: string } | null )?.message ||
+ __( 'WooCommerce could not save the product.', 'desktop-mode' ),
+ );
+ }
+ runtime.result = body as ProductResult;
+ clearStoredDraft();
+ announceCreatedProduct( runtime.result );
+ renderSuccess( runtime );
+ } catch ( error ) {
+ if ( runtime.requestSignal.aborted ) {
+ return;
+ }
+ runtime.error = error instanceof Error
+ ? error.message
+ : __( 'WooCommerce could not save the product.', 'desktop-mode' );
+ runtime.busy = false;
+ runtime.submitMode = null;
+ runtime.statusBadge.setAttribute( 'tone', 'danger' );
+ runtime.statusBadge.textContent = __( 'Needs attention', 'desktop-mode' );
+ renderStage( runtime );
+ }
+}
+
+function renderNavigation( runtime: StudioRuntime ): void {
+ runtime.footer.replaceChildren();
+ const leading = document.createElement( 'div' );
+ leading.className = 'os-woo-product-studio__footer-leading';
+ if ( runtime.stepIndex > 0 ) {
+ leading.appendChild(
+ button( __( 'Back', 'desktop-mode' ), 'ghost', () =>
+ goToStep( runtime, runtime.stepIndex - 1 ),
+ ),
+ );
+ }
+ leading.appendChild(
+ button( __( 'Save draft', 'desktop-mode' ), 'secondary', () => {
+ void submitProduct( runtime, 'draft' );
+ } ),
+ );
+
+ const trailing = document.createElement( 'div' );
+ trailing.className = 'os-woo-product-studio__footer-trailing';
+ if ( runtime.stepIndex < STEPS.length - 1 ) {
+ const labels = [
+ __( 'Continue to offer', 'desktop-mode' ),
+ __( 'Continue to shelf', 'desktop-mode' ),
+ __( 'Review product', 'desktop-mode' ),
+ ];
+ trailing.appendChild(
+ button( labels[ runtime.stepIndex ], 'primary', () => {
+ if ( validateCurrentStep( runtime ) ) {
+ goToStep( runtime, runtime.stepIndex + 1 );
+ }
+ } ),
+ );
+ } else if ( runtime.bootstrap.canPublish ) {
+ trailing.appendChild(
+ button( __( 'Publish product', 'desktop-mode' ), 'holo', () => {
+ void submitProduct( runtime, 'publish' );
+ } ),
+ );
+ } else {
+ trailing.appendChild(
+ button( __( 'Save product draft', 'desktop-mode' ), 'primary', () => {
+ void submitProduct( runtime, 'draft' );
+ } ),
+ );
+ }
+
+ if ( runtime.busy ) {
+ for ( const control of [ ...leading.children, ...trailing.children ] ) {
+ control.setAttribute( 'disabled', '' );
+ }
+ const active = runtime.submitMode === 'draft'
+ ? leading.lastElementChild
+ : trailing.lastElementChild;
+ active?.removeAttribute( 'disabled' );
+ active?.setAttribute( 'busy', '' );
+ }
+ runtime.footer.append( leading, trailing );
+}
+
+function openUrlWindow( url: string, title: string, icon: string ): void {
+ if ( ! url ) {
+ return;
+ }
+ const os = (
+ window.wp as
+ | {
+ os?: {
+ deriveWindowId?: ( target: string ) => string;
+ windowManager?: {
+ open: ( args: {
+ id: string;
+ url: string;
+ title: string;
+ icon?: string;
+ } ) => unknown;
+ };
+ };
+ }
+ | undefined
+ )?.os;
+ if ( ! os?.windowManager?.open ) {
+ window.open( url, '_blank', 'noopener,noreferrer' );
+ return;
+ }
+ const id = typeof os.deriveWindowId === 'function'
+ ? os.deriveWindowId( url )
+ : `os-woo-product-${ url.replace( /[^a-z0-9]+/gi, '-' ).slice( -60 ) }`;
+ os.windowManager.open( { id, url, title, icon } );
+}
+
+function renderSuccess( runtime: StudioRuntime ): void {
+ const result = runtime.result;
+ if ( ! result ) {
+ return;
+ }
+ runtime.busy = false;
+ runtime.submitMode = null;
+ runtime.root.classList.add( 'is-complete' );
+ runtime.statusBadge.setAttribute( 'tone', 'success' );
+ runtime.statusBadge.textContent = result.status === 'publish'
+ ? __( 'Published', 'desktop-mode' )
+ : __( 'Draft saved', 'desktop-mode' );
+
+ const success = document.createElement( 'section' );
+ success.className = 'os-woo-product-studio__success';
+ const mark = document.createElement( 'span' );
+ mark.className = 'dashicons dashicons-yes-alt';
+ mark.setAttribute( 'aria-hidden', 'true' );
+ const eyebrow = document.createElement( 'div' );
+ eyebrow.className = 'os-woo-product-studio__eyebrow';
+ eyebrow.textContent = result.status === 'publish'
+ ? __( 'Now on the shelf', 'desktop-mode' )
+ : __( 'Safe in WooCommerce', 'desktop-mode' );
+ const title = document.createElement( 'h2' );
+ title.textContent = result.name;
+ const body = document.createElement( 'p' );
+ body.textContent = result.status === 'publish'
+ ? __( 'The product is live. Open it beside Product Studio, or refine advanced details in the full editor.', 'desktop-mode' )
+ : __( 'The draft is saved. Open the full editor whenever you are ready to finish it.', 'desktop-mode' );
+
+ const product = document.createElement( 'div' );
+ product.className = 'os-woo-product-studio__success-product';
+ const image = document.createElement( 'img' );
+ image.src = result.thumbnail || runtime.draft.imagePreviewUrl || runtime.bootstrap.placeholderUrl;
+ image.alt = '';
+ const meta = document.createElement( 'div' );
+ const name = document.createElement( 'strong' );
+ name.textContent = result.name;
+ const price = document.createElement( 'span' );
+ price.textContent = result.price || formatPrice( runtime, runtime.draft.regularPrice );
+ meta.append( name, price );
+ product.append( image, meta );
+
+ const actions = document.createElement( 'div' );
+ actions.className = 'os-woo-product-studio__success-actions';
+ if ( result.viewUrl ) {
+ actions.appendChild(
+ button( __( 'View product', 'desktop-mode' ), 'holo', () =>
+ openUrlWindow( result.viewUrl, result.name, 'dashicons-products' ),
+ ),
+ );
+ }
+ actions.append(
+ button( __( 'Open full editor', 'desktop-mode' ), 'secondary', () =>
+ openUrlWindow( result.editUrl, result.name, 'dashicons-products' ),
+ ),
+ button( __( 'Create another', 'desktop-mode' ), 'ghost', () => {
+ if ( runtime.objectUrl ) {
+ URL.revokeObjectURL( runtime.objectUrl );
+ }
+ runtime.objectUrl = '';
+ runtime.draft = emptyDraft();
+ runtime.requestId = createRequestId();
+ runtime.recoveredDraft = false;
+ runtime.recoveryNeedsImage = false;
+ runtime.result = null;
+ runtime.stepIndex = 0;
+ runtime.error = '';
+ runtime.root.classList.remove( 'is-complete' );
+ clearStoredDraft();
+ mountWorkbench( runtime );
+ } ),
+ );
+ success.append( mark, eyebrow, title, body, product, actions );
+
+ const header = runtime.root.querySelector( '.os-woo-product-studio__header' );
+ runtime.root.replaceChildren();
+ if ( header ) {
+ runtime.root.appendChild( header );
+ }
+ runtime.root.appendChild( success );
+}
+
+function mountWorkbench( runtime: StudioRuntime ): void {
+ runtime.root.replaceChildren();
+
+ const header = document.createElement( 'header' );
+ header.className = 'os-woo-product-studio__header';
+ const brand = document.createElement( 'div' );
+ const eyebrow = document.createElement( 'div' );
+ eyebrow.className = 'os-woo-product-studio__eyebrow';
+ eyebrow.textContent = __( 'WooCommerce · Product Studio', 'desktop-mode' );
+ const title = document.createElement( 'h1' );
+ title.textContent = __( 'Put something new on the shelf.', 'desktop-mode' );
+ brand.append( eyebrow, title );
+ runtime.statusBadge = document.createElement( 'os-badge' );
+ runtime.statusBadge.setAttribute( 'tone', runtime.recoveredDraft ? 'info' : 'neutral' );
+ runtime.statusBadge.textContent = runtime.recoveredDraft
+ ? __( 'Recovered', 'desktop-mode' )
+ : __( 'Unsaved', 'desktop-mode' );
+ header.append( brand, runtime.statusBadge );
+
+ runtime.progress = document.createElement( 'os-progress-bar' );
+ runtime.progress.className = 'os-woo-product-studio__progress';
+ runtime.progress.setAttribute( 'max', '100' );
+
+ const workbench = document.createElement( 'div' );
+ workbench.className = 'os-woo-product-studio__workbench';
+ runtime.stepRail = document.createElement( 'nav' );
+ runtime.stepRail.className = 'os-woo-product-studio__steps';
+ runtime.stepRail.setAttribute( 'aria-label', __( 'Product creation steps', 'desktop-mode' ) );
+ runtime.stage = document.createElement( 'main' );
+ runtime.stage.className = 'os-woo-product-studio__stage';
+ runtime.preview = document.createElement( 'aside' );
+ runtime.preview.className = 'os-woo-product-studio__preview';
+ runtime.preview.setAttribute( 'aria-label', __( 'Product preview', 'desktop-mode' ) );
+ workbench.append( runtime.stage, runtime.preview );
+
+ runtime.footer = document.createElement( 'footer' );
+ runtime.footer.className = 'os-woo-product-studio__footer';
+ runtime.root.append(
+ header,
+ runtime.stepRail,
+ runtime.progress,
+ workbench,
+ runtime.footer,
+ );
+
+ renderStepRail( runtime );
+ updateProgress( runtime );
+ renderStage( runtime );
+ renderPreview( runtime );
+}
+
+function mountProductStudio(
+ root: HTMLElement,
+ bootstrap: ProductStudioBootstrap,
+ signal: AbortSignal,
+): () => void {
+ const placeholder = document.createElement( 'div' );
+ const restored = restoreDraft( bootstrap );
+ const runtime: StudioRuntime = {
+ root,
+ bootstrap,
+ draft: restored?.draft ?? emptyDraft(),
+ stepIndex: restored?.stepIndex ?? 0,
+ busy: false,
+ submitMode: null,
+ error: '',
+ objectUrl: '',
+ requestSignal: signal,
+ stage: placeholder,
+ preview: placeholder,
+ progress: placeholder,
+ stepRail: placeholder,
+ statusBadge: placeholder,
+ footer: placeholder,
+ result: null,
+ requestId: restored?.requestId ?? createRequestId(),
+ recoveredDraft: Boolean( restored ),
+ recoveryNeedsImage: Boolean( restored?.hadImage ),
+ };
+ mountWorkbench( runtime );
+ return () => {
+ if ( runtime.objectUrl ) {
+ URL.revokeObjectURL( runtime.objectUrl );
+ }
+ };
+}
+
+function paintLoadError( root: HTMLElement, message: string, retry: () => void ): void {
+ const host = document.createElement( 'div' );
+ host.className = 'os-woo-product-studio__load-error';
+ const icon = document.createElement( 'span' );
+ icon.className = 'dashicons dashicons-warning';
+ icon.setAttribute( 'aria-hidden', 'true' );
+ const title = document.createElement( 'h2' );
+ title.textContent = __( 'Product Studio could not start.', 'desktop-mode' );
+ const body = document.createElement( 'p' );
+ body.textContent = message;
+ host.append( icon, title, body, button( __( 'Try again', 'desktop-mode' ), 'primary', retry ) );
+ root.replaceChildren( host );
+}
+
+export function openProductStudio(): boolean {
+ const openWindow = (
+ window.wp as
+ | {
+ os?: {
+ openWindow?: (
+ id: string,
+ opts?: { source?: string },
+ ) => boolean;
+ };
+ }
+ | undefined
+ )?.os?.openWindow;
+ return typeof openWindow === 'function'
+ ? openWindow( WINDOW_ID, { source: 'my-wordpress/woocommerce' } )
+ : false;
+}
+
+function appendLauncher( container: HTMLElement ): void {
+ if ( container.querySelector( '.os-woo-product-studio-launcher' ) ) {
+ return;
+ }
+ const host = document.createElement( 'section' );
+ host.className = 'os-woo-product-studio-launcher';
+ const icon = document.createElement( 'span' );
+ icon.className = 'dashicons dashicons-plus-alt2';
+ icon.setAttribute( 'aria-hidden', 'true' );
+ const copy = document.createElement( 'div' );
+ const eyebrow = document.createElement( 'div' );
+ eyebrow.className = 'os-woo-product-studio-launcher__eyebrow';
+ eyebrow.textContent = __( 'Product Studio', 'desktop-mode' );
+ const title = document.createElement( 'h3' );
+ title.textContent = __( 'Put a new product on the shelf', 'desktop-mode' );
+ const body = document.createElement( 'p' );
+ body.textContent = __( 'Name it, price it, add a photo, and publish through a focused four-step flow.', 'desktop-mode' );
+ copy.append( eyebrow, title, body );
+ const launch = button( __( 'Create product', 'desktop-mode' ), 'holo', () => {
+ openProductStudio();
+ } );
+ host.append( icon, copy, launch );
+ container.appendChild( host );
+}
+
+let registered = false;
+
+/** Register the Woo folder launcher and native-window renderer. */
+export function registerWooProductStudio(): void {
+ if ( registered ) {
+ return;
+ }
+ registered = true;
+
+ addAction(
+ 'os.my-wordpress.group-extras',
+ 'desktop-mode/woocommerce-product-studio',
+ ( payload: GroupExtrasPayload ) => {
+ const cfg = getConfig();
+ if ( payload.groupId !== WOO_GROUP_ID || ! cfg?.canCreateProducts ) {
+ return;
+ }
+ appendLauncher( payload.container );
+ },
+ );
+
+ type NativeRenderContext = { signal?: AbortSignal };
+ type NativeWindowRenderer = (
+ body: HTMLElement,
+ ctx?: NativeRenderContext,
+ ) => unknown;
+ const globals = window as unknown as {
+ openStationNativeWindows?: Record< string, NativeWindowRenderer >;
+ };
+ globals.openStationNativeWindows = globals.openStationNativeWindows ?? {};
+ globals.openStationNativeWindows[ WINDOW_ID ] = ( body, ctx ) => {
+ const root = body.querySelector< HTMLElement >(
+ '[data-os-woo-product-studio-root]',
+ ) ?? body;
+ const local = new AbortController();
+ const upstream = ctx?.signal;
+ const abort = () => local.abort();
+ upstream?.addEventListener( 'abort', abort, { once: true } );
+ let cleanup: () => void = () => undefined;
+
+ const load = async (): Promise< void > => {
+ const loading = document.createElement( 'div' );
+ loading.className = 'os-woo-product-studio__loading';
+ loading.appendChild( document.createElement( 'os-spinner' ) );
+ root.replaceChildren( loading );
+ try {
+ const bootstrap = await fetchBootstrap( local.signal );
+ if ( local.signal.aborted ) {
+ return;
+ }
+ cleanup();
+ cleanup = mountProductStudio( root, bootstrap, local.signal );
+ } catch ( error ) {
+ if ( local.signal.aborted ) {
+ return;
+ }
+ paintLoadError(
+ root,
+ error instanceof Error
+ ? error.message
+ : __( 'Could not prepare Product Studio.', 'desktop-mode' ),
+ () => void load(),
+ );
+ }
+ };
+ void load();
+
+ return () => {
+ upstream?.removeEventListener( 'abort', abort );
+ local.abort();
+ cleanup();
+ };
+ };
+}
diff --git a/src/my-wordpress/integrations/woocommerce.ts b/src/my-wordpress/integrations/woocommerce.ts
index 7a8f72ce5..cf344000a 100644
--- a/src/my-wordpress/integrations/woocommerce.ts
+++ b/src/my-wordpress/integrations/woocommerce.ts
@@ -24,6 +24,10 @@ import {
} from '../../native-url-remap';
import { __, _n, sprintf } from '../../i18n';
import { trackedFetch } from '../../tracked-fetch';
+import {
+ openProductStudio,
+ registerWooProductStudio,
+} from './woocommerce-product-studio';
// Registers the `` tag this bundle stamps onto tiles. The
// main desktop bundle defines it too, but this bundle can load into a
// window whose shell bundle hasn't, so it owns its own import.
@@ -63,6 +67,7 @@ interface WooConfig {
restRoot: string;
restNonce: string;
canOrders: boolean;
+ canCreateProducts?: boolean;
/** Whether the viewer may see customer money at all. */
canCustomers?: boolean;
orderBands?: OrderBand[];
@@ -715,29 +720,49 @@ function renderProduct( data: ProductSummary ): Array< HTMLElement | null > {
data.variations,
)
: data.productType;
+ const snapshot = document.createElement( 'div' );
+ snapshot.className = `${ PANEL_CLASS }__row ${ PANEL_CLASS }__row--product-snapshot`;
+ const snapshotLabel = document.createElement( 'dt' );
+ snapshotLabel.className = 'screen-reader-text';
+ snapshotLabel.textContent = __( 'Storefront snapshot', 'desktop-mode' );
+ const snapshotValue = document.createElement( 'dd' );
+ snapshotValue.className = `${ PANEL_CLASS }__product-snapshot`;
+
+ const price = document.createElement( 'span' );
+ price.className = `${ PANEL_CLASS }__product-metric`;
+ const priceLabel = document.createElement( 'small' );
+ priceLabel.textContent = data.onSale
+ ? __( 'Sale price', 'desktop-mode' )
+ : __( 'Price', 'desktop-mode' );
+ const priceValue = priceNode( data.price, data.regular );
+ price.append( priceLabel, priceValue );
+
+ const availability = document.createElement( 'span' );
+ availability.className = `${ PANEL_CLASS }__product-metric`;
+ const availabilityLabel = document.createElement( 'small' );
+ availabilityLabel.textContent = __( 'Availability', 'desktop-mode' );
+ availability.append(
+ availabilityLabel,
+ pill( stock, stockToneFor( data.stockStatus, data.stockLevel ) ),
+ );
+
+ const sold = document.createElement( 'span' );
+ sold.className = `${ PANEL_CLASS }__product-metric`;
+ const soldLabel = document.createElement( 'small' );
+ soldLabel.textContent = __( 'Sold', 'desktop-mode' );
+ const soldValue = document.createElement( 'strong' );
+ soldValue.textContent = sprintf(
+ /* translators: %d: units sold. */
+ __( '%d units', 'desktop-mode' ),
+ Number( data.sold ) || 0,
+ );
+ sold.append( soldLabel, soldValue );
+ snapshotValue.append( price, availability, sold );
+ snapshot.append( snapshotLabel, snapshotValue );
return [
+ snapshot,
row( __( 'SKU', 'desktop-mode' ), data.sku ),
- row(
- data.onSale
- ? __( 'Price (on sale)', 'desktop-mode' )
- : __( 'Price', 'desktop-mode' ),
- priceNode( data.price, data.regular ),
- ),
- row(
- __( 'Stock', 'desktop-mode' ),
- pill( stock, stockToneFor( data.stockStatus, data.stockLevel ) ),
- ),
- row(
- __( 'Sold', 'desktop-mode' ),
- data.sold > 0
- ? sprintf(
- /* translators: %d: units sold. */
- __( '%d units', 'desktop-mode' ),
- data.sold,
- )
- : '',
- ),
row( __( 'Rating', 'desktop-mode' ), rating ),
row( __( 'Type', 'desktop-mode' ), type ),
row(
@@ -1068,14 +1093,14 @@ function renderCustomer( data: CustomerSummary ): Array< HTMLElement | null > {
* doesn't push content around under the pointer.
*/
const PANEL_TITLES: Record< Summary[ 'type' ], string > = {
- product: __( 'Product', 'desktop-mode' ),
+ product: __( 'Product overview', 'desktop-mode' ),
order: __( 'Order', 'desktop-mode' ),
coupon: __( 'Coupon', 'desktop-mode' ),
customer: __( 'Customer', 'desktop-mode' ),
};
const PANEL_ROW_COUNTS: Record< Summary[ 'type' ], number > = {
- product: 8,
+ product: 6,
order: 10,
coupon: 12,
customer: 9,
@@ -1254,6 +1279,79 @@ function decorateCustomerTile( payload: ListTilePayload ): boolean {
return true;
}
+function productTileStatus(
+ facts: ProductRowFacts,
+): { label: string; tone: BadgeTone } {
+ if ( facts.stockStatus === 'outofstock' ) {
+ return { label: __( 'Out of stock', 'desktop-mode' ), tone: 'danger' };
+ }
+ if ( facts.stockStatus === 'onbackorder' ) {
+ return { label: __( 'On backorder', 'desktop-mode' ), tone: 'warning' };
+ }
+ if ( facts.stockLevel !== null ) {
+ return {
+ label: sprintf(
+ /* translators: %d: units available. */
+ __( '%d in stock', 'desktop-mode' ),
+ facts.stockLevel,
+ ),
+ tone: facts.stockLevel <= LOW_STOCK_THRESHOLD ? 'warning' : 'success',
+ };
+ }
+ return { label: __( 'In stock', 'desktop-mode' ), tone: 'success' };
+}
+
+/** Paint a compact commerce readout over a product thumbnail. */
+function stampProductMeta( tile: HTMLElement ): void {
+ if ( tile.dataset.wooProduct !== 'true' ) {
+ return;
+ }
+ const host = tile.querySelector< HTMLElement >( '.os-file-tile__visual' );
+ if ( ! host || host.querySelector( '.os-woo-product-tile__meta' ) ) {
+ return;
+ }
+ const stockStatus = tile.dataset.wooStockStatus ?? 'instock';
+ const rawLevel = tile.dataset.wooStockLevel ?? '';
+ const stockLevel = rawLevel === '' ? null : Number( rawLevel );
+ let categories: string[] = [];
+ try {
+ const parsed = JSON.parse( tile.dataset.wooCategories ?? '[]' );
+ categories = Array.isArray( parsed ) ? parsed.map( String ) : [];
+ } catch {
+ categories = [];
+ }
+ const status = productTileStatus( {
+ stockStatus,
+ stockLevel: Number.isFinite( stockLevel ) ? stockLevel : null,
+ onSale: tile.dataset.wooOnSale === 'true',
+ categories,
+ } );
+
+ tile.classList.add( 'os-woo-product-tile' );
+ const meta = document.createElement( 'span' );
+ meta.className = 'os-woo-product-tile__meta';
+ const badge = pill( status.label, status.tone );
+ badge.setAttribute( 'aria-hidden', 'true' );
+ meta.appendChild( badge );
+ if ( categories[ 0 ] ) {
+ const category = document.createElement( 'span' );
+ category.className = 'os-woo-product-tile__category';
+ category.textContent = categories[ 0 ];
+ meta.appendChild( category );
+ }
+ host.appendChild( meta );
+ const productName = tile.getAttribute( 'label' ) ?? '';
+ tile.setAttribute(
+ 'aria-label',
+ sprintf(
+ /* translators: 1: product name, 2: inventory status. */
+ __( '%1$s — %2$s', 'desktop-mode' ),
+ productName,
+ status.label,
+ ),
+ );
+}
+
/**
* Put the band badge back on a tile that remembers it.
*
@@ -1329,6 +1427,14 @@ addAction(
if ( ! facts ) {
return;
}
+ payload.tile.dataset.wooProduct = 'true';
+ payload.tile.dataset.wooStockStatus = facts.stockStatus;
+ payload.tile.dataset.wooStockLevel = facts.stockLevel === null
+ ? ''
+ : String( facts.stockLevel );
+ payload.tile.dataset.wooOnSale = String( facts.onSale );
+ payload.tile.dataset.wooCategories = JSON.stringify( facts.categories ?? [] );
+ stampProductMeta( payload.tile );
// An out-of-stock product is the one thing a merchant must not
// miss while scanning the grid, so it gets a badge on the tile
@@ -1410,6 +1516,7 @@ addAction(
( payload: { tile: HTMLElement } ) => {
stampRibbon( payload.tile );
stampCustomerBand( payload.tile );
+ stampProductMeta( payload.tile );
},
);
@@ -1421,6 +1528,29 @@ addAction(
if ( ! type ) {
return;
}
+ if (
+ type === 'product' &&
+ payload.slot === 'footer' &&
+ getConfig()?.canCreateProducts
+ ) {
+ const action = document.createElement( 'div' );
+ action.className = 'os-woo-product-create-action';
+ const copy = document.createElement( 'span' );
+ const title = document.createElement( 'strong' );
+ title.textContent = __( 'Ready to add another product?', 'desktop-mode' );
+ const body = document.createElement( 'small' );
+ body.textContent = __( 'Product Studio keeps the essentials focused.', 'desktop-mode' );
+ copy.append( title, body );
+ const create = document.createElement( 'os-button' );
+ create.setAttribute( 'variant', 'holo' );
+ create.textContent = __( 'Add new product', 'desktop-mode' );
+ create.addEventListener( 'click', () => {
+ openProductStudio();
+ } );
+ action.append( copy, create );
+ payload.container.appendChild( action );
+ return;
+ }
// One panel per preview. A thing goes above its content
// (`header`); a *person* goes below their name and face
// (`meta`) — putting money above someone's avatar reads as a
@@ -2378,3 +2508,5 @@ nativeWindowRegistry.openStationNativeWindows[ CUSTOMER_WINDOW_ID ] = (
document.removeEventListener( 'os-window-reopened', onReopen );
};
};
+
+registerWooProductStudio();
diff --git a/tests/phpunit/tests/myWordpressWoocommerce.php b/tests/phpunit/tests/myWordpressWoocommerce.php
index 9cac34c4b..c284305e9 100644
--- a/tests/phpunit/tests/myWordpressWoocommerce.php
+++ b/tests/phpunit/tests/myWordpressWoocommerce.php
@@ -127,6 +127,114 @@ public function test_no_routes_without_woocommerce() {
$this->assertArrayNotHasKey( '/desktop-mode/v1/woocommerce/orders', $routes );
$this->assertArrayNotHasKey( '/desktop-mode/v1/woocommerce/store', $routes );
+ $this->assertArrayNotHasKey( '/desktop-mode/v1/woocommerce/product-studio', $routes );
+ $this->assertArrayNotHasKey( '/desktop-mode/v1/woocommerce/products', $routes );
+ }
+
+ /**
+ * Product Studio is a WooCommerce surface, not a generic product
+ * editor. It must disappear completely when WooCommerce is absent.
+ *
+ * @covers ::openstation_my_wordpress_woo_product_studio_register_window
+ */
+ public function test_no_product_studio_window_without_woocommerce() {
+ openstation_my_wordpress_woo_product_studio_register_window();
+
+ $this->assertArrayNotHasKey(
+ 'desktop-mode-woo-product-studio',
+ (array) openstation_native_window_registry()
+ );
+ $this->assertArrayNotHasKey(
+ 'desktop-mode-woo-product-studio',
+ (array) openstation_desktop_icon_registry()
+ );
+ }
+
+ /**
+ * @covers ::openstation_my_wordpress_woo_product_studio_template
+ */
+ public function test_product_studio_template_keeps_its_mount_point() {
+ ob_start();
+ openstation_my_wordpress_woo_product_studio_template();
+ $html = (string) ob_get_clean();
+
+ $this->assertStringContainsString( 'data-os-woo-product-studio-root', $html );
+ }
+
+ /**
+ * Category ids cross the multipart boundary as JSON. The server
+ * accepts existing product-category terms and rejects stale ids.
+ *
+ * @covers ::openstation_my_wordpress_woo_product_studio_categories
+ */
+ public function test_product_studio_category_parser_checks_live_terms() {
+ register_taxonomy( 'product_cat', 'post' );
+ $term = self::factory()->term->create(
+ array(
+ 'taxonomy' => 'product_cat',
+ 'name' => 'Lighting',
+ )
+ );
+
+ $this->assertSame(
+ array( $term ),
+ openstation_my_wordpress_woo_product_studio_categories( wp_json_encode( array( $term ) ) )
+ );
+ $this->assertWPError(
+ openstation_my_wordpress_woo_product_studio_categories( '[999999]' )
+ );
+ $this->assertWPError(
+ openstation_my_wordpress_woo_product_studio_categories( '{not-json}' )
+ );
+ $this->assertWPError(
+ openstation_my_wordpress_woo_product_studio_categories( '["not-an-id"]' )
+ );
+ $this->assertWPError(
+ openstation_my_wordpress_woo_product_studio_categories( '[0]' )
+ );
+
+ unregister_taxonomy( 'product_cat' );
+ }
+
+ /**
+ * Retry protection depends on a client-generated UUID rather than
+ * product fields such as SKU, which is optional.
+ *
+ * @covers ::openstation_my_wordpress_woo_product_studio_request_id
+ */
+ public function test_product_studio_request_ids_are_strict_uuids() {
+ $this->assertSame(
+ '123e4567-e89b-42d3-a456-426614174000',
+ openstation_my_wordpress_woo_product_studio_request_id( '123E4567-E89B-42D3-A456-426614174000' )
+ );
+ $this->assertWPError(
+ openstation_my_wordpress_woo_product_studio_request_id( 'retry-me' )
+ );
+ $this->assertWPError(
+ openstation_my_wordpress_woo_product_studio_request_id( '' )
+ );
+ }
+
+ /**
+ * The option-backed lock closes the race between two requests that
+ * both look up the UUID before either product has finished saving.
+ *
+ * @covers ::openstation_my_wordpress_woo_product_studio_acquire_lock
+ * @covers ::openstation_my_wordpress_woo_product_studio_release_lock
+ */
+ public function test_product_studio_request_lock_is_atomic_and_releasable() {
+ $request_id = '123e4567-e89b-42d3-a456-426614174000';
+ $lock_name = openstation_my_wordpress_woo_product_studio_acquire_lock( $request_id );
+
+ $this->assertIsString( $lock_name );
+ $this->assertWPError(
+ openstation_my_wordpress_woo_product_studio_acquire_lock( $request_id )
+ );
+
+ openstation_my_wordpress_woo_product_studio_release_lock( $lock_name );
+ $second_lock = openstation_my_wordpress_woo_product_studio_acquire_lock( $request_id );
+ $this->assertIsString( $second_lock );
+ openstation_my_wordpress_woo_product_studio_release_lock( $second_lock );
}
/**
diff --git a/tests/vitest/my-wordpress-woocommerce.test.ts b/tests/vitest/my-wordpress-woocommerce.test.ts
index 8c4135b3d..e150ffd51 100644
--- a/tests/vitest/my-wordpress-woocommerce.test.ts
+++ b/tests/vitest/my-wordpress-woocommerce.test.ts
@@ -28,6 +28,10 @@ import {
unregisterNativeUrlRemap,
} from '../../src/native-url-remap';
import type { OsSettingsSnapshot } from '../../src/settings/registry';
+import {
+ validateProductDraft,
+ type ProductDraft,
+} from '../../src/my-wordpress/integrations/woocommerce-product-studio';
const PRODUCTS = 'cpt-product';
const CUSTOMERS = 'wc-customers';
@@ -41,6 +45,7 @@ function setConfig( extra: Record< string, unknown > = {} ): void {
restRoot: 'http://example.test/wp-json/desktop-mode/v1/woocommerce/',
restNonce: 'nonce',
canOrders: true,
+ canCreateProducts: true,
canCustomers: true,
customerBands: [
{ id: 'vip', label: 'VIP', order: 10, count: 2 },
@@ -66,6 +71,24 @@ function setConfig( extra: Record< string, unknown > = {} ): void {
};
}
+function validProductDraft(): ProductDraft {
+ return {
+ name: 'Signal Desk Lamp',
+ shortDescription: 'A focused pool of warm light.',
+ description: 'A compact desk lamp for late-night work.',
+ kind: 'physical',
+ regularPrice: '89',
+ salePrice: '',
+ sku: 'SIGNAL-01',
+ manageStock: true,
+ stockQuantity: '12',
+ stockStatus: 'instock',
+ categoryIds: [],
+ imageFile: null,
+ imagePreviewUrl: '',
+ };
+}
+
/**
* Put a spy on `wp.os.openWindow` without disturbing `wp.hooks` —
* replacing the whole `wp` global takes the filter bus with it, and
@@ -99,6 +122,10 @@ function stubSummary( body: unknown, status = 200 ): void {
/** Fire the tile-decoration action the bundle subscribes to. */
function decorate( item: Record< string, unknown > ): HTMLElement {
const tile = document.createElement( 'div' );
+ tile.setAttribute( 'label', 'Test product' );
+ const visual = document.createElement( 'span' );
+ visual.className = 'os-file-tile__visual';
+ tile.appendChild( visual );
document.body.appendChild( tile );
doAction( 'os.my-wordpress.list-tile', {
tile,
@@ -126,10 +153,12 @@ describe( 'my-wordpress — WooCommerce integration', () => {
beforeEach( () => {
setConfig();
stubSummary( {} );
+ window.sessionStorage.clear();
} );
afterEach( () => {
document.body.innerHTML = '';
+ window.sessionStorage.clear();
vi.unstubAllGlobals();
vi.restoreAllMocks();
} );
@@ -212,6 +241,8 @@ describe( 'my-wordpress — WooCommerce integration', () => {
);
expect( tile.querySelector( 'os-ribbon' ) ).toBeNull();
+ expect( tile.classList ).toContain( 'os-woo-product-tile' );
+ expect( tile.textContent ).toContain( '40 in stock' );
} );
test( 'the ribbon is restored after the tile repaints', () => {
@@ -246,6 +277,311 @@ describe( 'my-wordpress — WooCommerce integration', () => {
} );
} );
+ describe( 'Product Studio', () => {
+ test( 'drafts need a name but may be saved before pricing', () => {
+ const draft = validProductDraft();
+ draft.name = '';
+ draft.regularPrice = '';
+
+ expect( validateProductDraft( draft, 'draft-save' ) ).toEqual( {
+ name: 'Give the product a name.',
+ } );
+ } );
+
+ test( 'publish rejects a sale price that is not lower', () => {
+ const draft = validProductDraft();
+ draft.salePrice = '89';
+
+ expect( validateProductDraft( draft, 'publish' ) ).toEqual( {
+ salePrice: 'Sale price must be lower than the regular price.',
+ } );
+ } );
+
+ test( 'tracked inventory requires a whole-number quantity', () => {
+ const draft = validProductDraft();
+ draft.stockQuantity = '2.5';
+
+ expect( validateProductDraft( draft, 'publish' ) ).toEqual( {
+ stockQuantity: 'Enter a whole-number stock quantity of zero or more.',
+ } );
+ } );
+
+ test( 'prices and stock reject scientific notation', () => {
+ const draft = validProductDraft();
+ draft.regularPrice = '1e3';
+ draft.stockQuantity = '1e2';
+
+ expect( validateProductDraft( draft, 'publish' ) ).toEqual( {
+ regularPrice: 'Enter a valid regular price.',
+ stockQuantity: 'Enter a whole-number stock quantity of zero or more.',
+ } );
+ } );
+
+ test( 'the Woo folder carries a focused creation launcher', () => {
+ const openWindow = vi.fn( () => true );
+ stubOpenWindow( openWindow );
+ const container = document.createElement( 'div' );
+
+ doAction( 'os.my-wordpress.group-extras', {
+ container,
+ groupId: 'plugin:woocommerce',
+ entityIds: [ PRODUCTS ],
+ } );
+
+ const launcher = container.querySelector(
+ '.os-woo-product-studio-launcher',
+ );
+ expect( launcher?.textContent ).toContain( 'Put a new product on the shelf' );
+ launcher?.querySelector( 'os-button' )?.dispatchEvent(
+ new MouseEvent( 'click', { bubbles: true } ),
+ );
+ expect( openWindow ).toHaveBeenCalledWith(
+ 'desktop-mode-woo-product-studio',
+ { source: 'my-wordpress/woocommerce' },
+ );
+ } );
+
+ test( 'the native window loads bootstrap data and paints a live preview', async () => {
+ stubSummary( {
+ categories: [
+ { id: 4, name: 'Lighting', parent: 0, count: 2 },
+ ],
+ currencyCode: 'USD',
+ currencySymbol: '$',
+ priceDecimals: 2,
+ canPublish: true,
+ maxImageBytes: 2000000,
+ maxImageLabel: '2 MB',
+ placeholderUrl: 'http://example.test/product-placeholder.png',
+ } );
+ const registry = (
+ window as unknown as {
+ openStationNativeWindows?: Record<
+ string,
+ ( body: HTMLElement, ctx?: { signal?: AbortSignal } ) => unknown
+ >;
+ }
+ ).openStationNativeWindows;
+ const body = document.createElement( 'div' );
+ const root = document.createElement( 'div' );
+ root.setAttribute( 'data-os-woo-product-studio-root', '' );
+ body.appendChild( root );
+ document.body.appendChild( body );
+ const controller = new AbortController();
+ const cleanup = registry?.[ 'desktop-mode-woo-product-studio' ]?.(
+ body,
+ { signal: controller.signal },
+ ) as ( () => void ) | undefined;
+
+ await vi.waitFor( () => {
+ if ( ! root.querySelector( '.os-woo-product-studio__stage-heading' ) ) {
+ throw new Error( 'studio still loading' );
+ }
+ } );
+
+ expect( root.textContent ).toContain( 'Tell shoppers what makes it special' );
+ expect(
+ root.querySelector( '.os-woo-product-studio__preview-frame' ),
+ ).not.toBeNull();
+ expect(
+ root.querySelector( '.os-woo-product-studio__editor-panel' ),
+ ).not.toBeNull();
+ expect(
+ root.querySelector< HTMLImageElement >(
+ '.os-woo-product-studio__product-media img',
+ )?.src,
+ ).toBe( 'http://example.test/product-placeholder.png' );
+ cleanup?.();
+ } );
+
+ test( 'recovers a same-tab draft and warns when its image must be reselected', async () => {
+ window.sessionStorage.setItem(
+ 'openstation/woocommerce-product-studio-draft:v1',
+ JSON.stringify( {
+ version: 1,
+ requestId: '123e4567-e89b-42d3-a456-426614174000',
+ stepIndex: 0,
+ hadImage: true,
+ draft: {
+ ...validProductDraft(),
+ imageFile: undefined,
+ imagePreviewUrl: undefined,
+ },
+ } ),
+ );
+ stubSummary( {
+ categories: [],
+ currencyCode: 'USD',
+ currencySymbol: '$',
+ priceDecimals: 2,
+ canPublish: true,
+ maxImageBytes: 2000000,
+ maxImageLabel: '2 MB',
+ placeholderUrl: 'http://example.test/product-placeholder.png',
+ } );
+ const registry = (
+ window as unknown as {
+ openStationNativeWindows?: Record<
+ string,
+ ( body: HTMLElement, ctx?: { signal?: AbortSignal } ) => unknown
+ >;
+ }
+ ).openStationNativeWindows;
+ const body = document.createElement( 'div' );
+ const root = document.createElement( 'div' );
+ root.setAttribute( 'data-os-woo-product-studio-root', '' );
+ body.appendChild( root );
+ document.body.appendChild( body );
+ const cleanup = registry?.[ 'desktop-mode-woo-product-studio' ]?.( body ) as
+ | ( () => void )
+ | undefined;
+
+ await vi.waitFor( () => {
+ if ( ! root.textContent?.includes( 'Draft recovered.' ) ) {
+ throw new Error( 'draft not restored' );
+ }
+ } );
+
+ expect( root.textContent ).toContain( 'Recovered' );
+ expect(
+ root.querySelector( 'os-text-field[label="Product name"]' )?.getAttribute( 'value' ),
+ ).toBe( 'Signal Desk Lamp' );
+ cleanup?.();
+ } );
+
+ test( 'sends one idempotent request when save is clicked twice', async () => {
+ const requestId = '123e4567-e89b-42d3-a456-426614174000';
+ window.sessionStorage.setItem(
+ 'openstation/woocommerce-product-studio-draft:v1',
+ JSON.stringify( {
+ version: 1,
+ requestId,
+ stepIndex: 3,
+ hadImage: false,
+ draft: {
+ ...validProductDraft(),
+ imageFile: undefined,
+ imagePreviewUrl: undefined,
+ },
+ } ),
+ );
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce( new Response( JSON.stringify( {
+ categories: [],
+ currencyCode: 'USD',
+ currencySymbol: '$',
+ priceDecimals: 2,
+ canPublish: true,
+ maxImageBytes: 2000000,
+ maxImageLabel: '2 MB',
+ placeholderUrl: 'http://example.test/product-placeholder.png',
+ } ), { status: 200 } ) )
+ .mockResolvedValueOnce( new Response( JSON.stringify( {
+ id: 44,
+ name: 'Signal Desk Lamp',
+ status: 'draft',
+ price: '$89.00',
+ editUrl: 'http://example.test/wp-admin/post.php?post=44',
+ viewUrl: '',
+ thumbnail: '',
+ } ), { status: 201 } ) );
+ vi.stubGlobal( 'fetch', fetchMock );
+ const registry = (
+ window as unknown as {
+ openStationNativeWindows?: Record< string, ( body: HTMLElement ) => unknown >;
+ }
+ ).openStationNativeWindows;
+ const body = document.createElement( 'div' );
+ const root = document.createElement( 'div' );
+ root.setAttribute( 'data-os-woo-product-studio-root', '' );
+ body.appendChild( root );
+ document.body.appendChild( body );
+ const cleanup = registry?.[ 'desktop-mode-woo-product-studio' ]?.( body ) as
+ | ( () => void )
+ | undefined;
+
+ await vi.waitFor( () => {
+ if ( ! root.textContent?.includes( 'Launch' ) ) {
+ throw new Error( 'review step not ready' );
+ }
+ } );
+ const save = [ ...root.querySelectorAll( 'os-button' ) ].find(
+ ( item ) => item.textContent === 'Save draft',
+ );
+ save?.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) );
+ save?.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) );
+
+ await vi.waitFor( () => {
+ expect( fetchMock ).toHaveBeenCalledTimes( 2 );
+ expect( window.sessionStorage.getItem(
+ 'openstation/woocommerce-product-studio-draft:v1',
+ ) ).toBeNull();
+ } );
+ const init = fetchMock.mock.calls[ 1 ][ 1 ] as RequestInit;
+ expect( ( init.body as FormData ).get( 'requestId' ) ).toBe( requestId );
+ cleanup?.();
+ } );
+
+ test( 'final validation keeps its error when returning to the price step', async () => {
+ const draft = validProductDraft();
+ draft.regularPrice = '';
+ window.sessionStorage.setItem(
+ 'openstation/woocommerce-product-studio-draft:v1',
+ JSON.stringify( {
+ version: 1,
+ requestId: '123e4567-e89b-42d3-a456-426614174000',
+ stepIndex: 3,
+ hadImage: false,
+ draft: {
+ ...draft,
+ imageFile: undefined,
+ imagePreviewUrl: undefined,
+ },
+ } ),
+ );
+ stubSummary( {
+ categories: [],
+ currencyCode: 'USD',
+ currencySymbol: '$',
+ priceDecimals: 2,
+ canPublish: true,
+ maxImageBytes: 2000000,
+ maxImageLabel: '2 MB',
+ placeholderUrl: 'http://example.test/product-placeholder.png',
+ } );
+ const registry = (
+ window as unknown as {
+ openStationNativeWindows?: Record< string, ( body: HTMLElement ) => unknown >;
+ }
+ ).openStationNativeWindows;
+ const body = document.createElement( 'div' );
+ const root = document.createElement( 'div' );
+ root.setAttribute( 'data-os-woo-product-studio-root', '' );
+ body.appendChild( root );
+ document.body.appendChild( body );
+ const cleanup = registry?.[ 'desktop-mode-woo-product-studio' ]?.( body ) as
+ | ( () => void )
+ | undefined;
+
+ await vi.waitFor( () => {
+ if ( ! root.textContent?.includes( 'Publish product' ) ) {
+ throw new Error( 'review step not ready' );
+ }
+ } );
+ const publish = [ ...root.querySelectorAll( 'os-button' ) ].find(
+ ( item ) => item.textContent === 'Publish product',
+ );
+ publish?.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) );
+
+ expect( root.textContent ).toContain( 'Enter a valid regular price.' );
+ expect( root.textContent ).toContain( 'The offer' );
+ expect( document.activeElement?.classList ).toContain(
+ 'os-woo-product-studio__error',
+ );
+ cleanup?.();
+ } );
+ } );
+
describe( 'preview panel', () => {
/** Fire the preview slot and wait for the panel to settle. */
async function paint( entityId = PRODUCTS ): Promise< HTMLElement > {
@@ -315,6 +651,9 @@ describe( 'my-wordpress — WooCommerce integration', () => {
expect( text ).toContain( 'SHOE-42' );
expect( text ).toContain( '231 units' );
expect( text ).toContain( '4.2' );
+ expect(
+ container.querySelector( '.os-woo-panel__product-snapshot' ),
+ ).not.toBeNull();
// Stock reads through ``, not a bespoke pill.
expect(
container.querySelector( 'os-badge' )?.getAttribute( 'tone' ),
@@ -370,7 +709,7 @@ describe( 'my-wordpress — WooCommerce integration', () => {
expect( container.children ).toHaveLength( 0 );
} );
- test( 'only the header slot paints a panel', () => {
+ test( 'the product footer offers a direct creation action', () => {
const container = document.createElement( 'div' );
doAction( 'os.my-wordpress.preview-extras', {
slot: 'footer',
@@ -380,7 +719,10 @@ describe( 'my-wordpress — WooCommerce integration', () => {
item: { id: 7 },
} );
- expect( container.children ).toHaveLength( 0 );
+ expect( container.textContent ).toContain( 'Add new product' );
+ expect(
+ container.querySelector( '.os-woo-product-create-action' ),
+ ).not.toBeNull();
} );
} );