Files
zeekay 9eb3d07b65 sign-in: one page to the code, the API warm, no asks a member never sees
Try Hanzo opens /signup directly instead of /chat bouncing a stranger
there, which cost a whole page load on a phone (13 s throttled).

/login no longer sends a new address to /signup: both pages send the
link at once and make the account once the code proves the address, so
the person stays on the page they chose.

/login, /signup and /auth/callback preconnect to api.hanzo.ai from the
head, so the methods read, the token exchange and the discovery a
provider press needs start on a warm connection.

A member at /login is marked before first paint (the app's BOOT under
data-member), the form stays off screen while the page moves them on,
and the panel asks IAM for no sign-in methods it would never draw.

The callback starts the bind and the standing read together instead of
one after the other.
2026-10-30 07:36:48 -07:00

70 lines
3.3 KiB
TypeScript

/**
* The checkout a sign-in interrupts.
*
* pay (hanzo.ai/pay) sends a buyer with no session to /login or /signup with
* the checkout they were on as `?next=` (hanzo-inc/pay lib/auth `login`), and a
* plan bought from /pricing does the same. Such a sign-in page is the checkout's
* first step: it is drawn at the checkout's measure, with no demo beside it, and
* a coupon the checkout carries is said at the top (components/auth/page.tsx).
* Once signed in, the buyer goes back to that checkout exactly as it was asked
* for (components/onboarding/flow.tsx).
*/
import { APP, BOOT } from '@hanzo/app/lib/session'
import { clean } from '@/lib/coupon'
/** What the checkout names: its plan's catalog slug and the coupon it carries, each when it carries one. */
export interface Sale {
plan: string | null
coupon: string | null
}
/** pay's mount: `/pay` and every path under it, as the browser resolves the address. */
const PAY = /^\/pay(?:\/|$)/
/** An origin no address can name, to resolve a path against. */
const HERE = 'https://hanzo.invalid'
const HANZO_HOST = /(?:^|\.)(?:hanzo\.(?:ai|app|computer)|localhost)$/i
/**
* The checkout `next` names, or null when it names none: an address on this
* site whose path, as the browser resolves it, is under /pay. `next` is read
* only after lib/auth/destination `onward` has held it to this site.
*/
export function sale(next: string | undefined | null): Sale | null {
if (!next) return null
const url = new URL(next, HERE)
const allowed = url.origin === HERE || HANZO_HOST.test(url.hostname)
if (!allowed || !PAY.test(url.pathname)) return null
return { plan: url.searchParams.get('plan') || null, coupon: clean(url.searchParams.get('coupon')) }
}
/** The mark on <html> while a checkout's sign-in page waits for its script: the hero is held off the screen. */
export const MARK = 'data-sale'
/**
* The mark on <html> while a sign-in page with a live session here decides where
* the reader goes: on to the page they were headed for, or a spinner for the site
* that asked. The form is held off the screen meanwhile, so a signed-in reader
* never sees it flash on the way through (components/auth/panel `Gate` clears the
* mark once it draws anything).
*/
export const MEMBER = 'data-member'
/**
* Run as the sign-in page is parsed, before anything under it paints: a `next`
* under /pay marks <html> (`MARK`), so the plain column never flashes before
* the checkout's page, with its offer, replaces it; and a session live in this
* browser marks it `MEMBER`, by @hanzo/app's own test for one (`BOOT`, the same
* keys and expiry, marking this page rather than the app). The page clears each
* mark once it is drawn.
*/
export const EARLY = `try{var q=new URLSearchParams(location.search),n=q.get('next')||q.get('redirect');if(n&&/^\\/(?![/\\\\])/.test(n)){var u=new URL(n,location.origin);if(u.origin===location.origin&&${PAY}.test(u.pathname)){document.documentElement.setAttribute('${MARK}','')}}}catch(_){}${BOOT.replace(`'${APP}'`, `'${MEMBER}'`)}`
/**
* The hero is held off the screen while either mark is set, and shows by itself
* after four seconds, so a page whose script never runs is not left blank.
*/
export const HOLD = `html:is([${MARK}],[${MEMBER}]) [data-slot="hero"]{visibility:hidden;animation:sale-hold 0s 4s forwards}@keyframes sale-hold{to{visibility:visible}}`