From dad5ead534911d9d7bb8beea60417cbceb30ea63 Mon Sep 17 00:00:00 2001 From: e3mrah <81884938+emrahbaysal@users.noreply.github.com> Date: Mon, 4 May 2026 11:52:17 +0400 Subject: [PATCH] feat(wizard): Marketplace mode step (#710 wave 3a) (#725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inserts StepMarketplace between StepComponents and StepDomain so the operator can opt the new Sovereign into a multi-tenant SaaS platform during provisioning. The toggle drives store.marketplaceEnabled, which StepReview now ships in the POST /v1/deployments body — the catalyst-api Request struct + OpenTofu var.marketplace_enabled + cloud-init Flux substitute + bp-catalyst-platform ingress.marketplace.enabled values were all wired earlier (PR #719); this PR is the missing UI seam. Brand fields (name / tagline / primary colour) persist on the wizard state so a future settings page can read them without re-prompting on every wizard run. The chart only consumes the enabled flag for now. Wizard step list grows from 7 to 8 stops (StepMarketplace at id=6, shifting Domain → 7 and Review → 8). WizardLayout test updated to assert the new count; the existing pre-existing StepComponents test failures (CORTEX cascade) and the @tabler/icons-react typecheck error are untouched and unrelated. Companion PRs (other agents): post-launch settings page + catalog publish/unpublish admin. This is 1 of 3 parallel pieces on #710 wave 3. Co-authored-by: hatiyildiz Co-authored-by: Claude Opus 4.7 (1M context) --- .../ui/src/app/layouts/WizardLayout.test.tsx | 12 +- .../ui/src/app/layouts/WizardLayout.tsx | 21 +- .../ui/src/entities/deployment/model.ts | 42 +++ .../ui/src/entities/deployment/store.test.ts | 36 +++ .../ui/src/entities/deployment/store.ts | 39 +++ .../ui/src/pages/wizard/WizardPage.tsx | 14 +- .../pages/wizard/steps/StepMarketplace.tsx | 302 ++++++++++++++++++ .../ui/src/pages/wizard/steps/StepReview.tsx | 116 ++++++- 8 files changed, 567 insertions(+), 15 deletions(-) create mode 100644 products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepMarketplace.tsx diff --git a/products/catalyst/bootstrap/ui/src/app/layouts/WizardLayout.test.tsx b/products/catalyst/bootstrap/ui/src/app/layouts/WizardLayout.test.tsx index cb727934..044b2287 100644 --- a/products/catalyst/bootstrap/ui/src/app/layouts/WizardLayout.test.tsx +++ b/products/catalyst/bootstrap/ui/src/app/layouts/WizardLayout.test.tsx @@ -14,8 +14,9 @@ * • The OpenOva logo lives inside the header (anchored on the brand * `Link`). * - * • Exactly seven step indicators render (StepOrg → StepReview), - * matching the wizard's seven-step waterfall. + * • Exactly eight step indicators render (StepOrg → StepReview), + * matching the wizard's eight-step waterfall (Marketplace step + * was inserted between Components and Domain — issue #710 wave 3a). * * • The active step gets the `active` class and `aria-current="step"`, * so screen readers and visual regression tests both confirm the @@ -107,14 +108,15 @@ describe('WizardLayout — page-header refactor (#174)', () => { expect(within(header).getByText('OpenOva')).toBeTruthy() }) - it('renders exactly seven step indicators inside the header', async () => { + it('renders exactly eight step indicators inside the header', async () => { renderLayout() const header = await screen.findByTestId('wizard-header') const stepper = within(header).getByTestId('wizard-stepper') - // Each step is a button — 7 buttons match WIZARD_STEPS.length. + // Each step is a button — 8 buttons match WIZARD_STEPS.length + // (Marketplace step inserted between Components and Domain — #710). const stepButtons = within(stepper).getAllByRole('button') expect(stepButtons).toHaveLength(WIZARD_STEPS.length) - expect(WIZARD_STEPS.length).toBe(7) + expect(WIZARD_STEPS.length).toBe(8) // Every step exposes a stable testid so visual regression tests can // pin to it without relying on text content. for (const step of WIZARD_STEPS) { diff --git a/products/catalyst/bootstrap/ui/src/app/layouts/WizardLayout.tsx b/products/catalyst/bootstrap/ui/src/app/layouts/WizardLayout.tsx index 172e21e4..8765df25 100644 --- a/products/catalyst/bootstrap/ui/src/app/layouts/WizardLayout.tsx +++ b/products/catalyst/bootstrap/ui/src/app/layouts/WizardLayout.tsx @@ -10,7 +10,7 @@ import { ProfileMenu } from '@/widgets/auth/ProfileMenu' import { consumeProvisionFlashBanner } from '@/shared/lib/flashBanner' /** - * Wizard step list — seven progress stops in dependency order. StepSuccess + * Wizard step list — eight progress stops in dependency order. StepSuccess * and StepNSDelegation are post-provisioning destinations after StepReview * launches provisioning; they are not part of the visible progress, so * they are not in this list. @@ -27,14 +27,24 @@ import { consumeProvisionFlashBanner } from '@/shared/lib/flashBanner' * 4. Credentials — once each region has a provider, collect the API * token each chosen provider needs, plus the SSH key. * 5. Components — platform building-block selection. - * 6. Domain — pool subdomain or BYO domain + admin email. - * 7. Review — POST body preview + launch. + * 6. Marketplace — opt into Marketplace mode (issue #710 wave 3a). The + * toggle decides whether the Sovereign exposes a + * per-tenant SaaS storefront at + * marketplace.. + * 7. Domain — pool subdomain or BYO domain + admin email. + * 8. Review — POST body preview + launch. * * Topology BEFORE Provider is the dependency-correct order: a provider is * a per-region property, picked AFTER topology decides how many regions * exist. SKU choices belong INSIDE the provider step because every cloud * has its own instance-type vocabulary (see shared/constants/providerSizes.ts). * + * Marketplace is positioned AFTER Components and BEFORE Domain on purpose: + * the operator has just curated the apps that will populate the storefront, + * and the next step (Domain) names the Sovereign — making the marketplace + * URL preview (`marketplace.`) meaningful in either order without a + * forward-reference. + * * After Review, the post-provisioning steps (StepSuccess, StepNSDelegation) * run on the same wizard state machine but live OUTSIDE the visible * progress indicator — they are operator-facing handover gates, not user @@ -46,8 +56,9 @@ export const WIZARD_STEPS = [ { id: 3, label: 'Provider', desc: 'Cloud + region + sizing per slot' }, { id: 4, label: 'Credentials', desc: 'API tokens + SSH key' }, { id: 5, label: 'Components', desc: 'Platform building blocks' }, - { id: 6, label: 'Domain', desc: 'Pool or BYO + admin email' }, - { id: 7, label: 'Review', desc: 'Confirm and provision' }, + { id: 6, label: 'Marketplace', desc: 'Multi-tenant SaaS storefront' }, + { id: 7, label: 'Domain', desc: 'Pool or BYO + admin email' }, + { id: 8, label: 'Review', desc: 'Confirm and provision' }, ] /** diff --git a/products/catalyst/bootstrap/ui/src/entities/deployment/model.ts b/products/catalyst/bootstrap/ui/src/entities/deployment/model.ts index cb06e75c..6bd00f50 100644 --- a/products/catalyst/bootstrap/ui/src/entities/deployment/model.ts +++ b/products/catalyst/bootstrap/ui/src/entities/deployment/model.ts @@ -87,6 +87,24 @@ export interface SovereignPoolDomain { description: string } +/** + * Marketplace brand — purely cosmetic per-Sovereign theming for the + * `marketplace.` storefront when the operator opts into + * Marketplace mode (issue #710 wave 3a). All three fields are optional; + * the chart only consumes `marketplaceEnabled` today, but the brand + * fields persist on the wizard state so a future settings page can + * surface them without re-prompting on every wizard run. Values that are + * empty strings mean "use the platform default" — never "unset". + */ +export interface MarketplaceBrand { + /** Display name shown in the storefront header (e.g. "Acme Marketplace"). */ + name: string + /** Short tagline rendered under the storefront name. */ + tagline: string + /** CSS-style hex colour (e.g. "#38BDF8") used for the storefront accent. */ + primaryColor: string +} + export interface WizardState { orgName: string; orgDomain: string; orgEmail: string; orgIndustry: string orgSize: string; orgHeadquarters: string; orgCompliance: string[] @@ -211,6 +229,24 @@ export interface WizardState { selectedBlueprints: string[] regions: Region[] controlPlaneSize: NodeSize; workerSize: NodeSize; workerCount: number; haEnabled: boolean + /** + * Marketplace mode toggle (issue #710 wave 3a). When true, the + * provisioner sets `marketplace_enabled=true` on the OpenTofu var that + * cloud-init substitutes into the bp-catalyst-platform HelmRelease, + * which in turn renders the marketplace HTTPRoutes (storefront + + * per-tenant subdomain shells). The backend Request struct + * (`provisioner.Request.MarketplaceEnabled`) and the chart values + * (`ingress.marketplace.enabled`) are already wired — this flag is the + * UI seam. + */ + marketplaceEnabled: boolean + /** + * Optional brand fields shown on `marketplace.` when + * Marketplace mode is on. Never required — empty strings mean "use the + * platform default banner". Persisted in the wizard store so a return + * visit (or a future settings page) doesn't lose them. + */ + marketplaceBrand: MarketplaceBrand /** * Selected platform components from the corporate StepComponents grid * (the 60+ catalog defined in pages/wizard/steps/componentGroups.ts). @@ -434,6 +470,12 @@ export const INITIAL_WIZARD_STATE: WizardState = { // literal that won't pass validation against a non-hetzner provider. regions: [], controlPlaneSize: '', workerSize: '', workerCount: 0, haEnabled: false, selectedComponents: [...computeDefaultSelection()].sort(), + // Marketplace mode (issue #710 wave 3a) — opt-in. Defaults off so a + // first-run wizard provisions a private Sovereign; the operator can + // flip the toggle on StepMarketplace before launch, and a future + // settings page can flip it post-launch without touching the wizard. + marketplaceEnabled: false, + marketplaceBrand: { name: '', tagline: '', primaryColor: '' }, airgap: false, currentStep: 1, completedSteps: [], deploymentId: null, lastProvisionResult: null, diff --git a/products/catalyst/bootstrap/ui/src/entities/deployment/store.test.ts b/products/catalyst/bootstrap/ui/src/entities/deployment/store.test.ts index ecab863e..09c17d37 100644 --- a/products/catalyst/bootstrap/ui/src/entities/deployment/store.test.ts +++ b/products/catalyst/bootstrap/ui/src/entities/deployment/store.test.ts @@ -90,6 +90,42 @@ describe('wizard store — registrar credentials', () => { }) }) +describe('wizard store — marketplace mode (#710 wave 3a)', () => { + it('defaults marketplaceEnabled to false and brand to empty strings', () => { + const s = useWizardStore.getState() + expect(s.marketplaceEnabled).toBe(false) + expect(s.marketplaceBrand).toEqual({ name: '', tagline: '', primaryColor: '' }) + }) + + it('setMarketplaceEnabled flips the flag without touching the brand', () => { + useWizardStore.setState({ + ...INITIAL_WIZARD_STATE, + marketplaceBrand: { name: 'Acme', tagline: 'Apps for the regulated cloud', primaryColor: '#38BDF8' }, + }) + useWizardStore.getState().setMarketplaceEnabled(true) + const s = useWizardStore.getState() + expect(s.marketplaceEnabled).toBe(true) + expect(s.marketplaceBrand).toEqual({ + name: 'Acme', + tagline: 'Apps for the regulated cloud', + primaryColor: '#38BDF8', + }) + }) + + it('setMarketplaceBrand merges partial updates into the existing brand', () => { + useWizardStore.setState({ + ...INITIAL_WIZARD_STATE, + marketplaceBrand: { name: 'Acme', tagline: 'old tagline', primaryColor: '#000000' }, + }) + useWizardStore.getState().setMarketplaceBrand({ tagline: 'new tagline' }) + expect(useWizardStore.getState().marketplaceBrand).toEqual({ + name: 'Acme', + tagline: 'new tagline', + primaryColor: '#000000', + }) + }) +}) + describe('wizard store — persistence hygiene', () => { it('drops registrarToken and registrarTokenValidated from the persist payload', () => { useWizardStore.setState({ diff --git a/products/catalyst/bootstrap/ui/src/entities/deployment/store.ts b/products/catalyst/bootstrap/ui/src/entities/deployment/store.ts index 17f86821..75e1d76d 100644 --- a/products/catalyst/bootstrap/ui/src/entities/deployment/store.ts +++ b/products/catalyst/bootstrap/ui/src/entities/deployment/store.ts @@ -9,6 +9,7 @@ import { type NodeSize, type TopologyTemplate, type ProvisionResult, + type MarketplaceBrand, } from './model' import { findComponent, @@ -182,6 +183,13 @@ interface WizardActions { setWorkerSize: (size: NodeSize) => void setWorkerCount: (count: number) => void setHaEnabled: (enabled: boolean) => void + + // Step — Marketplace (issue #710 wave 3a). Both setters are simple + // mirrors of the setHaEnabled pattern; the brand setter takes a + // partial so callers can edit one field at a time. + setMarketplaceEnabled: (enabled: boolean) => void + setMarketplaceBrand: (brand: Partial) => void + toggleComponent: (component: SelectedComponent) => void setComponents: (components: SelectedComponent[]) => void } @@ -491,6 +499,19 @@ export const useWizardStore = create()( setWorkerSize: (workerSize) => set({ workerSize }, false, 'wizard/setWorkerSize'), setWorkerCount: (workerCount) => set({ workerCount }, false, 'wizard/setWorkerCount'), setHaEnabled: (haEnabled) => set({ haEnabled }, false, 'wizard/setHaEnabled'), + + // Marketplace mode — issue #710 wave 3a. Toggle persists via the + // existing deploy-request → tofu var → cloud-init → Flux substitute + // → chart render path; no ConfigMap shortcut. + setMarketplaceEnabled: (marketplaceEnabled) => + set({ marketplaceEnabled }, false, 'wizard/setMarketplaceEnabled'), + setMarketplaceBrand: (brand) => + set( + (s) => ({ marketplaceBrand: { ...s.marketplaceBrand, ...brand } }), + false, + 'wizard/setMarketplaceBrand', + ), + toggleComponent: (component) => // Legacy action — kept for back-compat with any old call site that // hands a SelectedComponent record. Internally we just toggle the @@ -737,6 +758,24 @@ export const useWizardStore = create()( if (p.lastProvisionResult === undefined) { p.lastProvisionResult = null } + // Marketplace fields added in #710 wave 3a — coerce missing + // values on a legacy persisted payload so StepMarketplace and + // StepReview never crash on undefined. + if (typeof p.marketplaceEnabled !== 'boolean') { + p.marketplaceEnabled = false + } + if ( + !p.marketplaceBrand || + typeof p.marketplaceBrand !== 'object' + ) { + p.marketplaceBrand = { name: '', tagline: '', primaryColor: '' } + } else { + p.marketplaceBrand = { + name: typeof p.marketplaceBrand.name === 'string' ? p.marketplaceBrand.name : '', + tagline: typeof p.marketplaceBrand.tagline === 'string' ? p.marketplaceBrand.tagline : '', + primaryColor: typeof p.marketplaceBrand.primaryColor === 'string' ? p.marketplaceBrand.primaryColor : '', + } + } // SSH-key fields added after first install (#160) — coerce missing // values so the StepCredentials SSH section renders cleanly on a // legacy persisted payload. diff --git a/products/catalyst/bootstrap/ui/src/pages/wizard/WizardPage.tsx b/products/catalyst/bootstrap/ui/src/pages/wizard/WizardPage.tsx index a27a297e..b932df88 100644 --- a/products/catalyst/bootstrap/ui/src/pages/wizard/WizardPage.tsx +++ b/products/catalyst/bootstrap/ui/src/pages/wizard/WizardPage.tsx @@ -7,6 +7,7 @@ import { StepTopology } from './steps/StepTopology' import { StepProvider } from './steps/StepProvider' import { StepCredentials } from './steps/StepCredentials' import { StepComponents } from './steps/StepComponents' +import { StepMarketplace } from './steps/StepMarketplace' import { StepReview } from './steps/StepReview' import { StepSuccess } from './steps/StepSuccess' import { StepNSDelegation } from './steps/StepNSDelegation' @@ -24,10 +25,14 @@ import { StepNSDelegation } from './steps/StepNSDelegation' // why sizing lives here, not in topology. // 4. StepCredentials — API tokens (per chosen provider) + SSH key. // 5. StepComponents — unified marketplace catalog. -// 6. StepDomain — pool subdomain or BYO domain + admin email. -// 7. StepReview — single source of truth for the POST body. -// 8. StepSuccess — provisioning result. -// 9. StepNSDelegation — post-handover parent-zone NS delegation +// 6. StepMarketplace — opt into Marketplace mode (issue #710 wave 3a). +// The toggle decides whether the Sovereign exposes +// a per-tenant SaaS storefront at +// marketplace.. +// 7. StepDomain — pool subdomain or BYO domain + admin email. +// 8. StepReview — single source of truth for the POST body. +// 9. StepSuccess — provisioning result. +// 10. StepNSDelegation — post-handover parent-zone NS delegation // (omantel.omani.works → Sovereign-owned PowerDNS). // Closes #374. Pure runbook-emitter by default; // "auto-apply" toggle gates a stub catalyst-api @@ -38,6 +43,7 @@ const STEPS = [ StepProvider, StepCredentials, StepComponents, + StepMarketplace, StepDomain, StepReview, StepSuccess, diff --git a/products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepMarketplace.tsx b/products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepMarketplace.tsx new file mode 100644 index 00000000..22c8dab6 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepMarketplace.tsx @@ -0,0 +1,302 @@ +/** + * StepMarketplace — Marketplace mode opt-in (issue #710 wave 3a). + * + * Inserted between StepComponents and StepDomain. The toggle decides + * whether the operator wants to turn this Sovereign into a SaaS + * platform (per-tenant subdomains, public storefront at + * `marketplace.`, isolated tenant shells) or keep it + * as a private single-tenant install. + * + * Wiring (already in place — this step is the missing UI seam): + * + * StepMarketplace toggle → store.marketplaceEnabled + * → StepReview POST /v1/deployments + * { …, marketplaceEnabled: true } + * → catalyst-api provisioner.Request.MarketplaceEnabled + * → OpenTofu var marketplace_enabled + * → cloud-init → Flux substitute + * → bp-catalyst-platform values.yaml + * ingress.marketplace.enabled = true + * → marketplace HTTPRoutes rendered + * + * The brand fields (name / tagline / primary colour) are PURELY + * cosmetic and persist on the wizard state so a future settings + * page can read them without re-prompting. The chart only consumes + * the enabled flag for now (companion PRs land settings + catalog + * admin surfaces). + * + * Visual style mirrors StepComponents: StepShell wrapper, card grid + * within `--wiz-bg-sub` surfaces, body fields in `--wiz-bg-input`, + * inline help text in `--wiz-text-sub`. Footer Back/Next buttons are + * published via useStepNav() — same contract every other step uses. + */ + +import { useWizardStore } from '@/entities/deployment/store' +import { resolveSovereignDomain } from '@/entities/deployment/model' +import { StepShell, useStepNav } from './_shared' + +const FIELD_INPUT_STYLE: React.CSSProperties = { + width: '100%', + padding: '0.55rem 0.7rem', + background: 'var(--wiz-bg-input)', + border: '1px solid var(--wiz-border-sub)', + borderRadius: 7, + color: 'var(--wiz-text-hi)', + font: 'inherit', + fontSize: '0.88rem', +} + +const FIELD_LABEL_STYLE: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 4, + fontSize: '0.78rem', + fontWeight: 600, + color: 'var(--wiz-text-md)', + letterSpacing: '0.02em', +} + +const FIELD_HELP_STYLE: React.CSSProperties = { + fontSize: '0.74rem', + fontWeight: 400, + color: 'var(--wiz-text-sub)', + lineHeight: 1.45, +} + +export function StepMarketplace() { + const { next, back } = useStepNav() + const store = useWizardStore() + + const fqdn = resolveSovereignDomain(store) + // Show a stable "marketplace." preview when the user has filled + // in the domain step ahead of this one; otherwise dim-fall back to a + // generic placeholder so the operator still understands what the + // toggle will produce. + const marketplaceURL = fqdn ? `marketplace.${fqdn}` : 'marketplace.' + + const enabled = store.marketplaceEnabled + const brand = store.marketplaceBrand + + return ( + +
+ {/* ── Toggle card ──────────────────────────────────────── */} +
+
+
+ Enable Marketplace mode +
+

+ Turn this Sovereign into a SaaS platform. Customers sign up at{' '} + + {marketplaceURL} + + , get isolated per-tenant subdomains, and consume apps you publish from your + unified catalog. +

+
+ + {/* Pill switch — accessibility role="switch" plus the + floating ball element. Clicking anywhere on the switch + flips the store flag. */} + +
+ + {/* ── Brand fields (revealed when enabled) ─────────────── */} + {enabled && ( +
+
+

+ Storefront branding +

+

+ Optional. All three fields can be left blank — the storefront falls back to + platform defaults, and you can edit them later from the post-launch Settings + page without re-running the wizard. +

+
+ +
+ + + + + +
+
+ )} +
+
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepReview.tsx b/products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepReview.tsx index 023b1298..1625e567 100644 --- a/products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepReview.tsx +++ b/products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepReview.tsx @@ -667,6 +667,17 @@ export function StepReview() { objectStorageRegion: store.objectStorageRegion, objectStorageAccessKey: store.objectStorageAccessKey, objectStorageSecretKey: store.objectStorageSecretKey, + // Marketplace mode (issue #710 wave 3a) — captured by + // StepMarketplace. The catalyst-api Request struct already + // accepts `marketplaceEnabled` and threads it through to + // OpenTofu via var.marketplace_enabled (PR #719); from there + // cloud-init substitutes it into the bp-catalyst-platform + // HelmRelease values (`ingress.marketplace.enabled`). The + // brand fields are captured for forward-compat with the + // post-launch settings page (companion PR) and ignored by + // the chart for now. + marketplaceEnabled: store.marketplaceEnabled, + marketplaceBrand: store.marketplaceBrand, }), }) const data = await res.json() @@ -934,7 +945,110 @@ export function StepReview() { `} - {/* ── 6. Domain ────────────────────────────────────────── */} + {/* ── 6. Marketplace (issue #710 wave 3a) ──────────────── */} +
+ Marketplace + + {store.marketplaceEnabled ? 'Enabled' : 'Disabled'} + + + } + testId="review-section-marketplace" + > + {store.marketplaceEnabled ? ( + + + marketplace.{sovereignFQDN} + + ) : ( + + — resolved from Domain step — + + ) + } + /> + + + + + + {store.marketplaceBrand.primaryColor} + + + ) : ( + — platform default — + ) + } + /> + + ) : ( +

+ Single-tenant private Sovereign. No storefront, no per-tenant subdomains. You can + flip Marketplace mode on later from the post-launch Settings page without + re-provisioning. +

+ )} +
+ + {/* ── 7. Domain ────────────────────────────────────────── */}