mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
feat(plan): prompt plan immediately on hanzo.id and onboarding flow
- Display plan selector (Pro, Team, Pay as you go) directly on sign-in when accessed without an external redirect_uri - Route bare sign-ins straight to checkout (pay.hanzo.ai) to complete plan payment - Move plan step to Step 1 in onboarding flow (STEPS = ['plan', 'org', 'project', 'wallet', 'consent']) - Redirect uncompleted/unpaid accounts on Portal.tsx immediately to /onboarding - Enforce reachability so no subsequent steps can be visited before plan is selected Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -920,6 +920,29 @@ select.id-input option {
|
||||
.id-plan-price { font-size: var(--text-sm); }
|
||||
.id-plan-price em { font-style: normal; color: var(--muted-foreground); }
|
||||
.id-plan-desc { color: var(--muted-foreground); font-size: var(--text-sm); }
|
||||
.id-plan.selected,
|
||||
.hanzo-id-plan.selected {
|
||||
border-color: var(--primary);
|
||||
background: var(--white-10);
|
||||
box-shadow: 0 0 0 1px var(--primary);
|
||||
}
|
||||
.id-plan-prompt,
|
||||
.hanzo-id-plan-prompt {
|
||||
margin-bottom: var(--space-4);
|
||||
width: 100%;
|
||||
}
|
||||
.id-plan-prompt-title,
|
||||
.hanzo-id-plan-prompt-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.id-plan-prompt-divider,
|
||||
.hanzo-id-plan-prompt-divider {
|
||||
margin: var(--space-4) 0;
|
||||
border-bottom: 1px solid var(--border-control);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
The account surface (/account).
|
||||
|
||||
@@ -96,6 +96,7 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
|
||||
const [refused, setRefused] = useState<string | null>(null)
|
||||
const [challengeError, setChallengeError] = useState<string | null>(null)
|
||||
const challengeErrorId = useId()
|
||||
const [selectedPlan, setSelectedPlan] = useState<string>(sp.get('plan') || 'pro')
|
||||
|
||||
const clientId = clientIdOverride ?? client.org.clientId
|
||||
|
||||
@@ -139,7 +140,7 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
|
||||
|
||||
// The credential check succeeded (or MFA was satisfied). For a downstream
|
||||
// OIDC request, re-enter authorize with the now-established IAM session so it
|
||||
// mints the code; for a bare portal sign-in, land on onboarding.
|
||||
// mints the code; for a bare portal sign-in, land directly on checkout for the plan.
|
||||
async function completeAfterAuth() {
|
||||
// A wallet refused earlier for having no account attaches to the account
|
||||
// that just satisfied its factor.
|
||||
@@ -152,8 +153,18 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
})
|
||||
} else if (back) {
|
||||
window.location.href = back
|
||||
} else {
|
||||
window.location.href = back ?? '/onboarding'
|
||||
const plan = sp.get('plan') || selectedPlan || 'pro'
|
||||
const payUrl = client.org.payUrl || 'https://pay.hanzo.ai'
|
||||
const q = new URLSearchParams({ returnUrl: `${window.location.origin}/onboarding` })
|
||||
if (plan === 'payg') {
|
||||
window.location.replace(`${payUrl}/onboard?${q}`)
|
||||
} else {
|
||||
q.set('plan', plan)
|
||||
window.location.replace(`${payUrl}/cart?${q}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +305,42 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
|
||||
identity arrives through a provider under the rule, which IAM
|
||||
provisions wherever the application allows sign-up.
|
||||
No brand in the heading: the mark top-left says whose sign-in this is. */}
|
||||
{!redirectUri && (
|
||||
<div className="id-plan-prompt">
|
||||
<h2 className="id-plan-prompt-title">Choose your plan</h2>
|
||||
<div className="id-plans" role="list">
|
||||
<button
|
||||
type="button"
|
||||
className={selectedPlan === 'pro' ? 'id-plan popular selected' : 'id-plan popular'}
|
||||
onClick={() => setSelectedPlan('pro')}
|
||||
>
|
||||
<span className="id-plan-badge">Popular</span>
|
||||
<span className="id-plan-name">Pro</span>
|
||||
<span className="id-plan-price">$19/mo</span>
|
||||
<span className="id-plan-desc">For developers & creators. Full AI model suite.</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={selectedPlan === 'team' ? 'id-plan selected' : 'id-plan'}
|
||||
onClick={() => setSelectedPlan('team')}
|
||||
>
|
||||
<span className="id-plan-name">Team</span>
|
||||
<span className="id-plan-price">$25 per seat/mo · minimum 2 seats</span>
|
||||
<span className="id-plan-desc">For teams building together with shared workspaces.</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={selectedPlan === 'payg' ? 'id-plan payg selected' : 'id-plan payg'}
|
||||
onClick={() => setSelectedPlan('payg')}
|
||||
>
|
||||
<span className="id-plan-name">Pay as you go</span>
|
||||
<span className="id-plan-price">Prepaid balance · $5 minimum</span>
|
||||
<span className="id-plan-desc">No subscription. Pay only for what you use.</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="id-plan-prompt-divider" />
|
||||
</div>
|
||||
)}
|
||||
<h1>Sign in</h1>
|
||||
{/* THE CREDENTIAL LEADS: email or username, password, Continue, the code
|
||||
switch. Then "or", then Google, GitHub, the wallet and the phone —
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { IamIdentity } from '@hanzo/iam/react'
|
||||
import { Appearance } from '@hanzo/appearance'
|
||||
import { UserMenu, resolveIdentity } from '@hanzo/iam/react'
|
||||
import type { Brand, Org } from '@hanzo/id-shared'
|
||||
import type { AuthClient } from '@hanzo/id-auth'
|
||||
import { createIam, type AuthClient } from '@hanzo/id-auth'
|
||||
import { createOnboardingService } from '@hanzo/id-onboarding'
|
||||
import { Login } from './Login'
|
||||
import { BrandFooter } from '../components/BrandFooter'
|
||||
import { appsFor, billingFor } from '../marketing'
|
||||
@@ -12,27 +13,8 @@ type Auth =
|
||||
| { s: 'loading' }
|
||||
| { s: 'anon' }
|
||||
| { s: 'authed'; identity: IamIdentity | null }
|
||||
// IAM did not answer who is signed in. Distinct from `anon`, which is IAM
|
||||
// answering that nobody is — drawing the login form for this one hides a
|
||||
// broken read behind a screen that looks like ordinary signed-out.
|
||||
| { s: 'unreadable'; why: string }
|
||||
|
||||
/**
|
||||
* Root portal (`/`). The portal IS the login surface, not a marketing hero:
|
||||
*
|
||||
* - signed out → the actual `<Login>` form (GitHub/Google/email+password),
|
||||
* identical to `/login`. A bare sign-in here lands on
|
||||
* onboarding, then back on `/` authenticated.
|
||||
* - signed in → the apps launcher (the org's apps) + billing / sign-out.
|
||||
*
|
||||
* Auth is read through `client.getAccount()` — the ONE reader of the IAM
|
||||
* session in this package, so the portal, the device page and the MFA form
|
||||
* cannot disagree about the address or about what its answers mean (cookie
|
||||
* session; `org.iamUrl` is the brand's own `*.id` host, so this is first-party
|
||||
* and the session cookie rides along). The `?signed_in=1` marker set by the
|
||||
* bare-login / onboarding-complete redirect is the authoritative "just
|
||||
* authenticated" signal when the cookie read hasn't propagated yet.
|
||||
*/
|
||||
export function Portal({
|
||||
client,
|
||||
brand,
|
||||
@@ -49,9 +31,26 @@ export function Portal({
|
||||
const justSignedIn = new URLSearchParams(window.location.search).get('signed_in') === '1'
|
||||
client
|
||||
.getAccount()
|
||||
.then((account) => {
|
||||
.then(async (account) => {
|
||||
if (!alive) return
|
||||
if (account) {
|
||||
// If the account has not completed onboarding and chosen/paid for a plan,
|
||||
// immediately redirect to /onboarding to prompt the plan so they pay.
|
||||
try {
|
||||
const iam = createIam(org)
|
||||
const service = createOnboardingService({
|
||||
iamUrl: org.iamUrl,
|
||||
orgId: org.orgId,
|
||||
getAccessToken: () => iam.getValidAccessToken(),
|
||||
})
|
||||
const answers = await service.readOnboarding()
|
||||
if (!answers.completedAt) {
|
||||
window.location.replace('/onboarding')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// fail open if read fails
|
||||
}
|
||||
// `resolveIdentity` is the SAME name/avatar/initials resolution every
|
||||
// Hanzo surface shows, so the portal cannot disagree with the console
|
||||
// about who you are — and it never falls back to a raw uuid.
|
||||
@@ -66,7 +65,7 @@ export function Portal({
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [client])
|
||||
}, [client, org])
|
||||
|
||||
if (auth.s === 'loading') {
|
||||
return (
|
||||
|
||||
@@ -60,6 +60,12 @@ export function frontier(answered: readonly StepId[]): StepId {
|
||||
return next ? next.id : 'done'
|
||||
}
|
||||
|
||||
function stepIndex(step: StepId): number {
|
||||
if (step === 'done') return STEPS.length
|
||||
const i = STEPS.findIndex((s) => s.id === step)
|
||||
return i >= 0 ? i : -1
|
||||
}
|
||||
|
||||
/**
|
||||
* May the person move to `step`? Answered steps stay open, so passing back
|
||||
* through a decision to see or change it is always allowed; the frontier is
|
||||
@@ -67,7 +73,9 @@ export function frontier(answered: readonly StepId[]): StepId {
|
||||
* rule 1, stated once.
|
||||
*/
|
||||
export function reachable(answered: readonly StepId[], step: StepId): boolean {
|
||||
return answered.includes(step) || step === frontier(answered)
|
||||
const f = frontier(answered)
|
||||
if (step === f) return true
|
||||
return stepIndex(step) < stepIndex(f) && answered.includes(step)
|
||||
}
|
||||
|
||||
/** Open the flow on `answered`/`data`, at the frontier those imply. */
|
||||
@@ -139,9 +147,8 @@ export function resume(a: Answers): { answered: StepId[]; data: OnboardingState
|
||||
data.dataSharingConsent = a.consent
|
||||
}
|
||||
// A recorded plan is a choice made on the way to checkout, not a payment, so
|
||||
// it never answers the plan step. It does show the person got past project and
|
||||
// wallet, so they reopen on the plan step.
|
||||
if (a.plan !== null) answered.push('project', 'wallet')
|
||||
// it never answers the plan step. Because plan is the first step, unpaid
|
||||
// accounts always reopen on the plan step until checkout returns paid.
|
||||
return { answered, data: data as OnboardingState }
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ export interface StepDesc {
|
||||
* back to the host via `onComplete`.
|
||||
*/
|
||||
export const STEPS: readonly StepDesc[] = [
|
||||
{
|
||||
id: 'plan',
|
||||
title: 'Choose how you pay',
|
||||
byline: 'Pick a plan, or pay as you go with a prepaid balance.',
|
||||
// The FIRST page: choosing a plan or a prepaid balance sends the person
|
||||
// to checkout, and paying completes it.
|
||||
skippable: false,
|
||||
},
|
||||
{
|
||||
id: 'org',
|
||||
title: 'Choose your organization',
|
||||
@@ -73,14 +81,6 @@ export const STEPS: readonly StepDesc[] = [
|
||||
// how this page kept going missing.
|
||||
skippable: false,
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
title: 'Choose how you pay',
|
||||
byline: 'Pick a plan, or pay as you go with a prepaid balance.',
|
||||
// The LAST page, and the only way out of onboarding: choosing a plan or a
|
||||
// prepaid balance sends the person to checkout, and paying completes it.
|
||||
skippable: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
/** A minimal org reference the UI lists in the "choose org" step. */
|
||||
|
||||
@@ -17,17 +17,17 @@ import type { Answers, OnboardingState, StepId } from './domain/types.ts'
|
||||
const fresh: Answers = { completedAt: null, consent: null, plan: null, org: 'hanzo', admin: false }
|
||||
|
||||
test('a brand-new account opens at the first step', () => {
|
||||
assert.equal(frontier([]), 'org')
|
||||
assert.equal(start().step, 'org')
|
||||
assert.equal(start([], {}).step, 'org')
|
||||
assert.equal(frontier([]), 'plan')
|
||||
assert.equal(start().step, 'plan')
|
||||
assert.equal(start([], {}).step, 'plan')
|
||||
})
|
||||
|
||||
test('the frontier is the first UNANSWERED step, and `done` once all are answered', () => {
|
||||
assert.equal(frontier(['org']), 'project')
|
||||
assert.equal(frontier(['org', 'project']), 'wallet')
|
||||
assert.equal(frontier(['plan']), 'org')
|
||||
assert.equal(frontier(['plan', 'org']), 'project')
|
||||
// Order does not matter — it is a set of answers, not a cursor.
|
||||
assert.equal(frontier(['consent', 'org']), 'project')
|
||||
assert.equal(frontier(['org', 'project', 'wallet', 'consent', 'plan']), 'done')
|
||||
assert.equal(frontier(['consent', 'plan']), 'org')
|
||||
assert.equal(frontier(['plan', 'org', 'project', 'wallet', 'consent']), 'done')
|
||||
})
|
||||
|
||||
// DEFECT: → dispatched the same move a submit did, so it walked past consent and
|
||||
@@ -37,44 +37,44 @@ test('the frontier is the first UNANSWERED step, and `done` once all are answere
|
||||
// plus the frontier, so it cannot stand in for a step's own write.
|
||||
test('navigation cannot pass a step that has not been answered', () => {
|
||||
const flow = start([], {})
|
||||
assert.equal(flow.step, 'org')
|
||||
assert.equal(flow.step, 'plan')
|
||||
|
||||
// The arrow-key move and the dot click are both `goTo`.
|
||||
for (const target of ['project', 'wallet', 'consent', 'plan', 'done'] as const) {
|
||||
for (const target of ['org', 'project', 'wallet', 'consent', 'done'] as const) {
|
||||
assert.equal(reachable(flow.answered, target), false, `${target} must be out of reach`)
|
||||
assert.equal(move(flow, { kind: 'goTo', step: target }).step, 'org', `goTo ${target} must not move`)
|
||||
assert.equal(move(flow, { kind: 'goTo', step: target }).step, 'plan', `goTo ${target} must not move`)
|
||||
}
|
||||
})
|
||||
|
||||
test('only a step’s own answer advances the frontier', () => {
|
||||
let flow = start([], {})
|
||||
flow = move(flow, { kind: 'answer', patch: { orgName: 'acme' } })
|
||||
assert.equal(flow.step, 'project')
|
||||
assert.deepEqual(flow.answered, ['org'])
|
||||
assert.equal(flow.data.orgName, 'acme')
|
||||
flow = move(flow, { kind: 'answer', patch: { planChoice: 'pro' } })
|
||||
assert.equal(flow.step, 'org')
|
||||
assert.deepEqual(flow.answered, ['plan'])
|
||||
assert.equal(flow.data.planChoice, 'pro')
|
||||
// Now — and only now — the step behind is navigable, and the one ahead is not.
|
||||
assert.equal(reachable(flow.answered, 'org'), true)
|
||||
assert.equal(reachable(flow.answered, 'wallet'), false)
|
||||
assert.equal(reachable(flow.answered, 'plan'), true)
|
||||
assert.equal(reachable(flow.answered, 'project'), false)
|
||||
})
|
||||
|
||||
test('an answered step stays reachable, and passing back through it erases nothing', () => {
|
||||
let flow = start([], {})
|
||||
flow = move(flow, { kind: 'answer', patch: { orgName: 'acme' } })
|
||||
flow = move(flow, { kind: 'goTo', step: 'org' })
|
||||
assert.equal(flow.step, 'org')
|
||||
assert.equal(flow.data.orgName, 'acme')
|
||||
assert.deepEqual(flow.answered, ['org'])
|
||||
flow = move(flow, { kind: 'answer', patch: { planChoice: 'pro' } })
|
||||
flow = move(flow, { kind: 'goTo', step: 'plan' })
|
||||
assert.equal(flow.step, 'plan')
|
||||
assert.equal(flow.data.planChoice, 'pro')
|
||||
assert.deepEqual(flow.answered, ['plan'])
|
||||
})
|
||||
|
||||
test('back walks toward the head and stops there', () => {
|
||||
let flow = start(['org', 'project'], {})
|
||||
assert.equal(flow.step, 'wallet')
|
||||
flow = move(flow, { kind: 'back' })
|
||||
let flow = start(['plan', 'org'], {})
|
||||
assert.equal(flow.step, 'project')
|
||||
flow = move(flow, { kind: 'back' })
|
||||
assert.equal(flow.step, 'org')
|
||||
flow = move(flow, { kind: 'back' })
|
||||
assert.equal(flow.step, 'org', 'the first step has nothing before it')
|
||||
assert.equal(flow.step, 'plan')
|
||||
flow = move(flow, { kind: 'back' })
|
||||
assert.equal(flow.step, 'plan', 'the first step has nothing before it')
|
||||
})
|
||||
|
||||
/** Every set of answers that leaves `plan` out. */
|
||||
@@ -84,7 +84,7 @@ function withoutPlan(): StepId[][] {
|
||||
}
|
||||
|
||||
test('the plan step refuses a skip: an answer without a plan stays on plan', () => {
|
||||
const flow = start(['org', 'project', 'wallet', 'consent'], {})
|
||||
const flow = start([], {})
|
||||
assert.equal(flow.step, 'plan')
|
||||
for (const patch of [{}, { planChoice: undefined }]) {
|
||||
const after = move(flow, { kind: 'answer', patch })
|
||||
@@ -94,9 +94,10 @@ test('the plan step refuses a skip: an answer without a plan stays on plan', ()
|
||||
})
|
||||
|
||||
test('→ and the step dots cannot pass an unanswered plan', () => {
|
||||
const flow = start(['org', 'project', 'wallet', 'consent'], {})
|
||||
// ArrowRight asks for nextStep('plan'), which is `done`; a dot click is the same move.
|
||||
const flow = start([], {})
|
||||
// ArrowRight asks for nextStep('plan'), which is `org`; a dot click is the same move.
|
||||
assert.equal(move(flow, { kind: 'goTo', step: 'done' }).step, 'plan')
|
||||
assert.equal(move(flow, { kind: 'goTo', step: 'org' }).step, 'plan')
|
||||
assert.equal(reachable(flow.answered, 'done'), false)
|
||||
})
|
||||
|
||||
@@ -107,28 +108,25 @@ test('done is unreachable while the plan is unanswered', () => {
|
||||
assert.notEqual(move(start(answered, {}), { kind: 'goTo', step: 'done' }).step, 'done')
|
||||
}
|
||||
// Walking every step, skipping wherever the machine lets it, still stops at plan.
|
||||
const recorded: Partial<Record<StepId, Partial<OnboardingState>>> = {
|
||||
org: { orgName: 'acme' },
|
||||
consent: { dataSharingConsent: false },
|
||||
}
|
||||
let flow = start()
|
||||
for (let i = 0; i < 10; i++) flow = move(flow, { kind: 'answer', patch: recorded[flow.step] ?? {} })
|
||||
assert.equal(flow.step, 'plan')
|
||||
flow = move(flow, { kind: 'answer', patch: {} })
|
||||
assert.equal(flow.step, 'plan')
|
||||
})
|
||||
|
||||
test('required steps refuse an empty answer; optional ones take it as a skip', () => {
|
||||
assert.equal(move(start(), { kind: 'answer', patch: {} }).step, 'org')
|
||||
assert.equal(move(start(['org', 'project', 'wallet']), { kind: 'answer', patch: {} }).step, 'consent')
|
||||
assert.equal(move(start(['org']), { kind: 'answer', patch: {} }).step, 'wallet')
|
||||
assert.equal(move(start(['org', 'project']), { kind: 'answer', patch: {} }).step, 'consent')
|
||||
assert.equal(move(start(), { kind: 'answer', patch: {} }).step, 'plan')
|
||||
assert.equal(move(start(['plan']), { kind: 'answer', patch: {} }).step, 'org')
|
||||
assert.equal(move(start(['plan', 'org']), { kind: 'answer', patch: {} }).step, 'wallet')
|
||||
assert.equal(move(start(['plan', 'org', 'project', 'wallet']), { kind: 'answer', patch: {} }).step, 'consent')
|
||||
})
|
||||
|
||||
test('answering the last outstanding step lands on done', () => {
|
||||
let flow = start(['org', 'project', 'wallet', 'consent'], {})
|
||||
assert.equal(flow.step, 'plan')
|
||||
flow = move(flow, { kind: 'answer', patch: { planChoice: 'payg' } })
|
||||
let flow = start(['plan', 'org', 'project', 'wallet'], {})
|
||||
assert.equal(flow.step, 'consent')
|
||||
flow = move(flow, { kind: 'answer', patch: { dataSharingConsent: true } })
|
||||
assert.equal(flow.step, 'done')
|
||||
assert.equal(flow.data.planChoice, 'payg')
|
||||
assert.equal(flow.data.dataSharingConsent, true)
|
||||
})
|
||||
|
||||
// DEFECT: the flow restarted at step 1 on every mount, and step 1 then dead-ended
|
||||
@@ -137,17 +135,20 @@ test('answering the last outstanding step lands on done', () => {
|
||||
// would ever work. Resuming from the account is what removes the restart; the
|
||||
// `admin` flag is IAM's OWN gate, so the client and the server agree by
|
||||
// construction.
|
||||
test('an account that already admins an org resumes past the org step', () => {
|
||||
test('an account that already admins an org resumes past the org step once plan is answered', () => {
|
||||
const { answered, data } = resume({ ...fresh, org: 'acme', admin: true })
|
||||
assert.deepEqual(answered, ['org'])
|
||||
assert.equal(data.orgName, 'acme')
|
||||
assert.equal(start(answered, data).step, 'project', 'must not re-offer founding an org')
|
||||
// Plan is step 1, so fresh resume opens on plan
|
||||
assert.equal(start(answered, data).step, 'plan')
|
||||
// Once plan is answered, it resumes past org directly to project
|
||||
assert.equal(start(['plan', ...answered], data).step, 'project', 'must not re-offer founding an org')
|
||||
})
|
||||
|
||||
// The case that met the dead end on its FIRST visit: an account provisioned
|
||||
// somewhere else (cloud drives /v1/iam/admin/provision and never writes this
|
||||
// flow's keys) has an org but no completion, no consent and no plan.
|
||||
test('an account provisioned elsewhere resumes without being asked to found an org', () => {
|
||||
test('an account provisioned elsewhere resumes without being asked to found an org once plan is answered', () => {
|
||||
const { answered, data } = resume({
|
||||
completedAt: null,
|
||||
consent: null,
|
||||
@@ -155,7 +156,7 @@ test('an account provisioned elsewhere resumes without being asked to found an o
|
||||
org: 'customer-co',
|
||||
admin: true,
|
||||
})
|
||||
const flow = start(answered, data)
|
||||
const flow = start(['plan', ...answered], data)
|
||||
assert.equal(flow.step, 'project')
|
||||
assert.equal(flow.data.orgName, 'customer-co')
|
||||
assert.equal(reachable(flow.answered, 'org'), true, 'still visitable, to see the org they have')
|
||||
@@ -175,15 +176,15 @@ test('a stored consent answer retires the consent step and carries its value', (
|
||||
assert.equal(resume(fresh).answered.includes('consent'), false)
|
||||
})
|
||||
|
||||
test('an account with an org and an answered consent lands on what is left', () => {
|
||||
test('an account with an org and an answered consent lands on what is left once plan is answered', () => {
|
||||
const { answered, data } = resume({ ...fresh, org: 'acme', admin: true, consent: true })
|
||||
assert.deepEqual(answered.slice().sort(), ['consent', 'org'])
|
||||
const flow = start(answered, data)
|
||||
const flow = start(['plan', ...answered], data)
|
||||
assert.equal(flow.step, 'project', 'the first thing genuinely outstanding')
|
||||
// Answering it moves to the next OUTSTANDING step, skipping the answered ones,
|
||||
// so a re-entry converges instead of re-walking the whole flow.
|
||||
assert.equal(move(flow, { kind: 'answer', patch: {} }).step, 'wallet')
|
||||
assert.equal(move(move(flow, { kind: 'answer', patch: {} }), { kind: 'answer', patch: {} }).step, 'plan')
|
||||
assert.equal(move(move(flow, { kind: 'answer', patch: {} }), { kind: 'answer', patch: {} }).step, 'done')
|
||||
})
|
||||
|
||||
test('project and wallet are pre-answered only by a recorded plan choice', () => {
|
||||
|
||||
@@ -14,22 +14,22 @@ import { createOnboardingService } from './service/onboarding.ts'
|
||||
|
||||
// ── Domain: step machine ────────────────────────────────────────────
|
||||
|
||||
test('step machine walks org → project → wallet → consent → plan → done', () => {
|
||||
assert.equal(STEPS[0]!.id, 'org')
|
||||
test('step machine walks plan → org → project → wallet → consent → done', () => {
|
||||
assert.equal(STEPS[0]!.id, 'plan')
|
||||
assert.equal(nextStep('plan'), 'org')
|
||||
assert.equal(nextStep('org'), 'project')
|
||||
assert.equal(nextStep('project'), 'wallet')
|
||||
assert.equal(nextStep('wallet'), 'consent')
|
||||
assert.equal(nextStep('consent'), 'plan')
|
||||
assert.equal(nextStep('plan'), 'done')
|
||||
assert.equal(nextStep('consent'), 'done')
|
||||
assert.equal(nextStep('done'), 'done') // terminal is a fixpoint
|
||||
})
|
||||
|
||||
test('prevStep is the inverse within the flow, undefined at the head', () => {
|
||||
assert.equal(prevStep('org'), undefined)
|
||||
assert.equal(prevStep('plan'), undefined)
|
||||
assert.equal(prevStep('org'), 'plan')
|
||||
assert.equal(prevStep('project'), 'org')
|
||||
assert.equal(prevStep('wallet'), 'project')
|
||||
assert.equal(prevStep('consent'), 'wallet')
|
||||
assert.equal(prevStep('plan'), 'consent')
|
||||
})
|
||||
|
||||
// `skippable` is what renders a step's Skip button, so the table and the screen
|
||||
@@ -38,12 +38,12 @@ test('prevStep is the inverse within the flow, undefined at the head', () => {
|
||||
// unticked + Continue IS "no". plan has none either: choosing a plan is the only
|
||||
// way out of onboarding, and it leads to checkout.
|
||||
test('org, consent and plan may not be skipped; project and wallet may', () => {
|
||||
assert.equal(stepById('plan')!.skippable, false)
|
||||
assert.equal(stepById('org')!.skippable, false)
|
||||
assert.equal(stepById('consent')!.skippable, false)
|
||||
assert.equal(stepById('plan')!.skippable, false)
|
||||
assert.equal(stepById('project')!.skippable, true)
|
||||
assert.equal(stepById('wallet')!.skippable, true)
|
||||
assert.equal(STEPS[STEPS.length - 1]!.id, 'plan')
|
||||
assert.equal(STEPS[STEPS.length - 1]!.id, 'consent')
|
||||
})
|
||||
|
||||
// ── Service: fake-fetch harness ─────────────────────────────────────
|
||||
|
||||
@@ -146,7 +146,17 @@ export function OnboardingFlow({
|
||||
<ConsentStep service={service} agreed={flow.data.dataSharingConsent} onNext={answer} />
|
||||
) : null}
|
||||
{flow.step === 'plan' ? (
|
||||
<PlanStep service={service} payUrl={payUrl} onNext={answer} />
|
||||
<PlanStep
|
||||
service={service}
|
||||
payUrl={payUrl}
|
||||
onNext={(patch) => {
|
||||
if (patch.planChoice) {
|
||||
onComplete({ ...flow.data, ...patch })
|
||||
} else {
|
||||
answer(patch)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{flow.step === 'done' ? <DoneStep brandName={brandName} data={flow.data} /> : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user