From 21cdbdde1530907a2cfca847f39366a071f692e7 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 19 Sep 2026 19:55:57 +0000 Subject: [PATCH 1/3] fix: simplify settings access status --- src/locales/en.json | 6 +- src/locales/es.json | 6 +- src/locales/hu.json | 6 +- src/pages/SettingsPage.tsx | 35 ++-------- .../components/LimitReachedModal.test.tsx | 31 +++++++++ src/test/pages/SettingsPage.test.tsx | 67 +++++++++++++++++++ 6 files changed, 108 insertions(+), 43 deletions(-) create mode 100644 src/test/components/LimitReachedModal.test.tsx create mode 100644 src/test/pages/SettingsPage.test.tsx 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..c36dded7a 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 (
@@ -179,27 +170,9 @@ export default function SettingsPage() { )}
- {/* 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..16c302803 --- /dev/null +++ b/src/test/pages/SettingsPage.test.tsx @@ -0,0 +1,67 @@ +import { cleanup, render, screen } 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(), + }, +})); + +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: (): null => null })); +vi.mock('@/hooks/use-subscription', () => ({ useSubscription: () => mocks.subscription })); +vi.mock('@/lib/stripe', () => ({ redirectToPortal: vi.fn() })); + +function renderSettings() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +beforeEach(async () => { + await i18n.changeLanguage('en'); +}); + +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(); + }); +}); From 825591e36a1e888a7b2c5565b94b0dd710a29b9e Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 19 Sep 2026 20:17:10 +0000 Subject: [PATCH 2/3] fix: avoid active access copy while loading --- src/pages/SettingsPage.tsx | 4 ++- src/test/pages/SettingsPage.test.tsx | 39 +++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index c36dded7a..eb2efaeea 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -171,7 +171,9 @@ export default function SettingsPage() {

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

diff --git a/src/test/pages/SettingsPage.test.tsx b/src/test/pages/SettingsPage.test.tsx index 16c302803..ecf65fa84 100644 --- a/src/test/pages/SettingsPage.test.tsx +++ b/src/test/pages/SettingsPage.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@testing-library/react'; +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'; @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ hasStripeBilling: false, refresh: vi.fn(), }, + redirectToPortal: vi.fn(), })); vi.mock('@/lib/supabase/AuthProvider', () => ({ @@ -28,9 +29,11 @@ vi.mock('@/lib/stores/settings-store', () => ({ useSettingsStore: () => ({ }) })); 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: (): null => null })); +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: vi.fn() })); +vi.mock('@/lib/stripe', () => ({ redirectToPortal: mocks.redirectToPortal })); function renderSettings() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -43,6 +46,8 @@ function renderSettings() { beforeEach(async () => { await i18n.changeLanguage('en'); + Object.assign(mocks.subscription, { tier: 'trial', loading: false, hasStripeBilling: false }); + mocks.redirectToPortal.mockReset().mockResolvedValue(undefined); }); afterEach(() => cleanup()); @@ -63,5 +68,33 @@ describe('Settings subscription card', () => { 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(); + }); + + 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()); }); }); From be79eafce18d89ed079fb5be763556d8fa8516e5 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 19 Sep 2026 20:28:29 +0000 Subject: [PATCH 3/3] fix: hide placeholder subscription state --- src/pages/SettingsPage.tsx | 66 +++++++++++++++------------- src/test/pages/SettingsPage.test.tsx | 2 + 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index eb2efaeea..33eeaf12e 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -143,38 +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 ? ( + + ) : ( + + )} +
-

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

+

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

+ + )}
diff --git a/src/test/pages/SettingsPage.test.tsx b/src/test/pages/SettingsPage.test.tsx index ecf65fa84..83720ebc5 100644 --- a/src/test/pages/SettingsPage.test.tsx +++ b/src/test/pages/SettingsPage.test.tsx @@ -78,6 +78,8 @@ describe('Settings subscription card', () => { 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', () => {