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
+90
View File
@@ -0,0 +1,90 @@
import React from 'react';
import { Button, Input, Title, Text, Divider } from '../../../shared/components/sketchy';
import { useNextGraph } from '../../../shared/context/NextGraphContext';
import type { ScreenProps } from '../../../screens';
export function LoginScreen({ navigate }: ScreenProps) {
const { status, connect } = useNextGraph();
const handleNgLogin = () => {
if (status === 'connected') {
navigate('home');
} else {
connect();
}
};
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 8 }}>Festipod</Title>
<Text style={{ textAlign: 'center', marginBottom: 32 }}>Créez et rejoignez des événements entre amis</Text>
{/* NextGraph login */}
<div style={{ marginBottom: 24 }}>
{status === 'connected' ? (
<div style={{ textAlign: 'center', marginBottom: 8 }}>
<Text style={{ color: 'var(--sketch-green, #4caf50)', fontWeight: 'bold', margin: '0 0 8px 0' }}>
✓ Connecté via NextGraph
</Text>
<Button variant="primary" onClick={() => navigate('home')} style={{ width: '100%' }}>
Continuer vers l'accueil
</Button>
</div>
) : status === 'connecting' ? (
<Button disabled style={{ width: '100%', opacity: 0.6 }}>
Connexion NextGraph en cours...
</Button>
) : (
<div>
<Button
variant="primary"
onClick={handleNgLogin}
style={{ width: '100%' }}
>
Se connecter avec NextGraph
</Button>
{status === 'error' && (
<Text style={{ textAlign: 'center', fontSize: 12, color: 'var(--sketch-gray)', marginTop: 8 }}>
NextGraph non disponible — mode démonstration
</Text>
)}
</div>
)}
</div>
<Divider />
{/* Classic email/password login (mockup) */}
<Text style={{ textAlign: 'center', fontSize: 14, color: 'var(--sketch-gray)', margin: '16px 0' }}>
ou connexion classique (démo)
</Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<Text style={{ marginBottom: 4, fontSize: 14 }}>Email</Text>
<Input type="email" placeholder="vous@exemple.com" />
</div>
<div>
<Text style={{ marginBottom: 4, fontSize: 14 }}>Mot de passe</Text>
<Input type="password" placeholder="••••••••" />
</div>
<Button onClick={() => navigate('home')}>
Se connecter
</Button>
<Text style={{ textAlign: 'center', fontSize: 14, color: 'var(--sketch-gray)' }}>
Mot de passe oublié ?
</Text>
</div>
</div>
<Text style={{ textAlign: 'center', fontSize: 14, color: 'var(--sketch-gray)' }}>
Pas encore de compte ? S'inscrire
</Text>
</div>
);
}
@@ -0,0 +1,58 @@
import React from 'react';
import { Button, Title, Text } from '../../../shared/components/sketchy';
import type { ScreenProps } from '../../../screens';
export function WelcomeScreen({ navigate }: ScreenProps) {
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 24 }}>Festipod</Title>
<Text style={{ textAlign: 'center', fontSize: 18, marginBottom: 32, lineHeight: 1.5 }}>
Découvrez des événements près de chez vous, relayés par des gens de confiance.
</Text>
<div style={{
background: 'var(--sketch-light-gray)',
padding: 16,
borderRadius: 8,
marginBottom: 24,
}}>
<Text style={{ margin: 0, fontSize: 14, lineHeight: 1.6, color: 'var(--sketch-gray)' }}>
Festipod est un projet collaboratif en construction. Nous croyons qu'on découvre
les meilleurs événements grâce au bouche-à-oreille, pas via des algorithmes.
Rejoignez les premiers utilisateurs et aidez-nous à créer une alternative
humaine aux réseaux sociaux traditionnels.
</Text>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 20 }}>🎪</span>
<Text style={{ margin: 0, fontSize: 14 }}>Relayez des événements à votre réseau</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 20 }}>🤝</span>
<Text style={{ margin: 0, fontSize: 14 }}>Rencontrez des personnes partageant vos centres d'intérêt</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 20 }}>🔒</span>
<Text style={{ margin: 0, fontSize: 14 }}>Vos données restent les vôtres</Text>
</div>
</div>
<Button variant="primary" onClick={() => navigate('login')} style={{ marginBottom: 12 }}>
Rejoindre la communauté
</Button>
<Text style={{ textAlign: 'center', fontSize: 13, color: 'var(--sketch-gray)' }}>
Déjà membre ? Se connecter
</Text>
</div>
<Text style={{ textAlign: 'center', fontSize: 12, color: 'var(--sketch-gray)' }}>
Version beta - 127 membres actifs
</Text>
</div>
);
}
@@ -0,0 +1,53 @@
# language: fr
@EVENT @priority-1
Fonctionnalité: US-13 Relayer/Modifier/Supprimer un événement
En tant qu'utilisateur
Je peux relayer/modifier/supprimer un événement
En choisissant les dates, horaires, lieu et thématique
Afin de relayer/présenter le contenu de cet événement et le catégoriser
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: Vérifier les champs obligatoires du formulaire
É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 |
Scénario: Vérifier la présence du bouton de relai
Étant donné que je suis sur la page "relayer un événement"
Alors l'écran contient une section "Relayer l'événement"
Scénario: Pouvoir annuler le relai d'événement
Étant donné que je suis sur la page "relayer un événement"
Alors je peux annuler et revenir à l'écran précédent
Scénario: Détecter un événement similaire déjà relayé
Étant donné que l'écran "create-event" est affiché
Alors le formulaire permet de détecter les doublons
Scénario: Importer un événement depuis une source externe
Étant donné que l'écran "create-event" est affiché
Alors le formulaire permet d'importer depuis Mobilizon ou Transiscope
Scénario: Pas d'alerte doublon lors d'un import externe
Étant donné que l'écran "create-event" est affiché
Alors l'import externe ne déclenche pas d'alerte doublon
Scénario: Modifier un événement
* Scénario non implémenté
Scénario: Supprimer un événement
* Scénario non implémenté
Scénario: Retirer une organisation (personne ou structure)
* Scénario non implémenté
@@ -0,0 +1,22 @@
# language: fr
@EVENT @priority-1
Fonctionnalité: US-3 Visualiser un événement terminé
En tant qu'utilisateur
Je peux visualiser un événement terminé et consulter la description de l'événement
Afin de voir les personnes qui ont participé à cet événement
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder aux détails d'un événement terminé
Étant donné que je suis sur la page "accueil"
Quand je clique sur un événement
Alors je vois l'écran "event-detail"
Scénario: Voir la description de l'événement
Étant donné que je suis sur la page "détail événement"
Alors l'écran affiche les informations de l'événement
Scénario: Voir la liste des participants
Étant donné que je suis sur la page "détail événement"
Alors je peux voir la liste des participants
@@ -0,0 +1,25 @@
# language: fr
@EVENT @priority-3
Fonctionnalité: US-5 Ajouter/modifier/supprimer un commentaire à un événement
En tant qu'utilisateur
Je peux consulter et ajouter/modifier/supprimer un commentaire à un événement
En sélectionnant l'icône "ajouter un commentaire" en dessous du titre
Afin de voir les commentaires précédents et ajouter mes notes personnelles
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Voir les commentaires existants
* Scénario non implémenté
Scénario: Ajouter un commentaire
* Scénario non implémenté
Scénario: Modifier un commentaire
* Scénario non implémenté
Scénario: Supprimer un commentaire
* Scénario non implémenté
Scénario: Enregistrer les interactions avec des individus (Date/Heure/Lieu)
* Scénario non implémenté
@@ -0,0 +1,30 @@
# language: fr
@EVENT @priority-1
Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
En tant qu'utilisateur
Je peux m'inscrire/me désinscrire à un événement
Après avoir consulté la description de l'événement, les dates et le lieu
S'il existe déjà dans le système ou en le retrouvant dans une base existante
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Consulter un événement avant inscription
Étant donné que je suis sur la page "détail événement"
Alors l'écran affiche les informations de l'événement
Scénario: S'inscrire à un événement
* Scénario non implémenté
Scénario: Se désinscrire d'un événement
* Scénario non implémenté
Scénario: Rechercher un événement existant
Étant donné que je suis sur la page "découvrir"
Alors je peux voir la liste des événements
Scénario: Vérifier les données de l'écran
* Scénario non implémenté
Scénario: Rechercher dans une base existante (Mobilizon)
* Scénario non implémenté
@@ -0,0 +1,34 @@
# language: fr
@EVENT @priority-3
Fonctionnalité: US-8 Consulter et m'inscrire à un macro-événement
En tant qu'utilisateur
Je peux consulter et m'inscrire à un événement de type "Macro-événement"
En créant ou en rattachant des événements existants à ce macro-événement
Afin de voir une consolidation des commentaires/liens/ressources/participants
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Consulter un macro-événement
* Scénario non implémenté
Scénario: Voir les événements rattachés
* Scénario non implémenté
Scénario: Rattacher un événement existant
* Scénario non implémenté
Scénario: Voir la consolidation des participants
* Scénario non implémenté
Scénario: Créer un macro-événement
* Scénario non implémenté
Scénario: Voir la consolidation des commentaires/liens/ressources
* Scénario non implémenté
Scénario: Rattacher à une thématique particulière
* Scénario non implémenté
Scénario: Gérer un événement répété sur plusieurs périodes
* Scénario non implémenté
@@ -0,0 +1,336 @@
import React, { useState } from 'react';
import { Header, Text, Input, Button, Placeholder } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
// Demo data for suggestions
const importableEvents = [
{
name: 'Festival des Utopies Concrètes',
source: 'Mobilizon',
date: '2026-03-15',
location: 'Paris, Parc de la Villette',
description: 'Festival annuel présentant des alternatives concrètes pour un monde durable.',
},
{
name: 'Rencontres de l\'Écologie',
source: 'Transiscope',
date: '2026-04-20',
location: 'Lyon, Halle Tony Garnier',
description: 'Deux jours de conférences et ateliers sur la transition écologique.',
},
];
export function CreateEventScreen({ navigate }: ScreenProps) {
const { events, createEvent, setSelectedEventId } = useFestipodData();
const [name, setName] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [location, setLocation] = useState('');
const [description, setDescription] = useState('');
const [selectedThemes, setSelectedThemes] = useState<string[]>(['Social']);
const [showSuggestions, setShowSuggestions] = useState(false);
const [importedFrom, setImportedFrom] = useState<string | null>(null);
// Check for existing events with similar names
const existingMatches = events.filter(e =>
name.length > 3 && e.title.toLowerCase().includes(name.toLowerCase())
);
// Show warning only when key fields are filled AND not imported from external source
const showDuplicateWarning = existingMatches.length > 0 && startDate && location.length > 3 && !importedFrom;
const handleCreate = () => {
const dateLabel = startDate
? (endDate ? `${startDate} - ${endDate}` : startDate)
: 'Date à définir';
const newEvent = createEvent({
title: name || 'Nouvel événement',
date: dateLabel,
startDate,
endDate,
startTime,
endTime,
location: location || 'Lieu à définir',
description,
participantCount: 1,
themes: selectedThemes,
hostName: 'Moi',
hostInitials: 'MD',
});
setSelectedEventId(newEvent.id);
navigate('event-detail');
};
const toggleTheme = (themeId: string) => {
setSelectedThemes(prev =>
prev.includes(themeId)
? prev.filter(t => t !== themeId)
: [...prev, themeId]
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Relayer un événement"
left={<span onClick={() => navigate('home')} style={{ cursor: 'pointer' }}>✕</span>}
/>
{/* Content */}
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
{/* Cover image upload */}
<Placeholder
height={140}
label="+ Ajouter une photo"
style={{ marginBottom: 20, cursor: 'pointer' }}
/>
{/* Duplicate warning - shown when key fields are filled */}
{showDuplicateWarning && (
<div style={{
background: '#FEF3C7',
border: '2px solid #F59E0B',
borderRadius: 8,
padding: 12,
marginBottom: 16,
}}>
<Text style={{ margin: 0, fontWeight: 'bold', fontSize: 14, marginBottom: 4 }}>
Événement similaire détecté
</Text>
<Text style={{ margin: 0, fontSize: 13, lineHeight: 1.5 }}>
Un événement similaire « {existingMatches[0].title} » existe déjà.
Vous pouvez continuer si vous pensez qu'il s'agit d'un événement différent.
</Text>
<Text
style={{ margin: '8px 0 0 0', fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => {
setSelectedEventId(existingMatches[0].id);
navigate('event-detail');
}}
>
Voir l'événement existant →
</Text>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ position: 'relative' }}>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Nom de l'événement *</Text>
<Input
placeholder="Donnez un nom à votre événement"
value={name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
setShowSuggestions(e.target.value.length > 0);
setImportedFrom(null);
}}
onFocus={() => name.length > 0 && setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
/>
{/* Suggestions dropdown */}
{showSuggestions && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
background: 'white',
border: '2px solid var(--sketch-black)',
borderRadius: 8,
marginTop: 4,
zIndex: 10,
maxHeight: 250,
overflow: 'auto',
}}>
{/* Existing events - not selectable */}
{existingMatches.length > 0 && (
<>
<div style={{ padding: '8px 12px', background: 'var(--sketch-light-gray)', fontSize: 12, fontWeight: 'bold' }}>
Déjà relayé sur Festipod
</div>
{existingMatches.map((event) => (
<div
key={event.id}
style={{
padding: '10px 12px',
borderBottom: '1px solid var(--sketch-light-gray)',
opacity: 0.6,
cursor: 'not-allowed',
}}
>
<Text style={{ margin: 0, fontSize: 14 }}>{event.title}</Text>
<Text style={{ margin: '2px 0 0 0', fontSize: 12, color: 'var(--sketch-gray)' }}>
{event.hostName ? `Relayé par ${event.hostName}` : 'Relayé'}
</Text>
</div>
))}
</>
)}
{/* Importable events */}
{importableEvents.length > 0 && (
<>
<div style={{ padding: '8px 12px', background: 'var(--sketch-light-gray)', fontSize: 12, fontWeight: 'bold' }}>
Importer depuis une source externe
</div>
{importableEvents.map((event, i) => (
<div
key={`import-${i}`}
style={{
padding: '10px 12px',
borderBottom: '1px solid var(--sketch-light-gray)',
cursor: 'pointer',
}}
onClick={() => {
setName(event.name);
setStartDate(event.date);
setLocation(event.location);
setDescription(event.description);
setImportedFrom(event.source);
setShowSuggestions(false);
}}
>
<Text style={{ margin: 0, fontSize: 14 }}>{event.name}</Text>
<Text style={{ margin: '2px 0 0 0', fontSize: 12, color: 'var(--sketch-gray)' }}>
via {event.source} · {event.location}
</Text>
</div>
))}
</>
)}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Date de début *</Text>
<Input
type="date"
placeholder="Début"
value={startDate}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartDate(e.target.value)}
/>
</div>
<div style={{ flex: 1 }}>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Date de fin</Text>
<Input
type="date"
placeholder="Fin"
value={endDate}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndDate(e.target.value)}
/>
</div>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Heure de début *</Text>
<Input
type="time"
placeholder="Début"
value={startTime}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartTime(e.target.value)}
/>
</div>
<div style={{ flex: 1 }}>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Heure de fin</Text>
<Input
type="time"
placeholder="Fin"
value={endTime}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndTime(e.target.value)}
/>
</div>
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Lieu *</Text>
<Input
placeholder="Ajouter un lieu"
value={location}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLocation(e.target.value)}
/>
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Description</Text>
<textarea
className="sketchy-input"
placeholder="Décrivez votre événement..."
rows={4}
style={{ resize: 'none' }}
value={description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
/>
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Thématique *</Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{[
{ id: 'Culture', label: 'Culture', emoji: '🎭' },
{ id: 'Sport', label: 'Sport', emoji: '⚽' },
{ id: 'Nature', label: 'Nature', emoji: '🌿' },
{ id: 'Social', label: 'Social', emoji: '👥' },
{ id: 'Gastronomie', label: 'Gastronomie', emoji: '🍽️' },
{ id: 'Musique', label: 'Musique', emoji: '🎵' },
{ id: 'Tech', label: 'Tech', emoji: '💻' },
{ id: 'Autre', label: 'Autre', emoji: '✨' },
].map((theme) => (
<Button
key={theme.id}
variant={selectedThemes.includes(theme.id) ? 'primary' : 'default'}
style={{ fontSize: 13 }}
onClick={() => toggleTheme(theme.id)}
>
{theme.emoji} {theme.label}
</Button>
))}
</div>
</div>
</div>
</div>
{/* Footer */}
<div style={{ padding: 16, borderTop: '2px solid var(--sketch-black)' }}>
{showDuplicateWarning && (
<div style={{
background: '#FEF3C7',
border: '2px solid #F59E0B',
borderRadius: 8,
padding: 12,
marginBottom: 12,
}}>
<Text style={{ margin: 0, fontSize: 13, lineHeight: 1.5 }}>
Un événement similaire « {existingMatches[0].title} » existe déjà.{' '}
<span
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => {
setSelectedEventId(existingMatches[0].id);
navigate('event-detail');
}}
>
Voir →
</span>
</Text>
</div>
)}
<Button
variant="primary"
style={{ width: '100%' }}
onClick={handleCreate}
>
Relayer l'événement
</Button>
</div>
</div>
);
}
@@ -0,0 +1,174 @@
import React from 'react';
import { Header, Title, Text, Button, Avatar, Placeholder, Divider } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
export function EventDetailScreen({ navigate }: ScreenProps) {
const {
selectedEvent,
selectedEventId,
currentUserId,
isParticipating,
joinEvent,
leaveEvent,
getEventParticipants,
setSelectedUserId,
} = useFestipodData();
const event = selectedEvent;
const joined = isParticipating(selectedEventId);
const participants = getEventParticipants(selectedEventId);
// In a real app, this would come from comparing current user with event creator
const isOwner = true;
const knownParticipants = participants.filter(p => p.id !== currentUserId);
const unknownCount = Math.max(0, (event?.participantCount ?? 0) - participants.length);
const handleToggleJoin = () => {
if (joined) {
leaveEvent(selectedEventId);
} else {
joinEvent(selectedEventId);
}
};
if (!event) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Événement"
left={<span onClick={() => navigate('events')} style={{ cursor: 'pointer' }}>←</span>}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text>Événement non trouvé</Text>
</div>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Événement"
left={<span onClick={() => navigate('events')} style={{ cursor: 'pointer' }}>←</span>}
right={isOwner && <span onClick={() => navigate('update-event')} style={{ cursor: 'pointer' }}>✎</span>}
/>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto' }}>
{/* Cover image */}
<Placeholder height={180} label="Photo de couverture" />
<div style={{ padding: 16 }}>
<Title className="user-content" style={{ marginBottom: 8 }}>{event.title}</Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
<Text style={{ margin: 0, fontSize: 15 }}>
📅 <span className="user-content">{event.date}</span>
</Text>
{event.startTime && (
<Text style={{ margin: 0, fontSize: 15 }}>
🕓 <span className="user-content">{event.startTime}{event.endTime ? ` - ${event.endTime}` : ''}</span>
</Text>
)}
<Text style={{ margin: 0, fontSize: 15 }}>
📍 <span className="user-content">{event.location}</span>
{event.distance != null && (
<span style={{ color: 'var(--sketch-gray)' }}> · {event.distance} km</span>
)}
</Text>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Button
variant={joined ? 'default' : 'primary'}
onClick={handleToggleJoin}
style={{ flex: 1 }}
>
{joined ? '✓ Inscrit' : 'Participer'}
</Button>
<Button onClick={() => navigate('invite')}>Inviter</Button>
</div>
{joined && (
<Button
onClick={() => navigate('meeting-points')}
style={{ width: '100%', marginBottom: 16 }}
>
📍 Points de rencontre
</Button>
)}
<Divider />
{/* Host */}
{event.hostName && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<Avatar initials={event.hostInitials || event.hostName.substring(0, 2).toUpperCase()} />
<div>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{event.hostName}</Text>
<Text style={{ margin: 0, fontSize: 14, color: 'var(--sketch-gray)' }}>Relayé par</Text>
</div>
</div>
<Divider />
</>
)}
{/* Description */}
<Text style={{ fontWeight: 'bold', marginBottom: 8 }}>À propos</Text>
<Text className="user-content" style={{ lineHeight: 1.6 }}>
{event.description}
</Text>
<Divider />
{/* Attendees */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>Participants ({event.participantCount})</Text>
<Text
style={{ margin: 0, fontSize: 14, cursor: 'pointer' }}
onClick={() => navigate('participants-list')}
>
Voir tout →
</Text>
</div>
<div style={{ display: 'flex', gap: 12 }}>
{knownParticipants.slice(0, 3).map((p) => (
<div
key={p.id}
style={{ textAlign: 'center', cursor: 'pointer' }}
onClick={() => { setSelectedUserId(p.id); navigate('user-profile'); }}
>
<Avatar initials={p.initials} size="sm" />
<Text className="user-content" style={{ margin: '4px 0 0 0', fontSize: 12 }}>{p.name.split(' ')[0]}</Text>
</div>
))}
{unknownCount > 0 && (
<div
style={{ textAlign: 'center', cursor: 'pointer' }}
onClick={() => navigate('participants-list')}
>
<div style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'var(--sketch-light-gray)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
}}>
+{unknownCount}
</div>
<Text style={{ margin: '4px 0 0 0', fontSize: 12, color: 'var(--sketch-gray)' }}>inconnus</Text>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import React from 'react';
import { Header, Input, Card, Text, Badge, NavBar } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
function EventCard({ title, date, location, distance, attendees, onClick }: {
title: string;
date: string;
location: string;
distance: number;
attendees: number;
onClick: () => void;
}) {
return (
<Card onClick={onClick} style={{ marginBottom: 12 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{title}</Text>
<Text style={{ margin: '4px 0', fontSize: 14 }}>
📅 <span className="user-content">{date}</span>
</Text>
<Text style={{ margin: '0 0 8px 0', fontSize: 14 }}>
📍 <span className="user-content">{location}</span>
{distance != null && <span style={{ color: 'var(--sketch-gray)' }}> · {distance} km</span>}
</Text>
<Badge>{attendees} inscrits</Badge>
</Card>
);
}
export function EventsScreen({ navigate }: ScreenProps) {
const { events, setSelectedEventId } = useFestipodData();
const handleEventClick = (eventId: string) => {
setSelectedEventId(eventId);
navigate('event-detail');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Découvrir"
left={<span onClick={() => navigate('home')} style={{ cursor: 'pointer' }}>←</span>}
/>
{/* Search */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--sketch-light-gray)' }}>
<Input placeholder="Rechercher un événement..." />
</div>
{/* Filter tabs */}
<div style={{
display: 'flex',
gap: 8,
padding: '12px 16px',
borderBottom: '1px solid var(--sketch-light-gray)',
}}>
<Badge style={{ background: 'var(--sketch-black)', color: 'var(--sketch-white)' }}>Tous</Badge>
<Badge>Cette semaine</Badge>
<Badge>Proches</Badge>
<Badge>Amis</Badge>
</div>
{/* Content */}
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
{/* Helper text */}
<div style={{
background: 'var(--sketch-light-gray)',
padding: 12,
borderRadius: 8,
marginBottom: 16,
}}>
<Text style={{ margin: 0, fontSize: 13, color: 'var(--sketch-gray)', lineHeight: 1.5 }}>
Événements relayés par vos contacts. Explorez, participez, et relayez
à votre tour pour faire grandir votre réseau.
</Text>
</div>
{events.map((event) => (
<EventCard
key={event.id}
title={event.title}
date={event.date}
location={event.location}
distance={event.distance ?? 0}
attendees={event.participantCount}
onClick={() => handleEventClick(event.id)}
/>
))}
</div>
{/* Bottom Nav */}
<NavBar
items={[
{ icon: '⌂', label: 'Accueil', onClick: () => navigate('home') },
{ icon: '◎', label: 'Découvrir', active: true },
{ icon: '+', label: 'Relayer', onClick: () => navigate('create-event') },
{ icon: '☺', label: 'Profil', onClick: () => navigate('profile') },
]}
/>
</div>
);
}
@@ -0,0 +1,86 @@
import React, { useState } from 'react';
import { Header, Input, Text, Avatar, Checkbox, Button } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
export function InviteScreen({ navigate }: ScreenProps) {
const { getFriends } = useFestipodData();
const friends = getFriends();
const [selected, setSelected] = useState<Set<string>>(new Set());
const toggleFriend = (id: string) => {
const newSelected = new Set(selected);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelected(newSelected);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Inviter des amis"
left={<span onClick={() => navigate('event-detail')} style={{ cursor: 'pointer' }}>←</span>}
/>
{/* Search */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--sketch-light-gray)' }}>
<Input placeholder="Rechercher un ami..." />
</div>
{/* Selected count */}
{selected.size > 0 && (
<div style={{
padding: '8px 16px',
background: 'var(--sketch-light-gray)',
fontSize: 14,
fontFamily: 'var(--font-sketch)',
}}>
{selected.size} ami{selected.size > 1 ? 's' : ''} sélectionné{selected.size > 1 ? 's' : ''}
</div>
)}
{/* Friends list */}
<div style={{ flex: 1, overflow: 'auto' }}>
{friends.map((friend) => (
<div
key={friend.id}
onClick={() => toggleFriend(friend.id)}
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
borderBottom: '1px solid var(--sketch-light-gray)',
cursor: 'pointer',
background: selected.has(friend.id) ? 'var(--sketch-light-gray)' : 'transparent',
}}
>
<Avatar initials={friend.initials} size="sm" />
<div style={{ flex: 1, marginLeft: 12 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{friend.name}</Text>
<Text className="user-content" style={{ margin: 0, fontSize: 14 }}>
{friend.username}
</Text>
</div>
<Checkbox checked={selected.has(friend.id)} />
</div>
))}
</div>
{/* Footer */}
<div style={{ padding: 16, borderTop: '2px solid var(--sketch-black)' }}>
<Button
variant="primary"
style={{ width: '100%' }}
onClick={() => navigate('event-detail')}
disabled={selected.size === 0}
>
Envoyer {selected.size > 0 ? `${selected.size} ` : ''}invitation{selected.size !== 1 ? 's' : ''}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,117 @@
import React, { useState } from 'react';
import { Header, Text, Button, Card, Avatar, Input, Divider } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
export function MeetingPointsScreen({ navigate }: ScreenProps) {
const { selectedEventId, getEventMeetingPoints, addMeetingPoint, currentUser } = useFestipodData();
const meetingPoints = getEventMeetingPoints(selectedEventId);
const [showForm, setShowForm] = useState(false);
const [mpLocation, setMpLocation] = useState('');
const [mpTime, setMpTime] = useState('1h avant');
const handleCreate = () => {
if (!mpLocation.trim()) return;
addMeetingPoint({
eventId: selectedEventId,
location: mpLocation,
time: mpTime,
hostName: currentUser?.name?.split(' ')[0] ?? 'Moi',
hostInitials: currentUser?.initials ?? '?',
});
setMpLocation('');
setMpTime('1h avant');
setShowForm(false);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Points de rencontre"
left={<span onClick={() => navigate('event-detail')} style={{ cursor: 'pointer' }}>←</span>}
/>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
<Text style={{ color: 'var(--sketch-gray)', marginBottom: 16 }}>
Proposez un lieu de rendez-vous pour y aller ensemble !
</Text>
{/* Existing meeting points */}
{meetingPoints.map((mp) => (
<Card key={mp.id} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<Avatar initials={mp.hostInitials} size="sm" />
<div style={{ flex: 1 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{mp.location}</Text>
<Text style={{ margin: '4px 0', fontSize: 14, color: 'var(--sketch-gray)' }}>
<span className="user-content">{mp.time}</span> · Proposé par <span className="user-content">{mp.hostName}</span>
</Text>
</div>
</div>
</Card>
))}
<Divider />
{/* Create new meeting point */}
{!showForm ? (
<Button variant="primary" style={{ width: '100%' }} onClick={() => setShowForm(true)}>
+ Proposer un point de rencontre
</Button>
) : (
<>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Proposer un point de rencontre</Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Lieu</Text>
<Input
placeholder="Ex: Café de la Gare, Entrée du parc..."
value={mpLocation}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMpLocation(e.target.value)}
/>
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Heure</Text>
<div style={{ display: 'flex', gap: 8 }}>
<Button
style={{ flex: 1 }}
variant={mpTime === '30 min avant' ? 'primary' : 'default'}
onClick={() => setMpTime('30 min avant')}
>
30 min avant
</Button>
<Button
variant={mpTime === '1h avant' ? 'primary' : 'default'}
style={{ flex: 1 }}
onClick={() => setMpTime('1h avant')}
>
1h avant
</Button>
<Button
style={{ flex: 1 }}
variant={mpTime !== '30 min avant' && mpTime !== '1h avant' ? 'primary' : 'default'}
onClick={() => setMpTime('Personnalisé')}
>
Personnalisé
</Button>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button style={{ flex: 1 }} onClick={() => setShowForm(false)}>Annuler</Button>
<Button variant="primary" style={{ flex: 1 }} onClick={handleCreate}>
Créer le point de rencontre
</Button>
</div>
</div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,81 @@
import React from 'react';
import { Header, Avatar, Text, Input } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
export function ParticipantsListScreen({ navigate }: ScreenProps) {
const { selectedEvent, selectedEventId, getEventParticipants, getFriends, setSelectedUserId } = useFestipodData();
const participants = getEventParticipants(selectedEventId);
const friends = getFriends();
const friendIds = new Set(friends.map(f => f.id));
const totalCount = selectedEvent?.participantCount ?? participants.length;
const unknownCount = Math.max(0, totalCount - participants.length);
// Build participant list: known participants + unknown placeholders
const participantRows = [
...participants.map(p => ({
key: p.id,
initials: p.initials,
name: p.name,
username: p.username,
known: true,
isFriend: friendIds.has(p.id),
})),
...Array.from({ length: unknownCount }, (_, i) => ({
key: `unknown-${i}`,
initials: '?',
name: '',
username: '',
known: false,
isFriend: false,
})),
];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title={`Participants (${totalCount})`}
left={<span onClick={() => navigate('event-detail')} style={{ cursor: 'pointer' }}>←</span>}
/>
{/* Search bar */}
<div style={{ padding: 16, borderBottom: '1px solid var(--sketch-light-gray)' }}>
<Input placeholder="Rechercher un participant..." />
</div>
{/* Participants list */}
<div style={{ flex: 1, overflow: 'auto' }}>
{participantRows.map((p) => (
<div
key={p.key}
onClick={p.known ? () => { setSelectedUserId(p.key); navigate('user-profile'); } : undefined}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 16px',
cursor: p.known ? 'pointer' : 'default',
borderBottom: '1px solid var(--sketch-light-gray)',
}}
>
<Avatar initials={p.initials} size="sm" />
<div style={{ flex: 1 }}>
{p.known ? (
<>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{p.name}</Text>
<Text className="user-content" style={{ margin: 0, fontSize: 13 }}>
{p.username}
</Text>
</>
) : (
<Text style={{ margin: 0, color: 'var(--sketch-gray)' }}>Participant inconnu</Text>
)}
</div>
{p.known && <Text style={{ margin: 0, fontSize: 20, color: 'var(--sketch-gray)' }}>›</Text>}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,146 @@
import React, { useState } from 'react';
import { Header, Text, Input, Button, Placeholder } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
export function UpdateEventScreen({ navigate }: ScreenProps) {
const { selectedEvent, updateEvent, selectedEventId } = useFestipodData();
const event = selectedEvent;
const [title, setTitle] = useState(event?.title ?? '');
const [startDate, setStartDate] = useState(event?.startDate ?? '');
const [endDate, setEndDate] = useState(event?.endDate ?? '');
const [startTime, setStartTime] = useState(event?.startTime ?? '');
const [endTime, setEndTime] = useState(event?.endTime ?? '');
const [location, setLocation] = useState(event?.location ?? '');
const [description, setDescription] = useState(event?.description ?? '');
const [themes, setThemes] = useState<string[]>(event?.themes ?? ['Social']);
const toggleTheme = (themeId: string) => {
setThemes(prev =>
prev.includes(themeId)
? prev.filter(t => t !== themeId)
: [...prev, themeId]
);
};
const handleSave = () => {
const dateLabel = startDate
? (endDate ? `${startDate} - ${endDate}` : startDate)
: event?.date ?? '';
updateEvent(selectedEventId, {
title,
date: dateLabel,
startDate,
endDate,
startTime,
endTime,
location,
description,
themes,
});
navigate('event-detail');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier l'événement"
left={<span onClick={() => navigate('event-detail')} style={{ cursor: 'pointer' }}>✕</span>}
/>
{/* Content */}
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
{/* Cover image upload */}
<Placeholder
height={140}
label="Photo de couverture"
style={{ marginBottom: 20, cursor: 'pointer' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Nom de l'événement *</Text>
<Input value={title} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)} />
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Date de début *</Text>
<Input type="date" value={startDate} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartDate(e.target.value)} />
</div>
<div style={{ flex: 1 }}>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Date de fin</Text>
<Input type="date" value={endDate} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndDate(e.target.value)} />
</div>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Heure de début *</Text>
<Input type="time" value={startTime} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartTime(e.target.value)} />
</div>
<div style={{ flex: 1 }}>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Heure de fin</Text>
<Input type="time" value={endTime} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndTime(e.target.value)} />
</div>
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Lieu *</Text>
<Input value={location} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLocation(e.target.value)} />
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Description</Text>
<textarea
className="sketchy-input"
value={description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
rows={4}
style={{ resize: 'none' }}
/>
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Thématique *</Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{[
{ id: 'Culture', label: 'Culture', emoji: '🎭' },
{ id: 'Sport', label: 'Sport', emoji: '⚽' },
{ id: 'Nature', label: 'Nature', emoji: '🌿' },
{ id: 'Social', label: 'Social', emoji: '👥' },
{ id: 'Gastronomie', label: 'Gastronomie', emoji: '🍽️' },
{ id: 'Musique', label: 'Musique', emoji: '🎵' },
{ id: 'Tech', label: 'Tech', emoji: '💻' },
{ id: 'Autre', label: 'Autre', emoji: '✨' },
].map((theme) => (
<Button
key={theme.id}
variant={themes.includes(theme.id) ? 'primary' : 'default'}
style={{ fontSize: 13 }}
onClick={() => toggleTheme(theme.id)}
>
{theme.emoji} {theme.label}
</Button>
))}
</div>
</div>
</div>
</div>
{/* Footer */}
<div style={{ padding: 16, borderTop: '2px solid var(--sketch-black)' }}>
<Button
variant="primary"
style={{ width: '100%' }}
onClick={handleSave}
>
Enregistrer les modifications
</Button>
</div>
</div>
);
}
@@ -0,0 +1,124 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
When('je clique sur un événement', async function (this: FestipodWorld) {
this.navigateTo('#/demo/event-detail');
});
Given('je visualise l\'événement {string}', async function (this: FestipodWorld, eventName: string) {
this.navigateTo('#/demo/event-detail');
expect(this.currentScreen, 'Event detail screen should be loaded').to.not.be.null;
this.attach(`Viewing event: ${eventName}`, 'text/plain');
});
Then('je peux annuler et revenir à l\'écran précédent', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('create-event');
const source = this.getRenderedText();
const found = /onClick\s*=\s*\{\s*\(\)\s*=>\s*navigate\s*\(['"]home['"]\)\s*\}[^>]*>✕</.test(source);
expect(found, 'Create event screen should have ✕ button with navigate("home")').to.be.true;
});
Then('je peux voir la liste des participants', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('event-detail');
const source = this.getRenderedText();
const hasAvatars = /<Avatar/.test(source);
const hasParticipantsSection = /Participants\s*\(\d+\)/.test(source);
expect(hasAvatars, 'Event detail should have Avatar components for participants').to.be.true;
expect(hasParticipantsSection, 'Event detail should have "Participants (N)" section').to.be.true;
});
Then('je peux voir les détails de l\'événement', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('event-detail');
const source = this.getRenderedText();
expect(/<Title[^>]*>[^<]+<\/Title>/.test(source), 'Event detail should have a Title').to.be.true;
expect(/📅/.test(source), 'Event detail should have date emoji 📅').to.be.true;
expect(/🕓/.test(source), 'Event detail should have time emoji 🕓').to.be.true;
expect(/📍/.test(source), 'Event detail should have location emoji 📍').to.be.true;
expect(/À propos/.test(source), 'Event detail should have "À propos" section').to.be.true;
});
Then('l\'écran affiche les informations de l\'événement', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('event-detail');
const source = this.getRenderedText();
expect(/<Title[^>]*>[^<]+<\/Title>/.test(source), 'Event detail should have a Title').to.be.true;
expect(/📅/.test(source), 'Event detail should have date emoji 📅').to.be.true;
expect(/🕓/.test(source), 'Event detail should have time emoji 🕓').to.be.true;
expect(/📍/.test(source), 'Event detail should have location emoji 📍').to.be.true;
expect(/À propos/.test(source), 'Event detail should have "À propos" section').to.be.true;
});
Then('je peux voir la liste des événements', async function (this: FestipodWorld) {
const source = this.getRenderedText();
if (this.currentScreenId === 'home') {
expect(/Mes événements à venir/.test(source), 'Home screen should have "Événements à venir" text').to.be.true;
} else if (this.currentScreenId === 'events') {
expect(/<Card[^>]*onClick/.test(source), 'Events screen should have clickable Card components').to.be.true;
} else {
expect.fail(`Unexpected screen "${this.currentScreenId}" - events list should be on home or events screen`);
}
});
Then('les événements affichent leur lieu', async function (this: FestipodWorld) {
const source = this.getRenderedText();
const locationPattern = /📍.*<span[^>]*className="user-content"[^>]*>[^<]+<\/span>/;
expect(locationPattern.test(source), 'Event cards should display location text after 📍 emoji').to.be.true;
});
Then('je peux m\'inscrire à l\'événement', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('event-detail');
const source = this.getRenderedText();
const hasParticiperButton = /isJoined \? '✓ Inscrit' : 'Participer'/.test(source);
expect(hasParticiperButton, 'Event detail should have Participer/Inscrit toggle button').to.be.true;
});
Then('je peux me désinscrire de l\'événement', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('event-detail');
const source = this.getRenderedText();
const hasInscritButton = /isJoined \? '✓ Inscrit' : 'Participer'/.test(source);
expect(hasInscritButton, 'Event detail should have Participer/Inscrit toggle button (click to unregister)').to.be.true;
});
// Event form steps (create-event specific)
Then('le formulaire contient le champ obligatoire {string}', async function (this: FestipodWorld, fieldName: string) {
expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');
const source = this.getRenderedText();
const escapedName = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`>${escapedName}\\s*\\*<`);
expect(pattern.test(source), `Field "${fieldName}" should be marked as required (with *) in create-event screen`).to.be.true;
});
Then('le formulaire contient les champs obligatoires suivants:', async function (this: FestipodWorld, dataTable) {
expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');
const source = this.getRenderedText();
const expectedFields = dataTable.raw().flat();
expectedFields.forEach((fieldName: string) => {
const escapedName = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`>${escapedName}\\s*\\*<`);
expect(pattern.test(source), `Field "${fieldName}" should be marked as required (with *) in create-event screen`).to.be.true;
});
});
Then('le formulaire permet de détecter les doublons', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('create-event');
const source = this.getRenderedText();
expect(/showDuplicateWarning/.test(source), 'Form should have duplicate detection logic').to.be.true;
expect(/Événement similaire détecté/.test(source), 'Form should have duplicate warning message').to.be.true;
});
Then('le formulaire permet d\'importer depuis Mobilizon ou Transiscope', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('create-event');
const source = this.getRenderedText();
expect(/importableEvents/.test(source), 'Form should have importable events data').to.be.true;
expect(/Mobilizon/.test(source), 'Form should support Mobilizon import').to.be.true;
expect(/Transiscope/.test(source), 'Form should support Transiscope import').to.be.true;
expect(/Importer depuis une source externe/.test(source), 'Form should have import section').to.be.true;
});
Then('l\'import externe ne déclenche pas d\'alerte doublon', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('create-event');
const source = this.getRenderedText();
expect(/importedFrom/.test(source), 'Form should track import source').to.be.true;
expect(/&& !importedFrom/.test(source), 'Duplicate warning should be disabled for imports').to.be.true;
});
+101
View File
@@ -0,0 +1,101 @@
import React from 'react';
import { Button, Title, Text, Card, NavBar, Badge } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import type { ScreenProps } from '../../../screens';
function EventCard({ title, date, location, distance, attendees, onClick }: { title: string; date: string; location: string; distance: number; attendees: number; onClick: () => void }) {
return (
<Card onClick={onClick} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{title}</Text>
<Text className="user-content" style={{ margin: '4px 0 0 0', fontSize: 14 }}>{date}</Text>
<Text style={{ margin: '2px 0 0 0', fontSize: 14 }}>
📍 <span className="user-content">{location}</span>
{distance != null && <span style={{ color: 'var(--sketch-gray)' }}> · {distance} km</span>}
</Text>
</div>
<Badge>{attendees} inscrits</Badge>
</div>
</Card>
);
}
export function HomeScreen({ navigate }: ScreenProps) {
const { events, getUserEvents, currentUserId, setSelectedEventId } = useFestipodData();
const myEvents = getUserEvents(currentUserId);
// Show user's events first, fall back to all events if none
const displayEvents = myEvents.length > 0 ? myEvents : events;
const handleEventClick = (eventId: string) => {
setSelectedEventId(eventId);
navigate('event-detail');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Header */}
<div style={{ padding: '16px', borderBottom: '2px solid var(--sketch-black)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title style={{ margin: 0 }}>Festipod</Title>
<span onClick={() => navigate('profile')} style={{ cursor: 'pointer', fontSize: 24 }}>☺</span>
</div>
</div>
{/* Content */}
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
{/* Helper text */}
<div style={{
background: 'var(--sketch-light-gray)',
padding: 12,
borderRadius: 8,
marginBottom: 16,
}}>
<Text style={{ margin: 0, fontSize: 13, color: 'var(--sketch-gray)', lineHeight: 1.5 }}>
Voici les événements auxquels vous participez. Retrouvez les infos pratiques
et les autres participants.
</Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Text style={{ margin: 0, fontWeight: 'bold' }}>Mes événements à venir</Text>
<Text
style={{ margin: 0, fontSize: 14, cursor: 'pointer' }}
onClick={() => navigate('events')}
>
Voir tout →
</Text>
</div>
{displayEvents.slice(0, 3).map((event) => (
<EventCard
key={event.id}
title={event.title}
date={event.date}
location={event.location}
distance={event.distance ?? 0}
attendees={event.participantCount}
onClick={() => handleEventClick(event.id)}
/>
))}
<div style={{ marginTop: 24 }}>
<Button variant="primary" onClick={() => navigate('create-event')} style={{ width: '100%' }}>
+ Relayer un événement
</Button>
</div>
</div>
{/* Bottom Nav */}
<NavBar
items={[
{ icon: '⌂', label: 'Accueil', active: true },
{ icon: '◎', label: 'Découvrir', onClick: () => navigate('events') },
{ icon: '+', label: 'Relayer', onClick: () => navigate('create-event') },
{ icon: '☺', label: 'Profil', onClick: () => navigate('profile') },
]}
/>
</div>
);
}
@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { Header, Text, ListItem, Toggle, Divider, NavBar } from '../../../shared/components/sketchy';
import type { ScreenProps } from '../../../screens';
export function SettingsScreen({ navigate }: ScreenProps) {
const [notifications, setNotifications] = useState(true);
const [darkMode, setDarkMode] = useState(false);
const [location, setLocation] = useState(true);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Paramètres"
left={<span onClick={() => navigate('home')} style={{ cursor: 'pointer' }}>←</span>}
/>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto' }}>
<Text style={{ padding: '16px 16px 8px', fontSize: 14, color: 'var(--sketch-gray)', margin: 0 }}>
PRÉFÉRENCES
</Text>
<ListItem>
<div style={{ flex: 1 }}>
<Text style={{ margin: 0 }}>Notifications</Text>
<Text style={{ margin: 0, fontSize: 12, color: 'var(--sketch-gray)' }}>
Recevoir les invitations par e-mail
</Text>
</div>
<Toggle checked={notifications} onChange={setNotifications} />
</ListItem>
<ListItem>
<div style={{ flex: 1 }}>
<Text style={{ margin: 0 }}>Mode sombre</Text>
<Text style={{ margin: 0, fontSize: 12, color: 'var(--sketch-gray)' }}>
Activer le thème sombre
</Text>
</div>
<Toggle checked={darkMode} onChange={setDarkMode} />
</ListItem>
<ListItem>
<div style={{ flex: 1 }}>
<Text style={{ margin: 0 }}>Localisation</Text>
<Text style={{ margin: 0, fontSize: 12, color: 'var(--sketch-gray)' }}>
Autoriser l'accès à la position
</Text>
</div>
<Toggle checked={location} onChange={setLocation} />
</ListItem>
<Divider />
<Text style={{ padding: '16px 16px 8px', fontSize: 14, color: 'var(--sketch-gray)', margin: 0 }}>
COMPTE
</Text>
<ListItem onClick={() => navigate('profile')}>
<Text style={{ margin: 0, flex: 1 }}>Modifier le profil</Text>
<span>→</span>
</ListItem>
<ListItem>
<Text style={{ margin: 0, flex: 1 }}>Changer le mot de passe</Text>
<span>→</span>
</ListItem>
<ListItem>
<Text style={{ margin: 0, flex: 1 }}>Confidentialité</Text>
<span>→</span>
</ListItem>
<Divider />
<ListItem onClick={() => navigate('login')}>
<Text style={{ margin: 0, color: '#c00' }}>Se déconnecter</Text>
</ListItem>
</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', onClick: () => navigate('profile') },
]}
/>
</div>
);
}
@@ -0,0 +1,10 @@
import { Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
Then('je peux configurer mes notifications', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('settings');
const source = this.getRenderedText();
expect(/>Notifications</.test(source), 'Settings should have "Notifications" text').to.be.true;
expect(/<Toggle[^>]*checked=\{notifications\}/.test(source), 'Settings should have Toggle for notifications').to.be.true;
});
@@ -0,0 +1,32 @@
# language: fr
@MEETING @priority-1
Fonctionnalité: US-16 Indiquer un ou plusieurs points de rencontre
En tant qu'utilisateur
Je peux indiquer un ou plusieurs points de rencontre
En précisant le lieu et l'heure de cette rencontre
Afin de croiser et faire connaissance d'autres participants
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder aux points de rencontre
Étant donné que je suis sur la page "détail événement"
Quand je navigue vers "points de rencontre"
Alors je vois l'écran "meeting-points"
Scénario: Voir le bouton pour proposer un point de rencontre
Étant donné que je suis sur la page "points de rencontre"
Alors l'écran contient un bouton "Proposer un point de rencontre"
Scénario: Ouvrir le formulaire de proposition
Étant donné que je suis sur la page "points de rencontre"
Quand je clique sur "Proposer un point de rencontre"
Alors l'écran contient un bouton "Créer le point de rencontre"
Et l'écran contient un champ "Lieu"
Scénario: Définir l'heure de rencontre
Étant donné que je suis sur la page "points de rencontre"
Quand je clique sur "Proposer un point de rencontre"
Alors l'écran contient un bouton "30 min avant"
Et l'écran contient un bouton "1h avant"
Et l'écran contient un bouton "Personnalisé"
@@ -0,0 +1,26 @@
# language: fr
# Note: US-17 concerne les notifications par email - non testable via écrans
@NOTIF @priority-2
Fonctionnalité: US-17 Informer automatiquement d'autres utilisateurs
En tant qu'utilisateur
Je peux informer automatiquement d'autres utilisateurs de ma participation à un événement
En utilisant un système de notifications pour transmettre le lien de l'événement
Afin d'informer les utilisateurs proches, intéressés par la thématique, ou mes abonnés
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Partager un événement auquel je participe
* Scénario non implémenté
Scénario: Informer les utilisateurs à proximité
* Scénario non implémenté
Scénario: Informer les utilisateurs par thématique
* Scénario non implémenté
Scénario: Informer mes abonnés
* Scénario non implémenté
Scénario: Combiner les options de notification
* Scénario non implémenté
@@ -0,0 +1,23 @@
# language: fr
@NOTIF @priority-2
Fonctionnalité: US-18 Être informé lorsque de nouveaux participants s'inscrivent
En tant qu'utilisateur
Je peux être informé lorsque de nouveaux participants s'inscrivent à un événement auquel je suis inscrit
En utilisant un système de notifications
Afin de savoir qui participe à un événement
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Configurer les notifications de nouveaux participants
Étant donné que je suis sur la page "paramètres"
Alors l'écran contient une section "Notifications"
Scénario: Activer les notifications pour un événement
* Scénario non implémenté
Scénario: Filtrer les notifications par réseau
* Scénario non implémenté
Scénario: Voir les nouveaux participants sur l'accueil
* Scénario non implémenté
@@ -0,0 +1,30 @@
# language: fr
# Note: US-19 concerne les récapitulatifs par email - non testable via écrans
# Les scénarios ci-dessous testent l'affichage sur l'écran d'accueil (aspect UI)
@NOTIF @priority-2
Fonctionnalité: US-19 Recevoir un récapitulatif des prochaines rencontres
En tant qu'utilisateur
Je peux recevoir un récapitulatif des prochaines rencontres
En réceptionnant une liste des événements auxquels je suis inscrit ou qui sont proches de chez moi
Afin d'établir un programme des événements auxquels je participe par période
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Voir les événements à venir sur l'accueil
Étant donné que je suis sur la page "accueil"
Alors l'écran contient une section "Mes événements à venir"
Scénario: Voir le récapitulatif par période
* Scénario non implémenté
Scénario: Voir les événements proches géographiquement
* Scénario non implémenté
Scénario: Voir mes inscriptions
Étant donné que je suis sur la page "mon profil"
Alors l'écran contient une section "Mes événements à venir"
Scénario: Vérifier les données de l'accueil
Étant donné que je suis sur la page "accueil"
Alors les événements affichent leur lieu
@@ -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;
});
@@ -0,0 +1,24 @@
# language: fr
@WORKSHOP @priority-3
Fonctionnalité: US-1 Visualiser un événement terminé (ateliers)
En tant qu'utilisateur
Je peux visualiser un événement terminé et consulter le programme détaillé des ateliers par journée/heure
Afin de voir les personnes qui ont participé à chaque atelier et consulter les notes/liens/commentaires
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder aux détails d'un événement terminé
* Scénario non implémenté
Scénario: Consulter la liste des participants d'un atelier
* Scénario non implémenté
Scénario: Consulter les ressources d'un atelier
* Scénario non implémenté
Scénario: Consulter le programme détaillé par journée/heure
* Scénario non implémenté
Scénario: Accéder à la zone de partage collective
* Scénario non implémenté
@@ -0,0 +1,19 @@
# language: fr
@WORKSHOP @priority-3
Fonctionnalité: US-11 Visualiser le bilan consolidé de l'événement
En tant qu'utilisateur
Je peux visualiser le bilan consolidé de l'événement
En consultant l'ensemble des commentaires regroupés par atelier
Afin d'obtenir une synthèse du contenu de chaque atelier et de l'ensemble des ateliers
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder au bilan consolidé
* Scénario non implémenté
Scénario: Voir les commentaires regroupés par atelier
* Scénario non implémenté
Scénario: Voir la synthèse globale
* Scénario non implémenté
@@ -0,0 +1,31 @@
# language: fr
@WORKSHOP @priority-3
Fonctionnalité: US-14 Créer/Modifier/Supprimer un atelier
En tant qu'utilisateur
Je peux créer/modifier/supprimer un atelier
En sélectionnant mon événement et en saisissant les dates et horaires de l'atelier
Afin de définir le programme de mon événement et ajouter une description
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder à la création d'atelier
* Scénario non implémenté
Scénario: Vérifier les champs obligatoires pour créer un atelier
* Scénario non implémenté
Scénario: Créer un atelier
* Scénario non implémenté
Scénario: Modifier un atelier existant
* Scénario non implémenté
Scénario: Supprimer un atelier
* Scénario non implémenté
Scénario: Sélectionner mon événement parent
* Scénario non implémenté
Scénario: Définir les horaires de fin de l'atelier
* Scénario non implémenté
@@ -0,0 +1,24 @@
# language: fr
@WORKSHOP @priority-3
Fonctionnalité: US-2 Visualiser un événement terminé (notes)
En tant qu'utilisateur
Je peux visualiser un événement terminé et consulter le programme détaillé des ateliers
Afin d'ajouter d'éventuelles prises de notes/liens ou des commentaires associés à l'atelier
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Accéder à la zone de notes personnelles
* Scénario non implémenté
Scénario: Accéder à la zone de partage publique
* Scénario non implémenté
Scénario: Ajouter une note personnelle
* Scénario non implémenté
Scénario: Ajouter un lien/ressource
* Scénario non implémenté
Scénario: Consulter le programme détaillé des ateliers par journée/heure
* Scénario non implémenté
@@ -0,0 +1,25 @@
# language: fr
@WORKSHOP @priority-3
Fonctionnalité: US-4 Ajouter/modifier/supprimer un commentaire à un atelier
En tant qu'utilisateur
Je peux consulter et ajouter/modifier/supprimer un commentaire à un atelier
En sélectionnant l'icône "ajouter un commentaire" en dessous du titre de l'atelier
Afin de voir les commentaires précédents et ajouter mes commentaires
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Voir les commentaires existants d'un atelier
* Scénario non implémenté
Scénario: Ajouter un commentaire à un atelier
* Scénario non implémenté
Scénario: Modifier un commentaire existant
* Scénario non implémenté
Scénario: Supprimer un commentaire
* Scénario non implémenté
Scénario: Accéder à l'icône ajouter un commentaire
* Scénario non implémenté
@@ -0,0 +1,22 @@
# language: fr
@WORKSHOP @priority-3
Fonctionnalité: US-6 M'inscrire/me désinscrire à un atelier
En tant qu'utilisateur
Je peux m'inscrire/me désinscrire à un atelier
En regardant si l'événement public existe déjà et en m'enregistrant sur les différents ateliers
Afin de m'inscrire à l'atelier tout en visualisant les personnes qui sont déjà pré-inscrites
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
Scénario: Voir les ateliers d'un événement
* Scénario non implémenté
Scénario: Voir les personnes pré-inscrites à un atelier
* Scénario non implémenté
Scénario: S'inscrire à un atelier
* Scénario non implémenté
Scénario: Se désinscrire d'un atelier
* Scénario non implémenté