Public Access
149 lines
6.9 KiB
React
149 lines
6.9 KiB
React
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';
|
|
import PageContainer from '../components/PageContainer.jsx';
|
|
|
|
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 (
|
|
<PageContainer className="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>
|
|
)}
|
|
</PageContainer>
|
|
);
|
|
}
|