Public Access
feat(library): Steam wishlist tab with prices and discounts
The library page gets a Bibliothek | Wunschliste tab bar. The wishlist shows the current store price, the discount and can show only games on sale.
This commit is contained in:
1 parent
89c5ba5253
commit
aa5124ea1e
8 files changed
+281
-5
No files matched your search
@@ -207,5 +207,14 @@
|
|||||||
"MEDIUM": "Mittel",
|
"MEDIUM": "Mittel",
|
||||||
"LOW": "Niedrig"
|
"LOW": "Niedrig"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"WISHLIST": {
|
||||||
|
"TITLE": "Wunschliste",
|
||||||
|
"ONLY_ON_SALE": "Nur im Angebot",
|
||||||
|
"EMPTY": "Deine Steam-Wunschliste ist leer oder privat. Stelle sie in Steam auf öffentlich; der nächste Sync holt sie.",
|
||||||
|
"NONE_ON_SALE": "Gerade ist nichts von deiner Wunschliste im Angebot.",
|
||||||
|
"NO_PRICE": "Noch kein Preis",
|
||||||
|
"ADDED": "Hinzugefügt am {{date}}",
|
||||||
|
"ERROR_LOAD": "Wunschliste konnte nicht geladen werden."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,5 +207,14 @@
|
|||||||
"MEDIUM": "Medium",
|
"MEDIUM": "Medium",
|
||||||
"LOW": "Low"
|
"LOW": "Low"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"WISHLIST": {
|
||||||
|
"TITLE": "Wishlist",
|
||||||
|
"ONLY_ON_SALE": "On sale only",
|
||||||
|
"EMPTY": "Your Steam wishlist is empty or private. Make it public in Steam; the next sync fetches it.",
|
||||||
|
"NONE_ON_SALE": "Nothing on your wishlist is on sale right now.",
|
||||||
|
"NO_PRICE": "No price yet",
|
||||||
|
"ADDED": "Added {{date}}",
|
||||||
|
"ERROR_LOAD": "Could not load the wishlist."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import RegistrationPage from './pages/RegistrationPage.jsx';
|
|||||||
import SettingsPage from './pages/SettingsPage.jsx';
|
import SettingsPage from './pages/SettingsPage.jsx';
|
||||||
import GameLibrary from './pages/GameLibrary.jsx';
|
import GameLibrary from './pages/GameLibrary.jsx';
|
||||||
import GameDetail from './pages/GameDetail.jsx';
|
import GameDetail from './pages/GameDetail.jsx';
|
||||||
|
import WishlistPage from './pages/WishlistPage.jsx';
|
||||||
import AppHeader from './components/AppHeader.jsx';
|
import AppHeader from './components/AppHeader.jsx';
|
||||||
import {Toaster} from '@/components/ui/sonner';
|
import {Toaster} from '@/components/ui/sonner';
|
||||||
import {TooltipProvider} from '@/components/ui/tooltip';
|
import {TooltipProvider} from '@/components/ui/tooltip';
|
||||||
@@ -47,6 +48,7 @@ function App() {
|
|||||||
<Route path="/dashboard" element={<Dashboard/>}/>
|
<Route path="/dashboard" element={<Dashboard/>}/>
|
||||||
<Route path="/library" element={<GameLibrary/>}/>
|
<Route path="/library" element={<GameLibrary/>}/>
|
||||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||||
|
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Link, NavLink} from 'react-router-dom';
|
import {Link, NavLink, useLocation} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Gamepad2, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun, UserRound} from 'lucide-react';
|
import {Gamepad2, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun, UserRound} from 'lucide-react';
|
||||||
|
|
||||||
@@ -15,10 +15,11 @@ import {cn} from '@/lib/utils';
|
|||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
||||||
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library},
|
// The wishlist is a tab of the library.
|
||||||
|
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
|
||||||
];
|
];
|
||||||
|
|
||||||
const navLinkClass = ({isActive}) => cn(
|
const navLinkClass = (isActive) => cn(
|
||||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
@@ -28,6 +29,7 @@ const navLinkClass = ({isActive}) => cn(
|
|||||||
export default function AppHeader({isAuthenticated, onLogout}) {
|
export default function AppHeader({isAuthenticated, onLogout}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
||||||
|
const {pathname} = useLocation();
|
||||||
|
|
||||||
const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de');
|
const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de');
|
||||||
const toggleTheme = () => setDarkMode(!isDarkMode);
|
const toggleTheme = () => setDarkMode(!isDarkMode);
|
||||||
@@ -45,8 +47,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
|
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
{NAV_ITEMS.map(({to, labelKey, Icon}) => (
|
{NAV_ITEMS.map(({to, labelKey, Icon, alsoActiveOn = []}) => (
|
||||||
<NavLink key={to} to={to} className={navLinkClass} title={t(labelKey)}>
|
<NavLink key={to} to={to} title={t(labelKey)}
|
||||||
|
className={({isActive}) => navLinkClass(isActive || alsoActiveOn.includes(pathname))}>
|
||||||
<Icon className="size-4"/>
|
<Icon className="size-4"/>
|
||||||
<span className="hidden sm:inline">{t(labelKey)}</span>
|
<span className="hidden sm:inline">{t(labelKey)}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {NavLink} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Gift, Library} from 'lucide-react';
|
||||||
|
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library},
|
||||||
|
{to: '/wishlist', labelKey: 'WISHLIST.TITLE', icon: Gift},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function LibraryTabs() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
return (
|
||||||
|
<nav aria-label={t('LIBRARY.TITLE')} className="inline-flex rounded-lg bg-muted p-1 text-sm">
|
||||||
|
{TABS.map(({to, labelKey, icon: Icon}) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end
|
||||||
|
className={({isActive}) => cn(
|
||||||
|
'flex items-center gap-1.5 rounded-md px-3 py-1.5 font-medium transition-colors',
|
||||||
|
isActive ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4"/>
|
||||||
|
{t(labelKey)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {Input} from '@/components/ui/input';
|
|||||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||||
import {Skeleton} from '@/components/ui/skeleton';
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
import GameCover from '../components/GameCover.jsx';
|
import GameCover from '../components/GameCover.jsx';
|
||||||
|
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||||
|
|
||||||
const PAGE_SIZES = [20, 40, 80];
|
const PAGE_SIZES = [20, 40, 80];
|
||||||
@@ -145,6 +146,7 @@ export default function GameLibrary() {
|
|||||||
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
|
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<LibraryTabs/>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{t('LIBRARY.TITLE')}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{t('LIBRARY.TITLE')}</h1>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<SyncStatusBadge
|
<SyncStatusBadge
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {useNavigate} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {ExternalLink, Gift, Percent} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Badge} from '@/components/ui/badge';
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
|
import GameCover from '../components/GameCover.jsx';
|
||||||
|
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||||
|
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||||
|
|
||||||
|
const formatAdded = (instant, locale) =>
|
||||||
|
instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
|
||||||
|
|
||||||
|
export default function WishlistPage() {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const expireSession = useSessionExpired();
|
||||||
|
const [games, setGames] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [onlyOnSale, setOnlyOnSale] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('tbp_jwt');
|
||||||
|
if (!token) {
|
||||||
|
navigate('/login');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/wishlist`, {
|
||||||
|
headers: {'Authorization': `Bearer ${token}`},
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (response.status === 401) {
|
||||||
|
expireSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(t('WISHLIST.ERROR_LOAD'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
if (!cancelled) setGames(json.games ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Wishlist load error:', err);
|
||||||
|
if (!cancelled) setError(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [navigate, expireSession, t]);
|
||||||
|
|
||||||
|
const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0);
|
||||||
|
const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<LibraryTabs/>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{t('WISHLIST.TITLE')}</h1>
|
||||||
|
</div>
|
||||||
|
{games?.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={onlyOnSale ? 'default' : 'outline'}
|
||||||
|
aria-pressed={onlyOnSale}
|
||||||
|
className="self-start md:self-auto"
|
||||||
|
onClick={() => setOnlyOnSale((v) => !v)}
|
||||||
|
>
|
||||||
|
<Percent/>{t('WISHLIST.ONLY_ON_SALE')}
|
||||||
|
<span className="tabular-nums opacity-70">{onSaleCount}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{games === null && !error && (
|
||||||
|
<Card className="gap-0 py-0 divide-y">
|
||||||
|
{Array.from({length: 5}, (_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4 px-5 py-3">
|
||||||
|
<Skeleton className="h-11 w-24 rounded-md"/>
|
||||||
|
<Skeleton className="h-4 flex-1"/>
|
||||||
|
<Skeleton className="h-4 w-16"/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="py-12 text-center text-destructive">{error}</div>}
|
||||||
|
|
||||||
|
{games !== null && shown.length === 0 && (
|
||||||
|
<Card className="items-center py-16 text-center text-muted-foreground">
|
||||||
|
<Gift className="size-10"/>
|
||||||
|
<p className="max-w-sm text-sm">{games.length === 0 ? t('WISHLIST.EMPTY') : t('WISHLIST.NONE_ON_SALE')}</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shown.length > 0 && (
|
||||||
|
<Card className="gap-0 py-0">
|
||||||
|
<ul className="divide-y">
|
||||||
|
{shown.map((g) => (
|
||||||
|
<li key={g.appid}>
|
||||||
|
<a
|
||||||
|
href={`https://store.steampowered.com/app/${g.appid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<GameCover appid={g.appid} className="w-16 sm:w-24 shrink-0"/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium group-hover:text-primary transition-colors">
|
||||||
|
{g.name ?? `App ${g.appid}`}
|
||||||
|
</div>
|
||||||
|
{g.addedAt && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('WISHLIST.ADDED', {date: formatAdded(g.addedAt, i18n.language)})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 text-sm tabular-nums">
|
||||||
|
{g.discountPct > 0 && (
|
||||||
|
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
|
||||||
|
-{g.discountPct}%
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{g.discountPct > 0 && g.originalPrice && (
|
||||||
|
<span className="hidden text-muted-foreground line-through sm:inline">{g.originalPrice}</span>
|
||||||
|
)}
|
||||||
|
{g.price
|
||||||
|
? <span className="font-medium">{g.price}</span>
|
||||||
|
: <span className="text-muted-foreground">{t('WISHLIST.NO_PRICE')}</span>}
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="size-4 shrink-0 text-muted-foreground"/>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||||
|
import WishlistPage from './WishlistPage.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
const wishlist = {games: [
|
||||||
|
{appid: 1257610, name: 'Prison Architect 2', addedAt: '2026-01-01T00:00:00Z', price: '7,49€', originalPrice: '29,99€', discountPct: 75},
|
||||||
|
{appid: 3012600, name: 'Coming Soon', addedAt: '2025-06-01T00:00:00Z', price: null, originalPrice: null, discountPct: 0},
|
||||||
|
]};
|
||||||
|
|
||||||
|
function LoginState() {
|
||||||
|
return <div data-testid="login">{JSON.stringify(useLocation().state)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPage = () => render(
|
||||||
|
<MemoryRouter initialEntries={['/wishlist']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||||
|
<Route path="/login" element={<LoginState/>}/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('WishlistPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_jwt', 'token');
|
||||||
|
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => wishlist}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows each game with its price, and the discount for games on sale', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Prison Architect 2')).toBeTruthy();
|
||||||
|
expect(screen.getByText('-75%')).toBeTruthy();
|
||||||
|
expect(screen.getByText('7,49€')).toBeTruthy();
|
||||||
|
expect(screen.getByText('29,99€')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Coming Soon')).toBeTruthy();
|
||||||
|
expect(screen.getByText('WISHLIST.NO_PRICE')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can show only the games on sale', async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Coming Soon');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: /WISHLIST.ONLY_ON_SALE/}));
|
||||||
|
|
||||||
|
expect(screen.queryByText('Coming Soon')).toBeNull();
|
||||||
|
expect(screen.getByText('Prison Architect 2')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains an empty or private wishlist', async () => {
|
||||||
|
fetch.mockResolvedValueOnce({ok: true, status: 200, json: async () => ({games: []})});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('WISHLIST.EMPTY')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the login page on a 401', async () => {
|
||||||
|
fetch.mockResolvedValueOnce({ok: false, status: 401, json: async () => ({})});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() => expect(JSON.parse(screen.getByTestId('login').textContent).sessionExpired).toBe(true));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user