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