(null);
+ // Session 12 — Africa-language users see VYNDR Africa first. The
+ // tier order is stable per locale (no flicker between renders).
+ // Browser region (NG / KE / ZA / GH) isn't available server-side
+ // without IP geolocation, so we use the locale as a proxy. Users
+ // outside the locale set can still pick the Africa tier; it just
+ // doesn't lead the card grid for them.
+ const orderedTiers = AFRICA_LOCALES.has(locale)
+ ? [TIERS.find((x) => x.id === 'africa')!, TIERS.find((x) => x.id === 'free')!,
+ TIERS.find((x) => x.id === 'analyst')!, TIERS.find((x) => x.id === 'desk')!]
+ : TIERS;
+
async function startCheckout(tier: TierId) {
setError(null);
@@ -100,6 +138,16 @@ export default function Pricing() {
return;
}
+ // Session 12 — Africa tier: Stripe product + backend validation
+ // not yet wired (intentional this session). Show an honest
+ // "coming soon" instead of a 400. When STRIPE_PRICE_AFRICA is
+ // configured AND the backend accepts the tier, this short-circuit
+ // gets removed and the standard checkout path takes over.
+ if (tier === 'africa') {
+ setError('VYNDR Africa launches once Stripe regional processing is finalized. Email support@vyndr.app to lock the $4.99/mo founder price.');
+ return;
+ }
+
// Anonymous → bounce to signup with a returnTo back to /#pricing.
if (!session) {
router.push('/signup?return=/%23pricing');
@@ -171,7 +219,7 @@ export default function Pricing() {
)}
- {TIERS.map((tier, i) => {
+ {orderedTiers.map((tier, i) => {
const isPending = pending === tier.id;
const isDisabled = authLoading || (pending !== null && !isPending);
return (
@@ -270,7 +318,15 @@ export default function Pricing() {
}
@media (min-width: 768px) {
:global(.pricing-grid) {
- grid-template-columns: repeat(3, 1fr);
+ /* Session 12 — Africa tier brings the count to 4. On
+ tablet we stay 2-up so cards don't squeeze; desktop
+ unfolds to 4-up at >=1100px. */
+ grid-template-columns: repeat(2, 1fr);
+ }
+ }
+ @media (min-width: 1100px) {
+ :global(.pricing-grid) {
+ grid-template-columns: repeat(4, 1fr);
}
}
`}
diff --git a/web/src/contexts/LocaleContext.tsx b/web/src/contexts/LocaleContext.tsx
new file mode 100644
index 0000000..d89c85c
--- /dev/null
+++ b/web/src/contexts/LocaleContext.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import { createContext, useContext, useMemo, ReactNode } from 'react';
+import { Locale, DEFAULT_LOCALE, isLocale, LOCALE_META } from '@/lib/locales';
+import { getTranslations, TFunction } from '@/lib/i18n';
+
+/**
+ * Client-side locale context (Session 12).
+ *
+ * The root layout (server component) resolves the locale from the
+ * request header and passes it as a prop to ``. From
+ * there every client component can `useT()` without prop-drilling.
+ *
+ * Memoized: the `t` function is stable per render of the provider,
+ * so consumers don't re-render on every parent render.
+ */
+
+interface LocaleContextValue {
+ locale: Locale;
+ dir: 'ltr' | 'rtl';
+ t: TFunction;
+}
+
+const LocaleContext = createContext(null);
+
+export function LocaleProvider({ locale, children }: { locale: string; children: ReactNode }) {
+ const value = useMemo(() => {
+ const resolved: Locale = isLocale(locale) ? locale : DEFAULT_LOCALE;
+ const bundle = getTranslations(resolved);
+ return { locale: resolved, dir: LOCALE_META[resolved].dir, t: bundle.t };
+ }, [locale]);
+ return {children};
+}
+
+export function useT(): TFunction {
+ const ctx = useContext(LocaleContext);
+ if (!ctx) {
+ // Fall back to English silently — better than a crash if some
+ // component renders outside the provider (test envs, storybook).
+ return getTranslations('en').t;
+ }
+ return ctx.t;
+}
+
+export function useLocale(): { locale: Locale; dir: 'ltr' | 'rtl' } {
+ const ctx = useContext(LocaleContext);
+ if (!ctx) return { locale: DEFAULT_LOCALE, dir: 'ltr' };
+ return { locale: ctx.locale, dir: ctx.dir };
+}
diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts
new file mode 100644
index 0000000..bbc5d56
--- /dev/null
+++ b/web/src/lib/i18n.ts
@@ -0,0 +1,115 @@
+/**
+ * Translation helpers (Session 12).
+ *
+ * Two surfaces:
+ * - `getTranslations(locale)` — synchronous loader used by server
+ * components. Returns `{ t, locale, dir }`.
+ * - `useT()` + `` — client-side hook backed by a
+ * React context populated by the
+ * root layout.
+ *
+ * Translation keys use dot notation: `t('nav.home')` → 'Home'. Missing
+ * keys fall back to English, then to the key itself (visible during
+ * dev so the gap is obvious, harmless in prod).
+ *
+ * No async fetch — JSON files are bundled at build time. The bundle
+ * cost is ~3 KB per locale gzipped (we ship all 10 even on en pages
+ * for now; a future optimization is dynamic import per locale).
+ */
+
+import en from '@/locales/en.json';
+import es from '@/locales/es.json';
+import fr from '@/locales/fr.json';
+import pt from '@/locales/pt.json';
+import ar from '@/locales/ar.json';
+import sw from '@/locales/sw.json';
+import hi from '@/locales/hi.json';
+import ja from '@/locales/ja.json';
+import ko from '@/locales/ko.json';
+import zh from '@/locales/zh.json';
+
+import { Locale, DEFAULT_LOCALE, isLocale, LOCALE_META } from './locales';
+
+type Dict = Record;
+
+const DICTS: Record = {
+ en: en as Dict,
+ es: es as Dict,
+ fr: fr as Dict,
+ pt: pt as Dict,
+ ar: ar as Dict,
+ sw: sw as Dict,
+ hi: hi as Dict,
+ ja: ja as Dict,
+ ko: ko as Dict,
+ zh: zh as Dict,
+};
+
+function getByPath(dict: Dict, path: string): string | null {
+ const parts = path.split('.');
+ let cursor: unknown = dict;
+ for (const part of parts) {
+ if (!cursor || typeof cursor !== 'object') return null;
+ cursor = (cursor as Dict)[part];
+ }
+ return typeof cursor === 'string' ? cursor : null;
+}
+
+export type TFunction = (key: string, vars?: Record) => string;
+
+function interpolate(template: string, vars?: Record): string {
+ if (!vars) return template;
+ return template.replace(/\{(\w+)\}/g, (_, name) => {
+ const v = vars[name];
+ return v == null ? `{${name}}` : String(v);
+ });
+}
+
+function makeT(locale: Locale): TFunction {
+ const primary = DICTS[locale] || DICTS[DEFAULT_LOCALE];
+ const fallback = DICTS[DEFAULT_LOCALE];
+ return function t(key, vars) {
+ const hit = getByPath(primary, key);
+ if (hit !== null) return interpolate(hit, vars);
+ // Fallback to English so we never render a raw key in prod just
+ // because a translation is missing.
+ const fallbackHit = getByPath(fallback, key);
+ if (fallbackHit !== null) return interpolate(fallbackHit, vars);
+ // Last resort — the key itself. Makes missing strings visible
+ // during dev without crashing.
+ return key;
+ };
+}
+
+export interface TranslationBundle {
+ locale: Locale;
+ dir: 'ltr' | 'rtl';
+ t: TFunction;
+}
+
+export function getTranslations(locale: string | null | undefined): TranslationBundle {
+ const resolved: Locale = isLocale(locale) ? locale : DEFAULT_LOCALE;
+ return {
+ locale: resolved,
+ dir: LOCALE_META[resolved].dir,
+ t: makeT(resolved),
+ };
+}
+
+/**
+ * Server-component convenience — reads the locale header that the
+ * middleware stamped on the request and returns a translation bundle.
+ * Only safe to call in a server component (uses next/headers).
+ *
+ * The dynamic import keeps `next/headers` out of client bundles even
+ * though this file is imported from both contexts.
+ */
+export async function getServerTranslations(): Promise {
+ // Inline require so the client bundle never sees `next/headers`.
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { headers } = require('next/headers');
+ const hdr = await headers();
+ // The header name lives in lib/locales to keep middleware + here in sync.
+ const { LOCALE_HEADER } = await import('./locales');
+ return getTranslations(hdr.get(LOCALE_HEADER));
+}
diff --git a/web/src/lib/locales.ts b/web/src/lib/locales.ts
new file mode 100644
index 0000000..d353125
--- /dev/null
+++ b/web/src/lib/locales.ts
@@ -0,0 +1,47 @@
+/**
+ * Locale registry (Session 12).
+ *
+ * Single source of truth for which languages the app supports.
+ * Imported by the middleware, the translation loader, and the locale
+ * switcher so adding a new language is a one-file change here plus a
+ * matching JSON file in `web/src/locales/`.
+ *
+ * RTL languages get `dir: 'rtl'` so the root layout can toggle the
+ * `` attribute without a per-locale lookup.
+ */
+
+export const LOCALES = [
+ 'en', 'es', 'fr', 'pt', 'ar', 'sw', 'hi', 'ja', 'ko', 'zh',
+] as const;
+
+export type Locale = (typeof LOCALES)[number];
+
+export const DEFAULT_LOCALE: Locale = 'en';
+
+export const LOCALE_META: Record = {
+ en: { label: 'English', native: 'English', dir: 'ltr', region: 'Global' },
+ es: { label: 'Spanish', native: 'Español', dir: 'ltr', region: 'Latin America / Spain' },
+ fr: { label: 'French', native: 'Français', dir: 'ltr', region: 'France / West Africa' },
+ pt: { label: 'Portuguese', native: 'Português', dir: 'ltr', region: 'Brazil / Portugal' },
+ ar: { label: 'Arabic', native: 'العربية', dir: 'rtl', region: 'MENA' },
+ sw: { label: 'Swahili', native: 'Kiswahili', dir: 'ltr', region: 'East Africa' },
+ hi: { label: 'Hindi', native: 'हिन्दी', dir: 'ltr', region: 'India' },
+ ja: { label: 'Japanese', native: '日本語', dir: 'ltr', region: 'Japan' },
+ ko: { label: 'Korean', native: '한국어', dir: 'ltr', region: 'South Korea' },
+ zh: { label: 'Chinese', native: '中文', dir: 'ltr', region: 'China' },
+};
+
+// Localess that map to predominantly-African markets — used by the
+// pricing page to surface the Africa tier first. Browser region
+// codes (NG/KE/ZA/GH/...) are checked separately at the component
+// layer.
+export const AFRICA_LOCALES: ReadonlySet = new Set(['sw']);
+
+export function isLocale(value: string | null | undefined): value is Locale {
+ return !!value && (LOCALES as readonly string[]).includes(value);
+}
+
+// Cookie name + locale-detection header name (set by middleware,
+// read by server components via next/headers).
+export const LOCALE_COOKIE = 'NEXT_LOCALE';
+export const LOCALE_HEADER = 'x-vyndr-locale';
diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json
new file mode 100644
index 0000000..3850b2e
--- /dev/null
+++ b/web/src/locales/ar.json
@@ -0,0 +1,87 @@
+{
+ "_meta": {
+ "locale": "ar",
+ "dir": "rtl",
+ "review_status": "translated_unreviewed",
+ "note": "Translations should be reviewed by a native Arabic speaker before production. Sports-betting context is region-sensitive."
+ },
+ "nav": {
+ "home": "الرئيسية",
+ "scan": "تحليل",
+ "pricing": "الأسعار",
+ "ledger": "السجل",
+ "tracker": "المتابعة",
+ "login": "تسجيل الدخول",
+ "signup": "إنشاء حساب",
+ "logout": "تسجيل الخروج"
+ },
+ "slate": {
+ "tonights_slate": "مباريات اليوم",
+ "games": "مباريات",
+ "props_available": "احتمالات متاحة",
+ "read": "تحليل",
+ "read_more": "احتمالات أخرى",
+ "no_games": "لا توجد مباريات مباشرة حالياً.",
+ "props_not_available": "الاحتمالات غير متاحة بعد لهذه المباراة.",
+ "check_back": "تحقق مرة أخرى قرب بداية المباراة."
+ },
+ "grade": {
+ "grade": "التقييم",
+ "confidence": "الثقة",
+ "reasoning": "الذكاء التحليلي",
+ "kill_conditions": "ظروف الإلغاء",
+ "trap_score": "نقاط الفخ",
+ "upgrade_to_read": "قم بالترقية لقراءة المزيد",
+ "unlock_analysis": "افتح التحليل الكامل"
+ },
+ "pricing": {
+ "title": "أسعار مصممة للمراهنين، لا للمستثمرين.",
+ "subtitle": "أول 100 مستخدم يثبتون 14.99 دولار شهرياً مدى الحياة. سعر تجريبي — ينتهي عند المستخدم رقم 101.",
+ "founder_pricing": "سعر المؤسس — مثبت مدى الحياة",
+ "beta_locks_for_life": "سعر تجريبي — مثبت مدى الحياة",
+ "per_month": "/شهرياً",
+ "free_reads": "3 تحليلات مجانية يومياً",
+ "upgrade": "ترقية",
+ "current_plan": "الخطة الحالية",
+ "cta_start_free": "ابدأ مجاناً",
+ "cta_lock_founder": "ثبت سعر المؤسس",
+ "cta_go_desk": "اشترك في Desk",
+ "cta_unlock_africa": "افتح سعر أفريقيا",
+ "footnote": "إلغاء في أي وقت. بدون عقود. بطاقة / Apple Pay / Google Pay — تتم المعالجة عبر Stripe."
+ },
+ "tiers": {
+ "free": "مجاني",
+ "africa": "VYNDR أفريقيا",
+ "analyst": "Analyst",
+ "desk": "Desk"
+ },
+ "sports": {
+ "nba": "NBA",
+ "wnba": "WNBA",
+ "mlb": "MLB",
+ "soccer": "كرة القدم",
+ "world_cup": "كأس العالم"
+ },
+ "auth": {
+ "continue_with_google": "المتابعة مع Google",
+ "continue_with_apple": "المتابعة مع Apple",
+ "continue_with_x": "المتابعة مع X",
+ "email": "البريد الإلكتروني",
+ "password": "كلمة المرور",
+ "forgot_password": "نسيت كلمة المرور؟"
+ },
+ "common": {
+ "see_what_market_doesnt": "شاهد ما لا يراه السوق.",
+ "loading": "جاري التحميل...",
+ "error": "حدث خطأ ما.",
+ "try_again": "حاول مرة أخرى",
+ "cancel": "إلغاء",
+ "save": "حفظ",
+ "close": "إغلاق"
+ },
+ "cookie": {
+ "message": "نستخدم ملفات تعريف الارتباط للمصادقة والتحليلات.",
+ "accept": "قبول",
+ "privacy_policy": "سياسة الخصوصية"
+ }
+}
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
new file mode 100644
index 0000000..6f0f9cd
--- /dev/null
+++ b/web/src/locales/en.json
@@ -0,0 +1,86 @@
+{
+ "_meta": {
+ "locale": "en",
+ "dir": "ltr",
+ "review_status": "source"
+ },
+ "nav": {
+ "home": "Home",
+ "scan": "Scan",
+ "pricing": "Pricing",
+ "ledger": "Ledger",
+ "tracker": "Tracker",
+ "login": "Log in",
+ "signup": "Sign up",
+ "logout": "Log out"
+ },
+ "slate": {
+ "tonights_slate": "Tonight's Slate",
+ "games": "games",
+ "props_available": "props available",
+ "read": "Read",
+ "read_more": "more props",
+ "no_games": "No live games right now.",
+ "props_not_available": "Props not yet available for this game.",
+ "check_back": "Check back closer to kickoff."
+ },
+ "grade": {
+ "grade": "Grade",
+ "confidence": "Confidence",
+ "reasoning": "Intelligence",
+ "kill_conditions": "Kill Conditions",
+ "trap_score": "Trap Score",
+ "upgrade_to_read": "Upgrade to read more",
+ "unlock_analysis": "Unlock full analysis"
+ },
+ "pricing": {
+ "title": "Pricing built for bettors. Not for SaaS investors.",
+ "subtitle": "First 100 users lock $14.99/mo for life. Beta pricing — this price dies at user 101.",
+ "founder_pricing": "Founder pricing — locks for life",
+ "beta_locks_for_life": "Beta pricing — locks for life",
+ "per_month": "/mo",
+ "free_reads": "3 free reads per day",
+ "upgrade": "Upgrade",
+ "current_plan": "Current plan",
+ "cta_start_free": "Start Free",
+ "cta_lock_founder": "Lock Founder Price",
+ "cta_go_desk": "Go Desk",
+ "cta_unlock_africa": "Unlock Africa Pricing",
+ "footnote": "Cancel anytime. No contracts. Card / Apple Pay / Google Pay — payments processed by Stripe."
+ },
+ "tiers": {
+ "free": "Free",
+ "africa": "VYNDR Africa",
+ "analyst": "Analyst",
+ "desk": "Desk"
+ },
+ "sports": {
+ "nba": "NBA",
+ "wnba": "WNBA",
+ "mlb": "MLB",
+ "soccer": "Soccer",
+ "world_cup": "World Cup"
+ },
+ "auth": {
+ "continue_with_google": "Continue with Google",
+ "continue_with_apple": "Continue with Apple",
+ "continue_with_x": "Continue with X",
+ "email": "Email",
+ "password": "Password",
+ "forgot_password": "Forgot password?"
+ },
+ "common": {
+ "see_what_market_doesnt": "See what the market doesn't.",
+ "loading": "Loading...",
+ "error": "Something went wrong.",
+ "try_again": "Try again",
+ "cancel": "Cancel",
+ "save": "Save",
+ "close": "Close"
+ },
+ "cookie": {
+ "message": "We use cookies for authentication and analytics.",
+ "accept": "Accept",
+ "privacy_policy": "Privacy Policy"
+ }
+}
diff --git a/web/src/locales/es.json b/web/src/locales/es.json
new file mode 100644
index 0000000..10d504d
--- /dev/null
+++ b/web/src/locales/es.json
@@ -0,0 +1,86 @@
+{
+ "_meta": {
+ "locale": "es",
+ "dir": "ltr",
+ "review_status": "translated_unreviewed"
+ },
+ "nav": {
+ "home": "Inicio",
+ "scan": "Analizar",
+ "pricing": "Precios",
+ "ledger": "Historial",
+ "tracker": "Seguimiento",
+ "login": "Iniciar sesión",
+ "signup": "Crear cuenta",
+ "logout": "Cerrar sesión"
+ },
+ "slate": {
+ "tonights_slate": "Partidos de hoy",
+ "games": "partidos",
+ "props_available": "props disponibles",
+ "read": "Analizar",
+ "read_more": "más props",
+ "no_games": "No hay partidos en vivo ahora mismo.",
+ "props_not_available": "Aún no hay props disponibles para este partido.",
+ "check_back": "Vuelve cuando se acerque el inicio."
+ },
+ "grade": {
+ "grade": "Calificación",
+ "confidence": "Confianza",
+ "reasoning": "Inteligencia",
+ "kill_conditions": "Condiciones de Riesgo",
+ "trap_score": "Puntaje de Trampa",
+ "upgrade_to_read": "Mejora tu plan para analizar más",
+ "unlock_analysis": "Desbloquea el análisis completo"
+ },
+ "pricing": {
+ "title": "Precios hechos para apostadores. No para inversores SaaS.",
+ "subtitle": "Los primeros 100 usuarios bloquean $14.99/mes de por vida. Precio beta — termina con el usuario 101.",
+ "founder_pricing": "Precio fundador — bloqueado de por vida",
+ "beta_locks_for_life": "Precio beta — bloqueado de por vida",
+ "per_month": "/mes",
+ "free_reads": "3 análisis gratis al día",
+ "upgrade": "Mejorar plan",
+ "current_plan": "Plan actual",
+ "cta_start_free": "Empezar gratis",
+ "cta_lock_founder": "Bloquear precio fundador",
+ "cta_go_desk": "Ir a Desk",
+ "cta_unlock_africa": "Desbloquear precio África",
+ "footnote": "Cancela cuando quieras. Sin contratos. Tarjeta / Apple Pay / Google Pay — procesado por Stripe."
+ },
+ "tiers": {
+ "free": "Gratis",
+ "africa": "VYNDR África",
+ "analyst": "Analyst",
+ "desk": "Desk"
+ },
+ "sports": {
+ "nba": "NBA",
+ "wnba": "WNBA",
+ "mlb": "MLB",
+ "soccer": "Fútbol",
+ "world_cup": "Copa del Mundo"
+ },
+ "auth": {
+ "continue_with_google": "Continuar con Google",
+ "continue_with_apple": "Continuar con Apple",
+ "continue_with_x": "Continuar con X",
+ "email": "Correo electrónico",
+ "password": "Contraseña",
+ "forgot_password": "¿Olvidaste tu contraseña?"
+ },
+ "common": {
+ "see_what_market_doesnt": "Ve lo que el mercado no ve.",
+ "loading": "Cargando...",
+ "error": "Algo salió mal.",
+ "try_again": "Intentar de nuevo",
+ "cancel": "Cancelar",
+ "save": "Guardar",
+ "close": "Cerrar"
+ },
+ "cookie": {
+ "message": "Usamos cookies para autenticación y análisis.",
+ "accept": "Aceptar",
+ "privacy_policy": "Política de Privacidad"
+ }
+}
diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json
new file mode 100644
index 0000000..b9b96c3
--- /dev/null
+++ b/web/src/locales/fr.json
@@ -0,0 +1,86 @@
+{
+ "_meta": {
+ "locale": "fr",
+ "dir": "ltr",
+ "review_status": "translated_unreviewed"
+ },
+ "nav": {
+ "home": "Accueil",
+ "scan": "Analyser",
+ "pricing": "Tarifs",
+ "ledger": "Registre",
+ "tracker": "Suivi",
+ "login": "Connexion",
+ "signup": "S'inscrire",
+ "logout": "Déconnexion"
+ },
+ "slate": {
+ "tonights_slate": "Programme du jour",
+ "games": "matchs",
+ "props_available": "props disponibles",
+ "read": "Analyser",
+ "read_more": "plus de props",
+ "no_games": "Aucun match en direct pour le moment.",
+ "props_not_available": "Props pas encore disponibles pour ce match.",
+ "check_back": "Reviens plus près du coup d'envoi."
+ },
+ "grade": {
+ "grade": "Note",
+ "confidence": "Confiance",
+ "reasoning": "Intelligence",
+ "kill_conditions": "Conditions Critiques",
+ "trap_score": "Score de Piège",
+ "upgrade_to_read": "Améliorer pour analyser plus",
+ "unlock_analysis": "Débloquer l'analyse complète"
+ },
+ "pricing": {
+ "title": "Des tarifs pensés pour les parieurs. Pas pour les investisseurs SaaS.",
+ "subtitle": "Les 100 premiers utilisateurs bloquent 14,99 $/mois à vie. Prix bêta — il disparaît au 101e.",
+ "founder_pricing": "Tarif fondateur — bloqué à vie",
+ "beta_locks_for_life": "Tarif bêta — bloqué à vie",
+ "per_month": "/mois",
+ "free_reads": "3 analyses gratuites par jour",
+ "upgrade": "Améliorer",
+ "current_plan": "Plan actuel",
+ "cta_start_free": "Commencer gratuitement",
+ "cta_lock_founder": "Bloquer le tarif fondateur",
+ "cta_go_desk": "Passer à Desk",
+ "cta_unlock_africa": "Débloquer le tarif Afrique",
+ "footnote": "Annulable à tout moment. Sans engagement. Carte / Apple Pay / Google Pay — paiements traités par Stripe."
+ },
+ "tiers": {
+ "free": "Gratuit",
+ "africa": "VYNDR Afrique",
+ "analyst": "Analyst",
+ "desk": "Desk"
+ },
+ "sports": {
+ "nba": "NBA",
+ "wnba": "WNBA",
+ "mlb": "MLB",
+ "soccer": "Football",
+ "world_cup": "Coupe du Monde"
+ },
+ "auth": {
+ "continue_with_google": "Continuer avec Google",
+ "continue_with_apple": "Continuer avec Apple",
+ "continue_with_x": "Continuer avec X",
+ "email": "E-mail",
+ "password": "Mot de passe",
+ "forgot_password": "Mot de passe oublié ?"
+ },
+ "common": {
+ "see_what_market_doesnt": "Voir ce que le marché ne voit pas.",
+ "loading": "Chargement...",
+ "error": "Une erreur s'est produite.",
+ "try_again": "Réessayer",
+ "cancel": "Annuler",
+ "save": "Enregistrer",
+ "close": "Fermer"
+ },
+ "cookie": {
+ "message": "Nous utilisons des cookies pour l'authentification et les analyses.",
+ "accept": "Accepter",
+ "privacy_policy": "Politique de confidentialité"
+ }
+}
diff --git a/web/src/locales/hi.json b/web/src/locales/hi.json
new file mode 100644
index 0000000..938d488
--- /dev/null
+++ b/web/src/locales/hi.json
@@ -0,0 +1,87 @@
+{
+ "_meta": {
+ "locale": "hi",
+ "dir": "ltr",
+ "review_status": "translated_unreviewed",
+ "note": "Hindi translations should be reviewed by a native speaker. Sports-betting terminology in Hindi varies by region."
+ },
+ "nav": {
+ "home": "होम",
+ "scan": "स्कैन",
+ "pricing": "मूल्य निर्धारण",
+ "ledger": "लेजर",
+ "tracker": "ट्रैकर",
+ "login": "लॉग इन",
+ "signup": "साइन अप",
+ "logout": "लॉग आउट"
+ },
+ "slate": {
+ "tonights_slate": "आज के मैच",
+ "games": "मैच",
+ "props_available": "प्रॉप्स उपलब्ध",
+ "read": "विश्लेषण",
+ "read_more": "और प्रॉप्स",
+ "no_games": "अभी कोई लाइव मैच नहीं है।",
+ "props_not_available": "इस मैच के लिए प्रॉप्स अभी उपलब्ध नहीं हैं।",
+ "check_back": "मैच शुरू होने के करीब फिर से जांचें।"
+ },
+ "grade": {
+ "grade": "ग्रेड",
+ "confidence": "विश्वास",
+ "reasoning": "इंटेलिजेंस",
+ "kill_conditions": "किल कंडीशन",
+ "trap_score": "ट्रैप स्कोर",
+ "upgrade_to_read": "अधिक पढ़ने के लिए अपग्रेड करें",
+ "unlock_analysis": "पूर्ण विश्लेषण अनलॉक करें"
+ },
+ "pricing": {
+ "title": "बेटर्स के लिए बनाई गई कीमतें। SaaS निवेशकों के लिए नहीं।",
+ "subtitle": "पहले 100 उपयोगकर्ता जीवनभर के लिए $14.99/माह लॉक करते हैं। बीटा प्राइसिंग — 101वें उपयोगकर्ता पर समाप्त।",
+ "founder_pricing": "फाउंडर प्राइसिंग — जीवनभर के लिए लॉक",
+ "beta_locks_for_life": "बीटा प्राइसिंग — जीवनभर के लिए लॉक",
+ "per_month": "/माह",
+ "free_reads": "प्रति दिन 3 मुफ्त रीड्स",
+ "upgrade": "अपग्रेड करें",
+ "current_plan": "वर्तमान प्लान",
+ "cta_start_free": "मुफ्त शुरू करें",
+ "cta_lock_founder": "फाउंडर प्राइस लॉक करें",
+ "cta_go_desk": "Desk पर जाएं",
+ "cta_unlock_africa": "अफ्रीका मूल्य अनलॉक करें",
+ "footnote": "किसी भी समय रद्द करें। कोई अनुबंध नहीं। कार्ड / Apple Pay / Google Pay — Stripe द्वारा प्रसंस्कृत।"
+ },
+ "tiers": {
+ "free": "मुफ्त",
+ "africa": "VYNDR अफ्रीका",
+ "analyst": "Analyst",
+ "desk": "Desk"
+ },
+ "sports": {
+ "nba": "NBA",
+ "wnba": "WNBA",
+ "mlb": "MLB",
+ "soccer": "फुटबॉल",
+ "world_cup": "विश्व कप"
+ },
+ "auth": {
+ "continue_with_google": "Google के साथ जारी रखें",
+ "continue_with_apple": "Apple के साथ जारी रखें",
+ "continue_with_x": "X के साथ जारी रखें",
+ "email": "ईमेल",
+ "password": "पासवर्ड",
+ "forgot_password": "पासवर्ड भूल गए?"
+ },
+ "common": {
+ "see_what_market_doesnt": "वो देखें जो बाज़ार नहीं देखता।",
+ "loading": "लोड हो रहा है...",
+ "error": "कुछ गलत हो गया।",
+ "try_again": "पुनः प्रयास करें",
+ "cancel": "रद्द करें",
+ "save": "सहेजें",
+ "close": "बंद करें"
+ },
+ "cookie": {
+ "message": "हम प्रमाणीकरण और विश्लेषण के लिए कुकीज़ का उपयोग करते हैं।",
+ "accept": "स्वीकार करें",
+ "privacy_policy": "गोपनीयता नीति"
+ }
+}
diff --git a/web/src/locales/ja.json b/web/src/locales/ja.json
new file mode 100644
index 0000000..8e4d1e2
--- /dev/null
+++ b/web/src/locales/ja.json
@@ -0,0 +1,87 @@
+{
+ "_meta": {
+ "locale": "ja",
+ "dir": "ltr",
+ "review_status": "translated_unreviewed",
+ "note": "Japanese translations should be reviewed by a native speaker. Sports-betting context applies."
+ },
+ "nav": {
+ "home": "ホーム",
+ "scan": "分析",
+ "pricing": "料金",
+ "ledger": "履歴",
+ "tracker": "トラッカー",
+ "login": "ログイン",
+ "signup": "新規登録",
+ "logout": "ログアウト"
+ },
+ "slate": {
+ "tonights_slate": "本日の試合",
+ "games": "試合",
+ "props_available": "プロップ利用可能",
+ "read": "分析",
+ "read_more": "さらにプロップ",
+ "no_games": "現在ライブ中の試合はありません。",
+ "props_not_available": "この試合のプロップはまだ利用できません。",
+ "check_back": "試合開始前にもう一度確認してください。"
+ },
+ "grade": {
+ "grade": "グレード",
+ "confidence": "信頼度",
+ "reasoning": "インテリジェンス",
+ "kill_conditions": "キルコンディション",
+ "trap_score": "トラップスコア",
+ "upgrade_to_read": "アップグレードしてさらに分析",
+ "unlock_analysis": "完全な分析をアンロック"
+ },
+ "pricing": {
+ "title": "ベッター向けの価格設定。SaaS投資家向けではない。",
+ "subtitle": "最初の100人のユーザーは月額$14.99を生涯固定。ベータ価格 — 101番目のユーザーで終了。",
+ "founder_pricing": "ファウンダー価格 — 生涯固定",
+ "beta_locks_for_life": "ベータ価格 — 生涯固定",
+ "per_month": "/月",
+ "free_reads": "1日3回の無料分析",
+ "upgrade": "アップグレード",
+ "current_plan": "現在のプラン",
+ "cta_start_free": "無料で始める",
+ "cta_lock_founder": "ファウンダー価格を確定",
+ "cta_go_desk": "Deskへ",
+ "cta_unlock_africa": "アフリカ価格をアンロック",
+ "footnote": "いつでもキャンセル可能。契約なし。カード / Apple Pay / Google Pay — Stripeで処理。"
+ },
+ "tiers": {
+ "free": "無料",
+ "africa": "VYNDR アフリカ",
+ "analyst": "Analyst",
+ "desk": "Desk"
+ },
+ "sports": {
+ "nba": "NBA",
+ "wnba": "WNBA",
+ "mlb": "MLB",
+ "soccer": "サッカー",
+ "world_cup": "ワールドカップ"
+ },
+ "auth": {
+ "continue_with_google": "Googleで続行",
+ "continue_with_apple": "Appleで続行",
+ "continue_with_x": "Xで続行",
+ "email": "メールアドレス",
+ "password": "パスワード",
+ "forgot_password": "パスワードをお忘れですか?"
+ },
+ "common": {
+ "see_what_market_doesnt": "市場が見えないものを見る。",
+ "loading": "読み込み中...",
+ "error": "問題が発生しました。",
+ "try_again": "もう一度試す",
+ "cancel": "キャンセル",
+ "save": "保存",
+ "close": "閉じる"
+ },
+ "cookie": {
+ "message": "認証と分析のためにCookieを使用します。",
+ "accept": "同意する",
+ "privacy_policy": "プライバシーポリシー"
+ }
+}
diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json
new file mode 100644
index 0000000..72adc98
--- /dev/null
+++ b/web/src/locales/ko.json
@@ -0,0 +1,87 @@
+{
+ "_meta": {
+ "locale": "ko",
+ "dir": "ltr",
+ "review_status": "translated_unreviewed",
+ "note": "Korean translations should be reviewed by a native speaker."
+ },
+ "nav": {
+ "home": "홈",
+ "scan": "분석",
+ "pricing": "요금제",
+ "ledger": "기록",
+ "tracker": "트래커",
+ "login": "로그인",
+ "signup": "회원가입",
+ "logout": "로그아웃"
+ },
+ "slate": {
+ "tonights_slate": "오늘의 경기",
+ "games": "경기",
+ "props_available": "프롭 사용 가능",
+ "read": "분석",
+ "read_more": "추가 프롭",
+ "no_games": "현재 라이브 경기가 없습니다.",
+ "props_not_available": "이 경기의 프롭은 아직 사용할 수 없습니다.",
+ "check_back": "경기 시작에 가까워질 때 다시 확인하세요."
+ },
+ "grade": {
+ "grade": "등급",
+ "confidence": "신뢰도",
+ "reasoning": "인텔리전스",
+ "kill_conditions": "킬 조건",
+ "trap_score": "트랩 점수",
+ "upgrade_to_read": "더 분석하려면 업그레이드",
+ "unlock_analysis": "전체 분석 잠금 해제"
+ },
+ "pricing": {
+ "title": "베터를 위한 가격. SaaS 투자자를 위한 것이 아닙니다.",
+ "subtitle": "첫 100명의 사용자는 월 $14.99를 평생 고정합니다. 베타 가격 — 101번째 사용자에서 종료.",
+ "founder_pricing": "파운더 가격 — 평생 고정",
+ "beta_locks_for_life": "베타 가격 — 평생 고정",
+ "per_month": "/월",
+ "free_reads": "하루 3회 무료 분석",
+ "upgrade": "업그레이드",
+ "current_plan": "현재 플랜",
+ "cta_start_free": "무료로 시작",
+ "cta_lock_founder": "파운더 가격 고정",
+ "cta_go_desk": "Desk로",
+ "cta_unlock_africa": "아프리카 가격 잠금 해제",
+ "footnote": "언제든 취소 가능. 계약 없음. 카드 / Apple Pay / Google Pay — Stripe에서 처리."
+ },
+ "tiers": {
+ "free": "무료",
+ "africa": "VYNDR 아프리카",
+ "analyst": "Analyst",
+ "desk": "Desk"
+ },
+ "sports": {
+ "nba": "NBA",
+ "wnba": "WNBA",
+ "mlb": "MLB",
+ "soccer": "축구",
+ "world_cup": "월드컵"
+ },
+ "auth": {
+ "continue_with_google": "Google로 계속",
+ "continue_with_apple": "Apple로 계속",
+ "continue_with_x": "X로 계속",
+ "email": "이메일",
+ "password": "비밀번호",
+ "forgot_password": "비밀번호를 잊으셨나요?"
+ },
+ "common": {
+ "see_what_market_doesnt": "시장이 보지 못하는 것을 보세요.",
+ "loading": "로딩 중...",
+ "error": "문제가 발생했습니다.",
+ "try_again": "다시 시도",
+ "cancel": "취소",
+ "save": "저장",
+ "close": "닫기"
+ },
+ "cookie": {
+ "message": "인증 및 분석을 위해 쿠키를 사용합니다.",
+ "accept": "동의",
+ "privacy_policy": "개인정보 처리방침"
+ }
+}
diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json
new file mode 100644
index 0000000..9778f58
--- /dev/null
+++ b/web/src/locales/pt.json
@@ -0,0 +1,86 @@
+{
+ "_meta": {
+ "locale": "pt",
+ "dir": "ltr",
+ "review_status": "translated_unreviewed"
+ },
+ "nav": {
+ "home": "Início",
+ "scan": "Analisar",
+ "pricing": "Preços",
+ "ledger": "Histórico",
+ "tracker": "Rastreador",
+ "login": "Entrar",
+ "signup": "Criar conta",
+ "logout": "Sair"
+ },
+ "slate": {
+ "tonights_slate": "Jogos de hoje",
+ "games": "jogos",
+ "props_available": "props disponíveis",
+ "read": "Analisar",
+ "read_more": "mais props",
+ "no_games": "Nenhum jogo ao vivo agora.",
+ "props_not_available": "Props ainda não disponíveis para este jogo.",
+ "check_back": "Volte mais perto do início."
+ },
+ "grade": {
+ "grade": "Nota",
+ "confidence": "Confiança",
+ "reasoning": "Inteligência",
+ "kill_conditions": "Condições Críticas",
+ "trap_score": "Pontuação de Armadilha",
+ "upgrade_to_read": "Faça upgrade para analisar mais",
+ "unlock_analysis": "Desbloqueie a análise completa"
+ },
+ "pricing": {
+ "title": "Preços para apostadores. Não para investidores de SaaS.",
+ "subtitle": "Os primeiros 100 usuários travam $14,99/mês para sempre. Preço beta — termina no usuário 101.",
+ "founder_pricing": "Preço fundador — travado para sempre",
+ "beta_locks_for_life": "Preço beta — travado para sempre",
+ "per_month": "/mês",
+ "free_reads": "3 análises grátis por dia",
+ "upgrade": "Fazer upgrade",
+ "current_plan": "Plano atual",
+ "cta_start_free": "Começar grátis",
+ "cta_lock_founder": "Travar preço fundador",
+ "cta_go_desk": "Ir para Desk",
+ "cta_unlock_africa": "Desbloquear preço África",
+ "footnote": "Cancele quando quiser. Sem contratos. Cartão / Apple Pay / Google Pay — processado pela Stripe."
+ },
+ "tiers": {
+ "free": "Grátis",
+ "africa": "VYNDR África",
+ "analyst": "Analyst",
+ "desk": "Desk"
+ },
+ "sports": {
+ "nba": "NBA",
+ "wnba": "WNBA",
+ "mlb": "MLB",
+ "soccer": "Futebol",
+ "world_cup": "Copa do Mundo"
+ },
+ "auth": {
+ "continue_with_google": "Continuar com Google",
+ "continue_with_apple": "Continuar com Apple",
+ "continue_with_x": "Continuar com X",
+ "email": "E-mail",
+ "password": "Senha",
+ "forgot_password": "Esqueceu a senha?"
+ },
+ "common": {
+ "see_what_market_doesnt": "Veja o que o mercado não vê.",
+ "loading": "Carregando...",
+ "error": "Algo deu errado.",
+ "try_again": "Tentar novamente",
+ "cancel": "Cancelar",
+ "save": "Salvar",
+ "close": "Fechar"
+ },
+ "cookie": {
+ "message": "Usamos cookies para autenticação e análises.",
+ "accept": "Aceitar",
+ "privacy_policy": "Política de Privacidade"
+ }
+}
diff --git a/web/src/locales/sw.json b/web/src/locales/sw.json
new file mode 100644
index 0000000..8211e55
--- /dev/null
+++ b/web/src/locales/sw.json
@@ -0,0 +1,87 @@
+{
+ "_meta": {
+ "locale": "sw",
+ "dir": "ltr",
+ "review_status": "translated_unreviewed",
+ "note": "Swahili translations should be reviewed by a native speaker. East African mobile betting context."
+ },
+ "nav": {
+ "home": "Mwanzo",
+ "scan": "Changanua",
+ "pricing": "Bei",
+ "ledger": "Rekodi",
+ "tracker": "Ufuatiliaji",
+ "login": "Ingia",
+ "signup": "Jisajili",
+ "logout": "Toka"
+ },
+ "slate": {
+ "tonights_slate": "Michezo ya Leo",
+ "games": "michezo",
+ "props_available": "props zinapatikana",
+ "read": "Changanua",
+ "read_more": "props zaidi",
+ "no_games": "Hakuna michezo ya moja kwa moja sasa.",
+ "props_not_available": "Props bado hazipatikani kwa mchezo huu.",
+ "check_back": "Rudi karibu na mwanzo wa mchezo."
+ },
+ "grade": {
+ "grade": "Daraja",
+ "confidence": "Uhakika",
+ "reasoning": "Akili Bandia",
+ "kill_conditions": "Hali za Hatari",
+ "trap_score": "Alama ya Mtego",
+ "upgrade_to_read": "Boresha ili kusoma zaidi",
+ "unlock_analysis": "Fungua uchanganuzi kamili"
+ },
+ "pricing": {
+ "title": "Bei zilizoundwa kwa wabashiri. Si kwa wawekezaji wa SaaS.",
+ "subtitle": "Watumiaji 100 wa kwanza wanafunga $14.99/mwezi maisha yote. Bei ya beta — inaisha kwa mtumiaji wa 101.",
+ "founder_pricing": "Bei ya mwanzilishi — imefungwa maisha yote",
+ "beta_locks_for_life": "Bei ya beta — imefungwa maisha yote",
+ "per_month": "/mwezi",
+ "free_reads": "Uchanganuzi 3 wa bure kwa siku",
+ "upgrade": "Boresha",
+ "current_plan": "Mpango wa sasa",
+ "cta_start_free": "Anza Bure",
+ "cta_lock_founder": "Funga Bei ya Mwanzilishi",
+ "cta_go_desk": "Nenda Desk",
+ "cta_unlock_africa": "Fungua Bei ya Afrika",
+ "footnote": "Sitisha wakati wowote. Hakuna mikataba. Kadi / Apple Pay / Google Pay — malipo yanachakatwa na Stripe."
+ },
+ "tiers": {
+ "free": "Bure",
+ "africa": "VYNDR Afrika",
+ "analyst": "Analyst",
+ "desk": "Desk"
+ },
+ "sports": {
+ "nba": "NBA",
+ "wnba": "WNBA",
+ "mlb": "MLB",
+ "soccer": "Soka",
+ "world_cup": "Kombe la Dunia"
+ },
+ "auth": {
+ "continue_with_google": "Endelea na Google",
+ "continue_with_apple": "Endelea na Apple",
+ "continue_with_x": "Endelea na X",
+ "email": "Barua pepe",
+ "password": "Nenosiri",
+ "forgot_password": "Umesahau nenosiri?"
+ },
+ "common": {
+ "see_what_market_doesnt": "Ona kile soko haioni.",
+ "loading": "Inapakia...",
+ "error": "Kuna tatizo limetokea.",
+ "try_again": "Jaribu tena",
+ "cancel": "Ghairi",
+ "save": "Hifadhi",
+ "close": "Funga"
+ },
+ "cookie": {
+ "message": "Tunatumia vidakuzi kwa uthibitishaji na uchanganuzi.",
+ "accept": "Kubali",
+ "privacy_policy": "Sera ya Faragha"
+ }
+}
diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json
new file mode 100644
index 0000000..d413bef
--- /dev/null
+++ b/web/src/locales/zh.json
@@ -0,0 +1,87 @@
+{
+ "_meta": {
+ "locale": "zh",
+ "dir": "ltr",
+ "review_status": "translated_unreviewed",
+ "note": "Simplified Chinese. Native review recommended. Sports-betting context in mainland China is legally restricted."
+ },
+ "nav": {
+ "home": "首页",
+ "scan": "分析",
+ "pricing": "价格",
+ "ledger": "记录",
+ "tracker": "追踪",
+ "login": "登录",
+ "signup": "注册",
+ "logout": "退出"
+ },
+ "slate": {
+ "tonights_slate": "今日赛事",
+ "games": "比赛",
+ "props_available": "可用 props",
+ "read": "分析",
+ "read_more": "更多 props",
+ "no_games": "当前没有正在进行的比赛。",
+ "props_not_available": "本场比赛的 props 尚不可用。",
+ "check_back": "请在开赛前回来查看。"
+ },
+ "grade": {
+ "grade": "评级",
+ "confidence": "信心度",
+ "reasoning": "智能分析",
+ "kill_conditions": "终止条件",
+ "trap_score": "陷阱评分",
+ "upgrade_to_read": "升级以查看更多",
+ "unlock_analysis": "解锁完整分析"
+ },
+ "pricing": {
+ "title": "为下注者打造的价格。不是为了 SaaS 投资者。",
+ "subtitle": "前 100 名用户终身锁定 $14.99/月。Beta 价格 — 第 101 个用户开始结束。",
+ "founder_pricing": "创始价格 — 终身锁定",
+ "beta_locks_for_life": "Beta 价格 — 终身锁定",
+ "per_month": "/月",
+ "free_reads": "每天 3 次免费分析",
+ "upgrade": "升级",
+ "current_plan": "当前套餐",
+ "cta_start_free": "免费开始",
+ "cta_lock_founder": "锁定创始价格",
+ "cta_go_desk": "升级 Desk",
+ "cta_unlock_africa": "解锁非洲价格",
+ "footnote": "随时取消。无合约。信用卡 / Apple Pay / Google Pay — 由 Stripe 处理。"
+ },
+ "tiers": {
+ "free": "免费",
+ "africa": "VYNDR 非洲",
+ "analyst": "Analyst",
+ "desk": "Desk"
+ },
+ "sports": {
+ "nba": "NBA",
+ "wnba": "WNBA",
+ "mlb": "MLB",
+ "soccer": "足球",
+ "world_cup": "世界杯"
+ },
+ "auth": {
+ "continue_with_google": "使用 Google 继续",
+ "continue_with_apple": "使用 Apple 继续",
+ "continue_with_x": "使用 X 继续",
+ "email": "邮箱",
+ "password": "密码",
+ "forgot_password": "忘记密码?"
+ },
+ "common": {
+ "see_what_market_doesnt": "看到市场看不到的。",
+ "loading": "加载中...",
+ "error": "出错了。",
+ "try_again": "重试",
+ "cancel": "取消",
+ "save": "保存",
+ "close": "关闭"
+ },
+ "cookie": {
+ "message": "我们使用 cookie 进行身份验证和分析。",
+ "accept": "接受",
+ "privacy_policy": "隐私政策"
+ }
+}
diff --git a/web/src/middleware.ts b/web/src/middleware.ts
new file mode 100644
index 0000000..f2c7f22
--- /dev/null
+++ b/web/src/middleware.ts
@@ -0,0 +1,86 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE, LOCALE_HEADER, isLocale, Locale } from '@/lib/locales';
+
+/**
+ * Locale-detection middleware (Session 12).
+ *
+ * Detection priority:
+ * 1. URL prefix (/es/scan, /fr/pricing) — reserved for a future
+ * session that does the [locale] segment refactor. For now this
+ * branch is unreachable because no route uses the prefix.
+ * 2. NEXT_LOCALE cookie — set by the locale switcher and persisted
+ * across sessions.
+ * 3. Accept-Language header — best guess from the browser.
+ * 4. Default 'en'.
+ *
+ * The resolved locale lands on the `x-vyndr-locale` REQUEST header
+ * (NOT the response) so downstream server components can read it via
+ * `headers()` without parsing cookies themselves. The cookie is set
+ * on the response when the locale switcher fires (separate code
+ * path); the middleware itself doesn't write cookies.
+ *
+ * Skips: Next.js internals (`_next`), public files (anything with a
+ * dot in the path), and API routes (they don't render UI, no locale
+ * needed).
+ */
+
+function parseAcceptLanguage(header: string | null): Locale | null {
+ if (!header) return null;
+ // "en-US,en;q=0.9,es;q=0.8" → [{lang:'en-us', q:1}, {lang:'en', q:0.9}, ...]
+ const ranked = header
+ .split(',')
+ .map((chunk) => {
+ const [tag, ...params] = chunk.trim().split(';');
+ const qParam = params.find((p) => p.trim().startsWith('q='));
+ const q = qParam ? Number(qParam.split('=')[1]) : 1;
+ return { tag: tag.toLowerCase(), q: Number.isFinite(q) ? q : 1 };
+ })
+ .filter((entry) => entry.tag)
+ .sort((a, b) => b.q - a.q);
+
+ for (const entry of ranked) {
+ // Match the primary subtag (en-US → en).
+ const primary = entry.tag.split('-')[0];
+ if (isLocale(primary)) return primary;
+ }
+ return null;
+}
+
+function resolveLocale(req: NextRequest): Locale {
+ // 1. URL prefix — placeholder for the future [locale] refactor.
+ // Check the first path segment against the locale registry.
+ const firstSegment = req.nextUrl.pathname.split('/')[1] || '';
+ if (isLocale(firstSegment)) return firstSegment;
+
+ // 2. Cookie.
+ const cookie = req.cookies.get(LOCALE_COOKIE)?.value;
+ if (isLocale(cookie)) return cookie;
+
+ // 3. Accept-Language.
+ const fromHeader = parseAcceptLanguage(req.headers.get('accept-language'));
+ if (fromHeader) return fromHeader;
+
+ // 4. Default.
+ return DEFAULT_LOCALE;
+}
+
+export function middleware(req: NextRequest) {
+ const locale = resolveLocale(req);
+ // Stamp the request header so server components can read locale
+ // via `headers().get('x-vyndr-locale')`. NextResponse.next() with
+ // request headers is the canonical pattern for this.
+ const requestHeaders = new Headers(req.headers);
+ requestHeaders.set(LOCALE_HEADER, locale);
+ return NextResponse.next({
+ request: { headers: requestHeaders },
+ });
+}
+
+// Skip Next.js internals, public files, and API routes (no UI).
+export const config = {
+ matcher: ['/((?!api|_next|.*\\..*).*)'],
+};
+
+// Re-export so tests can import without pulling the full middleware.
+export { resolveLocale, parseAcceptLanguage };
+export const __SUPPORTED_LOCALES = LOCALES;