Skip to content

Commit b2ba178

Browse files
authored
fix: simplify Settings access status (#48)
* fix: simplify settings access status * fix: avoid active access copy while loading * fix: hide placeholder subscription state
1 parent cc93caf commit b2ba178

6 files changed

Lines changed: 175 additions & 69 deletions

File tree

‎src/locales/en.json‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,8 @@
154154
"manageSubscription": "Manage Subscription",
155155
"setUpBilling": "Set Up Billing",
156156
"upgradeToPro": "Upgrade to Pro",
157-
"dailyUsage": "AI Usage",
158-
"requests": "uses",
159-
"resetsIn": "Resets in {{hours}}h {{minutes}}m",
160-
"usageHelp": "Your AI usage refreshes at midnight UTC. If access pauses, you can wait for the refresh or upgrade.",
157+
"accessActive": "AI access is active. Usage limits may apply.",
158+
"accessExpired": "Your access has expired. Upgrade to continue using AI tools.",
161159
"globalSettings": "Global Settings",
162160
"account": "Account",
163161
"accountDesc": "Signed in with Google. API keys are managed securely on the server.",

‎src/locales/es.json‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,8 @@
154154
"manageSubscription": "Gestionar suscripción",
155155
"setUpBilling": "Configurar facturación",
156156
"upgradeToPro": "Mejorar a Pro",
157-
"dailyUsage": "Uso de IA",
158-
"requests": "usos",
159-
"resetsIn": "Se reinicia en {{hours}}h {{minutes}}m",
160-
"usageHelp": "Tu uso de IA se renueva a medianoche UTC. Si el acceso se pausa, puedes esperar la renovación o mejorar el plan.",
157+
"accessActive": "El acceso a la IA está activo. Pueden aplicarse límites de uso.",
158+
"accessExpired": "Tu acceso ha caducado. Mejora el plan para seguir usando las herramientas de IA.",
161159
"globalSettings": "Ajustes globales",
162160
"account": "Cuenta",
163161
"accountDesc": "Sesión iniciada con Google. Las claves API se gestionan de forma segura en el servidor.",

‎src/locales/hu.json‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,8 @@
154154
"manageSubscription": "Előfizetés kezelése",
155155
"setUpBilling": "Számlázás beállítása",
156156
"upgradeToPro": "Váltás Pro-ra",
157-
"dailyUsage": "MI használat",
158-
"requests": "használat",
159-
"resetsIn": "Visszaállítás {{hours}}ó {{minutes}}p múlva",
160-
"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.",
157+
"accessActive": "Az MI-hozzáférés aktív. Használati korlátok érvényesek lehetnek.",
158+
"accessExpired": "A hozzáférésed lejárt. Válts magasabb csomagra az MI-eszközök további használatához.",
161159
"globalSettings": "Globális beállítások",
162160
"account": "Fiók",
163161
"accountDesc": "Google-fiókkal vagy bejelentkezve. Az API-kulcsokat biztonságosan a szerver kezeli.",

‎src/pages/SettingsPage.tsx‎

Lines changed: 36 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ import { useState } from 'react';
33
import { useQuery } from '@tanstack/react-query';
44
import { Link } from 'react-router-dom';
55
import { useTranslation } from 'react-i18next';
6-
import { Settings, Lock, Trash2, Crown, CreditCard, Zap, Shield, Info } from 'lucide-react';
6+
import { Settings, Lock, Trash2, Crown, CreditCard, Zap, Shield } from 'lucide-react';
77
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
88
import { Label } from '@/components/ui/label';
99
import { Button } from '@/components/ui/button';
10-
import { Progress } from '@/components/ui/progress';
1110
import {
1211
Select,
1312
SelectContent,
@@ -28,7 +27,6 @@ import { PricingModal } from '@/components/features/subscription/PricingModal';
2827
import { formatEuropeanDate } from '@/lib/date-format';
2928
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
3029
import { Input } from '@/components/ui/input';
31-
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
3230

3331

3432
const tierLabels: Record<string, { labelKey: string; color: string }> = {
@@ -121,15 +119,8 @@ export default function SettingsPage() {
121119
};
122120

123121
const tierInfo = tierLabels[subscription.tier] || tierLabels.expired;
124-
const usagePercent = subscription.dailyLimit > 0 ? Math.min(100, (subscription.dailyUsed / subscription.dailyLimit) * 100) : 100;
125122
const canManageStripeBilling = subscription.tier === 'pro' && subscription.hasStripeBilling;
126123
const upgradeLabel = subscription.tier === 'pro' ? t('settings.setUpBilling') : t('settings.upgradeToPro');
127-
const midnightUtc = new Date();
128-
midnightUtc.setUTCDate(midnightUtc.getUTCDate() + 1);
129-
midnightUtc.setUTCHours(0, 0, 0, 0);
130-
const resetMs = Math.max(0, midnightUtc.getTime() - Date.now());
131-
const resetHours = Math.floor(resetMs / 3_600_000);
132-
const resetMinutes = Math.floor((resetMs % 3_600_000) / 60_000);
133124

134125
return (
135126
<div className="container py-8 px-4">
@@ -152,54 +143,42 @@ export default function SettingsPage() {
152143
</div>
153144
</CardHeader>
154145
<CardContent className="space-y-4">
155-
<div className="flex items-center justify-between">
156-
<div className="flex items-center gap-3">
157-
<Badge className={`${tierInfo.color} text-white`}>{t(tierInfo.labelKey)}</Badge>
158-
{subscription.tier === 'trial' && subscription.trialEndsAt && (
159-
<span className="text-sm text-muted-foreground">
160-
{t('settings.ends')} {formatEuropeanDate(subscription.trialEndsAt)}
161-
</span>
162-
)}
163-
</div>
164-
{canManageStripeBilling ? (
165-
<Button
166-
variant="outline"
167-
size="sm"
168-
onClick={() => void handleManageSubscription()}
169-
disabled={portalLoading}
170-
>
171-
<CreditCard className="h-4 w-4 mr-2" />
172-
{portalLoading ? t('settings.loading') : t('settings.manageSubscription')}
173-
</Button>
174-
) : (
175-
<Button size="sm" onClick={() => setPricingOpen(true)}>
176-
<Zap className="h-4 w-4 mr-2" />
177-
{upgradeLabel}
178-
</Button>
179-
)}
180-
</div>
146+
{subscription.loading ? (
147+
<p role="status" className="text-sm text-muted-foreground">{t('settings.loading')}</p>
148+
) : (
149+
<>
150+
<div className="flex items-center justify-between">
151+
<div className="flex items-center gap-3">
152+
<Badge className={`${tierInfo.color} text-white`}>{t(tierInfo.labelKey)}</Badge>
153+
{subscription.tier === 'trial' && subscription.trialEndsAt && (
154+
<span className="text-sm text-muted-foreground">
155+
{t('settings.ends')} {formatEuropeanDate(subscription.trialEndsAt)}
156+
</span>
157+
)}
158+
</div>
159+
{canManageStripeBilling ? (
160+
<Button
161+
variant="outline"
162+
size="sm"
163+
onClick={() => void handleManageSubscription()}
164+
disabled={portalLoading}
165+
>
166+
<CreditCard className="h-4 w-4 mr-2" />
167+
{portalLoading ? t('settings.loading') : t('settings.manageSubscription')}
168+
</Button>
169+
) : (
170+
<Button size="sm" onClick={() => setPricingOpen(true)}>
171+
<Zap className="h-4 w-4 mr-2" />
172+
{upgradeLabel}
173+
</Button>
174+
)}
175+
</div>
181176

182-
{/* Usage Bar */}
183-
<div className="space-y-2">
184-
<div className="flex justify-between text-sm">
185-
<span className="text-muted-foreground">{t('settings.dailyUsage')}</span>
186-
<span className="font-medium">
187-
{subscription.dailyUsed} / {subscription.dailyLimit} {t('settings.requests')}
188-
</span>
189-
</div>
190-
<Progress value={usagePercent} className="h-2" />
191-
<TooltipProvider>
192-
<Tooltip>
193-
<TooltipTrigger asChild>
194-
<p className="inline-flex items-center gap-1 text-xs text-muted-foreground">
195-
{t('settings.resetsIn', { hours: resetHours, minutes: resetMinutes })}
196-
<Info className="h-3 w-3" />
197-
</p>
198-
</TooltipTrigger>
199-
<TooltipContent>{t('settings.usageHelp')}</TooltipContent>
200-
</Tooltip>
201-
</TooltipProvider>
202-
</div>
177+
<p className="text-sm text-muted-foreground">
178+
{t(subscription.tier === 'expired' ? 'settings.accessExpired' : 'settings.accessActive')}
179+
</p>
180+
</>
181+
)}
203182
</CardContent>
204183
</Card>
205184

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import i18n from '@/i18n';
4+
import { LimitReachedModal } from '@/components/features/subscription/LimitReachedModal';
5+
6+
beforeEach(async () => {
7+
await i18n.changeLanguage('en');
8+
});
9+
10+
afterEach(() => cleanup());
11+
12+
describe('LimitReachedModal', () => {
13+
it('shows server-provided usage details and keeps the upgrade action', () => {
14+
const onUpgrade = vi.fn();
15+
16+
render(
17+
<LimitReachedModal
18+
open
19+
onOpenChange={vi.fn()}
20+
variant="limit_reached"
21+
current={7}
22+
limit={10}
23+
onUpgrade={onUpgrade}
24+
/>,
25+
);
26+
27+
expect(screen.getByText("You've used 7 of 10 included AI uses today. Access refreshes at midnight UTC.")).toBeInTheDocument();
28+
fireEvent.click(screen.getByRole('button', { name: 'Upgrade to Pro - $5/mo' }));
29+
expect(onUpgrade).toHaveBeenCalledOnce();
30+
});
31+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3+
import { MemoryRouter } from 'react-router-dom';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import i18n from '@/i18n';
6+
import SettingsPage from '@/pages/SettingsPage';
7+
8+
const mocks = vi.hoisted(() => ({
9+
subscription: {
10+
tier: 'trial',
11+
loading: false,
12+
isPro: false,
13+
dailyUsed: 4,
14+
dailyLimit: 5,
15+
trialEndsAt: '2026-10-01T00:00:00.000Z',
16+
hasStripeBilling: false,
17+
refresh: vi.fn(),
18+
},
19+
redirectToPortal: vi.fn(),
20+
}));
21+
22+
vi.mock('@/lib/supabase/AuthProvider', () => ({
23+
useAuth: () => ({ user: null as { id: string; email: string } | null, signOut: vi.fn() }),
24+
}));
25+
vi.mock('@/lib/supabase/client', () => ({ supabase: { from: vi.fn() } }));
26+
vi.mock('@/lib/stores/settings-store', () => ({ useSettingsStore: () => ({
27+
language: 'en', outputLanguage: 'en', theme: 'dark',
28+
setLanguage: vi.fn(), setOutputLanguage: vi.fn(), setTheme: vi.fn(),
29+
}) }));
30+
vi.mock('@/lib/stores/session-store', () => ({ useSessionStore: () => ({ clearSession: vi.fn(), clearAllData: vi.fn() }) }));
31+
vi.mock('@/lib/stores/sparring-store', () => ({ useSparringStore: () => vi.fn() }));
32+
vi.mock('@/components/features/subscription/PricingModal', () => ({
33+
PricingModal: ({ open }: { open: boolean }) => open ? <div>Pricing open</div> : null,
34+
}));
35+
vi.mock('@/hooks/use-subscription', () => ({ useSubscription: () => mocks.subscription }));
36+
vi.mock('@/lib/stripe', () => ({ redirectToPortal: mocks.redirectToPortal }));
37+
38+
function renderSettings() {
39+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
40+
return render(
41+
<QueryClientProvider client={client}>
42+
<MemoryRouter><SettingsPage /></MemoryRouter>
43+
</QueryClientProvider>,
44+
);
45+
}
46+
47+
beforeEach(async () => {
48+
await i18n.changeLanguage('en');
49+
Object.assign(mocks.subscription, { tier: 'trial', loading: false, hasStripeBilling: false });
50+
mocks.redirectToPortal.mockReset().mockResolvedValue(undefined);
51+
});
52+
53+
afterEach(() => cleanup());
54+
55+
describe('Settings subscription card', () => {
56+
it.each([
57+
['trial', false, 'Free Trial', 'AI access is active. Usage limits may apply.', 'Upgrade to Pro'],
58+
['pro', true, 'Pro', 'AI access is active. Usage limits may apply.', 'Manage Subscription'],
59+
['expired', false, 'Expired', 'Your access has expired. Upgrade to continue using AI tools.', 'Upgrade to Pro'],
60+
])('shows non-numeric access status for %s users', (tier, hasStripeBilling, badge, status, action) => {
61+
Object.assign(mocks.subscription, { tier, hasStripeBilling });
62+
63+
renderSettings();
64+
65+
expect(screen.getByText(badge)).toBeInTheDocument();
66+
expect(screen.getByText(status)).toBeInTheDocument();
67+
expect(screen.getByRole('button', { name: action })).toBeInTheDocument();
68+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
69+
expect(screen.queryByText('4 / 5 uses')).not.toBeInTheDocument();
70+
expect(screen.queryByText(/Resets in/)).not.toBeInTheDocument();
71+
if (tier === 'trial') expect(screen.getByText('Ends 01/10/2026')).toBeInTheDocument();
72+
});
73+
74+
it('does not claim access is active while the subscription is loading', () => {
75+
Object.assign(mocks.subscription, { loading: true });
76+
77+
renderSettings();
78+
79+
expect(screen.getByText('Loading...')).toBeInTheDocument();
80+
expect(screen.queryByText('AI access is active. Usage limits may apply.')).not.toBeInTheDocument();
81+
expect(screen.queryByText('Free Trial')).not.toBeInTheDocument();
82+
expect(screen.queryByRole('button', { name: 'Upgrade to Pro' })).not.toBeInTheDocument();
83+
});
84+
85+
it('opens the upgrade flow', () => {
86+
Object.assign(mocks.subscription, { tier: 'expired' });
87+
renderSettings();
88+
89+
fireEvent.click(screen.getByRole('button', { name: 'Upgrade to Pro' }));
90+
91+
expect(screen.getByText('Pricing open')).toBeInTheDocument();
92+
});
93+
94+
it('opens billing management for Stripe-backed Pro users', async () => {
95+
Object.assign(mocks.subscription, { tier: 'pro', hasStripeBilling: true });
96+
renderSettings();
97+
98+
fireEvent.click(screen.getByRole('button', { name: 'Manage Subscription' }));
99+
100+
await waitFor(() => expect(mocks.redirectToPortal).toHaveBeenCalledOnce());
101+
});
102+
});

0 commit comments

Comments
 (0)