diff --git a/src/locales/en.json b/src/locales/en.json index e00aaf291..aea69bb8f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -154,10 +154,8 @@ "manageSubscription": "Manage Subscription", "setUpBilling": "Set Up Billing", "upgradeToPro": "Upgrade to Pro", - "dailyUsage": "AI Usage", - "requests": "uses", - "resetsIn": "Resets in {{hours}}h {{minutes}}m", - "usageHelp": "Your AI usage refreshes at midnight UTC. If access pauses, you can wait for the refresh or upgrade.", + "accessActive": "AI access is active. Usage limits may apply.", + "accessExpired": "Your access has expired. Upgrade to continue using AI tools.", "globalSettings": "Global Settings", "account": "Account", "accountDesc": "Signed in with Google. API keys are managed securely on the server.", diff --git a/src/locales/es.json b/src/locales/es.json index d74de0c10..cdf10663e 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -154,10 +154,8 @@ "manageSubscription": "Gestionar suscripción", "setUpBilling": "Configurar facturación", "upgradeToPro": "Mejorar a Pro", - "dailyUsage": "Uso de IA", - "requests": "usos", - "resetsIn": "Se reinicia en {{hours}}h {{minutes}}m", - "usageHelp": "Tu uso de IA se renueva a medianoche UTC. Si el acceso se pausa, puedes esperar la renovación o mejorar el plan.", + "accessActive": "El acceso a la IA está activo. Pueden aplicarse límites de uso.", + "accessExpired": "Tu acceso ha caducado. Mejora el plan para seguir usando las herramientas de IA.", "globalSettings": "Ajustes globales", "account": "Cuenta", "accountDesc": "Sesión iniciada con Google. Las claves API se gestionan de forma segura en el servidor.", diff --git a/src/locales/hu.json b/src/locales/hu.json index 0133e404d..af63033dd 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -154,10 +154,8 @@ "manageSubscription": "Előfizetés kezelése", "setUpBilling": "Számlázás beállítása", "upgradeToPro": "Váltás Pro-ra", - "dailyUsage": "MI használat", - "requests": "használat", - "resetsIn": "Visszaállítás {{hours}}ó {{minutes}}p múlva", - "usageHelp": "Az MI használat éjfélkor (UTC) frissül. Ha a hozzáférés szünetel, várhatsz a frissítésig vagy válthatsz csomagot.", + "accessActive": "Az MI-hozzáférés aktív. Használati korlátok érvényesek lehetnek.", + "accessExpired": "A hozzáférésed lejárt. Válts magasabb csomagra az MI-eszközök további használatához.", "globalSettings": "Globális beállítások", "account": "Fiók", "accountDesc": "Google-fiókkal vagy bejelentkezve. Az API-kulcsokat biztonságosan a szerver kezeli.", diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index a6ee465ff..33eeaf12e 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -3,11 +3,10 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Settings, Lock, Trash2, Crown, CreditCard, Zap, Shield, Info } from 'lucide-react'; +import { Settings, Lock, Trash2, Crown, CreditCard, Zap, Shield } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; -import { Progress } from '@/components/ui/progress'; import { Select, SelectContent, @@ -28,7 +27,6 @@ import { PricingModal } from '@/components/features/subscription/PricingModal'; import { formatEuropeanDate } from '@/lib/date-format'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; const tierLabels: Record = { @@ -121,15 +119,8 @@ export default function SettingsPage() { }; const tierInfo = tierLabels[subscription.tier] || tierLabels.expired; - const usagePercent = subscription.dailyLimit > 0 ? Math.min(100, (subscription.dailyUsed / subscription.dailyLimit) * 100) : 100; const canManageStripeBilling = subscription.tier === 'pro' && subscription.hasStripeBilling; const upgradeLabel = subscription.tier === 'pro' ? t('settings.setUpBilling') : t('settings.upgradeToPro'); - const midnightUtc = new Date(); - midnightUtc.setUTCDate(midnightUtc.getUTCDate() + 1); - midnightUtc.setUTCHours(0, 0, 0, 0); - const resetMs = Math.max(0, midnightUtc.getTime() - Date.now()); - const resetHours = Math.floor(resetMs / 3_600_000); - const resetMinutes = Math.floor((resetMs % 3_600_000) / 60_000); return (
@@ -152,54 +143,42 @@ export default function SettingsPage() {
-
-
- {t(tierInfo.labelKey)} - {subscription.tier === 'trial' && subscription.trialEndsAt && ( - - {t('settings.ends')} {formatEuropeanDate(subscription.trialEndsAt)} - - )} -
- {canManageStripeBilling ? ( - - ) : ( - - )} -
+ {subscription.loading ? ( +

{t('settings.loading')}

+ ) : ( + <> +
+
+ {t(tierInfo.labelKey)} + {subscription.tier === 'trial' && subscription.trialEndsAt && ( + + {t('settings.ends')} {formatEuropeanDate(subscription.trialEndsAt)} + + )} +
+ {canManageStripeBilling ? ( + + ) : ( + + )} +
- {/* Usage Bar */} -
-
- {t('settings.dailyUsage')} - - {subscription.dailyUsed} / {subscription.dailyLimit} {t('settings.requests')} - -
- - - - -

- {t('settings.resetsIn', { hours: resetHours, minutes: resetMinutes })} - -

-
- {t('settings.usageHelp')} -
-
-
+

+ {t(subscription.tier === 'expired' ? 'settings.accessExpired' : 'settings.accessActive')} +

+ + )}
diff --git a/src/test/components/LimitReachedModal.test.tsx b/src/test/components/LimitReachedModal.test.tsx new file mode 100644 index 000000000..9757b7246 --- /dev/null +++ b/src/test/components/LimitReachedModal.test.tsx @@ -0,0 +1,31 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import i18n from '@/i18n'; +import { LimitReachedModal } from '@/components/features/subscription/LimitReachedModal'; + +beforeEach(async () => { + await i18n.changeLanguage('en'); +}); + +afterEach(() => cleanup()); + +describe('LimitReachedModal', () => { + it('shows server-provided usage details and keeps the upgrade action', () => { + const onUpgrade = vi.fn(); + + render( + , + ); + + expect(screen.getByText("You've used 7 of 10 included AI uses today. Access refreshes at midnight UTC.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Upgrade to Pro - $5/mo' })); + expect(onUpgrade).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/pages/SettingsPage.test.tsx b/src/test/pages/SettingsPage.test.tsx new file mode 100644 index 000000000..83720ebc5 --- /dev/null +++ b/src/test/pages/SettingsPage.test.tsx @@ -0,0 +1,102 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import i18n from '@/i18n'; +import SettingsPage from '@/pages/SettingsPage'; + +const mocks = vi.hoisted(() => ({ + subscription: { + tier: 'trial', + loading: false, + isPro: false, + dailyUsed: 4, + dailyLimit: 5, + trialEndsAt: '2026-10-01T00:00:00.000Z', + hasStripeBilling: false, + refresh: vi.fn(), + }, + redirectToPortal: vi.fn(), +})); + +vi.mock('@/lib/supabase/AuthProvider', () => ({ + useAuth: () => ({ user: null as { id: string; email: string } | null, signOut: vi.fn() }), +})); +vi.mock('@/lib/supabase/client', () => ({ supabase: { from: vi.fn() } })); +vi.mock('@/lib/stores/settings-store', () => ({ useSettingsStore: () => ({ + language: 'en', outputLanguage: 'en', theme: 'dark', + setLanguage: vi.fn(), setOutputLanguage: vi.fn(), setTheme: vi.fn(), +}) })); +vi.mock('@/lib/stores/session-store', () => ({ useSessionStore: () => ({ clearSession: vi.fn(), clearAllData: vi.fn() }) })); +vi.mock('@/lib/stores/sparring-store', () => ({ useSparringStore: () => vi.fn() })); +vi.mock('@/components/features/subscription/PricingModal', () => ({ + PricingModal: ({ open }: { open: boolean }) => open ?
Pricing open
: null, +})); +vi.mock('@/hooks/use-subscription', () => ({ useSubscription: () => mocks.subscription })); +vi.mock('@/lib/stripe', () => ({ redirectToPortal: mocks.redirectToPortal })); + +function renderSettings() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +beforeEach(async () => { + await i18n.changeLanguage('en'); + Object.assign(mocks.subscription, { tier: 'trial', loading: false, hasStripeBilling: false }); + mocks.redirectToPortal.mockReset().mockResolvedValue(undefined); +}); + +afterEach(() => cleanup()); + +describe('Settings subscription card', () => { + it.each([ + ['trial', false, 'Free Trial', 'AI access is active. Usage limits may apply.', 'Upgrade to Pro'], + ['pro', true, 'Pro', 'AI access is active. Usage limits may apply.', 'Manage Subscription'], + ['expired', false, 'Expired', 'Your access has expired. Upgrade to continue using AI tools.', 'Upgrade to Pro'], + ])('shows non-numeric access status for %s users', (tier, hasStripeBilling, badge, status, action) => { + Object.assign(mocks.subscription, { tier, hasStripeBilling }); + + renderSettings(); + + expect(screen.getByText(badge)).toBeInTheDocument(); + expect(screen.getByText(status)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: action })).toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + expect(screen.queryByText('4 / 5 uses')).not.toBeInTheDocument(); + expect(screen.queryByText(/Resets in/)).not.toBeInTheDocument(); + if (tier === 'trial') expect(screen.getByText('Ends 01/10/2026')).toBeInTheDocument(); + }); + + it('does not claim access is active while the subscription is loading', () => { + Object.assign(mocks.subscription, { loading: true }); + + renderSettings(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(screen.queryByText('AI access is active. Usage limits may apply.')).not.toBeInTheDocument(); + expect(screen.queryByText('Free Trial')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Upgrade to Pro' })).not.toBeInTheDocument(); + }); + + it('opens the upgrade flow', () => { + Object.assign(mocks.subscription, { tier: 'expired' }); + renderSettings(); + + fireEvent.click(screen.getByRole('button', { name: 'Upgrade to Pro' })); + + expect(screen.getByText('Pricing open')).toBeInTheDocument(); + }); + + it('opens billing management for Stripe-backed Pro users', async () => { + Object.assign(mocks.subscription, { tier: 'pro', hasStripeBilling: true }); + renderSettings(); + + fireEvent.click(screen.getByRole('button', { name: 'Manage Subscription' })); + + await waitFor(() => expect(mocks.redirectToPortal).toHaveBeenCalledOnce()); + }); +});