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');