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(); } ); } );