Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
6 changes: 2 additions & 4 deletions src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
6 changes: 2 additions & 4 deletions src/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
93 changes: 36 additions & 57 deletions src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, { labelKey: string; color: string }> = {
Expand Down Expand Up @@ -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 (
<div className="container py-8 px-4">
Expand All @@ -152,54 +143,42 @@ export default function SettingsPage() {
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge className={`${tierInfo.color} text-white`}>{t(tierInfo.labelKey)}</Badge>
{subscription.tier === 'trial' && subscription.trialEndsAt && (
<span className="text-sm text-muted-foreground">
{t('settings.ends')} {formatEuropeanDate(subscription.trialEndsAt)}
</span>
)}
</div>
{canManageStripeBilling ? (
<Button
variant="outline"
size="sm"
onClick={() => void handleManageSubscription()}
disabled={portalLoading}
>
<CreditCard className="h-4 w-4 mr-2" />
{portalLoading ? t('settings.loading') : t('settings.manageSubscription')}
</Button>
) : (
<Button size="sm" onClick={() => setPricingOpen(true)}>
<Zap className="h-4 w-4 mr-2" />
{upgradeLabel}
</Button>
)}
</div>
{subscription.loading ? (
<p role="status" className="text-sm text-muted-foreground">{t('settings.loading')}</p>
) : (
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge className={`${tierInfo.color} text-white`}>{t(tierInfo.labelKey)}</Badge>
{subscription.tier === 'trial' && subscription.trialEndsAt && (
<span className="text-sm text-muted-foreground">
{t('settings.ends')} {formatEuropeanDate(subscription.trialEndsAt)}
</span>
)}
</div>
{canManageStripeBilling ? (
<Button
variant="outline"
size="sm"
onClick={() => void handleManageSubscription()}
disabled={portalLoading}
>
<CreditCard className="h-4 w-4 mr-2" />
{portalLoading ? t('settings.loading') : t('settings.manageSubscription')}
</Button>
) : (
<Button size="sm" onClick={() => setPricingOpen(true)}>
<Zap className="h-4 w-4 mr-2" />
{upgradeLabel}
</Button>
)}
</div>

{/* Usage Bar */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('settings.dailyUsage')}</span>
<span className="font-medium">
{subscription.dailyUsed} / {subscription.dailyLimit} {t('settings.requests')}
</span>
</div>
<Progress value={usagePercent} className="h-2" />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<p className="inline-flex items-center gap-1 text-xs text-muted-foreground">
{t('settings.resetsIn', { hours: resetHours, minutes: resetMinutes })}
<Info className="h-3 w-3" />
</p>
</TooltipTrigger>
<TooltipContent>{t('settings.usageHelp')}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p className="text-sm text-muted-foreground">
{t(subscription.tier === 'expired' ? 'settings.accessExpired' : 'settings.accessActive')}
</p>
</>
)}
</CardContent>
</Card>

Expand Down
31 changes: 31 additions & 0 deletions src/test/components/LimitReachedModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<LimitReachedModal
open
onOpenChange={vi.fn()}
variant="limit_reached"
current={7}
limit={10}
onUpgrade={onUpgrade}
/>,
);

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();
});
});
102 changes: 102 additions & 0 deletions src/test/pages/SettingsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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 ? <div>Pricing open</div> : 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(
<QueryClientProvider client={client}>
<MemoryRouter><SettingsPage /></MemoryRouter>
</QueryClientProvider>,
);
}

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());
});
});
Loading