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