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
+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;