)}
diff --git a/src/components/features/subscription/PricingModal.tsx b/src/components/features/subscription/PricingModal.tsx
index eb304e64c..b896062c8 100644
--- a/src/components/features/subscription/PricingModal.tsx
+++ b/src/components/features/subscription/PricingModal.tsx
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Check, CreditCard, Sparkles, Zap, Crown } from 'lucide-react';
-import { redirectToCheckout, redirectToPortal, redeemPromoCode } from '@/lib/stripe';
+import { isNoActiveSubscription, redirectToCheckout, redirectToPortal, redeemPromoCode } from '@/lib/stripe';
import { useToast } from '@/hooks/use-toast';
import { useSubscription } from '@/hooks/use-subscription';
import { useExternalRedirectPending } from '@/hooks/use-external-redirect-pending';
@@ -81,8 +81,8 @@ export function PricingModal({ open, onOpenChange, onPromoRedeemed }: PricingMod
try {
await runRedirect(() => action === 'manage' ? redirectToPortal() : redirectToCheckout(promoCode || undefined));
} catch (err) {
- // The portal's own refusal is English; say it the way Settings does.
- if (action === 'manage' && err instanceof Error && err.message.toLowerCase().includes('no active subscription')) {
+ // Say the portal's refusal the way Settings does.
+ if (action === 'manage' && isNoActiveSubscription(err)) {
toast({ title: t('settings.noSubscription'), description: t('settings.noSubscriptionDesc') });
return;
}
diff --git a/src/components/layout/PublicPageShell.tsx b/src/components/layout/PublicPageShell.tsx
index 09afd0183..2efd764b5 100644
--- a/src/components/layout/PublicPageShell.tsx
+++ b/src/components/layout/PublicPageShell.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '@/lib/supabase/auth-context';
import { cn } from '@/lib/utils';
import { MAIN_CONTENT_ID, SkipLink } from '@/components/layout/SkipLink';
+import { LanguageSwitcher } from '@/components/LanguageSwitcher';
/** Where "home" is for this visitor: the app when signed in, the landing page otherwise. */
// eslint-disable-next-line react-refresh/only-export-components
@@ -23,13 +24,14 @@ export function PublicPageShell({ children, className }: { children: ReactNode;
-
+
DebateLab
+
{children}
diff --git a/src/lib/ai/errors.ts b/src/lib/ai/errors.ts
index b4c3f092d..755c429ec 100644
--- a/src/lib/ai/errors.ts
+++ b/src/lib/ai/errors.ts
@@ -1,4 +1,5 @@
import i18n from '@/i18n';
+import { serverErrorMessage } from '@/lib/server-errors';
import { subscriptionLimitMessage } from './limit-message';
/**
@@ -342,10 +343,7 @@ function mapAIErrorPayload(
// Return appropriate error type based on status code
if (statusCode === 401) {
- return new AIAuthError(
- 'Authentication failed. Your session may have expired. Please sign in again.',
- { statusCode, requestId }
- );
+ return new AIAuthError(i18n.t('serverErrors.sessionExpired'), { statusCode, requestId });
}
if (statusCode === 402) {
@@ -364,8 +362,9 @@ function mapAIErrorPayload(
if (code === 'upgrade_required' || message.includes('model') || message.includes('upgrade')) {
return new AIModelRestrictionError(message, { requestId });
}
+ // A suspended account says so; another refusal keeps the server's text.
return new AIAuthError(
- message || 'Access denied. Your session may have expired. Please sign in again.',
+ serverErrorMessage(code, payloadMessage || i18n.t('serverErrors.accessDenied')),
{ statusCode, requestId }
);
}
@@ -396,17 +395,13 @@ function mapAIErrorPayload(
if (!statusCode) {
return new AIUnknownError(i18n.t('aiErrors.unexpected'), { requestId, causeType: privacySafeCauseType(error) });
}
+ // A known code reads in the interface language; anything else keeps the server's text.
+ const serviceMessage = serverErrorMessage(errorCode, i18n.t('serverErrors.aiService', { message }));
if (statusCode >= 500) {
- return new AIServerError(
- `AI service error: ${message}. Please try again.`,
- { statusCode, requestId }
- );
+ return new AIServerError(serviceMessage, { statusCode, requestId });
}
- return new AIError(
- `AI service error: ${message}. Please try again.`,
- { statusCode, requestId }
- );
+ return new AIError(serviceMessage, { statusCode, requestId });
}
export function isProviderErrorMessage(message: string): boolean {
diff --git a/src/lib/ai/features/ask.ts b/src/lib/ai/features/ask.ts
index 7d59bafe5..8bba0a3a8 100644
--- a/src/lib/ai/features/ask.ts
+++ b/src/lib/ai/features/ask.ts
@@ -1,3 +1,4 @@
+import i18n from '@/i18n';
import type { ProviderConfig } from '../providers/types';
import { AskOutputSchema, type AskOutput } from '../schemas/ask';
import type { WorkspaceToolContext } from '@/lib/workspace/tool-context';
@@ -13,7 +14,7 @@ const MAX_ASK_MESSAGE_BYTES = 80_000;
export function boundAskMessage(message: string): string {
if (!isAskMessageWithinBudget(message)) {
- throw new AIError('Message is too large. Shorten it before sending.', { statusCode: 413 });
+ throw new AIError(i18n.t('serverErrors.messageTooLarge'), { statusCode: 413 });
}
return message;
}
diff --git a/src/lib/ai/request.ts b/src/lib/ai/request.ts
index c223aa408..be92b61ac 100644
--- a/src/lib/ai/request.ts
+++ b/src/lib/ai/request.ts
@@ -11,6 +11,7 @@ import { captureException, captureProductEvent } from '@/lib/observability';
import { isAppLanguage, type AppLanguage } from '@/lib/languages';
import i18n from '@/i18n';
import { isSubscriptionRefusalCode, subscriptionLimitMessage } from './limit-message';
+import { serverErrorMessage } from '@/lib/server-errors';
import type { WorkspaceToolContext } from '@/lib/workspace/tool-context';
import {
AIError,
@@ -219,7 +220,7 @@ export async function callSupabaseAI(
}
if (!accessToken) {
- throw new AIAuthError('Please sign in to use AI features', { requestId });
+ throw new AIAuthError(i18n.t('serverErrors.signInRequired'), { requestId });
}
const requestBody = {
@@ -240,7 +241,7 @@ export async function callSupabaseAI(
};
if (utf8ByteLength(JSON.stringify(requestBody)) > MAX_CLIENT_REQUEST_BODY_BYTES) {
- throw new AIError('Request is too large. Shorten the input and try again.', { statusCode: 413, requestId });
+ throw new AIError(i18n.t('serverErrors.requestTooLarge'), { statusCode: 413, requestId });
}
const live = options.live;
@@ -317,7 +318,7 @@ export async function callSupabaseAI(
}
// The function reported a failure that is not a limit, auth, or provider error.
- throw new AIServerError(errorMessage, { statusCode: 500, requestId });
+ throw new AIServerError(serverErrorMessage(errorCode, errorMessage), { statusCode: 500, requestId });
}
// Validate response has expected output
diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts
index a1a8d33a2..0082362d8 100644
--- a/src/lib/ai/streaming.ts
+++ b/src/lib/ai/streaming.ts
@@ -110,7 +110,7 @@ export async function streamAskChat(
throw error;
}
if (!accessToken) {
- const error = new AIAuthError('Please sign in to use AI features', { requestId });
+ const error = new AIAuthError(i18n.t('serverErrors.signInRequired'), { requestId });
captureStreamFailure(error, provider, config.model, startTime, requestId, Boolean(handlers.signal?.aborted));
throw error;
}
diff --git a/src/lib/seo/head-text.ts b/src/lib/seo/head-text.ts
new file mode 100644
index 000000000..9786b4089
--- /dev/null
+++ b/src/lib/seo/head-text.ts
@@ -0,0 +1,19 @@
+import site from '@/config/site.json';
+import en from '@/locales/en.json';
+
+/** The catalog section (`pageHead.`) holding each page's title and description. */
+export type PageHeadId = keyof typeof en.pageHead;
+
+/** Fills `{{brand}}` and `{{product}}` in a head string; the same values usePageHead hands to t(). */
+export const HEAD_NAMES = { brand: site.brand, product: site.product } as const;
+
+const fill = (text: string) => text.replace(/\{\{(brand|product)\}\}/g, (_, name: keyof typeof HEAD_NAMES) => HEAD_NAMES[name]);
+
+/**
+ * A page's English head from the catalog. The prerender writes it into the
+ * HTML, and usePageHead shows the same strings in the visitor's language.
+ */
+export function englishHead(id: PageHeadId): { title: string; description: string } {
+ const head = en.pageHead[id];
+ return { title: fill(head.title), description: fill(head.description) };
+}
diff --git a/src/lib/seo/pages.ts b/src/lib/seo/pages.ts
index 3c3aa1e79..94d5714ee 100644
--- a/src/lib/seo/pages.ts
+++ b/src/lib/seo/pages.ts
@@ -1,5 +1,6 @@
import site from '@/config/site.json';
-import { TOOL_PAGE_HEADS } from './tool-pages';
+import { englishHead, type PageHeadId } from './head-text';
+import { TOOL_PAGE_HEAD_IDS, TOOL_PAGE_HEADS } from './tool-pages';
/** Brand and domain strings live in src/config/site.json; nothing else should hardcode them. */
export const SITE_ORIGIN = site.origin;
@@ -24,40 +25,32 @@ export interface PageHead {
}
/**
- * Head tags for the pages the build prerenders (scripts/prerender.mjs). The
- * client sets the same tags when it shows these pages (usePageHead), so a
- * crawler that runs the app sees what the HTML said.
+ * Head tags for the pages the build prerenders (scripts/prerender.mjs), in
+ * English from the `pageHead` catalog section. The client sets the same tags
+ * when it shows these pages (usePageHead), in the visitor's language, so a
+ * crawler that runs the app in English sees what the HTML said.
*/
export const PAGE_HEADS = {
- '/': {
- title: `${BRAND} | AI Debate Prep, Cases & Rebuttals`,
- description: `${PRODUCT} is an AI debate prep and practice app for students and competitive debaters: cases, rebuttals, evidence, coaching and timed practice rounds against an AI opponent.`,
- canonicalPath: '/',
- },
- '/terms': {
- title: `Terms of Service | ${BRAND}`,
- description: `The terms for using ${BRAND}: accounts, subscriptions and billing, acceptable use, AI-generated content, intellectual property, and liability.`,
- canonicalPath: '/terms',
- },
- '/privacy': {
- title: `Privacy Policy | ${BRAND}`,
- description: `How ${BRAND} collects, uses, stores, and shares your information, how AI requests are processed, and the rights you have over your data.`,
- canonicalPath: '/privacy',
- },
- '/expocit/sources': {
- title: `Sources | ${BRAND}`,
- description: `The sources consulted during the research and development of ${BRAND}, listed alphabetically by author surname or institutional name.`,
- canonicalPath: '/expocit/sources',
- },
+ '/': { ...englishHead('home'), canonicalPath: '/' },
+ '/terms': { ...englishHead('terms'), canonicalPath: '/terms' },
+ '/privacy': { ...englishHead('privacy'), canonicalPath: '/privacy' },
+ '/expocit/sources': { ...englishHead('sources'), canonicalPath: '/expocit/sources' },
...TOOL_PAGE_HEADS,
- '*': {
- title: `Page not found | ${BRAND}`,
- description: `This page does not exist on ${BRAND}.`,
- },
+ '*': englishHead('notFound'),
} as const satisfies Record;
export type PrerenderedRoute = keyof typeof PAGE_HEADS;
+/** Each prerendered page's `pageHead.` catalog section. */
+export const PAGE_HEAD_IDS: Record = {
+ '/': 'home',
+ '/terms': 'terms',
+ '/privacy': 'privacy',
+ '/expocit/sources': 'sources',
+ ...TOOL_PAGE_HEAD_IDS,
+ '*': 'notFound',
+};
+
/**
* Head the client restores for a public page. index.html itself (the app
* shell copied to app-shell.html) says `noindex, nofollow`, matching the
diff --git a/src/lib/seo/tool-pages.ts b/src/lib/seo/tool-pages.ts
index 08c7b82c6..38bb59cf3 100644
--- a/src/lib/seo/tool-pages.ts
+++ b/src/lib/seo/tool-pages.ts
@@ -1,4 +1,5 @@
import site from '@/config/site.json';
+import { englishHead, type PageHeadId } from './head-text';
/**
* Heads and structured data for the free tools under /tools. pages.ts spreads
@@ -9,35 +10,20 @@ import site from '@/config/site.json';
const BRAND = site.brand;
const ORIGIN = site.origin;
-export const TOOL_PAGE_HEADS = {
- '/tools': {
- title: `Free Debate Tools: Timer, Motions, Flow Sheets | ${BRAND}`,
- description: 'Free debate tools that need no account: a format-aware round timer, a practice motion generator, printable flow sheets and a speech time calculator.',
- canonicalPath: '/tools',
- },
- '/tools/debate-timer': {
- title: `Free Debate Timer for PF, LD, Policy, BP & World Schools | ${BRAND}`,
- description: 'A free debate round timer with presets for Public Forum, Lincoln-Douglas, Policy, British Parliamentary, World Schools and Asian Parliamentary: protected time, POI knocks and prep banks.',
- canonicalPath: '/tools/debate-timer',
- },
- '/tools/debate-motion-generator': {
- title: `Debate Motion Generator: 300 Practice Motions | ${BRAND}`,
- description: 'Draw a random debate motion, or three for a veto drill, from 300 original practice motions sorted by topic, difficulty and motion type. THW and Resolved wording.',
- canonicalPath: '/tools/debate-motion-generator',
- },
- '/tools/debate-flow-sheet': {
- title: `Printable Debate Flow Sheet Generator | ${BRAND}`,
- description: 'Print a one-page debate flow sheet with a column for every speech in PF, LD, Policy, BP, World Schools or Asian Parliamentary, on A4 or Letter.',
- canonicalPath: '/tools/debate-flow-sheet',
- },
- '/tools/speech-time-calculator': {
- title: `Speech Time Calculator for Debate Speeches | ${BRAND}`,
- description: 'Paste a speech to see its word count, how long it takes to deliver at slow, average and fast pace, and which debate speech slots it fits. Runs in your browser.',
- canonicalPath: '/tools/speech-time-calculator',
- },
-} as const;
+/** Each tool page's `pageHead.` catalog section, where its title and description live. */
+export const TOOL_PAGE_HEAD_IDS = {
+ '/tools': 'tools',
+ '/tools/debate-timer': 'debateTimer',
+ '/tools/debate-motion-generator': 'motionGenerator',
+ '/tools/debate-flow-sheet': 'flowSheet',
+ '/tools/speech-time-calculator': 'speechTimeCalculator',
+} as const satisfies Record;
-export type ToolRoute = keyof typeof TOOL_PAGE_HEADS;
+export type ToolRoute = keyof typeof TOOL_PAGE_HEAD_IDS;
+
+export const TOOL_PAGE_HEADS = Object.fromEntries(
+ Object.entries(TOOL_PAGE_HEAD_IDS).map(([route, id]) => [route, { ...englishHead(id), canonicalPath: route }]),
+) as Record;
export type ToolPageRoute = Exclude;
/** The last time each page's content changed in substance; bump it with the change. */
diff --git a/src/lib/seo/use-page-head.ts b/src/lib/seo/use-page-head.ts
index dedebc9df..708048454 100644
--- a/src/lib/seo/use-page-head.ts
+++ b/src/lib/seo/use-page-head.ts
@@ -1,5 +1,7 @@
import { useEffect } from 'react';
-import { PAGE_HEADS, SHELL_HEAD, SITE_ORIGIN, type PrerenderedRoute } from './pages';
+import { useTranslation } from 'react-i18next';
+import { HEAD_NAMES } from './head-text';
+import { PAGE_HEAD_IDS, PAGE_HEADS, SHELL_HEAD, SITE_ORIGIN, type PageHead, type PrerenderedRoute } from './pages';
function setMeta(name: string, content: string) {
let meta = document.head.querySelector(`meta[name="${name}"]`);
@@ -25,14 +27,24 @@ function setCanonical(path: string | undefined) {
/**
* Gives a prerendered page its own title, description, canonical URL and
* robots rule while it is on screen, and puts the shell's back when the visitor
- * moves on to a page that has none. The structured data and og:url the build
- * wrote describe only the page first loaded, so they go when it does.
+ * moves on to a page that has none. The title and description follow the
+ * interface language. The structured data and og:url the build wrote describe
+ * only the page first loaded, so they go when it does.
*/
export function usePageHead(route: PrerenderedRoute) {
+ const { t } = useTranslation();
+ const id = PAGE_HEAD_IDS[route];
+ const title = t(`pageHead.${id}.title`, HEAD_NAMES);
+ const description = t(`pageHead.${id}.description`, HEAD_NAMES);
+
+ // Its own effect: a language switch retitles the page without the cleanup below.
+ useEffect(() => {
+ document.title = title;
+ setMeta('description', description);
+ }, [title, description]);
+
useEffect(() => {
- const head: { title: string; description: string; canonicalPath?: string } = PAGE_HEADS[route];
- document.title = head.title;
- setMeta('description', head.description);
+ const head: PageHead = PAGE_HEADS[route];
setMeta('robots', head.canonicalPath ? SHELL_HEAD.robots : 'noindex, nofollow');
setCanonical(head.canonicalPath);
return () => {
@@ -49,20 +61,25 @@ export function usePageHead(route: PrerenderedRoute) {
const PRIVATE_ROUTE = /^\/(login|auth|app|share)(\/|$)/;
/**
- * Titles each private page " · DebateLab" (the shell's title) and marks it noindex while it
- * is on screen. Every other route keeps the shell's head or sets its own.
+ * Titles each private page " · DebateLab" (the shell's title), gives it the landing
+ * description, both in the interface language, and marks it noindex while it is on screen.
+ * Every other route keeps the shell's head or sets its own.
*/
export function usePrivateRouteHead(pathname: string, label: string) {
+ const { t } = useTranslation();
const isPrivate = PRIVATE_ROUTE.test(pathname);
+ const description = t('pageHead.home.description', HEAD_NAMES);
useEffect(() => {
if (!isPrivate) return;
// "Sign in to DebateLab" already names the app.
document.title = label.includes(SHELL_HEAD.title) ? label : `${label} · ${SHELL_HEAD.title}`;
+ setMeta('description', description);
setMeta('robots', 'noindex, nofollow');
setCanonical(undefined);
return () => {
document.title = SHELL_HEAD.title;
+ setMeta('description', SHELL_HEAD.description);
setMeta('robots', SHELL_HEAD.robots);
};
- }, [isPrivate, label]);
+ }, [isPrivate, label, description]);
}
diff --git a/src/lib/server-errors.ts b/src/lib/server-errors.ts
new file mode 100644
index 000000000..fd5c34ced
--- /dev/null
+++ b/src/lib/server-errors.ts
@@ -0,0 +1,40 @@
+import i18n from '@/i18n';
+import { type PromoResultCode } from '../../supabase/functions/_shared/promo-result';
+
+/**
+ * Codes the Edge Functions send beside their English text, and the catalog key
+ * the browser shows for each. Quota and plan refusals have their own wording
+ * (limit-message.ts).
+ */
+const SERVER_ERROR_KEYS: Record = {
+ unauthenticated: 'serverErrors.signInRequired',
+ banned: 'serverErrors.banned',
+ upgrade_required: 'modelModal.restrictedDesc',
+ usage_check_failed: 'serverErrors.usageCheckFailed',
+ access_check_failed: 'serverErrors.accessCheckFailed',
+ no_active_subscription: 'settings.noSubscriptionDesc',
+ already_subscribed: 'serverErrors.alreadySubscribed',
+ promo_expired: 'serverErrors.promoExpired',
+ promo_exhausted: 'serverErrors.promoExhausted',
+ promo_applied: 'serverErrors.promoApplied',
+ promo_discount_noted: 'serverErrors.promoDiscountNoted',
+ promo_invalid: 'serverErrors.promoInvalid',
+ promo_already_redeemed: 'serverErrors.promoAlreadyRedeemed',
+ promo_too_many_attempts: 'serverErrors.promoTooManyAttempts',
+ promo_unsupported: 'serverErrors.promoUnsupported',
+ promo_missing: 'serverErrors.promoMissing',
+ promo_failed: 'serverErrors.promoFailed',
+} satisfies Record & Record;
+
+/** Whether the browser has its own words for this code. */
+export function isKnownServerErrorCode(code: unknown): code is string {
+ return typeof code === 'string' && Object.prototype.hasOwnProperty.call(SERVER_ERROR_KEYS, code);
+}
+
+/**
+ * The translated text for a server error code. An unknown or missing code
+ * shows the fallback, usually the server's own English text.
+ */
+export function serverErrorMessage(code: unknown, fallback: string): string {
+ return isKnownServerErrorCode(code) ? i18n.t(SERVER_ERROR_KEYS[code]) : fallback;
+}
diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts
index c87740d98..627a78339 100644
--- a/src/lib/stripe.ts
+++ b/src/lib/stripe.ts
@@ -1,6 +1,36 @@
import { supabase } from '@/lib/supabase/client';
-import { getEdgeFunctionErrorMessage } from '@/lib/supabase/edge-function-error';
+import { readEdgeFunctionError } from '@/lib/supabase/edge-function-error';
import { getWebAppRedirectUrl, openExternalUrl } from '@/lib/native';
+import i18n from '@/i18n';
+import { serverErrorMessage } from '@/lib/server-errors';
+import { promoResultCode } from '../../supabase/functions/_shared/promo-result';
+
+/** A billing call that failed, with its message in the interface language and the server's code. */
+export class BillingError extends Error {
+ readonly code?: string;
+
+ constructor(message: string, code?: string) {
+ super(message);
+ this.name = 'BillingError';
+ this.code = code;
+ }
+}
+
+/**
+ * The portal's refusal for an account Stripe has no subscription for. A
+ * create-portal deployed before its codes sends only the English text.
+ */
+export function isNoActiveSubscription(error: unknown): boolean {
+ if (error instanceof BillingError && error.code === 'no_active_subscription') return true;
+ return error instanceof Error && error.message.toLowerCase().includes('no active subscription');
+}
+
+/** A failed billing function call as a BillingError: a known code translated, else the server's text, else the fallback. */
+async function billingError(error: unknown, fallbackKey: string): Promise {
+ const { message, code, status } = await readEdgeFunctionError(error);
+ if (status === 401) return new BillingError(i18n.t('serverErrors.sessionExpired'), code);
+ return new BillingError(serverErrorMessage(code, message || i18n.t(fallbackKey)), code);
+}
/**
* Redirect user to Stripe Checkout for Pro subscription.
@@ -8,7 +38,7 @@ import { getWebAppRedirectUrl, openExternalUrl } from '@/lib/native';
export async function redirectToCheckout(promoCode?: string): Promise {
const { data: sessionData } = await supabase.auth.getSession();
const accessToken = sessionData.session?.access_token;
- if (!accessToken) throw new Error('Please sign in to upgrade');
+ if (!accessToken) throw new BillingError(i18n.t('serverErrors.signInRequired'), 'unauthenticated');
const checkoutBody = {
successUrl: `${getWebAppRedirectUrl('/app/settings')}?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
@@ -21,8 +51,8 @@ export async function redirectToCheckout(promoCode?: string): Promise {
body: checkoutBody,
});
- if (error) throw new Error(await getEdgeFunctionErrorMessage(error) || 'Failed to create checkout session');
- if (!data?.url) throw new Error('No checkout URL returned');
+ if (error) throw await billingError(error, 'pricingModal.checkoutErrorDesc');
+ if (!data?.url) throw new BillingError(i18n.t('pricingModal.checkoutErrorDesc'));
await openExternalUrl(data.url);
}
@@ -33,7 +63,7 @@ export async function redirectToCheckout(promoCode?: string): Promise {
export async function redirectToPortal(): Promise {
const { data: sessionData } = await supabase.auth.getSession();
const accessToken = sessionData.session?.access_token;
- if (!accessToken) throw new Error('Please sign in to manage subscription');
+ if (!accessToken) throw new BillingError(i18n.t('serverErrors.signInRequired'), 'unauthenticated');
const { data, error } = await supabase.functions.invoke('create-portal', {
headers: { Authorization: `Bearer ${accessToken}` },
@@ -42,25 +72,33 @@ export async function redirectToPortal(): Promise {
},
});
- if (error) throw new Error(await getEdgeFunctionErrorMessage(error) || 'Failed to create portal session');
- if (!data?.url) throw new Error('No portal URL returned');
+ if (error) throw await billingError(error, 'serverErrors.portalFailed');
+ if (!data?.url) throw new BillingError(i18n.t('serverErrors.portalFailed'));
await openExternalUrl(data.url);
}
/**
- * Redeem a promo code for the current user.
+ * Redeem a promo code for the current user. The message is in the interface
+ * language when the answer is one the browser knows, else the server's text.
*/
export async function redeemPromoCode(code: string): Promise<{ success: boolean; message: string }> {
const { data: sessionData } = await supabase.auth.getSession();
const accessToken = sessionData.session?.access_token;
- if (!accessToken) throw new Error('Please sign in to redeem a code');
+ if (!accessToken) throw new BillingError(i18n.t('serverErrors.signInRequired'), 'unauthenticated');
const { data, error } = await supabase.functions.invoke('redeem-promo', {
headers: { Authorization: `Bearer ${accessToken}` },
body: { code },
});
- if (error) return { success: false, message: await getEdgeFunctionErrorMessage(error) || 'Failed to redeem code' };
- return data as { success: boolean; message: string };
+ if (error) {
+ // redeem-promo answers a refusal with { success, message } rather than { error }.
+ const { message, body, status } = await readEdgeFunctionError(error);
+ if (status === 401) return { success: false, message: i18n.t('serverErrors.sessionExpired') };
+ const fallback = (typeof body?.message === 'string' && body.message) || message || i18n.t('pricingModal.redeemErrorDesc');
+ return { success: false, message: serverErrorMessage(promoResultCode(body), fallback) };
+ }
+ const result = data as { success: boolean; message: string; code?: string };
+ return { success: result.success, message: serverErrorMessage(promoResultCode(result), result.message) };
}
diff --git a/src/lib/url-language.ts b/src/lib/url-language.ts
new file mode 100644
index 000000000..f0ab199e5
--- /dev/null
+++ b/src/lib/url-language.ts
@@ -0,0 +1,29 @@
+import { isAppLanguage, type AppLanguage } from '@/lib/languages';
+import { useSettingsStore } from '@/lib/stores/settings-store';
+
+/** The language a `?lang=es` (or hu, en) link asks for, or null. /es and /hu redirect to one. */
+export function urlLanguage(search: string): AppLanguage | null {
+ const value = new URLSearchParams(search).get('lang')?.trim().toLowerCase();
+ return value && isAppLanguage(value) ? value : null;
+}
+
+/** The query string without `lang`: '' when nothing else is left. */
+export function searchWithoutLanguage(search: string): string {
+ const params = new URLSearchParams(search);
+ params.delete('lang');
+ const rest = params.toString();
+ return rest ? `?${rest}` : '';
+}
+
+/**
+ * Switches to the language a `?lang=` link asks for, the way the language
+ * switcher does: kept on this device and, when signed in, on the account.
+ * Returns whether the address asked for one.
+ */
+export function chooseUrlLanguage(search: string): boolean {
+ const language = urlLanguage(search);
+ if (!language) return false;
+ const settings = useSettingsStore.getState();
+ if (settings.language !== language) settings.setLanguage(language);
+ return true;
+}
diff --git a/src/locales/en.json b/src/locales/en.json
index 7073d5293..b744bed91 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -2581,5 +2581,76 @@
"daysLeft_other": "Free trial: {{count}} days left.",
"endsToday": "Your free trial ends today.",
"dismiss": "Hide the trial reminder"
+ },
+ "pageHead": {
+ "home": {
+ "title": "{{brand}} | AI Debate Prep, Cases & Rebuttals",
+ "description": "{{product}} is an AI debate prep and practice app for students and debaters: cases, rebuttals, evidence, coaching and timed rounds against an AI opponent."
+ },
+ "terms": {
+ "title": "Terms of Service | {{brand}}",
+ "description": "The terms for using {{brand}}: accounts, subscriptions and billing, acceptable use, AI-generated content, intellectual property, and liability."
+ },
+ "privacy": {
+ "title": "Privacy Policy | {{brand}}",
+ "description": "How {{brand}} collects, uses, stores, and shares your information, how AI requests are processed, and the rights you have over your data."
+ },
+ "sources": {
+ "title": "Sources | {{brand}}",
+ "description": "The sources consulted during the research and development of {{brand}}, listed alphabetically by author surname or institutional name."
+ },
+ "notFound": {
+ "title": "Page not found | {{brand}}",
+ "description": "This page does not exist on {{brand}}."
+ },
+ "tools": {
+ "title": "Free Debate Tools: Timer, Motions, Flow Sheets | {{brand}}",
+ "description": "Free debate tools that need no account: a format-aware round timer, a practice motion generator, printable flow sheets and a speech time calculator."
+ },
+ "debateTimer": {
+ "title": "Free Debate Timer for PF, LD, Policy, BP & World Schools | {{brand}}",
+ "description": "A free debate timer with presets for Public Forum, LD, Policy, BP, World Schools and Asian Parliamentary: protected time, POI knocks and prep banks."
+ },
+ "motionGenerator": {
+ "title": "Debate Motion Generator: 300 Practice Motions | {{brand}}",
+ "description": "Draw a random debate motion, or three for a veto drill, from 300 original practice motions sorted by topic, difficulty and type. THW and Resolved wording."
+ },
+ "flowSheet": {
+ "title": "Printable Debate Flow Sheet Generator | {{brand}}",
+ "description": "Print a one-page debate flow sheet with a column for every speech in PF, LD, Policy, BP, World Schools or Asian Parliamentary, on A4 or Letter."
+ },
+ "speechTimeCalculator": {
+ "title": "Speech Time Calculator for Debate Speeches | {{brand}}",
+ "description": "Paste a speech to see its word count, how long it takes to deliver at slow, average and fast pace, and which debate speech slots it fits. Runs in your browser."
+ }
+ },
+ "validation": {
+ "caseTextRequired": "Case text is required",
+ "motionRequired": "Motion is required",
+ "claimRequired": "Claim is required",
+ "messageRequired": "Message is required"
+ },
+ "serverErrors": {
+ "signInRequired": "Please sign in to continue.",
+ "sessionExpired": "Authentication failed. Your session may have expired. Please sign in again.",
+ "accessDenied": "Access denied. Your session may have expired. Please sign in again.",
+ "banned": "Your account has been suspended. Please contact support.",
+ "requestTooLarge": "Request is too large. Shorten the input and try again.",
+ "messageTooLarge": "Message is too large. Shorten it before sending.",
+ "usageCheckFailed": "Unable to verify your quota. Please try again.",
+ "accessCheckFailed": "Unable to verify your account access. Please try again.",
+ "aiService": "AI service error: {{message}}. Please try again.",
+ "portalFailed": "Could not open the billing portal. Please try again.",
+ "alreadySubscribed": "You already have a subscription. Manage it from Settings.",
+ "promoExpired": "This promo code has expired",
+ "promoExhausted": "This promo code has reached its usage limit",
+ "promoApplied": "Promo code applied!",
+ "promoDiscountNoted": "Discount code noted! Apply it at checkout for a discount.",
+ "promoInvalid": "Invalid or expired promo code",
+ "promoAlreadyRedeemed": "You've already redeemed this code",
+ "promoTooManyAttempts": "Too many promo code attempts. Try again in 15 minutes.",
+ "promoUnsupported": "Unsupported promo code type",
+ "promoMissing": "Please enter a promo code",
+ "promoFailed": "Unable to redeem promo code. Please try again."
}
}
diff --git a/src/locales/es.json b/src/locales/es.json
index 2eca8fe96..3b99dcea8 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -2581,5 +2581,76 @@
"daysLeft_other": "Prueba gratis: quedan {{count}} días.",
"endsToday": "Tu prueba gratis termina hoy.",
"dismiss": "Ocultar el aviso de la prueba"
+ },
+ "pageHead": {
+ "home": {
+ "title": "{{brand}} | Preparación de debates con IA: casos y refutaciones",
+ "description": "{{product}} es una app con IA para preparar y practicar debates: casos, refutaciones, evidencia, coaching y rondas cronometradas contra un oponente de IA."
+ },
+ "terms": {
+ "title": "Términos del servicio | {{brand}}",
+ "description": "Las condiciones de uso de {{brand}}: cuentas, suscripciones, uso aceptable, contenido generado por IA, propiedad intelectual y responsabilidad."
+ },
+ "privacy": {
+ "title": "Política de privacidad | {{brand}}",
+ "description": "Cómo {{brand}} recopila, usa, guarda y comparte tu información, cómo se procesan las solicitudes de IA y qué derechos tienes sobre tus datos."
+ },
+ "sources": {
+ "title": "Fuentes | {{brand}}",
+ "description": "Las fuentes consultadas para investigar y desarrollar {{brand}}, por orden alfabético del apellido del autor o del nombre de la institución."
+ },
+ "notFound": {
+ "title": "Página no encontrada | {{brand}}",
+ "description": "Esta página no existe en {{brand}}."
+ },
+ "tools": {
+ "title": "Herramientas de debate gratis: cronómetro, mociones, hojas de flujo | {{brand}}",
+ "description": "Herramientas de debate gratis y sin cuenta: cronómetro por formato, generador de mociones, hojas de flujo imprimibles y calculadora de tiempo de discurso."
+ },
+ "debateTimer": {
+ "title": "Cronómetro de debate gratis para PF, LD, Policy, BP y World Schools | {{brand}}",
+ "description": "Cronómetro de debate gratis con ajustes para PF, LD, Policy, BP, World Schools y Asian Parliamentary: tiempo protegido, avisos de POI y preparación."
+ },
+ "motionGenerator": {
+ "title": "Generador de mociones de debate: 300 mociones de práctica | {{brand}}",
+ "description": "Saca una moción de debate al azar, o tres para practicar el veto, de entre 300 mociones originales ordenadas por tema, dificultad y tipo."
+ },
+ "flowSheet": {
+ "title": "Generador de hojas de flujo de debate imprimibles | {{brand}}",
+ "description": "Imprime una hoja de flujo de una página con una columna por discurso en PF, LD, Policy, BP, World Schools o Asian Parliamentary, en A4 o Carta."
+ },
+ "speechTimeCalculator": {
+ "title": "Calculadora de tiempo para discursos de debate | {{brand}}",
+ "description": "Pega un discurso para ver cuántas palabras tiene, cuánto dura a ritmo lento, medio o rápido y en qué turnos de debate cabe. Funciona en tu navegador."
+ }
+ },
+ "validation": {
+ "caseTextRequired": "El texto del argumento es obligatorio",
+ "motionRequired": "La moción es obligatoria",
+ "claimRequired": "La declaración es obligatoria",
+ "messageRequired": "El mensaje es obligatorio"
+ },
+ "serverErrors": {
+ "signInRequired": "Inicia sesión para continuar.",
+ "sessionExpired": "No se pudo verificar tu sesión. Puede que haya caducado. Vuelve a iniciar sesión.",
+ "accessDenied": "Acceso denegado. Puede que tu sesión haya caducado. Vuelve a iniciar sesión.",
+ "banned": "Tu cuenta está suspendida. Ponte en contacto con soporte.",
+ "requestTooLarge": "La solicitud es demasiado grande. Acorta el texto e inténtalo de nuevo.",
+ "messageTooLarge": "El mensaje es demasiado largo. Acórtalo antes de enviarlo.",
+ "usageCheckFailed": "No se pudo comprobar tu cuota. Inténtalo de nuevo.",
+ "accessCheckFailed": "No se pudo comprobar el acceso de tu cuenta. Inténtalo de nuevo.",
+ "aiService": "Error del servicio de IA: {{message}}. Inténtalo de nuevo.",
+ "portalFailed": "No se pudo abrir el portal de facturación. Inténtalo de nuevo.",
+ "alreadySubscribed": "Ya tienes una suscripción. Gestiónala desde Ajustes.",
+ "promoExpired": "Este código promocional ha caducado",
+ "promoExhausted": "Este código promocional ha alcanzado su límite de usos",
+ "promoApplied": "¡Código promocional aplicado!",
+ "promoDiscountNoted": "¡Código de descuento guardado! Aplícalo al pagar para obtener el descuento.",
+ "promoInvalid": "Código promocional no válido o caducado",
+ "promoAlreadyRedeemed": "Ya has canjeado este código",
+ "promoTooManyAttempts": "Demasiados intentos con códigos promocionales. Vuelve a intentarlo en 15 minutos.",
+ "promoUnsupported": "Tipo de código promocional no compatible",
+ "promoMissing": "Introduce un código promocional",
+ "promoFailed": "No se pudo canjear el código promocional. Inténtalo de nuevo."
}
}
diff --git a/src/locales/hu.json b/src/locales/hu.json
index 2620dad33..72452a271 100644
--- a/src/locales/hu.json
+++ b/src/locales/hu.json
@@ -2581,5 +2581,76 @@
"daysLeft_other": "Ingyenes próba: még {{count}} nap van hátra.",
"endsToday": "Az ingyenes próbád ma lejár.",
"dismiss": "Próbaidő-emlékeztető elrejtése"
+ },
+ "pageHead": {
+ "home": {
+ "title": "{{brand}} | MI-alapú vitafelkészülés, érvek és cáfolatok",
+ "description": "A {{product}} MI-alapú app vitára készülő diákoknak: esetépítés, cáfolatok, bizonyítékok, coaching és időzített gyakorlókörök egy MI-ellenféllel szemben."
+ },
+ "terms": {
+ "title": "Felhasználási feltételek | {{brand}}",
+ "description": "A {{brand}} használatának feltételei: fiókok, előfizetések, elfogadható használat, MI által generált tartalom, szellemi tulajdon és felelősség."
+ },
+ "privacy": {
+ "title": "Adatvédelmi tájékoztató | {{brand}}",
+ "description": "Hogyan gyűjti, használja, tárolja és osztja meg a {{brand}} az adataidat, hogyan dolgozza fel az MI-kéréseket, és milyen jogaid vannak."
+ },
+ "sources": {
+ "title": "Források | {{brand}}",
+ "description": "A {{brand}} kutatása és fejlesztése során felhasznált források, a szerző vezetékneve vagy az intézmény neve szerint betűrendben."
+ },
+ "notFound": {
+ "title": "Az oldal nem található | {{brand}}",
+ "description": "Ez az oldal nem létezik a {{brand}} webhelyén."
+ },
+ "tools": {
+ "title": "Ingyenes vitaeszközök: időzítő, indítványok, jegyzetlapok | {{brand}}",
+ "description": "Ingyenes, fiók nélkül használható vitaeszközök: formátumhoz igazodó időzítő, indítványgenerátor, nyomtatható jegyzetlapok és beszédidő-kalkulátor."
+ },
+ "debateTimer": {
+ "title": "Ingyenes vitaidőzítő PF, LD, Policy, BP és World Schools formátumhoz | {{brand}}",
+ "description": "Ingyenes vitaidőzítő PF, LD, Policy, BP, World Schools és Asian Parliamentary beállításokkal: védett idő, POI-jelzés és felkészülési idő."
+ },
+ "motionGenerator": {
+ "title": "Indítványgenerátor: 300 gyakorló vitaindítvány | {{brand}}",
+ "description": "Húzz egy véletlen vitaindítványt, vagy hármat a vétógyakorláshoz, 300 eredeti gyakorló indítványból, téma, nehézség és típus szerint rendezve."
+ },
+ "flowSheet": {
+ "title": "Nyomtatható vitajegyzetlap-generátor | {{brand}}",
+ "description": "Egyoldalas, nyomtatható vitajegyzetlap minden beszédhez külön oszloppal PF, LD, Policy, BP, World Schools vagy Asian Parliamentary formátumhoz."
+ },
+ "speechTimeCalculator": {
+ "title": "Beszédidő-kalkulátor vitabeszédekhez | {{brand}}",
+ "description": "Illessz be egy beszédet, és megmutatjuk a szószámát, hogy lassú, átlagos és gyors tempóban mennyi ideig tart, és mely vitabeszéd-idősávokba fér bele."
+ }
+ },
+ "validation": {
+ "caseTextRequired": "Az érv szövegének megadása kötelező",
+ "motionRequired": "Az indítvány megadása kötelező",
+ "claimRequired": "Az állítás megadása kötelező",
+ "messageRequired": "Az üzenet megadása kötelező"
+ },
+ "serverErrors": {
+ "signInRequired": "A folytatáshoz jelentkezz be.",
+ "sessionExpired": "A hitelesítés nem sikerült. Lehet, hogy lejárt a munkameneted. Jelentkezz be újra.",
+ "accessDenied": "Hozzáférés megtagadva. Lehet, hogy lejárt a munkameneted. Jelentkezz be újra.",
+ "banned": "A fiókodat felfüggesztettük. Fordulj az ügyfélszolgálathoz.",
+ "requestTooLarge": "A kérés túl nagy. Rövidítsd le a szöveget, és próbáld újra.",
+ "messageTooLarge": "Az üzenet túl hosszú. Rövidítsd le küldés előtt.",
+ "usageCheckFailed": "Nem sikerült ellenőrizni a keretedet. Próbáld újra.",
+ "accessCheckFailed": "Nem sikerült ellenőrizni a fiókod hozzáférését. Próbáld újra.",
+ "aiService": "MI-szolgáltatási hiba: {{message}}. Próbáld újra.",
+ "portalFailed": "Nem sikerült megnyitni a számlázási portált. Próbáld újra.",
+ "alreadySubscribed": "Már van előfizetésed. A Beállításokban kezelheted.",
+ "promoExpired": "Ez a promóciós kód lejárt",
+ "promoExhausted": "Ez a promóciós kód elérte a felhasználási korlátját",
+ "promoApplied": "Promóciós kód beváltva!",
+ "promoDiscountNoted": "Kedvezménykód rögzítve! A kedvezményhez add meg fizetéskor.",
+ "promoInvalid": "Érvénytelen vagy lejárt promóciós kód",
+ "promoAlreadyRedeemed": "Ezt a kódot már beváltottad",
+ "promoTooManyAttempts": "Túl sok próbálkozás promóciós kóddal. Próbáld újra 15 perc múlva.",
+ "promoUnsupported": "Nem támogatott promóciós kódtípus",
+ "promoMissing": "Adj meg egy promóciós kódot",
+ "promoFailed": "Nem sikerült beváltani a promóciós kódot. Próbáld újra."
}
}
diff --git a/src/main.tsx b/src/main.tsx
index 233c5f727..1cadc9091 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -10,6 +10,7 @@ import { installStaleChunkReload } from "./lib/reload-stale-chunk";
import { initializeObservability } from "./lib/observability";
import { preloadStartupRoute } from "./lib/route-modules";
import { legacyCallbackPath } from "./lib/auth-callback";
+import { chooseUrlLanguage, searchWithoutLanguage } from "./lib/url-language";
const initializeTelemetry = (): void => {
const run = (): void => { void initializeObservability(); };
@@ -36,6 +37,12 @@ export function mountApp(): void {
// is replaced and AuthProvider exchanges the code there.
const callbackPath = legacyCallbackPath(window.location);
if (callbackPath) window.history.replaceState(window.history.state, "", callbackPath);
+ // A ?lang= link picks the language before the first render waits for it, and
+ // leaves the address so a later switcher choice survives a reload.
+ const { pathname, search, hash } = window.location;
+ if (chooseUrlLanguage(search)) {
+ window.history.replaceState(window.history.state, "", `${pathname}${searchWithoutLanguage(search)}${hash}`);
+ }
const container = document.getElementById("root")!;
const root = createRoot(container);
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
index 34f44011d..953bf1cc5 100644
--- a/src/pages/LoginPage.tsx
+++ b/src/pages/LoginPage.tsx
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { ArrowLeft, Loader2 } from 'lucide-react';
import { useAuth } from '@/lib/supabase/AuthProvider';
import { useToast } from '@/hooks/use-toast';
@@ -320,11 +321,12 @@ export default function LoginPage() {
return (
// Below md (768 px), phones included, the card starts near the top; from md up it sits in the middle.
-
+
{t('login.backHome', 'Back to DebateLab')}
+
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 86c659d98..4d89e303d 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -25,7 +25,7 @@ import { getActiveEnv } from '@/lib/supabase/env';
import { useToast } from '@/hooks/use-toast';
import { useSubscription } from '@/hooks/use-subscription';
import { useExternalRedirectPending } from '@/hooks/use-external-redirect-pending';
-import { redirectToPortal } from '@/lib/stripe';
+import { isNoActiveSubscription, redirectToPortal } from '@/lib/stripe';
import { deleteAccount, downloadAccountData } from '@/lib/account';
import { PricingModal } from '@/components/features/subscription/PricingModal';
import { SubscriptionStatus } from '@/components/features/subscription/SubscriptionStatus';
@@ -222,8 +222,7 @@ export default function SettingsPage() {
try {
await runPortalRedirect(redirectToPortal);
} catch (err) {
- const message = err instanceof Error ? err.message : 'Failed to open billing portal';
- if (message.toLowerCase().includes('no active subscription')) {
+ if (isNoActiveSubscription(err)) {
toast({
title: t('settings.noSubscription'),
description: t('settings.noSubscriptionDesc'),
@@ -232,7 +231,8 @@ export default function SettingsPage() {
} else {
toast({
title: t('settings.portalError'),
- description: message,
+ // redirectToPortal's errors are translated when the server's code is known.
+ description: err instanceof Error ? err.message : t('serverErrors.portalFailed'),
variant: 'destructive',
});
}
diff --git a/src/test/auth/account-switch-route.test.tsx b/src/test/auth/account-switch-route.test.tsx
index 4ba9b9174..025a305b7 100644
--- a/src/test/auth/account-switch-route.test.tsx
+++ b/src/test/auth/account-switch-route.test.tsx
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { TooltipProvider } from '@/components/ui/tooltip';
import { RequireAuth } from '@/components/auth/RequireAuth';
import LoginPage from '@/pages/LoginPage';
@@ -25,15 +26,17 @@ function Where() {
function App() {
return (
-
-
- >} />
- }>
-
-
-
-
-
+
+
+
+ >} />
+ }>
+
+
+
+
+
+
);
}
diff --git a/src/test/auth/login-form.test.tsx b/src/test/auth/login-form.test.tsx
index af4f95f0a..c680dd62e 100644
--- a/src/test/auth/login-form.test.tsx
+++ b/src/test/auth/login-form.test.tsx
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
+import { TooltipProvider } from '@/components/ui/tooltip';
import { AuthApiError } from '@supabase/supabase-js';
import LoginPage from '@/pages/LoginPage';
import { authRetryAfterSeconds } from '@/lib/auth-errors';
@@ -22,7 +23,7 @@ vi.mock('@/lib/supabase/AuthProvider', () => ({
}));
function renderLogin() {
- return render();
+ return render();
}
function submitEmail(value: string) {
diff --git a/src/test/auth/login-google-redirect.test.tsx b/src/test/auth/login-google-redirect.test.tsx
index 49a1e3f86..8970f4e9e 100644
--- a/src/test/auth/login-google-redirect.test.tsx
+++ b/src/test/auth/login-google-redirect.test.tsx
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
+import { TooltipProvider } from '@/components/ui/tooltip';
import LoginPage from '@/pages/LoginPage';
import { restoreFromBackForwardCache } from '@/test/helpers/page-lifecycle';
@@ -23,7 +24,7 @@ vi.mock('@/lib/supabase/AuthProvider', () => ({
// back/forward cache with the button stuck on "Redirecting...".
describe('Continue with Google', () => {
it('works again after Back from Google restores the page', async () => {
- render();
+ render();
fireEvent.click(screen.getByRole('button', { name: 'Continue with Google' }));
const redirecting = await screen.findByRole('button', { name: 'Redirecting...' });
diff --git a/src/test/auth/login-otp.test.tsx b/src/test/auth/login-otp.test.tsx
index 50a42340e..16d097742 100644
--- a/src/test/auth/login-otp.test.tsx
+++ b/src/test/auth/login-otp.test.tsx
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
+import { TooltipProvider } from '@/components/ui/tooltip';
import { AuthApiError } from '@supabase/supabase-js';
import LoginPage from '@/pages/LoginPage';
@@ -23,7 +24,7 @@ vi.mock('@/lib/supabase/AuthProvider', () => ({
beforeEach(() => sessionStorage.clear());
async function requestCode() {
- render();
+ render();
fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'student@example.com' } });
fireEvent.click(screen.getByRole('button', { name: 'Send code' }));
return await screen.findByLabelText('Verification code');
diff --git a/src/test/i18n/localized-errors.test.tsx b/src/test/i18n/localized-errors.test.tsx
new file mode 100644
index 000000000..91a3c6bf7
--- /dev/null
+++ b/src/test/i18n/localized-errors.test.tsx
@@ -0,0 +1,69 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { applyAppLanguage } from '@/i18n';
+import es from '@/locales/es.json';
+import hu from '@/locales/hu.json';
+import { AttackForm } from '@/components/features/attack/AttackForm';
+import { useSessionStore } from '@/lib/stores/session-store';
+import { mapAIError } from '@/lib/ai/errors';
+import { serverErrorMessage } from '@/lib/server-errors';
+import { promoResultCode } from '../../../supabase/functions/_shared/promo-result';
+
+// IN-14: form validation and IN-11: server errors read in the interface language.
+
+vi.mock('@/components/ModelSelector', () => ({ ModelSelector: (): null => null }));
+vi.mock('@/components/features/debate/DebateContextControl', () => ({ DebateContextControl: (): null => null }));
+
+const switchTo = (language: 'en' | 'es' | 'hu') => act(() => applyAppLanguage(language));
+const non2xx = (status: number) => ({ message: 'Edge Function returned a non-2xx status code', context: { status } });
+
+afterEach(() => switchTo('en'));
+
+describe('form validation', () => {
+ beforeEach(() => useSessionStore.setState({ attackForm: {} }));
+
+ it('says an empty case is required in the interface language, only after a submit', async () => {
+ await switchTo('hu');
+ const onSubmit = vi.fn();
+ render();
+ expect(screen.queryByText(hu.validation.caseTextRequired)).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: hu.attackForm.submit }));
+ expect(await screen.findByText(hu.validation.caseTextRequired)).toBeInTheDocument();
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ await switchTo('es');
+ await waitFor(() => expect(screen.getByText(es.validation.caseTextRequired)).toBeInTheDocument());
+ });
+});
+
+describe('server error codes', () => {
+ it('translate a known code and keep the server text for an unknown one', async () => {
+ await switchTo('hu');
+ expect(serverErrorMessage('banned', 'Your account has been suspended.')).toBe(hu.serverErrors.banned);
+ expect(serverErrorMessage('something_new', 'Server says no')).toBe('Server says no');
+ expect(serverErrorMessage(undefined, 'Server says no')).toBe('Server says no');
+ });
+
+ it('give AI refusals the translated reason', async () => {
+ await switchTo('es');
+ const banned = mapAIError(non2xx(403), 'r', 'openrouter', 'm', { error: 'Your account has been suspended.', code: 'banned' });
+ expect(banned.message).toBe(es.serverErrors.banned);
+ const expired = mapAIError(non2xx(401), 'r', 'openrouter', 'm', { error: 'Invalid JWT' });
+ expect(expired.message).toBe(es.serverErrors.sessionExpired);
+ // Not a provider quota failure, though the server's text mentions a quota.
+ const unverified = mapAIError(non2xx(500), 'r', 'openrouter', 'm', { error: 'Unable to verify your quota. Please try again.', code: 'usage_check_failed' });
+ expect(unverified.name).toBe('AIServerError');
+ expect(unverified.message).toBe(es.serverErrors.usageCheckFailed);
+ const unknown = mapAIError(non2xx(500), 'r', 'openrouter', 'm', { error: 'Database on fire' });
+ expect(unknown.message).toBe('Error del servicio de IA: Database on fire. Inténtalo de nuevo.');
+ });
+
+ it('name a promo answer by its code, or by the English text an older redeem-promo sends', () => {
+ expect(promoResultCode({ message: 'Promo code applied!' })).toBe('promo_applied');
+ expect(promoResultCode({ message: "You've already redeemed this code" })).toBe('promo_already_redeemed');
+ expect(promoResultCode({ code: 'too_many_attempts', message: 'anything' })).toBe('promo_too_many_attempts');
+ expect(promoResultCode({ code: 'promo_invalid' })).toBe('promo_invalid');
+ expect(promoResultCode({ message: 'Something the browser has never seen' })).toBeUndefined();
+ });
+});
diff --git a/src/test/i18n/url-language.test.tsx b/src/test/i18n/url-language.test.tsx
new file mode 100644
index 000000000..64763da5c
--- /dev/null
+++ b/src/test/i18n/url-language.test.tsx
@@ -0,0 +1,44 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, useLocation } from 'react-router-dom';
+import { afterEach, describe, expect, it } from 'vitest';
+import i18n, { appLanguageReady } from '@/i18n';
+import { UrlLanguage } from '@/App';
+import { useSettingsStore } from '@/lib/stores/settings-store';
+import { searchWithoutLanguage, urlLanguage } from '@/lib/url-language';
+
+// IN-04: ?lang= links (and the /es and /hu redirects to them) open the app in that language.
+
+function Where() {
+ const { pathname, search } = useLocation();
+ return
{pathname + search}
;
+}
+
+describe('?lang= links', () => {
+ afterEach(async () => {
+ useSettingsStore.getState().setLanguage('en');
+ await appLanguageReady();
+ });
+
+ it('read only the app languages', () => {
+ expect(urlLanguage('?lang=hu')).toBe('hu');
+ expect(urlLanguage('?x=1&lang=ES')).toBe('es');
+ expect(urlLanguage('?lang=fr')).toBeNull();
+ expect(urlLanguage('')).toBeNull();
+ expect(searchWithoutLanguage('?lang=hu&tab=saved')).toBe('?tab=saved');
+ expect(searchWithoutLanguage('?lang=hu')).toBe('');
+ });
+
+ it('switch the language the way the switcher does and leave the address', async () => {
+ render(
+
+
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByTestId('where')).toHaveTextContent('/?tab=saved'));
+ await appLanguageReady();
+ expect(useSettingsStore.getState().language).toBe('hu');
+ expect(i18n.language).toBe('hu');
+ expect(document.documentElement.lang).toBe('hu');
+ });
+});
diff --git a/src/test/pages/SharedPage.test.tsx b/src/test/pages/SharedPage.test.tsx
index 8f9306138..f0b1cfe9e 100644
--- a/src/test/pages/SharedPage.test.tsx
+++ b/src/test/pages/SharedPage.test.tsx
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { TooltipProvider } from '@/components/ui/tooltip';
import { AuthContext, type AuthContextValue } from '@/lib/supabase/auth-context';
import SharedPage from '@/pages/SharedPage';
@@ -30,11 +31,13 @@ const signedOut: AuthContextValue = {
function renderShare(path = '/share/doesnotexist123') {
return render(
-
-
- } />
-
-
+
+
+
+ } />
+
+
+ ,
);
}
diff --git a/src/test/seo/page-head.test.tsx b/src/test/seo/page-head.test.tsx
new file mode 100644
index 000000000..8c9c305d5
--- /dev/null
+++ b/src/test/seo/page-head.test.tsx
@@ -0,0 +1,69 @@
+import { act, render } from '@testing-library/react';
+import { useTranslation } from 'react-i18next';
+import { afterEach, describe, expect, it } from 'vitest';
+import { applyAppLanguage } from '@/i18n';
+import en from '@/locales/en.json';
+import es from '@/locales/es.json';
+import hu from '@/locales/hu.json';
+import { HEAD_NAMES } from '@/lib/seo/head-text';
+import { PAGE_HEADS, type PrerenderedRoute } from '@/lib/seo/pages';
+import { usePageHead, usePrivateRouteHead } from '@/lib/seo/use-page-head';
+
+// IN-01, SE-01: every page's tab title and description follow the interface language.
+
+function PublicPage({ route }: { route: PrerenderedRoute }) {
+ usePageHead(route);
+ return null;
+}
+
+function GeneratePage() {
+ const { t } = useTranslation();
+ usePrivateRouteHead('/app/generate', t('nav.generate'));
+ return null;
+}
+
+const description = () => document.head.querySelector('meta[name="description"]')?.content;
+
+async function switchTo(language: 'en' | 'es' | 'hu') {
+ await act(() => applyAppLanguage(language));
+}
+
+describe('page titles', () => {
+ afterEach(() => switchTo('en'));
+
+ it('match the prerendered English head, then follow a language switch', async () => {
+ render();
+ expect(document.title).toBe(PAGE_HEADS['/terms'].title);
+ expect(description()).toBe(PAGE_HEADS['/terms'].description);
+
+ await switchTo('hu');
+ expect(document.title).toBe('Felhasználási feltételek | DebateLab by Pi-Stack');
+ await switchTo('es');
+ expect(document.title).toBe('Términos del servicio | DebateLab by Pi-Stack');
+ });
+
+ it('translate the 404 page and keep it out of the index', async () => {
+ render();
+ await switchTo('es');
+ expect(document.title).toBe('Página no encontrada | DebateLab by Pi-Stack');
+ expect(document.head.querySelector('meta[name="robots"]')?.content).toBe('noindex, nofollow');
+ });
+
+ it('name each app page and DebateLab, in the interface language', async () => {
+ render();
+ expect(document.title).toBe('Generate · DebateLab');
+ expect(description()).toBe(PAGE_HEADS['/'].description);
+
+ await switchTo('hu');
+ expect(document.title).toBe(`${hu.nav.generate} · DebateLab`);
+ expect(description()).toBe(hu.pageHead.home.description.replace('{{product}}', HEAD_NAMES.product));
+ });
+
+ it('keep every description within 160 characters in every language', () => {
+ const fill = (text: string) => text.replace('{{brand}}', HEAD_NAMES.brand).replace('{{product}}', HEAD_NAMES.product);
+ const tooLong = [en, es, hu].flatMap((catalog) => Object.entries(catalog.pageHead)
+ .filter(([, head]) => fill(head.description).length > 160)
+ .map(([id, head]) => `${id}: ${fill(head.description).length}`));
+ expect(tooLong).toEqual([]);
+ });
+});
diff --git a/src/test/vercel-deployment-config.test.ts b/src/test/vercel-deployment-config.test.ts
index b6182c47b..1da8d319c 100644
--- a/src/test/vercel-deployment-config.test.ts
+++ b/src/test/vercel-deployment-config.test.ts
@@ -102,6 +102,11 @@ describe("Vercel frontend deployment configuration", () => {
})));
});
+ it("sends /es and /hu to the landing page in that language, not a 404 (IN-04)", () => {
+ // Temporary: the language is the visitor's choice, not a moved page.
+ expect(loadConfig().redirects).toContainEqual({ source: "/:lang(es|hu)", destination: "/?lang=:lang", statusCode: 302 });
+ });
+
it("redirects each retired guide to a live guide that no page links past", () => {
// Google already knows these URLs; dropping a rule turns one into a 404.
const retiredGuides: Record = {
@@ -119,7 +124,8 @@ describe("Vercel frontend deployment configuration", () => {
"/debatelab-alternatives": "/best-ai-debate-tools",
"/ai-tools-for-debate-students": "/best-ai-debate-tools",
};
- const guideRedirects = (loadConfig().redirects ?? []).filter((rule) => !rule.source.startsWith("/:page("));
+ const guideRedirects = (loadConfig().redirects ?? [])
+ .filter((rule) => !rule.source.startsWith("/:page(") && !rule.source.startsWith("/:lang("));
const retired = new Set(Object.keys(retiredGuides));
const pages = readdirSync(resolve(process.cwd(), "public"), { withFileTypes: true })
.filter((entry) => entry.isDirectory() && existsSync(resolve(process.cwd(), "public", entry.name, "index.html")))
diff --git a/supabase/functions/_shared/entitlements.ts b/supabase/functions/_shared/entitlements.ts
index e70ba8b22..9a811ae81 100644
--- a/supabase/functions/_shared/entitlements.ts
+++ b/supabase/functions/_shared/entitlements.ts
@@ -28,8 +28,10 @@ export interface RateLimitResult {
* trial_ended, payment_failed and subscription_ended replace the single
* trial_expired code older builds sent for every account without access.
* The client still accepts trial_expired from functions deployed before them.
+ * usage_check_failed and access_check_failed name a check that could not run
+ * (500); they avoid "quota", which browsers read as a provider failure.
*/
- code?: "limit_reached" | "weekly_budget_reached" | AccessRefusalCode | "banned" | "upgrade_required" | "unauthenticated";
+ code?: "limit_reached" | "weekly_budget_reached" | AccessRefusalCode | "banned" | "upgrade_required" | "unauthenticated" | "usage_check_failed" | "access_check_failed";
/** Quota units this request needed; more than 1 for expensive models. */
units?: number;
statusCode?: number;
@@ -254,7 +256,7 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti
if (quotaError) {
console.error("[RATE LIMIT] Quota reservation failed:", quotaError);
- return { allowed: false, reason: "Unable to verify your quota. Please try again.", statusCode: 500 };
+ return { allowed: false, reason: "Unable to verify your quota. Please try again.", code: "usage_check_failed", statusCode: 500 };
}
const quotaRow = Array.isArray(quota) ? quota[0] : quota;
@@ -267,7 +269,7 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti
return { allowed: true, limit: dailyLimit, current, units, canAccessProModels, charged, ...(session ? { session } : { reservedOn: utcDay() }), ...sessionCharge };
} catch (err) {
console.error("[RATE LIMIT] Check failed:", err);
- return { allowed: false, reason: "Unable to verify your account access. Please try again.", statusCode: 500 };
+ return { allowed: false, reason: "Unable to verify your account access. Please try again.", code: "access_check_failed", statusCode: 500 };
}
}
@@ -293,7 +295,7 @@ export async function peekRateLimit(userId: string, model?: ModelSelection): Pro
return { allowed: true, limit: dailyLimit, current, units };
} catch (err) {
console.error("[RATE LIMIT] Quota peek failed:", err);
- return { allowed: false, reason: "Unable to verify your quota. Please try again.", statusCode: 500 };
+ return { allowed: false, reason: "Unable to verify your quota. Please try again.", code: "usage_check_failed", statusCode: 500 };
}
}
diff --git a/supabase/functions/_shared/promo-result.ts b/supabase/functions/_shared/promo-result.ts
new file mode 100644
index 000000000..7b58cb36c
--- /dev/null
+++ b/supabase/functions/_shared/promo-result.ts
@@ -0,0 +1,41 @@
+// Codes for redeem-promo's answers. redeem_promo_code_atomic answers in English
+// (only too_many_attempts has a code), so redeem-promo and the browser both name
+// its message by code, and the browser shows its own translation. The browser
+// also reads the message, for a redeem-promo deployed before these codes.
+
+export const PROMO_RESULT_CODES = [
+ "promo_applied",
+ "promo_discount_noted",
+ "promo_invalid",
+ "promo_already_redeemed",
+ "promo_too_many_attempts",
+ "promo_unsupported",
+ "promo_missing",
+ "promo_failed",
+] as const;
+
+export type PromoResultCode = typeof PROMO_RESULT_CODES[number];
+
+/** The English messages the database function and redeem-promo answer with. */
+export const PROMO_RESULT_MESSAGES: Record = {
+ promo_applied: "Promo code applied!",
+ promo_discount_noted: "Discount code noted! Apply it at checkout for a discount.",
+ promo_invalid: "Invalid or expired promo code",
+ promo_already_redeemed: "You've already redeemed this code",
+ promo_too_many_attempts: "Too many promo code attempts. Try again in 15 minutes.",
+ promo_unsupported: "Unsupported promo code type",
+ promo_missing: "Please enter a promo code",
+ promo_failed: "Unable to redeem promo code. Please try again.",
+};
+
+const isPromoResultCode = (value: unknown): value is PromoResultCode =>
+ typeof value === "string" && (PROMO_RESULT_CODES as readonly string[]).includes(value);
+
+/** The code for a redemption answer: its own code when it has one, else the code its message stands for. */
+export function promoResultCode(result: { code?: unknown; message?: unknown } | null | undefined): PromoResultCode | undefined {
+ if (!result) return undefined;
+ if (result.code === "too_many_attempts") return "promo_too_many_attempts";
+ if (isPromoResultCode(result.code)) return result.code;
+ const message = typeof result.message === "string" ? result.message.trim() : "";
+ return PROMO_RESULT_CODES.find((code) => PROMO_RESULT_MESSAGES[code] === message);
+}
diff --git a/supabase/functions/create-portal/index.ts b/supabase/functions/create-portal/index.ts
index 7f2df950b..53f0089a2 100644
--- a/supabase/functions/create-portal/index.ts
+++ b/supabase/functions/create-portal/index.ts
@@ -50,7 +50,13 @@ async function findCustomerIdByUser(user: { id: string; email?: string | null })
return findCustomerOwnedByUser(candidates.data, user.id);
}
-function classifyStripeError(err: unknown): { status: number; message: string } {
+// The browser translates this code; the English message stays for older browsers.
+const NO_ACTIVE_SUBSCRIPTION = {
+ error: "No active subscription found. Upgrade to Pro to start a subscription.",
+ code: "no_active_subscription",
+};
+
+function classifyStripeError(err: unknown): { status: number; message: string; code?: string } {
const stripeErr = err as {
type?: string;
code?: string;
@@ -63,10 +69,7 @@ function classifyStripeError(err: unknown): { status: number; message: string }
: "Failed to create billing portal session";
if (stripeErr.code === "resource_missing") {
- return {
- status: 400,
- message: "No active subscription found. Upgrade to Pro to start a subscription.",
- };
+ return { status: 400, message: NO_ACTIVE_SUBSCRIPTION.error, code: NO_ACTIVE_SUBSCRIPTION.code };
}
if (typeof stripeErr.statusCode === "number") {
@@ -133,7 +136,7 @@ export async function handleRequest(req: Request): Promise {
const customerId = await findCustomerIdByUser({ id: user.id, email: user.email });
if (!customerId) {
- return new Response(JSON.stringify({ error: "No active subscription found. Upgrade to Pro to start a subscription." }), {
+ return new Response(JSON.stringify(NO_ACTIVE_SUBSCRIPTION), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
@@ -151,9 +154,9 @@ export async function handleRequest(req: Request): Promise {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (err) {
- const { status, message } = classifyStripeError(err);
+ const { status, message, code } = classifyStripeError(err);
console.error("[PORTAL] Error:", message);
- return new Response(JSON.stringify({ error: message }), {
+ return new Response(JSON.stringify({ error: message, ...(code ? { code } : {}) }), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
diff --git a/supabase/functions/redeem-promo/auth_test.ts b/supabase/functions/redeem-promo/auth_test.ts
index f5f3deea7..3ed6ddcbc 100644
--- a/supabase/functions/redeem-promo/auth_test.ts
+++ b/supabase/functions/redeem-promo/auth_test.ts
@@ -38,7 +38,7 @@ Deno.test("redeem-promo redeems through a client scoped to the caller's token",
label: "valid token",
authorization: `Bearer ${VALID_TOKEN}`,
status: 200,
- body: { success: true, message: "Promo code applied!" },
+ body: { success: true, code: "promo_applied", message: "Promo code applied!" },
authCalls: 1,
}], (url, headers, init) => {
if (url.pathname !== "/rest/v1/rpc/redeem_promo_code_atomic") throw new Error(`Unexpected request: ${url}`);
@@ -57,6 +57,6 @@ Deno.test("redeem-promo rejects malformed JSON, non-string and oversized codes b
for (const body of ["{", "null", "[]", '{}', '{"code":{}}', '{"code":123}', JSON.stringify({ code: "x".repeat(65) }), '{"code":" "}']) {
await runBearerCases(handleRequest, (authorization) => new Request("http://127.0.0.1/functions/v1/redeem-promo", {
method: "POST", headers: { Authorization: authorization! }, body,
- }), [{ label: `invalid promo input length ${body.length}`, authorization: `Bearer ${VALID_TOKEN}`, status: 400, body: { success: false, message: "Please enter a promo code" }, authCalls: 1 }]);
+ }), [{ label: `invalid promo input length ${body.length}`, authorization: `Bearer ${VALID_TOKEN}`, status: 400, body: { success: false, code: "promo_missing", message: "Please enter a promo code" }, authCalls: 1 }]);
}
});
diff --git a/supabase/functions/redeem-promo/index.ts b/supabase/functions/redeem-promo/index.ts
index f9f381bbb..8ea92c545 100644
--- a/supabase/functions/redeem-promo/index.ts
+++ b/supabase/functions/redeem-promo/index.ts
@@ -3,6 +3,7 @@
import { type AuthContext, HttpError, requireUser } from "../_shared/auth.ts";
import { getCorsHeaders } from "../_shared/cors.ts";
+import { PROMO_RESULT_MESSAGES, promoResultCode } from "../_shared/promo-result.ts";
export async function handleRequest(req: Request): Promise {
const origin = req.headers.get("origin");
@@ -37,7 +38,7 @@ export async function handleRequest(req: Request): Promise {
: "";
if (!code || code.length > 64) {
- return new Response(JSON.stringify({ success: false, message: "Please enter a promo code" }), {
+ return new Response(JSON.stringify({ success: false, code: "promo_missing", message: PROMO_RESULT_MESSAGES.promo_missing }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
@@ -51,9 +52,12 @@ export async function handleRequest(req: Request): Promise {
console.log(`[PROMO] Redemption ${result?.success ? "succeeded" : "failed"}`);
+ const message = result?.message || PROMO_RESULT_MESSAGES.promo_applied;
return new Response(JSON.stringify({
success: Boolean(result?.success),
- message: result?.message || "Promo code applied!",
+ // The browser translates the code; the English message stays for older browsers.
+ code: promoResultCode({ code: result?.code, message }),
+ message,
}), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
@@ -61,7 +65,7 @@ export async function handleRequest(req: Request): Promise {
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error("[PROMO] Error:", message.slice(0, 2000));
- return new Response(JSON.stringify({ success: false, message: "Unable to redeem promo code. Please try again." }), {
+ return new Response(JSON.stringify({ success: false, code: "promo_failed", message: PROMO_RESULT_MESSAGES.promo_failed }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
diff --git a/vercel.json b/vercel.json
index 28c70e97f..281182f0b 100644
--- a/vercel.json
+++ b/vercel.json
@@ -26,6 +26,11 @@
"destination": "/",
"statusCode": 301
},
+ {
+ "source": "/:lang(es|hu)",
+ "destination": "/?lang=:lang",
+ "statusCode": 302
+ },
{
"source": "/online-debate-timer-tool",
"destination": "/online-debate-timer",
From a56d8dfcafbd3fc59d38b6018dc54236a1a8a440 Mon Sep 17 00:00:00 2001
From: Adam
Date: Fri, 23 Oct 2026 09:37:41 +0000
Subject: [PATCH 2/7] Import mapAIError through the AI client boundary in the
T12 test
Co-Authored-By: Claude Opus 5.5
---
src/test/i18n/localized-errors.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/test/i18n/localized-errors.test.tsx b/src/test/i18n/localized-errors.test.tsx
index 91a3c6bf7..f59d2854f 100644
--- a/src/test/i18n/localized-errors.test.tsx
+++ b/src/test/i18n/localized-errors.test.tsx
@@ -5,7 +5,7 @@ import es from '@/locales/es.json';
import hu from '@/locales/hu.json';
import { AttackForm } from '@/components/features/attack/AttackForm';
import { useSessionStore } from '@/lib/stores/session-store';
-import { mapAIError } from '@/lib/ai/errors';
+import { mapAIError } from '@/lib/ai/client';
import { serverErrorMessage } from '@/lib/server-errors';
import { promoResultCode } from '../../../supabase/functions/_shared/promo-result';
From ba542157659e85c9dd87f7ef1a9bf1c05944cb0e Mon Sep 17 00:00:00 2001
From: Adam
Date: Fri, 23 Oct 2026 09:40:07 +0000
Subject: [PATCH 3/7] Add the localization verify recipe; type the page-head
test components
Co-Authored-By: Claude Opus 5.5
---
.../verify-debatelab/features/README.md | 3 +-
.../verify-debatelab/features/localization.md | 33 ++++
.../recipes/localization.spec.ts | 151 ++++++++++++++++++
src/test/seo/page-head.test.tsx | 4 +-
4 files changed, 188 insertions(+), 3 deletions(-)
create mode 100644 .claude/skills/verify-debatelab/features/localization.md
create mode 100644 .claude/skills/verify-debatelab/recipes/localization.spec.ts
diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md
index 2e7bb199b..03db23784 100644
--- a/.claude/skills/verify-debatelab/features/README.md
+++ b/.claude/skills/verify-debatelab/features/README.md
@@ -53,6 +53,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
- Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`).
- [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`).
- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`).
+- [Localization](./localization.md): tab titles per route and language, translated form validation and server refusals, the language switcher on sign-in and 404, and `?lang=` with the `/es` and `/hu` home pages (`recipes/localization.spec.ts`).
- Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`).
-Not yet mapped in recipes: theme and UI language, the public landing and SEO pages, the free no-login tools under `/tools` and `/embed/debate-timer` (no backend; `e2e/free-tools.spec.ts` covers them), and share-link creation (the app has no client caller for the `share` function). The agentic suite covers UI language switching (`agentic/workspace.e2e.ts`) and renders the public pages in every language (`agentic/pages.e2e.ts`).
+Not yet mapped in recipes: theme, the public landing and SEO pages beyond their titles, the free no-login tools under `/tools` and `/embed/debate-timer` (no backend; `e2e/free-tools.spec.ts` covers them), and share-link creation (the app has no client caller for the `share` function). The agentic suite covers UI language switching (`agentic/workspace.e2e.ts`) and renders the public pages in every language (`agentic/pages.e2e.ts`).
diff --git a/.claude/skills/verify-debatelab/features/localization.md b/.claude/skills/verify-debatelab/features/localization.md
new file mode 100644
index 000000000..5da559215
--- /dev/null
+++ b/.claude/skills/verify-debatelab/features/localization.md
@@ -0,0 +1,33 @@
+# Localization
+
+The interface runs in English, Spanish and Hungarian. Every page names itself in the tab title in the interface language ("Generálás · DebateLab" in the app, "Felhasználási feltételek | DebateLab by Pi-Stack" on public pages), form validation and server refusals read in that language, the language switcher sits in every header, and `?lang=es|hu|en` links and the `/es` and `/hu` home pages open the app in that language.
+
+## Sub-features
+
+- loc-titles: `document.title` on app and public routes, per language, and a switch retitling the page without a reload. App routes read ` · DebateLab` (`usePrivateRouteHead`); public pages use the `pageHead` catalog section (`usePageHead`), whose English text is also what the prerender writes.
+- loc-validation: an empty Attack submit shows `validation.caseTextRequired` in the interface language, and nothing shows before the first submit. Generate, Brainstorm and Factoids use the same keys.
+- loc-switcher: the switcher on `/login`, the 404 page, and the other public pages (`PublicPageShell`), besides the landing page and the app header.
+- loc-links: `/?lang=hu` switches and saves the language, sets ``, and drops the parameter; `/es` and `/hu` land on `/` in that language (a client route here; `vercel.json` redirects them on the host).
+- loc-server-error: a refused promo code shows `serverErrors.promoInvalid` in the interface language while redeem-promo still answers in English beside its `code`.
+
+## How to get to it (user POV)
+
+- The globe button in any header, then a language under "Interface language".
+- A link with `?lang=hu`, or the address `/hu` or `/es`.
+- Settings, `Upgrade to Pro`, `Have a promo code?`, then an unknown code and `Apply`, for the translated refusal.
+
+## Driving it with verify.sh + Playwright
+
+```bash
+$H drive recipes/localization --project desktop-chromium
+$H drive recipes/localization --project mobile-chromium # 375 px
+```
+
+`01-document-titles.json` in the loc-titles evidence lists the title of every route in each language.
+
+## Gotchas
+
+- The switcher's own name is translated (`langSwitcher.label`); match it in all three languages.
+- The recipe reads the catalogs from `src/locales/`, so its expectations follow the strings. Match on keys, never on copied text.
+- The promo field shows only for an account that would start a Checkout, so the recipe marks its Pro demo user expired first.
+- `/es` and `/hu` on the host are a `vercel.json` redirect; the local stack proves only the client route.
diff --git a/.claude/skills/verify-debatelab/recipes/localization.spec.ts b/.claude/skills/verify-debatelab/recipes/localization.spec.ts
new file mode 100644
index 000000000..f4864d265
--- /dev/null
+++ b/.claude/skills/verify-debatelab/recipes/localization.spec.ts
@@ -0,0 +1,151 @@
+import { readFileSync } from "node:fs";
+import { randomUUID } from "node:crypto";
+import type { Page } from "@playwright/test";
+import { admin, expect, test } from "./fixtures";
+
+// Feature map: features/localization.md (T12: IN-01, SE-01, IN-03, IN-04, IN-11, IN-14).
+
+const catalog = (language: string) => JSON.parse(readFileSync(`src/locales/${language}.json`, "utf8"));
+const en = catalog("en");
+const es = catalog("es");
+const hu = catalog("hu");
+const BRAND = "DebateLab by Pi-Stack";
+const LANGUAGE_NAMES = { en: "English", es: "Español", hu: "Magyar" } as const;
+/** The switcher's own name in whichever language is showing. */
+const SWITCHER_NAME = new RegExp(`^(${[en, es, hu].map((strings) => strings.langSwitcher.label).join("|")})$`);
+
+const APP_ROUTES = ["/app", "/app/generate", "/app/attack", "/app/history", "/app/settings"];
+const PUBLIC_ROUTES = ["/terms", "/privacy", "/tools", "/no-such-page"];
+
+/** Phones at 375 px, the width the QA report used; desktop keeps the project's 1280. */
+test.beforeEach(async ({ page }, testInfo) => {
+ if (testInfo.project.name === "mobile-chromium") await page.setViewportSize({ width: 375, height: page.viewportSize()!.height });
+});
+
+async function chooseLanguage(page: Page, language: keyof typeof LANGUAGE_NAMES) {
+ await page.getByRole("button", { name: SWITCHER_NAME }).first().click();
+ await page.getByRole("radiogroup").first().getByRole("radio", { name: LANGUAGE_NAMES[language] }).click();
+ await expect(page.locator("html")).toHaveAttribute("lang", language);
+}
+
+/** The tab title once the page has set its own (the shell's is just "DebateLab"). */
+async function pageTitle(page: Page, route: string) {
+ await page.goto(route);
+ await expect.poll(() => page.title()).not.toBe("DebateLab");
+ return page.title();
+}
+
+test("loc-titles: every route's tab title is its own and follows the interface language", async ({ signedIn: page, evidence }) => {
+ const titles: Record> = {};
+ for (const language of ["en", "hu", "es"] as const) {
+ await page.goto(`/app?lang=${language}`);
+ await expect(page.locator("html")).toHaveAttribute("lang", language);
+ titles[language] = {};
+ for (const route of [...APP_ROUTES, ...PUBLIC_ROUTES]) titles[language][route] = await pageTitle(page, route);
+ await evidence.step(`Recorded document.title on ${APP_ROUTES.length + PUBLIC_ROUTES.length} routes in ${language}`);
+ }
+ await evidence.json("01-document-titles", titles);
+
+ for (const route of APP_ROUTES) {
+ for (const language of ["en", "hu", "es"]) expect(titles[language][route]).toMatch(/ · DebateLab$/);
+ }
+ for (const route of PUBLIC_ROUTES) {
+ for (const language of ["en", "hu", "es"]) expect(titles[language][route]).toMatch(new RegExp(`\\| ${BRAND}$`));
+ }
+ expect(titles.en["/app/generate"]).toBe(`${en.nav.generate} · DebateLab`);
+ expect(titles.hu["/app/generate"]).toBe(`${hu.nav.generate} · DebateLab`);
+ expect(titles.hu["/no-such-page"]).toBe(hu.pageHead.notFound.title.replace("{{brand}}", BRAND));
+ // Each language names every page in its own words (the brand aside).
+ for (const route of [...APP_ROUTES, ...PUBLIC_ROUTES]) {
+ expect(titles.hu[route], route).not.toBe(titles.en[route]);
+ }
+
+ // A switch retitles the page on screen, with no reload.
+ await page.goto("/app/attack");
+ await chooseLanguage(page, "en");
+ await expect(page).toHaveTitle(`${en.nav.attack} · DebateLab`);
+ await chooseLanguage(page, "hu");
+ await expect(page).toHaveTitle(`${hu.nav.attack} · DebateLab`);
+ await evidence.shot("02-attack-title-hu");
+});
+
+test("loc-validation: an empty Attack shows its required-field error in the interface language, only after submit", async ({ signedIn: page, evidence }) => {
+ await page.goto("/app/attack?lang=hu");
+ const caseText = page.getByRole("textbox", { name: new RegExp(hu.attackForm.argument) });
+ await expect(caseText).toBeVisible();
+ await expect(page.getByText(hu.validation.caseTextRequired)).toHaveCount(0);
+ await evidence.shot("01-attack-hu-before-submit");
+ await evidence.aria("01-attack-hu-before-submit", page.getByRole("main"));
+
+ await page.getByRole("button", { name: hu.attackForm.submit }).click();
+ await expect(page.getByText(hu.validation.caseTextRequired)).toBeVisible();
+ await expect(page.getByText(en.validation.caseTextRequired)).toHaveCount(0);
+ await evidence.step("Submitted an empty Attack in Hungarian");
+ await evidence.shot("02-attack-hu-required");
+
+ await chooseLanguage(page, "es");
+ await expect(page.getByText(es.validation.caseTextRequired)).toBeVisible();
+ await evidence.shot("03-attack-es-required");
+});
+
+test("loc-switcher: sign-in and not-found pages carry the language switcher", async ({ page, evidence }) => {
+ await page.goto("/login");
+ await expect(page.getByRole("heading", { level: 1, name: en.login.title })).toBeVisible();
+ await evidence.shot("01-login-en");
+ await chooseLanguage(page, "hu");
+ await expect(page.getByRole("heading", { level: 1, name: hu.login.title })).toBeVisible();
+ await expect(page).toHaveTitle(new RegExp(`DebateLab$`));
+ await evidence.shot("02-login-hu");
+
+ await page.goto("/no-such-page");
+ await expect(page.getByRole("heading", { level: 1, name: hu.notFound.title })).toBeVisible();
+ await expect(page).toHaveTitle(hu.pageHead.notFound.title.replace("{{brand}}", BRAND));
+ await evidence.shot("03-not-found-hu");
+ await chooseLanguage(page, "es");
+ await expect(page).toHaveTitle(es.pageHead.notFound.title.replace("{{brand}}", BRAND));
+ await expect(page.getByText(es.notFound.message)).toBeVisible();
+ await evidence.shot("04-not-found-es");
+});
+
+test("loc-links: ?lang= and the /es and /hu home pages open the landing page in that language", async ({ page, evidence }) => {
+ await page.goto("/?lang=hu");
+ await expect(page.locator("html")).toHaveAttribute("lang", "hu");
+ await expect(page).toHaveTitle(hu.pageHead.home.title.replace("{{brand}}", BRAND));
+ // The choice is kept and the address drops the parameter.
+ await expect.poll(() => new URL(page.url()).search).toBe("");
+ await evidence.shot("01-landing-lang-hu");
+
+ await page.goto("/es");
+ await expect.poll(() => new URL(page.url()).pathname).toBe("/");
+ await expect(page.locator("html")).toHaveAttribute("lang", "es");
+ await expect(page).toHaveTitle(es.pageHead.home.title.replace("{{brand}}", BRAND));
+ await evidence.shot("02-landing-es");
+
+ await page.goto("/hu");
+ await expect.poll(() => new URL(page.url()).pathname).toBe("/");
+ await expect(page.locator("html")).toHaveAttribute("lang", "hu");
+ await page.reload();
+ await expect(page.locator("html")).toHaveAttribute("lang", "hu");
+ await evidence.shot("03-landing-hu-after-reload");
+});
+
+test("loc-server-error: a refused promo code reads in the interface language", async ({ signedIn: page, user, evidence }) => {
+ const userId = await user.userId();
+ // The promo field shows for an account that would start a Checkout.
+ await admin.from("user_settings")
+ .update({ subscription_tier: "expired", trial_ends_at: null, grace_period_ends_at: null, daily_limit_override: null })
+ .eq("user_id", userId);
+ await page.goto("/app/settings?lang=hu");
+ await page.getByRole("button", { name: hu.billing.upgradeToPro }).first().click();
+ await page.getByRole("button", { name: hu.pricingModal.promoCode }).click();
+ await page.getByPlaceholder(hu.pricingModal.promoPlaceholder).fill(`NOPE${randomUUID().slice(0, 6).toUpperCase()}`);
+ const answer = page.waitForResponse((response) => response.url().includes("/functions/v1/redeem-promo"));
+ await page.getByRole("button", { name: hu.pricingModal.apply, exact: true }).click();
+ const body = await (await answer).json();
+ await evidence.json("01-redeem-promo-answer", body);
+ // The server still sends English beside the code; the screen shows the translation.
+ expect(body).toMatchObject({ success: false, code: "promo_invalid", message: en.serverErrors.promoInvalid });
+ await expect(page.getByText(hu.serverErrors.promoInvalid).first()).toBeVisible();
+ await expect(page.getByText(en.serverErrors.promoInvalid)).toHaveCount(0);
+ await evidence.shot("02-promo-refused-hu");
+});
diff --git a/src/test/seo/page-head.test.tsx b/src/test/seo/page-head.test.tsx
index 8c9c305d5..cde31bb3f 100644
--- a/src/test/seo/page-head.test.tsx
+++ b/src/test/seo/page-head.test.tsx
@@ -11,12 +11,12 @@ import { usePageHead, usePrivateRouteHead } from '@/lib/seo/use-page-head';
// IN-01, SE-01: every page's tab title and description follow the interface language.
-function PublicPage({ route }: { route: PrerenderedRoute }) {
+function PublicPage({ route }: { route: PrerenderedRoute }): null {
usePageHead(route);
return null;
}
-function GeneratePage() {
+function GeneratePage(): null {
const { t } = useTranslation();
usePrivateRouteHead('/app/generate', t('nav.generate'));
return null;
From b362f04f5e10f1de45f74792aae91a52ad443531 Mon Sep 17 00:00:00 2001
From: Adam
Date: Fri, 23 Oct 2026 09:56:03 +0000
Subject: [PATCH 4/7] Save a ?lang= link's language on its own and keep a
missing share link's title (review round 1)
A link opened before the account's settings load no longer saves this device's older snapshot or flips back after hydration. A page can name its tab over its route's label, so a language switch keeps the not-found title.
Co-Authored-By: Claude Opus 5.5
---
src/App.tsx | 20 +++++++-----
src/lib/seo/use-page-head.ts | 20 +++++++++++-
src/lib/stores/settings-store.ts | 33 +++++++++++++++++++-
src/lib/url-language.ts | 9 +++---
src/pages/SharedPage.tsx | 8 ++---
src/test/pages/SharedPage.test.tsx | 20 +++++++++---
src/test/settings/settings-migration.test.ts | 14 +++++++++
7 files changed, 101 insertions(+), 23 deletions(-)
diff --git a/src/App.tsx b/src/App.tsx
index b8e85f133..e4de6d57a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -17,7 +17,7 @@ import { StagingBanner } from "@/components/StagingBanner";
import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability";
import { appLayoutLoader, preloadStartupRoute, routeLoaders, type RouteLoader } from "@/lib/route-modules";
import type { PrerenderedRoute } from "@/lib/seo/pages";
-import { usePrivateRouteHead } from "@/lib/seo/use-page-head";
+import { PageLabelProvider, usePrivateRouteHead } from "@/lib/seo/use-page-head";
import { RouteSkeleton, type RouteSkeletonVariant } from "@/components/loading/RouteSkeleton";
import { useTranslation } from "react-i18next";
import { COLLABORATION_URL } from "@/lib/workspace/collaboration-config";
@@ -253,17 +253,21 @@ function PendingAppRoute() {
);
}
-function RouteContent({ children }: { children: ReactNode }) {
+export function RouteContent({ children }: { children: ReactNode }) {
const location = useLocation();
const { label } = useRouteLabel(location.pathname);
- usePrivateRouteHead(location.pathname, label);
+ // A page that names itself better than its route (usePageLabel).
+ const [pageLabel, setPageLabel] = useState(null);
+ usePrivateRouteHead(location.pathname, pageLabel ?? label);
return (
-
- }>
- {children}
-
-
+
+
+ }>
+ {children}
+
+
+
);
}
diff --git a/src/lib/seo/use-page-head.ts b/src/lib/seo/use-page-head.ts
index 708048454..c068a4e1b 100644
--- a/src/lib/seo/use-page-head.ts
+++ b/src/lib/seo/use-page-head.ts
@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import { createContext, useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { HEAD_NAMES } from './head-text';
import { PAGE_HEAD_IDS, PAGE_HEADS, SHELL_HEAD, SITE_ORIGIN, type PageHead, type PrerenderedRoute } from './pages';
@@ -57,6 +57,24 @@ export function usePageHead(route: PrerenderedRoute) {
}, [route]);
}
+const PageLabelContext = createContext<(label: string | null) => void>(() => undefined);
+
+/** Lets a page under a route name the tab better than the route does (see usePageLabel). */
+export const PageLabelProvider = PageLabelContext.Provider;
+
+/**
+ * Names the tab "