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
+10 -4
View File
@@ -1,6 +1,8 @@
import React from 'react';
import { RouterProvider, useRouter } from './router';
import { ThemeProvider } from './context/ThemeContext';
import { ThemeProvider } from '../shared/context/ThemeContext';
import { NextGraphProvider } from '../shared/context/NextGraphContext';
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
import { Gallery } from './components/Gallery';
import { DemoMode } from './components/DemoMode';
import { SpecsPage } from './components/specs';
@@ -41,9 +43,13 @@ function AppContent() {
export function App() {
return (
<ThemeProvider>
<RouterProvider>
<AppContent />
</RouterProvider>
<NextGraphProvider>
<FestipodDataProvider>
<RouterProvider>
<AppContent />
</RouterProvider>
</FestipodDataProvider>
</NextGraphProvider>
</ThemeProvider>
);
}
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { PhoneFrame } from './sketchy';
import { screens, getScreen } from '../screens';
import { getStoriesForScreen, categoryLabels, categoryColors, priorityColors } from '../data';
import { PhoneFrame, BrokerBanner } from '../../shared/components/sketchy';
import { screens, getScreen } from '../../screens';
import { getStoriesForScreen, categoryLabels, categoryColors, priorityColors } from '../../shared/data';
import { getStoryUrl } from '../router';
import { ThemeToggle } from './ThemeToggle';
@@ -31,6 +31,7 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
const currentScreen = getScreen(currentScreenId);
const ScreenComponent = currentScreen?.component;
const linkedStories = getStoriesForScreen(currentScreenId);
const isConnectedScreen = !['welcome', 'login'].includes(currentScreenId);
const navigate = (screenId: string) => {
const newHistory = [...history.slice(0, historyIndex + 1), screenId];
@@ -365,6 +366,7 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
transformOrigin: 'center center',
}}>
<ScaledPhoneFrame isMobile={isMobile}>
{isConnectedScreen && <BrokerBanner />}
{ScreenComponent && <ScreenComponent navigate={navigate} />}
</ScaledPhoneFrame>
</div>
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { PhoneFrame } from './sketchy';
import { screenGroups, type Screen } from '../screens';
import { PhoneFrame, BrokerBanner } from '../../shared/components/sketchy';
import { screenGroups, type Screen } from '../../screens';
import { ThemeToggle } from './ThemeToggle';
function useIsMobile(breakpoint = 768) {
@@ -157,10 +157,13 @@ interface GalleryItemProps {
onClick: () => void;
}
const NON_CONNECTED_SCREENS = ['welcome', 'login'];
function GalleryItem({ screen, scale, onClick }: GalleryItemProps) {
const ScreenComponent = screen.component;
const phoneWidth = 375;
const phoneHeight = 812;
const isConnected = !NON_CONNECTED_SCREENS.includes(screen.id);
return (
<div className="gallery-item" onClick={onClick} style={{ flexShrink: 0 }}>
@@ -177,6 +180,7 @@ function GalleryItem({ screen, scale, onClick }: GalleryItemProps) {
height: phoneHeight,
}}>
<PhoneFrame>
{isConnected && <BrokerBanner />}
<ScreenComponent navigate={() => {}} />
</PhoneFrame>
</div>
@@ -1,5 +1,5 @@
import React from 'react';
import { useTheme } from '../context/ThemeContext';
import { useTheme } from '../../shared/context/ThemeContext';
import { Sun, Moon, Monitor } from 'lucide-react';
export function ThemeToggle() {
@@ -8,8 +8,8 @@ import {
getScreenIdsWithStories,
type UserStory,
type StoryCategory,
} from '../data';
import { getScreen, screens } from '../screens';
} from '../../shared/data';
import { getScreen, screens } from '../../screens';
import { ThemeToggle } from './ThemeToggle';
function useIsMobile(breakpoint = 768) {
@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Input } from '../ui/input';
import { Button } from '../ui/button';
import { Input } from '../../../shared/components/ui/input';
import { Button } from '../../../shared/components/ui/button';
import { ChevronDown, ChevronUp, Filter } from 'lucide-react';
import { categoryLabels, categoryColors, priorityLabels, priorityColors, type StoryCategory } from '../../data';
import { categoryLabels, categoryColors, priorityLabels, priorityColors, type StoryCategory } from '../../../shared/data';
const categories: StoryCategory[] = ['WORKSHOP', 'EVENT', 'USER', 'MEETING', 'NOTIF'];
@@ -1,10 +1,10 @@
import React from 'react';
import type { ParsedFeature } from '../../types/gherkin';
import { getStoryById, categoryLabels, categoryColors, priorityLabels, priorityColors, type StoryCategory } from '../../data';
import { getTestStatus, getScenarioResults } from '../../data/testResults';
import { getScreen } from '../../screens';
import type { ParsedFeature } from '../../../shared/types/gherkin';
import { getStoryById, categoryLabels, categoryColors, priorityLabels, priorityColors, type StoryCategory } from '../../../shared/data';
import { getTestStatus, getScenarioResults } from '../../../shared/data/testResults';
import { getScreen } from '../../../screens';
import { GherkinHighlighter } from './GherkinHighlighter';
import { Button } from '../ui/button';
import { Button } from '../../../shared/components/ui/button';
import { ArrowLeft, Monitor, CheckCircle2, XCircle, AlertCircle } from 'lucide-react';
interface FeatureViewProps {
@@ -1,8 +1,8 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Code2, CheckCircle2, XCircle, AlertCircle, Clock, Table2 } from 'lucide-react';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader } from '../ui/card';
import { findStepDefinition, type StepDefinitionInfo } from '../../data/stepDefinitions';
import { Button } from '../../../shared/components/ui/button';
import { Card, CardContent, CardHeader } from '../../../shared/components/ui/card';
import { findStepDefinition, type StepDefinitionInfo } from '../../../shared/data/stepDefinitions';
interface ScenarioResult {
name: string;
@@ -1,14 +1,14 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { parsedFeatures, getFeatureById } from '../../data/features';
import { categoryLabels, categoryColors, priorityLabels, priorityColors, getStoryById, getScreenIdsWithStories, type StoryCategory } from '../../data';
import { getTestStatus, getTestSummary } from '../../data/testResults';
import { getScreen } from '../../screens';
import { parsedFeatures, getFeatureById } from '../../../shared/data/features';
import { categoryLabels, categoryColors, priorityLabels, priorityColors, getStoryById, getScreenIdsWithStories, type StoryCategory } from '../../../shared/data';
import { getTestStatus, getTestSummary } from '../../../shared/data/testResults';
import { getScreen } from '../../../screens';
import { FeatureView } from './FeatureView';
import { FeatureFilter } from './FeatureFilter';
import { Card, CardHeader, CardTitle, CardContent } from '../ui/card';
import { Button } from '../ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '../../../shared/components/ui/card';
import { Button } from '../../../shared/components/ui/button';
import { ArrowLeft, FileText, Monitor, CheckCircle2, XCircle, AlertCircle, ExternalLink } from 'lucide-react';
import type { ParsedFeature } from '../../types/gherkin';
import type { ParsedFeature } from '../../../shared/types/gherkin';
import { ThemeToggle } from '../ThemeToggle';
interface SpecsPageProps {
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
<link rel="stylesheet" href="./index.css" />
<title>Festipod - Wireframe Prototyping</title>
<script type="module" src="./frontend.tsx" async></script>
<script type="module" src="./app/frontend.tsx" async></script>
</head>
<body>
<div id="root"></div>
+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>
);
}
@@ -1,6 +1,6 @@
import React from 'react';
import { Button, Title, Text } from '../components/sketchy';
import type { ScreenProps } from './index';
import { Button, Title, Text } from '../../../shared/components/sketchy';
import type { ScreenProps } from '../../../screens';
export function WelcomeScreen({ navigate }: ScreenProps) {
return (
@@ -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é
@@ -1,12 +1,9 @@
import React, { useState } from 'react';
import { Header, Text, Input, Button, Placeholder } from '../components/sketchy';
import type { ScreenProps } from './index';
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 existingEvents = [
{ name: 'Résidence Reconnexion', relayedBy: 'Thomas Martin' },
];
const importableEvents = [
{
name: 'Festival des Utopies Concrètes',
@@ -25,15 +22,57 @@ const importableEvents = [
];
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 = name.length > 3 && startDate && location.length > 3 && !importedFrom;
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%' }}>
@@ -64,12 +103,15 @@ export function CreateEventScreen({ navigate }: ScreenProps) {
Événement similaire détecté
</Text>
<Text style={{ margin: 0, fontSize: 13, lineHeight: 1.5 }}>
Un événement similaire a déjà é relayé par <strong>Thomas Martin</strong>.
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={() => navigate('event-detail')}
onClick={() => {
setSelectedEventId(existingMatches[0].id);
navigate('event-detail');
}}
>
Voir l'événement existant
</Text>
@@ -85,7 +127,7 @@ export function CreateEventScreen({ navigate }: ScreenProps) {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
setShowSuggestions(e.target.value.length > 0);
setImportedFrom(null); // Reset import flag when user types manually
setImportedFrom(null);
}}
onFocus={() => name.length > 0 && setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
@@ -107,14 +149,14 @@ export function CreateEventScreen({ navigate }: ScreenProps) {
overflow: 'auto',
}}>
{/* Existing events - not selectable */}
{existingEvents.length > 0 && (
{existingMatches.length > 0 && (
<>
<div style={{ padding: '8px 12px', background: 'var(--sketch-light-gray)', fontSize: 12, fontWeight: 'bold' }}>
Déjà relayé sur Festipod
</div>
{existingEvents.map((event, i) => (
{existingMatches.map((event) => (
<div
key={`existing-${i}`}
key={event.id}
style={{
padding: '10px 12px',
borderBottom: '1px solid var(--sketch-light-gray)',
@@ -122,9 +164,9 @@ export function CreateEventScreen({ navigate }: ScreenProps) {
cursor: 'not-allowed',
}}
>
<Text style={{ margin: 0, fontSize: 14 }}>{event.name}</Text>
<Text style={{ margin: 0, fontSize: 14 }}>{event.title}</Text>
<Text style={{ margin: '2px 0 0 0', fontSize: 12, color: 'var(--sketch-gray)' }}>
Relayé par {event.relayedBy}
{event.hostName ? `Relayé par ${event.hostName}` : 'Relayé'}
</Text>
</div>
))}
@@ -178,18 +220,33 @@ export function CreateEventScreen({ navigate }: ScreenProps) {
</div>
<div style={{ flex: 1 }}>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Date de fin</Text>
<Input type="date" placeholder="Fin" />
<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" />
<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" />
<Input
type="time"
placeholder="Fin"
value={endTime}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndTime(e.target.value)}
/>
</div>
</div>
@@ -218,19 +275,20 @@ export function CreateEventScreen({ navigate }: ScreenProps) {
<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: 'food', label: 'Gastronomie', emoji: '🍽️' },
{ id: 'music', label: 'Musique', emoji: '🎵' },
{ id: 'tech', label: 'Tech', emoji: '💻' },
{ id: 'other', label: 'Autre', emoji: '✨' },
{ 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={theme.id === 'social' ? 'primary' : 'default'}
variant={selectedThemes.includes(theme.id) ? 'primary' : 'default'}
style={{ fontSize: 13 }}
onClick={() => toggleTheme(theme.id)}
>
{theme.emoji} {theme.label}
</Button>
@@ -252,10 +310,13 @@ export function CreateEventScreen({ navigate }: ScreenProps) {
marginBottom: 12,
}}>
<Text style={{ margin: 0, fontSize: 13, lineHeight: 1.5 }}>
Un événement similaire a déjà é relayé par <strong>Thomas Martin</strong>.{' '}
Un événement similaire « {existingMatches[0].title} » existe déjà.{' '}
<span
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => navigate('event-detail')}
onClick={() => {
setSelectedEventId(existingMatches[0].id);
navigate('event-detail');
}}
>
Voir
</span>
@@ -265,7 +326,7 @@ export function CreateEventScreen({ navigate }: ScreenProps) {
<Button
variant="primary"
style={{ width: '100%' }}
onClick={() => navigate('event-detail')}
onClick={handleCreate}
>
Relayer l'événement
</Button>
@@ -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>
);
}
@@ -1,6 +1,7 @@
import React from 'react';
import { Header, Input, Card, Text, Badge, NavBar } from '../components/sketchy';
import type { ScreenProps } from './index';
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;
@@ -18,7 +19,7 @@ function EventCard({ title, date, location, distance, attendees, onClick }: {
</Text>
<Text style={{ margin: '0 0 8px 0', fontSize: 14 }}>
📍 <span className="user-content">{location}</span>
<span style={{ color: 'var(--sketch-gray)' }}> · {distance} km</span>
{distance != null && <span style={{ color: 'var(--sketch-gray)' }}> · {distance} km</span>}
</Text>
<Badge>{attendees} inscrits</Badge>
</Card>
@@ -26,6 +27,13 @@ function EventCard({ title, date, location, distance, attendees, onClick }: {
}
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
@@ -66,46 +74,17 @@ export function EventsScreen({ navigate }: ScreenProps) {
</Text>
</div>
<EventCard
title="Résidence Reconnexion"
date="Lun. 16 - Ven. 20 fév."
location="Le Revel, Rogues (30)"
distance={142}
attendees={24}
onClick={() => navigate('event-detail')}
/>
<EventCard
title="Atelier low-tech"
date="Sam. 8 fév. · 14h00"
location="La Maison du Vélo, Lyon"
distance={3}
attendees={12}
onClick={() => navigate('event-detail')}
/>
<EventCard
title="Forum Ouvert Transition"
date="Sam. 22 fév. · 9h00"
location="Tiers-lieu L'Hermitage"
distance={89}
attendees={45}
onClick={() => navigate('event-detail')}
/>
<EventCard
title="Formation CNV"
date="Sam. 1 mars · 9h30"
location="MJC Montplaisir, Lyon"
distance={5}
attendees={16}
onClick={() => navigate('event-detail')}
/>
<EventCard
title="Rencontre des Colibris"
date="Mer. 12 fév. · 19h00"
location="La Maison de l'Environnement"
distance={7}
attendees={30}
onClick={() => navigate('event-detail')}
/>
{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 */}
@@ -1,24 +1,12 @@
import React, { useState } from 'react';
import { Header, Input, Text, Avatar, Checkbox, Button } from '../components/sketchy';
import type { ScreenProps } from './index';
interface Friend {
id: string;
name: string;
initials: string;
username: string;
}
const friends: Friend[] = [
{ id: '1', name: 'Alice Martin', initials: 'AM', username: '@alice' },
{ id: '2', name: 'Baptiste Morel', initials: 'BM', username: '@baptiste' },
{ id: '3', name: 'Camille Dubois', initials: 'CD', username: '@camille' },
{ id: '4', name: 'David Leroy', initials: 'DL', username: '@david' },
{ id: '5', name: 'Emma Bernard', initials: 'EB', username: '@emma' },
{ id: '6', name: 'François Petit', initials: 'FP', username: '@francois' },
];
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) => {
@@ -1,23 +1,29 @@
import React, { useState } from 'react';
import { Header, Text, Button, Card, Avatar, Input, Divider } from '../components/sketchy';
import type { ScreenProps } from './index';
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 meetingPoints = [
{
id: '1',
location: 'Café de la Place',
time: '30 min avant l\'événement',
host: { initials: 'MD', name: 'Marie' },
},
{
id: '2',
location: 'Station de métro Bellecour',
time: '15h30',
host: { initials: 'JD', name: 'Jean' },
},
];
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%' }}>
@@ -36,11 +42,11 @@ export function MeetingPointsScreen({ navigate }: ScreenProps) {
{meetingPoints.map((mp) => (
<Card key={mp.id} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<Avatar initials={mp.host.initials} size="sm" />
<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.host.name}</span>
<span className="user-content">{mp.time}</span> · Proposé par <span className="user-content">{mp.hostName}</span>
</Text>
</div>
</div>
@@ -61,21 +67,43 @@ export function MeetingPointsScreen({ navigate }: ScreenProps) {
<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..." />
<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 }}>30 min avant</Button>
<Button variant="primary" style={{ flex: 1 }}>1h avant</Button>
<Button style={{ flex: 1 }}>Personnalisé</Button>
<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 }}>
<Button variant="primary" style={{ flex: 1 }} onClick={handleCreate}>
Créer le point de rencontre
</Button>
</div>
@@ -1,27 +1,41 @@
import React from 'react';
import { Header, Avatar, Text, Input, Divider } from '../components/sketchy';
import type { ScreenProps } from './index';
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 participants = [
{ initials: 'MD', name: 'Marie Dupont', username: '@mariedupont', known: true },
{ initials: 'TM', name: 'Thomas Martin', username: '@thomas', known: true },
{ known: false },
{ known: false },
{ known: false },
{ known: false },
{ known: false },
{ known: false },
{ known: false },
{ known: false },
{ known: false },
{ known: false },
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 (12)"
title={`Participants (${totalCount})`}
left={<span onClick={() => navigate('event-detail')} style={{ cursor: 'pointer' }}></span>}
/>
@@ -32,10 +46,10 @@ export function ParticipantsListScreen({ navigate }: ScreenProps) {
{/* Participants list */}
<div style={{ flex: 1, overflow: 'auto' }}>
{participants.map((p, i) => (
{participantRows.map((p) => (
<div
key={i}
onClick={p.known ? () => navigate('user-profile') : undefined}
key={p.key}
onClick={p.known ? () => { setSelectedUserId(p.key); navigate('user-profile'); } : undefined}
style={{
display: 'flex',
alignItems: 'center',
@@ -45,7 +59,7 @@ export function ParticipantsListScreen({ navigate }: ScreenProps) {
borderBottom: '1px solid var(--sketch-light-gray)',
}}
>
<Avatar initials={p.known ? p.initials : '?'} size="sm" />
<Avatar initials={p.initials} size="sm" />
<div style={{ flex: 1 }}>
{p.known ? (
<>
@@ -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;
});
@@ -1,6 +1,7 @@
import React from 'react';
import { Button, Title, Text, Card, NavBar, Badge } from '../components/sketchy';
import type { ScreenProps } from './index';
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 (
@@ -11,7 +12,7 @@ function EventCard({ title, date, location, distance, attendees, onClick }: { ti
<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>
<span style={{ color: 'var(--sketch-gray)' }}> · {distance} km</span>
{distance != null && <span style={{ color: 'var(--sketch-gray)' }}> · {distance} km</span>}
</Text>
</div>
<Badge>{attendees} inscrits</Badge>
@@ -21,6 +22,17 @@ function EventCard({ title, date, location, distance, attendees, onClick }: { ti
}
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 */}
@@ -56,30 +68,17 @@ export function HomeScreen({ navigate }: ScreenProps) {
</Text>
</div>
<EventCard
title="Résidence Reconnexion"
date="Lun. 16 - Ven. 20 fév."
location="Le Revel, Rogues (30)"
distance={142}
attendees={24}
onClick={() => navigate('event-detail')}
/>
<EventCard
title="Atelier low-tech"
date="Sam. 8 fév. · 14h00"
location="La Maison du Vélo, Lyon"
distance={3}
attendees={12}
onClick={() => navigate('event-detail')}
/>
<EventCard
title="Forum Ouvert Transition"
date="Sam. 22 fév. · 9h00"
location="Tiers-lieu L'Hermitage"
distance={89}
attendees={45}
onClick={() => navigate('event-detail')}
/>
{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%' }}>
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Header, Text, ListItem, Toggle, Divider, NavBar } from '../components/sketchy';
import type { ScreenProps } from './index';
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);
@@ -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é
@@ -1,25 +1,14 @@
import React, { useState } from 'react';
import { Header, Text, Avatar, Input, Button, Badge } from '../components/sketchy';
import type { ScreenProps } from './index';
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 = [
{ initials: 'JD', name: 'Jean Durand', username: '@jeandurand', events: 5, mutual: true },
{ initials: 'AM', name: 'Alice Martin', username: '@alice', events: 12, mutual: true },
{ initials: 'BM', name: 'Baptiste Morel', username: '@baptiste', events: 3, mutual: true },
{ initials: 'CD', name: 'Camille Dubois', username: '@camille', events: 8, mutual: true },
{ initials: 'DL', name: 'David Leroy', username: '@david', events: 2, mutual: true },
{ initials: 'EG', name: 'Emma Girard', username: '@emma', events: 7, mutual: true },
];
const publicProfiles = [
{ initials: 'LB', name: 'Léa Bernard', username: '@leabernard', events: 45, role: 'Relayeuse' },
{ initials: 'MR', name: 'Marc Richard', username: '@marcrichard', events: 67, role: 'Animateur' },
{ initials: 'SF', name: 'Sophie Fontaine', username: '@sophief', events: 23, role: 'Créatrice' },
{ initials: 'PG', name: 'Pierre Gagnon', username: '@pierreg', events: 89, role: 'Relayeur' },
];
const friends = getFriends();
const publicProfiles = users.filter(u => u.isPublic);
const displayedList = activeTab === 'friends' ? friends : publicProfiles;
@@ -73,10 +62,10 @@ export function FriendsListScreen({ navigate }: ScreenProps) {
{/* List */}
<div style={{ flex: 1, overflow: 'auto' }}>
{displayedList.map((person, i) => (
{displayedList.map((person) => (
<div
key={i}
onClick={() => navigate('user-profile')}
key={person.id}
onClick={() => { setSelectedUserId(person.id); navigate('user-profile'); }}
style={{
display: 'flex',
alignItems: 'center',
@@ -90,13 +79,13 @@ export function FriendsListScreen({ navigate }: ScreenProps) {
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{person.name}</Text>
{'role' in person && (
<Badge>{person.role}</Badge>
)}
{person.role && <Badge>{person.role}</Badge>}
</div>
<Text style={{ margin: 0, fontSize: 13 }}>
<span className="user-content">{person.username}</span>
<span style={{ color: 'var(--sketch-gray)' }}> · {person.events} événements</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>
@@ -1,12 +1,19 @@
import React from 'react';
import { Header, Avatar, Title, Text, Button, Card, Badge, Divider, NavBar } from '../components/sketchy';
import type { ScreenProps } from './index';
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 upcomingEvents = [
{ title: 'Résidence Reconnexion', date: '16-20 fév.' },
{ title: 'Atelier low-tech', date: '8 fév.' },
];
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%' }}>
@@ -20,21 +27,21 @@ export function ProfileScreen({ navigate }: ScreenProps) {
<div style={{ flex: 1, overflow: 'auto' }}>
{/* Profile header */}
<div style={{ padding: 24, textAlign: 'center' }}>
<Avatar initials="MD" size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>Marie Dupont</Title>
<Text className="user-content" style={{ margin: 0 }}>@mariedupont</Text>
<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 }}>12</Text>
<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 }}>48</Text>
<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 }}>156</Text>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{user?.participationsCount ?? 0}</Text>
<Text style={{ fontSize: 12, color: 'var(--sketch-gray)', margin: 0 }}>Participations</Text>
</div>
</div>
@@ -50,8 +57,8 @@ export function ProfileScreen({ navigate }: ScreenProps) {
{/* Upcoming events */}
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Mes événements à venir</Text>
{upcomingEvents.map((event, i) => (
<Card key={i} onClick={() => navigate('event-detail')} style={{ marginBottom: 12 }}>
{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}
@@ -1,9 +1,12 @@
import React from 'react';
import { Header, Text, Button, Card, Divider, Avatar } from '../components/sketchy';
import type { ScreenProps } from './index';
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 profileLink = 'festipod.app/u/mariedupont';
const { currentUser } = useFestipodData();
const user = currentUser;
const profileLink = `festipod.app/u/${(user?.username ?? '').replace('@', '')}`;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -46,11 +49,11 @@ export function ShareProfileScreen({ navigate }: ScreenProps) {
padding: 4,
borderRadius: '50%',
}}>
<Avatar initials="MD" size="sm" />
<Avatar initials={user?.initials ?? '?'} size="sm" />
</div>
</div>
<Text className="user-content" style={{ fontWeight: 'bold', margin: '0 0 4px 0' }}>Marie Dupont</Text>
<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>
@@ -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>
);
}
@@ -1,16 +1,23 @@
import React from 'react';
import { Header, Avatar, Title, Text, Button, Card, Badge, Divider } from '../components/sketchy';
import type { ScreenProps } from './index';
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 upcomingEvents = [
{ title: 'Résidence Reconnexion', date: '16-20 fév.', location: 'Le Revel, Rogues (30)', distance: 142, common: true },
{ title: 'Atelier permaculture', date: '28 fév.', location: 'Ferme des Music, Vénissieux', distance: 12, common: false },
];
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: '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 },
];
@@ -26,39 +33,46 @@ export function UserProfileScreen({ navigate }: ScreenProps) {
<div style={{ flex: 1, overflow: 'auto' }}>
{/* User profile header */}
<div style={{ padding: 24, textAlign: 'center' }}>
<Avatar initials="JD" size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>Jean Durand</Title>
<Text className="user-content" style={{ margin: 0 }}>@jeandurand</Text>
<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 }}>8</Text>
<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 }}>23</Text>
<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 }}>42</Text>
<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="primary">Ajouter au réseau</Button>
<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 */}
{/* Upcoming events from store */}
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements à venir</Text>
{upcomingEvents.map((event, i) => (
<Card key={i} onClick={() => navigate('event-detail')} style={{ marginBottom: 12 }}>
{(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>
@@ -67,10 +81,12 @@ export function UserProfileScreen({ navigate }: ScreenProps) {
</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>
{event.distance != null && (
<span style={{ color: 'var(--sketch-gray)' }}> · {event.distance} km</span>
)}
</Text>
</div>
{event.common && <Badge>moi aussi</Badge>}
<Badge>moi aussi</Badge>
</div>
</Card>
))}
@@ -78,7 +94,7 @@ export function UserProfileScreen({ navigate }: ScreenProps) {
<Divider />
{/* Past events */}
{/* Past events (still hardcoded for mockup) */}
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements passés</Text>
@@ -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é
-134
View File
@@ -1,134 +0,0 @@
import React, { useState } from 'react';
import { Header, Title, Text, Button, Avatar, Placeholder, Divider } from '../components/sketchy';
import type { ScreenProps } from './index';
export function EventDetailScreen({ navigate }: ScreenProps) {
const [isJoined, setIsJoined] = useState(false);
// In a real app, this would come from comparing current user with event creator
const isOwner = true;
const knownAttendees = [
{ initials: 'MD', name: 'Marie' },
{ initials: 'TM', name: 'Thomas' },
];
const unknownCount = 22;
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 }}>Résidence Reconnexion</Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
<Text style={{ margin: 0, fontSize: 15 }}>
📅 <span className="user-content">Lundi 16 - Vendredi 20 février 2026</span>
</Text>
<Text style={{ margin: 0, fontSize: 15 }}>
🕓 <span className="user-content">Semaine complète (arrivée dimanche possible)</span>
</Text>
<Text style={{ margin: 0, fontSize: 15 }}>
📍 <span className="user-content">Le Revel, Rogues (30)</span>
<span style={{ color: 'var(--sketch-gray)' }}> · 142 km</span>
</Text>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Button
variant={isJoined ? 'default' : 'primary'}
onClick={() => setIsJoined(!isJoined)}
style={{ flex: 1 }}
>
{isJoined ? '✓ Inscrit' : 'Participer'}
</Button>
<Button onClick={() => navigate('invite')}>Inviter</Button>
</div>
{isJoined && (
<Button
onClick={() => navigate('meeting-points')}
style={{ width: '100%', marginBottom: 16 }}
>
📍 Points de rencontre
</Button>
)}
<Divider />
{/* Host */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<Avatar initials="RC" />
<div>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>Reconnexion</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 }}>
Une semaine collaborative pour se rencontrer, co-créer et faire avancer le projet de Réseau Social Universel.
Au programme : sessions plénières en intelligence collective, ateliers en forum ouvert, et randonnée
au Cirque de Navacelles. Hébergement sur place au Revel, écolieu à Rogues dans le Gard.
</Text>
<Divider />
{/* Attendees */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>Participants (24)</Text>
<Text
style={{ margin: 0, fontSize: 14, cursor: 'pointer' }}
onClick={() => navigate('participants-list')}
>
Voir tout
</Text>
</div>
<div style={{ display: 'flex', gap: 12 }}>
{knownAttendees.map((a, i) => (
<div
key={i}
style={{ textAlign: 'center', cursor: 'pointer' }}
onClick={() => navigate('user-profile')}
>
<Avatar initials={a.initials} size="sm" />
<Text className="user-content" style={{ margin: '4px 0 0 0', fontSize: 12 }}>{a.name}</Text>
</div>
))}
<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>
);
}
-39
View File
@@ -1,39 +0,0 @@
import React from 'react';
import { Button, Input, Title, Text } from '../components/sketchy';
import type { ScreenProps } from './index';
export function LoginScreen({ 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: 8 }}>Festipod</Title>
<Text style={{ textAlign: 'center', marginBottom: 32 }}>Créez et rejoignez des événements entre amis</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 variant="primary" 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>
);
}
-104
View File
@@ -1,104 +0,0 @@
import React from 'react';
import { Header, Text, Input, Button, Placeholder } from '../components/sketchy';
import type { ScreenProps } from './index';
export function UpdateEventScreen({ navigate }: ScreenProps) {
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 defaultValue="Résidence Reconnexion" />
</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" defaultValue="2026-02-16" />
</div>
<div style={{ flex: 1 }}>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Date de fin</Text>
<Input type="date" defaultValue="2026-02-20" />
</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" defaultValue="09:00" />
</div>
<div style={{ flex: 1 }}>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Heure de fin</Text>
<Input type="time" defaultValue="18:00" />
</div>
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Lieu *</Text>
<Input defaultValue="Le Revel, Rogues (30)" />
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Description</Text>
<textarea
className="sketchy-input"
defaultValue="Une semaine collaborative pour se rencontrer, co-créer et faire avancer le projet de Réseau Social Universel. Au programme : sessions plénières en intelligence collective, ateliers en forum ouvert, et randonnée au Cirque de Navacelles."
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: 'food', label: 'Gastronomie', emoji: '🍽' },
{ id: 'music', label: 'Musique', emoji: '🎵' },
{ id: 'tech', label: 'Tech', emoji: '💻' },
{ id: 'other', label: 'Autre', emoji: '' },
].map((theme) => (
<Button
key={theme.id}
variant={theme.id === 'social' ? 'primary' : 'default'}
style={{ fontSize: 13 }}
>
{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={() => navigate('event-detail')}
>
Enregistrer les modifications
</Button>
</div>
</div>
);
}
-68
View File
@@ -1,68 +0,0 @@
import React from 'react';
import { Header, Text, Input, Button, Avatar } from '../components/sketchy';
import type { ScreenProps } from './index';
export function UpdateProfileScreen({ navigate }: ScreenProps) {
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="MD" 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 defaultValue="Marie" />
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Nom *</Text>
<Input defaultValue="Dupont" />
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Pseudo</Text>
<Input defaultValue="@mariedupont" />
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Localisation</Text>
<Input defaultValue="Lyon, France" placeholder="Ville, Pays" />
</div>
<div>
<Text style={{ marginBottom: 6, fontSize: 14 }}>Bio</Text>
<textarea
className="sketchy-input"
defaultValue="Passionnée de transition écologique et de rencontres humaines."
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={() => navigate('profile')}
>
Enregistrer
</Button>
</div>
</div>
);
}
+24 -16
View File
@@ -1,20 +1,28 @@
import React from 'react';
import { HomeScreen } from './HomeScreen';
import { LoginScreen } from './LoginScreen';
import { ProfileScreen } from './ProfileScreen';
import { SettingsScreen } from './SettingsScreen';
import { UserProfileScreen } from './UserProfileScreen';
import { EventsScreen } from './EventsScreen';
import { EventDetailScreen } from './EventDetailScreen';
import { CreateEventScreen } from './CreateEventScreen';
import { UpdateEventScreen } from './UpdateEventScreen';
import { InviteScreen } from './InviteScreen';
import { ParticipantsListScreen } from './ParticipantsListScreen';
import { MeetingPointsScreen } from './MeetingPointsScreen';
import { FriendsListScreen } from './FriendsListScreen';
import { ShareProfileScreen } from './ShareProfileScreen';
import { UpdateProfileScreen } from './UpdateProfileScreen';
import { WelcomeScreen } from './WelcomeScreen';
// Home module
import { HomeScreen } from '../modules/home/screens/HomeScreen';
import { SettingsScreen } from '../modules/home/screens/SettingsScreen';
// Auth module
import { LoginScreen } from '../modules/auth/screens/LoginScreen';
import { WelcomeScreen } from '../modules/auth/screens/WelcomeScreen';
// Event module
import { EventsScreen } from '../modules/event/screens/EventsScreen';
import { EventDetailScreen } from '../modules/event/screens/EventDetailScreen';
import { CreateEventScreen } from '../modules/event/screens/CreateEventScreen';
import { UpdateEventScreen } from '../modules/event/screens/UpdateEventScreen';
import { InviteScreen } from '../modules/event/screens/InviteScreen';
import { ParticipantsListScreen } from '../modules/event/screens/ParticipantsListScreen';
import { MeetingPointsScreen } from '../modules/event/screens/MeetingPointsScreen';
// User module
import { ProfileScreen } from '../modules/user/screens/ProfileScreen';
import { UpdateProfileScreen } from '../modules/user/screens/UpdateProfileScreen';
import { UserProfileScreen } from '../modules/user/screens/UserProfileScreen';
import { FriendsListScreen } from '../modules/user/screens/FriendsListScreen';
import { ShareProfileScreen } from '../modules/user/screens/ShareProfileScreen';
export interface Screen {
id: string;
@@ -0,0 +1,57 @@
import React from 'react';
import { useNextGraph } from '../../context/NextGraphContext';
export function BrokerBanner() {
const { status, connect } = useNextGraph();
const isConnected = status === 'connected';
const isConnecting = status === 'connecting';
const bgColor = isConnected ? '#4CAF50' : isConnecting ? '#FFB74D' : '#A5D6A7';
const textColor = isConnected ? 'white' : isConnecting ? 'white' : '#2E7D32';
return (
<div
style={{
background: bgColor,
color: textColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 12px',
fontFamily: 'var(--font-sketch)',
fontSize: 13,
fontWeight: 'bold',
flexShrink: 0,
cursor: !isConnected && !isConnecting ? 'pointer' : 'default',
}}
onClick={!isConnected && !isConnecting ? connect : undefined}
title={isConnected ? 'Connecté à NextGraph' : isConnecting ? 'Connexion en cours...' : 'Cliquer pour se connecter à NextGraph'}
>
<span>
{isConnected ? 'NextGraph' : isConnecting ? 'Connexion...' : 'Se connecter'}
</span>
{isConnected && (
<button
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px 4px',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
}}
title="Recharger"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.85)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 2v6h-6" />
<path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
<path d="M3 22v-6h6" />
<path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
</svg>
</button>
)}
</div>
);
}
@@ -0,0 +1,78 @@
import React from 'react';
import { useNextGraph } from '../../context/NextGraphContext';
export function NgStatus() {
const { status } = useNextGraph();
if (status === 'disconnected' || status === 'error') {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
color: 'var(--sketch-gray)',
fontFamily: 'var(--font-sketch)',
}}
title="Mode démonstration — NextGraph non connecté"
>
<span style={{
width: 6,
height: 6,
borderRadius: '50%',
background: '#ccc',
display: 'inline-block',
}} />
démo
</div>
);
}
if (status === 'connecting') {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
color: 'var(--sketch-gray)',
fontFamily: 'var(--font-sketch)',
}}
>
<span style={{
width: 6,
height: 6,
borderRadius: '50%',
background: '#ff9800',
display: 'inline-block',
}} />
connexion...
</div>
);
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
color: '#4caf50',
fontFamily: 'var(--font-sketch)',
}}
title="Connecté à NextGraph"
>
<span style={{
width: 6,
height: 6,
borderRadius: '50%',
background: '#4caf50',
display: 'inline-block',
}} />
NextGraph
</div>
);
}
@@ -12,3 +12,4 @@ export { Header } from './Header';
export { NavBar } from './NavBar';
export { Divider } from './Divider';
export { PhoneFrame } from './PhoneFrame';
export { BrokerBanner } from './BrokerBanner';
@@ -2,7 +2,7 @@ import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -1,6 +1,6 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -1,6 +1,6 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
@@ -3,7 +3,7 @@
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
@@ -4,7 +4,7 @@ import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
@@ -1,6 +1,6 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
@@ -1,7 +1,7 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/lib/utils";
function TooltipProvider({
delayDuration = 0,
+473
View File
@@ -0,0 +1,473 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
import type {
FpEventData,
FpUserData,
FpParticipationData,
FpMeetingPointData,
FpFriendshipData,
} from '../data/types';
import {
CURRENT_USER_ID,
seedEvents,
seedUsers,
seedParticipations,
seedMeetingPoints,
seedFriendships,
} from '../data/seedData';
import { useNextGraph } from './NextGraphContext';
import { sessionPromise } from '../utils/ngSession';
import { useShapeWithDefaults } from '../hooks/useShapeWithDefaults';
import { bootstrapWallet } from '../utils/ngBootstrap';
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
// ============================================================================
// Context interface
// ============================================================================
interface FestipodDataContextValue {
currentUserId: string;
currentUser: FpUserData | undefined;
events: FpEventData[];
users: FpUserData[];
participations: FpParticipationData[];
meetingPoints: FpMeetingPointData[];
friendships: FpFriendshipData[];
getEvent(id: string): FpEventData | undefined;
getUser(id: string): FpUserData | undefined;
getEventParticipants(eventId: string): FpUserData[];
getUserEvents(userId: string): FpEventData[];
isParticipating(eventId: string, userId?: string): boolean;
getFriends(userId?: string): FpUserData[];
getEventMeetingPoints(eventId: string): FpMeetingPointData[];
selectedEventId: string;
setSelectedEventId(id: string): void;
selectedEvent: FpEventData | undefined;
selectedUserId: string;
setSelectedUserId(id: string): void;
selectedUser: FpUserData | undefined;
createEvent(event: Omit<FpEventData, 'id'>): FpEventData;
updateEvent(id: string, updates: Partial<FpEventData>): void;
joinEvent(eventId: string, userId?: string): void;
leaveEvent(eventId: string, userId?: string): void;
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
addFriend(friendId: string): void;
updateProfile(updates: Partial<FpUserData>): void;
}
const FestipodDataContext = createContext<FestipodDataContextValue | null>(null);
// ============================================================================
// Helpers
// ============================================================================
let idCounter = 100;
function nextId(prefix: string): string {
return `${prefix}-${++idCounter}`;
}
function findNg<T extends { "@id": string }>(set: Set<T>, predicate: (item: T) => boolean): T | undefined {
for (const item of set) {
if (predicate(item)) return item;
}
return undefined;
}
// NG shape → app type mappers
const mapEvent = (e: FpEvent): FpEventData => ({
id: e["@id"],
title: e.title,
description: e.description || '',
date: e.date,
location: e.location,
distance: e.distance,
participantCount: e.participantCount,
coverImage: e.coverImage,
hostName: e.hostName,
hostInitials: e.hostInitials,
});
const mapUser = (u: FpUserProfile): FpUserData => ({
id: u["@id"],
name: u.name,
initials: u.initials,
username: u.username,
role: u.role,
isPublic: u.isPublic,
});
const mapParticipation = (p: FpParticipation): FpParticipationData => ({
id: p["@id"],
eventId: p.event,
userId: p.user,
isConfirmed: p.isConfirmed,
});
// ============================================================================
// Shared queries builder — same logic for both local and NG modes
// ============================================================================
function buildQueries(
events: FpEventData[],
users: FpUserData[],
participations: FpParticipationData[],
meetingPoints: FpMeetingPointData[],
friendships: FpFriendshipData[],
currentUserId: string,
) {
const getEvent = (id: string) => events.find(e => e.id === id);
const getUser = (id: string) => users.find(u => u.id === id);
const getEventParticipants = (eventId: string) => {
const partUserIds = participations.filter(p => p.eventId === eventId).map(p => p.userId);
return users.filter(u => partUserIds.includes(u.id));
};
const getUserEvents = (userId: string) => {
const partEventIds = participations.filter(p => p.userId === userId).map(p => p.eventId);
return events.filter(e => partEventIds.includes(e.id));
};
const isParticipating = (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
return participations.some(p => p.eventId === eventId && p.userId === uid);
};
const getFriends = (userId?: string) => {
const uid = userId || currentUserId;
const friendIds = friendships
.filter(f => f.userId === uid || f.friendId === uid)
.map(f => f.userId === uid ? f.friendId : f.userId);
return users.filter(u => friendIds.includes(u.id));
};
const getEventMeetingPoints = (eventId: string) => {
return meetingPoints.filter(mp => mp.eventId === eventId);
};
return { getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints };
}
// ============================================================================
// Local (disconnected) provider — uses seed data directly, read-only
// ============================================================================
function useLocalData(): FestipodDataContextValue {
const [selectedEventId, setSelectedEventId] = useState<string>('event-1');
const [selectedUserId, setSelectedUserId] = useState<string>('');
const events = seedEvents;
const users = seedUsers;
const participations = seedParticipations;
const meetingPoints = seedMeetingPoints;
const friendships = seedFriendships;
const currentUserId = CURRENT_USER_ID;
const currentUser = users.find(u => u.id === currentUserId);
const selectedEvent = events.find(e => e.id === selectedEventId);
const selectedUser = users.find(u => u.id === selectedUserId);
const queries = buildQueries(events, users, participations, meetingPoints, friendships, currentUserId);
console.log('[FestipodData] Render — local | events:', events.length,
'| selectedEvent:', selectedEvent?.title ?? '(none)');
// Local mode: mutations are no-ops (static defaults)
const createEvent = useCallback((event: Omit<FpEventData, 'id'>): FpEventData => {
console.log('[FestipodData] createEvent (local, no-op):', event.title);
return { ...event, id: nextId('event') };
}, []);
const updateEvent = useCallback((_id: string, _updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (local, no-op)');
}, []);
const joinEvent = useCallback((_eventId: string) => {
console.log('[FestipodData] joinEvent (local, no-op)');
}, []);
const leaveEvent = useCallback((_eventId: string) => {
console.log('[FestipodData] leaveEvent (local, no-op)');
}, []);
const addMeetingPoint = useCallback((_mp: Omit<FpMeetingPointData, 'id'>) => {
console.log('[FestipodData] addMeetingPoint (local, no-op)');
}, []);
const addFriend = useCallback((_friendId: string) => {
console.log('[FestipodData] addFriend (local, no-op)');
}, []);
const updateProfile = useCallback((_updates: Partial<FpUserData>) => {
console.log('[FestipodData] updateProfile (local, no-op)');
}, []);
return {
currentUserId, currentUser,
events, users, participations, meetingPoints, friendships,
selectedEventId, setSelectedEventId, selectedEvent,
selectedUserId, setSelectedUserId, selectedUser,
...queries,
createEvent, updateEvent, joinEvent, leaveEvent,
addMeetingPoint, addFriend, updateProfile,
};
}
// ============================================================================
// NextGraph-connected provider — uses useShapeWithDefaults + bootstrap
// ============================================================================
function useNgData(): FestipodDataContextValue {
const [shapesReady, setShapesReady] = useState(false);
// useShapeWithDefaults calls useShape internally (only safe when NG is connected)
const eventsShape = useShapeWithDefaults(FpEventShapeType, seedEvents, mapEvent, shapesReady);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, seedUsers, mapUser, shapesReady);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, seedParticipations, mapParticipation, shapesReady);
const events = eventsShape.items;
const users = usersShape.items;
const participations = participationsShape.items;
// Not in SHEX shapes yet
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>(seedMeetingPoints);
const [friendships, setFriendships] = useState<FpFriendshipData[]>(seedFriendships);
const [selectedEventId, setSelectedEventId] = useState<string>('');
const [selectedUserId, setSelectedUserId] = useState<string>('');
// --- Bootstrap: detect when NG data is available, seed if first time ---
const bootstrapDone = useRef(false);
const bootstrapInProgress = useRef(false);
// Reactive detection: when ngSet gets populated, NG data has arrived (returning user)
// Skip if bootstrap is currently seeding (to avoid race condition)
useEffect(() => {
if (shapesReady || bootstrapDone.current || bootstrapInProgress.current) return;
const evSize = eventsShape.ngSet.size;
const uSize = usersShape.ngSet.size;
const pSize = participationsShape.ngSet.size;
console.log('[FestipodData] Checking ngSet sizes — events:', evSize, 'users:', uSize, 'participations:', pSize);
if (evSize > 0 && uSize > 0) {
console.log('[FestipodData] NG data fully loaded — returning user, marking ready');
bootstrapDone.current = true;
setShapesReady(true);
// Auto-select first event
const first = [...eventsShape.ngSet][0];
if (first) {
console.log('[FestipodData] Selecting first event:', first.title, first["@id"]);
setSelectedEventId(first["@id"]);
}
}
});
// Timeout fallback: if ngSet stays empty, assume empty wallet → bootstrap seed data
useEffect(() => {
if (bootstrapDone.current) return;
const timer = setTimeout(async () => {
if (bootstrapDone.current) return;
// Lock so reactive effect doesn't fire during seeding
bootstrapInProgress.current = true;
console.log('[FestipodData] Timeout reached — checking if seeding needed...');
console.log('[FestipodData] ngSet sizes — events:', eventsShape.ngSet.size,
'users:', usersShape.ngSet.size, 'participations:', participationsShape.ngSet.size);
// If data arrived while we waited, just mark ready
if (eventsShape.ngSet.size > 0 && usersShape.ngSet.size > 0) {
console.log('[FestipodData] Data arrived before timeout — marking ready');
bootstrapDone.current = true;
bootstrapInProgress.current = false;
setShapesReady(true);
const first = [...eventsShape.ngSet][0];
if (first) setSelectedEventId(first["@id"]);
return;
}
console.log('[FestipodData] Wallet empty — seeding...');
const result = await bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
);
bootstrapDone.current = true;
bootstrapInProgress.current = false;
setShapesReady(true);
if (result.seeded) {
const firstIri = result.eventIdMap.get('event-1');
if (firstIri) {
console.log('[FestipodData] Bootstrap done, selecting first event:', firstIri);
setSelectedEventId(firstIri);
}
}
}, 3000);
return () => clearTimeout(timer);
}, []);
// --- Derived ---
const currentUser = users.find(u => u.username === '@mariedupont') || users[0];
const currentUserId = currentUser?.id || '';
const selectedEvent = events.find(e => e.id === selectedEventId);
const selectedUser = users.find(u => u.id === selectedUserId);
const queries = buildQueries(events, users, participations, meetingPoints, friendships, currentUserId);
console.log('[FestipodData] Render — NG | events:', events.length,
'| users:', users.length, '| participations:', participations.length,
'| selectedEvent:', selectedEvent?.title ?? '(none)');
// --- Mutations (NG) ---
const createEvent = useCallback((event: Omit<FpEventData, 'id'>): FpEventData => {
console.log('[FestipodData] createEvent (NG):', event.title);
(async () => {
const session = await sessionPromise;
const graph = `did:ng:${session.private_store_id}`;
eventsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Event", "@id": "",
title: event.title, description: event.description, date: event.date,
location: event.location, distance: event.distance,
participantCount: event.participantCount || 1,
coverImage: event.coverImage, hostName: event.hostName, hostInitials: event.hostInitials,
} as FpEvent);
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
if (addedEvent && currentUserId) {
participationsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
} as FpParticipation);
setSelectedEventId(addedEvent["@id"]);
}
})();
return { ...event, id: `ng-pending-${Date.now()}` };
}, [eventsShape.ngSet, participationsShape.ngSet, currentUserId]);
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === id);
if (ngEvent) {
if (updates.title !== undefined) ngEvent.title = updates.title;
if (updates.description !== undefined) ngEvent.description = updates.description;
if (updates.date !== undefined) ngEvent.date = updates.date;
if (updates.location !== undefined) ngEvent.location = updates.location;
if (updates.distance !== undefined) ngEvent.distance = updates.distance;
if (updates.participantCount !== undefined) ngEvent.participantCount = updates.participantCount;
}
}, [eventsShape.ngSet]);
const joinEvent = useCallback((eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid);
const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (existing) {
console.log('[FestipodData] Already participating, skipping');
return;
}
(async () => {
const session = await sessionPromise;
const graph = `did:ng:${session.private_store_id}`;
participationsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
} as FpParticipation);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = ngEvent.participantCount + 1;
}
console.log('[FestipodData] joinEvent done');
})();
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
const leaveEvent = useCallback((eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid,
'| ngSet sizes — events:', eventsShape.ngSet.size,
'users:', usersShape.ngSet.size,
'participations:', participationsShape.ngSet.size,
'| bootstrapDone:', bootstrapDone.current);
const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (ngPart) {
console.log('[FestipodData] Deleting participation:', ngPart["@id"]);
participationsShape.ngSet.delete(ngPart);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
}
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
}, []);
const addFriend = useCallback((friendId: string) => {
setFriendships(prev => {
if (prev.some(f =>
(f.userId === currentUserId && f.friendId === friendId) ||
(f.userId === friendId && f.friendId === currentUserId)
)) return prev;
return [...prev, { id: `ng-fr-${Date.now()}`, userId: currentUserId, friendId }];
});
}, [currentUserId]);
const updateProfile = useCallback((updates: Partial<FpUserData>) => {
console.log('[FestipodData] updateProfile (NG):', updates);
const ngUser = findNg(usersShape.ngSet as any as Set<FpUserProfile>, u => u.username === '@mariedupont')
|| [...usersShape.ngSet][0];
if (ngUser) {
if (updates.name !== undefined) ngUser.name = updates.name;
if (updates.initials !== undefined) ngUser.initials = updates.initials;
if (updates.username !== undefined) ngUser.username = updates.username;
if (updates.role !== undefined) ngUser.role = updates.role;
if (updates.isPublic !== undefined) ngUser.isPublic = updates.isPublic;
}
}, [usersShape.ngSet]);
return {
currentUserId, currentUser,
events, users, participations, meetingPoints, friendships,
selectedEventId, setSelectedEventId, selectedEvent,
selectedUserId, setSelectedUserId, selectedUser,
...queries,
createEvent, updateEvent, joinEvent, leaveEvent,
addMeetingPoint, addFriend, updateProfile,
};
}
// ============================================================================
// Provider — switches between local and NG
// ============================================================================
function LocalDataProvider({ children }: { children: ReactNode }) {
const data = useLocalData();
return <FestipodDataContext.Provider value={data}>{children}</FestipodDataContext.Provider>;
}
function NgDataProvider({ children }: { children: ReactNode }) {
const data = useNgData();
return <FestipodDataContext.Provider value={data}>{children}</FestipodDataContext.Provider>;
}
export function FestipodDataProvider({ children }: { children: ReactNode }) {
const { status } = useNextGraph();
console.log('[FestipodData] Provider — NG status:', status);
if (status === 'connected') {
return <NgDataProvider>{children}</NgDataProvider>;
}
return <LocalDataProvider>{children}</LocalDataProvider>;
}
// ============================================================================
// Hook
// ============================================================================
export function useFestipodData() {
const context = useContext(FestipodDataContext);
if (!context) {
throw new Error('useFestipodData must be used within a FestipodDataProvider');
}
return context;
}
+87
View File
@@ -0,0 +1,87 @@
import React, { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
import { session, sessionPromise, init as initNg, type NextGraphSession } from '../utils/ngSession';
type NgStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
interface NextGraphContextValue {
status: NgStatus;
session: NextGraphSession | undefined;
error: string | undefined;
connect: () => void;
}
const NextGraphContext = createContext<NextGraphContextValue>({
status: 'disconnected',
session: undefined,
error: undefined,
connect: () => {},
});
// Track whether initNg() has been called (module-level to survive re-renders)
let ngInitStarted = false;
export function NextGraphProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<NgStatus>(session ? 'connected' : 'disconnected');
const [ngSession, setNgSession] = useState<NextGraphSession | undefined>(session);
const [error, setError] = useState<string | undefined>();
// Auto-init on mount: register the initNgWeb callback so we catch the
// auto-connect event from the NG iframe. This must happen early.
useEffect(() => {
if (ngInitStarted) return;
ngInitStarted = true;
console.log('[NG] Auto-init: calling initNg() on mount');
setStatus('connecting');
initNg();
sessionPromise
.then((s) => {
console.log('[NG] Session obtained, stores:', {
private: s.private_store_id,
protected: s.protected_store_id,
public: s.public_store_id,
});
setNgSession(s);
setStatus('connected');
})
.catch((err) => {
console.error('[NG] Connection failed:', err);
setError(err?.message || 'Connexion NextGraph impossible');
setStatus('error');
});
}, []);
// connect() is now just a fallback — initNg() already started on mount
const connect = useCallback(() => {
if (status === 'connecting' || status === 'connected') return;
console.log('[NG] connect() called, current status:', status);
setStatus('connecting');
setError(undefined);
// initNg() is idempotent (initNgWeb handles multiple calls)
initNg();
sessionPromise
.then((s) => {
setNgSession(s);
setStatus('connected');
})
.catch((err) => {
console.error('[NG] Connection failed:', err);
setError(err?.message || 'Connexion NextGraph impossible');
setStatus('error');
});
}, [status]);
return (
<NextGraphContext.Provider value={{ status, session: ngSession, error, connect }}>
{children}
</NextGraphContext.Provider>
);
}
export function useNextGraph() {
return useContext(NextGraphContext);
}
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
/**
* Seed/fake data for Festipod mockups.
* Used as defaults when not connected to NextGraph or when the store is empty.
*/
import type { FpEventData, FpUserData, FpParticipationData, FpMeetingPointData, FpFriendshipData } from './types';
// Current user (the logged-in user in the mockup)
export const CURRENT_USER_ID = 'user-1';
export const seedUsers: FpUserData[] = [
{
id: 'user-1',
name: 'Marie Dupont',
initials: 'MD',
username: '@mariedupont',
bio: 'Passionnée de transition écologique et de rencontres humaines.',
city: 'Lyon, France',
eventsCount: 12,
friendsCount: 48,
participationsCount: 156,
},
{
id: 'user-2',
name: 'Jean Durand',
initials: 'JD',
username: '@jeandurand',
eventsCount: 8,
friendsCount: 23,
participationsCount: 42,
},
{
id: 'user-3',
name: 'Alice Martin',
initials: 'AM',
username: '@alice',
},
{
id: 'user-4',
name: 'Baptiste Morel',
initials: 'BM',
username: '@baptiste',
},
{
id: 'user-5',
name: 'Camille Dubois',
initials: 'CD',
username: '@camille',
},
{
id: 'user-6',
name: 'David Leroy',
initials: 'DL',
username: '@david',
},
{
id: 'user-7',
name: 'Thomas Martin',
initials: 'TM',
username: '@thomas',
},
{
id: 'user-8',
name: 'Emma Bernard',
initials: 'EB',
username: '@emma',
},
{
id: 'user-9',
name: 'François Petit',
initials: 'FP',
username: '@francois',
},
{
id: 'user-10',
name: 'Emma Girard',
initials: 'EG',
username: '@emma',
eventsCount: 7,
},
// Public profiles
{
id: 'user-pub-1',
name: 'Léa Bernard',
initials: 'LB',
username: '@leabernard',
role: 'Relayeuse',
isPublic: true,
eventsCount: 45,
},
{
id: 'user-pub-2',
name: 'Marc Richard',
initials: 'MR',
username: '@marcrichard',
role: 'Animateur',
isPublic: true,
eventsCount: 67,
},
{
id: 'user-pub-3',
name: 'Sophie Fontaine',
initials: 'SF',
username: '@sophief',
role: 'Créatrice',
isPublic: true,
eventsCount: 23,
},
{
id: 'user-pub-4',
name: 'Pierre Gagnon',
initials: 'PG',
username: '@pierreg',
role: 'Relayeur',
isPublic: true,
eventsCount: 89,
},
];
export const seedEvents: FpEventData[] = [
{
id: 'event-1',
title: 'Résidence Reconnexion',
date: 'Lun. 16 - Ven. 20 fév.',
startDate: '2026-02-16',
endDate: '2026-02-20',
startTime: '09:00',
endTime: '18:00',
location: 'Le Revel, Rogues (30)',
distance: 142,
participantCount: 24,
description: 'Une semaine collaborative pour se rencontrer, co-créer et faire avancer le projet de Réseau Social Universel. Au programme : sessions plénières en intelligence collective, ateliers en forum ouvert, et randonnée au Cirque de Navacelles. Hébergement sur place au Revel, écolieu à Rogues dans le Gard.',
hostName: 'Reconnexion',
hostInitials: 'RC',
themes: ['Social'],
},
{
id: 'event-2',
title: 'Atelier low-tech',
date: 'Sam. 8 fév. · 14h00',
startDate: '2026-02-08',
startTime: '14:00',
endTime: '17:00',
location: 'La Maison du Vélo, Lyon',
distance: 3,
participantCount: 12,
description: 'Un atelier pratique pour découvrir les low-tech et apprendre à fabriquer des objets du quotidien.',
hostName: 'La Maison du Vélo',
hostInitials: 'MV',
themes: ['Tech', 'Nature'],
},
{
id: 'event-3',
title: 'Forum Ouvert Transition',
date: 'Sam. 22 fév. · 9h00',
startDate: '2026-02-22',
startTime: '09:00',
endTime: '18:00',
location: "Tiers-lieu L'Hermitage",
distance: 89,
participantCount: 45,
description: "Un forum ouvert sur la transition écologique et sociale, dans le tiers-lieu L'Hermitage.",
hostName: "L'Hermitage",
hostInitials: 'LH',
themes: ['Social', 'Nature'],
},
{
id: 'event-4',
title: 'Formation CNV',
date: 'Sam. 1 mars · 9h30',
startDate: '2026-03-01',
startTime: '09:30',
endTime: '17:00',
location: 'MJC Montplaisir, Lyon',
distance: 5,
participantCount: 16,
description: 'Initiation à la Communication Non Violente. Venez découvrir les bases de la CNV pour améliorer vos relations.',
hostName: 'MJC Montplaisir',
hostInitials: 'MJ',
themes: ['Social'],
},
{
id: 'event-5',
title: 'Rencontre des Colibris',
date: 'Mer. 12 fév. · 19h00',
startDate: '2026-02-12',
startTime: '19:00',
endTime: '21:00',
location: "La Maison de l'Environnement",
distance: 7,
participantCount: 30,
description: "Rencontre mensuelle du groupe local des Colibris pour échanger sur les projets en cours.",
hostName: 'Les Colibris',
hostInitials: 'LC',
themes: ['Social', 'Nature'],
},
];
// Participations: which users are in which events
export const seedParticipations: FpParticipationData[] = [
// Marie (current user) participates in events 1, 2, 3
{ id: 'part-1', eventId: 'event-1', userId: 'user-1', isConfirmed: true },
{ id: 'part-2', eventId: 'event-2', userId: 'user-1', isConfirmed: true },
{ id: 'part-3', eventId: 'event-3', userId: 'user-1', isConfirmed: true },
// Jean participates in events 1
{ id: 'part-4', eventId: 'event-1', userId: 'user-2', isConfirmed: true },
// Thomas participates in event 1
{ id: 'part-5', eventId: 'event-1', userId: 'user-7', isConfirmed: true },
];
export const seedMeetingPoints: FpMeetingPointData[] = [
{
id: 'mp-1',
eventId: 'event-1',
location: 'Café de la Place',
time: '30 min avant',
hostName: 'Marie',
hostInitials: 'MD',
},
{
id: 'mp-2',
eventId: 'event-1',
location: 'Station de métro Bellecour',
time: '15h30',
hostName: 'Jean',
hostInitials: 'JD',
},
];
export const seedFriendships: FpFriendshipData[] = [
{ id: 'fr-1', userId: 'user-1', friendId: 'user-2' },
{ id: 'fr-2', userId: 'user-1', friendId: 'user-3' },
{ id: 'fr-3', userId: 'user-1', friendId: 'user-4' },
{ id: 'fr-4', userId: 'user-1', friendId: 'user-5' },
{ id: 'fr-5', userId: 'user-1', friendId: 'user-6' },
{ id: 'fr-6', userId: 'user-1', friendId: 'user-10' },
];
@@ -10,6 +10,216 @@ export interface StepDefinitionInfo {
}
export const stepDefinitions: StepDefinitionInfo[] = [
{
"pattern": "je clique sur un événement",
"keyword": "When",
"file": "event.steps.ts",
"sourceCode": "When('je clique sur un événement', async function (this: FestipodWorld) {\n this.navigateTo('#/demo/event-detail');\n});",
"lineNumber": 5
},
{
"pattern": "je visualise l'événement {string}",
"keyword": "Given",
"file": "event.steps.ts",
"sourceCode": "Given('je visualise l\\'événement {string}', async function (this: FestipodWorld, eventName: string) {\n this.navigateTo('#/demo/event-detail');\n expect(this.currentScreen, 'Event detail screen should be loaded').to.not.be.null;\n this.attach(`Viewing event: ${eventName}`, 'text/plain');\n});",
"lineNumber": 9
},
{
"pattern": "je peux annuler et revenir à l'écran précédent",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('je peux annuler et revenir à l\\'écran précédent', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n const source = this.getRenderedText();\n const found = /onClick\\s*=\\s*\\{\\s*\\(\\)\\s*=>\\s*navigate\\s*\\(['\"]home['\"]\\)\\s*\\}[^>]*>✕</.test(source);\n expect(found, 'Create event screen should have ✕ button with navigate(\"home\")').to.be.true;\n});",
"lineNumber": 15
},
{
"pattern": "je peux voir la liste des participants",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('je peux voir la liste des participants', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n const hasAvatars = /<Avatar/.test(source);\n const hasParticipantsSection = /Participants\\s*\\(\\d+\\)/.test(source);\n expect(hasAvatars, 'Event detail should have Avatar components for participants').to.be.true;\n expect(hasParticipantsSection, 'Event detail should have \"Participants (N)\" section').to.be.true;\n});",
"lineNumber": 22
},
{
"pattern": "je peux voir les détails de l'événement",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('je peux voir les détails de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n expect(/<Title[^>]*>[^<]+<\\/Title>/.test(source), 'Event detail should have a Title').to.be.true;\n expect(/📅/.test(source), 'Event detail should have date emoji 📅').to.be.true;\n expect(/🕓/.test(source), 'Event detail should have time emoji 🕓').to.be.true;\n expect(/📍/.test(source), 'Event detail should have location emoji 📍').to.be.true;\n expect(/À propos/.test(source), 'Event detail should have \"À propos\" section').to.be.true;\n});",
"lineNumber": 31
},
{
"pattern": "l'écran affiche les informations de l'événement",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('l\\'écran affiche les informations de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n expect(/<Title[^>]*>[^<]+<\\/Title>/.test(source), 'Event detail should have a Title').to.be.true;\n expect(/📅/.test(source), 'Event detail should have date emoji 📅').to.be.true;\n expect(/🕓/.test(source), 'Event detail should have time emoji 🕓').to.be.true;\n expect(/📍/.test(source), 'Event detail should have location emoji 📍').to.be.true;\n expect(/À propos/.test(source), 'Event detail should have \"À propos\" section').to.be.true;\n});",
"lineNumber": 41
},
{
"pattern": "je peux voir la liste des événements",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('je peux voir la liste des événements', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n if (this.currentScreenId === 'home') {\n expect(/Mes événements à venir/.test(source), 'Home screen should have \"Événements à venir\" text').to.be.true;\n } else if (this.currentScreenId === 'events') {\n expect(/<Card[^>]*onClick/.test(source), 'Events screen should have clickable Card components').to.be.true;\n } else {\n expect.fail(`Unexpected screen \"${this.currentScreenId}\" - events list should be on home or events screen`);\n }\n});",
"lineNumber": 51
},
{
"pattern": "les événements affichent leur lieu",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('les événements affichent leur lieu', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n const locationPattern = /📍.*<span[^>]*className=\"user-content\"[^>]*>[^<]+<\\/span>/;\n expect(locationPattern.test(source), 'Event cards should display location text after 📍 emoji').to.be.true;\n});",
"lineNumber": 62
},
{
"pattern": "je peux m'inscrire à l'événement",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('je peux m\\'inscrire à l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n const hasParticiperButton = /isJoined \\? '✓ Inscrit' : 'Participer'/.test(source);\n expect(hasParticiperButton, 'Event detail should have Participer/Inscrit toggle button').to.be.true;\n});",
"lineNumber": 68
},
{
"pattern": "je peux me désinscrire de l'événement",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('je peux me désinscrire de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n const hasInscritButton = /isJoined \\? '✓ Inscrit' : 'Participer'/.test(source);\n expect(hasInscritButton, 'Event detail should have Participer/Inscrit toggle button (click to unregister)').to.be.true;\n});",
"lineNumber": 75
},
{
"pattern": "le formulaire contient le champ obligatoire {string}",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('le formulaire contient le champ obligatoire {string}', async function (this: FestipodWorld, fieldName: string) {\n expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');\n const source = this.getRenderedText();\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`>${escapedName}\\\\s*\\\\*<`);\n expect(pattern.test(source), `Field \"${fieldName}\" should be marked as required (with *) in create-event screen`).to.be.true;\n});",
"lineNumber": 84
},
{
"pattern": "le formulaire contient les champs obligatoires suivants:",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('le formulaire contient les champs obligatoires suivants:', async function (this: FestipodWorld, dataTable) {\n expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');\n const source = this.getRenderedText();\n const expectedFields = dataTable.raw().flat();\n expectedFields.forEach((fieldName: string) => {\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`>${escapedName}\\\\s*\\\\*<`);\n expect(pattern.test(source), `Field \"${fieldName}\" should be marked as required (with *) in create-event screen`).to.be.true;\n });",
"lineNumber": 92
},
{
"pattern": "le formulaire permet de détecter les doublons",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('le formulaire permet de détecter les doublons', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n const source = this.getRenderedText();\n expect(/showDuplicateWarning/.test(source), 'Form should have duplicate detection logic').to.be.true;\n expect(/Événement similaire détecté/.test(source), 'Form should have duplicate warning message').to.be.true;\n});",
"lineNumber": 103
},
{
"pattern": "le formulaire permet d'importer depuis Mobilizon ou Transiscope",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('le formulaire permet d\\'importer depuis Mobilizon ou Transiscope', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n const source = this.getRenderedText();\n expect(/importableEvents/.test(source), 'Form should have importable events data').to.be.true;\n expect(/Mobilizon/.test(source), 'Form should support Mobilizon import').to.be.true;\n expect(/Transiscope/.test(source), 'Form should support Transiscope import').to.be.true;\n expect(/Importer depuis une source externe/.test(source), 'Form should have import section').to.be.true;\n});",
"lineNumber": 110
},
{
"pattern": "l'import externe ne déclenche pas d'alerte doublon",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('l\\'import externe ne déclenche pas d\\'alerte doublon', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n const source = this.getRenderedText();\n expect(/importedFrom/.test(source), 'Form should track import source').to.be.true;\n expect(/&& !importedFrom/.test(source), 'Duplicate warning should be disabled for imports').to.be.true;\n});",
"lineNumber": 119
},
{
"pattern": "je peux configurer mes notifications",
"keyword": "Then",
"file": "home.steps.ts",
"sourceCode": "Then('je peux configurer mes notifications', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('settings');\n const source = this.getRenderedText();\n expect(/>Notifications</.test(source), 'Settings should have \"Notifications\" text').to.be.true;\n expect(/<Toggle[^>]*checked=\\{notifications\\}/.test(source), 'Settings should have Toggle for notifications').to.be.true;\n});",
"lineNumber": 5
},
{
"pattern": "je clique sur un participant",
"keyword": "When",
"file": "user.steps.ts",
"sourceCode": "When('je clique sur un participant', async function (this: FestipodWorld) {\n this.navigateTo('#/demo/user-profile');\n});",
"lineNumber": 5
},
{
"pattern": "je visualise le profil de {string}",
"keyword": "Given",
"file": "user.steps.ts",
"sourceCode": "Given('je visualise le profil de {string}', async function (this: FestipodWorld, userName: string) {\n this.navigateTo('#/demo/user-profile');\n expect(this.currentScreen, 'User profile screen should be loaded').to.not.be.null;\n this.attach(`Viewing profile: ${userName}`, 'text/plain');\n});",
"lineNumber": 9
},
{
"pattern": "je peux voir mon profil",
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('je peux voir mon profil', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('profile');\n const source = this.getRenderedText();\n expect(/<Avatar[^>]*initials=\"MD\"[^>]*size=\"lg\"/.test(source), 'Profile should have Avatar with initials=\"MD\" and size=\"lg\"').to.be.true;\n expect(/<Title[^>]*>Marie Dupont<\\/Title>/.test(source), 'Profile should have Title \"Marie Dupont\"').to.be.true;\n expect(/@mariedupont/.test(source), 'Profile should have username @mariedupont').to.be.true;\n});",
"lineNumber": 15
},
{
"pattern": "je peux voir le profil de l'utilisateur",
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('je peux voir le profil de l\\'utilisateur', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const source = this.getRenderedText();\n expect(/<Avatar[^>]*initials=\"JD\"[^>]*size=\"lg\"/.test(source), 'User profile should have Avatar with initials=\"JD\" and size=\"lg\"').to.be.true;\n expect(/<Title[^>]*>Jean Durand<\\/Title>/.test(source), 'User profile should have Title \"Jean Durand\"').to.be.true;\n expect(/@jeandurand/.test(source), 'User profile should have username @jeandurand').to.be.true;\n});",
"lineNumber": 23
},
{
"pattern": "l'écran affiche les informations du profil",
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('l\\'écran affiche les informations du profil', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n if (this.currentScreenId === 'profile') {\n expect(/<Avatar[^>]*initials=\"MD\"/.test(source), 'Profile should have Avatar with initials=\"MD\"').to.be.true;\n expect(/<Title[^>]*>Marie Dupont<\\/Title>/.test(source), 'Profile should have Title \"Marie Dupont\"').to.be.true;\n expect(/@mariedupont/.test(source), 'Profile should have username @mariedupont').to.be.true;\n } else if (this.currentScreenId === 'user-profile') {\n expect(/<Avatar[^>]*initials=\"JD\"/.test(source), 'User profile should have Avatar with initials=\"JD\"').to.be.true;\n expect(/<Title[^>]*>Jean Durand<\\/Title>/.test(source), 'User profile should have Title \"Jean Durand\"').to.be.true;\n expect(/@jeandurand/.test(source), 'User profile should have username @jeandurand').to.be.true;\n } else {\n expect.fail(`Unexpected screen \"${this.currentScreenId}\" for profile info check`);\n }\n});",
"lineNumber": 31
},
{
"pattern": "je peux contacter l'utilisateur",
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('je peux contacter l\\'utilisateur', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const source = this.getRenderedText();\n const hasContactButton = /<Button>Contacter<\\/Button>/.test(source);\n expect(hasContactButton, 'User profile should have \"Contacter\" button').to.be.true;\n});",
"lineNumber": 46
},
{
"pattern": "je peux voir les événements auxquels l'utilisateur a participé",
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('je peux voir les événements auxquels l\\'utilisateur a participé', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const source = this.getRenderedText();\n expect(/Événements à venir/.test(source), 'User profile should have \"Événements à venir\" section').to.be.true;\n expect(/Événements passés/.test(source), 'User profile should have \"Événements passés\" section').to.be.true;\n});",
"lineNumber": 53
},
{
"pattern": "les événements affichent leur localisation et distance",
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('les événements affichent leur localisation et distance', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const source = this.getRenderedText();\n expect(/location: '[^']+'/.test(source), 'Events should have location data').to.be.true;\n expect(/distance: \\d+/.test(source), 'Events should have distance data').to.be.true;\n expect(/\\{event\\.location\\}/.test(source), 'Events should render location').to.be.true;\n expect(/\\{event\\.distance\\}/.test(source), 'Events should render distance').to.be.true;\n});",
"lineNumber": 60
},
{
"pattern": "je peux voir le QR code",
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('je peux voir le QR code', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n if (this.currentScreenId === 'share-profile') {\n expect(/QR Code/.test(source), 'Share profile should have \"QR Code\" text').to.be.true;\n expect(/Scannez pour me retrouver/.test(source), 'Share profile should have \"Scannez pour me retrouver\" text').to.be.true;\n } else if (this.currentScreenId === 'meeting-points') {\n expect(/Mon QR Code/.test(source), 'Meeting points should have \"Mon QR Code\" text').to.be.true;\n expect(/Scannez pour m'ajouter/.test(source), 'Meeting points should have \"Scannez pour m\\'ajouter\" text').to.be.true;\n } else {\n expect.fail(`QR code should be on share-profile or meeting-points, not \"${this.currentScreenId}\"`);\n }\n});",
"lineNumber": 69
},
{
"pattern": "je peux voir le lien de partage",
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('je peux voir le lien de partage', async function (this: FestipodWorld) {\n expect(this.currentScreenId, 'Share link should be on share-profile screen').to.equal('share-profile');\n const source = this.getRenderedText();\n expect(/Mon lien de profil/.test(source), 'Share profile should have \"Mon lien de profil\" text').to.be.true;\n expect(/festipod\\.app\\/u\\//.test(source), 'Share profile should have profile link URL').to.be.true;\n});",
"lineNumber": 82
},
{
"pattern": "l'écran {string} est affiché",
"keyword": "Given",
"file": "form.steps.ts",
"sourceCode": "Given('l\\'écran {string} est affiché', async function (this: FestipodWorld, screenName: string) {\n const screenId = screenName.toLowerCase().replace(/ /g, '-');\n this.navigateTo(`#/demo/${screenId}`);\n});",
"lineNumber": 5
},
{
"pattern": "le formulaire de création est vide",
"keyword": "Given",
"file": "form.steps.ts",
"sourceCode": "Given('le formulaire de création est vide', async function (this: FestipodWorld) {\n this.formFields.forEach((field, key) => {\n this.formFields.set(key, { ...field, value: '' });",
"lineNumber": 10
},
{
"pattern": "le champ {string} est facultatif",
"keyword": "Then",
"file": "form.steps.ts",
"sourceCode": "Then('le champ {string} est facultatif', async function (this: FestipodWorld, fieldName: string) {\n const source = this.getRenderedText();\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const existsPattern = new RegExp(`>${escapedName}<`);\n const requiredPattern = new RegExp(`>${escapedName}\\\\s*\\\\*<`);\n expect(existsPattern.test(source), `Field \"${fieldName}\" should exist in screen`).to.be.true;\n expect(requiredPattern.test(source), `Field \"${fieldName}\" should NOT be marked as required`).to.be.false;\n});",
"lineNumber": 16
},
{
"pattern": "le champ {string} est présent",
"keyword": "Then",
"file": "form.steps.ts",
"sourceCode": "Then('le champ {string} est présent', async function (this: FestipodWorld, fieldName: string) {\n const source = this.getRenderedText();\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`>${escapedName}[^<]*<`);\n expect(pattern.test(source), `Field \"${fieldName}\" should be present in screen`).to.be.true;\n});",
"lineNumber": 25
},
{
"pattern": "Scénario non implémenté",
"keyword": "Given",
@@ -56,309 +266,99 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"pattern": "je clique sur {string}",
"keyword": "When",
"file": "navigation.steps.ts",
"sourceCode": "When('je clique sur {string}', async function (this: FestipodWorld, elementName: string) {\n const source = this.getRenderedText();\n // Check that a clickable element with this text exists (onClick handler + text content)\n const escapedName = elementName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`onClick[^>]*>[^<]*${escapedName}`, 'i');\n expect(pattern.test(source), `Clickable element \"${elementName}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"sourceCode": "When('je clique sur {string}', async function (this: FestipodWorld, elementName: string) {\n const source = this.getRenderedText();\n const escapedName = elementName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`onClick[^>]*>[^<]*${escapedName}`, 'i');\n expect(pattern.test(source), `Clickable element \"${elementName}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 60
},
{
"pattern": "je sélectionne {string}",
"keyword": "When",
"file": "navigation.steps.ts",
"sourceCode": "When('je sélectionne {string}', async function (this: FestipodWorld, elementName: string) {\n const source = this.getRenderedText();\n // Check that a selectable element with this text exists\n const escapedName = elementName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`onClick[^>]*>[^<]*${escapedName}`, 'i');\n expect(pattern.test(source), `Selectable element \"${elementName}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 68
"sourceCode": "When('je sélectionne {string}', async function (this: FestipodWorld, elementName: string) {\n const source = this.getRenderedText();\n const escapedName = elementName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`onClick[^>]*>[^<]*${escapedName}`, 'i');\n expect(pattern.test(source), `Selectable element \"${elementName}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 67
},
{
"pattern": "je clique sur le bouton {string}",
"keyword": "When",
"file": "navigation.steps.ts",
"sourceCode": "When('je clique sur le bouton {string}', async function (this: FestipodWorld, buttonName: string) {\n const source = this.getRenderedText();\n // Check that a Button component with this label exists\n const escapedName = buttonName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`<Button[^>]*>[^<]*${escapedName}[^<]*</Button>`, 'i');\n expect(pattern.test(source), `Button \"${buttonName}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 76
},
{
"pattern": "je clique sur un participant",
"keyword": "When",
"file": "navigation.steps.ts",
"sourceCode": "When('je clique sur un participant', async function (this: FestipodWorld) {\n this.navigateTo('#/demo/user-profile');\n});",
"lineNumber": 84
},
{
"pattern": "je clique sur un événement",
"keyword": "When",
"file": "navigation.steps.ts",
"sourceCode": "When('je clique sur un événement', async function (this: FestipodWorld) {\n this.navigateTo('#/demo/event-detail');\n});",
"lineNumber": 88
"sourceCode": "When('je clique sur le bouton {string}', async function (this: FestipodWorld, buttonName: string) {\n const source = this.getRenderedText();\n const escapedName = buttonName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`<Button[^>]*>[^<]*${escapedName}[^<]*</Button>`, 'i');\n expect(pattern.test(source), `Button \"${buttonName}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 74
},
{
"pattern": "je suis redirigé vers {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('je suis redirigé vers {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n expect(this.currentScreenId).to.equal(screenId);\n});",
"lineNumber": 92
"lineNumber": 81
},
{
"pattern": "je vois l'écran {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('je vois l\\'écran {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n expect(this.currentScreenId).to.equal(screenId);\n});",
"lineNumber": 97
"lineNumber": 86
},
{
"pattern": "je reste sur la page {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('je reste sur la page {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n expect(this.currentScreenId).to.equal(screenId);\n});",
"lineNumber": 102
"lineNumber": 91
},
{
"pattern": "l'écran contient une section {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('l\\'écran contient une section {string}', async function (this: FestipodWorld, sectionName: string) {\n expect(this.hasText(sectionName), `Section \"${sectionName}\" should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 107
},
{
"pattern": "je peux annuler et revenir à l'écran précédent",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('je peux annuler et revenir à l\\'écran précédent', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n const source = this.getRenderedText();\n // Detect ✕ close button with onClick handler that calls navigate()\n const found = /onClick\\s*=\\s*\\{\\s*\\(\\)\\s*=>\\s*navigate\\s*\\(['\"]home['\"]\\)\\s*\\}[^>]*>✕</.test(source);\n expect(found, 'Create event screen should have ✕ button with navigate(\"home\")').to.be.true;\n});",
"lineNumber": 111
"lineNumber": 96
},
{
"pattern": "je peux naviguer vers {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('je peux naviguer vers {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n const source = this.getRenderedText();\n // Check that a navigation link to this screen exists: navigate('screenId') or onClick={() => navigate('screenId')}\n const pattern = new RegExp(`navigate\\\\s*\\\\(\\\\s*['\"]${screenId}['\"]\\\\s*\\\\)`);\n expect(pattern.test(source), `Navigation to \"${screenId}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 119
"sourceCode": "Then('je peux naviguer vers {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n const source = this.getRenderedText();\n const pattern = new RegExp(`navigate\\\\s*\\\\(\\\\s*['\"]${screenId}['\"]\\\\s*\\\\)`);\n expect(pattern.test(source), `Navigation to \"${screenId}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 100
},
{
"pattern": "la navigation affiche {string} comme actif",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('la navigation affiche {string} comme actif', async function (this: FestipodWorld, menuItem: string) {\n const source = this.getRenderedText();\n // Check that NavBar has an item with this label and active: true\n // Pattern: { icon: '...', label: 'menuItem', active: true }\n const escapedItem = menuItem.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`label:\\\\s*['\"]${escapedItem}['\"][^}]*active:\\\\s*true`, 'i');\n expect(pattern.test(source), `Menu item \"${menuItem}\" should be active in NavBar of screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 127
"sourceCode": "Then('la navigation affiche {string} comme actif', async function (this: FestipodWorld, menuItem: string) {\n const source = this.getRenderedText();\n const escapedItem = menuItem.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`label:\\\\s*['\"]${escapedItem}['\"][^}]*active:\\\\s*true`, 'i');\n expect(pattern.test(source), `Menu item \"${menuItem}\" should be active in NavBar of screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 107
},
{
"pattern": "l'écran contient un bouton {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('l\\'écran contient un bouton {string}', async function (this: FestipodWorld, buttonText: string) {\n expect(this.hasText(buttonText), `Button \"${buttonText}\" should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 136
"lineNumber": 114
},
{
"pattern": "l'écran contient un champ {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('l\\'écran contient un champ {string}', async function (this: FestipodWorld, fieldLabel: string) {\n expect(this.hasText(fieldLabel), `Field \"${fieldLabel}\" should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 140
"lineNumber": 118
},
{
"pattern": "l'écran contient un texte {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('l\\'écran contient un texte {string}', async function (this: FestipodWorld, text: string) {\n expect(this.hasText(text), `Text \"${text}\" should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 144
"lineNumber": 122
},
{
"pattern": "l'écran contient un avatar",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('l\\'écran contient un avatar', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n const hasAvatar = /<Avatar/.test(source);\n expect(hasAvatar, `Avatar should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 148
},
{
"pattern": "l'écran {string} est affiché",
"keyword": "Given",
"file": "form.steps.ts",
"sourceCode": "Given('l\\'écran {string} est affiché', async function (this: FestipodWorld, screenName: string) {\n const screenId = screenName.toLowerCase().replace(/ /g, '-');\n this.navigateTo(`#/demo/${screenId}`);\n});",
"lineNumber": 5
},
{
"pattern": "le formulaire de création est vide",
"keyword": "Given",
"file": "form.steps.ts",
"sourceCode": "Given('le formulaire de création est vide', async function (this: FestipodWorld) {\n this.formFields.forEach((field, key) => {\n this.formFields.set(key, { ...field, value: '' });",
"lineNumber": 10
},
{
"pattern": "le formulaire contient le champ obligatoire {string}",
"keyword": "Then",
"file": "form.steps.ts",
"sourceCode": "Then('le formulaire contient le champ obligatoire {string}', async function (this: FestipodWorld, fieldName: string) {\n // This step is for form screens only (create-event)\n expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');\n const source = this.getRenderedText();\n // CreateEventScreen.tsx: Required fields have \" *\" after label: >Label *<\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`>${escapedName}\\\\s*\\\\*<`);\n expect(pattern.test(source), `Field \"${fieldName}\" should be marked as required (with *) in create-event screen`).to.be.true;\n});",
"lineNumber": 19
},
{
"pattern": "le formulaire contient les champs obligatoires suivants:",
"keyword": "Then",
"file": "form.steps.ts",
"sourceCode": "Then('le formulaire contient les champs obligatoires suivants:', async function (this: FestipodWorld, dataTable) {\n // This step is for form screens only (create-event)\n expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');\n const source = this.getRenderedText();\n const expectedFields = dataTable.raw().flat();\n expectedFields.forEach((fieldName: string) => {\n // CreateEventScreen.tsx: Required fields have \" *\" after label: >Label *<\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`>${escapedName}\\\\s*\\\\*<`);\n expect(pattern.test(source), `Field \"${fieldName}\" should be marked as required (with *) in create-event screen`).to.be.true;\n });",
"lineNumber": 29
},
{
"pattern": "le champ {string} est facultatif",
"keyword": "Then",
"file": "form.steps.ts",
"sourceCode": "Then('le champ {string} est facultatif', async function (this: FestipodWorld, fieldName: string) {\n const source = this.getRenderedText();\n // Optional fields have label without \" *\": >Label< followed by Input\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n // Check field exists but NOT marked as required\n const existsPattern = new RegExp(`>${escapedName}<`);\n const requiredPattern = new RegExp(`>${escapedName}\\\\s*\\\\*<`);\n expect(existsPattern.test(source), `Field \"${fieldName}\" should exist in screen`).to.be.true;\n expect(requiredPattern.test(source), `Field \"${fieldName}\" should NOT be marked as required`).to.be.false;\n});",
"lineNumber": 42
},
{
"pattern": "le champ {string} est présent",
"keyword": "Then",
"file": "form.steps.ts",
"sourceCode": "Then('le champ {string} est présent', async function (this: FestipodWorld, fieldName: string) {\n const source = this.getRenderedText();\n // Check that field label exists in screen source\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`>${escapedName}[^<]*<`);\n expect(pattern.test(source), `Field \"${fieldName}\" should be present in screen`).to.be.true;\n});",
"lineNumber": 53
},
{
"pattern": "le formulaire permet de détecter les doublons",
"keyword": "Then",
"file": "form.steps.ts",
"sourceCode": "Then('le formulaire permet de détecter les doublons', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n const source = this.getRenderedText();\n // CreateEventScreen.tsx has: showDuplicateWarning logic and \"Événement similaire détecté\" warning\n expect(/showDuplicateWarning/.test(source), 'Form should have duplicate detection logic').to.be.true;\n expect(/Événement similaire détecté/.test(source), 'Form should have duplicate warning message').to.be.true;\n});",
"lineNumber": 64
},
{
"pattern": "le formulaire permet d'importer depuis Mobilizon ou Transiscope",
"keyword": "Then",
"file": "form.steps.ts",
"sourceCode": "Then('le formulaire permet d\\'importer depuis Mobilizon ou Transiscope', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n const source = this.getRenderedText();\n // CreateEventScreen.tsx has: importableEvents with Mobilizon and Transiscope sources\n expect(/importableEvents/.test(source), 'Form should have importable events data').to.be.true;\n expect(/Mobilizon/.test(source), 'Form should support Mobilizon import').to.be.true;\n expect(/Transiscope/.test(source), 'Form should support Transiscope import').to.be.true;\n expect(/Importer depuis une source externe/.test(source), 'Form should have import section').to.be.true;\n});",
"lineNumber": 72
},
{
"pattern": "l'import externe ne déclenche pas d'alerte doublon",
"keyword": "Then",
"file": "form.steps.ts",
"sourceCode": "Then('l\\'import externe ne déclenche pas d\\'alerte doublon', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n const source = this.getRenderedText();\n // CreateEventScreen.tsx has: importedFrom state and !importedFrom in showDuplicateWarning condition\n expect(/importedFrom/.test(source), 'Form should track import source').to.be.true;\n expect(/&& !importedFrom/.test(source), 'Duplicate warning should be disabled for imports').to.be.true;\n});",
"lineNumber": 82
},
{
"pattern": "je peux voir la liste des participants",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux voir la liste des participants', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n // EventDetailScreen.tsx has: <Avatar components and \"Participants (12)\" text\n const hasAvatars = /<Avatar/.test(source);\n const hasParticipantsSection = /Participants\\s*\\(\\d+\\)/.test(source);\n expect(hasAvatars, 'Event detail should have Avatar components for participants').to.be.true;\n expect(hasParticipantsSection, 'Event detail should have \"Participants (N)\" section').to.be.true;\n});",
"lineNumber": 5
},
{
"pattern": "je peux voir les détails de l'événement",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux voir les détails de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n // EventDetailScreen.tsx has: <Title>, 📅, 🕓, 📍 emojis, and \"À propos\" section\n expect(/<Title[^>]*>[^<]+<\\/Title>/.test(source), 'Event detail should have a Title').to.be.true;\n expect(/📅/.test(source), 'Event detail should have date emoji 📅').to.be.true;\n expect(/🕓/.test(source), 'Event detail should have time emoji 🕓').to.be.true;\n expect(/📍/.test(source), 'Event detail should have location emoji 📍').to.be.true;\n expect(/À propos/.test(source), 'Event detail should have \"À propos\" section').to.be.true;\n});",
"lineNumber": 15
"lineNumber": 126
},
{
"pattern": "je peux voir la section {string}",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux voir la section {string}', async function (this: FestipodWorld, sectionName: string) {\n const source = this.getRenderedText();\n // Detect section by text search\n const found = source.includes(sectionName);\n if (!found) {\n this.attach(`Looking for section: \"${sectionName}\"`, 'text/plain');\n this.attach(`Rendered text: ${source.substring(0, 500)}...`, 'text/plain');\n }\n expect(found, `Section \"${sectionName}\" should be visible on screen`).to.be.true;\n});",
"lineNumber": 26
},
{
"pattern": "je peux voir mon profil",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux voir mon profil', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('profile');\n const source = this.getRenderedText();\n // ProfileScreen.tsx has: <Avatar initials=\"MD\" size=\"lg\" />, <Title>Marie Dupont</Title>, @mariedupont\n expect(/<Avatar[^>]*initials=\"MD\"[^>]*size=\"lg\"/.test(source), 'Profile should have Avatar with initials=\"MD\" and size=\"lg\"').to.be.true;\n expect(/<Title[^>]*>Marie Dupont<\\/Title>/.test(source), 'Profile should have Title \"Marie Dupont\"').to.be.true;\n expect(/@mariedupont/.test(source), 'Profile should have username @mariedupont').to.be.true;\n});",
"lineNumber": 40
},
{
"pattern": "je peux voir le profil de l'utilisateur",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux voir le profil de l\\'utilisateur', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const source = this.getRenderedText();\n // UserProfileScreen.tsx has: <Avatar initials=\"JD\" size=\"lg\" />, <Title>Jean Durand</Title>, @jeandurand\n expect(/<Avatar[^>]*initials=\"JD\"[^>]*size=\"lg\"/.test(source), 'User profile should have Avatar with initials=\"JD\" and size=\"lg\"').to.be.true;\n expect(/<Title[^>]*>Jean Durand<\\/Title>/.test(source), 'User profile should have Title \"Jean Durand\"').to.be.true;\n expect(/@jeandurand/.test(source), 'User profile should have username @jeandurand').to.be.true;\n});",
"lineNumber": 49
},
{
"pattern": "je peux voir la liste des événements",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux voir la liste des événements', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n if (this.currentScreenId === 'home') {\n // HomeScreen.tsx has: \"Événements à venir\" text and EventCard components\n expect(/Mes événements à venir/.test(source), 'Home screen should have \"Événements à venir\" text').to.be.true;\n } else if (this.currentScreenId === 'events') {\n // EventsScreen.tsx has: EventCard components with event data\n expect(/<Card[^>]*onClick/.test(source), 'Events screen should have clickable Card components').to.be.true;\n } else {\n expect.fail(`Unexpected screen \"${this.currentScreenId}\" - events list should be on home or events screen`);\n }\n});",
"lineNumber": 58
},
{
"pattern": "les événements affichent leur lieu",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('les événements affichent leur lieu', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n // HomeScreen.tsx and EventsScreen.tsx EventCard components display location as:\n // 📍 <span className=\"user-content\">{location}</span>\n // Check that there's actual location text after the emoji\n const locationPattern = /📍.*<span[^>]*className=\"user-content\"[^>]*>[^<]+<\\/span>/;\n expect(locationPattern.test(source), 'Event cards should display location text after 📍 emoji').to.be.true;\n});",
"lineNumber": 71
},
{
"pattern": "je peux voir le QR code",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux voir le QR code', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n if (this.currentScreenId === 'share-profile') {\n // ShareProfileScreen.tsx has: \"QR Code\" comment and \"Scannez pour me retrouver\" text\n expect(/QR Code/.test(source), 'Share profile should have \"QR Code\" text').to.be.true;\n expect(/Scannez pour me retrouver/.test(source), 'Share profile should have \"Scannez pour me retrouver\" text').to.be.true;\n } else if (this.currentScreenId === 'meeting-points') {\n // MeetingPointsScreen.tsx has: \"Mon QR Code\" text and \"Scannez pour m'ajouter\"\n expect(/Mon QR Code/.test(source), 'Meeting points should have \"Mon QR Code\" text').to.be.true;\n expect(/Scannez pour m'ajouter/.test(source), 'Meeting points should have \"Scannez pour m\\'ajouter\" text').to.be.true;\n } else {\n expect.fail(`QR code should be on share-profile or meeting-points, not \"${this.currentScreenId}\"`);\n }\n});",
"lineNumber": 80
},
{
"pattern": "je peux voir le lien de partage",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux voir le lien de partage', async function (this: FestipodWorld) {\n expect(this.currentScreenId, 'Share link should be on share-profile screen').to.equal('share-profile');\n const source = this.getRenderedText();\n // ShareProfileScreen.tsx has: \"Mon lien de profil\" text and profileLink variable\n expect(/Mon lien de profil/.test(source), 'Share profile should have \"Mon lien de profil\" text').to.be.true;\n expect(/festipod\\.app\\/u\\//.test(source), 'Share profile should have profile link URL').to.be.true;\n});",
"lineNumber": 95
},
{
"pattern": "je visualise l'événement {string}",
"keyword": "Given",
"file": "screen.steps.ts",
"sourceCode": "Given('je visualise l\\'événement {string}', async function (this: FestipodWorld, eventName: string) {\n this.navigateTo('#/demo/event-detail');\n expect(this.currentScreen, 'Event detail screen should be loaded').to.not.be.null;\n this.attach(`Viewing event: ${eventName}`, 'text/plain');\n});",
"lineNumber": 106
},
{
"pattern": "je visualise le profil de {string}",
"keyword": "Given",
"file": "screen.steps.ts",
"sourceCode": "Given('je visualise le profil de {string}', async function (this: FestipodWorld, userName: string) {\n this.navigateTo('#/demo/user-profile');\n expect(this.currentScreen, 'User profile screen should be loaded').to.not.be.null;\n this.attach(`Viewing profile: ${userName}`, 'text/plain');\n});",
"lineNumber": 112
},
{
"pattern": "l'écran affiche les informations de l'événement",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('l\\'écran affiche les informations de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n // EventDetailScreen.tsx has: <Title>, 📅, 🕓, 📍 emojis, and \"À propos\" section\n expect(/<Title[^>]*>[^<]+<\\/Title>/.test(source), 'Event detail should have a Title').to.be.true;\n expect(/📅/.test(source), 'Event detail should have date emoji 📅').to.be.true;\n expect(/🕓/.test(source), 'Event detail should have time emoji 🕓').to.be.true;\n expect(/📍/.test(source), 'Event detail should have location emoji 📍').to.be.true;\n expect(/À propos/.test(source), 'Event detail should have \"À propos\" section').to.be.true;\n});",
"lineNumber": 118
},
{
"pattern": "l'écran affiche les informations du profil",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('l\\'écran affiche les informations du profil', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n if (this.currentScreenId === 'profile') {\n // ProfileScreen.tsx has: <Avatar initials=\"MD\" size=\"lg\" />, <Title>Marie Dupont</Title>, @mariedupont\n expect(/<Avatar[^>]*initials=\"MD\"/.test(source), 'Profile should have Avatar with initials=\"MD\"').to.be.true;\n expect(/<Title[^>]*>Marie Dupont<\\/Title>/.test(source), 'Profile should have Title \"Marie Dupont\"').to.be.true;\n expect(/@mariedupont/.test(source), 'Profile should have username @mariedupont').to.be.true;\n } else if (this.currentScreenId === 'user-profile') {\n // UserProfileScreen.tsx has: <Avatar initials=\"JD\" size=\"lg\" />, <Title>Jean Durand</Title>, @jeandurand\n expect(/<Avatar[^>]*initials=\"JD\"/.test(source), 'User profile should have Avatar with initials=\"JD\"').to.be.true;\n expect(/<Title[^>]*>Jean Durand<\\/Title>/.test(source), 'User profile should have Title \"Jean Durand\"').to.be.true;\n expect(/@jeandurand/.test(source), 'User profile should have username @jeandurand').to.be.true;\n } else {\n expect.fail(`Unexpected screen \"${this.currentScreenId}\" for profile info check`);\n }\n});",
"lineNumber": 129
},
{
"pattern": "je peux m'inscrire à l'événement",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux m\\'inscrire à l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n // EventDetailScreen.tsx line 49: {isJoined ? '✓ Inscrit' : 'Participer'}\n // The button shows \"Participer\" when not joined\n const hasParticiperButton = /isJoined \\? '✓ Inscrit' : 'Participer'/.test(source);\n expect(hasParticiperButton, 'Event detail should have Participer/Inscrit toggle button').to.be.true;\n});",
"lineNumber": 149
},
{
"pattern": "je peux me désinscrire de l'événement",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux me désinscrire de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n // EventDetailScreen.tsx line 49: {isJoined ? '✓ Inscrit' : 'Participer'}\n // Same button toggles - clicking \"✓ Inscrit\" will unregister\n const hasInscritButton = /isJoined \\? '✓ Inscrit' : 'Participer'/.test(source);\n expect(hasInscritButton, 'Event detail should have Participer/Inscrit toggle button (click to unregister)').to.be.true;\n});",
"lineNumber": 158
},
{
"pattern": "je peux contacter l'utilisateur",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux contacter l\\'utilisateur', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const source = this.getRenderedText();\n // UserProfileScreen.tsx line 44: <Button>Contacter</Button>\n const hasContactButton = /<Button>Contacter<\\/Button>/.test(source);\n expect(hasContactButton, 'User profile should have \"Contacter\" button').to.be.true;\n});",
"lineNumber": 167
},
{
"pattern": "je peux voir les événements auxquels l'utilisateur a participé",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux voir les événements auxquels l\\'utilisateur a participé', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const source = this.getRenderedText();\n // UserProfileScreen.tsx: \"Événements à venir\" and \"Événements passés\" sections\n expect(/Événements à venir/.test(source), 'User profile should have \"Événements à venir\" section').to.be.true;\n expect(/Événements passés/.test(source), 'User profile should have \"Événements passés\" section').to.be.true;\n});",
"lineNumber": 175
},
{
"pattern": "les événements affichent leur localisation et distance",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('les événements affichent leur localisation et distance', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const source = this.getRenderedText();\n // UserProfileScreen.tsx: events have location and distance in data\n expect(/location: '[^']+'/.test(source), 'Events should have location data').to.be.true;\n expect(/distance: \\d+/.test(source), 'Events should have distance data').to.be.true;\n // Verify location is rendered in template\n expect(/\\{event\\.location\\}/.test(source), 'Events should render location').to.be.true;\n expect(/\\{event\\.distance\\}/.test(source), 'Events should render distance').to.be.true;\n});",
"lineNumber": 183
},
{
"pattern": "je peux configurer mes notifications",
"keyword": "Then",
"file": "screen.steps.ts",
"sourceCode": "Then('je peux configurer mes notifications', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('settings');\n const source = this.getRenderedText();\n // SettingsScreen.tsx line 25: <Text>Notifications</Text> with Toggle\n expect(/>Notifications</.test(source), 'Settings should have \"Notifications\" text').to.be.true;\n expect(/<Toggle[^>]*checked=\\{notifications\\}/.test(source), 'Settings should have Toggle for notifications').to.be.true;\n});",
"lineNumber": 194
"file": "navigation.steps.ts",
"sourceCode": "Then('je peux voir la section {string}', async function (this: FestipodWorld, sectionName: string) {\n const source = this.getRenderedText();\n const found = source.includes(sectionName);\n if (!found) {\n this.attach(`Looking for section: \"${sectionName}\"`, 'text/plain');\n this.attach(`Rendered text: ${source.substring(0, 500)}...`, 'text/plain');\n }\n expect(found, `Section \"${sectionName}\" should be visible on screen`).to.be.true;\n});",
"lineNumber": 132
}
];
+59
View File
@@ -0,0 +1,59 @@
/**
* App-level data types used by screens.
* These are plain TypeScript types (not RDF-bound).
* The data layer maps to/from NextGraph shapes internally.
*/
export interface FpEventData {
id: string;
title: string;
description: string;
date: string;
startDate?: string;
endDate?: string;
startTime?: string;
endTime?: string;
location: string;
distance?: number;
participantCount: number;
coverImage?: string;
hostName?: string;
hostInitials?: string;
themes?: string[];
}
export interface FpUserData {
id: string;
name: string;
initials: string;
username: string;
role?: string;
isPublic?: boolean;
bio?: string;
city?: string;
eventsCount?: number;
friendsCount?: number;
participationsCount?: number;
}
export interface FpParticipationData {
id: string;
eventId: string;
userId: string;
isConfirmed: boolean;
}
export interface FpMeetingPointData {
id: string;
eventId: string;
location: string;
time: string;
hostName: string;
hostInitials: string;
}
export interface FpFriendshipData {
id: string;
userId: string;
friendId: string;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* useShapeWithDefaults — wrapper around NextGraph ORM's useShape.
*
* Calls useShape with scope="" (whole dataset) and maps results to app types.
* If the NG set is empty (not yet loaded or truly empty),
* it returns the provided defaults.
*
* Must only be called when NG is connected (inside NgDataProvider).
*/
import { useShape } from '@ng-org/orm/react';
import type { ShapeType, BaseType } from '@ng-org/shex-orm';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
/** Mapped items from NG store */
items: AppT[];
/** Raw NG signal set for mutations */
ngSet: DeepSignalSet<NgT>;
}
export function useShapeWithDefaults<NgT extends BaseType, AppT>(
shapeType: ShapeType<NgT>,
defaults: AppT[],
mapFromNg: (item: NgT) => AppT,
shapesReady: boolean,
): ShapeWithDefaults<NgT, AppT> {
// scope="did:ng:i" means whole dataset
const ngSet = useShape(shapeType, "did:ng:i") as DeepSignalSet<NgT>;
// Before shapes are ready, always show defaults (static display)
// After ready, show NG data even if empty (means user deleted everything)
const usingDefaults = !shapesReady;
const items = usingDefaults ? defaults : [...ngSet].map(mapFromNg);
console.log(`[useShapeWithDefaults] ${(shapeType as any).shape ?? 'unknown'}: ngSet.size=${ngSet.size}, shapesReady=${shapesReady}, using=${usingDefaults ? 'DEFAULTS' : 'NG data'}`);
return { items, ngSet };
}
@@ -0,0 +1,179 @@
import type { Schema } from "@ng-org/shex-orm";
/**
* =============================================================================
* festipodShapesSchema: Schema for festipodShapes
* =============================================================================
*/
export const festipodShapesSchema: Schema = {
"http://festipod.org/Event": {
iri: "http://festipod.org/Event",
predicates: [
{
dataTypes: [
{
valType: "iri",
literals: ["http://festipod.org/Event"],
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/title",
readablePredicate: "title",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/description",
readablePredicate: "description",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/date",
readablePredicate: "date",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/location",
readablePredicate: "location",
},
{
dataTypes: [{ valType: "number" }],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/distance",
readablePredicate: "distance",
},
{
dataTypes: [{ valType: "number" }],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/participantCount",
readablePredicate: "participantCount",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/coverImage",
readablePredicate: "coverImage",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/hostName",
readablePredicate: "hostName",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/hostInitials",
readablePredicate: "hostInitials",
},
],
},
"http://festipod.org/UserProfile": {
iri: "http://festipod.org/UserProfile",
predicates: [
{
dataTypes: [
{
valType: "iri",
literals: ["http://festipod.org/UserProfile"],
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/name",
readablePredicate: "name",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/initials",
readablePredicate: "initials",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/username",
readablePredicate: "username",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/role",
readablePredicate: "role",
},
{
dataTypes: [{ valType: "boolean" }],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/isPublic",
readablePredicate: "isPublic",
},
],
},
"http://festipod.org/Participation": {
iri: "http://festipod.org/Participation",
predicates: [
{
dataTypes: [
{
valType: "iri",
literals: ["http://festipod.org/Participation"],
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "iri" }],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/event",
readablePredicate: "event",
},
{
dataTypes: [{ valType: "iri" }],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/user",
readablePredicate: "user",
},
{
dataTypes: [{ valType: "boolean" }],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/isConfirmed",
readablePredicate: "isConfirmed",
},
],
},
};
@@ -0,0 +1,17 @@
import type { ShapeType } from "@ng-org/shex-orm";
import { festipodShapesSchema } from "./festipodShapes.schema";
import type { FpEvent, FpUserProfile, FpParticipation } from "./festipodShapes.typings";
// ShapeTypes for festipodShapes
export const FpEventShapeType: ShapeType<FpEvent> = {
schema: festipodShapesSchema,
shape: "http://festipod.org/Event",
};
export const FpUserProfileShapeType: ShapeType<FpUserProfile> = {
schema: festipodShapesSchema,
shape: "http://festipod.org/UserProfile",
};
export const FpParticipationShapeType: ShapeType<FpParticipation> = {
schema: festipodShapesSchema,
shape: "http://festipod.org/Participation",
};

Some files were not shown because too many files have changed in this diff Show More