Modern UI port, render-based @ui tests, dev seed, layer contracts

- Port modern clean theme (DM Sans, orange accent, app-* CSS classes)
  and screen redesigns from festipod-mockups; replace sketchy Ubuntu
  theme. New shared components: BottomNav, EventCover, EventMeetingPoints,
  Toast, AvatarStack, Tag, RelevanceIcon.

- Restructure from prototyping shell to real mobile web app:
  path-based routing (History API), Gallery/DemoMode/PhoneFrame removed,
  Storybook setup for screen/component browsing.

- ConnectScreen ported from mockup (QR-based user connection); routed
  at /profile/connect, wired from FriendsListScreen.

- Dev-only auto-seed of NG wallet when empty
  (gated on NODE_ENV !== 'production'); bootstrapWallet already
  self-checks for non-empty ngSet so safe even in race conditions.

- Render-based @ui test infrastructure: happy-dom + LocalDataProvider +
  RouterProvider via src/shared/test-harness/renderHelper.tsx, exposed
  on the world as renderedDoc. world.hasText/hasField/hasElement prefer
  the rendered DOM and fall back to source for backward compatibility.

- Migrate 25 brittle @ui assertions from regex-on-source to DOM
  queries; delete implementation-detail tests (showDuplicateWarning,
  importableEvents, importedFrom — anti-patterns per the new contract).
  Update feature files where the UI changed: "Mes amis" → "Mon réseau",
  "Mes événements à venir" → "À venir" on home, Thématique removed
  from create-event wizard, etc.

- Path-based @e2e steps (pushState + popstate dispatch) replacing the
  legacy "#/demo/…" hash routing tied to the deleted Gallery.

- Add .project/knowledge/test-layer-contracts.md defining the role of
  each test layer (@ui = display with seed data + DOM, @data = mutations
  through NG broker, @e2e = critical user journeys) with anti-patterns
  and migration consequences.

