Files
id/legacy-nextjs/components/LanguageDropdown.tsx
zeekayandGitHub 43090cdebf rewrite: Vite + @hanzo/gui monorepo (drops CF Worker + Next.js) (#2)
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
2026-05-24 06:00:20 -07:00

149 lines
5.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}