Session 13: The Slate, Africa geo-restriction, OAuth providers, PropRow + GameCard (1311 tests)
This commit is contained in:
@@ -28,6 +28,11 @@ interface AuthContextValue {
|
||||
signUp: (email: string, password: string, ageVerified: boolean) => Promise<{ error?: string }>;
|
||||
signIn: (email: string, password: string) => Promise<{ error?: string }>;
|
||||
signInWithGoogle: () => Promise<void>;
|
||||
// Session 13 — generalized OAuth dispatch. Apple/Twitter call paths
|
||||
// exist in the UI; whether the call SUCCEEDS depends on the
|
||||
// provider being configured in the Supabase dashboard. Unconfigured
|
||||
// providers return an error string the login page surfaces inline.
|
||||
signInWithProvider: (provider: 'google' | 'apple' | 'twitter') => Promise<{ error?: string }>;
|
||||
signOut: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
bumpScanCount: () => void;
|
||||
@@ -151,13 +156,36 @@ export default function AuthProvider({ children }: { children: React.ReactNode }
|
||||
[supabase],
|
||||
);
|
||||
|
||||
// Session 13 — generic OAuth dispatcher. Supabase returns an error
|
||||
// object when the provider isn't configured in the dashboard
|
||||
// (Apple needs a Service ID + private key; Twitter/X needs an
|
||||
// OAuth 2.0 client). We translate the upstream error into a flat
|
||||
// `{ error: string }` shape so the login UI can show a friendly
|
||||
// line without inspecting Supabase internals.
|
||||
const signInWithProvider = useCallback<AuthContextValue['signInWithProvider']>(
|
||||
async (provider) => {
|
||||
if (!supabase) return { error: 'Auth not initialized' };
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
||||
});
|
||||
if (error) {
|
||||
return { error: `${provider} login isn't available yet. Use email or another method.` };
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return { error: 'Login failed. Try another method.' };
|
||||
}
|
||||
},
|
||||
[supabase],
|
||||
);
|
||||
|
||||
// Kept as a thin alias so legacy callers (signup/login pages) keep
|
||||
// working without churn. New code should call signInWithProvider.
|
||||
const signInWithGoogle = useCallback(async () => {
|
||||
if (!supabase) return;
|
||||
await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
||||
});
|
||||
}, [supabase]);
|
||||
await signInWithProvider('google');
|
||||
}, [signInWithProvider]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
if (!supabase) return;
|
||||
@@ -189,12 +217,13 @@ export default function AuthProvider({ children }: { children: React.ReactNode }
|
||||
signUp,
|
||||
signIn,
|
||||
signInWithGoogle,
|
||||
signInWithProvider,
|
||||
signOut,
|
||||
refresh,
|
||||
bumpScanCount,
|
||||
markMFAPrompted,
|
||||
};
|
||||
}, [user, session, profile, loading, signUp, signIn, signInWithGoogle, signOut, refresh, bumpScanCount, markMFAPrompted]);
|
||||
}, [user, session, profile, loading, signUp, signIn, signInWithGoogle, signInWithProvider, signOut, refresh, bumpScanCount, markMFAPrompted]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
@@ -214,6 +243,7 @@ export function useAuth(): AuthContextValue {
|
||||
signUp: async () => ({ error: 'Auth not initialized' }),
|
||||
signIn: async () => ({ error: 'Auth not initialized' }),
|
||||
signInWithGoogle: async () => {},
|
||||
signInWithProvider: async () => ({ error: 'Auth not initialized' }),
|
||||
signOut: async () => {},
|
||||
refresh: async () => {},
|
||||
bumpScanCount: () => {},
|
||||
|
||||
@@ -1,34 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useMemo, ReactNode } from 'react';
|
||||
import { Locale, DEFAULT_LOCALE, isLocale, LOCALE_META } from '@/lib/locales';
|
||||
import { Locale, DEFAULT_LOCALE, isLocale, LOCALE_META, isAfricanCountry } from '@/lib/locales';
|
||||
import { getTranslations, TFunction } from '@/lib/i18n';
|
||||
|
||||
/**
|
||||
* Client-side locale context (Session 12).
|
||||
* Client-side locale + region context (Session 12; Session 13 added
|
||||
* the `country` field from the CF-IPCountry header).
|
||||
*
|
||||
* 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.
|
||||
* The root layout (server component) resolves the locale + country
|
||||
* from request headers and passes them as props to `<LocaleProvider>`.
|
||||
* From there every client component can `useT()` / `useRegion()`
|
||||
* without prop-drilling or repeating the resolution.
|
||||
*
|
||||
* Memoized: the `t` function is stable per render of the provider,
|
||||
* so consumers don't re-render on every parent render.
|
||||
* Memoized: the `t` function and derived booleans are 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;
|
||||
// Session 13 region fields.
|
||||
country: string; // 'NG', 'US', '' (unknown / non-Cloudflare path)
|
||||
inAfrica: boolean; // true when country ∈ AFRICAN_COUNTRIES
|
||||
}
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
export function LocaleProvider({ locale, children }: { locale: string; children: ReactNode }) {
|
||||
export function LocaleProvider({
|
||||
locale,
|
||||
country = '',
|
||||
children,
|
||||
}: { locale: string; country?: 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]);
|
||||
const cc = String(country || '').toUpperCase();
|
||||
return {
|
||||
locale: resolved,
|
||||
dir: LOCALE_META[resolved].dir,
|
||||
t: bundle.t,
|
||||
country: cc,
|
||||
inAfrica: isAfricanCountry(cc),
|
||||
};
|
||||
}, [locale, country]);
|
||||
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -47,3 +64,16 @@ export function useLocale(): { locale: Locale; dir: 'ltr' | 'rtl' } {
|
||||
if (!ctx) return { locale: DEFAULT_LOCALE, dir: 'ltr' };
|
||||
return { locale: ctx.locale, dir: ctx.dir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Session 13 — region hook for components that need to gate by
|
||||
* geography (pricing, regulatory disclaimers, regional payment
|
||||
* methods). Returns `inAfrica: false` when country is unknown
|
||||
* (degrade-closed: don't surface region-specific UX on unverified
|
||||
* traffic).
|
||||
*/
|
||||
export function useRegion(): { country: string; inAfrica: boolean } {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (!ctx) return { country: '', inAfrica: false };
|
||||
return { country: ctx.country, inAfrica: ctx.inAfrica };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user