Read-only per-epic slice. The canonical source of truth is BRD.md — stories are addressed by US-ID, not by this page's line numbers.
Epic 4 — Enable subscription on a BC product (derived view)
Read-only per-epic slice of
BRD.md§9, lines 1614–1948. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-4.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (5): US-4.1, US-4.2, US-4.3, US-4.4, US-4.5
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 4 — Enable subscription on a BC product
<!-- traceability:start:BRD:Epic-4 --><!-- traceability:end:BRD:Epic-4 -->Prototype: Product Panel · Plan Wizard · Plan Activated
Value: Any existing BC product becomes subscribable through an App Extension panel without SKU duplication.
Epic context. The "subscription-enable a product" flow is the core moment of value for the merchant — the first time they see the product materialize. Quality here correlates with long-term engagement.
US-4.1: "Subscriptions" panel on a BC product
<!-- traceability:start:US-4.1 --><!-- traceability:end:US-4.1 -->Prototype: Product Panel
Phase: MVP · Priority: P0 · Effort: M · Persona: Merchant Admin
As a Merchant Admin, I want to see a "Subscriptions" panel on the BC product edit page, so that I enable subscriptions in context without leaving the BC admin.
Acceptance criteria:
- Given I open any BC product, When the product edit page renders, Then the "Subscriptions" App Extension panel appears showing: current plan status (none / draft / active), offered intervals, pricing strategy, eligibility summary.
- Given no plan is configured, When I click the panel, Then a "Configure subscription plan" CTA opens the plan wizard.
UX notes.
- Surface: BC product edit page → App Extension PANEL context on Products model
- States: no plan | draft plan | active plan | paused plan (plan existed but merchant deactivated it)
- Content: current state banner, offered intervals (pills), pricing strategy summary (e.g., "Subscribe and save 10%"), eligibility summary (e.g., "Monthly only, all variants"), "Edit plan" CTA, link to the subscribers list filtered to this plan
- Empty state: illustrative hero + "Configure subscription plan" CTA
Data contract.
- BC App Extension: registered during install (US-1.3), PANEL context, model PRODUCTS
- Panel URL:
/stores/[storeHash]/extensions/products/{productId}— JWT-loaded - Our API: GET
/api/v1/products/{bc_product_id}/plan - Our DB:
plansquery bybc_product_id - Events:
plan.panel_viewed(telemetry)
Success metrics.
- Functional (target): 100% of BC product edit pages render the panel in < 1s
- Product: ≥ 40% of merchants who view the panel create a plan in the same session (target; revise after 90d)
- Operational (target): panel load P95 < 1s
Dependencies.
- US-1.3 (App Extension registration)
- US-4.2 (wizard — where the CTA leads)
Non-functional.
- Panel iframe must be keyboard-navigable and screen-reader friendly
- BC's iframe context requires partitioned cookies (see US-1.2)
UI states.
<!-- ui-states US-4.1 -->surface: "Admin (React/BigDesign) App Extension PANEL on the BC product edit page — apps/admin/src/pages/products/ProductDetail.tsx (header comment 'Epic 4 / US-4.1 PANEL surface'). Persona: Merchant Admin. Loads the product's plans via getPlansForProduct (ProductDetail.tsx:106) and renders a single plan-status badge (none / draft / active / paused) plus a state-specific summary and deactivate/resume CTAs."
idle:
render: "A Panel headed 'Subscription plan' carrying a status Badge that is one of four states (ProductDetail.tsx:273-293): active (success Badge → ActivePlanState, lines 361-425, showing cadence/price/trial/plan-id/created SummaryRows + a destructive 'Deactivate' Button), paused (secondary Badge → PausedState, lines 469-510, with 'Resume signups' + 'Deactivate'), draft (warning Badge → DraftState, lines 430-463, with 'Resume editing'), or none (secondary Badge → NoPlanState, lines 344-359, with 'Configure subscription plan'). A breadcrumb 'All products' anchor sits above the title (lines 232-241) and an 'Edit in BC' external anchor beside it (lines 245-254)."
primary_action: "State-dependent: active → 'Deactivate' opens the US-4.4 DeactivationModal (line 419, setDeactivateOpen(true)); paused → 'Resume signups' (handleResumeSignups, line 179) re-enables new signups; none/draft → navigate to the plan wizard (lines 309/313)."
loading:
rehydrate: "On a catalog cache miss (deep-link / refresh to an uncached product) getAdminProduct runs and while rehydrating the panel shows a header+body 'Loading…' Panel (ProductDetail.tsx:121-129) — no spinner, text only."
plans: "While plans === null the same 'Loading…' Panel renders (ProductDetail.tsx:156-164) until getPlansForProduct resolves."
action: "While a resume/deactivate action is in flight (deactivating === true) the PausedState 'Resume signups' Button shows its isLoading spinner and both PausedState buttons are disabled (lines 501-504); the DeactivationModal confirm Button is isLoading and all modal actions disabled (lines 582/591-592). No double-submit (handleResumeSignups/confirmDeactivate early-return when deactivating, lines 180/196)."
error:
surfaced_at: "NORTH-STAR: a resume/deactivate action failure surfaces inline within the plan Panel (role=alert), scoped to the failing plan, leaving the panel and its other controls intact. TODAY (gap): the shared `error` state is set by the action catch (handleResumeSignups setError, line 189) and triggers the top-level early-return error Panel (ProductDetail.tsx:148-154) which REPLACES the whole product-detail view with a bare 'merchantAdmin.products.detail.error.header' Panel + raw <Text color='danger'>{error}</Text>."
recovery: "NORTH-STAR: the inline error offers 'Retry' on the same action with the panel still rendered. TODAY (gap): the full-page error Panel carries NO button or link, so the only recovery is a browser back / manual reload — a dead-end. Initial load failures (rehydrate/plans-fetch) legitimately use this full-page Panel, but action failures inherit the same dead-end path."
empty:
render: "When the product has no plan (no active/draft plan found, lines 175-176) NoPlanState renders (lines 344-359): explanatory body, the product description, and a primary 'Configure subscription plan' CTA — the panel is never a blank pane."
cta: "'Configure subscription plan' → navigates to /v2/products/{id}/wizard (line 313)."
edge_status:
- status: "draft — a plan exists at status='draft' (wizard started, never activated)"
affordance: "DraftState renders a warning Badge + 'Resume editing' Button → returns to the wizard with the saved snapshot (DraftState onResume, lines 307-310,457-459)"
- status: "paused — active plan with accepting_new_subscribers=0 (the 'stop new signups' deactivation outcome)"
affordance: "PausedState renders a secondary 'Paused' Badge + 'Resume signups' (handleResumeSignups → resumePlan, lines 179-193) to re-enable new signups; existing subscribers are unaffected"
- status: "product_not_found — server confirms the BC product id does not resolve (not just a cache miss)"
affordance: "A 'not found' Panel renders with a 'Back to products' secondary Button → /v2/products (ProductDetail.tsx:132-145)"
inputs: []
disabled_focus:
keyboard: "All actions are real BigDesign <Button> elements wrapping native <button> (Configure / Resume signups / Resume editing / Deactivate / Back) — no div-onClick CTA dead-ends; each is Tab-reachable with a visible focus ring and Enter/Space-activatable. The 'All products' breadcrumb and 'Edit in BC' are real <a> anchors (Tab-reachable; the breadcrumb is href='#' with onClick preventDefault, lines 232-241). The 'Resume signups' Button is disabled (removed from tab order) while busy (line 501)."
focus_move: "On a successful action the panel re-renders to the new state and a persistent status Message appears, but focus is NOT moved to it and it carries no aria-live region (statusToast Message, lines 319-327) — a screen-reader Merchant gets no announcement of the state change."
gaps: "(1) Shared `error` state conflates initial-load errors (full-page Panel is appropriate) with resume/deactivate ACTION errors (should be inline-scoped) — an action failure replaces the entire panel with a bare dead-end error Panel that has no retry/back affordance (lines 148-154 reached via setError at 189/223). (2) statusToast is named like a toast but is actually a PERSISTENT inline BigDesign Message with an onClose X and NO auto-dismiss timer (lines 319-327) — it persists until manual close or navigation, so there is no vanishing-toast risk; it does, however, lack aria-live, so the success is silent to screen readers."
US-4.2: Subscription-enable wizard
<!-- traceability:start:US-4.2 --><!-- traceability:end:US-4.2 -->Prototype: Plan Wizard · Plan Activated
Phase: MVP · Priority: P0 · Effort: L · Persona: Merchant Admin
As a Merchant Admin, I want a step-by-step wizard to enable subscriptions on a product, so that I don't have to learn a complex form.
Acceptance criteria:
- Given I click "Configure," When the wizard opens, Then it has steps: Interval(s) → Pricing → Optional extras (trial, commitment, max cycles) → Eligibility → Review.
- Given I complete all steps, When I click "Activate," Then the plan is saved, the product is flagged subscribable, and the storefront widget renders on the PDP.
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-4.2 -->surface: "/v2/products/[bcProductId]/wizard (apps/admin/src/pages/products/PlanWizard.tsx) — React/BigDesign 6-step plan-enable wizard: Intervals → Pricing → Options → Eligibility → Scope → Review."
idle:
render: "A StepBar of six numbered circles (active circle blue, completed circles green with a CheckIcon) sits above the current step's BigDesign Panel/Form, with a footer row of 'Back', 'Cancel', 'Save draft', and a primary 'Continue' button (PlanWizard.tsx:357-513)."
primary_action: "Advance with the primary 'Continue' button, which becomes 'Activate' on the final Review step; 'Continue' is disabled until the step's canAdvance gate passes (PlanWizard.tsx:230-251, 502-510)."
loading:
trigger: "On 'Activate'/'Save draft', createPlan() POSTs /api/v1/plans with submitting=true (PlanWizard.tsx:265-331); the eligibility step's getProductVariants() and the mount-time getCurrencies() also resolve asynchronously."
render: "The 'Activate' button shows its BigDesign isLoading spinner and all four footer buttons are disabled while submitting, blocking a double-submit (PlanWizard.tsx:484-510); the restricted variant list shows an inline 'Loading variants…' line (PlanWizard.tsx:647-650)."
error:
surfaced_at: "Inline in the wizard body above the footer as a BigDesign Message type='error' for activate/draft failures (PlanWizard.tsx:472-480), and inline inside the eligibility FormGroup for a variant-fetch failure (PlanWizard.tsx:639-646) — never a toast that vanishes."
render: "The submit error renders the createPlan failure text verbatim under the localized header merchantAdmin.products.wizard.error.header; the variant error renders the fetch failure text under merchantAdmin.products.wizard.eligibility.variantsError."
recovery: "submitting resets to false on failure (PlanWizard.tsx:328-330) so the merchant corrects input and re-clicks 'Activate'/'Save draft' from the same step; a currencies-fetch failure is non-blocking — the wizard keeps the 'USD' default and hides the currency selector (PlanWizard.tsx:177-183)."
empty:
render: "Not a list surface — the wizard is a multi-step form, so its steps never render blank. The one empty case is the restricted variant list: when scope='restricted' and the BC Catalog proxy returns zero variants, a BigDesign Message type='info' renders the 'variantsAuthRequired' copy when source='fallback_empty' (no stored OAuth token) or the 'variantsEmpty' copy otherwise (PlanWizard.tsx:651-664)."
cta: "n/a as a list surface — the empty variant Message directs the merchant to switch eligibility back to the 'All variants' radio or to authorize the store; the all-eligible default keeps the plan creatable."
inputs:
- field: "currency"
control: "select"
allowed_values: "store-enabled ISO-4217 currency codes loaded from BC /v2/currencies via GET /api/v1/admin/currencies, defaulting to the store primary (is_default:true)."
note: "A labeled BigDesign Select inside a FormGroup (PlanWizard.tsx:546-562), shown only when the store has more than one enabled currency (PlanWizard.tsx:415); single-currency stores see no selector and inherit the primary currency."
- field: "pricingStrategy"
control: "radio"
note: "BigDesign Radio group — 'Fixed discount %', 'Price List', or 'Fixed price' — that swaps the pricing sub-form per selection (PricingForm.tsx:53-127)."
- field: "eligibilityScope"
control: "radio"
note: "BigDesign Radio pair — 'All variants' vs 'Restricted' — gating whether the variant checkbox list reveals (PlanWizard.tsx:618-634)."
edge_status:
- status: "draft — the merchant clicks 'Save draft' at any step (submit('draft'), PlanWizard.tsx:334)."
badge: "Draft"
affordance: "createPlan persists status='draft' and navigates to the product detail page (PlanWizard.tsx:318-323), where DraftState renders a real 'Resume editing' BigDesign Button (ProductDetail.tsx:457-459) whose onResume reopens this same wizard (ProductDetail.tsx:307-309)."
- status: "step_invalid — the active step's canAdvance gate is unmet (no plan name/interval, discount outside 1–50%, missing price-list id, or fixed price ≤ 0) (PlanWizard.tsx:230-251)."
badge: "Incomplete step"
affordance: "The primary 'Continue' button renders disabled — never hidden (PlanWizard.tsx:507); the merchant completes the flagged field on the same step to re-enable it, and 'Back' preserves all prior-step state (PlanWizard.tsx:257-260)."
- status: "restricted_no_variant — eligibility scope is 'restricted' but zero variants are selected (PlanWizard.tsx:246-248)."
badge: "Select a variant"
affordance: "A warning Small ('select at least one variant') renders under the list (PlanWizard.tsx:683-687) and 'Continue' stays disabled; the merchant ticks at least one variant checkbox or switches to the 'All variants' radio."
- status: "variants_unavailable — the BC variant fetch returns an empty list, typically source='fallback_empty' when no store OAuth token is present (PlanWizard.tsx:651-664)."
badge: "Variants unavailable"
affordance: "The info Message explains authorization is required; the merchant authorizes/connects the store or selects the 'All variants' scope to proceed without per-variant restriction."
- status: "product_not_found — the :bcProductId route param resolves to no product (PlanWizard.tsx:214-228)."
badge: "Product not found"
affordance: "A Panel renders the not-found body with a secondary 'Back to products' button that navigates to /v2/products (PlanWizard.tsx:223-225)."
disabled_focus:
keyboard: "Every interactive control across all six steps is a real BigDesign control reached in tab order: the footer 'Back'/'Cancel'/'Save draft'/'Continue'/'Activate' are real <Button>s carrying the disabled attribute (PlanWizard.tsx:484-510); the intervals step uses a FormGroup-wrapped Input + Checkbox list (IntervalsForm.tsx:38-69), the pricing step a FormGroup-wrapped Radio + Input + Counter (PricingForm.tsx:53-127), the options step BigDesign Switch + Counter (OptionsForm.tsx:49,73,93,113,139), the eligibility step Radio + Checkbox list (PlanWizard.tsx:617-705), and the scope step FormGroup-wrapped Checkbox lists for channels/customer-groups/countries (ScopeStep.tsx:138-139,234-235,291-292,395-449); the currency picker is a labeled Select in a FormGroup (PlanWizard.tsx:546-562) and the product breadcrumb is a real <a> reachable by Tab (PlanWizard.tsx:339-351). There are no div-onClick rows and no bare unlabeled inputs anywhere in the wizard — the StepBar circles are presentational <div>s with no handler, so they are not keyboard traps."
focus_move: "NORTH-STAR target: on each step transition (next()/back(), PlanWizard.tsx:253-260) focus moves to the new step's Panel heading or its first field and the change is announced via an aria-live region, while the active StepCircle carries aria-current='step'; on the eligibility 'restricted' reveal (PlanWizard.tsx:637) and the currency-selector reveal (PlanWizard.tsx:415) focus enters the newly revealed region. NONE of this is implemented today — see gap notes."
guard: "'Activate' requires an explicit click on the final Review step (isLast, PlanWizard.tsx:502-505), and plan creation is reversible (a plan can be deactivated per US-4.4) so no typed-confirm is needed; the canAdvance gate blocks advancing past an invalid step, and submitting disables the whole footer to prevent a double-submit (PlanWizard.tsx:484-510)."
UX notes.
- Surface: full-page wizard (5 steps) in
/stores/[storeHash]/plans/new?product=X - Steps (with progress indicator):
- Intervals — checkbox list (Monthly, Every 2 weeks, Custom); add-custom interval sub-form
- Pricing — radio: Fixed discount % | Price List | Fixed price; form changes per selection
- Options — trial (days), commitment (min_cycles), max cycles, lock-price-at-creation
- Eligibility — variant selector; links to advanced eligibility rules (Epic 26)
- Review & activate — summary + "Activate" button
- Inline validation per step; "Back" preserves state
- Save-as-draft at any step (stored in
planswithstatus: draft)
Data contract.
- Our API: POST
/api/v1/planson activate; PATCH on draft save - BC API calls on activation:
- POST
/v3/catalog/products/{id}/metafieldsto create (PUT only on the item path/v3/catalog/products/{id}/metafields/{metafield_id}to update) — writesubscription.enabled,subscription.plan_idas stringvalues (catalog metafields store strings only; there is no typedvalue_typefield) - (If Price List strategy) verify Price List exists via
GET /v3/pricelists/{id}
- POST
- Events:
plan.created,plan.activated
Success metrics.
- Functional (target): activation results in storefront widget rendering within 60s (cache propagation)
- Product (target): wizard completion rate ≥ 75% (started → activated)
- Operational (target): P95 activation latency < 2s
Dependencies.
- US-4.1 (entry point)
- US-8.1 (storefront widget — the payoff)
Non-functional.
- Wizard state stored client-side + auto-saved server-side every 30s
- Accessibility: all form fields labeled, error messages announced
Risks / open questions.
- Merchants may want templates/presets ("Coffee brand starter plan"). Defer to P2.
US-4.3: Variant-level subscription enablement
<!-- traceability:start:US-4.3 --><!-- traceability:end:US-4.3 -->Prototype: Plan Wizard
Phase: MVP · Persona: Merchant Admin
As a Merchant Admin, I want to choose which variants of a product are subscribable, so that I can exclude sizes/flavors that aren't appropriate.
Acceptance criteria:
- Given a product has multiple variants, When I reach the eligibility step, Then I see all variants with checkboxes, defaulting to "all eligible."
- Given I uncheck a variant, When I activate, Then the storefront widget hides the subscribe option for that variant.
UI states.
<!-- ui-states US-4.3 -->surface: "Admin (React/BigDesign) plan-config wizard — Eligibility step (step 4 of 6) in apps/admin/src/pages/products/PlanWizard.tsx (STEP_KEYS index 3, lines 84/449-455; EligibilityStep component lines 576-708). Persona: Merchant Admin. An all-vs-restricted Radio choice; when 'restricted', a per-variant Checkbox list fetched from the BC Catalog proxy via getProductVariants (line 591)."
idle:
render: "A Panel ('eligibility.panelHeader') with two real BigDesign <Radio> options inside a FormGroup (PlanWizard.tsx:620-633): 'All variants eligible' (default checked — data.eligibilityScope defaults to 'all', line 149) and 'Restricted to specific variants'. Choosing 'restricted' reveals a second FormGroup that fetches variants and renders a vertical <Checkbox> list, one per variant (renderVariantLabel option+price), with help text showing the total count (lines 665-688). A static Epic-26 info Message is always shown at the foot (lines 693-704)."
primary_action: "The wizard footer 'Continue' primary Button advances to the Scope step; it is disabled until the step's canAdvance gate passes (lines 507, 230-251)."
loading:
variants: "When scope flips to 'restricted' and variants === null, a 'variantsLoading' <Small> placeholder renders (PlanWizard.tsx:647-650) while getProductVariants is in flight — no spinner, text only; the fetch is cached locally so toggling back and forth does not re-fetch (effect guard line 589)."
submit: "On the Review step, activate()/saveDraft() set submitting=true; the 'Activate' Button shows isLoading and Back/Cancel/Save-draft are disabled (lines 484/491/498/503). No double-submit (submit early-returns when submitting, line 266)."
error:
surfaced_at: "Variant-load failure renders an inline BigDesign Message(type='error', header 'variantsError') in the restricted FormGroup, scoped to the variant picker (PlanWizard.tsx:639-646). A create/draft submit failure renders a separate inline Message(type='error', header 'wizard.error.header') in the wizard footer above the nav buttons (lines 472-480)."
recovery: "Submit failure: setSubmitting(false) re-enables the populated wizard (line 329) so the Merchant edits and re-activates. Variant-load failure NORTH-STAR: a 'Retry' control re-runs getProductVariants. TODAY (gap): there is no retry button and variantsError is never cleared, so the only practical recovery is to switch back to 'All variants eligible' (which needs no variant list) and proceed, or reload the page."
empty:
render: "When the restricted fetch returns zero variants, an info Message renders (PlanWizard.tsx:651-664): 'variantsAuthRequired' when source==='fallback_empty' (no OAuth token on file) or 'variantsEmpty' otherwise — the picker is never a blank pane."
cta: "n/a as a collection CTA — the Merchant can switch the Radio back to 'All variants eligible' to proceed without a variant list."
edge_status:
- status: "restricted selected with zero variants checked — invalid (an empty allow-list would fail every signup at the order-create webhook)"
affordance: "A warning <Small> 'atLeastOne' renders beneath the checkbox list (lines 683-687) AND the footer 'Continue' Button is disabled by the canAdvance gate (lines 246-247, 507) until at least one variant is checked"
- status: "variants source = fallback_empty — the store has no OAuth token so the live variant list could not be fetched"
affordance: "An info Message ('variantsAuthRequired') explains auth is required (lines 657-661); the Merchant can complete the OAuth install or keep scope='All variants eligible' to proceed"
inputs:
- field: "eligibility_scope"
control: "radio"
allowed_values: "all | restricted — two BigDesign <Radio> options (PlanWizard.tsx:620-633); defaults to 'all'"
- field: "eligible_variant_ids"
control: "checkbox"
allowed_values: "the product's bc_variant_ids returned by getProductVariants; rendered one <Checkbox> per variant, toggled by toggleVariant (lines 606-613, 674-681). Only shown when scope='restricted'."
disabled_focus:
keyboard: "The two scope <Radio>s, every variant <Checkbox>, and the footer Back/Cancel/Save-draft/Continue Buttons are real BigDesign components wrapping native focusable inputs/buttons — Tab-reachable in DOM order, arrow/Space to toggle radios/checkboxes, Enter/Space to activate buttons; no div-onClick dead-ends. The radios carry real intl labels (lines 621-633), and 'Continue' is natively disabled (removed from tab order) when canAdvance is false."
focus_move: "When variants finish loading or a validation warning appears, the newly revealed checkbox list / 'atLeastOne' warning is inserted with no aria-live and no programmatic focus move — a screen-reader Merchant is not announced the list or the gate."
gaps: "Variant-load error is sticky and has no in-UI retry: variantsError is never reset to null, and the render ternary checks variantsError FIRST (line 639), so even after toggling back to 'restricted' (which does re-fire getProductVariants because variants is still null) a subsequent successful fetch still shows the stale error Message. The honest recovery is 'switch to All variants eligible' or reload."
US-4.4: Deactivate subscription plan
<!-- traceability:start:US-4.4 --><!-- traceability:end:US-4.4 -->Prototype: Product Panel
Phase: MVP · Persona: Merchant Admin
As a Merchant Admin, I want to deactivate a plan on a product, so that new subscribers can't sign up while existing subscribers continue.
Acceptance criteria:
- Given a plan is active, When I click "Deactivate," Then I am warned about active subscribers and asked to choose: stop new signups / end all existing subs at next cycle / cancel all immediately.
- Given I choose "Stop new signups," When the change saves, Then the storefront widget disappears but existing subs continue unaffected.
UI states.
<!-- ui-states US-4.4 -->surface: "Admin (React/BigDesign) deactivation Modal launched from the active/paused plan panel — DeactivationModal in apps/admin/src/pages/products/ProductDetail.tsx (component lines 512-678; opened by setDeactivateOpen(true) at lines 419/505; rendered at lines 329-339). Persona: Merchant Admin. Presents a 3-choice radio for how to deactivate a plan that has active subscribers."
idle:
render: "A BigDesign <Modal variant='dialog'> headed 'deactivate.modalHeader' with a warning row (WarningIcon + 'active subscribers' copy, lines 599-609) and three bordered option Boxes (lines 613-658), each containing a <Radio> plus a bold title and, for the two destructive options, a 'destructive' danger Badge and a 'stub' secondary Badge: 'Stop new signups' (non-destructive, default choice — deactivationChoice defaults to 'stop_new', line 49), 'End all at next cycle' (destructive, stubbed), 'Cancel all immediately' (destructive, stubbed). When a non-stop_new choice is selected an extra warning Message about the stub is shown (lines 661-674)."
primary_action: "The Modal's primary action Button (label = selected option's primaryLabel, actionType destructive for the two end/cancel choices) calls confirmDeactivate (lines 584-593, 195) → pausePlan / endAtNextCycle / cancelImmediately; the 'subtle' Cancel action closes the modal."
loading:
submit: "While confirmDeactivate is in flight (busy/deactivating === true) the primary Button shows isLoading with the 'workingBtn' label and BOTH modal actions are disabled (lines 582,585-592); the modal-level onClose is guarded so the dialog cannot be dismissed mid-flight (lines 334-335). No double-submit (confirmDeactivate early-returns when deactivating, line 196)."
error:
surfaced_at: "NORTH-STAR: a deactivate failure surfaces inline INSIDE the open modal (a role=alert Message above the actions), keeping the modal mounted and the choice intact. TODAY (gap): confirmDeactivate's catch sets the shared `error` state (ProductDetail.tsx:223) and DOES NOT call setDeactivateOpen(false); on re-render the top-level early-return error Panel (lines 148-154) fires, UNMOUNTING the modal and the whole product-detail view and replacing it with a bare 'detail.error.header' Panel + raw <Text color='danger'>{error}</Text>. The DeactivationModal itself has no error slot."
recovery: "NORTH-STAR: the inline modal error offers 'Try again' on the same choice with the modal still open. TODAY (gap): the full-page error Panel has no button/link, so a failed deactivation of a money-path plan dead-ends to a browser back / reload with the modal state lost."
empty:
render: "Not a list surface — a single-plan confirmation dialog. DeactivationModal returns null when plan is null (line 522), so there is no blank-pane state; the launching panel owns the no-plan empty state (NoPlanState, US-4.1)."
cta: "n/a (single-plan confirmation, not a collection)"
edge_status:
- status: "stop_new chosen — non-destructive; pauses new signups while existing subscribers continue"
affordance: "Confirm calls pausePlan; the launching panel re-renders to PausedState with a 'Resume signups' Button (ProductDetail.tsx:203-207, 469-510)"
- status: "end_at_next_cycle / cancel_immediately chosen — destructive AND currently stubbed"
affordance: "A 'destructive' Badge + 'stub' Badge mark the option and a warning Message ('stubWarning') explains the stub before the Merchant proceeds (lines 645-652, 661-674); the Merchant can pick 'Stop new signups' instead, which is fully implemented"
- status: "deactivate failed — the PATCH for the chosen action errored"
affordance: "NORTH-STAR: an inline modal error with 'Try again'. TODAY (gap): the page is replaced by a dead-end full-page error Panel with no recovery control (lines 148-154 via setError at 223)"
inputs:
- field: "deactivation_choice"
control: "radio"
allowed_values: "stop_new | end_at_next_cycle | cancel_immediately — three real BigDesign <Radio>s, one per bordered option (ProductDetail.tsx:524-568, 628-632)"
disabled_focus:
keyboard: "The BigDesign <Modal> traps focus while open and its two action Buttons (Cancel / confirm) are real, Tab-reachable, Enter/Space-activatable buttons (disabled — removed from tab order — while busy). The three choice <Radio>s are real focusable inputs reachable by Tab with arrow/Space selection; the bordered option <Box> is a redundant div-onClick MOUSE target (line 624) but is NOT the only path, so keyboard users are not dead-ended."
focus_move: "On open the modal moves focus into the dialog (BigDesign default); on a failed confirm the (north-star) inline error is not yet implemented so nothing is announced."
gaps: "(1) Deactivate-failure dead-end: the destructive confirm path routes its error through the shared load-`error` state, replacing the whole panel+modal with a bare error Panel that has no retry/back — a money-path action stranding the Merchant (setError line 223 → early-return 148-154). (2) Accessible-name gap: each choice <Radio> is rendered with label='' (line 631) and the visible option title lives in a separate, unassociated <Text> (line 636) — a screen reader hears an unlabeled radio."
US-4.5: Copy plan between products
Phase: P2 · Persona: Merchant Admin
As a Merchant Admin managing a large catalog, I want to copy an existing plan configuration to multiple products, so that I don't repeat the wizard 50 times.
Acceptance criteria:
- Given a plan exists on product A, When I click "Copy to products," Then I select target products via multi-select (with search, filter by category/brand).
- Given I confirm the copy, When the operation completes, Then I see a summary of successful/failed copies and any conflicts (e.g., target already has a plan).
UI states.
<!-- ui-states US-4.5 -->surface: "Admin (React/BigDesign) 'Copy plan to other products' Modal — apps/admin/src/pages/plans/CopyPlanModal.tsx, launched from the plan-edit page header 'Copy to other products' Button (apps/admin/src/pages/plans/PlanEdit.tsx:254-263, data-testid 'plan-edit-copy-cta'; opened via copyModalOpen, rendered lines 272-274). Persona: Merchant Admin. Three phases: select (searchable multi-select product picker), working (POST in flight), summary (per-target succeeded/failed table)."
idle:
render: "A BigDesign <Modal variant='dialog'> headed 'copy.header'. Select phase (renderSelect, CopyPlanModal.tsx:132-213): a search <Input> ('Search by name or SKU…'), then a scrollable list of one <Checkbox> per catalog product with the SOURCE product filtered out (visibleProducts, lines 92-101, 185-194), and a live 'N products selected' count (lines 202-210)."
primary_action: "The Modal's primary 'Copy plan' action (data-testid 'copy-plan-confirm-btn') calls handleConfirm (lines 296-305, 115) → copyPlan(plan.id, selectedIds); it is disabled while zero products are selected (line 303). The 'subtle' Cancel action closes the modal."
loading:
products: "While the catalog is loading (products === null) the select body shows a 'Loading products…' <Text> (CopyPlanModal.tsx:145-153) — no spinner."
working: "NORTH-STAR: the 'working' phase shows a progress spinner and a non-activatable confirm. TODAY (gap): during phase==='working' the modal body still renders renderSelect() (the product list, line 339) with NO spinner and the actions array switches to a single 'Done' Button (lines 307-316) — the Merchant gets no in-flight feedback; the modal-X is guarded against close (lines 322-323) but the 'Done' action's onClick is the raw onClose (line 314) and is not guarded."
error:
surfaced_at: "Catalog-load failure renders an inline BigDesign Message(type='error', header 'copy.error.loadHeader') as the whole select body (CopyPlanModal.tsx:133-143). A copy-POST failure renders an inline Message(type='error') inside the select list (lines 197-201) after handleConfirm resets phase back to 'select' (line 125)."
recovery: "Copy-POST failure: phase returns to 'select' with the picker still populated (line 125), so the Merchant re-selects/retries via 'Copy plan'. Catalog-load failure NORTH-STAR: an in-modal 'Retry' re-runs getAdminProducts. TODAY (gap): the load-error Message has no retry control, so recovery is to Cancel and re-open the modal (which re-mounts and re-fetches)."
empty:
render: "When no products match the search (or the catalog is empty / fallback_empty), the list area shows a 'No products match. Try a different search.' <Small> (CopyPlanModal.tsx:176-182) — never a blank pane."
cta: "Clear/adjust the search <Input> to widen the visibleProducts filter."
edge_status:
- status: "summary — per-target results after the POST, including conflicts (failed[].reason='already_has_plan')"
affordance: "renderSummary (CopyPlanModal.tsx:218-282) lists a 'Copied successfully' group (success Badge + new_plan_id) and a 'Conflicts and failures' group (warning Badge = reason); the Merchant reviews, presses 'Done' to close, fixes the conflicting target, and re-opens to copy again"
- status: "copy POST failed (whole request errored, not per-target)"
affordance: "phase resets to 'select' and an inline error Message shows (lines 124-125, 197-201); the populated picker stays so the Merchant retries 'Copy plan'"
- status: "catalog load failed"
affordance: "inline error Message (lines 133-143); recovery is Cancel + re-open to re-fetch (no in-modal retry — gap)"
inputs:
- field: "product_search"
control: "text"
allowed_values: "n/a — free-text product name/SKU query (CopyPlanModal.tsx:159-172); filters visibleProducts client-side"
- field: "target_products"
control: "checkbox"
allowed_values: "the merchant's catalog products (getAdminProducts, per_page 200) with the source product excluded; one <Checkbox> per product (lines 185-194)"
disabled_focus:
keyboard: "The search <Input>, the per-product <Checkbox>es, and the Modal action Buttons (Cancel / 'Copy plan' / 'Done') are all real BigDesign components wrapping native focusable elements — Tab-reachable inside the focus-trapped dialog, Space to toggle checkboxes, Enter/Space to activate buttons; no div-onClick dead-ends. 'Copy plan' is natively disabled (out of tab order) when zero products are selected (line 303)."
focus_move: "On transition to the summary phase the results render but focus is not moved to them and they carry no aria-live, so a screen-reader Merchant is not announced the success/failure outcome."
gaps: "(1) The 'working' phase has no loading affordance — it re-renders the select list with no spinner and a premature 'Done' button (body line 339; actions 307-316), so a multi-product copy gives no in-flight feedback. (2) MAX_TARGETS exists only as a code comment (lines 73-74); there is no UI cap or warning on selection count, so a Merchant selecting more than the backend bound would hit a silent server-side truncation/reject rather than an explicit cap notice. (3) The catalog-load error has no in-modal retry (recovery is Cancel + re-open)."