Commit e1d0c8338d1479c2951bee98c199eee83893d094
1 parent
4f88c6d4
feat(web): SPA i18n — locale context, en-US + zh-CN, language switcher
Adds client-side i18n infrastructure to the SPA (CLAUDE.md
guardrail #6: global/i18n from day one).
New files:
- i18n/messages.ts: flat key-value message bundles for en-US
and zh-CN. Keys use dot-notation (nav.*, action.*, status.*,
label.*). ~80 keys per locale covering navigation, actions,
status badges, and common labels.
- i18n/LocaleContext.tsx: LocaleProvider + useT() hook + useLocale()
hook. Active locale stored in localStorage, defaults to the
browser's navigator.language. Auto-detects zh-* → zh-CN.
Wired into the SPA:
- main.tsx wraps the app in <LocaleProvider>
- AppLayout sidebar uses t(key) for every heading and item
- Top bar has a locale dropdown (English / 中文) that
switches the entire sidebar + status labels instantly
- StatusBadge uses t('status.DRAFT') etc. so statuses render
as '草稿' / '已确认' / '已发货' in Chinese
The i18n system is intentionally simple: plain strings, no ICU
MessageFormat patterns (those live on the backend via ICU4J).
A future chunk can adopt @formatjs/intl-messageformat if the
SPA needs plural/gender/number formatting client-side.
Not yet translated: page-level titles and form labels (they
still use hard-coded English). The infrastructure is in place;
translating individual pages is incremental.
Showing
5 changed files
with
346 additions
and
66 deletions
web/src/components/StatusBadge.tsx
| 1 | +import { useT } from '@/i18n/LocaleContext' | |
| 2 | +import type { MessageKey } from '@/i18n/messages' | |
| 3 | + | |
| 1 | 4 | interface Props { |
| 2 | 5 | status: string |
| 3 | 6 | } |
| 4 | 7 | |
| 5 | -// Status → Tailwind class via the shared @layer components classes | |
| 6 | -// declared in index.css. Falls back to 'badge-pending' for unknown | |
| 7 | -// statuses so a new server-side enum value renders as a neutral pill | |
| 8 | -// instead of throwing. | |
| 8 | +const CLS_MAP: Record<string, string> = { | |
| 9 | + DRAFT: 'badge-draft', | |
| 10 | + CONFIRMED: 'badge-confirmed', | |
| 11 | + SHIPPED: 'badge-shipped', | |
| 12 | + RECEIVED: 'badge-received', | |
| 13 | + COMPLETED: 'badge-completed', | |
| 14 | + CANCELLED: 'badge-cancelled', | |
| 15 | + IN_PROGRESS: 'badge-in-progress', | |
| 16 | + PENDING: 'badge-pending', | |
| 17 | + POSTED: 'badge-confirmed', | |
| 18 | + SETTLED: 'badge-shipped', | |
| 19 | + REVERSED: 'badge-cancelled', | |
| 20 | +} | |
| 21 | + | |
| 9 | 22 | export function StatusBadge({ status }: Props) { |
| 10 | - const cls = | |
| 11 | - { | |
| 12 | - DRAFT: 'badge-draft', | |
| 13 | - CONFIRMED: 'badge-confirmed', | |
| 14 | - SHIPPED: 'badge-shipped', | |
| 15 | - RECEIVED: 'badge-received', | |
| 16 | - COMPLETED: 'badge-completed', | |
| 17 | - CANCELLED: 'badge-cancelled', | |
| 18 | - IN_PROGRESS: 'badge-in-progress', | |
| 19 | - PENDING: 'badge-pending', | |
| 20 | - POSTED: 'badge-confirmed', | |
| 21 | - SETTLED: 'badge-shipped', | |
| 22 | - REVERSED: 'badge-cancelled', | |
| 23 | - }[status] ?? 'badge-pending' | |
| 24 | - return <span className={cls}>{status}</span> | |
| 23 | + const t = useT() | |
| 24 | + const cls = CLS_MAP[status] ?? 'badge-pending' | |
| 25 | + const key = `status.${status}` as MessageKey | |
| 26 | + const label = t(key) | |
| 27 | + return <span className={cls}>{label}</span> | |
| 25 | 28 | } | ... | ... |
web/src/i18n/LocaleContext.tsx
0 → 100644
| 1 | +// vibe_erp SPA locale context. | |
| 2 | +// | |
| 3 | +// Provides a `useT()` hook that returns a translation function | |
| 4 | +// `t('key')` bound to the active locale. The active locale is | |
| 5 | +// stored in localStorage and defaults to the browser's language. | |
| 6 | +// | |
| 7 | +// **Why client-side, not server-driven.** The backend already has | |
| 8 | +// ICU4J i18n with per-plug-in locale chains. The SPA's UI chrome | |
| 9 | +// (sidebar, buttons, status labels) is a separate concern — it | |
| 10 | +// runs entirely in the browser and should not require an API call | |
| 11 | +// to render a button label. The two i18n systems share the same | |
| 12 | +// locale code (en-US, zh-CN) so they stay in sync when the user | |
| 13 | +// picks a language. | |
| 14 | + | |
| 15 | +import { | |
| 16 | + createContext, | |
| 17 | + useCallback, | |
| 18 | + useContext, | |
| 19 | + useState, | |
| 20 | + type ReactNode, | |
| 21 | +} from 'react' | |
| 22 | +import { en, locales, type LocaleCode, type MessageKey } from './messages' | |
| 23 | + | |
| 24 | +const STORAGE_KEY = 'vibeerp.locale' | |
| 25 | + | |
| 26 | +function detectLocale(): LocaleCode { | |
| 27 | + const stored = localStorage.getItem(STORAGE_KEY) | |
| 28 | + if (stored && stored in locales) return stored as LocaleCode | |
| 29 | + | |
| 30 | + const browserLang = navigator.language | |
| 31 | + if (browserLang.startsWith('zh')) return 'zh-CN' | |
| 32 | + return 'en-US' | |
| 33 | +} | |
| 34 | + | |
| 35 | +interface LocaleState { | |
| 36 | + locale: LocaleCode | |
| 37 | + setLocale: (code: LocaleCode) => void | |
| 38 | + t: (key: MessageKey) => string | |
| 39 | +} | |
| 40 | + | |
| 41 | +const LocaleContext = createContext<LocaleState | null>(null) | |
| 42 | + | |
| 43 | +export function LocaleProvider({ children }: { children: ReactNode }) { | |
| 44 | + const [locale, setLocaleState] = useState<LocaleCode>(detectLocale) | |
| 45 | + | |
| 46 | + const setLocale = useCallback((code: LocaleCode) => { | |
| 47 | + localStorage.setItem(STORAGE_KEY, code) | |
| 48 | + setLocaleState(code) | |
| 49 | + }, []) | |
| 50 | + | |
| 51 | + const messages = locales[locale] | |
| 52 | + | |
| 53 | + const t = useCallback( | |
| 54 | + (key: MessageKey): string => messages[key] ?? en[key] ?? key, | |
| 55 | + [messages], | |
| 56 | + ) | |
| 57 | + | |
| 58 | + return ( | |
| 59 | + <LocaleContext.Provider value={{ locale, setLocale, t }}> | |
| 60 | + {children} | |
| 61 | + </LocaleContext.Provider> | |
| 62 | + ) | |
| 63 | +} | |
| 64 | + | |
| 65 | +export function useT() { | |
| 66 | + const ctx = useContext(LocaleContext) | |
| 67 | + if (!ctx) throw new Error('useT must be used inside <LocaleProvider>') | |
| 68 | + return ctx.t | |
| 69 | +} | |
| 70 | + | |
| 71 | +export function useLocale() { | |
| 72 | + const ctx = useContext(LocaleContext) | |
| 73 | + if (!ctx) throw new Error('useLocale must be used inside <LocaleProvider>') | |
| 74 | + return ctx | |
| 75 | +} | |
| 76 | + | |
| 77 | +export const AVAILABLE_LOCALES: { code: LocaleCode; label: string }[] = [ | |
| 78 | + { code: 'en-US', label: 'English' }, | |
| 79 | + { code: 'zh-CN', label: '中文' }, | |
| 80 | +] | ... | ... |
web/src/i18n/messages.ts
0 → 100644
| 1 | +// vibe_erp SPA message bundles. | |
| 2 | +// | |
| 3 | +// Each locale is a flat key→string map. Keys use dot-notation | |
| 4 | +// grouped by area (nav.*, page.*, action.*, status.*, label.*). | |
| 5 | +// | |
| 6 | +// The framework's backend i18n uses ICU4J with MessageFormat | |
| 7 | +// patterns. The SPA uses plain strings for v1 — ICU MessageFormat | |
| 8 | +// in the browser (via @formatjs/intl-messageformat) is a v1.x | |
| 9 | +// enhancement when plural/gender/number formatting is needed | |
| 10 | +// client-side. | |
| 11 | + | |
| 12 | +export type MessageKey = keyof typeof en | |
| 13 | + | |
| 14 | +export const en = { | |
| 15 | + // ─── Navigation ──────────────────────────────────────────── | |
| 16 | + 'nav.overview': 'Overview', | |
| 17 | + 'nav.dashboard': 'Dashboard', | |
| 18 | + 'nav.catalog': 'Catalog & Partners', | |
| 19 | + 'nav.items': 'Items', | |
| 20 | + 'nav.uoms': 'Units of Measure', | |
| 21 | + 'nav.partners': 'Partners', | |
| 22 | + 'nav.inventory': 'Inventory', | |
| 23 | + 'nav.locations': 'Locations', | |
| 24 | + 'nav.balances': 'Stock Balances', | |
| 25 | + 'nav.movements': 'Stock Movements', | |
| 26 | + 'nav.orders': 'Orders', | |
| 27 | + 'nav.salesOrders': 'Sales Orders', | |
| 28 | + 'nav.purchaseOrders': 'Purchase Orders', | |
| 29 | + 'nav.production': 'Production', | |
| 30 | + 'nav.workOrders': 'Work Orders', | |
| 31 | + 'nav.shopFloor': 'Shop Floor', | |
| 32 | + 'nav.finance': 'Finance', | |
| 33 | + 'nav.accounts': 'Chart of Accounts', | |
| 34 | + 'nav.journalEntries': 'Journal Entries', | |
| 35 | + 'nav.system': 'System', | |
| 36 | + 'nav.users': 'Users', | |
| 37 | + 'nav.roles': 'Roles', | |
| 38 | + | |
| 39 | + // ─── Actions ─────────────────────────────────────────────── | |
| 40 | + 'action.confirm': 'Confirm', | |
| 41 | + 'action.ship': 'Ship', | |
| 42 | + 'action.receive': 'Receive', | |
| 43 | + 'action.cancel': 'Cancel', | |
| 44 | + 'action.start': 'Start', | |
| 45 | + 'action.complete': 'Complete', | |
| 46 | + 'action.create': 'Create', | |
| 47 | + 'action.save': 'Save', | |
| 48 | + 'action.back': '← Back', | |
| 49 | + 'action.logout': 'Logout', | |
| 50 | + 'action.signIn': 'Sign in', | |
| 51 | + 'action.signingIn': 'Signing in…', | |
| 52 | + 'action.creating': 'Creating…', | |
| 53 | + 'action.addLine': '+ Add line', | |
| 54 | + 'action.assign': 'Assign', | |
| 55 | + 'action.revoke': 'Revoke', | |
| 56 | + 'action.newItem': '+ New Item', | |
| 57 | + 'action.newPartner': '+ New Partner', | |
| 58 | + 'action.newLocation': '+ New Location', | |
| 59 | + 'action.newOrder': '+ New Order', | |
| 60 | + 'action.newWorkOrder': '+ New Work Order', | |
| 61 | + 'action.newUser': '+ New User', | |
| 62 | + 'action.newRole': '+ New Role', | |
| 63 | + 'action.newAccount': '+ New Account', | |
| 64 | + 'action.adjustStock': 'Adjust Stock', | |
| 65 | + | |
| 66 | + // ─── Status badges ──────────────────────────────────────── | |
| 67 | + 'status.DRAFT': 'Draft', | |
| 68 | + 'status.CONFIRMED': 'Confirmed', | |
| 69 | + 'status.SHIPPED': 'Shipped', | |
| 70 | + 'status.RECEIVED': 'Received', | |
| 71 | + 'status.COMPLETED': 'Completed', | |
| 72 | + 'status.CANCELLED': 'Cancelled', | |
| 73 | + 'status.IN_PROGRESS': 'In Progress', | |
| 74 | + 'status.PENDING': 'Pending', | |
| 75 | + 'status.POSTED': 'Posted', | |
| 76 | + 'status.SETTLED': 'Settled', | |
| 77 | + 'status.REVERSED': 'Reversed', | |
| 78 | + | |
| 79 | + // ─── Common labels ──────────────────────────────────────── | |
| 80 | + 'label.code': 'Code', | |
| 81 | + 'label.name': 'Name', | |
| 82 | + 'label.type': 'Type', | |
| 83 | + 'label.status': 'Status', | |
| 84 | + 'label.active': 'Active', | |
| 85 | + 'label.email': 'Email', | |
| 86 | + 'label.phone': 'Phone', | |
| 87 | + 'label.total': 'Total', | |
| 88 | + 'label.quantity': 'Quantity', | |
| 89 | + 'label.unitPrice': 'Unit Price', | |
| 90 | + 'label.date': 'Date', | |
| 91 | + 'label.currency': 'Currency', | |
| 92 | + 'label.description': 'Description', | |
| 93 | + 'label.username': 'Username', | |
| 94 | + 'label.password': 'Password', | |
| 95 | + 'label.signedInAs': 'Signed in as', | |
| 96 | + 'label.noRows': 'No rows.', | |
| 97 | + 'label.loading': 'Loading…', | |
| 98 | + 'label.error': 'Error', | |
| 99 | +} as const | |
| 100 | + | |
| 101 | +export const zhCN: Record<MessageKey, string> = { | |
| 102 | + // ─── 导航 ────────────────────────────────────────────────── | |
| 103 | + 'nav.overview': '概览', | |
| 104 | + 'nav.dashboard': '仪表盘', | |
| 105 | + 'nav.catalog': '产品与合作伙伴', | |
| 106 | + 'nav.items': '物料', | |
| 107 | + 'nav.uoms': '计量单位', | |
| 108 | + 'nav.partners': '合作伙伴', | |
| 109 | + 'nav.inventory': '库存', | |
| 110 | + 'nav.locations': '库位', | |
| 111 | + 'nav.balances': '库存余额', | |
| 112 | + 'nav.movements': '库存变动', | |
| 113 | + 'nav.orders': '订单', | |
| 114 | + 'nav.salesOrders': '销售订单', | |
| 115 | + 'nav.purchaseOrders': '采购订单', | |
| 116 | + 'nav.production': '生产', | |
| 117 | + 'nav.workOrders': '工单', | |
| 118 | + 'nav.shopFloor': '车间看板', | |
| 119 | + 'nav.finance': '财务', | |
| 120 | + 'nav.accounts': '科目表', | |
| 121 | + 'nav.journalEntries': '日记账', | |
| 122 | + 'nav.system': '系统', | |
| 123 | + 'nav.users': '用户', | |
| 124 | + 'nav.roles': '角色', | |
| 125 | + | |
| 126 | + // ─── 操作 ────────────────────────────────────────────────── | |
| 127 | + 'action.confirm': '确认', | |
| 128 | + 'action.ship': '发货', | |
| 129 | + 'action.receive': '收货', | |
| 130 | + 'action.cancel': '取消', | |
| 131 | + 'action.start': '开始', | |
| 132 | + 'action.complete': '完成', | |
| 133 | + 'action.create': '创建', | |
| 134 | + 'action.save': '保存', | |
| 135 | + 'action.back': '← 返回', | |
| 136 | + 'action.logout': '退出', | |
| 137 | + 'action.signIn': '登录', | |
| 138 | + 'action.signingIn': '登录中…', | |
| 139 | + 'action.creating': '创建中…', | |
| 140 | + 'action.addLine': '+ 添加行', | |
| 141 | + 'action.assign': '分配', | |
| 142 | + 'action.revoke': '撤销', | |
| 143 | + 'action.newItem': '+ 新物料', | |
| 144 | + 'action.newPartner': '+ 新合作伙伴', | |
| 145 | + 'action.newLocation': '+ 新库位', | |
| 146 | + 'action.newOrder': '+ 新订单', | |
| 147 | + 'action.newWorkOrder': '+ 新工单', | |
| 148 | + 'action.newUser': '+ 新用户', | |
| 149 | + 'action.newRole': '+ 新角色', | |
| 150 | + 'action.newAccount': '+ 新科目', | |
| 151 | + 'action.adjustStock': '库存调整', | |
| 152 | + | |
| 153 | + // ─── 状态 ────────────────────────────────────────────────── | |
| 154 | + 'status.DRAFT': '草稿', | |
| 155 | + 'status.CONFIRMED': '已确认', | |
| 156 | + 'status.SHIPPED': '已发货', | |
| 157 | + 'status.RECEIVED': '已收货', | |
| 158 | + 'status.COMPLETED': '已完成', | |
| 159 | + 'status.CANCELLED': '已取消', | |
| 160 | + 'status.IN_PROGRESS': '进行中', | |
| 161 | + 'status.PENDING': '待处理', | |
| 162 | + 'status.POSTED': '已过账', | |
| 163 | + 'status.SETTLED': '已结算', | |
| 164 | + 'status.REVERSED': '已冲销', | |
| 165 | + | |
| 166 | + // ─── 通用标签 ────────────────────────────────────────────── | |
| 167 | + 'label.code': '编码', | |
| 168 | + 'label.name': '名称', | |
| 169 | + 'label.type': '类型', | |
| 170 | + 'label.status': '状态', | |
| 171 | + 'label.active': '启用', | |
| 172 | + 'label.email': '邮箱', | |
| 173 | + 'label.phone': '电话', | |
| 174 | + 'label.total': '合计', | |
| 175 | + 'label.quantity': '数量', | |
| 176 | + 'label.unitPrice': '单价', | |
| 177 | + 'label.date': '日期', | |
| 178 | + 'label.currency': '币种', | |
| 179 | + 'label.description': '描述', | |
| 180 | + 'label.username': '用户名', | |
| 181 | + 'label.password': '密码', | |
| 182 | + 'label.signedInAs': '当前用户', | |
| 183 | + 'label.noRows': '暂无数据', | |
| 184 | + 'label.loading': '加载中…', | |
| 185 | + 'label.error': '错误', | |
| 186 | +} | |
| 187 | + | |
| 188 | +export const locales = { | |
| 189 | + 'en-US': en, | |
| 190 | + 'zh-CN': zhCN, | |
| 191 | +} as const | |
| 192 | + | |
| 193 | +export type LocaleCode = keyof typeof locales | ... | ... |
web/src/layout/AppLayout.tsx
| 1 | -// vibe_erp main app shell. | |
| 2 | -// | |
| 3 | -// Top bar (logo + version + user + logout) and a permanent left | |
| 4 | -// sidebar grouped by Packaged Business Capability (PBC). The | |
| 5 | -// `<Outlet />` renders whichever child route the router matched. | |
| 6 | -// | |
| 7 | -// **Why a static nav array.** The framework already exposes | |
| 8 | -// menu metadata at /api/v1/_meta/metadata, but plumbing that into | |
| 9 | -// the SPA is a Phase 3 (Tier 1 customization) concern. v1 SPA | |
| 10 | -// hard-codes a sensible default that mirrors the implemented PBCs; | |
| 11 | -// a future chunk replaces this with a metadata-driven sidebar so | |
| 12 | -// plug-ins can contribute new menus through their YAML. | |
| 1 | +// vibe_erp main app shell with i18n support. | |
| 13 | 2 | |
| 14 | 3 | import { NavLink, Outlet } from 'react-router-dom' |
| 15 | 4 | import { useEffect, useState } from 'react' |
| 16 | 5 | import { useAuth } from '@/auth/AuthContext' |
| 6 | +import { useLocale, useT, AVAILABLE_LOCALES } from '@/i18n/LocaleContext' | |
| 7 | +import type { MessageKey } from '@/i18n/messages' | |
| 17 | 8 | import { meta } from '@/api/client' |
| 18 | 9 | import type { MetaInfo } from '@/types/api' |
| 19 | 10 | |
| 20 | 11 | interface NavItem { |
| 21 | 12 | to: string |
| 22 | - label: string | |
| 13 | + labelKey: MessageKey | |
| 23 | 14 | } |
| 24 | 15 | interface NavGroup { |
| 25 | - heading: string | |
| 16 | + headingKey: MessageKey | |
| 26 | 17 | items: NavItem[] |
| 27 | 18 | } |
| 28 | 19 | |
| 29 | 20 | const NAV: NavGroup[] = [ |
| 30 | 21 | { |
| 31 | - heading: 'Overview', | |
| 32 | - items: [{ to: '/', label: 'Dashboard' }], | |
| 22 | + headingKey: 'nav.overview', | |
| 23 | + items: [{ to: '/', labelKey: 'nav.dashboard' }], | |
| 33 | 24 | }, |
| 34 | 25 | { |
| 35 | - heading: 'Catalog & Partners', | |
| 26 | + headingKey: 'nav.catalog', | |
| 36 | 27 | items: [ |
| 37 | - { to: '/items', label: 'Items' }, | |
| 38 | - { to: '/uoms', label: 'Units of Measure' }, | |
| 39 | - { to: '/partners', label: 'Partners' }, | |
| 28 | + { to: '/items', labelKey: 'nav.items' }, | |
| 29 | + { to: '/uoms', labelKey: 'nav.uoms' }, | |
| 30 | + { to: '/partners', labelKey: 'nav.partners' }, | |
| 40 | 31 | ], |
| 41 | 32 | }, |
| 42 | 33 | { |
| 43 | - heading: 'Inventory', | |
| 34 | + headingKey: 'nav.inventory', | |
| 44 | 35 | items: [ |
| 45 | - { to: '/locations', label: 'Locations' }, | |
| 46 | - { to: '/balances', label: 'Stock Balances' }, | |
| 47 | - { to: '/movements', label: 'Stock Movements' }, | |
| 36 | + { to: '/locations', labelKey: 'nav.locations' }, | |
| 37 | + { to: '/balances', labelKey: 'nav.balances' }, | |
| 38 | + { to: '/movements', labelKey: 'nav.movements' }, | |
| 48 | 39 | ], |
| 49 | 40 | }, |
| 50 | 41 | { |
| 51 | - heading: 'Orders', | |
| 42 | + headingKey: 'nav.orders', | |
| 52 | 43 | items: [ |
| 53 | - { to: '/sales-orders', label: 'Sales Orders' }, | |
| 54 | - { to: '/purchase-orders', label: 'Purchase Orders' }, | |
| 44 | + { to: '/sales-orders', labelKey: 'nav.salesOrders' }, | |
| 45 | + { to: '/purchase-orders', labelKey: 'nav.purchaseOrders' }, | |
| 55 | 46 | ], |
| 56 | 47 | }, |
| 57 | 48 | { |
| 58 | - heading: 'Production', | |
| 49 | + headingKey: 'nav.production', | |
| 59 | 50 | items: [ |
| 60 | - { to: '/work-orders', label: 'Work Orders' }, | |
| 61 | - { to: '/shop-floor', label: 'Shop Floor' }, | |
| 51 | + { to: '/work-orders', labelKey: 'nav.workOrders' }, | |
| 52 | + { to: '/shop-floor', labelKey: 'nav.shopFloor' }, | |
| 62 | 53 | ], |
| 63 | 54 | }, |
| 64 | 55 | { |
| 65 | - heading: 'Finance', | |
| 56 | + headingKey: 'nav.finance', | |
| 66 | 57 | items: [ |
| 67 | - { to: '/accounts', label: 'Chart of Accounts' }, | |
| 68 | - { to: '/journal-entries', label: 'Journal Entries' }, | |
| 58 | + { to: '/accounts', labelKey: 'nav.accounts' }, | |
| 59 | + { to: '/journal-entries', labelKey: 'nav.journalEntries' }, | |
| 69 | 60 | ], |
| 70 | 61 | }, |
| 71 | 62 | { |
| 72 | - heading: 'System', | |
| 63 | + headingKey: 'nav.system', | |
| 73 | 64 | items: [ |
| 74 | - { to: '/users', label: 'Users' }, | |
| 75 | - { to: '/roles', label: 'Roles' }, | |
| 65 | + { to: '/users', labelKey: 'nav.users' }, | |
| 66 | + { to: '/roles', labelKey: 'nav.roles' }, | |
| 76 | 67 | ], |
| 77 | 68 | }, |
| 78 | 69 | ] |
| 79 | 70 | |
| 80 | 71 | export function AppLayout() { |
| 81 | 72 | const { username, logout } = useAuth() |
| 73 | + const { locale, setLocale } = useLocale() | |
| 74 | + const t = useT() | |
| 82 | 75 | const [info, setInfo] = useState<MetaInfo | null>(null) |
| 83 | 76 | |
| 84 | 77 | useEffect(() => { |
| ... | ... | @@ -99,9 +92,9 @@ export function AppLayout() { |
| 99 | 92 | </div> |
| 100 | 93 | <nav className="flex-1 overflow-y-auto px-3 py-4"> |
| 101 | 94 | {NAV.map((group) => ( |
| 102 | - <div key={group.heading} className="mb-5"> | |
| 95 | + <div key={group.headingKey} className="mb-5"> | |
| 103 | 96 | <div className="px-2 text-xs font-semibold uppercase tracking-wider text-slate-400"> |
| 104 | - {group.heading} | |
| 97 | + {t(group.headingKey)} | |
| 105 | 98 | </div> |
| 106 | 99 | <ul className="mt-1 space-y-0.5"> |
| 107 | 100 | {group.items.map((item) => ( |
| ... | ... | @@ -118,7 +111,7 @@ export function AppLayout() { |
| 118 | 111 | ].join(' ') |
| 119 | 112 | } |
| 120 | 113 | > |
| 121 | - {item.label} | |
| 114 | + {t(item.labelKey)} | |
| 122 | 115 | </NavLink> |
| 123 | 116 | </li> |
| 124 | 117 | ))} |
| ... | ... | @@ -141,17 +134,25 @@ export function AppLayout() { |
| 141 | 134 | <header className="flex h-14 items-center justify-between border-b border-slate-200 bg-white px-6 sm:justify-end"> |
| 142 | 135 | <span className="text-lg font-bold text-brand-600 sm:hidden">vibe_erp</span> |
| 143 | 136 | <div className="flex items-center gap-3"> |
| 137 | + <select | |
| 138 | + value={locale} | |
| 139 | + onChange={(e) => setLocale(e.target.value as typeof locale)} | |
| 140 | + className="rounded-md border border-slate-200 bg-slate-50 px-2 py-1 text-xs text-slate-600" | |
| 141 | + > | |
| 142 | + {AVAILABLE_LOCALES.map((l) => ( | |
| 143 | + <option key={l.code} value={l.code}>{l.label}</option> | |
| 144 | + ))} | |
| 145 | + </select> | |
| 144 | 146 | <span className="text-sm text-slate-500"> |
| 145 | 147 | {username ? ( |
| 146 | 148 | <> |
| 147 | - Signed in as <span className="font-medium text-slate-700">{username}</span> | |
| 149 | + {t('label.signedInAs')}{' '} | |
| 150 | + <span className="font-medium text-slate-700">{username}</span> | |
| 148 | 151 | </> |
| 149 | - ) : ( | |
| 150 | - 'Signed in' | |
| 151 | - )} | |
| 152 | + ) : null} | |
| 152 | 153 | </span> |
| 153 | 154 | <button className="btn-secondary" onClick={logout}> |
| 154 | - Logout | |
| 155 | + {t('action.logout')} | |
| 155 | 156 | </button> |
| 156 | 157 | </div> |
| 157 | 158 | </header> | ... | ... |
web/src/main.tsx
| ... | ... | @@ -3,14 +3,17 @@ import ReactDOM from 'react-dom/client' |
| 3 | 3 | import { BrowserRouter } from 'react-router-dom' |
| 4 | 4 | import App from './App' |
| 5 | 5 | import { AuthProvider } from './auth/AuthContext' |
| 6 | +import { LocaleProvider } from './i18n/LocaleContext' | |
| 6 | 7 | import './index.css' |
| 7 | 8 | |
| 8 | 9 | ReactDOM.createRoot(document.getElementById('root')!).render( |
| 9 | 10 | <React.StrictMode> |
| 10 | 11 | <BrowserRouter> |
| 11 | - <AuthProvider> | |
| 12 | - <App /> | |
| 13 | - </AuthProvider> | |
| 12 | + <LocaleProvider> | |
| 13 | + <AuthProvider> | |
| 14 | + <App /> | |
| 15 | + </AuthProvider> | |
| 16 | + </LocaleProvider> | |
| 14 | 17 | </BrowserRouter> |
| 15 | 18 | </React.StrictMode>, |
| 16 | 19 | ) | ... | ... |