feat(wizard): Marketplace mode step (#710 wave 3a) (#725)

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 <hatiyildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-04 11:52:17 +04:00 committed by GitHub
parent f7365de162
commit dad5ead534
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 567 additions and 15 deletions

View File

@ -14,8 +14,9 @@
* The OpenOva logo lives inside the header (anchored on the brand * The OpenOva logo lives inside the header (anchored on the brand
* `Link`). * `Link`).
* *
* Exactly seven step indicators render (StepOrg StepReview), * Exactly eight step indicators render (StepOrg StepReview),
* matching the wizard's seven-step waterfall. * 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"`, * The active step gets the `active` class and `aria-current="step"`,
* so screen readers and visual regression tests both confirm the * 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() 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() renderLayout()
const header = await screen.findByTestId('wizard-header') const header = await screen.findByTestId('wizard-header')
const stepper = within(header).getByTestId('wizard-stepper') 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') const stepButtons = within(stepper).getAllByRole('button')
expect(stepButtons).toHaveLength(WIZARD_STEPS.length) 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 // Every step exposes a stable testid so visual regression tests can
// pin to it without relying on text content. // pin to it without relying on text content.
for (const step of WIZARD_STEPS) { for (const step of WIZARD_STEPS) {

View File

@ -10,7 +10,7 @@ import { ProfileMenu } from '@/widgets/auth/ProfileMenu'
import { consumeProvisionFlashBanner } from '@/shared/lib/flashBanner' 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 * and StepNSDelegation are post-provisioning destinations after StepReview
* launches provisioning; they are not part of the visible progress, so * launches provisioning; they are not part of the visible progress, so
* they are not in this list. * 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 * 4. Credentials once each region has a provider, collect the API
* token each chosen provider needs, plus the SSH key. * token each chosen provider needs, plus the SSH key.
* 5. Components platform building-block selection. * 5. Components platform building-block selection.
* 6. Domain pool subdomain or BYO domain + admin email. * 6. Marketplace opt into Marketplace mode (issue #710 wave 3a). The
* 7. Review POST body preview + launch. * toggle decides whether the Sovereign exposes a
* per-tenant SaaS storefront at
* marketplace.<sovereign-fqdn>.
* 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 * Topology BEFORE Provider is the dependency-correct order: a provider is
* a per-region property, picked AFTER topology decides how many regions * a per-region property, picked AFTER topology decides how many regions
* exist. SKU choices belong INSIDE the provider step because every cloud * exist. SKU choices belong INSIDE the provider step because every cloud
* has its own instance-type vocabulary (see shared/constants/providerSizes.ts). * 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.<fqdn>`) meaningful in either order without a
* forward-reference.
*
* After Review, the post-provisioning steps (StepSuccess, StepNSDelegation) * After Review, the post-provisioning steps (StepSuccess, StepNSDelegation)
* run on the same wizard state machine but live OUTSIDE the visible * run on the same wizard state machine but live OUTSIDE the visible
* progress indicator they are operator-facing handover gates, not user * 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: 3, label: 'Provider', desc: 'Cloud + region + sizing per slot' },
{ id: 4, label: 'Credentials', desc: 'API tokens + SSH key' }, { id: 4, label: 'Credentials', desc: 'API tokens + SSH key' },
{ id: 5, label: 'Components', desc: 'Platform building blocks' }, { id: 5, label: 'Components', desc: 'Platform building blocks' },
{ id: 6, label: 'Domain', desc: 'Pool or BYO + admin email' }, { id: 6, label: 'Marketplace', desc: 'Multi-tenant SaaS storefront' },
{ id: 7, label: 'Review', desc: 'Confirm and provision' }, { id: 7, label: 'Domain', desc: 'Pool or BYO + admin email' },
{ id: 8, label: 'Review', desc: 'Confirm and provision' },
] ]
/** /**

View File

@ -87,6 +87,24 @@ export interface SovereignPoolDomain {
description: string description: string
} }
/**
* Marketplace brand purely cosmetic per-Sovereign theming for the
* `marketplace.<sovereign-fqdn>` 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 { export interface WizardState {
orgName: string; orgDomain: string; orgEmail: string; orgIndustry: string orgName: string; orgDomain: string; orgEmail: string; orgIndustry: string
orgSize: string; orgHeadquarters: string; orgCompliance: string[] orgSize: string; orgHeadquarters: string; orgCompliance: string[]
@ -211,6 +229,24 @@ export interface WizardState {
selectedBlueprints: string[] selectedBlueprints: string[]
regions: Region[] regions: Region[]
controlPlaneSize: NodeSize; workerSize: NodeSize; workerCount: number; haEnabled: boolean 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.<sovereign-fqdn>` 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 * Selected platform components from the corporate StepComponents grid
* (the 60+ catalog defined in pages/wizard/steps/componentGroups.ts). * (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. // literal that won't pass validation against a non-hetzner provider.
regions: [], controlPlaneSize: '', workerSize: '', workerCount: 0, regions: [], controlPlaneSize: '', workerSize: '', workerCount: 0,
haEnabled: false, selectedComponents: [...computeDefaultSelection()].sort(), 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, airgap: false,
currentStep: 1, completedSteps: [], deploymentId: null, currentStep: 1, completedSteps: [], deploymentId: null,
lastProvisionResult: null, lastProvisionResult: null,

View File

@ -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', () => { describe('wizard store — persistence hygiene', () => {
it('drops registrarToken and registrarTokenValidated from the persist payload', () => { it('drops registrarToken and registrarTokenValidated from the persist payload', () => {
useWizardStore.setState({ useWizardStore.setState({

View File

@ -9,6 +9,7 @@ import {
type NodeSize, type NodeSize,
type TopologyTemplate, type TopologyTemplate,
type ProvisionResult, type ProvisionResult,
type MarketplaceBrand,
} from './model' } from './model'
import { import {
findComponent, findComponent,
@ -182,6 +183,13 @@ interface WizardActions {
setWorkerSize: (size: NodeSize) => void setWorkerSize: (size: NodeSize) => void
setWorkerCount: (count: number) => void setWorkerCount: (count: number) => void
setHaEnabled: (enabled: boolean) => 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<MarketplaceBrand>) => void
toggleComponent: (component: SelectedComponent) => void toggleComponent: (component: SelectedComponent) => void
setComponents: (components: SelectedComponent[]) => void setComponents: (components: SelectedComponent[]) => void
} }
@ -491,6 +499,19 @@ export const useWizardStore = create<WizardStore>()(
setWorkerSize: (workerSize) => set({ workerSize }, false, 'wizard/setWorkerSize'), setWorkerSize: (workerSize) => set({ workerSize }, false, 'wizard/setWorkerSize'),
setWorkerCount: (workerCount) => set({ workerCount }, false, 'wizard/setWorkerCount'), setWorkerCount: (workerCount) => set({ workerCount }, false, 'wizard/setWorkerCount'),
setHaEnabled: (haEnabled) => set({ haEnabled }, false, 'wizard/setHaEnabled'), 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) => toggleComponent: (component) =>
// Legacy action — kept for back-compat with any old call site that // Legacy action — kept for back-compat with any old call site that
// hands a SelectedComponent record. Internally we just toggle the // hands a SelectedComponent record. Internally we just toggle the
@ -737,6 +758,24 @@ export const useWizardStore = create<WizardStore>()(
if (p.lastProvisionResult === undefined) { if (p.lastProvisionResult === undefined) {
p.lastProvisionResult = null 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 // SSH-key fields added after first install (#160) — coerce missing
// values so the StepCredentials SSH section renders cleanly on a // values so the StepCredentials SSH section renders cleanly on a
// legacy persisted payload. // legacy persisted payload.

View File

@ -7,6 +7,7 @@ import { StepTopology } from './steps/StepTopology'
import { StepProvider } from './steps/StepProvider' import { StepProvider } from './steps/StepProvider'
import { StepCredentials } from './steps/StepCredentials' import { StepCredentials } from './steps/StepCredentials'
import { StepComponents } from './steps/StepComponents' import { StepComponents } from './steps/StepComponents'
import { StepMarketplace } from './steps/StepMarketplace'
import { StepReview } from './steps/StepReview' import { StepReview } from './steps/StepReview'
import { StepSuccess } from './steps/StepSuccess' import { StepSuccess } from './steps/StepSuccess'
import { StepNSDelegation } from './steps/StepNSDelegation' import { StepNSDelegation } from './steps/StepNSDelegation'
@ -24,10 +25,14 @@ import { StepNSDelegation } from './steps/StepNSDelegation'
// why sizing lives here, not in topology. // why sizing lives here, not in topology.
// 4. StepCredentials — API tokens (per chosen provider) + SSH key. // 4. StepCredentials — API tokens (per chosen provider) + SSH key.
// 5. StepComponents — unified marketplace catalog. // 5. StepComponents — unified marketplace catalog.
// 6. StepDomain — pool subdomain or BYO domain + admin email. // 6. StepMarketplace — opt into Marketplace mode (issue #710 wave 3a).
// 7. StepReview — single source of truth for the POST body. // The toggle decides whether the Sovereign exposes
// 8. StepSuccess — provisioning result. // a per-tenant SaaS storefront at
// 9. StepNSDelegation — post-handover parent-zone NS delegation // marketplace.<sovereign-fqdn>.
// 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). // (omantel.omani.works → Sovereign-owned PowerDNS).
// Closes #374. Pure runbook-emitter by default; // Closes #374. Pure runbook-emitter by default;
// "auto-apply" toggle gates a stub catalyst-api // "auto-apply" toggle gates a stub catalyst-api
@ -38,6 +43,7 @@ const STEPS = [
StepProvider, StepProvider,
StepCredentials, StepCredentials,
StepComponents, StepComponents,
StepMarketplace,
StepDomain, StepDomain,
StepReview, StepReview,
StepSuccess, StepSuccess,

View File

@ -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.<sovereign-fqdn>`, 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.<fqdn>" 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.<your-sovereign-fqdn>'
const enabled = store.marketplaceEnabled
const brand = store.marketplaceBrand
return (
<StepShell
title="Marketplace mode"
description={
'Marketplace mode turns this Sovereign into a multi-tenant SaaS platform. ' +
'You publish apps from your unified catalog, customers sign up at the storefront, ' +
'and each tenant runs in an isolated subdomain shell. Leave it off to provision a ' +
'single-tenant private Sovereign — you can flip the toggle later from Settings.'
}
onNext={next}
onBack={back}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{/* ── Toggle card ──────────────────────────────────────── */}
<div
data-testid="step-marketplace-toggle-card"
style={{
border: `1.5px solid ${enabled ? '#4ADE80' : 'var(--wiz-border-sub)'}`,
borderRadius: 12,
padding: '0.95rem 1.1rem',
display: 'flex',
alignItems: 'flex-start',
gap: '1rem',
transition: 'border-color 0.15s, background 0.15s',
background: enabled
? 'color-mix(in srgb, #4ADE80 6%, var(--wiz-bg-sub))'
: 'var(--wiz-bg-sub)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '0.95rem',
fontWeight: 600,
color: 'var(--wiz-text-hi)',
marginBottom: 4,
}}
>
Enable Marketplace mode
</div>
<p
style={{
margin: 0,
color: 'var(--wiz-text-md)',
fontSize: '0.82rem',
lineHeight: 1.5,
}}
>
Turn this Sovereign into a SaaS platform. Customers sign up at{' '}
<code
style={{
fontFamily: 'JetBrains Mono, monospace',
fontSize: '0.78rem',
color: 'var(--wiz-accent)',
background: 'rgba(56,189,248,0.08)',
padding: '0.05rem 0.35rem',
borderRadius: 4,
}}
>
{marketplaceURL}
</code>
, get isolated per-tenant subdomains, and consume apps you publish from your
unified catalog.
</p>
</div>
{/* Pill switch accessibility role="switch" plus the
floating ball element. Clicking anywhere on the switch
flips the store flag. */}
<button
type="button"
role="switch"
aria-checked={enabled}
data-testid="step-marketplace-toggle"
onClick={() => store.setMarketplaceEnabled(!enabled)}
style={{
flexShrink: 0,
width: 44,
height: 24,
padding: 0,
border: 'none',
borderRadius: 999,
cursor: 'pointer',
position: 'relative',
background: enabled ? '#4ADE80' : 'rgba(148,163,184,0.35)',
transition: 'background 0.15s',
}}
aria-label={enabled ? 'Disable Marketplace mode' : 'Enable Marketplace mode'}
>
<span
aria-hidden
style={{
position: 'absolute',
top: 2,
left: enabled ? 22 : 2,
width: 20,
height: 20,
borderRadius: '50%',
background: '#fff',
transition: 'left 0.18s ease',
boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
}}
/>
</button>
</div>
{/* ── Brand fields (revealed when enabled) ─────────────── */}
{enabled && (
<div
data-testid="step-marketplace-brand"
style={{
background: 'var(--wiz-bg-sub)',
border: '1px solid var(--wiz-border-sub)',
borderRadius: 12,
padding: '1rem 1.1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.85rem',
}}
>
<div>
<h3
style={{
margin: '0 0 0.25rem',
fontSize: '0.88rem',
fontWeight: 600,
color: 'var(--wiz-text-hi)',
}}
>
Storefront branding
</h3>
<p
style={{
margin: 0,
fontSize: '0.78rem',
color: 'var(--wiz-text-sub)',
lineHeight: 1.5,
}}
>
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.
</p>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: '0.7rem',
}}
>
<label style={FIELD_LABEL_STYLE}>
Brand name
<input
type="text"
data-testid="step-marketplace-brand-name"
placeholder="e.g. Acme Marketplace"
value={brand.name}
onChange={(e) => store.setMarketplaceBrand({ name: e.target.value })}
style={FIELD_INPUT_STYLE}
maxLength={64}
/>
<span style={FIELD_HELP_STYLE}>Shown in the storefront header.</span>
</label>
<label style={FIELD_LABEL_STYLE}>
Tagline
<input
type="text"
data-testid="step-marketplace-brand-tagline"
placeholder="e.g. Apps for the regulated cloud"
value={brand.tagline}
onChange={(e) => store.setMarketplaceBrand({ tagline: e.target.value })}
style={FIELD_INPUT_STYLE}
maxLength={120}
/>
<span style={FIELD_HELP_STYLE}>One-line subtitle below the brand name.</span>
</label>
<label style={FIELD_LABEL_STYLE}>
Primary colour
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}>
<input
type="text"
data-testid="step-marketplace-brand-color"
placeholder="#38BDF8"
value={brand.primaryColor}
onChange={(e) =>
store.setMarketplaceBrand({ primaryColor: e.target.value })
}
style={{ ...FIELD_INPUT_STYLE, fontFamily: 'JetBrains Mono, monospace' }}
maxLength={7}
pattern="^#?[0-9A-Fa-f]{6}$"
/>
{/^#?[0-9A-Fa-f]{6}$/.test(brand.primaryColor) && (
<span
aria-hidden
data-testid="step-marketplace-brand-color-swatch"
style={{
width: 30,
height: 30,
flexShrink: 0,
borderRadius: 6,
border: '1px solid var(--wiz-border-sub)',
background: brand.primaryColor.startsWith('#')
? brand.primaryColor
: `#${brand.primaryColor}`,
}}
/>
)}
</div>
<span style={FIELD_HELP_STYLE}>
CSS hex (e.g. <code>#38BDF8</code>). Used as the storefront accent.
</span>
</label>
</div>
</div>
)}
</div>
</StepShell>
)
}

View File

@ -667,6 +667,17 @@ export function StepReview() {
objectStorageRegion: store.objectStorageRegion, objectStorageRegion: store.objectStorageRegion,
objectStorageAccessKey: store.objectStorageAccessKey, objectStorageAccessKey: store.objectStorageAccessKey,
objectStorageSecretKey: store.objectStorageSecretKey, 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() const data = await res.json()
@ -934,7 +945,110 @@ export function StepReview() {
`}</style> `}</style>
</Section> </Section>
{/* ── 6. Domain ────────────────────────────────────────── */} {/* ── 6. Marketplace (issue #710 wave 3a) ──────────────── */}
<Section
title={
<>
<span>Marketplace</span>
<span
data-testid="review-marketplace-state"
style={{
fontSize: 9,
fontWeight: 700,
letterSpacing: '0.06em',
color: store.marketplaceEnabled ? '#4ADE80' : 'var(--wiz-text-sub)',
background: store.marketplaceEnabled
? 'rgba(74,222,128,0.12)'
: 'rgba(148,163,184,0.12)',
border: store.marketplaceEnabled
? '1px solid rgba(74,222,128,0.25)'
: '1px solid var(--wiz-border-sub)',
borderRadius: 3,
padding: '1px 6px',
textTransform: 'uppercase',
}}
>
{store.marketplaceEnabled ? 'Enabled' : 'Disabled'}
</span>
</>
}
testId="review-section-marketplace"
>
{store.marketplaceEnabled ? (
<FieldGrid minColumnWidth={200}>
<Field
label="Storefront URL"
fullWidth
value={
sovereignFQDN ? (
<code
style={{
fontFamily: 'JetBrains Mono, monospace',
fontSize: 11,
color: '#38BDF8',
}}
>
marketplace.{sovereignFQDN}
</code>
) : (
<span style={{ color: 'var(--wiz-text-hint)' }}>
resolved from Domain step
</span>
)
}
/>
<Field
label="Brand name"
value={dimIfMissing(store.marketplaceBrand.name, '— platform default —')}
/>
<Field
label="Tagline"
value={dimIfMissing(store.marketplaceBrand.tagline, '— platform default —')}
/>
<Field
label="Primary colour"
value={
store.marketplaceBrand.primaryColor ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span
aria-hidden
style={{
width: 12,
height: 12,
borderRadius: 3,
border: '1px solid var(--wiz-border-sub)',
background: store.marketplaceBrand.primaryColor.startsWith('#')
? store.marketplaceBrand.primaryColor
: `#${store.marketplaceBrand.primaryColor}`,
}}
/>
<code style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11 }}>
{store.marketplaceBrand.primaryColor}
</code>
</span>
) : (
<span style={{ color: 'var(--wiz-text-hint)' }}> platform default </span>
)
}
/>
</FieldGrid>
) : (
<p
style={{
margin: 0,
fontSize: 11,
color: 'var(--wiz-text-sub)',
lineHeight: 1.5,
}}
>
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.
</p>
)}
</Section>
{/* ── 7. Domain ────────────────────────────────────────── */}
<Section title="Domain" testId="review-section-domain"> <Section title="Domain" testId="review-section-domain">
<FieldGrid minColumnWidth={200}> <FieldGrid minColumnWidth={200}>
<Field label="Mode" value={DOMAIN_MODE_LABELS[store.sovereignDomainMode]} /> <Field label="Mode" value={DOMAIN_MODE_LABELS[store.sovereignDomainMode]} />