Files
zeekay 0f5536b707 hanzo.ai reads in the reader's language, and Arabic right to left
Every word the landing, the header menus, the phone sheet, the footer,
the help corner and the way in (sign-in, sign-up, the reset, the
invitation, the callback, onboarding, the 404) write goes through
@hanzo/i18n's t(): one sentence is one message, its figures, names and
links arguments; copy written as data is phrase('…') where written and
t(label) where drawn. Plans, the chart, the API states and the lines
lib/kai and lib/enso build for the landing do the same.

Right to left: <html lang dir> is set before the first paint (the
package's bootScript first in the head); a figure, clock, range or
price outside a sentence is isolated; sides are logical gui props, the
layout's [data-join] corners logical, a bar grows from the start;
arrows along the line mirror (Onward for "words ->" links). The repo
dates read in the reader's language.

Gates select by slot, never by an English label (rtl, help). New gate
translated.spec.ts: in Arabic the landing at 1440 and 390, every menu
open, the phone sheet, sign-in, sign-up and the app signed in hold no
authored English, nothing scrolls sideways, every range is isolated;
French the same for English's commonest words. What another package
draws with no words prop is listed by name (@hanzo/ui's Footer label,
@hanzo/build's composer row and suggestions).

@hanzo/i18n 0.5.3 (the catalogs: 1,443 keys, every offered locale
complete), @hanzo/app 0.2.1 (the app's words). The link gate imports
the terms versions from @hanzo/app, the one SDK main now resolves.
2026-10-22 19:54:31 -07:00

192 lines
6.9 KiB
TypeScript

'use client'
/**
* /join?org=<owner>&invite=<code>[&client_id=<app>] — the page an emailed
* invitation opens (hanzoai/iam internal/invitations `PathJoin`).
*
* An invitation is a row the organization's admin wrote in IAM; this page carries
* its code to IAM's one door for it, as the person signed in here:
*
* signed out the sign-in panel, which signs in or makes the account, and
* comes back to this invitation
* signed in Join (@hanzo/iam `acceptInvite`, with this page's access token,
* which IAM admits for a platform application only and which
* joins that token's own account). An invitation pinned to an
* address also wants a code: IAM sends one to the account's
* address itself and says so, and the page sends it back.
*
* The organization is named from the link and never looked up, so a guessed code
* learns nothing; IAM decides at the door and its refusal is shown as written.
* `client_id` names the application the invitation was sent from; the account
* signs in here as this site's own.
*/
import { useEffect, useId, useState, type FormEvent } from 'react'
import { Refused } from '@hanzo/iam'
import { useIam } from '@hanzo/iam/react'
import { Text, YStack } from '@hanzo/ui'
import { Action, TextLink } from '@hanzo/ui/marketing'
import { useI18n } from '@hanzo/i18n/react'
import Hero from '@/components/home/Hero'
import { Panel } from '@/components/auth/panel'
import { Code, Other, said, Say, Sheet, Wrong } from '@/components/auth/steps'
import { aim } from '@hanzo/app/lib/destination'
import { useHydrated } from '@hanzo/app/lib/hydrated'
import { useClient, useSignOut } from '@hanzo/app/lib/signout'
import { useQuery } from '@/lib/auth/query'
/** IAM's reasons for an invitation that wants the code it sent to the account's address. */
const ASKS = new Set(['email_code_sent', 'email_code_required'])
export default function Page() {
const { t } = useI18n()
const iam = useClient()
const signOut = useSignOut()
const { isLoading, isAuthenticated, user } = useIam()
const q = useQuery()
const hydrated = useHydrated()
const owner = q.get('org') ?? ''
const code = q.get('invite') ?? ''
const app = q.get('client_id')
// This page again, rebuilt from the link's own fields, for a sign-in to come back to.
const here = `/join?${new URLSearchParams({ ...(app ? { client_id: app } : {}), invite: code, org: owner })}`
// IAM's sentence about the code it sent, once it asked for one.
const [asked, setAsked] = useState<string | null>(null)
const [emailCode, setEmailCode] = useState('')
const [joined, setJoined] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [wrong, setWrong] = useState<string | null>(null)
const ids = { code: useId() }
// Signed out, the sign-in that follows comes back to this invitation.
useEffect(() => {
if (owner && code && !isLoading && !isAuthenticated) aim(here)
}, [owner, code, here, isLoading, isAuthenticated])
/**
* One call for every step. Without the code it asks to join, which for a pinned
* invitation is also how IAM sends one; with it, it answers that code.
*/
function join(withCode: boolean) {
if (withCode && !emailCode.trim()) {
setWrong(t('Enter the code from the email.'))
return
}
setBusy(true)
setWrong(null)
iam
.acceptInvite(owner, code, withCode ? emailCode.trim() : undefined)
.then((org) => setJoined(org))
.catch((err: unknown) => {
if (err instanceof Refused && ASKS.has(err.code)) {
setAsked(err.message)
setEmailCode('')
return
}
setWrong(said(err, t))
})
.finally(() => setBusy(false))
}
/** Sign out, and sign in as the account the invitation was sent to; the sign-in comes back here. */
function other() {
aim(here)
void signOut()
}
let card
if (isLoading || !hydrated) {
card = (
<Sheet label={t('Join an organization')}>
<Say>{t('One moment…')}</Say>
</Sheet>
)
} else if (!owner || !code) {
card = (
<Sheet label={t('Join an organization')}>
<Wrong text={t('This invite link is incomplete.')} />
<Say>
{t('It is missing the organization or the invitation code. Ask the person who invited you to send it again, or {signin}.', {
signin: <TextLink href="/login">{t('sign in to Hanzo')}</TextLink>,
})}
</Say>
</Sheet>
)
} else if (!isAuthenticated) {
card = <Panel mode="login" />
} else if (joined) {
card = (
<Sheet label={t('You joined {org}', { org: joined })}>
<Say>
{t('You are a member of {org}.', {
org: (
<Text fontWeight="600" color="$ink">
{joined}
</Text>
),
})}
</Say>
<Action href="/" fill>
{t('Continue')}
</Action>
</Sheet>
)
} else if (asked !== null) {
card = (
<Sheet label={t('Join {org}', { org: owner })}>
<form
onSubmit={(e: FormEvent) => {
e.preventDefault()
join(true)
}}
noValidate
>
<YStack gap="$4">
<Say>{asked}</Say>
<Code id={ids.code} value={emailCode} onChange={setEmailCode} />
<Wrong text={wrong} />
<Action render="button" type="submit" fill disabled={busy}>
{busy ? t('One moment…') : t('Join {org}', { org: owner })}
</Action>
<Other onClick={() => join(false)} disabled={busy}>
{t('Send a new code')}
</Other>
</YStack>
</form>
</Sheet>
)
} else {
card = (
<Sheet label={t('Join {org}', { org: owner })}>
<Say>
{t('Signed in as {who}.', {
who: (
<Text fontWeight="600" color="$ink">
{user?.email ?? user?.name ?? t('your account')}
</Text>
),
})}
</Say>
<Wrong text={wrong} />
<Action render="button" type="button" fill disabled={busy} onClick={() => join(false)}>
{busy ? t('One moment…') : t('Join {org}', { org: owner })}
</Action>
<Other onClick={other} disabled={busy}>
{t('Use another account')}
</Other>
</Sheet>
)
}
// Signed out with a whole link, the panel asks: the lede says what for, and the terms are stated.
const asking = Boolean(owner && code) && !isLoading && !isAuthenticated
return (
<Hero
title={owner ? t('Join {org}', { org: owner }) : t('Join an organization')}
line={`${owner ? t('You were invited to join {org} on Hanzo.', { org: owner }) : t('Accept an invitation to an organization on Hanzo.')}${asking ? ` ${t('Sign in, or create your account, to accept it.')}` : ''}`}
terms={asking ? undefined : null}
signin={card}
/>
)
}