From 0154202ddc791d04feb48b7f62f768f19260d428 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 1 Oct 2026 13:17:23 +0200 Subject: [PATCH] feat(points): equipped looks on dashboard and public profile Avatar borders frame the avatar on the dashboard, badges show next to the heading, banner frames wrap the hero image, and equipped accents set the color on both pages. --- src/components/DashboardWelcomeCard.jsx | 12 +++++-- src/components/DashboardWelcomeCard.test.jsx | 38 ++++++++++++++++++++ src/components/PointsBadge.jsx | 16 +++++++++ src/pages/Dashboard.jsx | 3 +- src/pages/PublicProfilePage.jsx | 20 +++++++++-- src/pages/PublicProfilePage.test.jsx | 30 +++++++++++++++- 6 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 src/components/DashboardWelcomeCard.test.jsx create mode 100644 src/components/PointsBadge.jsx diff --git a/src/components/DashboardWelcomeCard.jsx b/src/components/DashboardWelcomeCard.jsx index acfa832..a57c647 100644 --- a/src/components/DashboardWelcomeCard.jsx +++ b/src/components/DashboardWelcomeCard.jsx @@ -5,25 +5,30 @@ import {Hash, LayoutGrid, Link2, RefreshCw} from 'lucide-react'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; +import {cn} from '@/lib/utils'; import HeroBanner from './HeroBanner.jsx'; +import PointsBadge from './PointsBadge.jsx'; import SteamAvatar from './SteamAvatar.jsx'; import SyncStatusBadge from './SyncStatusBadge.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; +import {lookClass} from '../utils/rewards.js'; -export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, editing, onCustomize}) { +export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, editing, onCustomize, equipped}) { const {t} = useTranslation(); const {user: userData} = useCurrentUserContext(); const hasSteamId = !!userData?.steamId; const syncBusy = forceSync.busy || sync.isRunning; return ( - + {/* A picked game, else the game played most in the last two weeks. */}
{/* The slot is there before the avatar loads, so the title does not jump. */} {hasSteamId && ( - + )} @@ -32,6 +37,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc

