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..54c604712
--- /dev/null
+++ b/.claude/skills/verify-debatelab/recipes/localization.spec.ts
@@ -0,0 +1,152 @@
+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();
+ // "Bejelentkezés a DebateLab-ba" already names the app, so it gets no suffix.
+ await expect(page).toHaveTitle(hu.login.title);
+ 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/e2e/localization.spec.ts b/e2e/localization.spec.ts
new file mode 100644
index 000000000..d6ea3c3f0
--- /dev/null
+++ b/e2e/localization.spec.ts
@@ -0,0 +1,161 @@
+import { readFileSync, writeFileSync } from 'node:fs';
+import { expect, test, type Page } from '@playwright/test';
+import { stubSupabaseSession } from './helpers/auth';
+
+// T12 (IN-01, SE-01, IN-03, IN-04, IN-11, IN-14) in the browser without a
+// backend: every request off this origin is stubbed, so this proves the client
+// only. The verify-debatelab recipe recipes/localization.spec.ts repeats it
+// against real Auth, Postgres and Edge Functions. Screenshots go to the test's
+// output directory.
+
+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 LANGUAGES = { en: 'English', es: 'Español', hu: 'Magyar' } as const;
+const SWITCHER = new RegExp(`^(${[en, es, hu].map((strings) => strings.langSwitcher.label).join('|')})$`);
+const brand = (text: string) => text.replace('{{brand}}', BRAND);
+
+const APP_ROUTES = ['/app', '/app/generate', '/app/attack', '/app/history', '/app/settings'];
+const PUBLIC_ROUTES = ['/terms', '/privacy', '/tools', '/no-such-page'];
+
+test.beforeEach(async ({ page }, testInfo) => {
+ // Phones at 375 px, the width the QA report used; desktop keeps 1280.
+ if (testInfo.project.name === 'mobile-chromium') await page.setViewportSize({ width: 375, height: page.viewportSize()!.height });
+ // Nothing leaves this origin: empty tables, and 404 for anything else.
+ await page.route((url) => url.hostname !== '127.0.0.1' && url.hostname !== 'localhost', (route) => {
+ const request = route.request();
+ if (new URL(request.url()).pathname.includes('/rest/v1/')) {
+ // PostgREST answers a single-row read of an empty table with 406.
+ if ((request.headers().accept ?? '').includes('vnd.pgrst.object')) {
+ return route.fulfill({ status: 406, json: { code: 'PGRST116', message: 'JSON object requested, multiple (or no) rows returned' } });
+ }
+ return route.fulfill({ headers: { 'content-range': '*/0' }, json: [] });
+ }
+ return route.fulfill({ status: 404, body: '' });
+ });
+});
+
+async function shot(page: Page, name: string) {
+ await page.screenshot({ path: test.info().outputPath(`${name}.png`), fullPage: true });
+}
+
+async function choose(page: Page, language: keyof typeof LANGUAGES) {
+ await page.getByRole('button', { name: SWITCHER }).first().click();
+ await page.getByRole('radiogroup').first().getByRole('radio', { name: LANGUAGES[language] }).click();
+ await expect(page.locator('html')).toHaveAttribute('lang', language);
+ await page.keyboard.press('Escape');
+}
+
+test('every route has its own tab title in each language, and a switch retitles it', async ({ page }) => {
+ // 27 full page loads; the dev server compiles each route's chunk on first use.
+ test.setTimeout(240_000);
+ await stubSupabaseSession(page);
+ const titles: Record> = {};
+ for (const language of ['en', 'es', 'hu'] 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]) {
+ await page.goto(route);
+ await expect.poll(() => page.title(), { timeout: 30_000 }).not.toBe('DebateLab');
+ titles[language][route] = await page.title();
+ }
+ }
+ writeFileSync(test.info().outputPath('document-titles.json'), JSON.stringify(titles, null, 2));
+
+ for (const language of ['en', 'es', 'hu'] as const) {
+ for (const route of APP_ROUTES) expect(titles[language][route]).toMatch(/ · DebateLab$/);
+ for (const route of PUBLIC_ROUTES) expect(titles[language][route]).toMatch(/ \| DebateLab by Pi-Stack$/);
+ }
+ expect(titles.hu['/app/attack']).toBe(`${hu.nav.attack} · DebateLab`);
+ expect(titles.es['/no-such-page']).toBe(brand(es.pageHead.notFound.title));
+ for (const route of [...APP_ROUTES, ...PUBLIC_ROUTES]) expect(titles.hu[route], route).not.toBe(titles.en[route]);
+
+ // No reload: the switcher retitles the page on screen.
+ await page.goto('/app/generate');
+ await choose(page, 'en');
+ await expect(page).toHaveTitle(`${en.nav.generate} · DebateLab`);
+ await choose(page, 'hu');
+ await expect(page).toHaveTitle(`${hu.nav.generate} · DebateLab`);
+});
+
+test('an empty Attack says the case is required in the interface language, only after submit', async ({ page }) => {
+ await stubSupabaseSession(page);
+ await page.goto('/app/attack?lang=hu');
+ await expect(page.getByRole('button', { name: hu.attackForm.submit })).toBeVisible();
+ await expect(page.getByText(hu.validation.caseTextRequired)).toHaveCount(0);
+ await shot(page, '01-attack-hu-before-submit');
+
+ 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 shot(page, '02-attack-hu-required');
+
+ await choose(page, 'es');
+ await expect(page.getByText(es.validation.caseTextRequired)).toBeVisible();
+ await shot(page, '03-attack-es-required');
+});
+
+test('sign-in and not-found pages carry the language switcher', async ({ page }) => {
+ await page.goto('/login');
+ await expect(page.getByRole('heading', { level: 1, name: en.login.title })).toBeVisible();
+ await shot(page, '01-login-en');
+ await choose(page, 'hu');
+ await expect(page.getByRole('heading', { level: 1, name: hu.login.title })).toBeVisible();
+ // "Bejelentkezés a DebateLab-ba" already names the app, so it gets no suffix.
+ await expect(page).toHaveTitle(hu.login.title);
+ await shot(page, '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(brand(hu.pageHead.notFound.title));
+ await shot(page, '03-not-found-hu');
+ await choose(page, 'es');
+ await expect(page).toHaveTitle(brand(es.pageHead.notFound.title));
+ await expect(page.getByText(es.notFound.message)).toBeVisible();
+ await shot(page, '04-not-found-es');
+});
+
+test('?lang= and the /hu and /es home pages open the landing page in that language', async ({ page }) => {
+ await page.goto('/?lang=hu');
+ await expect(page.locator('html')).toHaveAttribute('lang', 'hu');
+ await expect(page).toHaveTitle(brand(hu.pageHead.home.title));
+ await expect.poll(() => new URL(page.url()).search).toBe('');
+ await shot(page, '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(brand(es.pageHead.home.title));
+ await shot(page, '02-landing-es');
+
+ await page.goto('/hu');
+ await expect.poll(() => new URL(page.url()).pathname).toBe('/');
+ await expect(page.locator('html')).toHaveAttribute('lang', 'hu');
+ // The choice is kept, not just shown once.
+ await page.reload();
+ await expect(page.locator('html')).toHaveAttribute('lang', 'hu');
+ await shot(page, '03-landing-hu-after-reload');
+});
+
+test('a server refusal with a code reads in the interface language', async ({ page }) => {
+ await stubSupabaseSession(page);
+ // The account is suspended: the ai function answers 403 with its English reason and a code.
+ await page.route((url) => url.pathname.endsWith('/functions/v1/ai'), (route) => route.fulfill({
+ status: 403,
+ json: { error: 'Your account has been suspended. Please contact support.', code: 'banned' },
+ }));
+ await page.goto('/app/attack?lang=hu');
+ await page.getByRole('textbox', { name: new RegExp(hu.attackForm.argument) }).fill('A tömegközlekedés legyen ingyenes, mert csökkenti a forgalmat.');
+ await page.getByRole('button', { name: hu.attackForm.submit }).click();
+ // Without a Current Debate, the tool may first ask whether to start one.
+ const once = page.getByRole('button', { name: hu.debate.offerNotNow });
+ const refusal = page.getByText(hu.serverErrors.banned);
+ await expect(once.or(refusal).first()).toBeVisible();
+ if (await once.isVisible()) await once.click();
+ await expect(refusal).toBeVisible();
+ await expect(page.getByText('Your account has been suspended.')).toHaveCount(0);
+ await shot(page, '01-attack-refused-hu');
+});
diff --git a/index.html b/index.html
index b59b864ea..58ca1c192 100644
--- a/index.html
+++ b/index.html
@@ -9,7 +9,7 @@
DebateLab
-
+
@@ -23,14 +23,14 @@
-
+
-
+
diff --git a/packages/core/src/ai/schemas/ask.ts b/packages/core/src/ai/schemas/ask.ts
index ee07860fd..2e0a502ae 100644
--- a/packages/core/src/ai/schemas/ask.ts
+++ b/packages/core/src/ai/schemas/ask.ts
@@ -3,7 +3,8 @@ import { z } from 'zod';
import { SafeWebUrlSchema } from './url';
export const AskInputSchema = z.object({
- message: z.string().min(1, 'Message is required'),
+ // A catalog key, not text: the form shows it in the interface language.
+ message: z.string().min(1, 'validation.messageRequired'),
mode: z.enum(['general', 'clarify', 'summarize', 'proscons', 'argumentmap']),
citationMode: z.boolean(),
webSearch: z.boolean().optional(),
diff --git a/packages/core/src/ai/schemas/attack.ts b/packages/core/src/ai/schemas/attack.ts
index 9db862b71..198a19543 100644
--- a/packages/core/src/ai/schemas/attack.ts
+++ b/packages/core/src/ai/schemas/attack.ts
@@ -2,7 +2,8 @@
import { z } from 'zod';
export const AttackInputSchema = z.object({
- caseText: z.string().min(1, 'Case text is required'),
+ // A catalog key, not text: the form shows it in the interface language.
+ caseText: z.string().min(1, 'validation.caseTextRequired'),
aggressiveness: z.enum(['mild', 'moderate', 'ruthless']),
judgeParadigm: z.enum(['logic', 'values', 'policy']),
detectFallacies: z.boolean(),
diff --git a/packages/core/src/ai/schemas/brainstorm.ts b/packages/core/src/ai/schemas/brainstorm.ts
index 75f50a622..1de065c10 100644
--- a/packages/core/src/ai/schemas/brainstorm.ts
+++ b/packages/core/src/ai/schemas/brainstorm.ts
@@ -2,7 +2,8 @@
import { z } from 'zod';
export const BrainstormInputSchema = z.object({
- motion: z.string().min(1, 'Motion is required'),
+ // A catalog key, not text: the form shows it in the interface language.
+ motion: z.string().min(1, 'validation.motionRequired'),
side: z.enum(['pro', 'con', 'both']),
context: z.string().optional(),
outputMode: z.enum(['mindmap', 'buckets', 'topfive']),
diff --git a/packages/core/src/ai/schemas/factoids.ts b/packages/core/src/ai/schemas/factoids.ts
index cb1e60e6a..dc6ff19d0 100644
--- a/packages/core/src/ai/schemas/factoids.ts
+++ b/packages/core/src/ai/schemas/factoids.ts
@@ -3,7 +3,8 @@ import { z } from 'zod';
import { SafeWebUrlSchema } from './url';
export const FactoidsInputSchema = z.object({
- claim: z.string().min(1, 'Claim is required'),
+ // A catalog key, not text: the form shows it in the interface language.
+ claim: z.string().min(1, 'validation.claimRequired'),
direction: z.enum(['for', 'against']).default('for'),
region: z.string().optional(),
sourceType: z.enum(['academic', 'government', 'news', 'any']),
diff --git a/packages/core/src/ai/schemas/generate.ts b/packages/core/src/ai/schemas/generate.ts
index 1d6c6a6fd..960c79b0b 100644
--- a/packages/core/src/ai/schemas/generate.ts
+++ b/packages/core/src/ai/schemas/generate.ts
@@ -2,7 +2,8 @@
import { z } from 'zod';
export const GenerateInputSchema = z.object({
- motion: z.string().min(1, 'Motion is required'),
+ // A catalog key, not text: the form shows it in the interface language.
+ motion: z.string().min(1, 'validation.motionRequired'),
side: z.enum(['pro', 'con']),
format: z.enum(['custom', 'bp', 'policy', 'classroom']), // Kept others for backward compatibility but UI will restrict
role: z.enum(['intro', 'r1', 'r2', 'conc', 'general']),
diff --git a/packages/core/src/supabase/edge-function-error.ts b/packages/core/src/supabase/edge-function-error.ts
index ab688a4fb..a0d6d3278 100644
--- a/packages/core/src/supabase/edge-function-error.ts
+++ b/packages/core/src/supabase/edge-function-error.ts
@@ -1,5 +1,16 @@
-export async function getEdgeFunctionErrorMessage(error: unknown): Promise {
- if (!error) return 'Request failed';
+export interface EdgeFunctionError {
+ /** The function's own `error` text, or the client's message when it is more than the generic non-2xx one. */
+ message?: string;
+ /** The stable `code` the function sent beside its English text, for the browser to translate. */
+ code?: string;
+ status?: number;
+ /** The parsed JSON body, for functions that answer with other fields (redeem-promo's `message`). */
+ body?: Record;
+}
+
+/** What a failed supabase.functions.invoke call said: its status, JSON body, text and code. */
+export async function readEdgeFunctionError(error: unknown): Promise {
+ if (!error) return {};
const err = error as {
message?: unknown;
@@ -36,15 +47,23 @@ export async function getEdgeFunctionErrorMessage(error: unknown): Promise
: undefined;
-
- if (typeof bodyError === 'string' && bodyError.trim()) return bodyError;
-
+ const bodyError = body?.error;
const msg = typeof err.message === 'string' ? err.message : '';
- if (msg && msg !== 'Edge Function returned a non-2xx status code') return msg;
+ const message = typeof bodyError === 'string' && bodyError.trim()
+ ? bodyError
+ : msg && msg !== 'Edge Function returned a non-2xx status code' ? msg : undefined;
+ const code = typeof body?.code === 'string' ? body.code : undefined;
+
+ return { message, code, status, body };
+}
+export async function getEdgeFunctionErrorMessage(error: unknown): Promise {
+ if (!error) return 'Request failed';
+ const { message, status } = await readEdgeFunctionError(error);
+ if (message) return message;
if (status) return `Request failed (HTTP ${status})`;
return 'Request failed';
}
diff --git a/src/App.tsx b/src/App.tsx
index 9ff60b001..e4de6d57a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,7 +4,7 @@ import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";
-import { BrowserRouter, Navigate, Routes, Route, useLocation, useNavigationType } from "react-router-dom";
+import { BrowserRouter, Navigate, Routes, Route, useLocation, useNavigate, useNavigationType } from "react-router-dom";
import { MotionConfig } from "framer-motion";
import { AuthProvider, useAuth } from "@/lib/supabase/AuthProvider";
import { RequireAuth } from "@/components/auth/RequireAuth";
@@ -17,11 +17,12 @@ 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";
import { AUTH_CALLBACK_PATH } from "@/lib/auth-callback";
+import { chooseUrlLanguage, searchWithoutLanguage } from "@/lib/url-language";
/**
* A page the build prerenders. mountApp loads its module before the first
@@ -252,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}
+
+
+
);
}
@@ -314,6 +319,19 @@ function ScrollToTopOnNavigate(): null {
return null;
}
+/**
+ * A ?lang= reached inside the app (the /es and /hu redirects): switch to it and
+ * drop it from the address. mountApp handles the one a page opens with.
+ */
+export function UrlLanguage(): null {
+ const { pathname, search, hash } = useLocation();
+ const navigate = useNavigate();
+ useEffect(() => {
+ if (chooseUrlLanguage(search)) navigate({ pathname, search: searchWithoutLanguage(search), hash }, { replace: true });
+ }, [hash, navigate, pathname, search]);
+ return null;
+}
+
function AppRoutes() {
useSessionStorageSync();
@@ -321,6 +339,9 @@ function AppRoutes() {
{/* Public Routes */}
} />
+ {/* Language home pages; vercel.json redirects them on a full load too. */}
+ } />
+ } />
} />
} />
} />
@@ -387,6 +408,7 @@ const App = () => (
+
diff --git a/src/components/features/attack/AttackForm.tsx b/src/components/features/attack/AttackForm.tsx
index 1992a76a5..7bf4ef87e 100644
--- a/src/components/features/attack/AttackForm.tsx
+++ b/src/components/features/attack/AttackForm.tsx
@@ -86,7 +86,7 @@ export function AttackForm({ onSubmit, isLoading, importedCase, debateContext =
className="min-h-[200px] resize-y"
/>
{errors.caseText && (
-
)}
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 e04b74688..dab9d9a0a 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..c068a4e1b 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 { 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';
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 () => {
@@ -45,24 +57,47 @@ 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 "