Public Access
feat(points): unlock items, progress bars and skins
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:
1 parent
8598024bce
commit
b6f3d123fb
22 files changed
+622
-38
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -19,12 +19,17 @@ const wallet = (extra = {}) => ({
|
||||
{code: 'FX_GOLD_SHINE', slot: 'COMPLETED_EFFECT', price: 300},
|
||||
{code: 'BG_GRID', slot: 'PAGE_BACKGROUND', price: 450},
|
||||
{code: 'BN_GRID', slot: 'BANNER_BACKGROUND', price: 300},
|
||||
{code: 'BADGE_FINISH_10', slot: 'BADGE', price: 0, track: 'GAMES_FINISHED', target: 10},
|
||||
{code: 'BADGE_STREAK_4', slot: 'BADGE', price: 0, track: 'FINISH_WEEKS', target: 4},
|
||||
{code: 'SKIN_PASTEL', slot: 'SKIN', price: 600},
|
||||
],
|
||||
progress: {GAMES_FINISHED: 7, FINISH_WEEKS: 9},
|
||||
owned: ['BADGE_PILE_SLAYER'],
|
||||
equipped: {},
|
||||
history: [
|
||||
{kind: 'FINISHED', ref: '620', gameName: 'Portal 2', points: 50, eventAt: '2026-09-30T10:00:00Z'},
|
||||
{kind: 'MONTH', ref: '2026-09', gameName: null, points: 40, eventAt: '2026-09-30T10:00:00Z'},
|
||||
{kind: 'UNLOCK', ref: 'BADGE_PERFECT_5', gameName: null, points: 0, eventAt: '2026-09-29T10:00:00Z'},
|
||||
],
|
||||
...extra,
|
||||
});
|
||||
@@ -60,6 +65,48 @@ describe('PointsPage', () => {
|
||||
expect(card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows a locked unlock item with its progress, a progress bar and no price or Buy button', async () => {
|
||||
await renderPage();
|
||||
|
||||
const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
|
||||
expect(within(item).getByText('POINTS.PROGRESS.GAMES_FINISHED:7')).toBeTruthy();
|
||||
const bar = within(item).getByRole('progressbar');
|
||||
expect(bar.getAttribute('aria-valuenow')).toBe('7');
|
||||
expect(bar.getAttribute('aria-valuemax')).toBe('10');
|
||||
expect(within(item).queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('caps the progress at the target', async () => {
|
||||
await renderPage();
|
||||
|
||||
const item = screen.getByText('POINTS.ITEMS.BADGE_STREAK_4').closest('li');
|
||||
expect(within(item).getByText('POINTS.PROGRESS.FINISH_WEEKS:4')).toBeTruthy();
|
||||
expect(within(item).getByRole('progressbar').getAttribute('aria-valuenow')).toBe('4');
|
||||
});
|
||||
|
||||
it('lets the user equip an unlock item once it is owned', async () => {
|
||||
await renderPage({'GET /api/rewards/wallet': json(200, wallet({owned: ['BADGE_FINISH_10']}))});
|
||||
|
||||
const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
|
||||
expect(within(item).getByRole('button', {name: 'POINTS.EQUIP'})).toBeTruthy();
|
||||
expect(within(item).queryByRole('progressbar')).toBeNull();
|
||||
});
|
||||
|
||||
it('previews a skin with its data-skin', async () => {
|
||||
await renderPage();
|
||||
|
||||
const card = screen.getByText('POINTS.SLOTS.SKIN').closest('[data-slot="card"]');
|
||||
expect(card.querySelector('[data-skin="pastel"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows an UNLOCK row with the item name and no number', async () => {
|
||||
await renderPage();
|
||||
|
||||
const row = screen.getByText(/POINTS.KINDS.UNLOCK/).closest('li');
|
||||
expect(row.textContent).toContain('POINTS.ITEMS.BADGE_PERFECT_5');
|
||||
expect(row.textContent).not.toMatch(/\+?0$/);
|
||||
});
|
||||
|
||||
it('holds the frame of previews that are off screen', async () => {
|
||||
const callbacks = [];
|
||||
vi.stubGlobal('IntersectionObserver', class {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {cn} from '@/lib/utils';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
||||
import {formatDay} from '../utils/format.js';
|
||||
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, lookClass} from '../utils/rewards.js';
|
||||
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
|
||||
import PointsBadge from '../components/PointsBadge.jsx';
|
||||
import {GAME_STATUSES} from '../utils/limits.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
@@ -152,7 +152,10 @@ export default function PublicProfilePage() {
|
||||
|
||||
// The owner's accent and corners apply to this page only; light or dark mode stays the viewer's.
|
||||
return (
|
||||
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}>
|
||||
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}
|
||||
data-skin={equippedSkin(equipped) ?? 'none'}>
|
||||
{/* The owner's base layer, below PageBackground (-z-10); the page's own bg-background is the viewer's. */}
|
||||
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-20 bg-background skin-bg"/>
|
||||
<PageBackground appid={style.backgroundAppid} effect={backgroundEffect(equipped)}/>
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<Card className={cn('relative overflow-hidden p-6 md:p-8', lookClass(equipped, 'BANNER_FRAME'))}>
|
||||
|
||||
@@ -55,6 +55,23 @@ describe('PublicProfilePage', () => {
|
||||
expect(screen.getByText('Portal 2').closest('li').querySelector('.fx-cover-neon-pulse')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('wears the skin of the owner on the wrapper with its own base layer, or resets to the plain theme', async () => {
|
||||
mockBackends({style: {accent: 'red'}, equipped: {SKIN: 'SKIN_CYBERPUNK'}});
|
||||
const {unmount} = renderPage();
|
||||
|
||||
expect(await screen.findByText('Portal 2')).toBeTruthy();
|
||||
const wrapper = screen.getByText('Lobo').closest('[data-skin]');
|
||||
expect(wrapper.dataset.skin).toBe('cyberpunk');
|
||||
expect(wrapper.querySelector('.skin-bg')).not.toBeNull();
|
||||
unmount();
|
||||
cleanup();
|
||||
|
||||
mockBackends({style: {accent: 'red'}, equipped: {}});
|
||||
renderPage();
|
||||
expect(await screen.findByText('Portal 2')).toBeTruthy();
|
||||
expect(screen.getByText('Lobo').closest('[data-skin]').dataset.skin).toBe('none');
|
||||
});
|
||||
|
||||
it('still shows the profile when the looks call fails', async () => {
|
||||
mockBackends({style: {accent: 'red'}, rewardsStatus: 500});
|
||||
renderPage();
|
||||
|
||||
Reference in new issue
Block a user