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
+33 -16
View File
@@ -3,8 +3,7 @@
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';
import { useT, useRegion } from '@/contexts/LocaleContext';
type TierId = 'free' | 'africa' | 'analyst' | 'desk';
@@ -113,21 +112,28 @@ const TIERS: TierConfig[] = [
export default function Pricing() {
const router = useRouter();
const { session, loading: authLoading } = useAuth();
const { locale } = useLocale();
const { inAfrica } = useRegion();
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)
// Session 13 — Africa tier visibility + order is now driven by
// REAL IP geolocation via Cloudflare's CF-IPCountry header (stamped
// onto x-vyndr-country by the middleware). The previous locale-
// based proxy (Swahili speakers everywhere) was both too narrow
// (most African users browse in English/French) and too broad
// (Swahili speakers outside Africa got the discount).
//
// Inside Africa: VYNDR Africa renders first, then Free, then Analyst, Desk.
// Outside Africa: the Africa tier card is filtered out of the render
// entirely — no path for non-African users to even
// see the $4.99 option.
// Unknown country (local dev, non-Cloudflare): degrades closed →
// Africa tier hidden (same as outside Africa).
const orderedTiers = inAfrica
? [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;
: TIERS.filter((x) => x.id !== 'africa');
async function startCheckout(tier: TierId) {
setError(null);
@@ -218,7 +224,18 @@ export default function Pricing() {
</div>
)}
<div className="pricing-grid" style={{ display: 'grid', gap: 24 }}>
<div
className="pricing-grid"
style={{
display: 'grid',
gap: 24,
// The desktop column count tracks the visible tier count
// (3 outside Africa, 4 inside). styled-jsx's `:global()`
// doesn't handle attribute selectors cleanly, so we pin
// the value via a CSS custom property on the grid root.
['--pricing-cols' as keyof React.CSSProperties]: String(orderedTiers.length),
} as React.CSSProperties}
>
{orderedTiers.map((tier, i) => {
const isPending = pending === tier.id;
const isDisabled = authLoading || (pending !== null && !isPending);
@@ -318,15 +335,15 @@ export default function Pricing() {
}
@media (min-width: 768px) {
:global(.pricing-grid) {
/* 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);
/* --pricing-cols is set by the React render (3 outside
Africa, 4 inside) so the desktop layout tracks the
visible tier count without an attribute selector. */
grid-template-columns: repeat(var(--pricing-cols, 3), 1fr);
}
}
`}</style>