Files

417 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}