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:
@@ -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 {
|
||||
+3
-3
@@ -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
@@ -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>
|
||||
|
||||
@@ -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é
|
||||
+91
-30
@@ -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à été 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à été 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) => {
|
||||
+51
-23
@@ -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>
|
||||
+34
-20
@@ -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é
|
||||
+13
-24
@@ -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}
|
||||
+8
-5
@@ -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>
|
||||
);
|
||||
}
|
||||
+37
-21
@@ -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é
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user