mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
417 lines
18 KiB
TypeScript
417 lines
18 KiB
TypeScript
'use client'
|
||
|
||
// /onboarding: where every sign-in lands (@hanzo/app/lib/destination `picker`)
|
||
// on its way to where it was headed (`?next=`), and a new account's first run.
|
||
//
|
||
// IAM says what the account is (`standing`): when it was made, what it has
|
||
// accepted, and what it answered here. Then, in order:
|
||
//
|
||
// returning a SuperAdmin, a support session, an account that has chosen a
|
||
// plan here (Free included), a member invited into an
|
||
// organization (its `/join`, or a member's role where it works),
|
||
// and one whose organization already pays, go on to where the
|
||
// sign-in was headed, and nothing is drawn
|
||
// terms a new account with no acceptance on record (a provider made it
|
||
// a moment ago) agrees to the terms first, whatever follows
|
||
// chosen a checkout the sign-in started from (`?next=/pay…`) is opened
|
||
// as it was asked for, plan, coupon and campaign, billed to the
|
||
// organization worked in
|
||
// offer a coupon link (`?coupon=`) opens pay's plans priced with it for
|
||
// an account past its first run; a new one is asked the question
|
||
// under the offer, and either answer carries the code
|
||
// free Free chosen before signing in (/pricing) goes into the app
|
||
// cart an account that has chosen no plan — none recorded here, Free
|
||
// included, and none its organization pays for — opens pay's
|
||
// plans (/pay/cart) with `welcome`, which draws a Back to the
|
||
// site until a plan is chosen. Every sign-in comes back here
|
||
// until one is, new account or not. There is no Personal or
|
||
// Business question first: the plans page has both tabs.
|
||
//
|
||
// A signed-out visit still sees the two cards (Who), each opening pay's plans.
|
||
//
|
||
// The plans are pay's (hanzo-inc/pay routes/cart): every plan the catalog
|
||
// sells, priced by commerce, the coupon quoted. The campaign that brought the
|
||
// visit rides every hand-off to pay's plans and card step: the address's own
|
||
// tags, else the ones this tab kept from the page it landed on (lib/campaign),
|
||
// which the sign-in took out of the address. The answer given
|
||
// here is kept on the IAM user (`onboarding` in /v1/iam/preferences) and
|
||
// counted as `onboarding_answered`, so a later sign-in goes straight on.
|
||
|
||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { useRouter, useSearchParams } from 'next/navigation'
|
||
import { useIam } from '@hanzo/iam/react'
|
||
import { useI18n } from '@hanzo/i18n/react'
|
||
import { Building2, UserRound } from 'lucide-react'
|
||
import { YStack } from '@hanzo/ui'
|
||
import { acceptTerms, NEW_WITHIN, owesTerms, preferences, type Accepted, type Progress } from '@hanzo/ui/onboarding'
|
||
import { useHydrated } from '@hanzo/app/lib/hydrated'
|
||
import { authorize, track } from '@hanzo/app/lib/tags'
|
||
import { countSignup } from '@hanzo/app/lib/intent'
|
||
import { useSignOut } from '@hanzo/app/lib/signout'
|
||
import { aim, choseFree, enter, LOGIN, PICKER, safe, signUp } from '@hanzo/app/lib/destination'
|
||
import { assumed, org, role, scope, superAdmin } from '@hanzo/app/lib/session'
|
||
import { api } from '@hanzo/app/lib/api'
|
||
import { paying } from '@hanzo/app/lib/tier'
|
||
import { AUP_VERSION, TERMS_VERSION } from '@hanzo/app/lib/terms'
|
||
import { plans } from '@/lib/checkout'
|
||
import { brought, campaign } from '@/lib/campaign'
|
||
import { clean, keep, kept } from '@/lib/coupon'
|
||
import { useQuote } from '@/hooks/useQuote'
|
||
import { sale } from '@/lib/auth/sale'
|
||
import { Offer } from '@/components/auth/offer'
|
||
import { Focus, Wait } from '@/components/shell/focus'
|
||
import { Side } from './look'
|
||
import { Terms } from './terms'
|
||
|
||
const POLICY = { terms: TERMS_VERSION, aup: AUP_VERSION }
|
||
|
||
/** How long a page waits on IAM (or commerce's quote) before going on without it, and on a write before leaving. */
|
||
const PATIENCE = 8000
|
||
const WRITE = 1500
|
||
|
||
/** Where an invitation into an organization is accepted: a sign-in headed there is a member's, and asks nothing. */
|
||
const JOIN = /^\/join(?:[/?#]|$)/
|
||
|
||
/** pay's plans and card step: the hand-offs a campaign rides to. */
|
||
const CHECKOUT = /^\/pay\/(?:cart|confirm\/[^/]+)\/?$/
|
||
|
||
/** What follows the terms, or the page when none are owed. */
|
||
type Then = 'on' | 'chosen' | 'offer' | 'free' | 'cart'
|
||
type Step = { at: 'wait' } | { at: 'terms'; then: Then } | { at: 'connect'; then: Then }
|
||
|
||
/** What IAM keeps on the account beside it: the terms it accepted and the answers given here. */
|
||
interface Stored {
|
||
terms?: Accepted
|
||
onboarding?: Progress
|
||
}
|
||
|
||
/** IAM's answer about this account: its address, when it was made, and what it keeps (`terms`, `onboarding`), when IAM said. */
|
||
interface Standing {
|
||
email: string
|
||
made?: string
|
||
/** IAM answered the preferences read; only then is anything written over them. */
|
||
known: boolean
|
||
stored: Stored
|
||
}
|
||
|
||
/** IAM wraps an answer as `{status, data}`; a bare body is its own answer. */
|
||
const unwrap = <T,>(body: unknown): T => (body && typeof body === 'object' && 'data' in body ? (body as { data: T }).data : (body as T))
|
||
|
||
/**
|
||
* Read the account from IAM. An answer that fails or does not come in time is
|
||
* read as a returning account's, made long ago with nothing owed, so a broken
|
||
* read never stands between a person and the app.
|
||
*/
|
||
async function standing(signal: AbortSignal): Promise<Standing> {
|
||
const read = Promise.all([
|
||
preferences(api()).then(
|
||
(stored) => ({ known: true, stored: (stored ?? {}) as Stored }),
|
||
() => ({ known: false, stored: {} as Stored }),
|
||
),
|
||
fetch(`${api()}/v1/iam/account`, { headers: scope(), cache: 'no-store', signal })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((body) => unwrap<{ email?: string; createdTime?: string } | null>(body))
|
||
.catch(() => null),
|
||
]).then(([prefs, me]) => ({ email: me?.email ?? '', made: me?.createdTime, ...prefs }))
|
||
const late = new Promise<Standing>((done) => setTimeout(() => done({ email: '', known: false, stored: {} }), PATIENCE))
|
||
return Promise.race([read, late])
|
||
}
|
||
|
||
/** Made by the sign-in that just finished: IAM created it within the hour. */
|
||
function fresh(made: string | undefined): boolean {
|
||
const at = made ? Date.parse(made) : Number.NaN
|
||
return Number.isFinite(at) && Date.now() - at <= NEW_WITHIN
|
||
}
|
||
|
||
/** The page's events to the stream; Create account on the terms page is the account being created. */
|
||
const emit = (name: string, props: Record<string, unknown> = {}): void => {
|
||
track(name, props)
|
||
if (name === 'terms_accepted') countSignup()
|
||
}
|
||
|
||
export function Flow() {
|
||
const { t } = useI18n()
|
||
const router = useRouter()
|
||
const hydrated = useHydrated()
|
||
const { isLoading, isAuthenticated } = useIam()
|
||
const [step, setStep] = useState<Step>({ at: 'wait' })
|
||
// What IAM keeps of the answers, null until it says: an answer is written over them only when it did.
|
||
const held = useRef<Progress | null>(null)
|
||
const [email, setEmail] = useState('')
|
||
|
||
// Where the sign-in was headed, as asked, and never back here. Read from the
|
||
// router, not the window: arriving from the callback, this page draws before
|
||
// the address bar is written.
|
||
const query = useSearchParams()
|
||
const asks = query.get('next')
|
||
const headed = useMemo(() => {
|
||
const cleanAsks = asks?.replace(/^https?:\/\/(?:[^/]+\.)?hanzo\.(?:ai|app|computer)/, '')
|
||
const to = safe(cleanAsks, '/chat')
|
||
// Compared as the browser resolves it, so `/./onboarding` is this page too.
|
||
const path = new URL(to, 'https://hanzo.invalid').pathname.replace(/\/+$/, '')
|
||
return path === PICKER ? '/chat' : to
|
||
}, [asks])
|
||
// The checkout the sign-in started from, if it started from one (lib/auth/sale).
|
||
const sold = useMemo(() => sale(headed), [headed])
|
||
// The app: where Free goes and where pay's plans come back to.
|
||
const app = headed === '/' || sold ? '/chat' : headed
|
||
|
||
// A coupon from the link (or the one this tab keeps), and the campaign that brought the visit.
|
||
const coupon = query.get('coupon')
|
||
const code = useMemo(() => clean(coupon) ?? kept(), [coupon])
|
||
const search = query.toString()
|
||
// Commerce's quote, asked only for a checkout chosen before signing in that names no coupon of its own.
|
||
const quote = useQuote(sold && !sold.coupon ? code : null)
|
||
// A quote that does not come in time is no deal: the checkout opens as it was asked for. Timed from the sign-in.
|
||
const [late, setLate] = useState(false)
|
||
useEffect(() => {
|
||
if (!isAuthenticated) return
|
||
const t = setTimeout(() => setLate(true), PATIENCE)
|
||
return () => clearTimeout(t)
|
||
}, [isAuthenticated])
|
||
|
||
const out = hydrated && !isLoading && !isAuthenticated
|
||
// A stranger keeps the coupon in their tab without forced login
|
||
useEffect(() => {
|
||
const asked = clean(coupon)
|
||
if (asked) keep(asked)
|
||
}, [coupon])
|
||
|
||
useEffect(() => {
|
||
if (!hydrated) return
|
||
track('onboarding_viewed', {
|
||
authenticated: isAuthenticated,
|
||
coupon: clean(coupon) ?? kept() ?? '',
|
||
})
|
||
}, [hydrated, isAuthenticated, coupon])
|
||
|
||
/**
|
||
* The checkout chosen before signing in, exactly as it was asked for. Only
|
||
* what it does not say is added: the organization the reader works in, and a
|
||
* coupon this tab keeps that commerce quoted for its plan on a monthly term.
|
||
*/
|
||
function chosen(): string {
|
||
const url = new URL(headed, window.location.origin)
|
||
const q = url.searchParams
|
||
const billed = org()
|
||
if (!q.has('org') && billed) q.set('org', billed)
|
||
const plan = q.get('plan')
|
||
const deal = plan && q.get('interval') !== 'year' && quote && 'deals' in quote ? quote.deals[plan] : undefined
|
||
if (!q.has('coupon') && deal) q.set('coupon', deal.code)
|
||
// The campaign the checkout names stands; pay's plans and card step that name none get the one that brought the visit.
|
||
if (CHECKOUT.test(url.pathname) && !campaign(url.search).length) for (const [k, v] of brought(search)) q.append(k, v)
|
||
// Held to this site again: a path rebuilt from a resolved URL can read as another host (`//…`).
|
||
return safe(`${url.pathname}${url.search}${url.hash}`, '/chat')
|
||
}
|
||
|
||
/** pay's plans, for the organization worked in, coming back to the app, with the link's coupon and campaign. */
|
||
const picker = (welcome = false): string =>
|
||
plans({ org: org() ?? undefined, coupon: code ?? undefined, campaign: brought(search), returnUrl: `${window.location.origin}${app}`, welcome })
|
||
|
||
/**
|
||
* Keep this answer on the IAM user and count it, waiting on IAM at most a
|
||
* moment. Nothing is written over answers IAM did not say it holds: an answer
|
||
* not kept is asked again next time, nothing worse.
|
||
*/
|
||
async function remember(patch: Progress): Promise<void> {
|
||
track('onboarding_answered', { use: patch.use ?? patch.plan })
|
||
const before = held.current
|
||
if (!before) return
|
||
const write = preferences(api(), { onboarding: { ...before, ...patch } }).catch((err: unknown) => {
|
||
console.warn('[onboarding] the answer was not kept:', err instanceof Error ? err.message : err)
|
||
})
|
||
await Promise.race([write, new Promise((done) => setTimeout(done, WRITE))])
|
||
}
|
||
|
||
/**
|
||
* Finalize onboarding immediately, proceeding to destination or checkout.
|
||
* Onboarding connect steps run non-intrusively inside the app's sidebar.
|
||
*/
|
||
function finalize(then: Then) {
|
||
if (then === 'on') router.replace(headed)
|
||
else if (then === 'chosen') window.location.replace(authorize(chosen()))
|
||
else if (then === 'offer') window.location.replace(authorize(picker()))
|
||
else if (then === 'free') void remember({ plan: 'free' }).then(() => router.replace(app))
|
||
else window.location.replace(authorize(picker(true)))
|
||
}
|
||
|
||
function go(then: Then) {
|
||
finalize(then)
|
||
}
|
||
|
||
// Signed in: what IAM says decides the page, once.
|
||
const quoted = !(sold && !sold.coupon && code) || quote !== undefined || late
|
||
useEffect(() => {
|
||
if (!isAuthenticated || step.at !== 'wait' || !quoted) return
|
||
if (superAdmin() || assumed()) {
|
||
router.replace(headed)
|
||
return
|
||
}
|
||
const stop = new AbortController()
|
||
void (async () => {
|
||
const { email: address, made, known, stored } = await standing(stop.signal)
|
||
if (stop.signal.aborted) return
|
||
const before = stored.onboarding ?? {}
|
||
held.current = known ? before : null
|
||
// Owed on IAM's word; with no word, an account made in the last hour agrees to them (twice is harmless, never is not).
|
||
const owed = known ? owesTerms(stored.terms, made) : fresh(made)
|
||
// A plan chosen: Free (or a plan) kept here. The Personal or Business
|
||
// answer an earlier version asked (`use`) chose no plan, and is not one.
|
||
const chose = Boolean(before.plan)
|
||
// A member invited into an organization is on their way into it, and buys nothing for it.
|
||
const member = JOIN.test(headed) || role(org()) === 'member'
|
||
// Read once, here: Free chosen on /pricing before this sign-in.
|
||
const free = choseFree()
|
||
// An offer link goes to the plans priced with it once a plan is chosen; before then the plans carry it anyway.
|
||
const offered = Boolean(clean(coupon))
|
||
// With no plan chosen and none paid for, every sign-in opens the plans.
|
||
// IAM unread is read as chosen, so a broken read never holds a person at the plans.
|
||
const then: Then = sold
|
||
? 'chosen'
|
||
: member
|
||
? offered
|
||
? 'offer'
|
||
: 'on'
|
||
: free
|
||
? 'free'
|
||
: chose || !known || (await paying(stop.signal))
|
||
? offered
|
||
? 'offer'
|
||
: 'on'
|
||
: 'cart'
|
||
if (stop.signal.aborted) return
|
||
if (sold) {
|
||
window.location.replace(authorize(chosen()))
|
||
return
|
||
}
|
||
if (!owed) return go(then)
|
||
setEmail(address)
|
||
setStep({ at: 'terms', then })
|
||
})()
|
||
return () => stop.abort()
|
||
// Once per arrival, when the session and any quote it needs are in.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isAuthenticated, step.at, quoted])
|
||
|
||
if (!hydrated || isLoading || (!out && step.at === 'wait')) return <Wait title={t('One moment')} />
|
||
|
||
const basePicker = picker()
|
||
const sep = basePicker.includes('?') ? '&' : '?'
|
||
const personalPicker = `${basePicker}${sep}tab=individual`
|
||
const teamPicker = `${basePicker}${sep}tab=team`
|
||
|
||
if (out) {
|
||
if (sold) {
|
||
window.location.replace(authorize(chosen()))
|
||
return <Wait title={t('One moment')} />
|
||
}
|
||
return (
|
||
<>
|
||
<Offer coupon={clean(coupon)} />
|
||
<Who
|
||
personalPicker={personalPicker}
|
||
teamPicker={teamPicker}
|
||
onPick={(_use, href) => {
|
||
window.location.assign(href)
|
||
}}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
if (step.at === 'terms') return <Owed email={email} onDone={() => finalize(step.then)} />
|
||
|
||
if (step.at === 'connect') {
|
||
finalize(step.then)
|
||
return <Wait title={t('One moment')} />
|
||
}
|
||
|
||
return <Wait title={t('One moment')} />
|
||
}
|
||
|
||
/** The terms, recorded by IAM for the signed-in account; another address signs this one out. */
|
||
function Owed({ email, onDone }: { email: string; onDone: () => void }) {
|
||
const { t } = useI18n()
|
||
const signOut = useSignOut()
|
||
const [busy, setBusy] = useState(false)
|
||
const [wrong, setWrong] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
track('onboarding_terms_viewed', { email })
|
||
}, [email])
|
||
|
||
return (
|
||
<Terms
|
||
email={email}
|
||
busy={busy}
|
||
wrong={wrong}
|
||
onOther={() => void signOut()}
|
||
onCreate={() => {
|
||
setBusy(true)
|
||
setWrong(null)
|
||
acceptTerms(api(), POLICY)
|
||
.then(() => {
|
||
emit('terms_accepted', { method: 'signed-in' })
|
||
onDone()
|
||
})
|
||
.catch((err: unknown) => {
|
||
console.warn('[onboarding] the terms were not recorded:', err instanceof Error ? err.message : err)
|
||
setWrong(t('We couldn’t save that. Try again.'))
|
||
setBusy(false)
|
||
})
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Who the account is for: two cards, each what its side's plans include, each
|
||
* opening pay's plans. Hanzo Team, the shared workspace, is a business plan's.
|
||
*/
|
||
function Who({
|
||
personalPicker,
|
||
teamPicker,
|
||
onPick,
|
||
}: {
|
||
personalPicker: string
|
||
teamPicker: string
|
||
onPick: (use: 'personal' | 'team', href: string) => void
|
||
}) {
|
||
const { t } = useI18n()
|
||
const [leaving, setLeaving] = useState(false)
|
||
const pick = (use: 'personal' | 'team') => (href: string) => {
|
||
if (leaving) return
|
||
setLeaving(true)
|
||
track('onboarding_choice_selected', { use })
|
||
track('plan_selected', { plan_type: use === 'personal' ? 'individual' : 'team' })
|
||
track('checkout_started', { tab: use === 'personal' ? 'individual' : 'team', source: 'onboarding' })
|
||
onPick(use, href)
|
||
}
|
||
return (
|
||
<Focus label={t('How you will use Hanzo')} title={t('How will you use Hanzo?')} width={760}>
|
||
<YStack gap="$3" $md={{ flexDirection: 'row' }}>
|
||
<Side
|
||
href={personalPicker}
|
||
icon={UserRound}
|
||
name={t('Personal')}
|
||
note={t('For you')}
|
||
plans={t('Pro and Max')}
|
||
points={[t('Every model, including Enso Ultra and Kai'), t('Usage included on every model'), t('Priority inference and support'), t('Automations, with daily runs included')]}
|
||
see={t('See personal plans')}
|
||
onPick={pick('personal')}
|
||
/>
|
||
<Side
|
||
href={teamPicker}
|
||
icon={Building2}
|
||
name={t('Business')}
|
||
note={t('For your team or company')}
|
||
plans={t('Team and Enterprise')}
|
||
points={[t('Hanzo Team: chat, meetings, calendar and contacts'), t('Usage for every member, shared by the team'), t('Single sign-on, roles and an audit log'), t('One bill for the whole team')]}
|
||
see={t('See business plans')}
|
||
onPick={pick('team')}
|
||
/>
|
||
</YStack>
|
||
</Focus>
|
||
)
|
||
}
|