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:
zooqueen
2026-09-29 20:30:57 -07:00
co-authored by Hanzo Dev
parent 61edd784de
commit 4027667d37
8 changed files with 180 additions and 93 deletions
+23
View File
@@ -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).
+49 -2
View File
@@ -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 &amp; 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 —
+21 -22
View File
@@ -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 (
+11 -4
View File
@@ -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 }
}
+8 -8
View File
@@ -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. */
+49 -48
View File
@@ -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', () => {
+8 -8
View File
@@ -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 ─────────────────────────────────────
+11 -1
View File
@@ -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>