{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋

+ {hasSteamId ? ( diff --git a/src/components/DashboardWelcomeCard.test.jsx b/src/components/DashboardWelcomeCard.test.jsx new file mode 100644 index 0000000..1e5c91d --- /dev/null +++ b/src/components/DashboardWelcomeCard.test.jsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, screen} from '@testing-library/react'; +import DashboardWelcomeCard from './DashboardWelcomeCard.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; +import {renderWithProviders} from '../test/renderWithProviders.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +const renderCard = async (equipped) => { + localStorage.setItem('tbp_session', '1'); + fetchRoutes({'GET /api/users/me': json(200, {displayName: 'Lobo', steamId: '1'}), 'GET /api/library/hero-art': json(200, {})}); + renderWithProviders(); + await screen.findByText(/Lobo/); +}; + +describe('DashboardWelcomeCard looks', () => { + it('wears the border, the frame and the badge from the shop', async () => { + await renderCard({AVATAR_BORDER: 'BORDER_GOLD', BANNER_FRAME: 'FRAME_NEON', BADGE: 'BADGE_COMPLETIONIST'}); + + const card = screen.getByText(/Lobo/).closest('[data-slot="card"]'); + expect(card.className).toContain('ring-fuchsia-500'); + expect(card.querySelector('.rounded-full').className).toContain('ring-amber-400'); + expect(screen.getByText('POINTS.ITEMS.BADGE_COMPLETIONIST')).toBeTruthy(); + }); + + it('keeps the default avatar ring without a worn border', async () => { + await renderCard({}); + + expect(screen.getByText(/Lobo/).closest('[data-slot="card"]').querySelector('.rounded-full').className) + .toContain('ring-primary/60'); + }); +}); diff --git a/src/components/PointsBadge.jsx b/src/components/PointsBadge.jsx new file mode 100644 index 0000000..2e2e4f7 --- /dev/null +++ b/src/components/PointsBadge.jsx @@ -0,0 +1,16 @@ +import {useTranslation} from 'react-i18next'; + +import {cn} from '@/lib/utils'; +import {equippedBadge} from '../utils/rewards.js'; + +/** The badge worn from the points shop as a small pill; nothing when none is worn. */ +export default function PointsBadge({equipped, className}) { + const {t} = useTranslation(); + const badge = equippedBadge(equipped); + if (!badge) return null; + return ( + + {t(`POINTS.ITEMS.${badge.code}`)} + + ); +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 7bc7966..83ac379 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -60,7 +60,7 @@ const Dashboard = () => { const hasSteamId = !!userData?.steamId; const showLibrary = hasSteamId || steamData.count > 0; - const {appearance} = usePreferences(); + const {appearance, wallet} = usePreferences(); const steamProfile = useSteamProfile(hasSteamId); const bannerAppid = appearance.showBanner ? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null @@ -127,6 +127,7 @@ const Dashboard = () => { return ( setEditing(true)}/> { try { @@ -69,9 +73,20 @@ export default function PublicProfilePage() { return; } const data = await libraryResponse.json(); + // Looks are decoration: without them the profile still shows. + let looks = {}; + try { + const looksResponse = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/public/profiles/rewards`, { + headers: {'X-Profile-Ticket': found.ticket}, + }); + if (looksResponse.ok) looks = (await looksResponse.json()).equipped ?? {}; + } catch (err) { + console.error('Public profile looks error:', err); + } if (!cancelled) { setProfile(found); setLibrary(data); + setEquipped(looks); } } catch (err) { console.error('Public profile load error:', err); @@ -137,14 +152,15 @@ export default function PublicProfilePage() { // The owner's accent and corners apply to this page only; light or dark mode stays the viewer's. return ( -
+
- +

{t('PROFILE.TITLE')}

{profile.displayName}

+ {memberSince && (

{t('PROFILE.MEMBER_SINCE', {date: memberSince})}

)} diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index 8881130..8288388 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -15,11 +15,12 @@ const library = { }; // `me` only for a test with a session: without one the page asks nothing about the viewer. -function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me} = {}) { +function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me, equipped = {}, rewardsStatus = 200} = {}) { fetchRoutes({ [`GET /api/public/profiles/${encodeURIComponent(name)}`]: json(profileStatus, {displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style}), 'GET /api/public/profiles/library': json(libraryStatus, answer), + 'GET /api/public/profiles/rewards': json(rewardsStatus, {equipped}), ...(me && { 'GET /api/users/me': json(200, me), 'PUT /api/users/settings/profile-tiles': json(204, {}), @@ -32,6 +33,33 @@ const renderPage = (name = 'Lobo') => renderWithProviders(, describe('PublicProfilePage', () => { afterEach(cleanup); + it('wears the premium accent and the badge of the owner', async () => { + mockBackends({style: {accent: 'red'}, equipped: {ACCENT: 'ACCENT_GOLD', BADGE: 'BADGE_PILE_SLAYER', BANNER_FRAME: 'FRAME_GOLD'}}); + renderPage(); + + expect(await screen.findByText('POINTS.ITEMS.BADGE_PILE_SLAYER')).toBeTruthy(); + const wrapper = screen.getByText('Lobo').closest('[data-accent]'); + expect(wrapper.dataset.accent).toBe('gold'); + expect(wrapper.querySelector('[data-slot="card"]').className).toContain('ring-amber-400'); + expect(fetch.mock.calls[2][1].headers['X-Profile-Ticket']).toBe('the-ticket'); + }); + + it('still shows the profile when the looks call fails', async () => { + mockBackends({style: {accent: 'red'}, rewardsStatus: 500}); + renderPage(); + + expect(await screen.findByText('Portal 2')).toBeTruthy(); + expect(screen.getByText('Lobo').closest('[data-accent]').dataset.accent).toBe('red'); + }); + + it('ignores a code the app does not know', async () => { + mockBackends({style: {accent: 'red'}, equipped: {ACCENT: 'ACCENT_X', BADGE: 'BADGE_X'}}); + renderPage(); + + expect(await screen.findByText('Portal 2')).toBeTruthy(); + expect(screen.getByText('Lobo').closest('[data-accent]').dataset.accent).toBe('red'); + }); + it('asks Auth for the ticket, then Core for the library with it', async () => { mockBackends({name: 'Lo bo'}); renderPage('Lo bo');