diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 24b834fdd..2e7bb199b 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -52,7 +52,7 @@ Each feature file starts with an H1 title and one paragraph describing the user- - [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete All My Data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`). - 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 (extended access, Pro set to cancel, payment failed, subscription ended) shown the same way by Settings and the AI functions, in English and Hungarian at the project width and 360 px (`recipes/billing-states.spec.ts`); signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.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`). - 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`). diff --git a/.claude/skills/verify-debatelab/features/practice.md b/.claude/skills/verify-debatelab/features/practice.md index f968f5014..e628c9de0 100644 --- a/.claude/skills/verify-debatelab/features/practice.md +++ b/.claude/skills/verify-debatelab/features/practice.md @@ -31,7 +31,7 @@ Preconditions: - **Start Crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`. - **Crossfire turn.** Fill `crossfire-input` and click `submit-turn`. The text `FAKE-PROVIDER crossfire answer` and `Direct answer` appear. `providerCall("crossfire")` and `providerCall("jev")` both fired. - **Debrief.** Click `end-crossfire`. Test id `crossfire-debrief` is visible. `crossfires` for the user equals `[{title: , debates: {motion: }}]`. -- **Out of requests.** Through `admin`, set `user_settings.daily_limit_override` to N, upsert today's `daily_usage_counters` row (`quota_key: "ai"`, `request_count: N`; what the `ai` function checks), and insert N `ai_requests` rows (the fallback the Settings usage line counts against a database without `ai_usage_today`; Crossfire and Sparring ask the server). Start Crossfire in `mode-ai-questions-user`: a `dialog` containing `N of N` opens and `start-crossfire` is back. On `/app/sparring`, `sparring-request-shortfall` says `You have no AI requests left today`. +- **Out of requests.** Through `admin`, set `user_settings.daily_limit_override` to N, upsert today's `daily_usage_counters` row (`quota_key: "ai"`, `request_count: N`; what the `ai` function checks), and insert N `ai_requests` rows. The usage line and Crossfire and Sparring read only the counter (`ai_usage_today` and the quota check). Start Crossfire in `mode-ai-questions-user`: a `dialog` containing `N of N` opens and `start-crossfire` is back. On `/app/sparring`, `sparring-request-shortfall` says the same `You've used N of N included AI uses today.` ## Gotchas diff --git a/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts b/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts index 2df3ee2d2..bd07c0026 100644 --- a/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts @@ -1,31 +1,70 @@ import { randomUUID } from "node:crypto"; -import type { Page } from "@playwright/test"; -import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, test } from "./fixtures"; +import { join } from "node:path"; +import type { Locator, Page, TestInfo } from "@playwright/test"; +import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, test, type Evidence } from "./fixtures"; // Feature map: the Billing entry in features/README.md. Each state is written // straight into user_settings and subscriptions (what the Stripe webhook -// leaves behind), then Settings and an AI request must agree on it. +// leaves behind), then Settings, the pricing dialog and an AI request must +// agree on it. const DAY = 24 * 60 * 60 * 1000; const at = (days: number) => new Date(Date.now() + days * DAY).toISOString(); +type Text = { en: string; hu: string }; type State = { name: string; settings: Record; subscription?: Record; - badge: { en: string; hu: string }; + badge: Text; text: { en: RegExp; hu: RegExp }; - button: { en: string; hu: string }; + /** The one plan button, the same in Settings and the pricing dialog. */ + button: Text; + /** The pricing dialog card marked Current. */ + plan: "plan-trial" | "plan-pro" | null; + /** The plan's daily AI uses; null when the account has no access. */ + dailyLimit: number | null; + /** What a refused AI request opens. */ + refusal: { title: Text; button: Text }; }; +const UPGRADE: Text = { en: "Upgrade to Pro", hu: "Váltás Pro-ra" }; +const MANAGE: Text = { en: "Manage Subscription", hu: "Előfizetés kezelése" }; +const PAUSED: Text = { en: "Daily Access Paused", hu: "Napi hozzáférés szünetel" }; +const OK: Text = { en: "OK", hu: "Rendben" }; + const STATES: State[] = [ + { + name: "trial", + settings: { subscription_tier: "trial", trial_ends_at: at(2.5) }, + badge: { en: "Free Trial", hu: "Ingyenes próba" }, + text: { en: /AI access is active/, hu: /Az MI-hozzáférés aktív/ }, + button: UPGRADE, + plan: "plan-trial", + dailyLimit: 5, + refusal: { title: PAUSED, button: UPGRADE }, + }, { // QB-02: an override on an expired trial is a deliberate grant. name: "extended-access", settings: { subscription_tier: "trial", trial_ends_at: at(-1), daily_limit_override: 5 }, badge: { en: "Extended access", hu: "Bővített hozzáférés" }, text: { en: /Extended access is active: 5 AI uses a day/, hu: /Bővített hozzáférésed van: napi 5 MI-használat/ }, - button: { en: "Upgrade to Pro", hu: "Váltás Pro-ra" }, + button: UPGRADE, + plan: null, + dailyLimit: 5, + refusal: { title: PAUSED, button: UPGRADE }, + }, + { + name: "pro-active", + settings: { subscription_tier: "pro" }, + subscription: { status: "active", current_period_start: at(-10), current_period_end: at(20), cancel_at_period_end: false }, + badge: { en: "Pro", hu: "Pro" }, + text: { en: /Renews on/, hu: /Megújul:/ }, + button: MANAGE, + plan: "plan-pro", + dailyLimit: 50, + refusal: { title: PAUSED, button: OK }, }, { // QB-15 @@ -34,7 +73,10 @@ const STATES: State[] = [ subscription: { status: "active", current_period_start: at(-10), current_period_end: at(20), cancel_at_period_end: true }, badge: { en: "Pro", hu: "Pro" }, text: { en: /Cancels on/, hu: /Lemondva, lejár:/ }, - button: { en: "Manage Subscription", hu: "Előfizetés kezelése" }, + button: MANAGE, + plan: "plan-pro", + dailyLimit: 50, + refusal: { title: PAUSED, button: OK }, }, { // QB-13: the tier the old webhook wrote on the first failed invoice. @@ -42,8 +84,11 @@ const STATES: State[] = [ settings: { subscription_tier: "expired" }, subscription: { status: "past_due", current_period_start: at(-2), current_period_end: at(28) }, badge: { en: "Payment failed", hu: "Sikertelen fizetés" }, - text: { en: /Pro access continues until/, hu: /a Pro-hozzáférésed megmarad eddig/ }, - button: { en: "Manage Subscription", hu: "Előfizetés kezelése" }, + text: { en: /Pro access continues until/, hu: /a Pro-hozzáférésed .+ napjáig megmarad/ }, + button: MANAGE, + plan: "plan-pro", + dailyLimit: 50, + refusal: { title: PAUSED, button: OK }, }, { // QB-14 @@ -53,11 +98,17 @@ const STATES: State[] = [ badge: { en: "Expired", hu: "Lejárt" }, text: { en: /Your Pro subscription ended on/, hu: /A Pro-előfizetésed véget ért/ }, button: { en: "Resubscribe", hu: "Újra előfizetek" }, + plan: null, + dailyLimit: null, + refusal: { title: { en: "Pro Subscription Ended", hu: "A Pro-előfizetés véget ért" }, button: { en: "Resubscribe", hu: "Újra előfizetek" } }, }, ]; +const today = () => new Date().toISOString().slice(0, 10); + async function setState(userId: string, state: State) { await admin.from("subscriptions").delete().eq("user_id", userId); + await admin.from("daily_usage_counters").delete().eq("user_id", userId); const { error } = await admin.from("user_settings").update({ subscription_tier: "trial", trial_ends_at: null, @@ -77,50 +128,195 @@ async function setState(userId: string, state: State) { } } -async function brainstorm(page: Page) { +async function chargeAIUnits(userId: string, units: number) { + await admin.from("daily_usage_counters").delete().eq("user_id", userId); + const { error } = await admin.from("daily_usage_counters").insert({ user_id: userId, usage_date: today(), quota_key: "ai", request_count: units }); + if (error) throw error; +} + +async function brainstorm(page: Page, language: "en" | "hu" = "en") { await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("Public transport should be free"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); - await chooseForMotion(page, "once"); + await page.getByLabel(language === "en" ? "Motion / Topic *" : "Indítvány / téma *").fill("Public transport should be free"); + await page.getByRole("button", { name: language === "en" ? "Start Brainstorming" : "Ötletelés indítása" }).click(); + if (language === "en") return chooseForMotion(page, "once"); + const dialog = page.getByRole("dialog", { name: /^(Indítasz egy vitát ezzel a tétellel\?|Ez egy másik tétel)$/ }); + await dialog.getByRole("button", { name: /^(Most nem, csak egyszer használom|Egyszeri használat vitába mentés nélkül)$/ }).click(); + await expect(dialog).toBeHidden(); } -test("bill-states: Settings and the AI functions agree on each billing state, in English and Hungarian", async ({ signedIn: page, user, evidence }) => { - const userId = await user.userId(); - const viewport = page.viewportSize(); +async function useHungarian(page: Page) { + await page.goto("/app/settings"); + await page.getByLabel("Interface Language").click(); + await page.getByRole("option", { name: "Magyar" }).click(); + await expect(page.getByText("Előfizetés", { exact: true })).toBeVisible(); +} - for (const language of ["en", "hu"] as const) { - await page.goto("/app/settings"); - if (language === "hu") { - await page.getByLabel("Interface Language").click(); - await page.getByRole("option", { name: "Magyar" }).click(); - await expect(page.getByText("Előfizetés", { exact: true })).toBeVisible(); +// Phones on the touch project, tablet and desktop on the desktop project. +const WIDTHS: Record = { "mobile-chromium": [360, 375], "desktop-chromium": [768, 1280] }; + +/** One region at each width this project covers, light and dark. */ +async function shots(page: Page, evidence: Evidence, testInfo: TestInfo, name: string, region: () => Locator) { + const original = page.viewportSize()!; + for (const width of WIDTHS[testInfo.project.name] ?? [original.width]) { + await page.setViewportSize({ width, height: original.height }); + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await expect(page.locator("html")).toHaveClass(colorScheme === "dark" ? /\bdark\b/ : /^(?!.*\bdark\b)/); + await region().screenshot({ path: join(evidence.dir, `${name}-${width}-${colorScheme}.png`), animations: "disabled" }); } + } + await page.emulateMedia({ colorScheme: "light" }); + await page.setViewportSize(original); +} + +for (const language of ["en", "hu"] as const) { + test(`bill-states-${language}: Settings, the pricing dialog and a refused request agree on each billing state`, async ({ signedIn: page, user, evidence }, testInfo) => { + test.setTimeout(600_000); + const userId = await user.userId(); + if (language === "hu") await useHungarian(page); + // The portal needs Stripe. Answer as it does for an account Stripe does + // not know, which sends Settings to the pricing dialog: the one way a + // payer reaches it, and the path QB-16 is about. + let portalCalls = 0; + await page.route("**/functions/v1/create-portal", (route) => { + portalCalls += 1; + return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "No active subscription found. Upgrade to Pro to start a subscription." }) }); + }); + for (const state of STATES) { await setState(userId, state); await page.goto("/app/settings"); + // That fallback also toasts "No subscription found"; the shots are of the dialog. + await page.addStyleTag({ content: "[data-sonner-toaster] { display: none !important; }" }); const card = page.getByTestId("subscription-card"); await expect(card.getByText(state.badge[language], { exact: true })).toBeVisible(); await expect(card.getByText(state.text[language])).toBeVisible(); await expect(card.getByRole("button", { name: state.button[language] })).toBeVisible(); // QB-02: never "Expired · Your access has expired" beside a working plan. await expect(card.getByText(language === "en" ? "Your access has expired" : "A hozzáférésed lejárt", { exact: false })).toHaveCount(0); - await evidence.shot(`${language}-${state.name}`); - if (viewport && viewport.width > 360) { - await page.setViewportSize({ width: 360, height: 780 }); - await evidence.shot(`${language}-${state.name}-360`); - await page.setViewportSize(viewport); + // QB-03: the server's counter, the same number on a tool page. + if (state.dailyLimit) { + const usage = language === "en" ? `${state.dailyLimit} of ${state.dailyLimit} AI uses left today` : `Mára ${state.dailyLimit} / ${state.dailyLimit} MI-használat maradt`; + await expect(card.getByTestId("daily-usage")).toContainText(usage); + } else { + await expect(card.getByTestId("daily-usage")).toHaveCount(0); + } + await expect(page.getByTestId("trial-banner")).toHaveCount(state.name === "trial" ? 1 : 0); + await shots(page, evidence, testInfo, `${language}-${state.name}-settings`, () => card); + + // QB-16: the dialog marks the plan the account is on and offers only its next step. + const portalBefore = portalCalls; + await card.getByRole("button", { name: state.button[language] }).click(); + const pricing = page.getByRole("dialog"); + if (state.button === MANAGE) await expect.poll(() => portalCalls).toBe(portalBefore + 1); + await expect(pricing.getByRole("button", { name: state.button[language] })).toBeVisible(); + for (const other of [UPGRADE, MANAGE, { en: "Resubscribe", hu: "Újra előfizetek" }].filter((text) => text.en !== state.button.en)) { + await expect(pricing.getByRole("button", { name: other[language], exact: true })).toHaveCount(0); + } + for (const plan of ["plan-trial", "plan-pro"] as const) { + await expect(pricing.getByTestId(plan).getByText(language === "en" ? "Current" : "Jelenlegi", { exact: true })).toHaveCount(plan === state.plan ? 1 : 0); } + // IN-07: the price comes from the price config in the reader's format. + await expect(pricing.getByTestId("plan-pro")).toContainText(language === "en" ? "$5" : "5 $"); + // VS-08: a 44 px close target. + const close = await pricing.getByRole("button", { name: language === "en" ? "Close" : "Bezárás" }).boundingBox(); + expect(Math.round(close!.width)).toBeGreaterThanOrEqual(44); + expect(Math.round(close!.height)).toBeGreaterThanOrEqual(44); + await shots(page, evidence, testInfo, `${language}-${state.name}-pricing`, () => page.getByRole("dialog")); + await page.keyboard.press("Escape"); + await expect(pricing).toBeHidden(); + + // QB-06: a refused request opens the same dialog in every state's own words. + if (state.dailyLimit) await chargeAIUnits(userId, state.dailyLimit); + await brainstorm(page, language); + const refused = page.getByRole("dialog"); + await expect(refused.getByText(state.refusal.title[language], { exact: true })).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(refused.getByRole("button", { name: state.refusal.button[language] })).toBeVisible(); + await expect(refused.getByText(/non-2xx|Edge Function/)).toHaveCount(0); + await shots(page, evidence, testInfo, `${language}-${state.name}-limit`, () => page.getByRole("dialog")); + await page.keyboard.press("Escape"); } + await evidence.step(`${language}: each state shows its own badge, usage, pricing dialog and refusal`); + }); +} + +// VS-06: the Spanish card at phone widths too (the matrix above covers EN and HU). +test("bill-states-es: the Spanish subscription card wraps instead of squeezing", async ({ signedIn: page, user, evidence }, testInfo) => { + const userId = await user.userId(); + await page.goto("/app/settings"); + await page.getByLabel("Interface Language").click(); + await page.getByRole("option", { name: "Español" }).click(); + await expect(page.getByText("Suscripción", { exact: true })).toBeVisible(); + const ES: Record = { + "trial": { badge: "Prueba gratis", button: "Mejorar a Pro" }, + "pro-canceling": { badge: "Pro", button: "Gestionar suscripción" }, + "past-due-grace": { badge: "Pago fallido", button: "Gestionar suscripción" }, + }; + for (const state of STATES.filter((candidate) => candidate.name in ES)) { + await setState(userId, state); + await page.goto("/app/settings"); + const card = page.getByTestId("subscription-card"); + await expect(card.getByText(ES[state.name].badge, { exact: true })).toBeVisible(); + await expect(card.getByRole("button", { name: ES[state.name].button })).toBeVisible(); + await shots(page, evidence, testInfo, `es-${state.name}-settings`, () => card); + } + await evidence.step("es: trial, cancelling and past-due cards"); +}); + +test("bill-usage: the tool pages show the server's weighted count, and it updates after a run", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + await setState(userId, STATES[0]); + // Three units already charged today, as a weighted model or a Sparring round leaves them. + await chargeAIUnits(userId, 3); + + await page.goto("/app/settings"); + await expect(page.getByTestId("daily-usage")).toContainText("2 of 5 AI uses left today"); + for (const tool of ["generate", "attack", "brainstorm", "factoids", "ask", "crossfire", "sparring"]) { + await page.goto(`/app/${tool}`); + await expect(page.getByTestId("daily-usage")).toHaveText("2 of 5 AI uses left today"); } - await evidence.step("Each state shows its own badge, sentence and single button in EN and HU"); + await evidence.shot("01-sparring-header"); + + const served = aiResponse(page, "brainstorm"); + await brainstorm(page); + await served; + // No reload: the count is read again after the request. + await expect(page.getByTestId("daily-usage")).toHaveText("1 of 5 AI uses left today"); + await evidence.shot("02-brainstorm-after-run"); + await evidence.step("Settings and every tool show 2 of 5 left; one run later Brainstorm shows 1 of 5 without a reload"); +}); - // Back to English for the AI checks. +test("bill-trial-banner: the trial banner counts down, opens the plans and stays dismissed", async ({ signedIn: page, user, evidence }) => { + await setState(await user.userId(), STATES[0]); + await page.goto("/app"); + const banner = page.getByTestId("trial-banner"); + await expect(banner).toContainText(/Free trial: [23] days left\./); + await evidence.shot("01-banner"); + await banner.getByRole("button", { name: "Upgrade to Pro" }).click(); + await expect(page.getByRole("dialog").getByRole("heading", { name: "Upgrade to Pro" })).toBeVisible(); + await page.keyboard.press("Escape"); + await banner.getByRole("button", { name: "Hide the trial reminder" }).click(); + await expect(banner).toHaveCount(0); + // Once the plan has loaded again (the Settings badge), the banner stays away. await page.goto("/app/settings"); - await page.getByLabel("Felület nyelve").click(); - await page.getByRole("option", { name: "English" }).click(); + await expect(page.getByTestId("subscription-card").getByText("Free Trial", { exact: true })).toBeVisible(); + await expect(page.getByTestId("trial-banner")).toHaveCount(0); + await evidence.step("Trial banner shows days left, opens the pricing dialog, and stays hidden after dismissal"); +}); + +test("bill-redirect: /app/billing opens the Settings subscription section", async ({ signedIn: page, evidence }) => { + await page.goto("/app/billing"); + await expect(page).toHaveURL(/\/app\/settings#subscription$/); + await expect(page.getByTestId("subscription-card")).toBeInViewport(); + await evidence.shot("01-billing-redirect"); + await evidence.step("QB-12: /app/billing lands on Settings with the subscription card in view"); +}); + +test("bill-access: extended access is served; a past-due payer after the grace is sent to Settings", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); // QB-02: extended access really is served. - await setState(userId, STATES[0]); + await setState(userId, STATES[1]); const served = aiResponse(page, "brainstorm"); await brainstorm(page); expect(((await served).output.topFive as unknown[]).length).toBeGreaterThan(0); @@ -128,7 +324,7 @@ test("bill-states: Settings and the AI functions agree on each billing state, in // QB-13: past the grace, a past-due payer is refused with payment_failed and sent to Settings, not Checkout. await setState(userId, { - ...STATES[2], + ...STATES[4], subscription: { status: "past_due", current_period_start: at(-20), current_period_end: at(10) }, }); await brainstorm(page); diff --git a/.claude/skills/verify-debatelab/recipes/practice.spec.ts b/.claude/skills/verify-debatelab/recipes/practice.spec.ts index bc3478e66..10d98f992 100644 --- a/.claude/skills/verify-debatelab/recipes/practice.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/practice.spec.ts @@ -130,9 +130,9 @@ test("cross-ai-questions: the AI questions the user, answers are judged, and the /** * Today's AI requests are used up: the server's counter (what the ai function - * checks, and what Crossfire and Sparring ask it for before a round) and the - * usage rows (the Settings usage line's fallback on an older database). A refused call and a Jev - * helper row are on the list too, and must not count as used. + * checks, what Crossfire and Sparring ask it for before a round, and what the + * usage line reads) and the usage rows real calls leave. A refused call and a + * Jev helper row are on the list too, and must not count as used. */ async function useUpDailyRequests(userId: string, limit: number) { await admin.from("user_settings").update({ daily_limit_override: limit }).eq("user_id", userId); @@ -166,7 +166,8 @@ test("cross-daily-limit and spar-daily-limit: with no requests left, Crossfire s await evidence.shot("02-crossfire-back-in-setup"); await page.goto("/app/sparring"); - await expect(page.getByTestId("sparring-request-shortfall")).toContainText("You have no AI requests left today"); + // QB-06: the same words as the Crossfire dialog above. + await expect(page.getByTestId("sparring-request-shortfall")).toContainText("You've used 2 of 2 included AI uses today."); await evidence.shot("03-sparring-warns"); await evidence.aria("03-sparring-warns", page.getByTestId("sparring-request-shortfall")); }); diff --git a/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts index aa7b14ae3..7e8650fab 100644 --- a/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts @@ -121,7 +121,7 @@ test("usage-session-sparring: with one request left, a whole Sparring round (eve await page.getByRole("combobox", { name: "Select AI model for sparring" }).click(); await page.getByRole("option", { name: /Claude Haiku 5\.5/ }).click(); const cost = page.getByTestId("session-cost"); - await expect(cost).toHaveText("This round costs 1 AI request, however many turns it takes. You have 1 left today.", { timeout: AI_TIMEOUT }); + await expect(cost).toHaveText("This round costs 1 AI use, however many turns it takes. You have 1 left today.", { timeout: AI_TIMEOUT }); await evidence.shot("01-sparring-cost-before-start"); await page.getByRole("button", { name: "Start round" }).click(); await chooseForMotion(page, "create"); diff --git a/docs/architecture.md b/docs/architecture.md index 4a75d2f52..bde758ea2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -156,7 +156,7 @@ The daily AI quota counts units, not requests. `checkRateLimit` in [`supabase/fu Crossfire and Sparring are charged per round, not per call. The round's first call reserves the model's units through `claim_ai_session_quota` ([`20261022220000_ai_quota_sessions.sql`](../supabase/migrations/20261022220000_ai_quota_sessions.sql)), keyed by the Sparring `sessionId` or the Crossfire round id the client sends as `quotaSession` beside `input`; later turns, the debrief, and the judge in that round reserve nothing. A round past its call cap or lifetime, or switched to a heavier model, pays again (`SESSION_QUOTA` in `usage-limits.ts`), so a reused id cannot buy unlimited AI. A Crossfire request without `quotaSession` (an older client) is charged per call. Before a round starts, both pages send a `quotaCheck` request, which reserves nothing and answers with the round's cost and today's count, so a round the server would refuse never starts. -A run that produces nothing costs nothing. When a request fails, times out, or the browser goes away (reload, closed tab, Stop) before `REFUND_MAX_DELIVERED_CHARS` of streamed output reached it and before its answer was saved, `ai` and `ai-stream` give its units back (`refund_daily_quota`, or `refund_ai_session_quota` for the call that paid for a round) and answer with `charged: false`, which the client shows as "You were not charged". The weekly cap still counts what the run cost. The Settings usage line reads the same counter through `ai_usage_today()` (the caller's own units today), so per-round charging and refunds show there; against an older database it falls back to counting today's `ai_requests` rows. `ai_requests.error_code` holds a short code (`provider_error`, `timeout`, `aborted`, `claude_unavailable`, the refusal codes); `logUsage` maps anything else to one of those and sends the raw text, truncated, to the function log only. +A run that produces nothing costs nothing. When a request fails, times out, or the browser goes away (reload, closed tab, Stop) before `REFUND_MAX_DELIVERED_CHARS` of streamed output reached it and before its answer was saved, `ai` and `ai-stream` give its units back (`refund_daily_quota`, or `refund_ai_session_quota` for the call that paid for a round) and answer with `charged: false`, which the client shows as "You were not charged". The weekly cap still counts what the run cost. The usage line in Settings and on every tool page reads the same counter through `ai_usage_today()` (the caller's own units today), so per-round charging and refunds show there, and it is read again after each AI call (`refreshAIUsage` in `src/lib/query-client.ts`). When the counter cannot be read (an older database), the line is hidden rather than estimated from `ai_requests` rows. `ai_requests.error_code` holds a short code (`provider_error`, `timeout`, `aborted`, `claude_unavailable`, the refusal codes); `logUsage` maps anything else to one of those and sends the raw text, truncated, to the function log only. ## Observability diff --git a/src/App.tsx b/src/App.tsx index 8da96a23c..9ff60b001 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,8 +2,9 @@ import { Component, Suspense, lazy, useEffect, useRef, useState, type ComponentType, type ErrorInfo, type ReactNode } from "react"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { BrowserRouter, Routes, Route, useLocation, useNavigationType } from "react-router-dom"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "@/lib/query-client"; +import { BrowserRouter, Navigate, Routes, Route, useLocation, useNavigationType } from "react-router-dom"; import { MotionConfig } from "framer-motion"; import { AuthProvider, useAuth } from "@/lib/supabase/AuthProvider"; import { RequireAuth } from "@/components/auth/RequireAuth"; @@ -78,7 +79,6 @@ const AppLayout = lazy(() => { const OnboardingTour = lazy(() => import("@/components/features/onboarding/OnboardingTour") .then(({ OnboardingTour: Tour }) => ({ default: Tour }))); -const queryClient = new QueryClient(); // TanStack Query refetches after an `offline` then `online` pair, but a page // opened offline never sees `offline`. Retry whatever failed once the // connection returns, such as Settings' admin-role check. @@ -366,6 +366,8 @@ function AppRoutes() { } /> } /> } /> + {/* Billing lives in Settings (QB-12). */} + } /> } /> } /> diff --git a/src/components/features/sparring/SparringSetup.tsx b/src/components/features/sparring/SparringSetup.tsx index f30926b30..bc7b25541 100644 --- a/src/components/features/sparring/SparringSetup.tsx +++ b/src/components/features/sparring/SparringSetup.tsx @@ -24,6 +24,8 @@ import type { SparringDifficulty, SparringFormat, SparringSide } from '@/lib/ai/ import { VoiceModeToggle } from '@/components/features/voice/VoiceModeToggle'; import { useVoiceAvailability } from '@/lib/voice/availability'; import { SessionCostNote } from '@/components/features/subscription/SessionCostNote'; +import { DailyUsage } from '@/components/features/subscription/DailyUsage'; +import { shortfallMessage } from '@/lib/ai/limit-message'; export interface SparringSetupValues { motion: string; @@ -91,7 +93,7 @@ export function SparringSetup({ onStart, roundCost = null, remainingRequests = n return (
- + } /> {banner}
@@ -272,9 +274,8 @@ export function SparringSetup({ onStart, roundCost = null, remainingRequests = n {requestShortfall && (

- {remainingRequests === 0 - ? t('dailyLimit.sparringNone') - : t('dailyLimit.sparringShort', { needed: cost, remaining: remainingRequests })} + {/* The server's refusal when it answered, else the same words from the browser's count (QB-06). */} + {blockedReason ?? shortfallMessage(cost, remainingRequests)}

)} {/* The shortfall note above already explains a day that is used up. */} diff --git a/src/components/features/subscription/DailyUsage.tsx b/src/components/features/subscription/DailyUsage.tsx new file mode 100644 index 000000000..1170ecc2e --- /dev/null +++ b/src/components/features/subscription/DailyUsage.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next'; +import { useSubscription } from '@/hooks/use-subscription'; +import { cn } from '@/lib/utils'; + +/** The next midnight UTC, when the daily AI uses refresh, on the reader's clock. */ +function dailyResetTime(locale: string): string { + const now = new Date(); + const reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)); + return reset.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' }); +} + +/** + * "3 of 5 AI uses left today": the server's weighted counter, the number it + * enforces, so a tool page and Settings always agree (QB-03). Says nothing + * while the count is unknown or the account has no daily uses. + */ +export function DailyUsage({ showReset = false, className }: { showReset?: boolean; className?: string }) { + const { t, i18n } = useTranslation(); + const { remainingToday, dailyLimit } = useSubscription(); + if (remainingToday === null || dailyLimit <= 0) return null; + return ( +

+ {showReset + ? t('dailyLimit.remainingWithReset', { count: remainingToday, limit: dailyLimit, time: dailyResetTime(i18n.language) }) + : t('dailyLimit.remaining', { count: remainingToday, limit: dailyLimit })} +

+ ); +} diff --git a/src/components/features/subscription/LimitReachedModal.tsx b/src/components/features/subscription/LimitReachedModal.tsx index 93eb20d28..4c1dae359 100644 --- a/src/components/features/subscription/LimitReachedModal.tsx +++ b/src/components/features/subscription/LimitReachedModal.tsx @@ -6,6 +6,8 @@ import { AlertTriangle, Zap, Clock, CreditCard } from 'lucide-react'; import { useSubscription } from '@/hooks/use-subscription'; import { formatEuropeanDate } from '@/lib/date-format'; import { subscriptionLimitMessage } from '@/lib/ai/error-display'; +import { BILLING_ACTION_LABELS } from '@/lib/billing/plan'; +import { PLAN_DIALOG_CLASS, PLAN_DIALOG_CLOSE_CLASS } from './dialog-layout'; /** trial_expired is what functions older than the other access codes sent; it shows as trial_ended. */ export type LimitModalVariant = @@ -39,11 +41,13 @@ const TITLES: Record = { export function LimitReachedModal({ open, onOpenChange, variant, limit, current, units, onUpgrade }: LimitReachedModalProps) { const { t } = useTranslation(); const navigate = useNavigate(); - const { isPro, billingState, billingDate } = useSubscription(); + const { billingState, billingDate } = useSubscription(); const accessEnded = variant === 'trial_ended' || variant === 'trial_expired' || variant === 'subscription_ended'; const isWeeklyBudget = variant === 'weekly_budget_reached'; - // A Pro student who used today's included requests already pays. Selling Pro again is the wrong next step. - const paidDailyCap = isPro && variant === 'limit_reached'; + // A paying student who used today's included requests already has Pro, so selling it again is the wrong + // next step. Legacy free is served at Pro limits but pays nothing, so it is still offered Pro. + const paidDailyCap = (billingState === 'pro' || billingState === 'pro_canceling' || billingState === 'payment_failed') + && variant === 'limit_reached'; const endedOn = variant === 'subscription_ended' && billingState === 'subscription_ended' && billingDate ? formatEuropeanDate(billingDate) @@ -54,7 +58,7 @@ export function LimitReachedModal({ open, onOpenChange, variant, limit, current, return ( - +
{accessEnded ? ( @@ -89,7 +93,7 @@ export function LimitReachedModal({ open, onOpenChange, variant, limit, current, <> {!online && (

@@ -162,8 +212,8 @@ export function PricingModal({ open, onOpenChange, onPromoRedeemed }: PricingMod

- {/* Promo Code Section */} -
+ {/* A promo code goes with a Checkout, not with a subscription the portal manages. */} + {action !== 'manage' &&
{!showPromo ? (
)} -
+ } ); diff --git a/src/components/features/subscription/SubscriptionErrorModals.tsx b/src/components/features/subscription/SubscriptionErrorModals.tsx index 782fc46f5..98ae00959 100644 --- a/src/components/features/subscription/SubscriptionErrorModals.tsx +++ b/src/components/features/subscription/SubscriptionErrorModals.tsx @@ -5,6 +5,7 @@ import { PricingModal } from './PricingModal'; /** * The daily-limit, Pro-model and pricing dialogs a tool opens from useSubscriptionError. + * Every tool mounts these, so a refusal reads the same in every mode (QB-06). * Each mounts only while open, so a tool that never hits a limit never loads plan data for them. */ export function SubscriptionErrorModals({ subError }: { subError: ReturnType }) { @@ -16,6 +17,7 @@ export function SubscriptionErrorModals({ subError }: { subError: ReturnType { subError.setLimitModalOpen(false); subError.setPricingModalOpen(true); }} />} {subError.modelModalOpen && { switch (billingState) { @@ -79,24 +79,23 @@ export function SubscriptionStatus({ return ( <> -
-
+ {/* Wraps on a phone: a long badge, date and button (Hungarian) never squeeze into one row. */} +
+
{t(badge.labelKey)} {dateLabel && date && ( {t(dateLabel, { date })} )}
- {canManage ? ( + {action === 'manage' ? ( ) : ( )}
diff --git a/src/components/features/subscription/TrialBanner.tsx b/src/components/features/subscription/TrialBanner.tsx new file mode 100644 index 000000000..64ccbc631 --- /dev/null +++ b/src/components/features/subscription/TrialBanner.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Clock, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useSubscription } from '@/hooks/use-subscription'; +import { useAuth } from '@/lib/supabase/AuthProvider'; +import { BILLING_ACTION_LABELS, billingAction } from '@/lib/billing/plan'; +import { PricingModal } from './PricingModal'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Calendar days from today to the trial's end on the reader's clock; 0 on its last day. */ +function trialDaysLeft(endsAt: string, now = new Date()): number | null { + const end = new Date(endsAt); + if (Number.isNaN(end.getTime()) || end.getTime() <= now.getTime()) return null; + const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + return Math.round((startOfDay(end) - startOfDay(now)) / DAY_MS); +} + +function dismissedKey(userId: string, endsAt: string) { + return `debatelab:trial-banner-dismissed:${userId}:${endsAt}`; +} + +function readDismissed(key: string): boolean { + try { + return localStorage.getItem(key) === '1'; + } catch { + return false; + } +} + +/** + * "Free trial: 2 days left." A slim line under the header while a trial runs + * (QB-09). Dismissing it hides it for this trial on this browser; a new trial + * end date shows it again. + */ +export function TrialBanner() { + const { t } = useTranslation(); + const { user } = useAuth(); + const subscription = useSubscription(); + const { billingState, billingDate, billingDateKind } = subscription; + const [dismissed, setDismissed] = useState(null); + const [pricingOpen, setPricingOpen] = useState(false); + + const key = user && billingState === 'trial' && billingDateKind === 'ends' && billingDate + ? dismissedKey(user.id, billingDate) + : null; + const daysLeft = billingDate ? trialDaysLeft(billingDate) : null; + if (!key || daysLeft === null || dismissed === key || readDismissed(key)) return null; + + const dismiss = () => { + try { + localStorage.setItem(key, '1'); + } catch { + // Private browsing: hidden until the next page load. + } + setDismissed(key); + }; + + return ( +
+
+
+ {pricingOpen && } +
+ ); +} diff --git a/src/components/features/subscription/dialog-layout.ts b/src/components/features/subscription/dialog-layout.ts new file mode 100644 index 000000000..02b6525e4 --- /dev/null +++ b/src/components/features/subscription/dialog-layout.ts @@ -0,0 +1,7 @@ +/** + * The pricing, limit and Pro dialogs on a phone: inset from the screen edges + * with rounded corners, scrolling inside when taller than the screen, and a + * close button with a visible 44 px target (VS-08). + */ +export const PLAN_DIALOG_CLASS = 'max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] overflow-y-auto rounded-lg'; +export const PLAN_DIALOG_CLOSE_CLASS = 'right-2 top-2 flex h-11 w-11 items-center justify-center rounded-md'; diff --git a/src/components/features/subscription/index.ts b/src/components/features/subscription/index.ts index 9c55cd365..ceaa29fcc 100644 --- a/src/components/features/subscription/index.ts +++ b/src/components/features/subscription/index.ts @@ -3,3 +3,4 @@ export { LimitReachedModal } from './LimitReachedModal'; export { ModelRestrictedModal } from './ModelRestrictedModal'; export { SubscriptionErrorModals } from './SubscriptionErrorModals'; export { SessionCostNote } from './SessionCostNote'; +export { DailyUsage } from './DailyUsage'; diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index cfce55c35..2b3a18bb6 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -19,6 +19,7 @@ import { cn } from '@/lib/utils'; import { ThemeToggle } from '@/components/ThemeToggle'; import { LanguageSwitcher } from '@/components/LanguageSwitcher'; import { AnnouncementBanner } from '@/components/layout/AnnouncementBanner'; +import { TrialBanner } from '@/components/features/subscription/TrialBanner'; import { useRoutePrefetch } from '@/hooks/use-route-prefetch'; import { useAuth } from '@/lib/supabase/AuthProvider'; import { CurrentDebateMenu } from '@/components/features/debate/CurrentDebateMenu'; @@ -387,6 +388,7 @@ export function AppLayout() { onDismiss={() => handleDismissAnnouncement(announcements[0].id)} /> )} + diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx index 08a128ebb..6c1b3fd3b 100644 --- a/src/components/layout/PageHeader.tsx +++ b/src/components/layout/PageHeader.tsx @@ -24,7 +24,8 @@ export function PageHeader({ title, eyebrow, meta, actions, icon, className, tit {icon && {icon}}

{title}

- {meta &&
{meta}
} + {/* empty:hidden: a meta component that renders nothing leaves no gap. */} + {meta &&
{meta}
}
{actions &&
{actions}
} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index fa24f9365..52747d26a 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -30,8 +30,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => { + React.ComponentPropsWithoutRef & { closeClassName?: string } +>(({ className, closeClassName, children, ...props }, ref) => { const { t } = useTranslation(); return ( @@ -45,7 +45,7 @@ const DialogContent = React.forwardRef< {...props} > {children} - + {t('common.close')} diff --git a/src/hooks/use-subscription.test.tsx b/src/hooks/use-subscription.test.tsx index 9985c5a4e..2f8051ae1 100644 --- a/src/hooks/use-subscription.test.tsx +++ b/src/hooks/use-subscription.test.tsx @@ -13,8 +13,8 @@ const ACTIVE_TRIAL: Record = { subscription_tier: 'trial', dail let settingsRow: Record = ACTIVE_TRIAL; let subscriptionRows: Record[] = []; -// ai_usage_today: the server's counter. Missing (an older server) by default. -let usageToday: { data: number | null; error: { message: string } | null } = { data: null, error: { message: 'function not found' } }; +// ai_usage_today: the server's weighted counter, the only source of the count. +let usageToday: { data: number | null; error: { message: string } | null } = { data: 1, error: null }; function builder(table: string) { let column = ''; @@ -66,7 +66,7 @@ describe('useSubscription rate limits', () => { beforeEach(() => { orders.length = 0; rateLimitResult = { data: { limit_value: 12 }, error: null }; - usageToday = { data: null, error: { message: 'function not found' } }; + usageToday = { data: 1, error: null }; settingsRow = ACTIVE_TRIAL; subscriptionRows = []; }); @@ -103,6 +103,16 @@ describe('useSubscription rate limits', () => { expect(result.current.dailyUsed).toBe(3); expect(result.current.remainingToday).toBe(9); }); + + // QB-03: Settings counted logged ai_requests rows when the counter could not + // be read, a number the server does not enforce. Unknown is shown as nothing. + it('leaves the count unknown when the counter cannot be read, instead of counting rows', async () => { + usageToday = { data: null, error: { message: 'function not found' } }; + const { result } = renderHook(() => useSubscription(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.dailyLimit).toBe(12); + expect(result.current.remainingToday).toBeNull(); + }); }); // The hook resolves the same state as the AI functions (billing-state.ts); diff --git a/src/hooks/use-subscription.ts b/src/hooks/use-subscription.ts index 2f982a3d5..7fc613bb4 100644 --- a/src/hooks/use-subscription.ts +++ b/src/hooks/use-subscription.ts @@ -149,29 +149,13 @@ async function fetchSubscription(userId: string): Promise else if (typeof ownLimit?.limit_value === 'number') snapshot.dailyLimit = ownLimit.limit_value; } - // The server's own counter: model-weighted units, one charge per Crossfire - // or Sparring round, refunds given back. + // The server's own counter (daily_usage_counters through ai_usage_today): + // model-weighted units, one charge per Crossfire or Sparring round, refunds + // given back. Counting logged rows would disagree with what the server + // enforces, so an unreadable counter leaves the count unknown (QB-03). const counted = await readAIUsageToday(); - if (counted !== null) { - return { ...snapshot, dailyUsed: counted, usageKnown: limitKnown && Boolean(data) }; - } - - // A server from before ai_usage_today: count today's usage rows. Refused - // calls and the jev_* helper steps inside a call log rows too, but use no quota. - const todayStart = new Date(); - todayStart.setUTCHours(0, 0, 0, 0); - const { count, error: usageError } = await supabase - .from('ai_requests') - .select('*', { count: 'exact', head: true }) - .eq('user_id', userId) - .gte('created_at', todayStart.toISOString()) - .neq('status', 'rate_limited') - .not('feature', 'like', 'jev_%'); - if (usageError) { - console.warn('[useSubscription] Failed to load daily usage:', usageError.message); - } - - return { ...snapshot, dailyUsed: usageError ? 0 : count || 0, usageKnown: !usageError && limitKnown && Boolean(data) }; + if (counted === null) return { ...snapshot, usageKnown: false }; + return { ...snapshot, dailyUsed: counted, usageKnown: limitKnown && Boolean(data) }; } catch (error) { console.warn('[useSubscription] Failed to load:', error); throw error; diff --git a/src/lib/ai/error-display.ts b/src/lib/ai/error-display.ts index f900af39d..993bcd468 100644 --- a/src/lib/ai/error-display.ts +++ b/src/lib/ai/error-display.ts @@ -1,5 +1,5 @@ import i18n from '@/i18n'; -import { AIError, AIProviderError, isAINetworkError, isAISubscriptionError } from './errors'; +import { AIError, AIProviderError, isAIModelRestrictionError, isAINetworkError, isAISubscriptionError } from './errors'; import { subscriptionLimitMessage } from './limit-message'; const PROVIDER_LABELS: Record = { @@ -29,6 +29,8 @@ export function getUserFacingAIError(error: unknown): string { function describeAIError(error: unknown): string { if (isAINetworkError(error)) return i18n.t('aiErrors.offline'); if (isAISubscriptionError(error)) return subscriptionLimitMessage(error); + // The server's reason is English only; the Pro dialog explains the rest. + if (isAIModelRestrictionError(error)) return i18n.t('modelModal.restrictedDesc'); if (error instanceof AIProviderError || (error instanceof Error && error.name === 'AIProviderError')) { const provider = providerLabel((error as AIProviderError).provider, error.message); diff --git a/src/lib/ai/errors.ts b/src/lib/ai/errors.ts index cb8159743..b4c3f092d 100644 --- a/src/lib/ai/errors.ts +++ b/src/lib/ai/errors.ts @@ -1,5 +1,5 @@ import i18n from '@/i18n'; -import { localizedLimitMessage } from './limit-message'; +import { subscriptionLimitMessage } from './limit-message'; /** * Base class for AI-related errors with structured information. @@ -355,7 +355,7 @@ function mapAIErrorPayload( current: typeof payload?.current === 'number' ? payload.current : undefined, units: typeof payload?.units === 'number' ? payload.units : undefined, }; - return new AISubscriptionError(localizedLimitMessage(message, details), { statusCode, requestId, ...details }); + return new AISubscriptionError(subscriptionLimitMessage(details), { statusCode, requestId, ...details }); } if (statusCode === 403) { diff --git a/src/lib/ai/features/session-quota.ts b/src/lib/ai/features/session-quota.ts index 688e02e61..5eb1b9c23 100644 --- a/src/lib/ai/features/session-quota.ts +++ b/src/lib/ai/features/session-quota.ts @@ -1,7 +1,7 @@ import type { ProviderConfig } from '../providers/types'; import { getActiveAccessToken, invokeAuthenticatedFunction } from '@/lib/supabase/client'; import { AIModelRestrictionError, AISubscriptionError, type AIError } from '../errors'; -import { isSubscriptionRefusalCode, localizedLimitMessage } from '../limit-message'; +import { isSubscriptionRefusalCode, subscriptionLimitMessage } from '../limit-message'; /** What a new Crossfire or Sparring round costs, and whether today's quota covers it. */ export interface SessionQuota { @@ -78,5 +78,5 @@ export function sessionQuotaError(quota: SessionQuota | null | undefined): AIErr if (quota.code === 'upgrade_required') return new AIModelRestrictionError('This model requires a Pro subscription.'); if (!isSubscriptionRefusalCode(quota.code)) return null; const details = { code: quota.code, limit: quota.limit, current: quota.current, units: quota.units }; - return new AISubscriptionError(localizedLimitMessage('', details), { statusCode: 402, ...details }); + return new AISubscriptionError(subscriptionLimitMessage(details), { statusCode: 402, ...details }); } diff --git a/src/lib/ai/limit-message.test.ts b/src/lib/ai/limit-message.test.ts index 258c059d1..5b5a5879e 100644 --- a/src/lib/ai/limit-message.test.ts +++ b/src/lib/ai/limit-message.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; import i18n from '@/i18n'; import { mapAIError, AISubscriptionError } from './errors'; +import { getUserFacingAIError } from './error-display'; +import { sessionQuotaError } from './features/session-quota'; +import { subscriptionLimitMessage } from './limit-message'; describe('quota refusals are translated, not the server reason', () => { const refuse = (payload: Record) => @@ -23,7 +26,37 @@ describe('quota refusals are translated, not the server reason', () => { expect(error.message).toBe(i18n.t('limitModal.weightedDesc', { units: 5, remaining: 3 })); }); - it('keeps the server text when the refusal carries no code', () => { - expect(refuse({ error: 'Server text' }).message).toBe('Server text'); + // QB-06: a 402 without a code once showed "Edge Function returned a non-2xx status code". + it('never shows the server text, even when the refusal carries no code', () => { + const error = refuse({ error: 'Server text' }); + expect(error.message).toBe(i18n.t('limitModal.limitReachedGenericDesc')); + expect(getUserFacingAIError(error)).not.toMatch(/Server text|non-2xx/); + }); + + it('explains a zero limit instead of "0 of 0 used"', () => { + expect(refuse({ error: 'x', code: 'limit_reached', limit: 0, current: 0 }).message).toBe(i18n.t('limitModal.zeroLimitDesc')); + }); +}); + +// QB-06: the one-shot tools (Generate, Attack, Ask, Brainstorm, Factoids) meet a +// refusal through mapAIError; Crossfire and Sparring also meet it before Start +// through the quota check. Both must read exactly as the limit dialog does. +describe('a refusal reads the same in every mode', () => { + const REFUSALS = [ + { code: 'limit_reached', limit: 5, current: 5, units: 1 }, + { code: 'limit_reached', limit: 50, current: 47, units: 5 }, + { code: 'weekly_budget_reached', limit: 50, current: 20, units: 1 }, + { code: 'trial_ended', limit: 0, current: 0, units: 1 }, + { code: 'payment_failed', limit: 0, current: 0, units: 1 }, + { code: 'subscription_ended', limit: 0, current: 0, units: 1 }, + ]; + + it.each(REFUSALS)('$code ($current of $limit, $units units)', (details) => { + const dialog = subscriptionLimitMessage(details); + const oneShot = mapAIError({ message: 'Edge Function returned a non-2xx status code', context: { status: 402 } }, 'r', 'openrouter', 'm', { error: 'English server reason', ...details }); + const beforeStart = sessionQuotaError({ allowed: false, ...details }); + expect(oneShot.message).toBe(dialog); + expect(beforeStart?.message).toBe(dialog); + expect(getUserFacingAIError(oneShot)).toBe(dialog); }); }); diff --git a/src/lib/ai/limit-message.ts b/src/lib/ai/limit-message.ts index 067ee083c..8974060e4 100644 --- a/src/lib/ai/limit-message.ts +++ b/src/lib/ai/limit-message.ts @@ -1,4 +1,5 @@ import i18n from '@/i18n'; +import { TRIAL_DAYS } from '@/lib/billing/plan'; export interface LimitDetails { code?: string; @@ -26,27 +27,37 @@ export function isSubscriptionRefusalCode(code: unknown): code is string { } /** - * The translated explanation of a quota refusal. AISubscriptionError carries it - * as its message, so a page that shows error.message directly is translated too; - * the server's own reason is English only. + * The translated explanation of a quota refusal, the same in every tool. + * AISubscriptionError carries it as its message, so a page that shows + * error.message directly is translated too. The server's own reason is English + * only and never shown, even when the refusal carries no code. */ export function subscriptionLimitMessage({ code, limit, current, units }: LimitDetails): string { - if (code === 'trial_ended' || code === 'trial_expired') return i18n.t('limitModal.trialEndedDesc'); + if (code === 'trial_ended' || code === 'trial_expired') return i18n.t('limitModal.trialEndedDesc', { days: TRIAL_DAYS }); if (code === 'payment_failed') return i18n.t('limitModal.paymentFailedDesc'); if (code === 'subscription_ended') return i18n.t('limitModal.subscriptionEndedDesc'); if (code === 'weekly_budget_reached') return i18n.t('limitModal.weeklyReachedDesc'); + // An admin can set an account's limit to zero; "0 of 0 used" would explain nothing. + if (limit === 0) return i18n.t('limitModal.zeroLimitDesc'); + if (typeof limit !== 'number') return i18n.t('limitModal.limitReachedGenericDesc'); const weight = units ?? 1; // A model heavier than the whole daily limit never fits, so waiting does not help. - if (weight > 1 && typeof limit === 'number' && weight > limit) { + if (weight > 1 && weight > limit) { return i18n.t('limitModal.weightedTooLargeDesc', { units: weight, limit }); } - const remaining = Math.max(0, (limit || 0) - (current || 0)); + const remaining = Math.max(0, limit - (current || 0)); // A weighted model can be refused while lighter requests still fit today. if (weight > 1 && remaining > 0) return i18n.t('limitModal.weightedDesc', { units: weight, remaining }); - return i18n.t('limitModal.limitReachedDesc', { current: current || 0, limit: limit || 0 }); + // A limit lowered during the day must not read "7 of 5 used". + return i18n.t('limitModal.limitReachedDesc', { current: Math.min(current ?? limit, limit), limit }); } -/** Translated text for a refusal the server tagged with a code; otherwise the server's text. */ -export function localizedLimitMessage(serverMessage: string, details: LimitDetails): string { - return details.code ? subscriptionLimitMessage(details) : serverMessage; +/** + * Why a Sparring round will not fit, from the browser's own count while the + * server's answer is unknown. A round's cost is not always the model's weight + * (an older server charges per call), so this names the round, not the model. + */ +export function shortfallMessage(units: number, remaining: number): string { + if (remaining > 0) return i18n.t('limitModal.roundShortDesc', { units, remaining }); + return i18n.t('limitModal.limitReachedGenericDesc'); } diff --git a/src/lib/ai/request.ts b/src/lib/ai/request.ts index 3e2d987d4..c223aa408 100644 --- a/src/lib/ai/request.ts +++ b/src/lib/ai/request.ts @@ -10,7 +10,7 @@ import { toast } from '@/hooks/use-toast'; import { captureException, captureProductEvent } from '@/lib/observability'; import { isAppLanguage, type AppLanguage } from '@/lib/languages'; import i18n from '@/i18n'; -import { isSubscriptionRefusalCode, localizedLimitMessage } from './limit-message'; +import { isSubscriptionRefusalCode, subscriptionLimitMessage } from './limit-message'; import type { WorkspaceToolContext } from '@/lib/workspace/tool-context'; import { AIError, @@ -36,6 +36,7 @@ import { import { parseSourceAssessments } from './sources'; import { utf8ByteLength } from './text'; import { readLiveResponse, type LiveGeneration } from './live'; +import { refreshAIUsage } from '@/lib/query-client'; // Request timeout in milliseconds (200 seconds) export const REQUEST_TIMEOUT_MS = 200 * 1000; @@ -288,7 +289,7 @@ export async function callSupabaseAI( current: typeof data.current === 'number' ? data.current : undefined, units: typeof data.units === 'number' ? data.units : undefined, }; - throw new AISubscriptionError(localizedLimitMessage(errorMessage, details), { statusCode: 402, requestId, ...details }); + throw new AISubscriptionError(subscriptionLimitMessage(details), { statusCode: 402, requestId, ...details }); } // Model restriction (403 / upgrade_required) @@ -413,6 +414,7 @@ export async function callSupabaseAI( } finally { clearTimeout(timeoutId); options.signal?.removeEventListener('abort', abortFromCaller); + refreshAIUsage(); } } diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts index 23cb062aa..a1a8d33a2 100644 --- a/src/lib/ai/streaming.ts +++ b/src/lib/ai/streaming.ts @@ -6,6 +6,7 @@ import { parseDebateContextUsage, useDebateContextStore } from '@/lib/stores/deb import type { ProviderConfig } from './providers/types'; import { AskOutputSchema, type AskOutput } from './schemas/ask'; import { consumeSSEChunk, type ParsedSSEEvent } from './sse'; +import { refreshAIUsage } from '@/lib/query-client'; export { consumeSSEChunk }; @@ -277,5 +278,6 @@ export async function streamAskChat( handlers.signal?.removeEventListener('abort', abortForCaller); controller.signal.removeEventListener('abort', cancelReader); cancelReader(); + refreshAIUsage(); } } diff --git a/src/lib/billing/plan.ts b/src/lib/billing/plan.ts new file mode 100644 index 000000000..3596efa04 --- /dev/null +++ b/src/lib/billing/plan.ts @@ -0,0 +1,59 @@ +import site from '@/config/site.json'; +import type { BillingStateName, SubscriptionTier } from '@/hooks/use-subscription'; + +/** The trial length, from the same price config the landing page and SEO pages use. */ +export const TRIAL_DAYS = site.pricing.trialDays; + +/** Pro's monthly price from the price config, never typed into a translation. */ +export const PRO_MONTHLY_PRICE = Number(site.pricing.proMonthly); + +/** A plan price in the reader's number format: "$5" in English, "5 $" in Hungarian and Spanish. */ +export function formatPlanPrice(amount: number, locale: string): string { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: site.pricing.currency, + currencyDisplay: 'narrowSymbol', + maximumFractionDigits: Number.isInteger(amount) ? 0 : 2, + }).format(amount); +} + +/** + * The one billing step an account's state calls for, wherever a plan button + * shows (Settings, the pricing dialog): + * - manage: a Stripe subscription can still bill, so the billing portal. A + * second Checkout would charge twice. + * - setUpBilling: Pro without Stripe (a promo), so Checkout to keep it. + * - resubscribe: a former subscriber, so Checkout again. + * - upgrade: everyone else (trial, extended access, ended trial, legacy free). + */ +export type BillingAction = 'manage' | 'setUpBilling' | 'resubscribe' | 'upgrade'; + +export function billingAction({ billingState, hasOpenSubscription, hasStripeBilling }: { + billingState: BillingStateName; + hasOpenSubscription: boolean; + hasStripeBilling: boolean; +}): BillingAction { + const onPro = billingState === 'pro' || billingState === 'pro_canceling'; + if (hasOpenSubscription || (onPro && hasStripeBilling)) return 'manage'; + if (onPro) return 'setUpBilling'; + if (billingState === 'subscription_ended') return 'resubscribe'; + return 'upgrade'; +} + +/** + * The plan the account is on now, for the pricing dialog's Current badge: Pro + * includes a payment grace. Legacy free, extended access and ended plans are + * on neither card, since each is offered Upgrade to Pro. + */ +export function currentPlan(tier: SubscriptionTier): 'trial' | 'pro' | null { + if (tier === 'trial') return 'trial'; + if (tier === 'pro') return 'pro'; + return null; +} + +export const BILLING_ACTION_LABELS: Record = { + manage: 'billing.manageSubscription', + setUpBilling: 'billing.setUpBilling', + resubscribe: 'billing.resubscribe', + upgrade: 'billing.upgradeToPro', +}; diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts new file mode 100644 index 000000000..6aafdd940 --- /dev/null +++ b/src/lib/query-client.ts @@ -0,0 +1,15 @@ +import { QueryClient } from '@tanstack/react-query'; + +/** The app's one query cache, shared with code outside React (the AI transport). */ +export const queryClient = new QueryClient(); + +/** + * Re-reads what is left today after an AI call: the server charged it, gave it + * back, or counted a round, so the usage line on the tool pages, Settings and + * the Crossfire and Sparring cost notes would otherwise show the old count for + * a minute (QB-03). + */ +export function refreshAIUsage(): void { + void queryClient.invalidateQueries({ queryKey: ['subscription'] }); + void queryClient.invalidateQueries({ queryKey: ['session-quota'] }); +} diff --git a/src/locales/en.json b/src/locales/en.json index 03bc89b41..7073d5293 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -454,9 +454,6 @@ "tierExpired": "Expired", "ends": "Ends", "loading": "Loading...", - "manageSubscription": "Manage Subscription", - "setUpBilling": "Set Up Billing", - "upgradeToPro": "Upgrade to Pro", "confirmingSubscription": "Confirming your Pro subscription…", "checkoutCanceled": "Checkout canceled", "checkoutCanceledDesc": "No charge was made.", @@ -534,8 +531,7 @@ "paymentGraceUntil": "Pro access continues until {{date}} while we retry the payment.", "paymentFailedNoAccess": "Payment failed: update your payment method to restore AI access.", "subscriptionEndedOn": "Your Pro subscription ended on {{date}}. Resubscribe to keep using AI tools.", - "subscriptionEnded": "Your Pro subscription has ended. Resubscribe to keep using AI tools.", - "resubscribe": "Resubscribe" + "subscriptionEnded": "Your Pro subscription has ended. Resubscribe to keep using AI tools." }, "login": { "title": "Sign in to DebateLab", @@ -1389,7 +1385,7 @@ }, "limitModal": { "trialEnded": "Free Trial Ended", - "trialEndedDesc": "Your 3-day free trial has ended. Upgrade to Pro to keep using DebateLab.", + "trialEndedDesc": "Your {{days}}-day free trial has ended. Upgrade to Pro to keep using DebateLab.", "limitReached": "Daily Access Paused", "limitReachedDesc": "You've used {{current}} of {{limit}} included AI uses today. Access refreshes at midnight UTC.", "weightedDesc": "This model counts as {{units}} uses, and you have {{remaining}} left today. Pick a lighter model or wait until midnight UTC.", @@ -1397,7 +1393,6 @@ "weeklyReached": "Weekly AI Allowance Used", "weeklyReachedDesc": "You've used this week's AI allowance. It refreshes on Monday at 00:00 UTC. Contact support if you need more.", "ok": "OK", - "upgrade": "Upgrade to Pro - $5/mo", "upgradeNote": "More AI prep access + high reasoning", "notNow": "Not now", "gotIt": "OK", @@ -1407,43 +1402,42 @@ "subscriptionEndedDesc": "Your Pro subscription has ended. Resubscribe to keep using DebateLab.", "subscriptionEndedOnDesc": "Your Pro subscription ended on {{date}}. Resubscribe to keep using DebateLab.", "updatePayment": "Update payment method", - "resubscribe": "Resubscribe" + "limitReachedGenericDesc": "You've used today's included AI uses. Access refreshes at midnight UTC.", + "zeroLimitDesc": "This account has no daily AI uses right now. Contact support if this seems wrong.", + "roundShortDesc": "This round needs {{units}} AI uses and you have {{remaining}} left today. Pick a lighter model or wait until midnight UTC." }, "modelModal": { "title": "Pro Reasoning", "description": "High reasoning is reserved for Pro members.", "descriptionWithModel": "High reasoning with {{model}} is reserved for Pro members.", "upgradeNote": "Upgrade to unlock high reasoning.", - "upgrade": "Upgrade to Pro - $5/mo", - "useFree": "Use low reasoning instead" + "useFree": "Use low reasoning instead", + "restrictedDesc": "This model or reasoning level needs Pro." }, "dailyLimit": { - "usage": "Today: {{used}} of {{limit}} AI requests used. They refresh at midnight UTC ({{time}} your time).", - "sparringNone": "You have no AI requests left today, so the opponent can't answer. They refresh at midnight UTC.", - "sparringShort": "This round costs {{needed}} AI requests on this model and you have {{remaining}} left today. Pick a lighter model or wait until midnight UTC." + "remaining_one": "{{count}} of {{limit}} AI uses left today", + "remaining_other": "{{count}} of {{limit}} AI uses left today", + "remainingWithReset_one": "{{count}} of {{limit}} AI uses left today. They refresh at midnight UTC ({{time}} your time).", + "remainingWithReset_other": "{{count}} of {{limit}} AI uses left today. They refresh at midnight UTC ({{time}} your time)." }, "pricingModal": { "title": "Upgrade to Pro", - "subtitle": "Unlock higher usage limits, all 10 models, and configurable reasoning.", - "free": "Free", + "subtitle": "Unlock higher usage limits, every model, and configurable reasoning.", + "free": "Free trial", "pro": "Pro", "current": "Current", "recommended": "Recommended", - "freePrice": "$0", - "proPrice": "$5", - "freePeriod": "/mo", "proPeriod": "/mo + tax", "freeFeature1": "Starter AI prep access", "freeFeature2": "Starter models", "freeFeature3": "All debate features", - "freeFeature4": "3-day free trial", + "freeFeature4": "No credit card needed", "proFeature1": "Expanded AI prep access", - "proFeature2": "All 10 models, including Claude Opus and Sonnet, GPT-6.1 Sol, and Gemini", + "proFeature2": "Every model, including Claude Opus and Sonnet, GPT-6.1 Sol, and Gemini", "proFeature3": "Deep research with Exa", "proFeature4": "Priority processing", "proFeature5": "All debate features", "redirecting": "Redirecting...", - "upgradeNow": "Upgrade Now", "offlineUpgrade": "You are offline. Connect to the internet to upgrade.", "applying": "Applying...", "apply": "Apply", @@ -1454,7 +1448,15 @@ "codeRedeemed": "Code Redeemed", "invalidCode": "Invalid Code", "redeemError": "Error", - "redeemErrorDesc": "Failed to redeem code" + "redeemErrorDesc": "Failed to redeem code", + "titleYourPlan": "Your plan", + "titleResubscribe": "Resubscribe to Pro", + "subtitleManage": "Change your payment method, see invoices or cancel in the billing portal.", + "subtitleCanceling": "Your Pro plan is set to cancel on {{date}}. You can keep it in the billing portal.", + "subtitlePaymentFailed": "Your last payment failed. Update your payment method in the billing portal to keep Pro.", + "subtitleSetUpBilling": "Your Pro access comes from a promotion. Set up billing to keep Pro after it ends.", + "freePeriod_one": "for {{count}} day", + "freePeriod_other": "for {{count}} days" }, "onboarding": { "welcome": "Welcome to DebateLab! 🎉", @@ -2559,13 +2561,25 @@ "compare": "Compare AI debate tools" }, "sessionQuota": { - "cost_one": "This round costs {{count}} AI request, however many turns it takes.", - "cost_other": "This round costs {{count}} AI requests, however many turns it takes.", + "cost_one": "This round costs {{count}} AI use, however many turns it takes.", + "cost_other": "This round costs {{count}} AI uses, however many turns it takes.", "remaining_one": "You have {{count}} left today.", "remaining_other": "You have {{count}} left today." }, "resultActions": { "savedToHistory": "Saved to History", "saveFailed": "Could not save. Try again." + }, + "billing": { + "upgradeToPro": "Upgrade to Pro", + "manageSubscription": "Manage Subscription", + "setUpBilling": "Set Up Billing", + "resubscribe": "Resubscribe" + }, + "trialBanner": { + "daysLeft_one": "Free trial: {{count}} day left.", + "daysLeft_other": "Free trial: {{count}} days left.", + "endsToday": "Your free trial ends today.", + "dismiss": "Hide the trial reminder" } } diff --git a/src/locales/es.json b/src/locales/es.json index 03604c0cf..2eca8fe96 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -454,9 +454,6 @@ "tierExpired": "Caducado", "ends": "Termina", "loading": "Cargando...", - "manageSubscription": "Gestionar suscripción", - "setUpBilling": "Configurar facturación", - "upgradeToPro": "Mejorar a Pro", "confirmingSubscription": "Confirmando tu suscripción Pro…", "checkoutCanceled": "Pago cancelado", "checkoutCanceledDesc": "No se hizo ningún cargo.", @@ -534,8 +531,7 @@ "paymentGraceUntil": "Conservas el acceso Pro hasta el {{date}} mientras reintentamos el cobro.", "paymentFailedNoAccess": "El pago falló: actualiza tu método de pago para recuperar el acceso a la IA.", "subscriptionEndedOn": "Tu suscripción Pro terminó el {{date}}. Vuelve a suscribirte para seguir usando las herramientas de IA.", - "subscriptionEnded": "Tu suscripción Pro ha terminado. Vuelve a suscribirte para seguir usando las herramientas de IA.", - "resubscribe": "Volver a suscribirme" + "subscriptionEnded": "Tu suscripción Pro ha terminado. Vuelve a suscribirte para seguir usando las herramientas de IA." }, "login": { "title": "Iniciar sesión en DebateLab", @@ -1389,7 +1385,7 @@ }, "limitModal": { "trialEnded": "Prueba gratuita finalizada", - "trialEndedDesc": "Tu prueba gratuita de 3 días ha terminado. Actualiza a Pro para seguir usando DebateLab.", + "trialEndedDesc": "Tu prueba gratuita de {{days}} días ha terminado. Mejora a Pro para seguir usando DebateLab.", "limitReached": "Acceso diario pausado", "limitReachedDesc": "Has usado {{current}} de {{limit}} usos de IA incluidos hoy. El acceso se renueva a medianoche UTC.", "weightedDesc": "Este modelo cuenta como {{units}} usos y te quedan {{remaining}} hoy. Elige un modelo más ligero o espera a la medianoche UTC.", @@ -1397,7 +1393,6 @@ "weeklyReached": "Asignación semanal de IA agotada", "weeklyReachedDesc": "Has usado la asignación de IA de esta semana. Se renueva el lunes a las 00:00 UTC. Contacta con soporte si necesitas más.", "ok": "Entendido", - "upgrade": "Actualizar a Pro - $5/mes", "upgradeNote": "Más acceso a preparación con IA + razonamiento alto", "notNow": "Ahora no", "gotIt": "Entendido", @@ -1407,43 +1402,42 @@ "subscriptionEndedDesc": "Tu suscripción Pro ha terminado. Vuelve a suscribirte para seguir usando DebateLab.", "subscriptionEndedOnDesc": "Tu suscripción Pro terminó el {{date}}. Vuelve a suscribirte para seguir usando DebateLab.", "updatePayment": "Actualizar método de pago", - "resubscribe": "Volver a suscribirme" + "limitReachedGenericDesc": "Has usado los usos de IA incluidos hoy. El acceso se renueva a medianoche UTC.", + "zeroLimitDesc": "Esta cuenta no tiene usos diarios de IA ahora mismo. Contacta con soporte si crees que es un error.", + "roundShortDesc": "Esta ronda necesita {{units}} usos de IA y hoy te quedan {{remaining}}. Elige un modelo más ligero o espera a la medianoche UTC." }, "modelModal": { "title": "Razonamiento Pro", "description": "El razonamiento alto está reservado para miembros Pro.", "descriptionWithModel": "El razonamiento alto con {{model}} está reservado para miembros Pro.", "upgradeNote": "Actualiza para desbloquear el razonamiento alto.", - "upgrade": "Actualizar a Pro - $5/mes", - "useFree": "Usar razonamiento bajo" + "useFree": "Usar razonamiento bajo", + "restrictedDesc": "Este modelo o nivel de razonamiento requiere Pro." }, "dailyLimit": { - "usage": "Hoy: {{used}} de {{limit}} solicitudes de IA usadas. Se renuevan a medianoche UTC ({{time}} en tu hora).", - "sparringNone": "No te quedan solicitudes de IA hoy, así que el oponente no podrá responder. Se renuevan a medianoche UTC.", - "sparringShort": "Con este modelo, esta ronda cuesta {{needed}} solicitudes de IA y hoy te quedan {{remaining}}. Elige un modelo más ligero o espera a la medianoche UTC." + "remaining_one": "Te queda {{count}} de {{limit}} usos de IA hoy", + "remaining_other": "Te quedan {{count}} de {{limit}} usos de IA hoy", + "remainingWithReset_one": "Te queda {{count}} de {{limit}} usos de IA hoy. Se renuevan a medianoche UTC ({{time}} en tu hora).", + "remainingWithReset_other": "Te quedan {{count}} de {{limit}} usos de IA hoy. Se renuevan a medianoche UTC ({{time}} en tu hora)." }, "pricingModal": { - "title": "Actualizar a Pro", - "subtitle": "Desbloquea límites de uso más altos, los 10 modelos y razonamiento configurable.", - "free": "Gratis", + "title": "Mejorar a Pro", + "subtitle": "Desbloquea límites de uso más altos, todos los modelos y razonamiento configurable.", + "free": "Prueba gratis", "pro": "Pro", "current": "Actual", "recommended": "Recomendado", - "freePrice": "$0", - "proPrice": "$5", - "freePeriod": "/mes", "proPeriod": "/mes + impuestos", "freeFeature1": "Acceso inicial a preparación con IA", "freeFeature2": "Modelos iniciales", "freeFeature3": "Todas las funciones de debate", - "freeFeature4": "Prueba gratis de 3 días", + "freeFeature4": "Sin tarjeta de crédito", "proFeature1": "Acceso ampliado a preparación con IA", - "proFeature2": "Los 10 modelos, incluidos Claude Opus y Sonnet, GPT-6.1 Sol y Gemini", + "proFeature2": "Todos los modelos, incluidos Claude Opus y Sonnet, GPT-6.1 Sol y Gemini", "proFeature3": "Investigación profunda con Exa", "proFeature4": "Procesamiento prioritario", "proFeature5": "Todas las funciones de debate", "redirecting": "Redirigiendo...", - "upgradeNow": "Actualizar ahora", "offlineUpgrade": "Estás sin conexión. Conéctate a internet para mejorar tu plan.", "applying": "Aplicando...", "apply": "Aplicar", @@ -1454,7 +1448,15 @@ "codeRedeemed": "Código canjeado", "invalidCode": "Código inválido", "redeemError": "Error", - "redeemErrorDesc": "No se pudo canjear el código" + "redeemErrorDesc": "No se pudo canjear el código", + "titleYourPlan": "Tu plan", + "titleResubscribe": "Vuelve a Pro", + "subtitleManage": "Cambia tu método de pago, consulta tus facturas o cancela en el portal de facturación.", + "subtitleCanceling": "Tu plan Pro se cancelará el {{date}}. Puedes mantenerlo desde el portal de facturación.", + "subtitlePaymentFailed": "Tu último pago falló. Actualiza tu método de pago en el portal de facturación para mantener Pro.", + "subtitleSetUpBilling": "Tu acceso Pro viene de una promoción. Configura la facturación para mantener Pro cuando termine.", + "freePeriod_one": "durante {{count}} día", + "freePeriod_other": "durante {{count}} días" }, "onboarding": { "welcome": "¡Bienvenido a DebateLab! 🎉", @@ -2559,13 +2561,25 @@ "compare": "Comparar herramientas de IA para debate" }, "sessionQuota": { - "cost_one": "Esta ronda cuesta {{count}} solicitud de IA, dure los turnos que dure.", - "cost_other": "Esta ronda cuesta {{count}} solicitudes de IA, dure los turnos que dure.", + "cost_one": "Esta ronda cuesta {{count}} uso de IA, dure los turnos que dure.", + "cost_other": "Esta ronda cuesta {{count}} usos de IA, dure los turnos que dure.", "remaining_one": "Hoy te queda {{count}}.", "remaining_other": "Hoy te quedan {{count}}." }, "resultActions": { "savedToHistory": "Guardado en el historial", "saveFailed": "No se pudo guardar. Inténtalo de nuevo." + }, + "billing": { + "upgradeToPro": "Mejorar a Pro", + "manageSubscription": "Gestionar suscripción", + "setUpBilling": "Configurar facturación", + "resubscribe": "Volver a suscribirme" + }, + "trialBanner": { + "daysLeft_one": "Prueba gratis: queda {{count}} día.", + "daysLeft_other": "Prueba gratis: quedan {{count}} días.", + "endsToday": "Tu prueba gratis termina hoy.", + "dismiss": "Ocultar el aviso de la prueba" } } diff --git a/src/locales/hu.json b/src/locales/hu.json index 9d8dec6cc..2620dad33 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -454,9 +454,6 @@ "tierExpired": "Lejárt", "ends": "Vége", "loading": "Betöltés...", - "manageSubscription": "Előfizetés kezelése", - "setUpBilling": "Számlázás beállítása", - "upgradeToPro": "Váltás Pro-ra", "confirmingSubscription": "A Pro előfizetés megerősítése…", "checkoutCanceled": "Fizetés megszakítva", "checkoutCanceledDesc": "Nem történt terhelés.", @@ -531,11 +528,10 @@ "cancelsOn": "Lemondva, lejár: {{date}}", "endsOn": "Vége: {{date}}", "paymentFailedDesc": "Sikertelen fizetés: frissítsd a fizetési módodat, hogy megtartsd a Pro-t.", - "paymentGraceUntil": "Amíg újrapróbáljuk a terhelést, a Pro-hozzáférésed megmarad eddig: {{date}}.", + "paymentGraceUntil": "Amíg újrapróbáljuk a terhelést, a Pro-hozzáférésed {{date}} napjáig megmarad.", "paymentFailedNoAccess": "Sikertelen fizetés: frissítsd a fizetési módodat az MI-hozzáférés visszaállításához.", "subscriptionEndedOn": "A Pro-előfizetésed véget ért ({{date}}). Fizess elő újra az MI-eszközök további használatához.", - "subscriptionEnded": "A Pro-előfizetésed véget ért. Fizess elő újra az MI-eszközök további használatához.", - "resubscribe": "Újra előfizetek" + "subscriptionEnded": "A Pro-előfizetésed véget ért. Fizess elő újra az MI-eszközök további használatához." }, "login": { "title": "Bejelentkezés a DebateLab-ba", @@ -1389,15 +1385,14 @@ }, "limitModal": { "trialEnded": "Ingyenes próba lejárt", - "trialEndedDesc": "A 3 napos ingyenes próbaidőszakod lejárt. Frissíts Pro-ra a DebateLab további használatához.", + "trialEndedDesc": "A {{days}} napos ingyenes próbaidőszakod lejárt. Válts Pro-ra a DebateLab további használatához.", "limitReached": "Napi hozzáférés szünetel", - "limitReachedDesc": "Ma {{current}} / {{limit}} benne foglalt MI használatot használtál fel. A hozzáférés éjfélkor (UTC) frissül.", + "limitReachedDesc": "Elérted a mai keretet: {{current}} / {{limit}} MI-használatot vettél igénybe. A hozzáférés éjfélkor (UTC) frissül.", "weightedDesc": "Ez a modell {{units}} használatnak számít, és ma még {{remaining}} maradt. Válassz könnyebb modellt, vagy várj éjfélig (UTC).", "weightedTooLargeDesc": "Ez a modell {{units}} használatnak számít, ami több, mint a napi {{limit}} kereted. Válassz könnyebb modellt.", "weeklyReached": "Elfogyott a heti MI keret", "weeklyReachedDesc": "Felhasználtad az e heti MI keretet. Hétfőn 00:00-kor (UTC) frissül. Ha többre van szükséged, írj az ügyfélszolgálatnak.", "ok": "Rendben", - "upgrade": "Frissítés Pro-ra - $5/hó", "upgradeNote": "Több MI felkészülési hozzáférés + magas szintű érvelés", "notNow": "Most nem", "gotIt": "Rendben", @@ -1407,43 +1402,42 @@ "subscriptionEndedDesc": "A Pro-előfizetésed véget ért. Fizess elő újra a DebateLab további használatához.", "subscriptionEndedOnDesc": "A Pro-előfizetésed véget ért ({{date}}). Fizess elő újra a DebateLab további használatához.", "updatePayment": "Fizetési mód frissítése", - "resubscribe": "Újra előfizetek" + "limitReachedGenericDesc": "Elfogyott a mai MI-használati kereted. A hozzáférés éjfélkor (UTC) frissül.", + "zeroLimitDesc": "Ennek a fióknak most nincs napi MI-használati kerete. Ha szerinted ez hiba, írj az ügyfélszolgálatnak.", + "roundShortDesc": "Ez a kör {{units}} MI-használatot igényel, de mára csak {{remaining}} maradt. Válassz könnyebb modellt, vagy várj éjfélig (UTC)." }, "modelModal": { "title": "Pro érvelés", "description": "A magas szintű érvelés csak Pro tagoknak érhető el.", "descriptionWithModel": "A magas szintű érvelés a {{model}} modellel csak Pro tagoknak érhető el.", "upgradeNote": "Frissíts a magas szintű érvelés feloldásához.", - "upgrade": "Frissítés Pro-ra - $5/hó", - "useFree": "Alacsony szintű érvelés használata" + "useFree": "Alacsony szintű érvelés használata", + "restrictedDesc": "Ehhez a modellhez vagy érvelési szinthez Pro kell." }, "dailyLimit": { - "usage": "Ma: {{used}} / {{limit}} MI-kérés felhasználva. Éjfélkor (UTC) frissülnek (nálad {{time}}).", - "sparringNone": "Mára nem maradt MI-kérésed, így az ellenfél nem tud válaszolni. Éjfélkor (UTC) frissülnek.", - "sparringShort": "Ezzel a modellel ez a kör {{needed}} MI-kérésbe kerül, de mára csak {{remaining}} maradt. Válassz könnyebb modellt, vagy várj éjfélig (UTC)." + "remaining_one": "Mára {{count}} / {{limit}} MI-használat maradt", + "remaining_other": "Mára {{count}} / {{limit}} MI-használat maradt", + "remainingWithReset_one": "Mára {{count}} / {{limit}} MI-használat maradt. Éjfélkor (UTC) frissül (nálad {{time}}).", + "remainingWithReset_other": "Mára {{count}} / {{limit}} MI-használat maradt. Éjfélkor (UTC) frissül (nálad {{time}})." }, "pricingModal": { - "title": "Frissítés Pro-ra", - "subtitle": "Oldd fel a magasabb használati keretet, mind a 10 modellt és a beállítható érvelést.", - "free": "Ingyenes", + "title": "Váltás Pro-ra", + "subtitle": "Magasabb használati keret, az összes modell és beállítható érvelés.", + "free": "Ingyenes próba", "pro": "Pro", "current": "Jelenlegi", "recommended": "Ajánlott", - "freePrice": "$0", - "proPrice": "$5", - "freePeriod": "/hó", "proPeriod": "/hó + adó", "freeFeature1": "Kezdő MI felkészülési hozzáférés", "freeFeature2": "Kezdő modellek", "freeFeature3": "Minden vitafunkció", - "freeFeature4": "3 napos ingyenes próba", + "freeFeature4": "Bankkártya nélkül", "proFeature1": "Bővített MI felkészülési hozzáférés", - "proFeature2": "Mind a 10 modell, köztük a Claude Opus és Sonnet, a GPT-6.1 Sol és a Gemini", + "proFeature2": "Az összes modell, köztük a Claude Opus és Sonnet, a GPT-6.1 Sol és a Gemini", "proFeature3": "Mélyreható kutatás Exa-val", "proFeature4": "Elsőbbségi feldolgozás", "proFeature5": "Minden vitafunkció", "redirecting": "Átirányítás...", - "upgradeNow": "Frissítés most", "offlineUpgrade": "Nincs internetkapcsolat. A váltáshoz csatlakozz az internethez.", "applying": "Alkalmazás...", "apply": "Alkalmaz", @@ -1454,7 +1448,15 @@ "codeRedeemed": "Kód beváltva", "invalidCode": "Érvénytelen kód", "redeemError": "Hiba", - "redeemErrorDesc": "Nem sikerült beváltani a kódot" + "redeemErrorDesc": "Nem sikerült beváltani a kódot", + "titleYourPlan": "A csomagod", + "titleResubscribe": "Fizess elő újra a Pro-ra", + "subtitleManage": "A számlázási portálon módosíthatod a fizetési módot, megnézheted a számláidat, vagy lemondhatod az előfizetést.", + "subtitleCanceling": "A Pro-csomagodat lemondtad, {{date}} napján lejár. A számlázási portálon megtarthatod.", + "subtitlePaymentFailed": "Az utolsó fizetésed sikertelen volt. Frissítsd a fizetési módodat a számlázási portálon, hogy megtartsd a Pro-t.", + "subtitleSetUpBilling": "A Pro-hozzáférésed promócióból származik. Állítsd be a számlázást, hogy utána is megmaradjon a Pro.", + "freePeriod_one": "{{count}} napig", + "freePeriod_other": "{{count}} napig" }, "onboarding": { "welcome": "Üdvözlünk a DebateLab-ban! 🎉", @@ -2559,13 +2561,25 @@ "compare": "MI-alapú vitaeszközök összehasonlítása" }, "sessionQuota": { - "cost_one": "Ez a kör {{count}} MI-kérésbe kerül, akárhány fordulóból áll.", - "cost_other": "Ez a kör {{count}} MI-kérésbe kerül, akárhány fordulóból áll.", + "cost_one": "Ez a kör {{count}} MI-használatba kerül, akárhány fordulóból áll.", + "cost_other": "Ez a kör {{count}} MI-használatba kerül, akárhány fordulóból áll.", "remaining_one": "Mára még {{count}} maradt.", "remaining_other": "Mára még {{count}} maradt." }, "resultActions": { "savedToHistory": "Mentve az előzményekbe", "saveFailed": "Nem sikerült menteni. Próbáld újra." + }, + "billing": { + "upgradeToPro": "Váltás Pro-ra", + "manageSubscription": "Előfizetés kezelése", + "setUpBilling": "Számlázás beállítása", + "resubscribe": "Újra előfizetek" + }, + "trialBanner": { + "daysLeft_one": "Ingyenes próba: még {{count}} nap van hátra.", + "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" } } diff --git a/src/pages/AskPage.tsx b/src/pages/AskPage.tsx index facb05988..a45cd0b69 100644 --- a/src/pages/AskPage.tsx +++ b/src/pages/AskPage.tsx @@ -8,7 +8,7 @@ import { askChat, isAIModelRestrictionError, isAIProviderError, isAISubscription import { isAskStreamingSupported, streamAskChat } from '@/lib/ai/streaming'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { getUserFacingAIError } from '@/lib/ai/error-display'; -import { LimitReachedModal, ModelRestrictedModal, PricingModal } from '@/components/features/subscription'; +import { DailyUsage, SubscriptionErrorModals } from '@/components/features/subscription'; import { useToast } from '@/hooks/use-toast'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; @@ -476,7 +476,7 @@ export default function AskPage() {
- + } />
); } diff --git a/src/pages/AttackPage.tsx b/src/pages/AttackPage.tsx index 38bf2b525..b8478d314 100644 --- a/src/pages/AttackPage.tsx +++ b/src/pages/AttackPage.tsx @@ -10,7 +10,7 @@ import { AttackResults } from '@/components/features/attack/AttackResults'; import { attackCase, type DiscoveredSource } from '@/lib/ai/client'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { getUserFacingAIError } from '@/lib/ai/error-display'; -import { LimitReachedModal, ModelRestrictedModal, PricingModal } from '@/components/features/subscription'; +import { DailyUsage, SubscriptionErrorModals } from '@/components/features/subscription'; import type { AttackInput } from '@/lib/ai/schemas/attack'; import { useNavigate } from 'react-router-dom'; import { EmptyState } from '@/components/ui/empty-state'; @@ -110,7 +110,7 @@ export default function AttackPage() {
- + } />
- { subError.setLimitModalOpen(false); subError.setPricingModalOpen(true); }} - /> - { - subError.setModelModalOpen(false); - subError.setPricingModalOpen(true); - }} - /> - +
); } diff --git a/src/pages/BrainstormPage.tsx b/src/pages/BrainstormPage.tsx index 1defcbd4b..010867500 100644 --- a/src/pages/BrainstormPage.tsx +++ b/src/pages/BrainstormPage.tsx @@ -11,7 +11,7 @@ import { BrainstormResults } from '@/components/features/brainstorm/BrainstormRe import { brainstorm, type DiscoveredSource } from '@/lib/ai/client'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { getUserFacingAIError } from '@/lib/ai/error-display'; -import { LimitReachedModal, ModelRestrictedModal, PricingModal } from '@/components/features/subscription'; +import { DailyUsage, SubscriptionErrorModals } from '@/components/features/subscription'; import type { BrainstormInput } from '@/lib/ai/schemas/brainstorm'; import { EmptyState } from '@/components/ui/empty-state'; import { PageHeader } from '@/components/layout/PageHeader'; @@ -111,7 +111,7 @@ export default function BrainstormPage() {
- + } />
); } diff --git a/src/pages/CrossfirePage.tsx b/src/pages/CrossfirePage.tsx index d2e30fd01..c43367a9f 100644 --- a/src/pages/CrossfirePage.tsx +++ b/src/pages/CrossfirePage.tsx @@ -76,7 +76,7 @@ import { useVoiceAvailability } from '@/lib/voice/availability'; import { combineDelivery, measureDelivery } from '@/lib/voice/delivery'; import { opponentVoice } from '@/lib/voice/opponent-voice'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; -import { SessionCostNote, SubscriptionErrorModals } from '@/components/features/subscription'; +import { DailyUsage, SessionCostNote, SubscriptionErrorModals } from '@/components/features/subscription'; import { useSessionQuota } from '@/hooks/use-session-quota'; const SESSION_ID_PREFIX = 'crossfire'; @@ -583,12 +583,14 @@ export default function CrossfirePage() { dispatch({ type: 'debrief-received', debrief: result }); } catch (error) { if (controller.signal.aborted) return; + // The same limit dialog as a turn; the inline error stays so the debrief can be retried. + handleSubscriptionError(error); const classified = classifyError(error); dispatch({ type: 'debrief-failed', errorKind: classified.kind, message: classified.message }); } finally { if (requestRef.current === controller) setLiveDebrief({}); } - }, [modelConfig, roundDebateId, workspace.context]); + }, [handleSubscriptionError, modelConfig, roundDebateId, workspace.context]); const endSession = useCallback((reason: 'manual' | 'timer') => { if (state.phase !== 'active') return; @@ -744,6 +746,7 @@ export default function CrossfirePage() {

{t('crossfire.title')}

+
); diff --git a/src/pages/FactoidsPage.tsx b/src/pages/FactoidsPage.tsx index b15d56a11..f30c17f9b 100644 --- a/src/pages/FactoidsPage.tsx +++ b/src/pages/FactoidsPage.tsx @@ -10,7 +10,7 @@ import { FactoidsResults } from '@/components/features/factoids/FactoidsResults' import { factoids, type DiscoveredSource } from '@/lib/ai/client'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { getUserFacingAIError } from '@/lib/ai/error-display'; -import { LimitReachedModal, ModelRestrictedModal, PricingModal } from '@/components/features/subscription'; +import { DailyUsage, SubscriptionErrorModals } from '@/components/features/subscription'; import type { FactoidsInput } from '@/lib/ai/schemas/factoids'; import { useToast } from '@/hooks/use-toast'; import { EmptyState } from '@/components/ui/empty-state'; @@ -138,7 +138,7 @@ export default function FactoidsPage() {
- + } />
- { subError.setLimitModalOpen(false); subError.setPricingModalOpen(true); }} - /> - { - subError.setModelModalOpen(false); - subError.setPricingModalOpen(true); - }} - /> - +
); } diff --git a/src/pages/GeneratePage.tsx b/src/pages/GeneratePage.tsx index 86adec542..ca4e0a9a8 100644 --- a/src/pages/GeneratePage.tsx +++ b/src/pages/GeneratePage.tsx @@ -12,7 +12,7 @@ import type { GenerateInput } from '@/lib/ai/schemas/generate'; import { useToast } from '@/hooks/use-toast'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { getUserFacingAIError } from '@/lib/ai/error-display'; -import { LimitReachedModal, ModelRestrictedModal, PricingModal } from '@/components/features/subscription'; +import { DailyUsage, SubscriptionErrorModals } from '@/components/features/subscription'; import { caseHasFlowchart, flowchartFromCase } from '@/lib/flowchart'; import { Button } from '@/components/ui/button'; @@ -201,6 +201,8 @@ export default function GeneratePage() { toast({ title: t('generate.regenerated', 'Section regenerated'), description: t('generate.regeneratedDesc', 'The selected section has been refreshed.'), variant: 'success' }); }) .catch((error) => { + // Out of uses: the same dialog as a full generation, not a toast. + if (subError.handleSubscriptionError(error)) return; console.error('Failed to regenerate section:', error); toast({ title: t('generate.failed'), description: getUserFacingAIError(error), variant: 'destructive' }); }) @@ -274,7 +276,7 @@ export default function GeneratePage() {
- + } />