Session 13: The Slate, Africa geo-restriction, OAuth providers, PropRow + GameCard (1311 tests)

This commit is contained in:
Kev
2026-06-11 03:48:07 -04:00
parent d957dee17b
commit 10159209fa
18 changed files with 1452 additions and 64 deletions
+13 -2
View File
@@ -5,6 +5,10 @@ import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { useParlay } from '@/contexts/ParlayContext';
import { GradePill } from '@/components/GradeCard';
// Session 13 — The Slate is the new browse-first lead surface. The
// existing dashboard sections (Most Parlayed, Recent Reads) stay
// below as intelligence layers on top of the raw odds.
import Slate from '@/components/Slate';
type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -159,8 +163,15 @@ export default function DashboardPage() {
)}
</header>
{/* Sport tabs */}
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginBottom: 32, borderBottom: '1px solid var(--border)' }}>
{/* Session 13 — Browse-first slate. Owns its own sport-tab UI,
search, and inline grading. Renders ABOVE the existing
intelligence sections (Top Graded / Most Parlayed / Recent
Reads) which serve as supplementary surfaces. */}
<Slate tier={tier} />
{/* Legacy sport tabs — supplementary, kept for the existing
Top Graded / Most Parlayed flows below. */}
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginTop: 40, marginBottom: 32, borderBottom: '1px solid var(--border)' }}>
{SPORT_TABS.map((s) => {
const active = s === sport;
const count = gameCountsBySport[s];
+6 -2
View File
@@ -14,7 +14,7 @@ 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 { LOCALE_HEADER, COUNTRY_HEADER, isLocale, DEFAULT_LOCALE, LOCALE_META } from '@/lib/locales';
import './globals.css';
export const metadata: Metadata = {
@@ -97,6 +97,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const localeHeader = hdrs.get(LOCALE_HEADER);
const locale = isLocale(localeHeader) ? localeHeader : DEFAULT_LOCALE;
const dir = LOCALE_META[locale].dir;
// Session 13 — country from CF-IPCountry (set by middleware).
// Empty string when traffic bypasses Cloudflare (local dev, direct
// origin hits). The Africa-tier gate degrades closed on empty.
const country = hdrs.get(COUNTRY_HEADER) || '';
return (
<html lang={locale} dir={dir} className="dark">
@@ -109,7 +113,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
/>
</head>
<body className="antialiased tex-grain">
<LocaleProvider locale={locale}>
<LocaleProvider locale={locale} country={country}>
<PostHogProvider>
<AuthProvider>
<ExplainModeProvider>
+25 -7
View File
@@ -10,7 +10,7 @@ function LoginInner() {
const router = useRouter();
const search = useSearchParams();
const next = search.get('next') || '/dashboard';
const { signIn, signInWithGoogle } = useAuth();
const { signIn, signInWithProvider } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -31,10 +31,20 @@ function LoginInner() {
router.replace(next);
};
const handleGoogle = async () => {
// Session 13 — generic OAuth dispatch. Apple + X providers must be
// configured in the Supabase dashboard (Apple needs a Service ID +
// private key; X needs OAuth 2.0 client creds) before the redirect
// succeeds. Unconfigured providers return an inline error string
// instead of silently failing.
const handleOAuth = async (provider: 'google' | 'apple' | 'twitter') => {
setBusy(true);
await signInWithGoogle();
// Supabase redirects to provider; on return AuthContext picks up the session.
setError('');
const { error: err } = await signInWithProvider(provider);
if (err) {
setError(err);
setBusy(false);
}
// On success the page redirects to the provider; no state change here.
};
return (
@@ -53,9 +63,17 @@ function LoginInner() {
Welcome back. Let&apos;s read something.
</p>
<button onClick={handleGoogle} disabled={busy} className="btn-ghost" style={{ width: '100%', marginBottom: 16, padding: 12 }}>
Continue with Google
</button>
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
<button onClick={() => handleOAuth('google')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
Continue with Google
</button>
<button onClick={() => handleOAuth('apple')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
Continue with Apple
</button>
<button onClick={() => handleOAuth('twitter')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
Continue with X
</button>
</div>
<div style={dividerStyle}>
<span style={dividerLine} />
+22 -6
View File
@@ -10,7 +10,7 @@ function SignupInner() {
const router = useRouter();
const search = useSearchParams();
const next = search.get('next') || '/dashboard';
const { signUp, signInWithGoogle } = useAuth();
const { signUp, signInWithProvider } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -40,9 +40,17 @@ function SignupInner() {
setTimeout(() => router.replace(next), 1500);
};
const handleGoogle = async () => {
// Session 13 — generic OAuth dispatch. Same provider buttons as
// the login page; same graceful-error contract for unconfigured
// providers (Apple/X).
const handleOAuth = async (provider: 'google' | 'apple' | 'twitter') => {
setBusy(true);
await signInWithGoogle();
setError('');
const { error: err } = await signInWithProvider(provider);
if (err) {
setError(err);
setBusy(false);
}
};
if (done) {
@@ -75,9 +83,17 @@ function SignupInner() {
5 free reads every month. Your first read is fully unlocked. No credit card.
</p>
<button onClick={handleGoogle} disabled={busy} className="btn-ghost" style={{ width: '100%', marginBottom: 16, padding: 12 }}>
Continue with Google
</button>
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
<button onClick={() => handleOAuth('google')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
Continue with Google
</button>
<button onClick={() => handleOAuth('apple')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
Continue with Apple
</button>
<button onClick={() => handleOAuth('twitter')} disabled={busy} className="btn-ghost" style={{ width: '100%', padding: 12 }}>
Continue with X
</button>
</div>
<div style={dividerStyle}>
<span style={dividerLine} />