mirror of
https://github.com/hanzoai/id.git
synced 2026-11-03 00:42:22 +00:00
Replaces the Cloudflare Worker (hanzo.id-worker) and the Next.js portal
with a pnpm monorepo following the ~/work/liquidity/id pattern.
Layout:
apps/web/ Vite + React 19 SPA, embeds @hanzo/gui shell
k8s/ Deployment(2) + Service + Ingress (4 hosts, 4 TLS)
pkgs/shared/ @hanzo/id-shared — TenantConfig, resolveTenant,
loadBrand (browser + node)
pkgs/auth/ @hanzo/id-auth — AuthClient (wraps @hanzo/iam REST)
+ LoginForm/SignupForm/ForgotForm/OTPForm
pkgs/idv/ @hanzo/id-idv — pluggable IDV (stub, persona,
onfido, veriff) behind one
IDVProvider interface
legacy-nextjs/ Frozen — Next.js predecessor. Delete after v0.1.0 ships.
Dockerfile Two-stage: pnpm build → hanzoai/static:0.4.1 serves /spa
README.md
LLM.md Architecture, dev, deploy, cutover plan
Tenant resolution: hostname → TenantConfig (orgId, iamUrl, clientId,
appName, publicOrigin, brandPackage). Built-in defaults for
hanzo.id/lux.id/zoo.id/pars.id; runtime override via
IAM_TENANT_CONFIG_JSON env (served as /config.json at pod startup).
Brand resolution: each per-org brand pkg (@hanzo/brand, @luxfi/brand,
@zooai/brand, @parsdao/brand) ships brand.json. The Vite plugin
brandJsonPlugin emits /brand/<pkg>/brand.json verbatim; the browser
fetches the right one based on the resolved tenant. No bundle bloat.
IDV: stub (default for dev), persona, onfido, veriff. Each adapter
implements `IDVProvider` from pkgs/idv/src/provider.ts. Swap providers
with one registration call at boot — portal code unchanged.
Cutover (separate ops PR — not in this commit):
1. Tag + push image ghcr.io/hanzoai/id:0.1.0
2. Apply k8s manifests, cert-manager issues TLS
3. Remove CF Worker routes for hanzo.id/lux.id/zoo.id/pars.id
4. CF A records → 129.212.164.5 (hanzo ingress LB)
5. Archive hanzo.id-worker repo
149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useRef, useEffect } from 'react'
|
||
|
||
const LANGUAGES = [
|
||
{ code: 'en', label: 'English' },
|
||
{ code: 'zh', label: '中文' },
|
||
{ code: 'zh-TW', label: '繁體中文' },
|
||
{ code: 'ja', label: '日本語' },
|
||
{ code: 'ko', label: '한국어' },
|
||
{ code: 'es', label: 'Español' },
|
||
{ code: 'fr', label: 'Français' },
|
||
{ code: 'de', label: 'Deutsch' },
|
||
{ code: 'pt', label: 'Português' },
|
||
{ code: 'pt-BR', label: 'Português (BR)' },
|
||
{ code: 'it', label: 'Italiano' },
|
||
{ code: 'nl', label: 'Nederlands' },
|
||
{ code: 'pl', label: 'Polski' },
|
||
{ code: 'cs', label: 'Čeština' },
|
||
{ code: 'sk', label: 'Slovenčina' },
|
||
{ code: 'hu', label: 'Magyar' },
|
||
{ code: 'ro', label: 'Română' },
|
||
{ code: 'bg', label: 'Български' },
|
||
{ code: 'hr', label: 'Hrvatski' },
|
||
{ code: 'sr', label: 'Српски' },
|
||
{ code: 'sl', label: 'Slovenščina' },
|
||
{ code: 'uk', label: 'Українська' },
|
||
{ code: 'ru', label: 'Русский' },
|
||
{ code: 'el', label: 'Ελληνικά' },
|
||
{ code: 'tr', label: 'Türkçe' },
|
||
{ code: 'ar', label: 'العربية' },
|
||
{ code: 'fa', label: 'فارسی' },
|
||
{ code: 'he', label: 'עברית' },
|
||
{ code: 'hi', label: 'हिन्दी' },
|
||
{ code: 'bn', label: 'বাংলা' },
|
||
{ code: 'ta', label: 'தமிழ்' },
|
||
{ code: 'te', label: 'తెలుగు' },
|
||
{ code: 'mr', label: 'मराठी' },
|
||
{ code: 'gu', label: 'ગુજરાતી' },
|
||
{ code: 'kn', label: 'ಕನ್ನಡ' },
|
||
{ code: 'ml', label: 'മലയാളം' },
|
||
{ code: 'pa', label: 'ਪੰਜਾਬੀ' },
|
||
{ code: 'ur', label: 'اردو' },
|
||
{ code: 'th', label: 'ไทย' },
|
||
{ code: 'vi', label: 'Tiếng Việt' },
|
||
{ code: 'id', label: 'Bahasa Indonesia' },
|
||
{ code: 'ms', label: 'Bahasa Melayu' },
|
||
{ code: 'tl', label: 'Filipino' },
|
||
{ code: 'sw', label: 'Kiswahili' },
|
||
{ code: 'am', label: 'አማርኛ' },
|
||
{ code: 'ha', label: 'Hausa' },
|
||
{ code: 'yo', label: 'Yorùbá' },
|
||
{ code: 'ig', label: 'Igbo' },
|
||
{ code: 'zu', label: 'isiZulu' },
|
||
{ code: 'af', label: 'Afrikaans' },
|
||
{ code: 'sv', label: 'Svenska' },
|
||
{ code: 'da', label: 'Dansk' },
|
||
{ code: 'no', label: 'Norsk' },
|
||
{ code: 'fi', label: 'Suomi' },
|
||
{ code: 'et', label: 'Eesti' },
|
||
{ code: 'lv', label: 'Latviešu' },
|
||
{ code: 'lt', label: 'Lietuvių' },
|
||
{ code: 'ca', label: 'Català' },
|
||
{ code: 'eu', label: 'Euskara' },
|
||
{ code: 'gl', label: 'Galego' },
|
||
{ code: 'ka', label: 'ქართული' },
|
||
{ code: 'hy', label: 'Հայերեն' },
|
||
{ code: 'az', label: 'Azərbaycan' },
|
||
{ code: 'uz', label: 'Oʻzbek' },
|
||
{ code: 'kk', label: 'Қазақ' },
|
||
{ code: 'mn', label: 'Монгол' },
|
||
{ code: 'my', label: 'မြန်မာ' },
|
||
{ code: 'km', label: 'ភាសាខ្មែរ' },
|
||
{ code: 'lo', label: 'ລາວ' },
|
||
{ code: 'ne', label: 'नेपाली' },
|
||
{ code: 'si', label: 'සිංහල' },
|
||
]
|
||
|
||
export default function LanguageDropdown() {
|
||
const [open, setOpen] = useState(false)
|
||
const [lang, setLang] = useState('en')
|
||
const ref = useRef<HTMLDivElement>(null)
|
||
|
||
useEffect(() => {
|
||
// Read from localStorage or browser language
|
||
const saved = localStorage.getItem('hanzo_lang')
|
||
if (saved) {
|
||
setLang(saved)
|
||
} else {
|
||
const browserLang = navigator.language.split('-')[0]
|
||
const match = LANGUAGES.find(l => l.code === browserLang)
|
||
if (match) setLang(match.code)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
function handleClickOutside(e: MouseEvent) {
|
||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||
setOpen(false)
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||
}, [])
|
||
|
||
const handleSelect = (code: string) => {
|
||
setLang(code)
|
||
localStorage.setItem('hanzo_lang', code)
|
||
setOpen(false)
|
||
// IAM uses ?lang= param for locale
|
||
const url = new URL(window.location.href)
|
||
url.searchParams.set('lang', code)
|
||
window.location.href = url.toString()
|
||
}
|
||
|
||
const current = LANGUAGES.find(l => l.code === lang) || LANGUAGES[0]
|
||
|
||
return (
|
||
<div ref={ref} className="relative">
|
||
<button
|
||
onClick={() => setOpen(!open)}
|
||
className="flex items-center gap-1.5 p-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white transition-colors"
|
||
aria-label="Language"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||
</svg>
|
||
<span className="text-xs">{current.label}</span>
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="absolute right-0 top-full mt-1 w-40 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl z-50 py-1 max-h-64 overflow-y-auto">
|
||
{LANGUAGES.map((l) => (
|
||
<button
|
||
key={l.code}
|
||
onClick={() => handleSelect(l.code)}
|
||
className={`w-full text-left px-3 py-2 text-sm hover:bg-zinc-800 transition-colors ${
|
||
l.code === lang ? 'text-white' : 'text-zinc-400'
|
||
}`}
|
||
>
|
||
{l.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|