Files
billing/app/page.tsx
zeekay 497ac1679b feat(billing): self-serve card checkout for paid plans + logged-out public pricing
Paid tiers now check out with a real card instead of the mailto request-activation
panel. Clicking a paid plan opens a Square card form (the tokenize wiring is
extracted to lib/square.ts and reused by the top-up page — one way, no dup) and
POSTs commerce v1.49.5's /v1/billing/subscribe/card with planId + seat quantity +
a per-attempt idempotency key (sessionStorage: kept on transient errors, rotated
on decline, cleared on success). Server-authoritative price × max(1,quantity); no
client amount. Honest error UI per status: 402 declined -> try another card,
409 in-progress, 422 no processor -> contact fallback (the old mailto panel, kept
ONLY for 422), 500 charged-but-activation-failed. Free tiers keep POST
/v1/billing/subscriptions; platform admins keep the mint path (canDirectSubscribe).
Success refreshes subscription + balance state and shows the active plan.

Public pricing renders logged-out (no login wall): app/page.tsx no longer
auto-redirects to IAM — it shows PublicPricing, and auth gates only the subscribe
action (onRequireAuth -> start sign-in). The grid fetches the public catalog from
the GATEWAY api.hanzo.ai/v1/plans/subscriptions (verified 200 unauth + returns
access-control-allow-origin: https://billing.hanzo.ai) — NOT api.cloud.hanzo.ai,
which is public but emits no CORS header so a browser fetch would be blocked.

- v1.0.4
2026-07-20 16:22:38 -07:00

63 lines
2.2 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { getOrgByHost, type BillingOrgConfig } from '@/lib/config'
import { isLoggedIn, startLogin, getCurrentUser, type IamUser } from '@/lib/iam-auth'
import { makeAuthConf } from '@/lib/auth'
import { BillingShell } from '@/lib/components/billing-shell'
import { PublicPricing } from '@/lib/components/public-pricing'
export default function BillingPage() {
const [org, setOrg] = useState<BillingOrgConfig | null>(null)
const [user, setUser] = useState<IamUser | null>(null)
const [checked, setChecked] = useState(false)
const [authError, setAuthError] = useState<string | null>(null)
useEffect(() => {
const detectedOrg = getOrgByHost(window.location.host)
setOrg(detectedOrg)
// Do NOT auto-redirect to login: logged-out prospects see public pricing
// (no login wall). Auth gates only the subscribe action.
if (isLoggedIn()) setUser(getCurrentUser())
setChecked(true)
}, [])
const handleSignIn = useCallback(() => {
if (!org) return
startLogin(makeAuthConf(org)).catch((err) => {
setAuthError(err instanceof Error ? err.message : 'Could not start sign in')
})
}, [org])
if (!org || !checked) {
return (
<main className="flex min-h-screen items-center justify-center bg-bg">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-text-dim border-t-brand" />
</main>
)
}
if (authError) {
return (
<main className="flex min-h-screen items-center justify-center bg-bg p-8">
<div className="max-w-md rounded-2xl border border-border bg-bg-card p-8 text-center">
<h1 className="text-lg font-semibold text-text">Sign-in unavailable</h1>
<p className="mt-3 text-sm text-text-muted">{authError}</p>
<button
onClick={() => window.location.reload()}
className="mt-6 inline-block rounded-lg bg-brand px-4 py-2 text-sm font-medium text-brand-foreground transition hover:bg-brand-hover"
>
Try again
</button>
</div>
</main>
)
}
if (!user) {
return <PublicPricing onSignIn={handleSignIn} />
}
return <BillingShell org={org} user={user} />
}