Session 12: i18n (10 languages, cookie-based), Africa tier .99, locale switcher, RTL Arabic (1305 tests)

This commit is contained in:
Kev
2026-06-10 22:24:40 -04:00
parent e5c45ecc8e
commit d957dee17b
27 changed files with 1834 additions and 29 deletions
+127 -1
View File
@@ -4,7 +4,133 @@
2026-06-10
## Current Phase
SHIP BUILD v10.0 — Internal-auth refactor, soccer prefetch cascade, Sentry, welcome email (Session 10)
SHIP BUILD v12.0 — i18n (10 languages) + Africa tier (Session 12)
## Session 12 (2026-06-11) — SHIPPED
### FIX 1 — i18n infrastructure (10 languages, cookie-based)
Honest scope decision: skipped the full `[locale]/` URL-prefix
refactor (would have touched all 24+ pages). Went cookie-based +
header-stamping middleware instead — same UX, much smaller blast
radius. URL-prefix routing can layer on later without breaking
anything.
- **`web/src/lib/locales.ts`** — locale registry. 10 locales:
en (source), es, fr, pt, ar (RTL), sw, hi, ja, ko, zh.
`LOCALE_META` carries native names + dir + region.
`AFRICA_LOCALES = {sw}` used by the pricing reorder logic.
- **`web/src/middleware.ts`** — locale resolver. Priority: URL
prefix → `NEXT_LOCALE` cookie → `Accept-Language` parsing →
default 'en'. Stamps `x-vyndr-locale` on the request so server
components can read it via `next/headers`.
- **`web/src/locales/{en,es,fr,pt,ar,sw,hi,ja,ko,zh}.json`** —
10 translation dictionaries, each ~17 keys covering nav, slate,
grade, pricing, sports, auth, common, cookie. Every file declares
its `_meta.review_status`: `en` is `source`, the other 9 are
`translated_unreviewed`. Sports terminology is locale-correct
(Fútbol/Football/サッカー/كرة القدم/Soka, etc.).
- **`web/src/lib/i18n.ts`** — synchronous server-side loader
(`getTranslations(locale) → {t, locale, dir}`) plus
`getServerTranslations()` which reads the middleware-stamped
header. English fallback per key, falls to the key string itself
when missing on both. `{name}` interpolation supported.
- **`web/src/contexts/LocaleContext.tsx`** — client provider +
`useT()` / `useLocale()` hooks. Mounted in the root layout above
every other provider.
- **RTL** — `<html dir="rtl">` set in root layout when locale is
Arabic. `globals.css` flips nav direction and isolates monospace
blocks (numbers stay LTR — financial data convention).
- **`LocaleSwitcher.tsx`** — compact mono dropdown with native
language names. Sets the cookie, reloads the page. Mounted in Nav
for both authenticated and anonymous states.
- **Wired into**: Nav (5 links + login button), CookieConsent
(message + accept + privacy link), Pricing (CTAs translate per
tier). High-impact components first; longer-tail strings remain
English with `t('key')` calls scheduled for a follow-up.
### FIX 2 — Africa tier ($4.99/mo)
- **`src/config/tiers.js`** — adds the `africa` tier between free
and analyst: 10 scans/day, reasoning_visible:true,
kill_conditions_detail:true, alerts:false, api_access:false.
Frozen.
- **Scan-limit middleware** — no change needed. `scanLimit()` reads
via `getScanLimit()`, which now resolves 'africa' to 10.
- **`web/src/components/Pricing.tsx`** — adds the VYNDR Africa
card. The pricing-grid CSS unfolds from 2-up (tablet) to 4-up
(≥1100px desktop). When the user's locale is Swahili (a proxy
for African markets — IP-based geolocation deferred to a future
session), the Africa tier renders FIRST.
- **Honest UX gap**: Africa-tier checkout short-circuits to an
inline "coming soon" message instead of triggering Stripe. Two
reasons: (a) the backend `/api/stripe/checkout` route validates
tier against `['analyst','desk']` and the spec forbids backend
edits this session; (b) `STRIPE_PRICE_AFRICA` is unset and the
Stripe product hasn't been created in the dashboard yet.
- **DB CHECK constraint blocker**: migrations 001 + 011 declare
`tier IN ('free','analyst','desk')`. The webhook will 23514
(check_violation) if it tries to write `africa` until the
constraint is extended. Documented in `tiers.js` header + in
SYSTEM-MANIFEST. Out of scope this session per the no-migration
rule.
- **`.env.example`** — `STRIPE_PRICE_AFRICA=price_...` placeholder
with explanatory comment.
### Tests added (Session 12)
| Suite | Tests |
|------------------------------------|-------|
| `tests/unit/i18n.test.js` | 14 |
| `tests/unit/tiers.test.js` (extended) | +5 |
| **Total new** | **19** |
### Quality gates
- `npm test`: **1305 / 1305 passing** (1286 + 19), 101 suites, 0 regressions
- `web/npm run build`: clean. NOTE — every page is now `ƒ Dynamic`
rather than `○ Static` because the root layout reads request
headers (`next/headers`) for locale resolution. This is the
expected cost of SSR i18n. If FCP regresses, the fallback is
client-side cookie reads (brief English flash on first paint, but
static prerender returns).
- License audit: third-party deps remain permissive (no new licenses
introduced — translation files are JSON in our own repo).
### Open items / follow-ups
1. **DB CHECK constraint** must be updated before the Africa tier
can actually be assigned to users. Manual SQL:
```
ALTER TABLE users DROP CONSTRAINT users_tier_check;
ALTER TABLE users ADD CONSTRAINT users_tier_check
CHECK (tier IN ('free','africa','analyst','desk'));
-- same for user_profiles
```
2. **Stripe product** for VYNDR Africa not created. Manual step:
create the product + price in the Stripe dashboard, set
`STRIPE_PRICE_AFRICA` in Coolify, then extend the backend
checkout route's validation list.
3. **Translation review** — only `en` is `source` quality. The
other 9 locales are `translated_unreviewed`. Native-speaker
review recommended for Arabic, Chinese, Korean, Japanese, Hindi
before public launch.
4. **Browser geolocation** — Africa tier currently sorts first only
for Swahili-locale users. IP-based detection (NG/KE/ZA/GH/etc.)
would catch English-speaking African users; deferred to a
session with proper geo middleware (Cloudflare headers, etc.).
5. **Per-page meta translations** — page `<title>` and OG tags are
still English. Adding per-locale metadata requires the
`[locale]/` segment refactor, deferred.
### Coolify env (Session 12 additions)
```
# Already required:
NEXT_LOCALE # No env — set as a per-user cookie by the switcher.
# New, optional:
STRIPE_PRICE_AFRICA=price_... # Once you create the Stripe product
```
---
## Session 10 (2026-06-10) — SHIPPED
+14
View File
@@ -486,3 +486,17 @@
{"ts":"2026-06-11T00:23:12.718Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-11T00:23:12.772Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-11T00:23:12.832Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-11T01:19:29.246Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-11T01:19:29.386Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-11T01:19:29.638Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-11T01:19:29.890Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-11T01:19:29.891Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-11T01:19:29.891Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-11T01:19:29.941Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-11T01:34:16.052Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-11T01:34:16.157Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-11T01:34:16.316Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-11T01:34:16.680Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-11T01:34:16.680Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-11T01:34:16.680Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-11T01:34:16.752Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
+35
View File
@@ -218,6 +218,41 @@ Container runtime (Session 9 finding):
in production and the container OOM-loops (44 restarts observed on
the live host before the fix was identified).
### Pricing tiers (Session 12 — Africa tier added)
| Var | Required | Default | Used By | Doc? |
| ---------------------------- | -------- | ------- | ---------------------------------- | ---- |
| `STRIPE_PRICE_AFRICA` | no | (none) | `web/components/Pricing`, `stripeService` (post-DB-CHECK migration) | ✓ S12 |
**Blocker**: the existing migrations (001 + 011) declare `tier IN
('free','analyst','desk')` as a CHECK constraint on `users.tier` and
`user_profiles.tier`. The `africa` tier value will VIOLATE that
constraint until the constraint is updated. The frontend Pricing page
shows the tier now (UX), but the click handler short-circuits to an
honest "coming soon" message rather than triggering checkout. Required
follow-up: manual SQL to drop + re-add the CHECK across both tables
including 'africa' (cannot be done in this session per the no-migration
rule).
### Internationalization (Session 12)
| Var / file | Required | Default | Used By | Doc? |
| ---------------------------- | -------- | ------- | ---------------------------------- | ---- |
| `NEXT_LOCALE` cookie | no | en | `web/middleware.ts`, switcher | ✓ S12 |
| `web/src/lib/locales.ts` | n/a | n/a | locale registry (10 languages) | ✓ S12 |
| `web/src/locales/{code}.json`| n/a | n/a | per-locale translation dictionaries | ✓ S12 |
Locale-detection priority (left wins): URL prefix (reserved for future
`[locale]/` segment) → `NEXT_LOCALE` cookie → `Accept-Language`
header → `'en'`. Resolved locale rides on the request via the
`x-vyndr-locale` header; server components read it through
`next/headers`, client components consume it via `LocaleContext`.
**Side effect**: every Next.js page is now `ƒ (Dynamic)` instead of
`○ (Static)` because the root layout reads request headers. If FCP
regresses meaningfully, the fix is to move locale resolution
client-side (cookie-only, no `headers()` in layout) — that re-enables
static prerendering at the cost of a brief English flash on first
paint for non-English users.
### Engine 2
| Var | Doc? |
| ---------------------------- | ---- |
+26 -5
View File
@@ -1,12 +1,17 @@
/**
* Tier access matrix — single source of truth for what each tier unlocks.
*
* The tier set matches the DB CHECK constraint in migrations 001 + 011:
* tier IN ('free', 'analyst', 'desk')
* The tier set DOES NOT YET match the DB CHECK constraint. Sessions
* 001 + 011 wrote `tier IN ('free', 'analyst', 'desk')`. Session 12
* adds 'africa' here, but the constraint must be extended in a
* follow-up migration BEFORE the Stripe webhook can write 'africa'
* to users.tier — otherwise inserts will 23514 (check_violation).
*
* Free is the top of the funnel. Users see the grade (the hook) but the
* reasoning + kill-condition details stay locked. Analyst opens the
* intelligence. Desk adds engine2 (LLM) and portfolio tracking.
* Roadmap:
* 1. Manual SQL to extend the constraint (drop + re-add covering all
* four tiers) — out of scope for this session per no-migration rule.
* 2. Stripe webhook event for the africa price ID maps to tier='africa'.
* 3. The frontend Pricing component shows the tier (already done here).
*
* `api_access` is FALSE on every tier and must stay false. VYNDR is a
* consumer product; the proprietary engine is never exposed externally.
@@ -29,6 +34,22 @@ const TIERS = Object.freeze({
stat_dashboard: true,
api_access: false,
}),
// Session 12 — $4.99/mo Africa tier. Slotted between free and analyst.
// Same intelligence surface as analyst (reasoning + kill conditions
// visible) but lower scan budget; no alerts. Activation blocked on a
// DB CHECK constraint update — see header comment.
africa: Object.freeze({
scans_per_day: 10,
grade_visible: true,
reasoning_visible: true,
kill_conditions_detail: true,
kill_conditions_count: true,
alerts: false,
portfolio: false,
engine2: false,
stat_dashboard: true,
api_access: false,
}),
analyst: Object.freeze({
scans_per_day: 15,
grade_visible: true,
+155
View File
@@ -0,0 +1,155 @@
// Translation hook (Session 12) — exercised as plain JS via Jest
// since the lib/i18n.ts file is the synchronous, server-safe portion.
// The dynamic import of next/headers inside getServerTranslations is
// NOT tested here (it depends on the Next.js request context); we
// cover the static loader, the per-key dot-path resolution, and the
// English fallback policy.
// jest can require .ts through the existing babel-jest config? Let me
// just point at the dictionaries directly.
const en = require('../../web/src/locales/en.json');
const es = require('../../web/src/locales/es.json');
const fr = require('../../web/src/locales/fr.json');
const ar = require('../../web/src/locales/ar.json');
const sw = require('../../web/src/locales/sw.json');
// Re-implement the bare minimum of i18n.ts logic for testing — keeps
// this Jest suite from needing the TS compile chain. The behavior
// under test is the contract: dot-path lookup + English fallback.
function getByPath(dict, path) {
const parts = path.split('.');
let cursor = dict;
for (const part of parts) {
if (!cursor || typeof cursor !== 'object') return null;
cursor = cursor[part];
}
return typeof cursor === 'string' ? cursor : null;
}
function makeT(localeDict, fallbackDict) {
return function t(key, vars) {
let hit = getByPath(localeDict, key);
if (hit === null) hit = getByPath(fallbackDict, key);
if (hit === null) return key;
if (!vars) return hit;
return hit.replace(/\{(\w+)\}/g, (_, name) => (vars[name] == null ? `{${name}}` : String(vars[name])));
};
}
describe('Translation dictionaries — Session 12', () => {
describe('all 10 locales load and carry the same key set', () => {
const expectedKeys = [
'nav.home', 'nav.pricing', 'nav.login',
'slate.tonights_slate', 'slate.read',
'grade.grade', 'grade.confidence',
'pricing.per_month', 'pricing.cta_unlock_africa',
'sports.soccer', 'sports.world_cup',
'auth.email', 'auth.password',
'common.loading', 'common.error',
'cookie.message', 'cookie.accept',
];
const allLocales = {
en: require('../../web/src/locales/en.json'),
es: require('../../web/src/locales/es.json'),
fr: require('../../web/src/locales/fr.json'),
pt: require('../../web/src/locales/pt.json'),
ar: require('../../web/src/locales/ar.json'),
sw: require('../../web/src/locales/sw.json'),
hi: require('../../web/src/locales/hi.json'),
ja: require('../../web/src/locales/ja.json'),
ko: require('../../web/src/locales/ko.json'),
zh: require('../../web/src/locales/zh.json'),
};
test('every locale exposes every expected key', () => {
for (const [code, dict] of Object.entries(allLocales)) {
for (const key of expectedKeys) {
const value = getByPath(dict, key);
expect({ locale: code, key, value }).toEqual({ locale: code, key, value: expect.any(String) });
}
}
});
test('every locale declares its dir in _meta', () => {
for (const [code, dict] of Object.entries(allLocales)) {
expect(dict._meta).toBeDefined();
expect(['ltr', 'rtl']).toContain(dict._meta.dir);
if (code === 'ar') expect(dict._meta.dir).toBe('rtl');
else expect(dict._meta.dir).toBe('ltr');
}
});
test('every locale marks its review_status (en=source, others=translated_unreviewed)', () => {
expect(allLocales.en._meta.review_status).toBe('source');
for (const code of Object.keys(allLocales)) {
if (code === 'en') continue;
expect(allLocales[code]._meta.review_status).toBe('translated_unreviewed');
}
});
});
describe('English fallback policy', () => {
test('returns the requested locale string when present', () => {
const t = makeT(es, en);
expect(t('nav.home')).toBe('Inicio');
});
test('falls back to English when the key is missing in the target locale', () => {
// Simulate a translation file with an extra-sparse subtree.
const sparse = { nav: { home: 'Inicio' } }; // no 'pricing'
const t = makeT(sparse, en);
expect(t('nav.pricing')).toBe(en.nav.pricing);
});
test('returns the key itself when missing in both locale and English', () => {
const t = makeT(es, en);
expect(t('nav.bogus_unknown_key')).toBe('nav.bogus_unknown_key');
});
});
describe('Sports terminology per locale (manual sanity)', () => {
test('Spanish renders soccer as "Fútbol"', () => {
expect(makeT(es, en)('sports.soccer')).toBe('Fútbol');
});
test('French renders soccer as "Football"', () => {
expect(makeT(fr, en)('sports.soccer')).toBe('Football');
});
test('Japanese renders soccer as "サッカー"', () => {
const ja = require('../../web/src/locales/ja.json');
expect(makeT(ja, en)('sports.soccer')).toBe('サッカー');
});
test('Arabic renders soccer as "كرة القدم"', () => {
expect(makeT(ar, en)('sports.soccer')).toBe('كرة القدم');
});
test('Swahili renders soccer as "Soka"', () => {
expect(makeT(sw, en)('sports.soccer')).toBe('Soka');
});
});
describe('Variable interpolation', () => {
test('replaces {name} placeholders', () => {
const t = makeT({ greet: { hello: 'Hola, {user}!' } }, en);
expect(t('greet.hello', { user: 'Mundo' })).toBe('Hola, Mundo!');
});
test('leaves unmatched placeholders intact (visible during dev)', () => {
const t = makeT({ greet: { hi: 'Hi {who}' } }, en);
expect(t('greet.hi')).toBe('Hi {who}');
});
});
});
describe('Locale registry — locales.ts shape', () => {
// We exercise the registry via its JSON shape only (the .ts file
// requires TS compile). The values that matter for runtime behavior
// (LOCALES array, RTL flags, AFRICA_LOCALES set) are asserted
// implicitly via the translation tests above.
test('every locale JSON declares the right locale code in _meta', () => {
const cases = [['en', 'en'], ['es', 'es'], ['fr', 'fr'], ['pt', 'pt'], ['ar', 'ar'],
['sw', 'sw'], ['hi', 'hi'], ['ja', 'ja'], ['ko', 'ko'], ['zh', 'zh']];
for (const [code, expected] of cases) {
const dict = require(`../../web/src/locales/${code}.json`);
expect(dict._meta.locale).toBe(expected);
}
});
});
+33 -2
View File
@@ -1,8 +1,39 @@
const { TIERS, VALID_TIERS, getTier, getScanLimit, canAccess } = require('../../src/config/tiers');
describe('tiers config', () => {
test('VALID_TIERS includes free, analyst, desk', () => {
expect(VALID_TIERS).toEqual(['free', 'analyst', 'desk']);
test('VALID_TIERS includes free, africa, analyst, desk (Session 12 added africa)', () => {
expect(VALID_TIERS).toEqual(['free', 'africa', 'analyst', 'desk']);
});
describe('africa tier (Session 12)', () => {
test('exists with 10 scans/day, reasoning + kill conditions visible', () => {
const a = TIERS.africa;
expect(a).toBeDefined();
expect(a.scans_per_day).toBe(10);
expect(a.reasoning_visible).toBe(true);
expect(a.kill_conditions_detail).toBe(true);
// alerts + portfolio + engine2 are paid-tier extras — Africa
// does not include them.
expect(a.alerts).toBe(false);
expect(a.portfolio).toBe(false);
expect(a.engine2).toBe(false);
});
test('api_access is FALSE on africa tier (immutable)', () => {
expect(TIERS.africa.api_access).toBe(false);
});
test('africa scans_per_day sits between free (3) and analyst (15)', () => {
expect(TIERS.africa.scans_per_day).toBeGreaterThan(TIERS.free.scans_per_day);
expect(TIERS.africa.scans_per_day).toBeLessThan(TIERS.analyst.scans_per_day);
});
test('getScanLimit resolves "africa" correctly (sanity for the scan-limit middleware)', () => {
expect(getScanLimit('africa')).toBe(10);
});
test('canAccess("africa", "reasoning_visible") is true', () => {
expect(canAccess('africa', 'reasoning_visible')).toBe(true);
expect(canAccess('africa', 'kill_conditions_detail')).toBe(true);
expect(canAccess('africa', 'alerts')).toBe(false);
expect(canAccess('africa', 'api_access')).toBe(false);
});
});
test('api_access is FALSE on every tier (non-negotiable)', () => {
+1 -1
View File
File diff suppressed because one or more lines are too long
+39
View File
@@ -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
View File
@@ -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>
);
+5 -3
View File
@@ -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>
+139
View File
@@ -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
View File
@@ -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>
+60 -4
View File
@@ -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>
+49
View File
@@ -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 };
}
+115
View File
@@ -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));
}
+47
View File
@@ -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';
+87
View File
@@ -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": "سياسة الخصوصية"
}
}
+86
View File
@@ -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"
}
}
+86
View File
@@ -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"
}
}
+86
View File
@@ -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é"
}
}
+87
View File
@@ -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": "गोपनीयता नीति"
}
}
+87
View File
@@ -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": "プライバシーポリシー"
}
}
+87
View File
@@ -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": "개인정보 처리방침"
}
}
+86
View File
@@ -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"
}
}
+87
View File
@@ -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"
}
}
+87
View File
@@ -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": "隐私政策"
}
}
+86
View File
@@ -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;