Public Access
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.
This commit is contained in:
1 parent
ca3d34bad0
commit
0154202ddc
6 files changed
+112
-7
No files matched your search
@@ -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 (
|
||||
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<Card className={cn('relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6',
|
||||
lookClass(equipped, 'BANNER_FRAME'))}>
|
||||
{/* A picked game, else the game played most in the last two weeks. */}
|
||||
<HeroBanner appid={bannerAppid}/>
|
||||
<div className="relative flex min-w-0 items-center gap-4">
|
||||
{/* The slot is there before the avatar loads, so the title does not jump. */}
|
||||
{hasSteamId && (
|
||||
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-primary/60">
|
||||
<span className={cn('flex size-16 shrink-0 items-center justify-center rounded-full bg-muted',
|
||||
lookClass(equipped, 'AVATAR_BORDER') ?? 'ring-2 ring-primary/60')}>
|
||||
<SteamAvatar url={avatarUrl} className="size-16" iconClassName="size-8 text-muted-foreground"/>
|
||||
</span>
|
||||
)}
|
||||
@@ -32,6 +37,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
|
||||
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
|
||||
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
|
||||
</h1>
|
||||
<PointsBadge equipped={equipped} className="mt-2"/>
|
||||
{hasSteamId ? (
|
||||
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
|
||||
<Hash className="size-3"/>
|
||||
|
||||
@@ -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(<DashboardWelcomeCard sync={{}} forceSync={{}} equipped={equipped}/>);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<span className={cn('inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium', badge.className, className)}>
|
||||
<badge.Icon className="size-3.5"/>{t(`POINTS.ITEMS.${badge.code}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||
equipped={wallet?.equipped}
|
||||
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||
|
||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||
|
||||
@@ -10,6 +10,8 @@ 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 {equippedAccent, lookClass} from '../utils/rewards.js';
|
||||
import PointsBadge from '../components/PointsBadge.jsx';
|
||||
import {GAME_STATUSES} from '../utils/limits.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import useSession from '../hooks/useSession.js';
|
||||
@@ -35,6 +37,7 @@ export default function PublicProfilePage() {
|
||||
const {name} = useParams();
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [library, setLibrary] = useState(null);
|
||||
const [equipped, setEquipped] = useState({});
|
||||
const [error, setError] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [reloads, setReloads] = useState(0);
|
||||
@@ -45,6 +48,7 @@ export default function PublicProfilePage() {
|
||||
let cancelled = false;
|
||||
setProfile(null);
|
||||
setLibrary(null);
|
||||
setEquipped({});
|
||||
setError(null);
|
||||
(async () => {
|
||||
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 (
|
||||
<div data-accent={style.accent} data-corners={style.corners}>
|
||||
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}>
|
||||
<PageBackground appid={style.backgroundAppid}/>
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<Card className="relative overflow-hidden p-6 md:p-8">
|
||||
<Card className={cn('relative overflow-hidden p-6 md:p-8', lookClass(equipped, 'BANNER_FRAME'))}>
|
||||
<HeroBanner appid={style.bannerAppid}/>
|
||||
<div className="relative">
|
||||
<p className="text-sm font-medium text-muted-foreground">{t('PROFILE.TITLE')}</p>
|
||||
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight break-words">{profile.displayName}</h1>
|
||||
<PointsBadge equipped={equipped} className="mt-2"/>
|
||||
{memberSince && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
|
||||
)}
|
||||
|
||||
@@ -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(<PublicProfilePage/>,
|
||||
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');
|
||||
|
||||
Reference in new issue
Block a user