Session 12: i18n (10 languages, cookie-based), Africa tier .99, locale switcher, RTL Arabic (1305 tests)
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
@@ -753,3 +753,42 @@ body.tex-grain::before {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
RTL support (Session 12 — Arabic).
|
||||
Toggled by <html dir="rtl">. The root layout server-
|
||||
resolves the locale and sets the attribute; these rules
|
||||
flip the few directional surfaces (nav flex direction,
|
||||
sidebar drawers, list markers) without inverting the
|
||||
whole grid (the Bloomberg-style data tables stay LTR
|
||||
even in RTL mode — numbers read left-to-right
|
||||
regardless).
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
[dir="rtl"] body {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[dir="rtl"] .nav-links,
|
||||
[dir="rtl"] .nav-desktop {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
[dir="rtl"] .mono,
|
||||
[dir="rtl"] [class*="font-mono"] {
|
||||
/* Monospace numeric blocks stay LTR — financial data reads
|
||||
left-to-right in every locale by convention. */
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
[dir="rtl"] .pricing-grid {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
[dir="rtl"] .btn-primary,
|
||||
[dir="rtl"] .btn-ghost {
|
||||
/* Buttons stay LTR so chevrons / arrows render predictably. */
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
+16
-2
@@ -12,6 +12,9 @@ import MFAPrompt from '@/components/MFAPrompt';
|
||||
import MFAChallenge from '@/components/MFAChallenge';
|
||||
import CookieConsent from '@/components/CookieConsent';
|
||||
import SentryInit from '@/components/SentryInit';
|
||||
import { LocaleProvider } from '@/contexts/LocaleContext';
|
||||
import { headers } from 'next/headers';
|
||||
import { LOCALE_HEADER, isLocale, DEFAULT_LOCALE, LOCALE_META } from '@/lib/locales';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -85,9 +88,18 @@ export const viewport: Viewport = {
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
// Session 12 — resolve locale from the middleware-stamped request
|
||||
// header so server components render with the right translations
|
||||
// and the <html> dir attribute is set before paint (no FOUC of
|
||||
// mis-directional text).
|
||||
const hdrs = await headers();
|
||||
const localeHeader = hdrs.get(LOCALE_HEADER);
|
||||
const locale = isLocale(localeHeader) ? localeHeader : DEFAULT_LOCALE;
|
||||
const dir = LOCALE_META[locale].dir;
|
||||
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<html lang={locale} dir={dir} className="dark">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
@@ -97,6 +109,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
/>
|
||||
</head>
|
||||
<body className="antialiased tex-grain">
|
||||
<LocaleProvider locale={locale}>
|
||||
<PostHogProvider>
|
||||
<AuthProvider>
|
||||
<ExplainModeProvider>
|
||||
@@ -115,6 +128,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
</ExplainModeProvider>
|
||||
</AuthProvider>
|
||||
</PostHogProvider>
|
||||
</LocaleProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useT } from '@/contexts/LocaleContext';
|
||||
|
||||
const STORAGE_KEY = 'vyndr_cookie_consent';
|
||||
|
||||
@@ -22,6 +23,7 @@ const STORAGE_KEY = 'vyndr_cookie_consent';
|
||||
* acknowledges that you saw the disclosure.
|
||||
*/
|
||||
export default function CookieConsent() {
|
||||
const t = useT();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,12 +80,12 @@ export default function CookieConsent() {
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
We use cookies for authentication and anonymized analytics.{' '}
|
||||
{t('cookie.message')}{' '}
|
||||
<Link
|
||||
href="/privacy"
|
||||
style={{ color: 'var(--grade-a)', textDecoration: 'underline', textUnderlineOffset: 2 }}
|
||||
>
|
||||
Privacy policy
|
||||
{t('cookie.privacy_policy')}
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
@@ -93,7 +95,7 @@ export default function CookieConsent() {
|
||||
className="btn-primary"
|
||||
style={{ padding: '6px 14px', fontSize: 12 }}
|
||||
>
|
||||
Accept
|
||||
{t('cookie.accept')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { LOCALES, LOCALE_META, LOCALE_COOKIE, Locale } from '@/lib/locales';
|
||||
import { useLocale } from '@/contexts/LocaleContext';
|
||||
|
||||
/**
|
||||
* Locale switcher (Session 12).
|
||||
*
|
||||
* Compact dropdown. Mounted alongside the BETA tag in Nav. On select,
|
||||
* writes the NEXT_LOCALE cookie (Path=/, 1-year expiry) and reloads
|
||||
* the page so the middleware picks up the new locale and the server
|
||||
* components rebuild with the new translations.
|
||||
*
|
||||
* SSR-safe: renders the current locale label before any state mutates
|
||||
* so hydration matches.
|
||||
*/
|
||||
export default function LocaleSwitcher() {
|
||||
const { locale } = useLocale();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, [open]);
|
||||
|
||||
function pick(next: Locale) {
|
||||
setOpen(false);
|
||||
if (next === locale) return;
|
||||
// 1-year cookie, root path, lax so server-side reads survive
|
||||
// cross-origin navigations (Stripe redirect, OAuth callback).
|
||||
const oneYear = 60 * 60 * 24 * 365;
|
||||
document.cookie = `${LOCALE_COOKIE}=${next}; Path=/; Max-Age=${oneYear}; SameSite=Lax`;
|
||||
// Hard reload so the middleware picks up the cookie and server
|
||||
// components rebuild. Soft router.refresh() would leave the
|
||||
// initial server-rendered locale stale on this page.
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
const current = LOCALE_META[locale];
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={`Language: ${current.label}`}
|
||||
className="mono"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-1)',
|
||||
padding: '4px 8px',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{locale}
|
||||
<span aria-hidden style={{ fontSize: 8, opacity: 0.6 }}>▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label="Select language"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 6px)',
|
||||
right: 0,
|
||||
minWidth: 180,
|
||||
zIndex: 70,
|
||||
background: 'var(--bg-2, #15151F)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
padding: 4,
|
||||
margin: 0,
|
||||
listStyle: 'none',
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.6)',
|
||||
maxHeight: 320,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{LOCALES.map((code) => {
|
||||
const meta = LOCALE_META[code];
|
||||
const active = code === locale;
|
||||
return (
|
||||
<li key={code}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => pick(code)}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
background: active ? 'var(--bg-3, #1A1A26)' : 'transparent',
|
||||
border: 0,
|
||||
padding: '8px 10px',
|
||||
cursor: 'pointer',
|
||||
color: active ? 'var(--grade-a)' : 'var(--text-1)',
|
||||
fontSize: 13,
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span>{meta.native}</span>
|
||||
<span
|
||||
className="mono"
|
||||
style={{ fontSize: 10, opacity: 0.55, letterSpacing: '0.06em' }}
|
||||
>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+21
-11
@@ -4,20 +4,26 @@ import { useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
import NotificationBell from '@/components/NotificationBell';
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ label: 'Read', href: '/scan' },
|
||||
{ label: 'Tracker', href: '/tracker' },
|
||||
{ label: 'Ledger', href: '/ledger' },
|
||||
{ label: 'Pricing', href: '/#pricing' },
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
];
|
||||
import LocaleSwitcher from '@/components/LocaleSwitcher';
|
||||
import { useT } from '@/contexts/LocaleContext';
|
||||
|
||||
export default function Nav() {
|
||||
const { user, tier, scansRemaining, signOut } = useAuth();
|
||||
const t = useT();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// Session 12 — translation labels resolved at render time so a
|
||||
// locale switch flips the nav without a code change. Hrefs stay
|
||||
// English (the [locale]/ refactor is a future session).
|
||||
const NAV_LINKS = [
|
||||
{ label: t('nav.scan'), href: '/scan' },
|
||||
{ label: t('nav.tracker'), href: '/tracker' },
|
||||
{ label: t('nav.ledger'), href: '/ledger' },
|
||||
{ label: t('nav.pricing'), href: '/pricing' },
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav
|
||||
style={{
|
||||
@@ -108,6 +114,7 @@ export default function Nav() {
|
||||
</span>
|
||||
)}
|
||||
<NotificationBell />
|
||||
<LocaleSwitcher />
|
||||
<button
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-haspopup="menu"
|
||||
@@ -180,9 +187,12 @@ export default function Nav() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<a href="/login" className="btn-primary" style={{ padding: '8px 16px', fontSize: 13 }}>
|
||||
Log In
|
||||
</a>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<LocaleSwitcher />
|
||||
<a href="/login" className="btn-primary" style={{ padding: '8px 16px', fontSize: 13 }}>
|
||||
{t('nav.login')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useT, useLocale } from '@/contexts/LocaleContext';
|
||||
import { AFRICA_LOCALES } from '@/lib/locales';
|
||||
|
||||
type TierId = 'free' | 'analyst' | 'desk';
|
||||
type TierId = 'free' | 'africa' | 'analyst' | 'desk';
|
||||
|
||||
interface TierConfig {
|
||||
id: TierId;
|
||||
@@ -29,7 +31,7 @@ const TIERS: TierConfig[] = [
|
||||
headline: 'Try the model. No card required.',
|
||||
cta: 'Start Free',
|
||||
features: [
|
||||
'5 reads per month',
|
||||
'3 reads per day',
|
||||
'Grade letter + projection',
|
||||
'Cross-book line comparison',
|
||||
'Confidence indicator',
|
||||
@@ -41,6 +43,29 @@ const TIERS: TierConfig[] = [
|
||||
],
|
||||
highlight: false,
|
||||
},
|
||||
// Session 12 — VYNDR Africa tier ($4.99/mo). Slotted between Free
|
||||
// and Analyst. Pricing component reorders dynamically based on
|
||||
// locale (African-language users see this first).
|
||||
{
|
||||
id: 'africa',
|
||||
name: 'VYNDR Africa',
|
||||
price: '$4.99',
|
||||
cadence: '/mo',
|
||||
headline: 'Built for African mobile bettors.',
|
||||
cta: 'Unlock Africa Pricing',
|
||||
features: [
|
||||
'10 reads per day',
|
||||
'Full factor analysis (40+ signals)',
|
||||
'Kill conditions surfaced inline',
|
||||
'Grade + reasoning visible',
|
||||
'World Cup soccer intelligence',
|
||||
],
|
||||
locked: [
|
||||
'Cascade alerts (Analyst+)',
|
||||
'Alt line ladder (Desk only)',
|
||||
],
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
id: 'analyst',
|
||||
name: 'Analyst',
|
||||
@@ -88,9 +113,22 @@ const TIERS: TierConfig[] = [
|
||||
export default function Pricing() {
|
||||
const router = useRouter();
|
||||
const { session, loading: authLoading } = useAuth();
|
||||
const { locale } = useLocale();
|
||||
const t = useT();
|
||||
const [pending, setPending] = useState<TierId | null>(null);
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
)}
|
||||
|
||||
<div className="pricing-grid" style={{ display: 'grid', gap: 24 }}>
|
||||
{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);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
@@ -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 `<LocaleProvider>`. 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<LocaleContextValue | null>(null);
|
||||
|
||||
export function LocaleProvider({ locale, children }: { locale: string; children: ReactNode }) {
|
||||
const value = useMemo<LocaleContextValue>(() => {
|
||||
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 <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Translation helpers (Session 12).
|
||||
*
|
||||
* Two surfaces:
|
||||
* - `getTranslations(locale)` — synchronous loader used by server
|
||||
* components. Returns `{ t, locale, dir }`.
|
||||
* - `useT()` + `<LocaleProvider>` — 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<string, unknown>;
|
||||
|
||||
const DICTS: Record<Locale, Dict> = {
|
||||
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, string | number>) => string;
|
||||
|
||||
function interpolate(template: string, vars?: Record<string, string | number>): 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<TranslationBundle> {
|
||||
// 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));
|
||||
}
|
||||
@@ -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
|
||||
* `<html dir>` 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<Locale, { label: string; native: string; dir: 'ltr' | 'rtl'; region: string }> = {
|
||||
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<Locale> = 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';
|
||||
@@ -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": "سياسة الخصوصية"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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é"
|
||||
}
|
||||
}
|
||||
@@ -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": "गोपनीयता नीति"
|
||||
}
|
||||
}
|
||||
@@ -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": "プライバシーポリシー"
|
||||
}
|
||||
}
|
||||
@@ -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": "개인정보 처리방침"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "隐私政策"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user