feat(points): unlock items, progress bars and skins
Build, Test & Push Frontend / quality-check (push) Successful in 28s
Build, Test & Push Frontend / docker (push) Successful in 25s

Locked items show a progress bar until the goal is reached. Four
CSS-only skins (Pastel, Retro CRT, Cyberpunk, Steam) in skins.css,
light and dark. A skin overrides accent and corners; badges, borders,
frames, effects and game art stay on top. New Platinum shine and
Equalizer effects.
This commit is contained in:
lobo committed 2026-10-02 15:40:46 +02:00
1 parent 8598024bce
commit b6f3d123fb
22 files changed
+622 -38

No files matched your search

+38 -6
View File
@@ -1,7 +1,7 @@
import {useRef} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Coins} from 'lucide-react';
import {Coins, LockKeyhole} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
@@ -17,7 +17,7 @@ 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'];
const HOW_TO = ['STARTED', 'FINISHED', 'COMPLETED', 'FINISHED_MANUAL', 'MONTH', 'STREAK', 'UNLOCK'];
export default function PointsPage() {
const {t, i18n} = useTranslation();
@@ -58,7 +58,7 @@ export default function PointsPage() {
</Link>
);
}
if (entry.kind === 'BUY') return t(`POINTS.ITEMS.${entry.ref}`);
if (['BUY', 'UNLOCK'].includes(entry.kind)) return t(`POINTS.ITEMS.${entry.ref}`);
if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref;
return null;
};
@@ -89,6 +89,7 @@ export default function PointsPage() {
{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}/>
@@ -99,6 +100,8 @@ export default function PointsPage() {
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>
@@ -133,9 +136,11 @@ export default function PointsPage() {
{t(`POINTS.KINDS.${entry.kind}`)}
{label && <> {label}</>}
</span>
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
{entry.points > 0 ? `+${entry.points}` : entry.points}
</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>
);
})}
@@ -157,6 +162,14 @@ function Preview({code}) {
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"/>;
}
@@ -172,3 +185,22 @@ function Preview({code}) {
}
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>
);
}