NextGraph integration (WIP), broker banner, and feature-based architecture

- Add NextGraph data layer with @ng-org/orm, SHEX shapes (Event, UserProfile,
  Participation), session management, and FestipodDataContext with dual-mode
  operation (connected via NextGraph or local seed data)
- Add BrokerBanner and NgStatus components showing connection status
- Refactor to feature-based architecture: organize code by business domain
  (event, user, home, auth, workshop, meeting, notification) instead of
  technical layer. Modules only import from shared/, never from each other
- Collocate BDD features and step definitions with their modules: event-specific
  steps in event/steps/, user steps in user/steps/, shared generic steps remain
  in shared/steps/
- Set up multi-layer BDD structure (frontend/backend/e2e steps per module)
- Add project documentation (AGENTS.md, .project/knowledge/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-03-11 12:19:45 +01:00
parent c9bc957d2a
commit 901fd659df
128 changed files with 5738 additions and 2885 deletions
@@ -0,0 +1,33 @@
# language: fr
@USER @priority-1
Fonctionnalité: US-10 Visualiser la fiche/le profil d'un participant
En tant qu'utilisateur
Je peux sélectionner un individu dans la liste des inscrits à un événement/atelier
Afin de voir les événements auxquels la personne a participé et voir un formulaire de contact
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder au profil d'un participant
Étant donné que je suis sur la page "détail événement"
Quand je clique sur un participant
Alors je vois l'écran "user-profile"
Scénario: Voir les événements du participant
Étant donné que je suis sur la page "profil utilisateur"
Alors je peux voir les événements auxquels l'utilisateur a participé
Scénario: Voir la localisation des événements
Étant donné que je suis sur la page "profil utilisateur"
Alors les événements affichent leur localisation et distance
Scénario: Voir le formulaire de contact
Étant donné que je suis sur la page "profil utilisateur"
Alors je peux contacter l'utilisateur
Scénario: Vérifier les informations du profil
* Scénario non implémenté
Scénario: Voir les détails du profil utilisateur
Étant donné que je suis sur la page "profil utilisateur"
Alors l'écran affiche les informations du profil
@@ -0,0 +1,34 @@
# language: fr
@USER @priority-2
Fonctionnalité: US-12 Consulter la carte/tableau des événements
En tant qu'utilisateur
Je peux consulter la carte/tableau des événements auxquels j'ai participé
En filtrant les événements par dates ou par personne
Afin d'avoir une vue consolidée des événements et lieux de rencontre
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder à la liste des événements depuis le profil
* Scénario non implémenté
Scénario: Accéder à la liste des événements depuis découvrir
Étant donné que je suis sur la page "découvrir"
Alors je peux voir la liste des événements
Scénario: Filtrer par date
* Scénario non implémenté
Scénario: Filtrer par personne
Étant donné que je suis sur la page "profil utilisateur"
Alors je peux voir les événements auxquels l'utilisateur a participé
Scénario: Vérifier les données de l'écran événements
Étant donné que je suis sur la page "découvrir"
Alors les événements affichent leur lieu
Scénario: Vérifier les données de l'écran profil
* Scénario non implémenté
Scénario: Voir la vue carte des événements
* Scénario non implémenté
@@ -0,0 +1,29 @@
# language: fr
@USER @priority-1
Fonctionnalité: US-15 Visualiser les inscrits à un atelier/événement
En tant qu'utilisateur
Je peux visualiser les inscrits à un atelier/événement
En sélectionnant l'atelier/l'événement désiré dans la liste
Afin de consulter la liste des inscrits triée par ordre alphabétique
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder à la liste des inscrits d'un événement
Étant donné que je suis sur la page "détail événement"
Alors je peux voir la liste des participants
Scénario: Accéder à la liste des inscrits d'un atelier
* Scénario non implémenté
Scénario: Voir la liste des participants d'un événement
Étant donné que je suis sur la page "détail événement"
Alors l'écran contient une section "Participants"
Scénario: Voir la liste des participants d'un atelier
* Scénario non implémenté
Scénario: Cliquer sur un inscrit pour voir son profil
Étant donné que je suis sur la page "détail événement"
Quand je clique sur un participant
Alors je vois l'écran "user-profile"
@@ -0,0 +1,38 @@
# language: fr
@USER @priority-1
Fonctionnalité: US-20 Voir le profil des personnes faisant partie de mon réseau
En tant qu'utilisateur
Je peux voir le profil des personnes faisant partie de mon réseau
Ainsi que le profil des personnes publiques
Et consulter la description de l'événement afin de savoir si je veux participer
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder à mon profil
Étant donné que je suis sur la page "accueil"
Quand je navigue vers "mon profil"
Alors je vois l'écran "profile"
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"
Scénario: Voir un profil de mon réseau
Étant donné que je suis sur la page "mon profil"
Quand je clique sur un participant
Alors je vois l'écran "user-profile"
Scénario: Consulter un événement depuis un profil
Étant donné que je suis sur la page "profil utilisateur"
Quand je clique sur un événement
Alors je vois l'écran "event-detail"
Scénario: Vérifier les données du profil
Étant donné que je suis sur la page "mon profil"
Alors l'écran contient un texte "Événements"
Et l'écran contient un texte "Participations"
Scénario: Voir les profils publiques
* Scénario non implémenté
@@ -0,0 +1,28 @@
# language: fr
@USER @priority-2
Fonctionnalité: US-21 Décider que tous les utilisateurs puissent suivre mes activités
En tant qu'utilisateur
Je peux décider que tous les utilisateurs puissent suivre toutes mes activités
En déclarant mon profil public
Afin de communiquer au sujet de mes déplacements et faire la publicité des événements
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder aux paramètres de profil
Étant donné que je suis sur la page "mon profil"
Quand je navigue vers "paramètres"
Alors je vois l'écran "settings"
Scénario: Configurer la visibilité du profil
Étant donné que je suis sur la page "paramètres"
Alors l'écran contient une section "Confidentialité"
Scénario: Rendre le profil public
* Scénario non implémenté
Scénario: Vérifier les données des paramètres
* Scénario non implémenté
Scénario: Vérifier les données du profil
* Scénario non implémenté
@@ -0,0 +1,34 @@
# language: fr
@USER @priority-2
Fonctionnalité: US-22 Parrainer un nouvel utilisateur
En tant qu'utilisateur
Je peux parrainer un nouvel utilisateur
En lui partageant mon QR code ou lien de contact
Afin de savoir combien de personnes ont rejoint le réseau grâce à moi
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder au partage de profil
Étant donné que je suis sur la page "profil"
Alors l'écran contient un bouton "Partager"
Scénario: Naviguer vers le partage de profil
Étant donné que je suis sur la page "profil"
Quand je navigue vers "partage de profil"
Alors je vois l'écran "share-profile"
Scénario: Voir le QR code de parrainage
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient un texte "Scannez pour me retrouver sur Festipod"
Scénario: Voir le lien de parrainage
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient une section "Mon lien de profil"
Et l'écran contient un bouton "Copier"
Scénario: Voir les statistiques de parrainage
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient une section "Statistiques de parrainage"
Et l'écran contient un texte "Personnes parrainées"
Et l'écran contient un texte "Scans du QR code"
@@ -0,0 +1,31 @@
# language: fr
@USER @priority-1
Fonctionnalité: US-23 Me connecter avec d'autres utilisateurs
En tant qu'utilisateur
Je peux me connecter avec d'autres utilisateurs
En partageant mon QR code ou mon lien de contact
Afin d'étendre mon réseau
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder au partage depuis le profil
Étant donné que je suis sur la page "mon profil"
Alors l'écran contient une section "Partager"
Scénario: Voir le QR code
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient un texte "Scannez pour me retrouver sur Festipod"
Scénario: Voir le lien de partage
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient une section "Mon lien de profil"
Scénario: Accéder à l'écran de partage dédié
Étant donné que je suis sur la page "mon profil"
Quand je navigue vers "partage de profil"
Alors je vois l'écran "share-profile"
Scénario: Vérifier les données du profil
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient un texte "Marie Dupont"
@@ -0,0 +1,20 @@
# language: fr
@USER @priority-2
Fonctionnalité: US-24 Être notifié des activités de mes contacts
En tant qu'utilisateur
Je peux être notifié lorsqu'un contact participe à des événements
Afin d'obtenir une synthèse du contenu des ateliers et événements
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder aux paramètres de notification
Étant donné que je suis sur la page "paramètres"
Alors l'écran contient une section "Notifications"
Scénario: Configurer les notifications de contacts
Étant donné que je suis sur la page "paramètres"
Alors je peux configurer mes notifications
Scénario: Voir les activités de mes contacts sur l'accueil
* Scénario non implémenté
@@ -0,0 +1,20 @@
# language: fr
@USER @priority-2
Fonctionnalité: US-25 Être averti des événements susceptibles de m'intéresser
En tant qu'utilisateur
Je peux être notifié lorsqu'un nouvel événement est ajouté près de chez moi
Et/ou avec une thématique qui m'intéresse
En configurant mes notifications
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder aux paramètres de notification
Étant donné que je suis sur la page "paramètres"
Alors l'écran contient une section "Notifications"
Scénario: Configurer le rayon de notification
* Scénario non implémenté
Scénario: Configurer les thématiques d'intérêt
* Scénario non implémenté
@@ -0,0 +1,31 @@
# language: fr
@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
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
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder au formulaire de relai d'événement
Étant donné que je suis sur la page "accueil"
Quand je navigue vers "relayer un événement"
Alors je vois l'écran "create-event"
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 |
@@ -0,0 +1,32 @@
# language: fr
@USER @priority-0
Fonctionnalité: US-9 Visualiser la photo d'un individu
En tant qu'utilisateur
Je peux visualiser la photo d'un individu ou ajouter une photo personnelle sur une fiche existante
Et consulter la liste des inscrits à un atelier
Afin d'identifier les personnes que j'ai rencontrées dont je n'ai pas noté leur nom
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder au profil pour voir la photo
Étant donné que je suis sur la page "mon profil"
Alors l'écran contient un avatar
Scénario: Naviguer vers le profil depuis la liste des participants
Étant donné que je suis sur la page "détail événement"
Quand je clique sur un participant
Alors je suis redirigé vers "profil utilisateur"
Et l'écran affiche les informations du profil
Scénario: Consulter la liste des inscrits à un atelier
Étant donné que je suis sur la page "détail événement"
Alors je peux voir la liste des participants
Scénario: Vérifier les champs de données du profil
Étant donné que je suis sur la page "mon profil"
Alors l'écran contient un texte "Marie Dupont"
Et l'écran contient un texte "@mariedupont"
Scénario: Ajouter une photo personnelle sur une fiche existante
* Scénario non implémenté
@@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { Header, Text, Avatar, Input, Button, Badge } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
export function FriendsListScreen({ navigate }: ScreenProps) {
const { getFriends, users, setSelectedUserId } = useFestipodData();
const [activeTab, setActiveTab] = useState<'friends' | 'public'>('friends');
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>}
/>
{/* 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)' }}>
<Input placeholder="Rechercher..." />
</div>
{/* List */}
<div style={{ flex: 1, overflow: 'auto' }}>
{displayedList.map((person) => (
<div
key={person.id}
onClick={() => { setSelectedUserId(person.id); navigate('user-profile'); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 16px',
cursor: 'pointer',
borderBottom: '1px solid var(--sketch-light-gray)',
}}
>
<Avatar initials={person.initials} 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 }}>
<span className="user-content">{person.username}</span>
{person.eventsCount != null && (
<span style={{ color: 'var(--sketch-gray)' }}> · {person.eventsCount} événements</span>
)}
</Text>
</div>
<Text style={{ margin: 0, fontSize: 20, color: 'var(--sketch-gray)' }}>›</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>
);
}
+106
View File
@@ -0,0 +1,106 @@
import React from 'react';
import { Header, Avatar, Title, Text, Button, Card, Badge, Divider, NavBar } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
export function ProfileScreen({ navigate }: ScreenProps) {
const { currentUser, getUserEvents, currentUserId, getFriends, setSelectedEventId } = 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>}
/>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto' }}>
{/* Profile header */}
<div style={{ padding: 24, textAlign: 'center' }}>
<Avatar initials={user?.initials ?? '?'} size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>{user?.name}</Title>
<Text className="user-content" style={{ margin: 0 }}>{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>
</div>
<div style={{ textAlign: 'center', cursor: 'pointer' }} onClick={() => navigate('friends-list')}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{user?.friendsCount ?? friends.length}</Text>
<Text style={{ fontSize: 12, color: 'var(--sketch-gray)', 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>
</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>
</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 }}>
<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>
</Card>
))}
<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')}
>
<span style={{ marginRight: 12 }}>+</span>
<Text style={{ margin: 0 }}>Relayer un événement</Text>
</div>
<div className="sketchy-list-item" onClick={() => navigate('friends-list')}>
<span style={{ marginRight: 12 }}>👥</span>
<Text style={{ margin: 0 }}>Mes amis</Text>
</div>
<div className="sketchy-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 },
]}
/>
</div>
);
}
@@ -0,0 +1,104 @@
import React from 'react';
import { Header, Text, Button, Card, Divider, Avatar } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
export function ShareProfileScreen({ navigate }: ScreenProps) {
const { currentUser } = useFestipodData();
const user = currentUser;
const profileLink = `festipod.app/u/${(user?.username ?? '').replace('@', '')}`;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Partager mon profil"
left={<span onClick={() => navigate('profile')} style={{ cursor: 'pointer' }}>←</span>}
/>
{/* 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,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--sketch-white)',
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%)
`,
backgroundSize: '15px 15px',
opacity: 0.8,
}} />
{/* Center avatar */}
<div style={{
position: 'absolute',
background: 'var(--sketch-white)',
padding: 4,
borderRadius: '50%',
}}>
<Avatar initials={user?.initials ?? '?'} 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 }}>
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={{
margin: 0,
flex: 1,
fontSize: 14,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{profileLink}
</Text>
<Button style={{ flexShrink: 0, padding: '8px 10px' }} title="Copier le lien">📋</Button>
<Button variant="primary" style={{ flexShrink: 0, padding: '8px 10px' }} title="Partager">↗</Button>
</Card>
<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>
</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>
</div>
</div>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { Header, Text, Input, Button, Avatar } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
export function UpdateProfileScreen({ navigate }: ScreenProps) {
const { currentUser, updateProfile } = useFestipodData();
const user = currentUser;
const nameParts = (user?.name ?? '').split(' ');
const [firstName, setFirstName] = useState(nameParts[0] ?? '');
const [lastName, setLastName] = useState(nameParts.slice(1).join(' '));
const [username, setUsername] = useState(user?.username ?? '');
const [city, setCity] = useState(user?.city ?? 'Lyon, France');
const [bio, setBio] = useState(user?.bio ?? '');
const handleSave = () => {
const fullName = `${firstName} ${lastName}`.trim();
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase();
updateProfile({
name: fullName,
initials,
username,
city,
bio,
});
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>}
/>
{/* Content */}
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
{/* Photo */}
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Avatar initials={user?.initials ?? '?'} size="lg" />
<Button style={{ marginTop: 12 }}>
Changer la photo
</Button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>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>
<Input value={lastName} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLastName(e.target.value)} />
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Pseudo</Text>
<Input value={username} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)} />
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>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>
<textarea
className="sketchy-input"
value={bio}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setBio(e.target.value)}
rows={3}
style={{ resize: 'none' }}
/>
</div>
</div>
</div>
{/* Footer */}
<div style={{ padding: 16, borderTop: '2px solid var(--sketch-black)' }}>
<Button
variant="primary"
style={{ width: '100%' }}
onClick={handleSave}
>
Enregistrer
</Button>
</div>
</div>
);
}
@@ -0,0 +1,143 @@
import React from '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';
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);
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 },
{ title: 'Formation CNV', date: '8 jan.', location: 'MJC Montplaisir, Lyon', distance: 5, common: false },
{ title: 'Café des possibles', date: '15 déc.', location: 'Café de la Mairie, Lyon 3', distance: 2, common: false },
];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Profil"
left={<span onClick={() => navigate('event-detail')} style={{ cursor: 'pointer' }}>←</span>}
/>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto' }}>
{/* User profile header */}
<div style={{ padding: 24, textAlign: 'center' }}>
<Avatar initials={viewedUser?.initials ?? '?'} size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>{viewedUser?.name}</Title>
<Text className="user-content" style={{ margin: 0 }}>{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>
</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>
</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>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 20, justifyContent: 'center' }}>
<Button
variant={isFriend ? 'default' : 'primary'}
onClick={() => viewedUser && addFriend(viewedUser.id)}
>
{isFriend ? '✓ Dans mon réseau' : 'Ajouter au réseau'}
</Button>
<Button>Contacter</Button>
</div>
</div>
<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 }}>
<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>
{event.distance != null && (
<span style={{ color: 'var(--sketch-gray)' }}> · {event.distance} km</span>
)}
</Text>
</div>
<Badge>moi aussi</Badge>
</div>
</Card>
))}
</div>
<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 }}>
<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>
</div>
{event.common && <Badge>moi aussi</Badge>}
</div>
</Card>
))}
</div>
<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',
padding: 12,
minHeight: 80,
fontFamily: 'var(--font-sketch)',
fontSize: 14,
color: 'var(--sketch-gray)',
}}>
Écrivez votre message ici...
</div>
<Button variant="primary" style={{ width: '100%', marginTop: 12 }}>
Envoyer
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { Given, When, Then } from '@cucumber/cucumber';
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');
});
Given('je visualise le profil de {string}', async function (this: FestipodWorld, userName: string) {
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;
});
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;
});
Then('l\'écran affiche les informations du profil', async function (this: FestipodWorld) {
const source = this.getRenderedText();
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;
} 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;
} else {
expect.fail(`Unexpected screen "${this.currentScreenId}" for profile info check`);
}
});
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;
});
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;
});
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;
});
Then('je peux voir le QR code', async function (this: FestipodWorld) {
const source = this.getRenderedText();
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;
} 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;
} else {
expect.fail(`QR code should be on share-profile or meeting-points, not "${this.currentScreenId}"`);
}
});
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;
});