Test status: 75 passed / 71 skipped (explicit "non implémenté")
/ 2 failed (pre-existing @wip on ngSet.delete() NG ORM limitation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-05-18 09:49:50 +02:00
parent 7099c817db
commit 5a29938130
91 changed files with 5474 additions and 6362 deletions
@@ -17,7 +17,7 @@ Fonctionnalité: US-20 Voir le profil des personnes faisant partie de mon résea
Scénario: Voir mon réseau
Étant donné que je suis sur la page "mon profil"
Alors l'écran contient un texte "Amis"
Et l'écran contient un texte "Mes amis"
Et l'écran contient un texte "Mon réseau"
Scénario: Voir un profil de mon réseau
Étant donné que je suis sur la page "mon profil"
@@ -2,7 +2,7 @@
@USER @priority-2
Fonctionnalité: US-26 Définir la portée d'un événement
En tant qu'utilisateur
Je peux relayer/présenter le contenu d'un événement et le catégoriser par type/thématique
Je peux relayer/présenter le contenu d'un événement
En indiquant son rayon d'intérêt en kilomètres
Afin de m'assurer que les utilisateurs qui habitent trop loin ne reçoivent pas de notification
@@ -17,15 +17,8 @@ Fonctionnalité: US-26 Définir la portée d'un événement
Scénario: Définir le rayon d'intérêt
* Scénario non implémenté
Scénario: Choisir une thématique
Étant donné que je suis sur la page "relayer un événement"
Alors l'écran contient une section "Thématique"
Scénario: Vérifier les champs obligatoires
Étant donné que l'écran "create-event" est affiché
Alors le formulaire contient les champs obligatoires suivants:
| Nom de l'événement |
| Date de début |
| Heure de début |
| Lieu |
| Thématique |
+182
View File
@@ -0,0 +1,182 @@
import React, { useEffect, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Text, Button, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate, useGoBack } from '../../../app/router';
type Mode = 'choice' | 'show' | 'scan';
export function ConnectScreen() {
const navigate = useNavigate();
const goBack = useGoBack();
const { currentUser } = useFestipodData();
const [mode, setMode] = useState<Mode>('choice');
const [scanProgress, setScanProgress] = useState(0);
useEffect(() => {
if (mode !== 'scan') return;
setScanProgress(0);
const start = Date.now();
const id = setInterval(() => {
const p = Math.min(1, (Date.now() - start) / 2800);
setScanProgress(p);
if (p >= 1) {
clearInterval(id);
showToast('Connexion établie avec Léa Bernard', 'success');
navigate('/profile/friends');
}
}, 80);
return () => clearInterval(id);
}, [mode, navigate]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Se connecter"
left={
<ArrowLeft
size={20}
onClick={() => (mode === 'choice' ? goBack() : setMode('choice'))}
style={{ cursor: 'pointer' }}
/>
}
/>
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
{mode === 'choice' && (
<>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 16, lineHeight: 1.5 }}>
Pour vous connecter, scannez le QR code de l'autre personne ou affichez le vôtre pour qu'elle le scanne.
</Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Button variant="primary" style={{ padding: 16, fontSize: 15 }} onClick={() => setMode('show')}>
Afficher mon QR code
</Button>
<Button style={{ padding: 16, fontSize: 15 }} onClick={() => setMode('scan')}>
Scanner un QR code
</Button>
</div>
</>
)}
{mode === 'show' && (
<div style={{ textAlign: 'center', paddingTop: 8 }}>
<div
style={{
width: 220,
height: 220,
margin: '0 auto 16px',
border: '2px solid #e0e0e0',
borderRadius: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
position: 'relative',
}}
>
<div
style={{
width: 190,
height: 190,
background: `
linear-gradient(90deg, #1a1a1a 10%, transparent 10%, transparent 20%, #1a1a1a 20%, #1a1a1a 30%, transparent 30%, transparent 40%, #1a1a1a 40%, #1a1a1a 50%, transparent 50%, transparent 60%, #1a1a1a 60%, #1a1a1a 70%, transparent 70%, transparent 80%, #1a1a1a 80%, #1a1a1a 90%, transparent 90%),
linear-gradient(#1a1a1a 10%, transparent 10%, transparent 20%, #1a1a1a 20%, #1a1a1a 30%, transparent 30%, transparent 40%, #1a1a1a 40%, #1a1a1a 50%, transparent 50%, transparent 60%, #1a1a1a 60%, #1a1a1a 70%, transparent 70%, transparent 80%, #1a1a1a 80%, #1a1a1a 90%, transparent 90%)
`,
backgroundSize: '17px 17px',
opacity: 0.85,
borderRadius: 8,
}}
/>
<div
style={{
position: 'absolute',
background: '#fff',
padding: 4,
borderRadius: '50%',
}}
>
<Avatar initials={currentUser?.initials ?? '?'} color="#E8590C" size="sm" />
</div>
</div>
<Text className="user-content" style={{ fontWeight: 'bold', margin: '0 0 4px 0' }}>{currentUser?.name}</Text>
<Text style={{ color: '#888', margin: 0, fontSize: 13 }}>
Montrez ce QR code pour permettre à l'autre personne de vous connecter.
</Text>
</div>
)}
{mode === 'scan' && (
<div style={{ textAlign: 'center', paddingTop: 8 }}>
<div
style={{
width: 240,
height: 240,
margin: '0 auto 16px',
background: '#111',
borderRadius: 16,
position: 'relative',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
inset: 24,
border: '2px solid rgba(255,255,255,0.8)',
borderRadius: 10,
}}
/>
{(['tl', 'tr', 'bl', 'br'] as const).map(corner => {
const base: React.CSSProperties = {
position: 'absolute',
width: 22,
height: 22,
border: '3px solid #E8590C',
};
const pos: React.CSSProperties =
corner === 'tl' ? { top: 18, left: 18, borderRight: 'none', borderBottom: 'none' }
: corner === 'tr' ? { top: 18, right: 18, borderLeft: 'none', borderBottom: 'none' }
: corner === 'bl' ? { bottom: 18, left: 18, borderRight: 'none', borderTop: 'none' }
: { bottom: 18, right: 18, borderLeft: 'none', borderTop: 'none' };
return <div key={corner} style={{ ...base, ...pos }} />;
})}
<div
style={{
position: 'absolute',
left: 24,
right: 24,
top: `calc(24px + (100% - 48px) * ${(Math.sin(scanProgress * Math.PI * 2) + 1) / 2})`,
height: 2,
background: 'linear-gradient(90deg, transparent, #E8590C, transparent)',
boxShadow: '0 0 12px #E8590C',
}}
/>
</div>
<Text style={{ fontSize: 13, color: '#888', margin: 0 }}>
Alignez le QR code dans le cadre…
</Text>
<div
style={{
marginTop: 16,
height: 4,
background: '#f0f0f0',
borderRadius: 2,
overflow: 'hidden',
}}
>
<div
style={{
width: `${scanProgress * 100}%`,
height: '100%',
background: '#E8590C',
transition: 'width 0.08s linear',
}}
/>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { FriendsListScreen } from './FriendsListScreen';
import { withProviders } from '../../../../.storybook/decorators';
const meta: Meta<typeof FriendsListScreen> = {
title: 'Screens/User/FriendsListScreen',
component: FriendsListScreen,
decorators: [withProviders],
};
export default meta;
type Story = StoryObj<typeof FriendsListScreen>;
export const Default: Story = {};
+29 -71
View File
@@ -1,106 +1,64 @@
import React, { useState } from 'react';
import { Header, Text, Avatar, Input, Button, Badge } from '../../../shared/components/sketchy';
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Avatar, Input, Button, BottomNav } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
import { useNavigate } from '../../../app/router';
export function FriendsListScreen({ navigate }: ScreenProps) {
const { getFriends, users, setSelectedUserId } = useFestipodData();
const [activeTab, setActiveTab] = useState<'friends' | 'public'>('friends');
const COLORS = ['#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E', '#E8590C'];
export function FriendsListScreen() {
const navigate = useNavigate();
const { getFriends } = useFestipodData();
const friends = getFriends();
const publicProfiles = users.filter(u => u.isPublic);
const displayedList = activeTab === 'friends' ? friends : publicProfiles;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Mon réseau"
left={<span onClick={() => navigate('profile')} style={{ cursor: 'pointer' }}>←</span>}
title={`Mon réseau (${friends.length})`}
left={<ArrowLeft size={20} onClick={() => navigate('/home')} style={{ cursor: 'pointer' }} />}
/>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '2px solid var(--sketch-black)' }}>
<button
onClick={() => setActiveTab('friends')}
style={{
flex: 1,
padding: '12px 16px',
background: activeTab === 'friends' ? 'var(--sketch-light-gray)' : 'transparent',
border: 'none',
borderBottom: activeTab === 'friends' ? '3px solid var(--sketch-black)' : '3px solid transparent',
fontFamily: 'var(--font-sketch)',
fontSize: 14,
fontWeight: activeTab === 'friends' ? 'bold' : 'normal',
cursor: 'pointer',
}}
>
Mes amis ({friends.length})
</button>
<button
onClick={() => setActiveTab('public')}
style={{
flex: 1,
padding: '12px 16px',
background: activeTab === 'public' ? 'var(--sketch-light-gray)' : 'transparent',
border: 'none',
borderBottom: activeTab === 'public' ? '3px solid var(--sketch-black)' : '3px solid transparent',
fontFamily: 'var(--font-sketch)',
fontSize: 14,
fontWeight: activeTab === 'public' ? 'bold' : 'normal',
cursor: 'pointer',
}}
>
Profils publics
</button>
</div>
{/* Search bar */}
<div style={{ padding: 16, borderBottom: '1px solid var(--sketch-light-gray)' }}>
<div style={{ padding: 16, borderBottom: '1px solid #f0f0f0' }}>
<Input placeholder="Rechercher..." />
</div>
{/* List */}
<div style={{ flex: 1, overflow: 'auto' }}>
{displayedList.map((person) => (
{friends.map((person, i) => (
<div
key={person.id}
onClick={() => { setSelectedUserId(person.id); navigate('user-profile'); }}
onClick={() => navigate(`/users/${person.id}`)}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 16px',
cursor: 'pointer',
borderBottom: '1px solid var(--sketch-light-gray)',
borderBottom: '1px solid #f5f5f5',
}}
>
<Avatar initials={person.initials} size="sm" />
<Avatar initials={person.initials} color={COLORS[i % COLORS.length]} size="sm" />
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{person.name}</Text>
{person.role && <Badge>{person.role}</Badge>}
</div>
<Text style={{ margin: 0, fontSize: 13 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{person.name}</Text>
<Text style={{ margin: 0, fontSize: 13, color: '#888' }}>
<span className="user-content">{person.username}</span>
{person.eventsCount != null && (
<span style={{ color: 'var(--sketch-gray)' }}> · {person.eventsCount} événements</span>
)}
{person.eventsCount != null && ` · ${person.eventsCount} événements`}
</Text>
</div>
<Text style={{ margin: 0, fontSize: 20, color: 'var(--sketch-gray)' }}>›</Text>
<Text style={{ margin: 0, fontSize: 20, color: '#ccc' }}>›</Text>
</div>
))}
</div>
{/* Add friend button */}
{activeTab === 'friends' && (
<div style={{ padding: 16, borderTop: '2px solid var(--sketch-black)' }}>
<Button variant="primary" style={{ width: '100%' }}>
+ Ajouter un ami
</Button>
</div>
)}
<div style={{ padding: 16, borderTop: '1px solid #f0f0f0' }}>
<Button
variant="primary"
style={{ width: '100%' }}
onClick={() => navigate('/profile/connect')}
>
Se connecter
</Button>
</div>
<BottomNav active="friends" />
</div>
);
}
@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { ProfileScreen } from './ProfileScreen';
import { withProviders } from '../../../../.storybook/decorators';
const meta: Meta<typeof ProfileScreen> = {
title: 'Screens/User/ProfileScreen',
component: ProfileScreen,
decorators: [withProviders],
};
export default meta;
type Story = StoryObj<typeof ProfileScreen>;
export const Default: Story = {};
+44 -46
View File
@@ -1,106 +1,104 @@
import React from 'react';
import { Header, Avatar, Title, Text, Button, Card, Badge, Divider, NavBar } from '../../../shared/components/sketchy';
import { Header, Avatar, Title, Text, Button, Card, Divider, BottomNav, Tag } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
import { useNavigate } from '../../../app/router';
export function ProfileScreen({ navigate }: ScreenProps) {
const { currentUser, getUserEvents, currentUserId, getFriends, setSelectedEventId } = useFestipodData();
export function ProfileScreen() {
const navigate = useNavigate();
const { currentUser, getUserEvents, currentUserId, getFriends } = useFestipodData();
const myEvents = getUserEvents(currentUserId);
const friends = getFriends();
const user = currentUser;
const handleEventClick = (eventId: string) => {
setSelectedEventId(eventId);
navigate('event-detail');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Mon profil"
left={<span onClick={() => navigate('home')} style={{ cursor: 'pointer' }}>←</span>}
right={<span onClick={() => navigate('settings')} style={{ cursor: 'pointer' }}>⚙</span>}
right={<span onClick={() => navigate('/settings')} style={{ cursor: 'pointer', fontSize: 18 }}>⚙</span>}
/>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto' }}>
{/* Profile header */}
<div style={{ padding: 24, textAlign: 'center' }}>
<Avatar initials={user?.initials ?? '?'} size="lg" />
<Avatar initials={user?.initials ?? '?'} color="#E8590C" size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>{user?.name}</Title>
<Text className="user-content" style={{ margin: 0 }}>{user?.username}</Text>
<Text className="user-content" style={{ margin: 0, color: '#888' }}>{user?.username}</Text>
<div style={{ display: 'flex', justifyContent: 'center', gap: 32, marginTop: 20 }}>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{user?.eventsCount ?? myEvents.length}</Text>
<Text style={{ fontSize: 12, color: 'var(--sketch-gray)', margin: 0 }}>Événements</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Événements</Text>
</div>
<div style={{ textAlign: 'center', cursor: 'pointer' }} onClick={() => navigate('friends-list')}>
<div style={{ textAlign: 'center', cursor: 'pointer' }} onClick={() => navigate('/profile/friends')}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{user?.friendsCount ?? friends.length}</Text>
<Text style={{ fontSize: 12, color: 'var(--sketch-gray)', margin: 0 }}>Amis</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Amis</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{user?.participationsCount ?? 0}</Text>
<Text style={{ fontSize: 12, color: 'var(--sketch-gray)', margin: 0 }}>Participations</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Participations</Text>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 20, justifyContent: 'center' }}>
<Button variant="primary" onClick={() => navigate('update-profile')}>Modifier le profil</Button>
<Button onClick={() => navigate('share-profile')}>Partager</Button>
<Button variant="primary" onClick={() => navigate('/profile/edit')}>Modifier le profil</Button>
<Button onClick={() => navigate('/profile/share')}>Partager</Button>
</div>
</div>
<Divider />
<div style={{
margin: '12px 16px',
padding: '14px 16px',
background: 'linear-gradient(135deg, #FFF7ED, #FFFBF5)',
borderRadius: 16,
border: '1px solid #FDDCB5',
}}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, color: '#C05621', marginBottom: 8 }}>
Mes intentions
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
<Tag label="gouvernance coopérative" />
<Tag label="communs numériques" />
<Tag label="habitat participatif" color="#4a3000" bg="#e8f5e9" />
<span style={{ fontSize: 20, cursor: 'pointer', color: '#C05621', lineHeight: 1 }}>
+
</span>
</div>
</div>
<Divider />
{/* Upcoming events */}
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Mes événements à venir</Text>
{myEvents.slice(0, 3).map((event) => (
<Card key={event.id} onClick={() => handleEventClick(event.id)} style={{ marginBottom: 12 }}>
<Card key={event.id} onClick={() => navigate(`/events/${event.id}`)} style={{ marginBottom: 12 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{event.title}</Text>
<Text className="user-content" style={{ margin: '4px 0 0 0', fontSize: 14 }}>
{event.date}
</Text>
<Text style={{ margin: '4px 0 0 0', fontSize: 13, color: '#888' }}>{event.date}</Text>
</Card>
))}
<Button style={{ width: '100%' }} onClick={() => navigate('events')}>
<Button style={{ width: '100%' }} onClick={() => navigate('/events')}>
Voir tous les événements
</Button>
</div>
<Divider />
{/* Quick actions */}
<div style={{ padding: '0 16px 16px' }}>
<div
className="sketchy-list-item"
onClick={() => navigate('create-event')}
>
<div className="app-list-item" onClick={() => navigate('/events/new')}>
<span style={{ marginRight: 12 }}>+</span>
<Text style={{ margin: 0 }}>Relayer un événement</Text>
</div>
<div className="sketchy-list-item" onClick={() => navigate('friends-list')}>
<div className="app-list-item" onClick={() => navigate('/profile/friends')}>
<span style={{ marginRight: 12 }}>👥</span>
<Text style={{ margin: 0 }}>Mes amis</Text>
<Text style={{ margin: 0 }}>Mon réseau</Text>
</div>
<div className="sketchy-list-item">
<div className="app-list-item">
<span style={{ marginRight: 12 }}>📜</span>
<Text style={{ margin: 0 }}>Événements passés</Text>
</div>
</div>
</div>
{/* Bottom Nav */}
<NavBar
items={[
{ icon: '⌂', label: 'Accueil', onClick: () => navigate('home') },
{ icon: '◎', label: 'Découvrir', onClick: () => navigate('events') },
{ icon: '+', label: 'Relayer', onClick: () => navigate('create-event') },
{ icon: '☺', label: 'Profil', active: true },
]}
/>
<BottomNav active="profile" />
</div>
);
}
+19 -26
View File
@@ -1,9 +1,10 @@
import React from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Button, Card, Divider, Avatar } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
import { useNavigate } from '../../../app/router';
export function ShareProfileScreen({ navigate }: ScreenProps) {
export function ShareProfileScreen() {
const navigate = useNavigate();
const { currentUser } = useFestipodData();
const user = currentUser;
const profileLink = `festipod.app/u/${(user?.username ?? '').replace('@', '')}`;
@@ -12,56 +13,52 @@ export function ShareProfileScreen({ navigate }: ScreenProps) {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Partager mon profil"
left={<span onClick={() => navigate('profile')} style={{ cursor: 'pointer' }}>←</span>}
left={<ArrowLeft size={20} onClick={() => navigate('/profile')} style={{ cursor: 'pointer' }} />}
/>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
{/* QR Code */}
<Card style={{ textAlign: 'center', padding: 24 }}>
<div style={{
width: 180,
height: 180,
margin: '0 auto 16px',
border: '3px solid var(--sketch-black)',
borderRadius: 12,
border: '2px solid #e0e0e0',
borderRadius: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--sketch-white)',
background: '#fff',
position: 'relative',
}}>
{/* Simulated QR code pattern */}
<div style={{
width: 150,
height: 150,
background: `
linear-gradient(90deg, var(--sketch-black) 10%, transparent 10%, transparent 20%, var(--sketch-black) 20%, var(--sketch-black) 30%, transparent 30%, transparent 40%, var(--sketch-black) 40%, var(--sketch-black) 50%, transparent 50%, transparent 60%, var(--sketch-black) 60%, var(--sketch-black) 70%, transparent 70%, transparent 80%, var(--sketch-black) 80%, var(--sketch-black) 90%, transparent 90%),
linear-gradient(var(--sketch-black) 10%, transparent 10%, transparent 20%, var(--sketch-black) 20%, var(--sketch-black) 30%, transparent 30%, transparent 40%, var(--sketch-black) 40%, var(--sketch-black) 50%, transparent 50%, transparent 60%, var(--sketch-black) 60%, var(--sketch-black) 70%, transparent 70%, transparent 80%, var(--sketch-black) 80%, var(--sketch-black) 90%, transparent 90%)
linear-gradient(90deg, #1a1a1a 10%, transparent 10%, transparent 20%, #1a1a1a 20%, #1a1a1a 30%, transparent 30%, transparent 40%, #1a1a1a 40%, #1a1a1a 50%, transparent 50%, transparent 60%, #1a1a1a 60%, #1a1a1a 70%, transparent 70%, transparent 80%, #1a1a1a 80%, #1a1a1a 90%, transparent 90%),
linear-gradient(#1a1a1a 10%, transparent 10%, transparent 20%, #1a1a1a 20%, #1a1a1a 30%, transparent 30%, transparent 40%, #1a1a1a 40%, #1a1a1a 50%, transparent 50%, transparent 60%, #1a1a1a 60%, #1a1a1a 70%, transparent 70%, transparent 80%, #1a1a1a 80%, #1a1a1a 90%, transparent 90%)
`,
backgroundSize: '15px 15px',
opacity: 0.8,
borderRadius: 8,
}} />
{/* Center avatar */}
<div style={{
position: 'absolute',
background: 'var(--sketch-white)',
background: '#fff',
padding: 4,
borderRadius: '50%',
}}>
<Avatar initials={user?.initials ?? '?'} size="sm" />
<Avatar initials={user?.initials ?? '?'} color="#E8590C" size="sm" />
</div>
</div>
<Text className="user-content" style={{ fontWeight: 'bold', margin: '0 0 4px 0' }}>{user?.name}</Text>
<Text style={{ color: 'var(--sketch-gray)', margin: 0, fontSize: 14 }}>
<Text style={{ color: '#888', margin: 0, fontSize: 14 }}>
Scannez pour me retrouver sur Festipod
</Text>
</Card>
<Divider />
{/* Link section */}
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Mon lien de profil</Text>
<Card style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{
@@ -71,6 +68,7 @@ export function ShareProfileScreen({ navigate }: ScreenProps) {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#888',
}}>
{profileLink}
</Text>
@@ -80,21 +78,16 @@ export function ShareProfileScreen({ navigate }: ScreenProps) {
<Divider />
{/* Stats */}
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Statistiques de parrainage</Text>
<Card>
<div style={{ display: 'flex', justifyContent: 'space-around', textAlign: 'center' }}>
<div>
<Text style={{ fontWeight: 'bold', fontSize: 24, margin: 0, color: 'var(--sketch-black)' }}>12</Text>
<Text style={{ fontSize: 12, color: 'var(--sketch-gray)', margin: 0 }}>
Personnes parrainées
</Text>
<Text style={{ fontWeight: 'bold', fontSize: 24, margin: 0, color: '#E8590C' }}>12</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Personnes parrainées</Text>
</div>
<div>
<Text style={{ fontWeight: 'bold', fontSize: 24, margin: 0, color: 'var(--sketch-black)' }}>47</Text>
<Text style={{ fontSize: 12, color: 'var(--sketch-gray)', margin: 0 }}>
Scans du QR code
</Text>
<Text style={{ fontWeight: 'bold', fontSize: 24, margin: 0, color: '#E8590C' }}>47</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Scans du QR code</Text>
</div>
</div>
</Card>
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import { Header, Text, Input, Button, Avatar } from '../../../shared/components/sketchy';
import { Header, Text, Input, Button, Avatar, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
import { useNavigate } from '../../../app/router';
export function UpdateProfileScreen({ navigate }: ScreenProps) {
export function UpdateProfileScreen() {
const navigate = useNavigate();
const { currentUser, updateProfile } = useFestipodData();
const user = currentUser;
@@ -17,28 +18,21 @@ export function UpdateProfileScreen({ navigate }: ScreenProps) {
const handleSave = () => {
const fullName = `${firstName} ${lastName}`.trim();
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase();
updateProfile({
name: fullName,
initials,
username,
city,
bio,
});
navigate('profile');
updateProfile({ name: fullName, initials, username, city, bio });
showToast('Profil mis à jour', 'success');
navigate('/profile');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier le profil"
left={<span onClick={() => navigate('profile')} style={{ cursor: 'pointer' }}>✕</span>}
left={<span onClick={() => navigate('/profile')} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
/>
{/* Content */}
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
{/* Photo */}
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Avatar initials={user?.initials ?? '?'} size="lg" />
<Avatar initials={user?.initials ?? '?'} color="#E8590C" size="lg" />
<Button style={{ marginTop: 12 }}>
Changer la photo
</Button>
@@ -46,29 +40,29 @@ export function UpdateProfileScreen({ navigate }: ScreenProps) {
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Prénom *</Text>
<Text style={{ marginBottom: 6, fontSize: 13, color: '#888' }}>Prénom *</Text>
<Input value={firstName} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFirstName(e.target.value)} />
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Nom *</Text>
<Text style={{ marginBottom: 6, fontSize: 13, color: '#888' }}>Nom *</Text>
<Input value={lastName} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLastName(e.target.value)} />
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Pseudo</Text>
<Text style={{ marginBottom: 6, fontSize: 13, color: '#888' }}>Pseudo</Text>
<Input value={username} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)} />
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Localisation</Text>
<Text style={{ marginBottom: 6, fontSize: 13, color: '#888' }}>Localisation</Text>
<Input value={city} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCity(e.target.value)} placeholder="Ville, Pays" />
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Bio</Text>
<Text style={{ marginBottom: 6, fontSize: 13, color: '#888' }}>Bio</Text>
<textarea
className="sketchy-input"
className="app-input"
value={bio}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setBio(e.target.value)}
rows={3}
@@ -78,8 +72,7 @@ export function UpdateProfileScreen({ navigate }: ScreenProps) {
</div>
</div>
{/* Footer */}
<div style={{ padding: 16, borderTop: '2px solid var(--sketch-black)' }}>
<div style={{ padding: 16, borderTop: '1px solid #f0f0f0' }}>
<Button
variant="primary"
style={{ width: '100%' }}
+29 -47
View File
@@ -1,20 +1,19 @@
import React from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Title, Text, Button, Card, Badge, Divider } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
import { useNavigate, useGoBack, useParams } from '../../../app/router';
export function UserProfileScreen({ navigate }: ScreenProps) {
const { users, currentUserId, selectedUser, getUserEvents, addFriend, getFriends, setSelectedEventId } = useFestipodData();
// Use selectedUser from context, fallback to first non-current user
const viewedUser = selectedUser
|| users.find(u => u.id !== currentUserId);
export function UserProfileScreen() {
const navigate = useNavigate();
const goBack = useGoBack();
const { userId } = useParams();
const { users, currentUserId, getUser, getUserEvents, addFriend, getFriends } = useFestipodData();
const viewedUser = userId ? getUser(userId) : users.find(u => u.id !== currentUserId);
const friends = getFriends();
const isFriend = viewedUser ? friends.some(f => f.id === viewedUser.id) : false;
const userEvents = viewedUser ? getUserEvents(viewedUser.id) : [];
// Hardcoded past events for this mockup view (not all in store yet)
const pastEvents = [
{ title: 'Forum Ouvert Transition', date: '22 jan.', location: "Tiers-lieu L'Hermitage", distance: 89, common: true },
{ title: 'Rencontre des Colibris', date: '12 jan.', location: "La Maison de l'Environnement", distance: 7, common: true },
@@ -26,29 +25,27 @@ export function UserProfileScreen({ navigate }: ScreenProps) {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Profil"
left={<span onClick={() => navigate('event-detail')} style={{ cursor: 'pointer' }}>←</span>}
left={<ArrowLeft size={20} onClick={goBack} style={{ cursor: 'pointer' }} />}
/>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto' }}>
{/* User profile header */}
<div style={{ padding: 24, textAlign: 'center' }}>
<Avatar initials={viewedUser?.initials ?? '?'} size="lg" />
<Avatar initials={viewedUser?.initials ?? '?'} color="#2B6CB0" size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>{viewedUser?.name}</Title>
<Text className="user-content" style={{ margin: 0 }}>{viewedUser?.username}</Text>
<Text className="user-content" style={{ margin: 0, color: '#888' }}>{viewedUser?.username}</Text>
<div style={{ display: 'flex', justifyContent: 'center', gap: 32, marginTop: 20 }}>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{viewedUser?.eventsCount ?? userEvents.length}</Text>
<Text style={{ fontSize: 12, color: 'var(--sketch-gray)', margin: 0 }}>Événements</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Événements</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{viewedUser?.friendsCount ?? 23}</Text>
<Text style={{ fontSize: 12, color: 'var(--sketch-gray)', margin: 0 }}>Contacts</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Contacts</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{viewedUser?.participationsCount ?? 42}</Text>
<Text style={{ fontSize: 12, color: 'var(--sketch-gray)', margin: 0 }}>Participations</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Participations</Text>
</div>
</div>
@@ -65,28 +62,20 @@ export function UserProfileScreen({ navigate }: ScreenProps) {
<Divider />
{/* Upcoming events from store */}
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements à venir</Text>
{(userEvents.length > 0 ? userEvents : [
{ id: 'event-1', title: 'Résidence Reconnexion', date: '16-20 fév.', location: 'Le Revel, Rogues (30)', distance: 142 },
]).map((event) => (
<Card key={event.id} onClick={() => { setSelectedEventId(event.id); navigate('event-detail'); }} style={{ marginBottom: 12 }}>
{userEvents.map((event) => (
<Card key={event.id} onClick={() => navigate(`/events/${event.id}`)} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{event.title}</Text>
<Text className="user-content" style={{ margin: '4px 0 0 0', fontSize: 14 }}>
{event.date}
</Text>
<Text style={{ margin: '2px 0 0 0', fontSize: 14 }}>
<Text style={{ margin: '4px 0 0 0', fontSize: 13, color: '#888' }}>{event.date}</Text>
<Text style={{ margin: '2px 0 0 0', fontSize: 13, color: '#888' }}>
📍 <span className="user-content">{event.location}</span>
{event.distance != null && (
<span style={{ color: 'var(--sketch-gray)' }}> · {event.distance} km</span>
)}
{event.distance != null && ` · ${event.distance} km`}
</Text>
</div>
<Badge>moi aussi</Badge>
<Badge style={{ background: '#FFF7ED', color: '#E8590C' }}>moi aussi</Badge>
</div>
</Card>
))}
@@ -94,24 +83,19 @@ export function UserProfileScreen({ navigate }: ScreenProps) {
<Divider />
{/* Past events (still hardcoded for mockup) */}
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements passés</Text>
{pastEvents.map((event, i) => (
<Card key={i} onClick={() => navigate('event-detail')} style={{ marginBottom: 12 }}>
<Card key={i} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{event.title}</Text>
<Text className="user-content" style={{ margin: '4px 0 0 0', fontSize: 14 }}>
{event.date}
</Text>
<Text style={{ margin: '2px 0 0 0', fontSize: 14 }}>
📍 <span className="user-content">{event.location}</span>
<span style={{ color: 'var(--sketch-gray)' }}> · {event.distance} km</span>
<Text style={{ margin: 0, fontWeight: 'bold' }}>{event.title}</Text>
<Text style={{ margin: '4px 0 0 0', fontSize: 13, color: '#888' }}>{event.date}</Text>
<Text style={{ margin: '2px 0 0 0', fontSize: 13, color: '#888' }}>
📍 {event.location} · {event.distance} km
</Text>
</div>
{event.common && <Badge>moi aussi</Badge>}
{event.common && <Badge style={{ background: '#FFF7ED', color: '#E8590C' }}>moi aussi</Badge>}
</div>
</Card>
))}
@@ -119,17 +103,15 @@ export function UserProfileScreen({ navigate }: ScreenProps) {
<Divider />
{/* Contact form section */}
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Envoyer un message</Text>
<div style={{
border: '2px solid var(--sketch-black)',
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
border: '1.5px solid #e0e0e0',
borderRadius: 12,
padding: 12,
minHeight: 80,
fontFamily: 'var(--font-sketch)',
fontSize: 14,
color: 'var(--sketch-gray)',
color: '#bbb',
}}>
Écrivez votre message ici...
</div>
+61 -36
View File
@@ -3,77 +3,83 @@ import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
When('je clique sur un participant', async function (this: FestipodWorld) {
this.navigateTo('#/demo/user-profile');
await this.navigateTo('#/demo/user-profile');
});
Given('je visualise le profil de {string}', async function (this: FestipodWorld, userName: string) {
this.navigateTo('#/demo/user-profile');
await this.navigateTo('#/demo/user-profile');
expect(this.currentScreen, 'User profile screen should be loaded').to.not.be.null;
this.attach(`Viewing profile: ${userName}`, 'text/plain');
});
Then('je peux voir mon profil', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('profile');
const source = this.getRenderedText();
expect(/<Avatar[^>]*initials="MD"[^>]*size="lg"/.test(source), 'Profile should have Avatar with initials="MD" and size="lg"').to.be.true;
expect(/<Title[^>]*>Marie Dupont<\/Title>/.test(source), 'Profile should have Title "Marie Dupont"').to.be.true;
expect(/@mariedupont/.test(source), 'Profile should have username @mariedupont').to.be.true;
expectProfileDom(this, { initials: 'MD', name: 'Marie Dupont', username: '@mariedupont' });
});
Then('je peux voir le profil de l\'utilisateur', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('user-profile');
const source = this.getRenderedText();
expect(/<Avatar[^>]*initials="JD"[^>]*size="lg"/.test(source), 'User profile should have Avatar with initials="JD" and size="lg"').to.be.true;
expect(/<Title[^>]*>Jean Durand<\/Title>/.test(source), 'User profile should have Title "Jean Durand"').to.be.true;
expect(/@jeandurand/.test(source), 'User profile should have username @jeandurand').to.be.true;
// user-profile defaults to the first non-current seed user when no userId is
// bound — see UserProfileScreen.tsx. With current seed data that's Jean Durand.
expectProfileDom(this, { initials: 'JD', name: 'Jean Durand', username: '@jeandurand' });
});
Then('l\'écran affiche les informations du profil', async function (this: FestipodWorld) {
const source = this.getRenderedText();
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const text = doc!.body.textContent ?? '';
if (this.currentScreenId === 'profile') {
expect(/<Avatar[^>]*initials="MD"/.test(source), 'Profile should have Avatar with initials="MD"').to.be.true;
expect(/<Title[^>]*>Marie Dupont<\/Title>/.test(source), 'Profile should have Title "Marie Dupont"').to.be.true;
expect(/@mariedupont/.test(source), 'Profile should have username @mariedupont').to.be.true;
expect(text.includes('Marie Dupont'), 'Profile should display "Marie Dupont"').to.be.true;
expect(text.includes('@mariedupont'), 'Profile should display "@mariedupont"').to.be.true;
} else if (this.currentScreenId === 'user-profile') {
expect(/<Avatar[^>]*initials="JD"/.test(source), 'User profile should have Avatar with initials="JD"').to.be.true;
expect(/<Title[^>]*>Jean Durand<\/Title>/.test(source), 'User profile should have Title "Jean Durand"').to.be.true;
expect(/@jeandurand/.test(source), 'User profile should have username @jeandurand').to.be.true;
expect(text.includes('Jean Durand'), 'User profile should display "Jean Durand"').to.be.true;
expect(text.includes('@jeandurand'), 'User profile should display "@jeandurand"').to.be.true;
} else {
expect.fail(`Unexpected screen "${this.currentScreenId}" for profile info check`);
}
const avatar = doc!.querySelector('.app-avatar');
expect(avatar, 'Profile screen should render an avatar').to.not.be.null;
});
Then('je peux contacter l\'utilisateur', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('user-profile');
const source = this.getRenderedText();
const hasContactButton = /<Button>Contacter<\/Button>/.test(source);
expect(hasContactButton, 'User profile should have "Contacter" button').to.be.true;
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');
expect(buttons.some(t => t.includes('Contacter')),
'User profile should expose a "Contacter" button').to.be.true;
});
Then('je peux voir les événements auxquels l\'utilisateur a participé', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('user-profile');
const source = this.getRenderedText();
expect(/Événements à venir/.test(source), 'User profile should have "Événements à venir" section').to.be.true;
expect(/Événements passés/.test(source), 'User profile should have "Événements passés" section').to.be.true;
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const text = doc!.body.textContent ?? '';
expect(text.includes('Événements à venir'),
'User profile should have "Événements à venir" section').to.be.true;
expect(text.includes('Événements passés'),
'User profile should have "Événements passés" section').to.be.true;
});
Then('les événements affichent leur localisation et distance', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('user-profile');
const source = this.getRenderedText();
expect(/location: '[^']+'/.test(source), 'Events should have location data').to.be.true;
expect(/distance: \d+/.test(source), 'Events should have distance data').to.be.true;
expect(/\{event\.location\}/.test(source), 'Events should render location').to.be.true;
expect(/\{event\.distance\}/.test(source), 'Events should render distance').to.be.true;
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const text = doc!.body.textContent ?? '';
expect(text.includes('📍'), 'Events should show a 📍 location icon').to.be.true;
expect(/\d+\s*km/.test(text), 'Events should show a distance in km').to.be.true;
});
Then('je peux voir le QR code', async function (this: FestipodWorld) {
const source = this.getRenderedText();
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const text = doc!.body.textContent ?? '';
if (this.currentScreenId === 'share-profile') {
expect(/QR Code/.test(source), 'Share profile should have "QR Code" text').to.be.true;
expect(/Scannez pour me retrouver/.test(source), 'Share profile should have "Scannez pour me retrouver" text').to.be.true;
expect(text.includes('Scannez pour me retrouver'),
'Share profile should invite to scan for follow-up').to.be.true;
} else if (this.currentScreenId === 'meeting-points') {
expect(/Mon QR Code/.test(source), 'Meeting points should have "Mon QR Code" text').to.be.true;
expect(/Scannez pour m'ajouter/.test(source), 'Meeting points should have "Scannez pour m\'ajouter" text').to.be.true;
expect(text.includes('QR') || text.includes('Scannez'),
'Meeting points should reference a QR code').to.be.true;
} else {
expect.fail(`QR code should be on share-profile or meeting-points, not "${this.currentScreenId}"`);
}
@@ -81,7 +87,26 @@ Then('je peux voir le QR code', async function (this: FestipodWorld) {
Then('je peux voir le lien de partage', async function (this: FestipodWorld) {
expect(this.currentScreenId, 'Share link should be on share-profile screen').to.equal('share-profile');
const source = this.getRenderedText();
expect(/Mon lien de profil/.test(source), 'Share profile should have "Mon lien de profil" text').to.be.true;
expect(/festipod\.app\/u\//.test(source), 'Share profile should have profile link URL').to.be.true;
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const text = doc!.body.textContent ?? '';
expect(text.includes('Mon lien de profil'),
'Share profile should label the profile link section').to.be.true;
expect(/festipod\.app\/u\//.test(text),
'Share profile should display a festipod.app/u/… link').to.be.true;
});
function expectProfileDom(
world: FestipodWorld,
expected: { initials: string; name: string; username: string },
) {
const doc = world.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const text = doc!.body.textContent ?? '';
expect(text.includes(expected.name), `Profile should display "${expected.name}"`).to.be.true;
expect(text.includes(expected.username), `Profile should display "${expected.username}"`).to.be.true;
const avatar = doc!.querySelector('.app-avatar');
expect(avatar, 'Profile should render an avatar').to.not.be.null;
expect((avatar?.textContent ?? '').trim(), `Avatar should show initials "${expected.initials}"`)
.to.equal(expected.initials);
}