Public Access
Display and game names shrink to 0.875rem, then end with an ellipsis (FitText + fit-text utility, CSS container units, full text in title). Rows and buttons wrap at large fonts, header points chip can shrink. Guard: scripts/check-overflow.js.
208 lines
10 KiB
React
208 lines
10 KiB
React
import {useRef} from 'react';
|
|
import {Link} from 'react-router-dom';
|
|
import {useTranslation} from 'react-i18next';
|
|
import {Coins, LockKeyhole} from 'lucide-react';
|
|
import {toast} from 'sonner';
|
|
|
|
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 {cn} from '@/lib/utils';
|
|
import EffectBackground from '../components/EffectBackground.jsx';
|
|
import FitText from '../components/FitText.jsx';
|
|
import PageContainer from '../components/PageContainer.jsx';
|
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
|
import useInView from '../hooks/useInView.js';
|
|
import {formatDate} from '../utils/format.js';
|
|
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
|
|
|
|
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED'];
|
|
const HOW_TO = ['STARTED', 'FINISHED', 'COMPLETED', 'FINISHED_MANUAL', 'MONTH', 'STREAK', 'UNLOCK'];
|
|
|
|
export default function PointsPage() {
|
|
const {t, i18n} = useTranslation();
|
|
const {wallet, reloadWallet} = usePreferences();
|
|
|
|
if (!wallet) {
|
|
return (
|
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
|
<Skeleton className="h-24 rounded-xl"/>
|
|
<Skeleton className="h-64 rounded-xl"/>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
const act = async (send) => {
|
|
try {
|
|
const response = await send();
|
|
if (response.ok) {
|
|
await reloadWallet();
|
|
} else if (response.status === 409) {
|
|
const body = await response.json().catch(() => null);
|
|
if (body?.code === 'NOT_ENOUGH_POINTS') toast.error(t('POINTS.ERROR_NOT_ENOUGH'));
|
|
await reloadWallet();
|
|
} else if (response.status !== 401) {
|
|
toast.error(t('SETTINGS.ERROR_SAVE'));
|
|
}
|
|
} catch (err) {
|
|
console.error('Points save error:', err);
|
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
}
|
|
};
|
|
|
|
const entryLabel = (entry) => {
|
|
if (GAME_KINDS.includes(entry.kind)) {
|
|
return (
|
|
<Link to={`/library/${entry.ref}`} className="block min-w-0 max-w-full font-medium text-primary hover:underline">
|
|
<FitText text={entry.gameName ?? entry.ref}/>
|
|
</Link>
|
|
);
|
|
}
|
|
if (['BUY', 'UNLOCK'].includes(entry.kind)) return t(`POINTS.ITEMS.${entry.ref}`);
|
|
if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref;
|
|
return null;
|
|
};
|
|
|
|
return (
|
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">{t('POINTS.TITLE')}</h1>
|
|
<p className="mt-2 flex items-center gap-2 text-lg">
|
|
<Coins className="size-5 text-primary"/>
|
|
{t('POINTS.BALANCE')}: <span className="font-bold tabular-nums">{wallet.balance}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<Card className="gap-2 p-5">
|
|
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
|
|
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
|
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`)}</li>)}
|
|
</ul>
|
|
</Card>
|
|
|
|
<section className="space-y-4">
|
|
<h2 className="text-xl font-semibold">{t('POINTS.SHOP')}</h2>
|
|
{SLOTS.map((slot) => (
|
|
<Card key={slot} className="gap-3 p-5">
|
|
<h3 className="font-medium">{t(`POINTS.SLOTS.${slot}`)}</h3>
|
|
<ul className="divide-y">
|
|
{wallet.catalog.filter((item) => item.slot === slot).map((item) => {
|
|
const owned = wallet.owned.includes(item.code);
|
|
const worn = wallet.equipped[slot] === item.code;
|
|
const locked = !owned && item.track;
|
|
return (
|
|
<li key={item.code} className="flex items-center gap-3 py-3">
|
|
<Preview code={item.code}/>
|
|
<span className="min-w-0 flex-1 truncate font-medium">{t(`POINTS.ITEMS.${item.code}`)}</span>
|
|
{worn && <Badge variant="secondary">{t('POINTS.EQUIPPED')}</Badge>}
|
|
{owned ? (
|
|
<Button size="sm" variant="outline"
|
|
onClick={() => act(() => equipItem(slot, worn ? null : item.code))}>
|
|
{t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')}
|
|
</Button>
|
|
) : locked ? (
|
|
<LockedProgress item={item} progress={wallet.progress?.[item.track] ?? 0}/>
|
|
) : (
|
|
<>
|
|
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
|
|
<Button size="sm" disabled={wallet.balance < item.price}
|
|
onClick={() => act(() => buyItem(item.code))}>
|
|
{t('POINTS.BUY')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</Card>
|
|
))}
|
|
</section>
|
|
|
|
<Card className="gap-3 p-5">
|
|
<h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2>
|
|
{wallet.history.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">{t('POINTS.EMPTY_HISTORY')}</p>
|
|
) : (
|
|
<ul className="divide-y">
|
|
{wallet.history.map((entry) => {
|
|
const label = entryLabel(entry);
|
|
return (
|
|
<li key={`${entry.kind}-${entry.ref}-${entry.eventAt}`} className="@container flex items-center gap-3 py-2 text-sm">
|
|
<span className="w-24 shrink-0 text-muted-foreground tabular-nums">
|
|
{formatDate(entry.eventAt, i18n.language)}
|
|
</span>
|
|
<span className="flex min-w-0 flex-1 gap-1">
|
|
<span className="min-w-0 truncate">{t(`POINTS.KINDS.${entry.kind}`)}</span>
|
|
{label && <span className="min-w-0 truncate">{label}</span>}
|
|
</span>
|
|
{entry.points !== 0 && (
|
|
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
|
|
{entry.points > 0 ? `+${entry.points}` : entry.points}
|
|
</span>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</Card>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
// A sample of the look: a ring on a circle, a pill with the badge icon, a color dot, a framed box, a tile with its
|
|
// effect or a box with the animated background.
|
|
function Preview({code}) {
|
|
const look = LOOKS[code];
|
|
const ref = useRef(null);
|
|
// The shop shows 20 animated previews at once; the ones off screen hold their frame.
|
|
const inView = useInView(ref, !!(look?.cover || look?.effect));
|
|
if (!look) return null;
|
|
if (look.Icon) {
|
|
return <span className={cn('flex size-8 items-center justify-center rounded-full', look.className)}><look.Icon className="size-4"/></span>;
|
|
}
|
|
if (look.skin) {
|
|
return (
|
|
<span data-skin={look.skin}
|
|
className="skin-bg bg-scroll flex size-8 shrink-0 items-center justify-center rounded-[var(--radius)] border bg-background">
|
|
<span className="size-3 rounded-full bg-primary"/>
|
|
</span>
|
|
);
|
|
}
|
|
if (look.accent) {
|
|
return <span data-accent={look.accent} className="size-8 rounded-full bg-primary"/>;
|
|
}
|
|
if (look.cover) {
|
|
return <span ref={ref} className={cn('relative h-8 w-14 shrink-0 overflow-hidden rounded-md bg-muted', look.cover, !inView && 'fx-paused')}/>;
|
|
}
|
|
if (look.effect) {
|
|
return (
|
|
<span ref={ref} className={cn('relative h-8 w-14 shrink-0 overflow-hidden rounded-md bg-background', !inView && 'fx-paused')}>
|
|
<EffectBackground effect={look.effect} className="absolute inset-0"/>
|
|
</span>
|
|
);
|
|
}
|
|
return <span className={cn('size-8 shrink-0 bg-muted', look.className, code.startsWith('BORDER') ? 'rounded-full' : 'rounded-md')}/>;
|
|
}
|
|
|
|
// An unlock-only item that is not earned yet: the lock, the progress as text and as a bar. Never a price or a Buy button.
|
|
function LockedProgress({item, progress}) {
|
|
const {t} = useTranslation();
|
|
const count = Math.min(progress, item.target);
|
|
const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target});
|
|
return (
|
|
<div className="flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground">
|
|
<span className="flex items-center gap-1">
|
|
<LockKeyhole className="size-3 shrink-0"/>
|
|
<span title={text}>{text}</span>
|
|
</span>
|
|
<div role="progressbar" aria-label={t('POINTS.LOCKED')} aria-valuemin={0} aria-valuenow={count}
|
|
aria-valuemax={item.target} className="h-1.5 overflow-hidden rounded-full bg-muted">
|
|
<div className="h-full bg-primary" style={{width: `${(count / item.target) * 100}%`}}/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|