From f17e5ed2ff8d0a4f0da117564932a13dd00f93dc Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 06:31:58 +0000 Subject: [PATCH 1/6] Show usage, limits and plans consistently (T6 work in progress) Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 8 +- .../features/sparring/SparringSetup.tsx | 9 +- .../features/subscription/DailyUsage.tsx | 27 ++++ .../subscription/LimitReachedModal.tsx | 6 +- .../subscription/ModelRestrictedModal.tsx | 6 +- .../features/subscription/PricingModal.tsx | 122 ++++++++++++------ .../subscription/SubscriptionErrorModals.tsx | 2 + .../subscription/SubscriptionStatus.tsx | 17 ++- .../features/subscription/TrialBanner.tsx | 87 +++++++++++++ .../features/subscription/dialog-layout.ts | 7 + src/components/features/subscription/index.ts | 1 + src/components/layout/AppLayout.tsx | 2 + src/components/ui/dialog.tsx | 6 +- src/hooks/use-subscription.test.tsx | 16 ++- src/hooks/use-subscription.ts | 28 +--- src/lib/ai/error-display.ts | 4 +- src/lib/ai/errors.ts | 4 +- src/lib/ai/features/session-quota.ts | 4 +- src/lib/ai/limit-message.test.ts | 37 +++++- src/lib/ai/limit-message.ts | 26 ++-- src/lib/ai/request.ts | 6 +- src/lib/ai/streaming.ts | 2 + src/lib/billing/plan.ts | 59 +++++++++ src/lib/query-client.ts | 15 +++ src/locales/en.json | 58 +++++---- src/locales/es.json | 60 +++++---- src/locales/hu.json | 62 +++++---- src/pages/AskPage.tsx | 26 +--- src/pages/AttackPage.tsx | 26 +--- src/pages/BrainstormPage.tsx | 26 +--- src/pages/CrossfirePage.tsx | 7 +- src/pages/FactoidsPage.tsx | 26 +--- src/pages/GeneratePage.tsx | 28 +--- src/pages/SettingsPage.tsx | 54 ++++---- .../billing/pricing-modal-redirect.test.tsx | 57 +++++++- .../components/LimitReachedModal.test.tsx | 32 ++++- src/test/pages/AskAccountSwitch.test.tsx | 5 +- src/test/pages/AskStreamFallback.test.tsx | 5 +- src/test/pages/CrossfireVoice.test.tsx | 4 + 39 files changed, 640 insertions(+), 337 deletions(-) create mode 100644 src/components/features/subscription/DailyUsage.tsx create mode 100644 src/components/features/subscription/TrialBanner.tsx create mode 100644 src/components/features/subscription/dialog-layout.ts create mode 100644 src/lib/billing/plan.ts create mode 100644 src/lib/query-client.ts 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..e2656f1c7 --- /dev/null +++ b/src/components/features/subscription/DailyUsage.tsx @@ -0,0 +1,27 @@ +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; + const remaining = t('dailyLimit.remaining', { count: remainingToday, limit: dailyLimit }); + return ( +

+ {showReset ? `${remaining}. ${t('dailyLimit.resets', { time: dailyResetTime(i18n.language) })}` : remaining} +

+ ); +} diff --git a/src/components/features/subscription/LimitReachedModal.tsx b/src/components/features/subscription/LimitReachedModal.tsx index 93eb20d28..220029b1c 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 = @@ -54,7 +56,7 @@ export function LimitReachedModal({ open, onOpenChange, variant, limit, current, return ( - +
{accessEnded ? ( @@ -89,7 +91,7 @@ export function LimitReachedModal({ open, onOpenChange, variant, limit, current, <> {!online && (

@@ -162,8 +204,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..0bee689d9 --- /dev/null +++ b/src/components/features/subscription/TrialBanner.tsx @@ -0,0 +1,87 @@ +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 } 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 { billingState, billingDate, billingDateKind } = useSubscription(); + 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/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..86b001f39 100644 --- a/src/lib/ai/limit-message.ts +++ b/src/lib/ai/limit-message.ts @@ -26,27 +26,35 @@ 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 === '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 }); + return i18n.t('limitModal.limitReachedDesc', { current: current ?? 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; +/** + * The same text for a round the browser can tell will not fit before asking + * the server: Sparring warns with it while the server's quota answer is unknown. + */ +export function shortfallMessage(units: number, remaining: number): string { + if (remaining > 0 && units > 1) return i18n.t('limitModal.weightedDesc', { 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..22b4393eb --- /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 served on now, for the pricing dialog's Current + * badge: a payment grace and legacy free still get Pro; extended access and + * ended plans are on neither card. + */ +export function currentPlan(tier: SubscriptionTier): 'trial' | 'pro' | null { + if (tier === 'trial') return 'trial'; + if (tier === 'pro' || tier === 'legacy_free') 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..fa293d224 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", @@ -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,40 @@ "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." }, "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", + "resets": "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 +1446,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 +2559,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..4ffaaa49b 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", @@ -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,40 @@ "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." }, "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", + "resets": "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 +1446,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 +2559,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..33df9a9c6 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.", @@ -534,8 +531,7 @@ "paymentGraceUntil": "Amíg újrapróbáljuk a terhelést, a Pro-hozzáférésed megmarad eddig: {{date}}.", "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", @@ -1391,13 +1387,12 @@ "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.", "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,40 @@ "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." }, "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", + "resets": "É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 +1446,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, lejár: {{date}}. 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 +2559,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..87ebf5007 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')}

+