Files

410 lines
17 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 IAM made more than
// an hour ago (@hanzo/ui/onboarding NEW_WITHIN), one that has
// answered here, 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
// who Personal or Business, two cards of what each side's plans
// include, each opening pay's plans (/pay/cart) for the
// organization worked in and coming back to the app
//
// 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' | 'who'
type Step = { at: 'wait' } | { at: 'terms'; then: Then } | { at: 'connect'; then: Then } | { at: 'who' }
/** 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 to = safe(asks, '/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 = (): string =>
plans({ org: org() ?? undefined, coupon: code ?? undefined, campaign: brought(search), returnUrl: `${window.location.origin}${app}` })
/**
* 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 setStep({ at: 'who' })
}
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)
const answered = Boolean(before.use || 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, except for a new account, which answers its question first (with Free beside it).
const offered = Boolean(clean(coupon))
const then: Then = sold
? 'chosen'
: !fresh(made) || answered || member
? offered
? 'offer'
: 'on'
: free
? 'free'
: (await paying(stop.signal))
? offered
? 'offer'
: 'on'
: 'who'
if (stop.signal.aborted) 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) {
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 (
<>
{/* An offer link's code, said as the sign-in page and pay say it. */}
<Offer coupon={clean(coupon)} />
<Who
personalPicker={personalPicker}
teamPicker={teamPicker}
onPick={(use, href) => void remember({ use }).then(() => window.location.assign(authorize(href)))}
/>
</>
)
}
/** 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>
)
}