Skip to content

Commit 0974b59

Browse files
committed
Merge remote-tracking branch 'origin/main' into w179
2 parents b8d906e + 34fb585 commit 0974b59

16 files changed

Lines changed: 402 additions & 21 deletions

‎public/sw.js‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const CACHE_VERSION = 'debate-lab-v3';
1+
const CACHE_VERSION = 'debate-lab-v4';
22
const APP_SHELL_CACHE = `${CACHE_VERSION}-shell`;
33
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
44

@@ -47,13 +47,22 @@ self.addEventListener('activate', (event) => {
4747
);
4848
});
4949

50+
function htmlMislabeledAsAsset(response, request) {
51+
const type = (response.headers.get('content-type') || '').toLowerCase();
52+
if (!type.includes('text/html')) return false;
53+
const path = new URL(request.url).pathname;
54+
return request.destination === 'script'
55+
|| request.destination === 'style'
56+
|| path.startsWith('/assets/');
57+
}
58+
5059
async function cacheFirst(request) {
5160
const cached = await caches.match(request, { ignoreVary: true });
52-
if (cached) return cached;
61+
if (cached && !htmlMislabeledAsAsset(cached, request)) return cached;
5362

5463
try {
5564
const response = await fetch(request);
56-
if (response.ok) {
65+
if (response.ok && !htmlMislabeledAsAsset(response, request)) {
5766
const cache = await caches.open(RUNTIME_CACHE);
5867
await cache.put(request, response.clone());
5968
}

‎src/components/features/subscription/LimitReachedModal.tsx‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
22
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
33
import { Button } from '@/components/ui/button';
44
import { AlertTriangle, Zap, Clock } from 'lucide-react';
5+
import { useSubscription } from '@/hooks/use-subscription';
56

67
interface LimitReachedModalProps {
78
open: boolean;
@@ -14,7 +15,10 @@ interface LimitReachedModalProps {
1415

1516
export function LimitReachedModal({ open, onOpenChange, variant, limit, current, onUpgrade }: LimitReachedModalProps) {
1617
const { t } = useTranslation();
18+
const { isPro } = useSubscription();
1719
const isTrialExpired = variant === 'trial_expired';
20+
// A Pro student who used today's included requests already pays. Selling Pro again is the wrong next step.
21+
const paidDailyCap = isPro && !isTrialExpired;
1822

1923
return (
2024
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -38,16 +42,24 @@ export function LimitReachedModal({ open, onOpenChange, variant, limit, current,
3842
</DialogHeader>
3943

4044
<div className="flex flex-col gap-2 mt-4">
41-
<Button onClick={onUpgrade} size="lg" className="w-full">
42-
<Zap className="h-4 w-4 mr-2" />
43-
{t('limitModal.upgrade')}
44-
</Button>
45-
<Button variant="ghost" className="w-full" onClick={() => onOpenChange(false)}>
46-
{t('limitModal.notNow', 'Not now')}
47-
</Button>
48-
<p className="text-xs text-muted-foreground text-center mt-1">
49-
{t('limitModal.upgradeNote')}
50-
</p>
45+
{paidDailyCap ? (
46+
<Button onClick={() => onOpenChange(false)} size="lg" className="w-full">
47+
{t('limitModal.gotIt')}
48+
</Button>
49+
) : (
50+
<>
51+
<Button onClick={onUpgrade} size="lg" className="w-full">
52+
<Zap className="h-4 w-4 mr-2" />
53+
{t('limitModal.upgrade')}
54+
</Button>
55+
<Button variant="ghost" className="w-full" onClick={() => onOpenChange(false)}>
56+
{t('limitModal.notNow', 'Not now')}
57+
</Button>
58+
<p className="text-xs text-muted-foreground text-center mt-1">
59+
{t('limitModal.upgradeNote')}
60+
</p>
61+
</>
62+
)}
5163
</div>
5264
</DialogContent>
5365
</Dialog>

‎src/lib/billing/checkout-return.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { SubscriptionTier } from '@/hooks/use-subscription';
2+
3+
/** How long Settings keeps asking after Stripe redirects back. */
4+
export const CHECKOUT_CONFIRM_WINDOW_MS = 20_000;
5+
export const CHECKOUT_CONFIRM_INTERVAL_MS = 2_000;
6+
7+
export type CheckoutReturnAction = 'ignore' | 'wait' | 'poll' | 'confirmed' | 'slow' | 'canceled';
8+
9+
/**
10+
* What Settings should do with `?checkout=` after Stripe.
11+
* The first subscription read often lands before the webhook, and that read
12+
* stays fresh for a minute, so a successful payment can look expired.
13+
*/
14+
export function nextCheckoutConfirmation(input: {
15+
checkout: string | null;
16+
tier: SubscriptionTier;
17+
elapsedMs: number;
18+
loading: boolean;
19+
}): CheckoutReturnAction {
20+
if (input.checkout === 'canceled') return 'canceled';
21+
if (input.checkout !== 'success') return 'ignore';
22+
if (input.loading) return 'wait';
23+
if (input.tier === 'pro' || input.tier === 'legacy_free') return 'confirmed';
24+
if (input.elapsedMs >= CHECKOUT_CONFIRM_WINDOW_MS) return 'slow';
25+
return 'poll';
26+
}
27+
28+
export function stripCheckoutParams(params: URLSearchParams): URLSearchParams {
29+
const next = new URLSearchParams(params);
30+
next.delete('checkout');
31+
next.delete('session_id');
32+
return next;
33+
}

‎src/lib/reload-stale-chunk.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const RELOAD_PREFIX = 'debatelab-stale-chunk:';
2+
3+
type ReloadTarget = Pick<Window, 'addEventListener' | 'sessionStorage' | 'location'>;
4+
5+
/**
6+
* After a deploy, an open tab can ask for a chunk the new build no longer has.
7+
* Reload once so the fresh document loads the new names. A second failure on
8+
* the same path stays put, so a missing file cannot refresh the tab forever.
9+
*/
10+
export function installStaleChunkReload(target: ReloadTarget = window): void {
11+
target.addEventListener('vite:preloadError', (event) => {
12+
const path = target.location.pathname || '/';
13+
const key = `${RELOAD_PREFIX}${path}`;
14+
let alreadyReloaded = false;
15+
try {
16+
alreadyReloaded = target.sessionStorage.getItem(key) === '1';
17+
if (!alreadyReloaded) target.sessionStorage.setItem(key, '1');
18+
} catch {
19+
return;
20+
}
21+
if (alreadyReloaded) return;
22+
event.preventDefault();
23+
target.location.reload();
24+
});
25+
}

‎src/lib/timer-shortcuts.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const INTERACTIVE_SELECTOR = [
2+
'button',
3+
'select',
4+
'a',
5+
'input',
6+
'textarea',
7+
'[role="menu"]',
8+
'[role="menuitem"]',
9+
'[role="listbox"]',
10+
'[role="option"]',
11+
'[role="dialog"]',
12+
'[role="combobox"]',
13+
].join(', ');
14+
15+
/**
16+
* Timer keys are on the window. They must stay out of controls that already use
17+
* Space, Escape, and the arrow keys, including a menu that just consumed Escape.
18+
*/
19+
export function timerShortcutBlocked(event: KeyboardEvent): boolean {
20+
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return true;
21+
const target = event.target;
22+
if (!(target instanceof Element)) return false;
23+
if (target instanceof HTMLElement && target.isContentEditable) return true;
24+
return Boolean(target.closest(INTERACTIVE_SELECTOR));
25+
}

‎src/locales/en.json‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,13 @@
445445
"manageSubscription": "Manage Subscription",
446446
"setUpBilling": "Set Up Billing",
447447
"upgradeToPro": "Upgrade to Pro",
448+
"confirmingSubscription": "Confirming your Pro subscription…",
449+
"checkoutCanceled": "Checkout canceled",
450+
"checkoutCanceledDesc": "No charge was made.",
451+
"checkoutConfirmed": "You're on Pro",
452+
"checkoutConfirmedDesc": "Your subscription is active.",
453+
"checkoutConfirmSlowTitle": "Still waiting on Stripe",
454+
"checkoutConfirmSlow": "Stripe hasn't confirmed the payment yet. This page will catch up after a refresh.",
448455
"accessActive": "AI access is active. Usage limits may apply.",
449456
"accessExpired": "Your access has expired. Upgrade to continue using AI tools.",
450457
"globalSettings": "Global Settings",
@@ -1332,7 +1339,8 @@
13321339
"limitReachedDesc": "You've used {{current}} of {{limit}} included AI uses today. Access refreshes at midnight UTC.",
13331340
"upgrade": "Upgrade to Pro - $5/mo",
13341341
"upgradeNote": "More AI prep access + high reasoning",
1335-
"notNow": "Not now"
1342+
"notNow": "Not now",
1343+
"gotIt": "OK"
13361344
},
13371345
"modelModal": {
13381346
"title": "Pro Reasoning",

‎src/locales/es.json‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,13 @@
445445
"manageSubscription": "Gestionar suscripción",
446446
"setUpBilling": "Configurar facturación",
447447
"upgradeToPro": "Mejorar a Pro",
448+
"confirmingSubscription": "Confirmando tu suscripción Pro…",
449+
"checkoutCanceled": "Pago cancelado",
450+
"checkoutCanceledDesc": "No se hizo ningún cargo.",
451+
"checkoutConfirmed": "Ya tienes Pro",
452+
"checkoutConfirmedDesc": "Tu suscripción está activa.",
453+
"checkoutConfirmSlowTitle": "Seguimos esperando a Stripe",
454+
"checkoutConfirmSlow": "Stripe todavía no ha confirmado el pago. Esta página se actualizará al recargar.",
448455
"accessActive": "El acceso a la IA está activo. Pueden aplicarse límites de uso.",
449456
"accessExpired": "Tu acceso ha caducado. Mejora el plan para seguir usando las herramientas de IA.",
450457
"globalSettings": "Ajustes globales",
@@ -1332,7 +1339,8 @@
13321339
"limitReachedDesc": "Has usado {{current}} de {{limit}} usos de IA incluidos hoy. El acceso se renueva a medianoche UTC.",
13331340
"upgrade": "Actualizar a Pro - $5/mes",
13341341
"upgradeNote": "Más acceso a preparación con IA + razonamiento alto",
1335-
"notNow": "Ahora no"
1342+
"notNow": "Ahora no",
1343+
"gotIt": "Entendido"
13361344
},
13371345
"modelModal": {
13381346
"title": "Razonamiento Pro",

‎src/locales/hu.json‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,13 @@
445445
"manageSubscription": "Előfizetés kezelése",
446446
"setUpBilling": "Számlázás beállítása",
447447
"upgradeToPro": "Váltás Pro-ra",
448+
"confirmingSubscription": "A Pro előfizetés megerősítése…",
449+
"checkoutCanceled": "Fizetés megszakítva",
450+
"checkoutCanceledDesc": "Nem történt terhelés.",
451+
"checkoutConfirmed": "Pro csomagod aktív",
452+
"checkoutConfirmedDesc": "Az előfizetésed él.",
453+
"checkoutConfirmSlowTitle": "Még várunk a Stripe-ra",
454+
"checkoutConfirmSlow": "A Stripe még nem erősítette meg a fizetést. Frissítés után ez az oldal utoléri.",
448455
"accessActive": "Az MI-hozzáférés aktív. Használati korlátok érvényesek lehetnek.",
449456
"accessExpired": "A hozzáférésed lejárt. Válts magasabb csomagra az MI-eszközök további használatához.",
450457
"globalSettings": "Globális beállítások",
@@ -1332,7 +1339,8 @@
13321339
"limitReachedDesc": "Ma {{current}} / {{limit}} benne foglalt MI használatot használtál fel. A hozzáférés éjfélkor (UTC) frissül.",
13331340
"upgrade": "Frissítés Pro-ra - $5/hó",
13341341
"upgradeNote": "Több MI felkészülési hozzáférés + magas szintű érvelés",
1335-
"notNow": "Most nem"
1342+
"notNow": "Most nem",
1343+
"gotIt": "Rendben"
13361344
},
13371345
"modelModal": {
13381346
"title": "Pro érvelés",

‎src/main.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import "./i18n";
66
import "@fontsource-variable/inter";
77
import "./index.css";
88
import { registerServiceWorker } from "./register-service-worker";
9+
import { installStaleChunkReload } from "./lib/reload-stale-chunk";
910
import { initializeObservability } from "./lib/observability";
1011
import { preloadRoute } from "./lib/route-modules";
1112

@@ -24,6 +25,8 @@ function prerenderedRoute(container: HTMLElement): string | undefined {
2425
return route === "*" || route === window.location.pathname ? route : undefined;
2526
}
2627

28+
installStaleChunkReload();
29+
2730
export function mountApp(): void {
2831
const container = document.getElementById("root")!;
2932
const root = createRoot(container);

‎src/pages/SettingsPage.tsx‎

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// src/pages/SettingsPage.tsx
2-
import { useState } from 'react';
2+
import { useEffect, useRef, useState } from 'react';
33
import { useQuery } from '@tanstack/react-query';
4-
import { Link, useNavigate } from 'react-router-dom';
4+
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
55
import { useTranslation } from 'react-i18next';
66
import { Settings, Lock, Trash2, Crown, CreditCard, Zap, Shield, Download, UserX } from 'lucide-react';
77
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -33,6 +33,11 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
3333
import { Input } from '@/components/ui/input';
3434
import { PageHeader } from '@/components/layout/PageHeader';
3535
import { APP_LANGUAGES, isAppLanguage } from '@/lib/languages';
36+
import {
37+
CHECKOUT_CONFIRM_INTERVAL_MS,
38+
nextCheckoutConfirmation,
39+
stripCheckoutParams,
40+
} from '@/lib/billing/checkout-return';
3641

3742

3843
// Tinted badges keep their label readable (white on amber or green was about 2:1).
@@ -62,6 +67,45 @@ export default function SettingsPage() {
6267
const [deletingData, setDeletingData] = useState(false);
6368
const [signingOut, setSigningOut] = useState(false);
6469
const navigate = useNavigate();
70+
const [searchParams, setSearchParams] = useSearchParams();
71+
const checkout = searchParams.get('checkout');
72+
const refreshSubscription = useRef(subscription.refresh);
73+
refreshSubscription.current = subscription.refresh;
74+
const checkoutStarted = useRef<number | null>(null);
75+
const checkoutSettled = useRef(false);
76+
const [checkoutPoll, setCheckoutPoll] = useState(0);
77+
const confirmingCheckout = checkout === 'success'
78+
&& subscription.tier !== 'pro'
79+
&& subscription.tier !== 'legacy_free';
80+
81+
useEffect(() => {
82+
const elapsed = checkoutStarted.current === null ? 0 : Date.now() - checkoutStarted.current;
83+
const action = nextCheckoutConfirmation({
84+
checkout,
85+
tier: subscription.tier,
86+
elapsedMs: elapsed,
87+
loading: subscription.loading,
88+
});
89+
if (action === 'ignore' || action === 'wait' || checkoutSettled.current) return;
90+
91+
if (action === 'canceled' || action === 'confirmed' || action === 'slow') {
92+
checkoutSettled.current = true;
93+
const notice = action === 'canceled'
94+
? { title: t('settings.checkoutCanceled'), description: t('settings.checkoutCanceledDesc') }
95+
: action === 'confirmed'
96+
? { title: t('settings.checkoutConfirmed'), description: t('settings.checkoutConfirmedDesc') }
97+
: { title: t('settings.checkoutConfirmSlowTitle'), description: t('settings.checkoutConfirmSlow') };
98+
toast(notice);
99+
setSearchParams(stripCheckoutParams(searchParams), { replace: true });
100+
return;
101+
}
102+
103+
if (checkoutStarted.current === null) checkoutStarted.current = Date.now();
104+
const timer = window.setTimeout(() => {
105+
void refreshSubscription.current().finally(() => setCheckoutPoll((count) => count + 1));
106+
}, CHECKOUT_CONFIRM_INTERVAL_MS);
107+
return () => window.clearTimeout(timer);
108+
}, [checkout, checkoutPoll, searchParams, setSearchParams, subscription.loading, subscription.tier, t, toast]);
65109
// Must match deletionConfirmation in supabase/functions/account/index.ts.
66110
const expectedAccountConfirmation = user?.email || 'DELETE';
67111

@@ -199,8 +243,10 @@ export default function SettingsPage() {
199243
</div>
200244
</CardHeader>
201245
<CardContent className="space-y-4">
202-
{subscription.loading ? (
203-
<p role="status" className="text-sm text-muted-foreground">{t('settings.loading')}</p>
246+
{subscription.loading || confirmingCheckout ? (
247+
<p role="status" className="text-sm text-muted-foreground">
248+
{confirmingCheckout ? t('settings.confirmingSubscription') : t('settings.loading')}
249+
</p>
204250
) : (
205251
<>
206252
<div className="flex items-center justify-between">

0 commit comments

Comments
 (0)