diff --git a/README.md b/README.md index 3fae2fd..ab75f8d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,10 @@ To Be Played is split into three repositories: - **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG, in `effects.css`), - shown on the dashboard and the public profile. The balance is in the header. Every effect stands still with the + shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar + until a goal is reached (games finished, 100 % games, weeks in a row, months with a smaller pile, months with more + play time). Skins (`skins.css`, Pastel, Retro CRT, Cyberpunk bought, Steam unlocked) are full themes made of CSS + variables; a skin wins over accent and corners, while badges, borders, frames and effects stay on top. The balance is in the header. Every effect stands still with the system setting "reduce motion" or the "Reduce animations" switch in the settings. - **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and finished games per year of all users. diff --git a/public/i18n/de.json b/public/i18n/de.json index 193c82c..37cac48 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -168,7 +168,8 @@ "PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".", "FAVORITE_PLATFORM": "Lieblingsplattform", "FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.", - "FAVORITE_PLATFORM_NONE": "Keine" + "FAVORITE_PLATFORM_NONE": "Keine", + "SKIN_OVERRIDES": "Dein Skin legt Farben und Ecken fest. Nimm ihn auf der Punkte-Seite ab." }, "VALIDATION": { "EMAIL_REQUIRED": "Email ist erforderlich.", @@ -1006,7 +1007,8 @@ "COMPLETED": "+40 Punkte, wenn du in einem abgeschlossenen Spiel 100 % der Erfolge hast.", "FINISHED_MANUAL": "15 Punkte für ein manuelles Spiel, das du durchspielst, höchstens 3 pro Woche.", "MONTH": "40 Punkte, wenn dein Stapel am Monatsende kleiner ist als am Monatsanfang und du in dem Monat ein Spiel durchgespielt hast.", - "STREAK": "20 Punkte für jede Woche mit einem beendeten Spiel nach einer Woche mit einem beendeten Spiel." + "STREAK": "20 Punkte für jede Woche mit einem beendeten Spiel nach einer Woche mit einem beendeten Spiel.", + "UNLOCK": "Gegenstände mit Schloss kann man nicht kaufen. Sie werden einmal und für immer freigeschaltet, wenn du ihr Ziel erreichst." }, "SLOTS": { "AVATAR_BORDER": "Avatarrahmen", @@ -1015,7 +1017,8 @@ "BANNER_FRAME": "Bannerrahmen", "COMPLETED_EFFECT": "Effekt für 100 %", "PAGE_BACKGROUND": "Seitenhintergrund", - "BANNER_BACKGROUND": "Bannerhintergrund" + "BANNER_BACKGROUND": "Bannerhintergrund", + "SKIN": "Skin" }, "ITEMS": { "BORDER_GOLD": "Goldener Avatarrahmen", @@ -1045,7 +1048,22 @@ "BG_WAVES": "Animierter Hintergrund: Wellen", "BN_WAVES": "Animiertes Banner: Wellen", "BG_BOKEH": "Animierter Hintergrund: Bokeh-Lichter", - "BN_BOKEH": "Animiertes Banner: Bokeh-Lichter" + "BN_BOKEH": "Animiertes Banner: Bokeh-Lichter", + "BADGE_FINISH_10": "Beender-Abzeichen", + "BORDER_FINISH_25": "Silberner Avatar-Rand", + "FRAME_FINISH_50": "Smaragd-Bannerrahmen", + "BADGE_PERFECT_5": "Perfektionisten-Abzeichen", + "FX_PLATINUM": "Platin-Glanz", + "BADGE_STREAK_4": "Serien-Abzeichen", + "BORDER_STREAK_12": "Flammen-Avatar-Rand", + "FRAME_PILE_DOWN_3": "Türkiser Bannerrahmen", + "BADGE_PLAY_MORE_1": "Mehr-spielen-Abzeichen", + "BORDER_PLAY_MORE_3": "Himmelblauer Avatar-Rand", + "BN_EQUALIZER": "Animiertes Banner: Equalizer", + "SKIN_PASTEL": "Skin: Pastell", + "SKIN_RETRO_CRT": "Skin: Retro-Röhre", + "SKIN_CYBERPUNK": "Skin: Cyberpunk", + "SKIN_STEAM": "Skin: Steam" }, "KINDS": { "STARTED": "Gestartet", @@ -1055,7 +1073,16 @@ "MONTH": "Kleinerer Stapel im", "STREAK": "Serien-Woche", "STARTER": "Startpunkte", - "BUY": "Gekauft" + "BUY": "Gekauft", + "UNLOCK": "Freigeschaltet" + }, + "LOCKED": "Gesperrt, Fortschritt", + "PROGRESS": { + "GAMES_FINISHED": "{{count}} / {{target}} Spiele beendet", + "GAMES_COMPLETED": "{{count}} / {{target}} Spiele zu 100 %", + "FINISH_WEEKS": "{{count}} / {{target}} Wochen in Folge mit einem beendeten Spiel", + "PILE_DOWN_MONTHS": "{{count}} / {{target}} Monate in Folge mit kleinerem Stapel", + "PLAY_MORE_MONTHS": "{{count}} / {{target}} Monate in Folge mit mehr Spielzeit als im Monat davor" } }, "UNSAVED_CHANGES": { diff --git a/public/i18n/en.json b/public/i18n/en.json index 631d877..a218735 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -168,7 +168,8 @@ "PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".", "FAVORITE_PLATFORM": "Favorite platform", "FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.", - "FAVORITE_PLATFORM_NONE": "None" + "FAVORITE_PLATFORM_NONE": "None", + "SKIN_OVERRIDES": "Your skin sets colors and corners. Take it off on the Points page." }, "VALIDATION": { "EMAIL_REQUIRED": "Email is required.", @@ -1006,7 +1007,8 @@ "COMPLETED": "+40 points when you reach 100 % achievements in a completed game.", "FINISHED_MANUAL": "15 points for a manual game you finish, at most 3 per week.", "MONTH": "40 points when your pile is smaller at the end of a month than at its start and you finished a game that month.", - "STREAK": "20 points for every week with a finished game after a week with a finished game." + "STREAK": "20 points for every week with a finished game after a week with a finished game.", + "UNLOCK": "Items with a lock cannot be bought. They unlock once, for good, when you reach their goal." }, "SLOTS": { "AVATAR_BORDER": "Avatar border", @@ -1015,7 +1017,8 @@ "BANNER_FRAME": "Banner frame", "COMPLETED_EFFECT": "Completed effect", "PAGE_BACKGROUND": "Page background", - "BANNER_BACKGROUND": "Banner background" + "BANNER_BACKGROUND": "Banner background", + "SKIN": "Skin" }, "ITEMS": { "BORDER_GOLD": "Golden avatar border", @@ -1045,7 +1048,22 @@ "BG_WAVES": "Animated background: waves", "BN_WAVES": "Animated banner: waves", "BG_BOKEH": "Animated background: bokeh", - "BN_BOKEH": "Animated banner: bokeh" + "BN_BOKEH": "Animated banner: bokeh", + "BADGE_FINISH_10": "Finisher badge", + "BORDER_FINISH_25": "Silver avatar border", + "FRAME_FINISH_50": "Emerald banner frame", + "BADGE_PERFECT_5": "Perfectionist badge", + "FX_PLATINUM": "Platinum shine", + "BADGE_STREAK_4": "Streak badge", + "BORDER_STREAK_12": "Flame avatar border", + "FRAME_PILE_DOWN_3": "Teal banner frame", + "BADGE_PLAY_MORE_1": "Play more badge", + "BORDER_PLAY_MORE_3": "Sky avatar border", + "BN_EQUALIZER": "Animated banner: equalizer", + "SKIN_PASTEL": "Skin: Pastel", + "SKIN_RETRO_CRT": "Skin: Retro CRT", + "SKIN_CYBERPUNK": "Skin: Cyberpunk", + "SKIN_STEAM": "Skin: Steam" }, "KINDS": { "STARTED": "Started", @@ -1055,7 +1073,16 @@ "MONTH": "Smaller pile in", "STREAK": "Streak week", "STARTER": "Starter points", - "BUY": "Bought" + "BUY": "Bought", + "UNLOCK": "Unlocked" + }, + "LOCKED": "Locked, progress", + "PROGRESS": { + "GAMES_FINISHED": "{{count}} / {{target}} games finished", + "GAMES_COMPLETED": "{{count}} / {{target}} games at 100 %", + "FINISH_WEEKS": "{{count}} / {{target}} weeks in a row with a finish", + "PILE_DOWN_MONTHS": "{{count}} / {{target}} months in a row with a smaller pile", + "PLAY_MORE_MONTHS": "{{count}} / {{target}} months in a row with more play time than the month before" } }, "UNSAVED_CHANGES": { diff --git a/src/App.jsx b/src/App.jsx index ee37f84..132b013 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -64,7 +64,7 @@ function App() { return ( {/* isolate: the background layer sits behind the content but in front of this div's own background. */} -
+
{/* A public profile shows the owner's background instead. */} {isAuthenticated && !location.pathname.startsWith('/u/') && ( diff --git a/src/components/AppearanceCard.jsx b/src/components/AppearanceCard.jsx index d50426b..b6d0b40 100644 --- a/src/components/AppearanceCard.jsx +++ b/src/components/AppearanceCard.jsx @@ -6,13 +6,14 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Label} from '@/components/ui/label'; import {Switch} from '@/components/ui/switch'; import {AccentPicker, CornersPicker} from './StylePickers.jsx'; +import {equippedSkin} from '../utils/rewards.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; import useSaveAppearance from '../hooks/useSaveAppearance.js'; import {coverUrl} from '../utils/games.js'; export default function AppearanceCard() { const {t} = useTranslation(); - const {appearance} = usePreferences(); + const {appearance, equipped} = usePreferences(); const saveAppearance = useSaveAppearance(); return ( @@ -23,6 +24,7 @@ export default function AppearanceCard() { saveAppearance({accent})}/> saveAppearance({corners})}/> + {equippedSkin(equipped) &&

{t('SETTINGS.SKIN_OVERRIDES')}

}
diff --git a/src/components/AppearanceCard.test.jsx b/src/components/AppearanceCard.test.jsx new file mode 100644 index 0000000..f56e7f5 --- /dev/null +++ b/src/components/AppearanceCard.test.jsx @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render, screen} from '@testing-library/react'; +import AppearanceCard from './AppearanceCard.jsx'; + +const preferences = vi.hoisted(() => ({value: null})); +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('../context/PreferencesContext.jsx', () => ({usePreferences: () => preferences.value})); +vi.mock('../hooks/useSaveAppearance.js', () => ({default: () => vi.fn()})); + +const show = (equipped) => { + preferences.value = { + appearance: {accent: 'violet', corners: 'default', wideLayout: false, reduceMotion: false, showBanner: true}, + equipped, + }; + render(); +}; + +afterEach(cleanup); + +describe('AppearanceCard', () => { + it('tells that the skin sets colors and corners only while a skin is worn', () => { + show({SKIN: 'SKIN_STEAM'}); + expect(screen.getByText('SETTINGS.SKIN_OVERRIDES')).toBeTruthy(); + }); + + it('shows no skin hint without a skin', () => { + show({}); + expect(screen.queryByText('SETTINGS.SKIN_OVERRIDES')).toBeNull(); + }); +}); diff --git a/src/components/EffectBackground.jsx b/src/components/EffectBackground.jsx index e623706..763514c 100644 --- a/src/components/EffectBackground.jsx +++ b/src/components/EffectBackground.jsx @@ -1,6 +1,6 @@ import {cn} from '@/lib/utils'; -export const EFFECTS = ['triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh']; +export const EFFECTS = ['triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh', 'equalizer']; const range = (count) => Array.from({length: count}, (_, i) => i); @@ -73,6 +73,8 @@ function shapes(effect) { )); + case 'equalizer': + return range(16).map((i) => ); case 'bokeh': return range(10).map((i) => ( { - applyAppearance(appearance, equippedAccent(equipped)); + applyAppearance(appearance, {accent: equippedAccent(equipped), skin: equippedSkin(equipped)}); cacheAppearance(appearance); cacheEquipped(equipped); }, [appearance, equipped]); diff --git a/src/context/PreferencesContext.test.jsx b/src/context/PreferencesContext.test.jsx index cb4df2a..f77b19c 100644 --- a/src/context/PreferencesContext.test.jsx +++ b/src/context/PreferencesContext.test.jsx @@ -137,4 +137,15 @@ describe('PreferencesProvider worn looks', () => { expect(preferences.equipped).toEqual({}); }); + + it('wears the skin on the root and takes it off on reset', () => { + localStorage.setItem('tbp_session', '1'); + localStorage.setItem('tbp_equipped', JSON.stringify({SKIN: 'SKIN_RETRO_CRT'})); + + render(); + expect(document.documentElement.dataset.skin).toBe('retro-crt'); + + act(() => preferences.resetAppearance()); + expect(document.documentElement.dataset.skin).toBeUndefined(); + }); }); diff --git a/src/effects.css b/src/effects.css index 33ae3ba..467e3bc 100644 --- a/src/effects.css +++ b/src/effects.css @@ -16,6 +16,7 @@ /* ---- COMPLETED_EFFECT, cover of a tile (GameCover has relative and overflow-hidden) ---- */ .fx-cover-gold-shine::after, +.fx-cover-platinum::after, .fx-cover-neon-pulse::after, .fx-cover-holo::after, .fx-cover-ember::before, @@ -33,6 +34,14 @@ animation: fx-sweep 4s ease-in-out infinite; } +/* The silver border is an inside outline in the look; a cool light sweeps over the cover. */ +.fx-cover-platinum::after { + inset: 0; + background: linear-gradient(115deg, transparent 38%, rgb(226 232 240 / 0.4) 48%, rgb(186 230 253 / 0.3) 54%, transparent 62%); + transform: translateX(-100%); + animation: fx-sweep 5s ease-in-out infinite; +} + /* The ring is an inside outline in the look; the glow is inside, because the cover clips outside shadows. */ .fx-cover-neon-pulse::after { inset: 0; @@ -98,6 +107,26 @@ .fx-detail-gold-shine > :nth-child(7) { --x: 70%; --y: 44%; --d: -2.4s; } .fx-detail-gold-shine > :nth-child(8) { --x: 40%; --y: 82%; --d: -0.2s; } +.fx-detail-platinum > span { + position: absolute; + left: var(--x); + top: var(--y); + width: 12px; + height: 12px; + background: #6b7a90; + clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%); + transform: scale(0.6); + animation: fx-twinkle 3s ease-in-out infinite; + animation-delay: var(--d); +} +.dark .fx-detail-platinum > span { background: #e2e8f0; } +.fx-detail-platinum > :nth-child(1) { --x: 48%; --y: 16%; --d: 0s; } +.fx-detail-platinum > :nth-child(2) { --x: 66%; --y: 64%; --d: -0.5s; } +.fx-detail-platinum > :nth-child(3) { --x: 80%; --y: 22%; --d: -1s; } +.fx-detail-platinum > :nth-child(4) { --x: 90%; --y: 58%; --d: -1.5s; } +.fx-detail-platinum > :nth-child(5) { --x: 56%; --y: 44%; --d: -2s; } +.fx-detail-platinum > :nth-child(6) { --x: 84%; --y: 84%; --d: -2.5s; } + .fx-detail-neon-pulse > span { position: absolute; inset: 0; @@ -333,6 +362,37 @@ background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 22%, transparent), transparent); } +/* equalizer: 16 bars at the bottom, scaleY only. The heights are fixed, so frame 0 is a static skyline. */ +@keyframes fx-bar { from { transform: scaleY(var(--lo)); } to { transform: scaleY(var(--hi)); } } +.fx-bg-equalizer > * { + bottom: 0; + left: calc(var(--i) * 6.25% + 1%); + width: 4.25%; + height: 55%; + transform-origin: bottom; + transform: scaleY(var(--hi)); + border-radius: 2px 2px 0 0; + background: linear-gradient(to top, color-mix(in oklab, var(--primary) 35%, transparent), color-mix(in oklab, var(--logo-teal) 12%, transparent)); + animation: fx-bar var(--dur) ease-in-out infinite alternate; + animation-delay: var(--d); +} +.fx-bg-equalizer > :nth-child(1) { --i: 0; --hi: 0.55; --lo: 0.2; --dur: 1.1s; --d: 0s; } +.fx-bg-equalizer > :nth-child(2) { --i: 1; --hi: 0.8; --lo: 0.3; --dur: 1.4s; --d: -0.3s; } +.fx-bg-equalizer > :nth-child(3) { --i: 2; --hi: 0.4; --lo: 0.15; --dur: 0.9s; --d: -0.6s; } +.fx-bg-equalizer > :nth-child(4) { --i: 3; --hi: 0.95; --lo: 0.4; --dur: 1.7s; --d: -0.2s; } +.fx-bg-equalizer > :nth-child(5) { --i: 4; --hi: 0.65; --lo: 0.25; --dur: 1.2s; --d: -0.9s; } +.fx-bg-equalizer > :nth-child(6) { --i: 5; --hi: 0.3; --lo: 0.1; --dur: 0.8s; --d: -0.4s; } +.fx-bg-equalizer > :nth-child(7) { --i: 6; --hi: 0.75; --lo: 0.35; --dur: 1.5s; --d: -1.1s; } +.fx-bg-equalizer > :nth-child(8) { --i: 7; --hi: 1; --lo: 0.45; --dur: 1.9s; --d: -0.7s; } +.fx-bg-equalizer > :nth-child(9) { --i: 8; --hi: 0.5; --lo: 0.2; --dur: 1s; --d: -1.3s; } +.fx-bg-equalizer > :nth-child(10) { --i: 9; --hi: 0.85; --lo: 0.3; --dur: 1.6s; --d: -0.1s; } +.fx-bg-equalizer > :nth-child(11) { --i: 10; --hi: 0.35; --lo: 0.12; --dur: 0.85s; --d: -0.8s; } +.fx-bg-equalizer > :nth-child(12) { --i: 11; --hi: 0.7; --lo: 0.28; --dur: 1.3s; --d: -1.5s; } +.fx-bg-equalizer > :nth-child(13) { --i: 12; --hi: 0.9; --lo: 0.4; --dur: 1.8s; --d: -0.5s; } +.fx-bg-equalizer > :nth-child(14) { --i: 13; --hi: 0.45; --lo: 0.18; --dur: 1.05s; --d: -1.2s; } +.fx-bg-equalizer > :nth-child(15) { --i: 14; --hi: 0.6; --lo: 0.22; --dur: 1.25s; --d: -0.35s; } +.fx-bg-equalizer > :nth-child(16) { --i: 15; --hi: 0.25; --lo: 0.1; --dur: 0.95s; --d: -1.0s; } + /* ---- Off screen covers hold their frame (components/GameCover.jsx) ---- */ .fx-paused, diff --git a/src/pages/PointsPage.jsx b/src/pages/PointsPage.jsx index 5fc252f..180e46a 100644 --- a/src/pages/PointsPage.jsx +++ b/src/pages/PointsPage.jsx @@ -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() { ); } - 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 (
  • @@ -99,6 +100,8 @@ export default function PointsPage() { onClick={() => act(() => equipItem(slot, worn ? null : item.code))}> {t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')} + ) : locked ? ( + ) : ( <> {item.price} @@ -133,9 +136,11 @@ export default function PointsPage() { {t(`POINTS.KINDS.${entry.kind}`)} {label && <> {label}} - - {entry.points > 0 ? `+${entry.points}` : entry.points} - + {entry.points !== 0 && ( + + {entry.points > 0 ? `+${entry.points}` : entry.points} + + )}
  • ); })} @@ -157,6 +162,14 @@ function Preview({code}) { if (look.Icon) { return ; } + if (look.skin) { + return ( + + + + ); + } if (look.accent) { return ; } @@ -172,3 +185,22 @@ function Preview({code}) { } return ; } + +// 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 ( +
    + + + {text} + +
    +
    +
    +
    + ); +} diff --git a/src/pages/PointsPage.test.jsx b/src/pages/PointsPage.test.jsx index b489696..15d22cd 100644 --- a/src/pages/PointsPage.test.jsx +++ b/src/pages/PointsPage.test.jsx @@ -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 { diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index 69ad8f8..fd8a5c5 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -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 ( -
    +
    + {/* The owner's base layer, below PageBackground (-z-10); the page's own bg-background is the viewer's. */} +