mirror of
https://github.com/hanzoai/billing.git
synced 2026-10-30 18:46:54 +00:00
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
63 lines
2.2 KiB
TypeScript
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} />
|
|
}
|