Compare commits
10 Commits
b7f86b139f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ffda889f34 | |||
| fd6d408de1 | |||
| 5a29938130 | |||
| 7099c817db | |||
| 6b95695d34 | |||
| ea8fbcf8b7 | |||
| 708cbeead8 | |||
| 6f9b3ece34 | |||
| 901fd659df | |||
| c9bc957d2a |
@@ -32,3 +32,12 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
.ng-tarballs
|
||||
|
||||
# Playwright persistent profile (contains NG wallet)
|
||||
.playwright-profile/
|
||||
.playwright-profile-debug/
|
||||
playwright/.auth/
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Matrice d'autorisations et inventaire des requêtes
|
||||
|
||||
**Status:** Incubating — analyse en cours
|
||||
**Last updated:** 2026-05-18
|
||||
|
||||
## Context
|
||||
|
||||
Préalable au refactor multi-store ([brief](./multi-store-refactor.md)) et à toute évolution multi-user. La structure de stores NextGraph cible doit être *dérivée* de :
|
||||
|
||||
1. Une matrice d'autorisations (qui peut faire quoi sur quel type de donnée).
|
||||
2. Un inventaire des requêtes nécessaires (lectures, abonnements, écritures par écran).
|
||||
3. Les partitions naturelles qui en découlent (regroupements de données qui partagent autorisations *et* schéma d'accès).
|
||||
|
||||
Ce brief porte cette analyse. Il alimentera la décision finale sur la structure de stores.
|
||||
|
||||
## Cadre
|
||||
|
||||
### Acteurs (tous authentifiés)
|
||||
|
||||
- `Self` — propriétaire de la donnée (varie par type : auteur d'un message, titulaire d'un profil…)
|
||||
- `D` — Déclarant d'un événement (celui qui a inséré la référence dans Festipod ; pas l'organisateur réel)
|
||||
- `H` — Hôte d'un point de rencontre (celui qui l'a créé)
|
||||
- `I` — Inscrit à un point de rencontre
|
||||
- `C` — Connexion (« ami ») d'un autre acteur lié à la donnée
|
||||
- `U` — Utilisateur authentifié quelconque, sans relation à la donnée
|
||||
|
||||
### Verbes
|
||||
|
||||
- `créer`
|
||||
- `lire` (one-shot)
|
||||
- `s'abonner` (lecture longue / réactive)
|
||||
- `modifier`
|
||||
- `supprimer`
|
||||
|
||||
### Conventions
|
||||
|
||||
`✓` autorisé · `✗` interdit · `cond` autorisé sous condition (notée) · `—` sans objet
|
||||
|
||||
## Décisions cadre (acquises)
|
||||
|
||||
- **Tous les utilisateurs sont authentifiés.** Pas d'accès anonyme.
|
||||
- **Points de rencontre publics universels.** Tout utilisateur peut lire et s'abonner.
|
||||
- **Création de point de rencontre ouverte à tous.** Pas de prérequis (adhésion, invitation).
|
||||
- **Hôte = détenteur technique des droits d'écriture** sur un point de rencontre. À ce stade : 1 hôte par PdR, celui qui l'a créé.
|
||||
- **Adhésion à une communauté : hors périmètre actuel.** Le rôle « Membre de communauté » n'est pas analysé ici.
|
||||
- **Suivi de communauté ou d'utilisateur : hors périmètre actuel.** À reprendre quand la fonctionnalité de discovery par abonnement sera traitée.
|
||||
|
||||
## Matrice par type de donnée
|
||||
|
||||
### Point de rencontre
|
||||
|
||||
| Verbe | Self (= Hôte) | I (autre inscrit) | D (déclarant de l'événement parent) | U (utilisateur lambda) |
|
||||
|---|---|---|---|---|
|
||||
| créer | ✓ (l'acte de créer rend l'utilisateur hôte) | — | ✗ | ✓ (l'acte le rend hôte) |
|
||||
| lire | ✓ | ✓ | ✓ | ✓ |
|
||||
| s'abonner | ✓ | ✓ | ✓ | ✓ |
|
||||
| modifier | ✓ | ✗ | ✗ | ✗ |
|
||||
| supprimer | ✓ | ✗ | ✗ | ✗ |
|
||||
|
||||
**Notes :**
|
||||
- Pas de différenciation `C` (connexion de l'hôte) — les connexions sont un filtre d'affichage côté UI, pas un droit d'accès, puisque tout est public.
|
||||
- Le `D` n'a pas de droit particulier sur les PdR greffés sur son événement déclaré — il a juste déclaré la référence.
|
||||
|
||||
### Inscription à un point de rencontre
|
||||
|
||||
L'objet « Inscription » lie un utilisateur et un point de rencontre. Représente l'engagement à participer.
|
||||
|
||||
| Verbe | Self (l'inscrit) | H (hôte du PdR) | I (autre inscrit au même PdR) | U (utilisateur lambda) |
|
||||
|---|---|---|---|---|
|
||||
| créer | ✓ (s'inscrire) | ✗ | ✗ | ✓ (l'acte le rend inscrit) |
|
||||
| lire | ✓ | ✓ | ? **à trancher** | ? **à trancher** |
|
||||
| s'abonner | ✓ | ✓ | ? **à trancher** | ? **à trancher** |
|
||||
| modifier | ? **à trancher** (selon les champs modifiables) | ✗ | ✗ | ✗ |
|
||||
| supprimer | ✓ (se désinscrire) | ? **à trancher** (modération ? blacklist ?) | ✗ | ✗ |
|
||||
|
||||
**Questions ouvertes :**
|
||||
- **Visibilité de la liste des inscrits.** Cohérent avec « tout est public » : tous les utilisateurs voient qui s'est inscrit. Mais à confirmer — y a-t-il un cas où on veut cacher la liste (PdR à inscription confidentielle) ?
|
||||
- **Champs modifiables d'une inscription.** Booléen seul, ou champs additionnels (commentaire, statut "peut-être", nombre d'accompagnants) ?
|
||||
- **Modération par l'hôte.** L'hôte peut-il désinscrire un inscrit (= blacklist) ?
|
||||
|
||||
### Événement
|
||||
|
||||
| Verbe | Self (= D, déclarant) | H (hôte d'un PdR greffé) | U (utilisateur lambda) |
|
||||
|---|---|---|---|
|
||||
| créer | ✓ (l'acte rend déclarant) | — | ✓ (l'acte le rend déclarant) |
|
||||
| lire | ✓ | ✓ | ✓ |
|
||||
| s'abonner | ✓ | ✓ | ✓ |
|
||||
| modifier | ? **à trancher** | ? **à trancher** | ? **à trancher** |
|
||||
| supprimer | ? **à trancher** | ✗ | ✗ |
|
||||
|
||||
**Questions ouvertes :**
|
||||
- **Qui peut modifier un événement déclaré ?** Le déclarant seul (modèle propriétaire) ? Tout utilisateur (modèle wiki, pour compléter/corriger) ? Personne après création (modèle immuable, pour éviter les modifications mal intentionnées) ? Cette question est centrale pour le défi de déduplication évoqué dans le README — un modèle wiki facilite la convergence, un modèle propriétaire complique.
|
||||
- **Qui peut supprimer ?** Si le déclarant supprime, que deviennent les PdR greffés (orphelins ? supprimés en cascade ? l'événement reste mais marqué supprimé ?) ?
|
||||
|
||||
### Profil utilisateur
|
||||
|
||||
À déterminer : un seul objet ou split public/privé ?
|
||||
|
||||
| Verbe | Self | C (connexion) | U (utilisateur lambda) |
|
||||
|---|---|---|---|
|
||||
| créer | ✓ (à l'inscription) | — | — |
|
||||
| lire (partie publique) | ✓ | ✓ | ? **à trancher** |
|
||||
| lire (partie privée) | ✓ | ? **à trancher** | ✗ |
|
||||
| s'abonner | ✓ | ? | ? |
|
||||
| modifier | ✓ | ✗ | ✗ |
|
||||
| supprimer | ✓ (auto-destruction du compte) | ✗ | ✗ |
|
||||
|
||||
**Questions ouvertes :**
|
||||
- **Split public/privé ?** Le profil contient-il des champs réservés aux connexions ou à l'utilisateur seul (préférences, paramètres, email) ?
|
||||
- **Profil entièrement public ?** Cohérent avec « points de rencontre publics » : un visiteur peut voir le profil de l'hôte d'un PdR. Mais le détail (bio, photos, ville…) ?
|
||||
|
||||
### Connexion (lien d'amitié)
|
||||
|
||||
| Verbe | Self (A, demandeur) | Other (B, l'autre côté de la connexion) | U (utilisateur lambda) |
|
||||
|---|---|---|---|
|
||||
| créer (demande) | ✓ | — | — |
|
||||
| accepter | — | ✓ | ✗ |
|
||||
| lire (sa propre liste d'amis) | ✓ | — | — |
|
||||
| lire (la liste d'amis d'un autre) | — | — | ? **à trancher** |
|
||||
| s'abonner (à sa liste) | ✓ | — | — |
|
||||
| modifier | — | — | — |
|
||||
| supprimer (rompre la connexion) | ✓ | ✓ | ✗ |
|
||||
|
||||
**Questions ouvertes :**
|
||||
- **Bilatérale ou unilatérale ?** Le concept « connexion / ami » suggère bilatérale (les deux acceptent). À confirmer ; si oui, il y a deux objets distincts : `DemandeDeConnexion` (unilatérale) et `Connexion` (bilatérale).
|
||||
- **Visibilité de la liste d'amis.** Une connexion est-elle observable par des tiers ? « Marie est connectée à Bob » est-il public, restreint, ou privé ?
|
||||
|
||||
## Hors périmètre actuel
|
||||
|
||||
À reprendre quand ces concepts deviendront actifs :
|
||||
|
||||
- **Communauté d'intérêt** (membres, modération, création)
|
||||
- **Adhésion à une communauté**
|
||||
- **Liste curated** (création, partage, abonnement)
|
||||
- **Suivi d'utilisateur ou de communauté** pour discovery distribuée
|
||||
|
||||
## Inventaire des requêtes par écran
|
||||
|
||||
*À remplir une fois la matrice des autorisations stabilisée.*
|
||||
|
||||
Schéma prévu :
|
||||
|
||||
| Écran | Lectures one-shot | Abonnements | Écritures | Acteur déclencheur |
|
||||
|---|---|---|---|---|
|
||||
|
||||
Écrans à analyser (depuis [AGENTS.md](../../AGENTS.md#routing)) :
|
||||
|
||||
- `WelcomeScreen` `/`
|
||||
- `LoginScreen` `/login`
|
||||
- `HomeScreen` `/home`
|
||||
- `EventsScreen` `/events`
|
||||
- `CreateEventScreen` `/events/new`
|
||||
- `EventDetailScreen` `/events/:id`
|
||||
- `UpdateEventScreen` `/events/:id/edit`
|
||||
- `InviteScreen` `/events/:id/invite` (à voir si encore pertinent)
|
||||
- `ParticipantsListScreen` `/events/:id/participants`
|
||||
- `MeetingPointsScreen` `/events/:id/meeting-points`
|
||||
- `ProfileScreen` `/profile`
|
||||
- `UpdateProfileScreen` `/profile/edit`
|
||||
- `FriendsListScreen` `/profile/friends`
|
||||
- `ShareProfileScreen` `/profile/share`
|
||||
- `UserProfileScreen` `/users/:id`
|
||||
- `SettingsScreen` `/settings`
|
||||
|
||||
## Partitions naturelles dérivées
|
||||
|
||||
*À remplir une fois la matrice + l'inventaire stabilisés.*
|
||||
|
||||
Heuristique de dérivation : on regroupe dans un même store les données qui (a) partagent leur cellule d'autorisation pour les verbes d'écriture, et (b) sont accédées ensemble dans la majorité des requêtes (pour éviter de multiplier les abonnements).
|
||||
|
||||
## See Also
|
||||
|
||||
- [Brief : refactor multi-store](./multi-store-refactor.md) — consommateur principal de cette analyse
|
||||
- [README §Modèle fonctionnel](../../README.md) — source des acteurs et concepts
|
||||
- [Knowledge : data layer](../knowledge/data-layer.md) — état actuel mono-store
|
||||
@@ -0,0 +1,126 @@
|
||||
# Refactor multi-store NextGraph
|
||||
|
||||
**Status:** Incubating — aucun travail démarré
|
||||
**Last updated:** 2026-05-17
|
||||
|
||||
## Context
|
||||
|
||||
L'app Festipod est aujourd'hui *mono-store* : tout ce que l'app écrit (events, profils, participations, friendships) atterrit dans le `private_store` de l'utilisateur connecté. C'est un héritage du sample expense-tracker-rdf, formalisé dans [la décision du 2026-03-17](../decisions/2026-03-17-1600-private-store-nuri-scope.md).
|
||||
|
||||
Ce choix bloque toute évolution vers du multi-utilisateurs : par construction le `private_store` est non partageable (cf. [data-layer](../knowledge/data-layer.md) et la doc NextGraph officielle — *« It is not possible to share the documents of your private store with anybody else »*). Tant que tout est dans le private_store, Bob ne pourra jamais voir l'event d'Alice.
|
||||
|
||||
Le modèle natif NextGraph est *multi-store par utilisateur* (private, protected, public, group, dialog) — chaque type d'information a sa place. Festipod doit s'aligner sur ce modèle avant de pouvoir devenir collaboratif.
|
||||
|
||||
**Déclencheur :** discussion du 2026-05-17 sur la suite multi-user. Décision prise : *poser le cap, exécuter plus tard*.
|
||||
|
||||
## What We Know
|
||||
|
||||
### État actuel du code
|
||||
|
||||
Deux fichiers concentrent le hardcoding du store unique :
|
||||
|
||||
- `src/shared/utils/ngGraph.ts:30` — `ensureGraphNuri()` retourne `did:ng:${session.private_store_id}` pour TOUTES les entités, peu importe leur nature.
|
||||
- `src/shared/hooks/useShapeWithDefaults.ts` — accepte un `storeNuri` mais l'appelant unique (`FestipodDataContext`) lui passe systématiquement le NURI du private_store.
|
||||
|
||||
Entités impactées (toutes mélangées dans le même store aujourd'hui) :
|
||||
- `FpEvent` — devrait vivre dans un store partagé (logique multi-user)
|
||||
- `FpUserProfile` — devrait être en partie privée, en partie publique
|
||||
- `FpParticipation` — liée à un event, devrait vivre avec lui
|
||||
- `FpMeetingPoint` — actuellement local-only côté types ([`src/shared/data/types.ts:106`](../../src/shared/data/types.ts)), pas encore branché à NextGraph
|
||||
- `FpFriendship` — actuellement local-only, naturellement privée
|
||||
|
||||
### Modèle cible proposé
|
||||
|
||||
Structure hiérarchique en **4 niveaux de Group stores** (pas de private/public pour le métier collaboratif — tout en Group) :
|
||||
|
||||
```
|
||||
┌─ Group store « index communautaire » ────────────────────┐
|
||||
│ Référence tous les events visibles dans la communauté │
|
||||
│ Lecture par tous les membres, sert d'annuaire/discovery │
|
||||
│ │
|
||||
│ ┌─ Group store « communauté » ──────────────────────┐ │
|
||||
│ │ Propriétaire de l'event │ │
|
||||
│ │ Permissions = qui peut modifier l'event │ │
|
||||
│ │ (organisateurs / membres de la communauté) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─ Group store « event » ─────────────────────┐ │ │
|
||||
│ │ │ Tout ce qui se rattache à l'event : │ │ │
|
||||
│ │ │ participations, infos pratiques, discu… │ │ │
|
||||
│ │ │ Membres = participants à l'event │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ┌─ Group store « meeting point » ───────┐ │ │ │
|
||||
│ │ │ │ Un RDV de l'event = son propre group │ │ │ │
|
||||
│ │ │ │ Permet participations + discu │ │ │ │
|
||||
│ │ │ │ scopées au point de rencontre │ │ │ │
|
||||
│ │ │ └───────────────────────────────────────┘ │ │ │
|
||||
│ │ └─────────────────────────────────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Mapping entités → store cible :
|
||||
|
||||
| Entité | Store cible | Justification |
|
||||
|---|---|---|
|
||||
| Event (métadonnées : titre, dates, description) | Group store « communauté » | C'est la communauté qui possède l'event, donc qui contrôle qui peut le modifier |
|
||||
| Référence d'event (pointeur depuis l'index) | Group store « index communautaire » | Discovery : « voici les events visibles » |
|
||||
| Participation | Group store « event » | Une participation n'a de sens que dans le contexte de son event |
|
||||
| MeetingPoint (métadonnées) | Group store « event » | Le RDV appartient à l'event |
|
||||
| Participation à un MeetingPoint | Group store « meeting point » | RSVP/présence scopés au RDV |
|
||||
| UserProfile (partie publique) | public_store de l'utilisateur | Modèle natif NextGraph |
|
||||
| Friendship | private_store de l'utilisateur | Donnée purement personnelle |
|
||||
|
||||
### Contrainte SDK bloquante
|
||||
|
||||
La création de Group stores et la gestion des invitations/permissions **ne sont pas exposées dans le SDK `@ng-org/web` actuel** (version `0.1.2-alpha.11`). Les méthodes disponibles : `doc_create`, `doc_subscribe`, `sparql_query/update`, `orm_start_*`, `file_get`, `app_request_stream`. Aucune méthode `share_doc`, `invite_user`, `create_group_store`, `accept_invite`. La doc NextGraph annonce qu'*« An API will be provided for permission manipulation »* — pas de date.
|
||||
|
||||
**Implication :** le refactor *structurel* (passer d'un store unique à un système de stores par entité) peut commencer sans attendre cette API, en utilisant des placeholders (par ex. continuer à pointer vers `private_store_id` pour les Group stores qui ne peuvent pas encore exister). Mais l'**aboutissement complet** (vrai multi-user, partage entre wallets distincts) dépend de l'arrivée de l'API SDK ou d'un contournement (fork du wallet, accès Rust direct, etc.).
|
||||
|
||||
### Implications côté code
|
||||
|
||||
Le refactor touche au moins :
|
||||
|
||||
1. **Disparition de `ensureGraphNuri()`** comme helper unique. Remplacé par des helpers par entité (`getEventStore(communityId)`, `getParticipationStore(eventId)`, `getProfileStore(scope: 'public' | 'private')`, …) ou par une couche `storeRegistry` qui résout le NURI selon `(entité, contexte)`.
|
||||
2. **`useShapeWithDefaults` reste un wrapper utile** mais l'appelant choisit explicitement le store. Aujourd'hui un seul appelant ([`FestipodDataContext`](../../src/shared/context/FestipodDataContext.tsx)), demain N appelants ou un appelant qui résout dynamiquement.
|
||||
3. **Chaque entité de domaine déclare son store cible** — soit via un mapping centralisé, soit via une convention (shape → store).
|
||||
4. **`bootstrapWallet()`** ([`src/shared/utils/ngBootstrap.ts`](../../src/shared/utils/ngBootstrap.ts)) doit être revu : on ne seed plus dans un unique store, on doit seed dans plusieurs (ou décider que seed ne crée que des données de l'utilisateur courant — ce qui colle mieux à la réalité multi-user).
|
||||
5. **`FestipodDataContext`** : structurer les hooks par entité, chacun avec son store résolu.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Quand crée-t-on un Group store de communauté ?** L'API n'existe pas en SDK aujourd'hui. Faut-il que ce soit un acte explicite de l'utilisateur (« créer une communauté ») ou bien tout user a une communauté par défaut à la création de son wallet ?
|
||||
2. **Comment Bob connaît-il l'index communautaire d'Alice ?** Discovery toujours ouverte — possiblement via le public_store d'Alice qui annonce le NURI de l'index communautaire.
|
||||
3. **Faut-il vraiment 4 niveaux d'imbrication ?** Le « meeting point comme group store » mérite d'être validé — quel besoin réel justifie une couche de permission supplémentaire vs un simple sous-graphe du group store de l'event ?
|
||||
4. **Que devient le seed de démo** quand l'app est multi-store et que les Group stores ne peuvent pas encore exister ? Mode dégradé en private_store le temps que le SDK rattrape, ou retirer le seed en mode connecté ?
|
||||
5. **Migration des wallets existants** : les wallets de test ont déjà des données dans le private_store. Comment on les fait évoluer (script de migration, wipe and reseed, ignore) ?
|
||||
6. **Bootstrap d'un user vierge** : à la première connexion, faut-il auto-créer un Group store communautaire « par défaut » pour lui ou attendre une action utilisateur ?
|
||||
|
||||
## Possible Approaches
|
||||
|
||||
Esquisses sans engagement (les arbitrages se feront dans une décision dédiée au moment de l'exécution) :
|
||||
|
||||
- **Refactor structurel d'abord, partage ensuite.** Réorganiser l'app en multi-store dès maintenant en utilisant `private_store_id` comme placeholder pour les Group stores manquants. Quand l'API arrive, on remplace les placeholders par de vrais NURIs de Group stores.
|
||||
- **Registry centralisé** vs **résolution par convention**. Soit un `storeRegistry.ts` qui mappe explicitement `(entité, contexte) → NURI`, soit chaque shape porte sa propre logique de scope.
|
||||
- **Big-bang** vs **par entité**. Tout migrer en un coup vs migrer entité par entité (commencer par Event qui est le plus stratégique).
|
||||
- **Maintenir un mode mono-store** parallèle pour le dev/demo tant que les Group stores ne sont pas fonctionnels.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Ce brief — et le refactor qui en découlera — **ne traite pas** :
|
||||
- L'invitation effective d'utilisateurs à un Group store (capability sharing, Nuri d'invitation)
|
||||
- La gestion des permissions par rôle (organisateur / membre / lecteur)
|
||||
- La résolution du problème de discovery cross-wallet
|
||||
- Le contournement éventuel de l'UI wallet (jugée dysfonctionnelle dans cette conversation)
|
||||
- Le mode P2P direct sans broker
|
||||
|
||||
Ces sujets relèvent d'un **second chantier multi-user** dont le refactor multi-store est seulement le *prérequis structurel*.
|
||||
|
||||
## Starting Points
|
||||
|
||||
- [decision: private_store NURI scope](../decisions/2026-03-17-1600-private-store-nuri-scope.md) — la décision actuelle qu'on viendra modifier
|
||||
- [knowledge: data-layer](../knowledge/data-layer.md) — état actuel du pattern d'écriture
|
||||
- [`src/shared/utils/ngGraph.ts`](../../src/shared/utils/ngGraph.ts) — point de hardcoding principal
|
||||
- [`src/shared/hooks/useShapeWithDefaults.ts`](../../src/shared/hooks/useShapeWithDefaults.ts) — l'autre point de hardcoding
|
||||
- [`src/shared/context/FestipodDataContext.tsx`](../../src/shared/context/FestipodDataContext.tsx) — l'unique appelant aujourd'hui
|
||||
- [`src/shared/utils/ngBootstrap.ts`](../../src/shared/utils/ngBootstrap.ts) — le seed à revoir
|
||||
- NextGraph docs : [Documents et Stores](https://docs.nextgraph.org/en/documents/), [Getting started](https://docs.nextgraph.org/en/getting-started/)
|
||||
@@ -0,0 +1,54 @@
|
||||
# Automated Headless Wallet Creation for CI
|
||||
|
||||
**Date:** 2026-03-12 15:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Data-layer BDD tests (`@data` scenarios) require a NextGraph wallet in a persistent Chromium profile. Previously, the first run required manual interaction: a visible browser opened and the user had to create a wallet and close the browser. This blocked CI execution.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: Programmatic wallet creation via NG SDK
|
||||
Call `ng.wallet_create()` directly from Node/Bun, bypassing the UI entirely.
|
||||
|
||||
**Arguments for:**
|
||||
- Fastest execution
|
||||
- No browser needed for wallet creation
|
||||
|
||||
**Arguments against:**
|
||||
- `@ng-org/web` is browser-only (WASM + postMessage)
|
||||
- Would need to reverse-engineer the registration API at `account.nextgraph.eu`
|
||||
- Doesn't test the real auth flow
|
||||
|
||||
### Option B: Automate the browser UI flow headlessly
|
||||
Use Playwright to drive the same wallet creation UI a real user would use, but in headless mode.
|
||||
|
||||
**Arguments for:**
|
||||
- Tests the real auth/login feature end-to-end
|
||||
- No API reverse-engineering needed
|
||||
- Same persistent profile used for subsequent test runs
|
||||
- CI-ready with no manual steps
|
||||
|
||||
**Arguments against:**
|
||||
- Depends on `nextgraph.eu` and `account.nextgraph.eu` being reachable
|
||||
- UI changes in NextGraph could break the automation
|
||||
- Adds ~27s to first run
|
||||
|
||||
## Decision
|
||||
|
||||
Option B — automate the browser UI. The wallet creation flow (navigate to `nextgraph.eu` → "Create Wallet" → accept ToS at `account.nextgraph.eu` → fill username/password → submit) is itself a legitimate test of the app's auth feature. The dependency on external services is acceptable since the tests already depend on the broker being reachable.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Tests are fully CI-ready (no human interaction)
|
||||
- Auth/login flow is tested as a side effect
|
||||
- Single command `bun run test:data` works from a clean state
|
||||
|
||||
**Negative:**
|
||||
- Requires internet access (nextgraph.eu, account.nextgraph.eu)
|
||||
- Fragile to NextGraph UI changes (button text, form IDs)
|
||||
|
||||
**Risks:**
|
||||
- `account.nextgraph.eu` rate limiting could block CI runs that frequently recreate wallets
|
||||
@@ -0,0 +1,48 @@
|
||||
# Conditional NextGraph Init Based on Broker Iframe Detection
|
||||
|
||||
**Date:** 2026-03-13 14:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
`@ng-org/web`'s `initNgWeb()` checks `window.self === window.top`. When the app runs standalone (not in an iframe), it redirects the entire page to `nextgraph.net/redir/` to trigger broker authentication. This caused the app to redirect on every load — even during development or when the user hadn't clicked "Se connecter".
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: Always auto-init NG on mount
|
||||
**Arguments for:**
|
||||
- Simpler code — no branching logic
|
||||
|
||||
**Arguments against:**
|
||||
- Causes immediate redirect to broker when loaded standalone
|
||||
- Breaks development workflow
|
||||
- User sees broker login page instead of the app
|
||||
|
||||
### Option B: Conditional auto-init based on iframe detection
|
||||
**Arguments for:**
|
||||
- When in iframe, the broker has already authenticated — safe to auto-init
|
||||
- When standalone, user must explicitly click "Se connecter" to trigger the redirect
|
||||
- Preserves standalone demo/development experience
|
||||
- Matches `@ng-org/web`'s own detection logic
|
||||
|
||||
**Arguments against:**
|
||||
- Relies on `window.self !== window.top` heuristic (could theoretically be wrong if embedded in non-broker iframe)
|
||||
|
||||
## Decision
|
||||
|
||||
Option B. `NextGraphContext` checks `const isInsideBroker = typeof window !== 'undefined' && window.self !== window.top` at module level. `useEffect` only auto-calls `initNg()` when `isInsideBroker` is true. The `connect()` callback remains available for explicit user-initiated connection.
|
||||
|
||||
Additionally, `FestipodDataContext` now renders empty data (not seed data) during the `connecting` phase to avoid flashing demo content before the wallet loads.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- App loads without redirecting — works standalone for development and demo
|
||||
- In broker iframe, connection is seamless and automatic
|
||||
- No seed data flash during wallet connection
|
||||
|
||||
**Negative:**
|
||||
- None significant
|
||||
|
||||
**Risks:**
|
||||
- If `@ng-org/web` changes its detection logic, our guard may diverge — keep them aligned
|
||||
@@ -0,0 +1,67 @@
|
||||
# Use private_store_id as useShape scope and @graph
|
||||
|
||||
**Date:** 2026-03-17 16:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Clicking "Charger données de test" loaded data in-memory (via ORM signals) but produced `RepoNotFound` errors from `doc_create` and `orm_frontend_update`. Data disappeared after page reload because SPARQL writes never reached the broker. The NextGraph verifier's `self.repos` HashMap didn't contain the private store repo, so `resolve_target()` failed.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: `did:ng:i` scope + `doc_create` for @graph
|
||||
Use "entire user site" scope for reads, create a new document for writes.
|
||||
|
||||
**Arguments for:**
|
||||
- `did:ng:i` is well-documented as a valid subscription scope
|
||||
- `doc_create` returns a real document NURI
|
||||
|
||||
**Arguments against:**
|
||||
- `did:ng:i` uses a special code path (`NuriTargetV0::UserSite`) that doesn't open individual repos
|
||||
- `doc_create` calls `resolve_target(NuriTargetV0::PrivateStore)` which needs the repo in `self.repos` — fails if repo wasn't opened
|
||||
- Requires complex retry logic / timing workarounds
|
||||
|
||||
### Option B: `private_store_id` as both scope AND @graph
|
||||
Mirror the expense-tracker-rdf example: `useShape(type, `did:ng:${session.private_store_id}`)` and `@graph: `did:ng:${session.private_store_id}``.
|
||||
|
||||
**Arguments for:**
|
||||
- Proven pattern: expense-tracker-rdf uses exactly this and works
|
||||
- `orm_start_graph` with private store NURI opens the repo in the verifier's `self.repos` HashMap
|
||||
- Subsequent writes via `orm_frontend_update` find the repo because it's now in the cache
|
||||
- Simple, no retry logic needed
|
||||
|
||||
**Arguments against:**
|
||||
- Slightly less flexible than `did:ng:i` (scoped to one store)
|
||||
- Requires passing session to `useShapeWithDefaults`
|
||||
|
||||
### Option C: `did:ng:i` scope + reuse existing entity @graph
|
||||
Subscribe with `did:ng:i`, then reuse `@graph` from any existing entity for writes.
|
||||
|
||||
**Arguments for:**
|
||||
- Works for returning users who already have data
|
||||
|
||||
**Arguments against:**
|
||||
- Fails for empty wallets (no existing entities to reuse)
|
||||
- Still needs `doc_create` fallback which hits the same `RepoNotFound` issue
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B**: Use `did:ng:${session.private_store_id}` as both `useShape` scope and `@graph` for writes. This matches the official expense-tracker-rdf example exactly.
|
||||
|
||||
The `useShapeWithDefaults` hook accepts a `storeNuri` parameter. `FestipodDataContext.useNgData()` gets the session from `useNextGraph()` and passes `did:ng:${session.private_store_id}`.
|
||||
|
||||
`ensureGraphNuri()` simplified: checks existing entities first (optimization), then falls back to `did:ng:${session.private_store_id}`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Writes work immediately after connection (no retries needed)
|
||||
- Data persists across page reloads
|
||||
- Pattern matches official NextGraph examples
|
||||
- All 7 e2e scenarios pass including data persistence
|
||||
|
||||
**Negative:**
|
||||
- `useShapeWithDefaults` signature changed (added `storeNuri` parameter)
|
||||
|
||||
**Risks:**
|
||||
- If NextGraph changes the private store behavior, this would break
|
||||
@@ -0,0 +1,81 @@
|
||||
# Use SPARQL DELETE instead of ORM ngSet.delete() for object removal
|
||||
|
||||
**Date:** 2026-03-17 18:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Leaving an event requires deleting the user's `Participation` object from the NextGraph store. The ORM's `DeepSignalSet.delete()` method updates the local reactive state (UI reflects the change immediately) but the deletion does not persist to the broker — after page refresh, the participation reappears.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: ORM `ngSet.delete(item)`
|
||||
|
||||
The ORM README shows `dogs.delete(aDog)` as the intended API. Internally, `.delete()` generates a `{ op: "remove", path: "/<syntheticId>" }` patch, delivered via microtask to `OrmSubscription.onSignalObjectUpdate`, which calls `ng.graph_orm_update()`.
|
||||
|
||||
**Arguments for:**
|
||||
- Official ORM API, shown in README examples
|
||||
- Immediate local reactive update (instant UI feedback)
|
||||
|
||||
**Arguments against:**
|
||||
- Does not persist in practice: `delete()` returns `true`, local set updates, but after refresh the object is back
|
||||
- The `graph_orm_update` WASM call may not correctly handle "remove" patches for top-level set objects (possible engine bug)
|
||||
- No error is thrown — fails silently
|
||||
|
||||
### Option B: `ng.sparql_update()` with SPARQL DELETE
|
||||
|
||||
Bypass the ORM patch mechanism entirely. Use `DELETE WHERE { GRAPH <graph> { <subject> ?p ?o } }` to remove all RDF triples for the object.
|
||||
|
||||
**Arguments for:**
|
||||
- Works: deletion persists across page refresh
|
||||
- The broker confirms via `TORMO became invalid` + `GraphOrmUpdate` remove, which reactively removes the item from the ORM set
|
||||
- Direct control over RDF triple removal
|
||||
|
||||
**Arguments against:**
|
||||
- Not instant: UI update waits for the SPARQL round-trip + broker `GraphOrmUpdate` callback (near-instant in practice, ~50ms)
|
||||
- Must not combine with `ngSet.delete()` — running both causes CRDT conflicts where the item reappears
|
||||
|
||||
### Option C: `ngSet.delete()` + `ng.sparql_update()` together
|
||||
|
||||
Use `.delete()` for instant UI and SPARQL for persistence.
|
||||
|
||||
**Arguments for:**
|
||||
- Instant UI feedback + guaranteed persistence
|
||||
|
||||
**Arguments against:**
|
||||
- **Does not work**: the ORM `.delete()` patch and the SPARQL DELETE backend update conflict in the CRDT, resulting in neither UI change nor persistence
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B: SPARQL DELETE only.** The broker sends back a `GraphOrmUpdate` with `op: "remove"` that reactively removes the item from the ORM set, so the UI still updates — just not synchronously.
|
||||
|
||||
Do NOT call `ngSet.delete()` alongside `sparql_update()` — they conflict.
|
||||
|
||||
## Implementation
|
||||
|
||||
```typescript
|
||||
// In FestipodDataContext.tsx leaveEvent():
|
||||
const session = await sessionPromise;
|
||||
await ng.sparql_update(
|
||||
session.session_id,
|
||||
`DELETE WHERE { GRAPH <${partGraph}> { <${partId}> ?p ?o } }`,
|
||||
partGraph,
|
||||
);
|
||||
```
|
||||
|
||||
Imports: `ng` from `@ng-org/web`, `sessionPromise` from `../utils/ngSession`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Deletion actually persists
|
||||
- Single source of truth (broker → ORM → UI)
|
||||
|
||||
**Negative:**
|
||||
- Slight UI delay (~50ms) vs instant for property mutations
|
||||
- Pattern diverges from ORM README examples
|
||||
|
||||
**Risks:**
|
||||
- If `ng.sparql_update` API changes, this breaks
|
||||
- Other delete operations (if added) must follow the same pattern
|
||||
- The ORM `ngSet.delete()` bug may be fixed in a future version — revisit when upgrading `@ng-org/orm`
|
||||
@@ -0,0 +1,84 @@
|
||||
# Architecture
|
||||
|
||||
Feature-based architecture where code is organized by business domain (module), not by technical layer.
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
src/modules/
|
||||
event/ # 7 screens, 5 features — events CRUD, discovery, participants, meeting points
|
||||
user/ # 5 screens, 11 features — profiles, friends, sharing
|
||||
home/ # 2 screens — dashboard, settings
|
||||
auth/ # 2 screens — login, welcome/onboarding
|
||||
workshop/ # 0 screens, 6 features — workshop/atelier specs (future)
|
||||
meeting/ # 0 screens, 1 feature — meeting point specs
|
||||
notification/ # 0 screens, 3 features — notification specs
|
||||
```
|
||||
|
||||
Each module can contain:
|
||||
- `screens/` — React screen components
|
||||
- `features/` — Gherkin `.feature` files (BDD specs)
|
||||
- `steps/{ui,data,e2e}/` — Cucumber step definitions by layer
|
||||
|
||||
## Import Rules
|
||||
|
||||
**Modules only import from `shared/` — never from each other.**
|
||||
|
||||
```
|
||||
src/modules/event/screens/EventDetailScreen.tsx
|
||||
✅ import from '../../../shared/components/sketchy'
|
||||
✅ import from '../../../shared/context/FestipodDataContext'
|
||||
✅ import from '../../../screens' (registry types)
|
||||
❌ import from '../../user/screens/...'
|
||||
```
|
||||
|
||||
## Shared Layer
|
||||
|
||||
`src/shared/` contains everything reusable across modules:
|
||||
|
||||
| Directory | Contents |
|
||||
|-----------|----------|
|
||||
| `components/sketchy/` | Hand-drawn UI library (Button, Card, Avatar, Header, NavBar, etc.) |
|
||||
| `components/ui/` | Shadcn/Radix components (used only in prototyping tool) |
|
||||
| `context/` | ThemeContext, NextGraphContext, FestipodDataContext |
|
||||
| `data/` | User stories (`index.ts`), auto-generated `features.ts`, `testResults.ts`, `seedData.ts`, `types.ts` |
|
||||
| `hooks/` | `useShapeWithDefaults` (NextGraph) |
|
||||
| `shapes/` | SHEX definitions + ORM TypeScript bindings |
|
||||
| `utils/` | `ngSession.ts`, `ngBootstrap.ts` |
|
||||
| `steps/ui/` | Shared BDD step definitions (navigation, screen, form) |
|
||||
| `support/` | Cucumber `world.ts`, `hooks.ts` |
|
||||
| `types/` | `gherkin.ts` (ParsedFeature, ParsedScenario types) |
|
||||
| `lib/` | `utils.ts` (cn helper for Tailwind) |
|
||||
|
||||
## App Shell
|
||||
|
||||
`src/app/` is the prototyping tool — not part of the Festipod app itself:
|
||||
|
||||
- `App.tsx` — Root: ThemeProvider > NextGraphProvider > FestipodDataProvider > RouterProvider
|
||||
- `router.tsx` — Hash-based routing: `#/` (gallery), `#/demo/{screenId}`, `#/specs/{featureId}`
|
||||
- `frontend.tsx` — React entry point (referenced from `src/index.html`)
|
||||
- `components/Gallery.tsx` — Screen preview grid
|
||||
- `components/DemoMode.tsx` — Interactive mockup viewer with sidebar navigation
|
||||
- `components/specs/` — BDD specs browser (SpecsPage, FeatureView, GherkinHighlighter)
|
||||
|
||||
## Screen Registry
|
||||
|
||||
`src/screens/index.ts` is the central registry that imports all screens from all modules and exports:
|
||||
- `screenGroups` — Grouped by domain (Accueil, Evenements, Utilisateur, General)
|
||||
- `screens` — Flat list
|
||||
- `getScreen(id)` — Lookup by ID
|
||||
- `ScreenProps` interface — `{ navigate: (screenId: string) => void }`
|
||||
|
||||
## Entry Points
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | Bun.serve() — HTTP server, serves index.html + cucumber report |
|
||||
| `src/index.html` | HTML entry, loads `src/app/frontend.tsx` |
|
||||
| `src/app/frontend.tsx` | React root, renders `<App />` |
|
||||
|
||||
## Build
|
||||
|
||||
- Dev: `bun --hot src/index.ts` (via `bun run dev`)
|
||||
- Prod: `bun run build.ts` — Bun bundler + Tailwind plugin → `dist/`
|
||||
- Path alias: `@/*` → `./src/*` (tsconfig)
|
||||
@@ -0,0 +1,113 @@
|
||||
# BDD Testing
|
||||
|
||||
Cucumber/Gherkin BDD specs in French with multi-layer step definitions.
|
||||
|
||||
## Overview
|
||||
|
||||
- 26 feature files (US-1 to US-26), all in French
|
||||
- Categories: EVENT, WORKSHOP, USER, MEETING, NOTIF
|
||||
- Priorities: 0 (Impossible), 1 (Haute), 2 (Moyenne), 3 (Basse)
|
||||
- Current results: 51 passed, 7 failed, 75 skipped (133 scenarios total)
|
||||
|
||||
## Multi-Layer BDD
|
||||
|
||||
Each module has step directories for three test layers:
|
||||
|
||||
```
|
||||
src/modules/event/steps/
|
||||
ui/ # UI/screen assertions (source analysis)
|
||||
data/ # Data layer assertions (Playwright + broker)
|
||||
e2e/ # E2E assertions (Playwright + broker + real app UI)
|
||||
```
|
||||
|
||||
Shared steps (cross-domain) live in `src/shared/steps/ui/`.
|
||||
|
||||
## Feature Files
|
||||
|
||||
Collocated with their module:
|
||||
|
||||
```
|
||||
src/modules/event/features/us-13-creer-evenement.feature
|
||||
src/modules/user/features/us-23-connexion-utilisateurs.feature
|
||||
src/modules/workshop/features/us-1-visualiser-atelier-termine.feature
|
||||
...
|
||||
```
|
||||
|
||||
Tagged with `@CATEGORY @priority-N` for filtering.
|
||||
|
||||
## Step Definitions
|
||||
|
||||
### Shared Steps (`src/shared/steps/ui/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `navigation.steps.ts` | Screen navigation, authentication, click/select actions, section/button/field assertions |
|
||||
| `form.steps.ts` | Form field validation, required fields, import/duplicate detection |
|
||||
| `screen.steps.ts` | Screen content assertions (participants, events, profiles, QR codes) |
|
||||
|
||||
### How UI Steps Work
|
||||
|
||||
`@ui` steps render the screen with `LocalDataProvider` (seed data) and `RouterProvider` via happy-dom, then assert on the rendered DOM. The render helper lives in `src/shared/test-harness/renderHelper.tsx` and is invoked from `world.ts:renderCurrentScreen()` on every `navigateTo(...)`.
|
||||
|
||||
See [test-layer-contracts](./test-layer-contracts.md) for what `@ui` is allowed to test and the patterns to follow (and avoid).
|
||||
|
||||
Legacy: `screenFileMap`, `screenFieldDetectors`, `screenExpectedContent`, `screenRequiredFields` in `world.ts` are vestiges of an earlier source-code-grep approach. `hasText`/`hasField`/`hasElement` now prefer the rendered DOM and fall back to source so unmigrated steps keep working during the transition.
|
||||
|
||||
### Screen Name Resolution
|
||||
|
||||
French names in `.feature` files map to screen IDs via `screenNameMap`:
|
||||
- `"accueil"` → `home`
|
||||
- `"détail événement"` → `event-detail`
|
||||
- `"mon profil"` → `profile`
|
||||
- `"relayer un événement"` → `create-event`
|
||||
|
||||
## Cucumber Configuration
|
||||
|
||||
`cucumber.json`:
|
||||
```json
|
||||
{
|
||||
"default": {
|
||||
"import": [
|
||||
"src/shared/support/**/*.ts",
|
||||
"src/shared/steps/**/*.ts",
|
||||
"src/modules/*/steps/**/*.ts"
|
||||
],
|
||||
"paths": ["src/modules/*/features/**/*.feature"],
|
||||
"language": "fr"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Requires `tsx` loader: `node --import tsx/esm node_modules/.bin/cucumber-js`
|
||||
|
||||
## Auto-Generated Files
|
||||
|
||||
Scripts in `scripts/` parse features and steps into TypeScript data files consumed by the prototyping tool:
|
||||
|
||||
| Script | Input | Output |
|
||||
|--------|-------|--------|
|
||||
| `parse-features.ts` | `src/modules/*/features/*.feature` | `src/shared/data/features.ts` |
|
||||
| `parse-test-results.ts` | `reports/cucumber-report.json` | `src/shared/data/testResults.ts` |
|
||||
| `extract-step-definitions.ts` | `src/shared/steps/ui/*.ts` | `src/shared/data/stepDefinitions.ts` |
|
||||
|
||||
Run all: `bun run test:cucumber`
|
||||
|
||||
## Data-Layer Testing
|
||||
|
||||
`@data` scenarios test through the real NextGraph broker. See [data-layer-testing](./data-layer-testing.md) for full architecture.
|
||||
|
||||
## E2E Testing
|
||||
|
||||
`@e2e` scenarios test the real app running in the broker iframe. See [data-layer-testing](./data-layer-testing.md#e2e-layer) for architecture. Key differences from `@data`:
|
||||
|
||||
- Uses the **real app** (not a test harness) served on a local HTTP port
|
||||
- Interacts via Playwright locators and `evaluate()` on the app iframe
|
||||
- Tests actual UI behavior: navigation, redirects, button clicks, screen content
|
||||
- Requires real broker mode (fails with `Error` if broker unavailable)
|
||||
|
||||
## Adding New Steps
|
||||
|
||||
1. **Module-specific**: Create in `src/modules/{module}/steps/ui/`
|
||||
2. **Cross-domain**: Add to `src/shared/steps/ui/`
|
||||
3. Import `FestipodWorld` type from `../../support/world` (shared) or adjust relative path
|
||||
4. Run `bun run steps:extract` to regenerate tooltip data
|
||||
@@ -0,0 +1,177 @@
|
||||
# Data-Layer Testing
|
||||
|
||||
BDD scenarios tagged `@data` test the real NextGraph data pipeline through a broker, not mocked data.
|
||||
|
||||
## Overview
|
||||
|
||||
`@data` scenarios run Cucumber steps against a real NextGraph broker. Playwright drives a Chromium instance that authenticates with the broker, which loads our test harness in an iframe. The harness uses real `useShape`/ORM subscriptions and exposes a `window.__testData` bridge for step definitions.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Cucumber steps → Playwright (Chromium, persistent profile)
|
||||
↓
|
||||
https://nextgraph.eu/auth/#/?o=http://127.0.0.1:{port}
|
||||
↓
|
||||
Broker wallet login (automated)
|
||||
↓
|
||||
Broker loads app in iframe → http://127.0.0.1:{port}
|
||||
↓
|
||||
harness-ng.tsx (init → useShape → ORM → broker)
|
||||
↓
|
||||
window.__testData bridge
|
||||
```
|
||||
|
||||
## Dual Mode
|
||||
|
||||
- **Real broker** (default): `harness-ng.tsx` with NextGraph ORM through broker iframe
|
||||
- **Mock fallback**: `harness.tsx` with standalone DeepSignalSets (if NG harness build fails)
|
||||
|
||||
## Wallet Lifecycle
|
||||
|
||||
Fully automated — no manual interaction required. CI-ready.
|
||||
|
||||
### First Run (wallet creation + bootstrap)
|
||||
1. `BeforeAll` detects no `.wallet-ready` marker in `.playwright-profile/`
|
||||
2. Launches headless Chromium with persistent profile
|
||||
3. Navigates to `https://nextgraph.eu/` → clicks "Create Wallet"
|
||||
4. Redirected to `account.nextgraph.eu` → clicks "I accept" (ToS)
|
||||
5. Redirected back → fills username/password form → submits
|
||||
6. Wallet created in localStorage
|
||||
7. **Logs in to the wallet** — this triggers the verifier bootstrap from the remote broker, populating localStorage with repo data
|
||||
8. Waits 10s for bootstrap to complete, then closes context
|
||||
9. Marker written
|
||||
|
||||
Step 7 is critical: the NextGraph verifier starts with an empty `repos` HashMap. On first login, `verifier.sync()` bootstraps from the remote broker, downloading repo data (including store repos). This data is saved to localStorage via `session_save`. Without this initial login, subsequent sessions would have empty repos and all writes would fail with `RepoNotFound`.
|
||||
|
||||
### Subsequent Runs (automated login)
|
||||
1. Marker found → skip wallet creation
|
||||
2. Headless Chromium with persistent profile
|
||||
3. Automated login: click "Login" → click wallet link → fill password → submit
|
||||
4. Broker authenticates, loads app harness in iframe
|
||||
5. Harness initializes NG, creates ORM subscriptions, seeds data if needed
|
||||
6. `window.__testData.ready` → steps execute via `appFrame.evaluate()`
|
||||
|
||||
### Wallet Credentials
|
||||
- Name: `festipod-tests`
|
||||
- Password: `festipod-tests`
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
### Chromium Flags
|
||||
```
|
||||
--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,...
|
||||
--allow-insecure-localhost
|
||||
--disable-web-security
|
||||
```
|
||||
Required because broker at `nextgraph.eu` (public) loads harness from `http://127.0.0.1:{port}` (local) in an iframe — Chromium's Private Network Access blocks this by default.
|
||||
|
||||
### Persistent Profile (`.playwright-profile/`)
|
||||
- Stores NG wallet in localStorage (`ng_wallets` on `nextgraph.eu`, `ng_bootstrap` on `nextgraph.net`)
|
||||
- Gitignored
|
||||
- Must use full Chrome binary, not `chrome-headless-shell`
|
||||
|
||||
### HTTP Server
|
||||
- Started in `BeforeAll` on auto-assigned port (`127.0.0.1:0`)
|
||||
- Serves harness HTML at `/` and JS bundle at `/harness.js` (separate files — inline script breaks due to special characters in bundle)
|
||||
- Shut down in `AfterAll`
|
||||
|
||||
### ORM Subscriptions
|
||||
Harness creates subscriptions for all three shapes with scope `did:ng:${session.private_store_id}` (opens the store repo for reads AND writes):
|
||||
- `FpEventShapeType` → events
|
||||
- `FpUserProfileShapeType` → users
|
||||
- `FpParticipationShapeType` → participations
|
||||
|
||||
### Test Bridge (`window.__testData`)
|
||||
Exposed by the harness, consumed by steps via `appFrame.evaluate()`:
|
||||
- `events`, `users`, `participations` — live DeepSignalSets
|
||||
- `currentUserId` — IRI of the test user
|
||||
- `getEvent(id)`, `getEventByTitle(title)` — lookups
|
||||
- `joinEvent(eventId, userId)`, `leaveEvent(eventId, userId)` — mutations
|
||||
- `isParticipating(eventId, userId)`, `getEventParticipants(eventId)` — queries
|
||||
- `updateEvent(eventId, updates)` — field updates
|
||||
|
||||
## E2E Layer (`@e2e`)
|
||||
|
||||
`@e2e` scenarios test the real app UI running inside the broker iframe. Unlike `@data` which loads a test harness, `@e2e` loads the actual app.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Cucumber steps → Playwright (Chromium, persistent profile)
|
||||
↓
|
||||
https://nextgraph.net/redir/#/?o=http://127.0.0.1:{appPort}
|
||||
↓
|
||||
Broker wallet login (automated, same as @data)
|
||||
↓
|
||||
Broker loads REAL APP in iframe → http://127.0.0.1:{appPort}
|
||||
↓
|
||||
App renders with NextGraphProvider auto-connecting
|
||||
↓
|
||||
Steps interact via appFrame.evaluate() and Playwright locators
|
||||
```
|
||||
|
||||
### App Server
|
||||
|
||||
Started in `BeforeAll` alongside the harness server:
|
||||
1. Find a free port
|
||||
2. `spawn('bun', ['src/index.ts'], { env: { PORT: appPort } })`
|
||||
3. Poll until the server responds to HTTP GET
|
||||
4. Killed in `AfterAll`
|
||||
|
||||
### Shared Infrastructure
|
||||
|
||||
`@e2e` reuses the same `setupBrokerPage()` helper as `@data` — handles broker redirect URL construction, wallet login automation, and iframe discovery.
|
||||
|
||||
### Step Definitions
|
||||
|
||||
E2E steps live in module directories (e.g., `src/modules/auth/steps/e2e/connexion.steps.ts`). They use:
|
||||
- `this.appFrame!.evaluate()` — run JS in the app iframe (hash navigation, content checks)
|
||||
- `this.appFrame!.locator()` — find and interact with DOM elements
|
||||
- `this.appFrame!.waitForFunction()` — poll for expected state (screen content, URL changes)
|
||||
- `SCREEN_MARKERS` — map screen IDs to unique text content for verification
|
||||
|
||||
### Before Hook (`@e2e`)
|
||||
|
||||
```
|
||||
1. Open new Playwright page
|
||||
2. setupBrokerPage(page, realAppUrl) → automated login → find app iframe
|
||||
3. Wait for React render (root.innerHTML.length > 100)
|
||||
4. Wait 3s for NG connection + provider stabilization
|
||||
```
|
||||
|
||||
### Differences from `@data`
|
||||
|
||||
| Aspect | `@data` | `@e2e` |
|
||||
|--------|---------|--------|
|
||||
| What loads in iframe | Test harness (`harness-ng.tsx`) | Real app (`src/index.ts`) |
|
||||
| Ready signal | `window.__testData.ready === true` | `root.innerHTML.length > 100` |
|
||||
| Interaction | `evaluate()` on test bridge | `evaluate()` + Playwright locators |
|
||||
| Mock fallback | Yes (standalone DeepSignalSets) | No — requires real broker |
|
||||
| Tests | Data operations (CRUD, queries) | UI behavior (navigation, redirects, clicks) |
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/shared/test-harness/harness-ng.tsx` | Real broker harness (useShape through broker iframe) |
|
||||
| `src/shared/test-harness/harness.tsx` | Mock harness (DeepSignalSets, no broker) |
|
||||
| `src/shared/support/hooks.ts` | Playwright lifecycle (wallet creation, login automation, iframe detection, app server) |
|
||||
| `src/shared/support/world.ts` | World with `page`/`appFrame` fields |
|
||||
| `src/modules/event/steps/data/inscription.steps.ts` | Inscription data steps |
|
||||
| `src/modules/auth/steps/e2e/connexion.steps.ts` | Auth/connection e2e steps |
|
||||
| `.playwright-profile/` | Persistent Chromium profile (gitignored) |
|
||||
| `scripts/debug-browser.ts` | Manual browser debug tool — launches headed Chromium to inspect broker interactions |
|
||||
| `.playwright-profile-debug/` | Chromium profile created by debug-browser.ts (gitignored) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun run test:data # Run @data scenarios (real broker if wallet exists, mock fallback)
|
||||
bun run test:cucumber # Run all scenarios (UI + data + e2e)
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [BDD Testing](./bdd-testing.md) — general Cucumber setup, UI-layer steps
|
||||
- [Data Layer](./data-layer.md) — NextGraph stack, shapes, context providers
|
||||
@@ -0,0 +1,115 @@
|
||||
# Data Layer
|
||||
|
||||
NextGraph-backed local-first data with fallback to local state for demo/disconnected mode.
|
||||
|
||||
## Overview
|
||||
|
||||
The app has two data modes:
|
||||
1. **Connected** — NextGraph ORM shapes (P2P, encrypted, local-first)
|
||||
2. **Disconnected/Demo** — Local React state seeded from `seedData.ts`
|
||||
|
||||
All screens use `useFestipodData()` hook regardless of mode.
|
||||
|
||||
## NextGraph Stack
|
||||
|
||||
```
|
||||
@ng-org/web # Browser WASM runtime
|
||||
@ng-org/orm # RDF shape-based ORM
|
||||
@ng-org/shex-orm # SHEX → TypeScript code generation
|
||||
@ng-org/alien-deepsignals # Reactive signals bridge
|
||||
```
|
||||
|
||||
Packages installed from npm (`@ng-org/*` alpha versions). For local development against an unreleased `nextgraph-rs` build, `scripts/build-ng-packages.sh` packs the monorepo into `.ng-tarballs/` and updates `package.json` to point at those paths.
|
||||
|
||||
## SHEX Shapes
|
||||
|
||||
`src/shared/shapes/shex/festipodShapes.shex` defines:
|
||||
- **Event** — title, description, dates, location, themes, participants
|
||||
- **UserProfile** — name, username, bio, city, visibility
|
||||
- **Participation** — links event + user, confirmation status
|
||||
|
||||
ORM bindings in `src/shared/shapes/orm/`:
|
||||
- `festipodShapes.schema.ts` — Schema registration
|
||||
- `festipodShapes.shapeTypes.ts` — Shape type constants
|
||||
- `festipodShapes.typings.ts` — TypeScript interfaces
|
||||
|
||||
Regenerate with `bun run build:orm`.
|
||||
|
||||
## NextGraph Read/Write Pattern
|
||||
|
||||
The app follows the same pattern as the official expense-tracker-rdf example:
|
||||
|
||||
- **Scope**: `useShape(shapeType, `did:ng:${session.private_store_id}`)` — opens the private store repo in the verifier
|
||||
- **@graph**: `did:ng:${session.private_store_id}` — writes target the same NURI
|
||||
|
||||
This is critical: `orm_start_graph` with the private store NURI explicitly opens the repo in the verifier's `self.repos` HashMap. Without this, `orm_frontend_update` fails with `RepoNotFound`.
|
||||
|
||||
**Do NOT use `did:ng:i` as scope** — it subscribes to the entire user site via a special code path that doesn't open individual repos, breaking all writes.
|
||||
|
||||
### Deleting Objects
|
||||
|
||||
`ngSet.delete(item)` updates the local reactive set but does **not** persist to the broker. Use `ng.sparql_update()` with SPARQL DELETE instead:
|
||||
|
||||
```typescript
|
||||
import { ng } from '@ng-org/web';
|
||||
import { sessionPromise } from '../utils/ngSession';
|
||||
|
||||
const session = await sessionPromise;
|
||||
await ng.sparql_update(
|
||||
session.session_id,
|
||||
`DELETE WHERE { GRAPH <${item["@graph"]}> { <${item["@id"]}> ?p ?o } }`,
|
||||
item["@graph"],
|
||||
);
|
||||
```
|
||||
|
||||
The broker sends back a `GraphOrmUpdate` with `op: "remove"` that reactively removes the item from the ORM set. **Do NOT combine with `ngSet.delete()`** — the two operations conflict in the CRDT.
|
||||
|
||||
See [decision record](../decisions/2026-03-17-1800-sparql-delete-for-orm-objects.md) for details.
|
||||
|
||||
### Key files
|
||||
|
||||
- `src/shared/hooks/useShapeWithDefaults.ts` — Accepts `storeNuri` param, passes to `useShape`
|
||||
- `src/shared/utils/ngGraph.ts` — `ensureGraphNuri()` returns `@graph` for entity creation
|
||||
- `src/shared/utils/ngBootstrap.ts` — Seeds test data using `ensureGraphNuri()` for `@graph`
|
||||
|
||||
See [decision record](.project/decisions/2026-03-17-1600-private-store-nuri-scope.md) for why.
|
||||
|
||||
## Context Providers
|
||||
|
||||
### NextGraphContext (`src/shared/context/NextGraphContext.tsx`)
|
||||
- Connection lifecycle: `disconnected` → `connecting` → `connected` | `error`
|
||||
- Provides session with store IDs (private, protected, public)
|
||||
- **Conditional auto-init**: Only auto-calls `initNg()` when running inside the broker iframe (`window.self !== window.top`). Outside the iframe, `initNgWeb()` would redirect the page to the broker — so connection waits for explicit `connect()` call.
|
||||
- `connect()`: Called by user clicking "Se connecter". When outside broker, triggers the redirect flow.
|
||||
|
||||
#### `@ng-org/web` redirect behavior
|
||||
`initNgWeb()` checks `window.self === window.top`. If the app is NOT in an iframe, it redirects to `nextgraph.net/redir/` with the current URL encoded as a return parameter. The broker then loads the app back in an iframe after auth. This means the app must NOT auto-init NG when loaded standalone.
|
||||
|
||||
### FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
|
||||
- Wraps NextGraph shapes with `useShapeWithDefaults()` hook
|
||||
- CRUD: `createEvent()`, `updateEvent()`, `joinEvent()`, `leaveEvent()`, etc.
|
||||
- Exposes `useFestipodData()` hook consumed by all screens
|
||||
- `selectedEventId` state for cross-screen event navigation
|
||||
- `loadTestData()`: Calls `bootstrapWallet()` to seed test data into NG wallet — only triggered by explicit user action
|
||||
- **Provider states based on NG status**:
|
||||
- `disconnected` → `LocalDataProvider` with seed data (demo mode)
|
||||
- `connecting` → `LocalDataProvider` with **empty data** (avoids flashing seed data before wallet loads)
|
||||
- `connected` → `NgDataProvider` with real wallet data
|
||||
- `error` → `LocalDataProvider` with seed data (graceful fallback)
|
||||
|
||||
## Data Types
|
||||
|
||||
`src/shared/data/types.ts`:
|
||||
- `FpEventData` — id, title, date, location, distance, themes, etc.
|
||||
- `FpUserData` — id, name, username, bio, city, counts
|
||||
- `FpParticipationData` — eventId + userId + confirmed
|
||||
- `FpMeetingPointData` — eventId, location, time, host (local-only)
|
||||
- `FpFriendshipData` — userId + friendId (local-only)
|
||||
|
||||
## Seed Data
|
||||
|
||||
`src/shared/data/seedData.ts`:
|
||||
- 10 users (Marie Dupont = current user, `user-1`)
|
||||
- Multiple events with dates, locations, themes
|
||||
- Participations, meeting points, friendships
|
||||
- `CURRENT_USER_ID = 'user-1'`
|
||||
@@ -0,0 +1,94 @@
|
||||
# Screens
|
||||
|
||||
16 mobile mockup screens using the sketchy hand-drawn component library.
|
||||
|
||||
## Screen Inventory
|
||||
|
||||
### Home Module (`src/modules/home/screens/`)
|
||||
|
||||
| ID | Name | File | Description |
|
||||
|----|------|------|-------------|
|
||||
| `welcome` | Bienvenue | WelcomeScreen.tsx | Onboarding/welcome page |
|
||||
| `home` | Accueil | HomeScreen.tsx | Dashboard with upcoming events, quick actions |
|
||||
| `settings` | Parametres | SettingsScreen.tsx | Notifications, privacy, location settings |
|
||||
|
||||
### Event Module (`src/modules/event/screens/`)
|
||||
|
||||
| ID | Name | File | Description |
|
||||
|----|------|------|-------------|
|
||||
| `events` | Decouvrir | EventsScreen.tsx | Event discovery/search |
|
||||
| `event-detail` | Detail evenement | EventDetailScreen.tsx | Event info, participants, join/leave |
|
||||
| `create-event` | Relayer evenement | CreateEventScreen.tsx | Create/relay event, import from Mobilizon/Transiscope |
|
||||
| `update-event` | Modifier evenement | UpdateEventScreen.tsx | Edit existing event |
|
||||
| `invite` | Inviter des amis | InviteScreen.tsx | Invite contacts to event |
|
||||
| `participants-list` | Liste des participants | ParticipantsListScreen.tsx | Event participant list |
|
||||
| `meeting-points` | Points de rencontre | MeetingPointsScreen.tsx | Carpooling/meeting coordination |
|
||||
|
||||
### User Module (`src/modules/user/screens/`)
|
||||
|
||||
| ID | Name | File | Description |
|
||||
|----|------|------|-------------|
|
||||
| `profile` | Mon profil | ProfileScreen.tsx | Current user profile |
|
||||
| `update-profile` | Modifier mon profil | UpdateProfileScreen.tsx | Edit profile form |
|
||||
| `user-profile` | Profil d'un utilisateur | UserProfileScreen.tsx | View another user's profile |
|
||||
| `friends-list` | Mon reseau | FriendsListScreen.tsx | Network/friends list |
|
||||
| `share-profile` | Partager mon profil | ShareProfileScreen.tsx | QR code + link sharing |
|
||||
|
||||
### Auth Module (`src/modules/auth/screens/`)
|
||||
|
||||
| ID | Name | File | Description |
|
||||
|----|------|------|-------------|
|
||||
| `login` | Connexion | LoginScreen.tsx | Login (NextGraph + email fallback) |
|
||||
|
||||
## Screen Registry
|
||||
|
||||
`src/screens/index.ts` imports all screens and exports:
|
||||
|
||||
```typescript
|
||||
export interface ScreenProps {
|
||||
navigate: (screenId: string) => void;
|
||||
}
|
||||
|
||||
export const screenGroups: ScreenGroup[] // Grouped: home, events, user, general
|
||||
export const screens: Screen[] // Flat list
|
||||
export function getScreen(id: string): Screen | undefined
|
||||
```
|
||||
|
||||
## Sketchy Component Library
|
||||
|
||||
`src/shared/components/sketchy/` — hand-drawn UI with custom font:
|
||||
|
||||
| Component | Usage |
|
||||
|-----------|-------|
|
||||
| `Header` | Screen header with back button |
|
||||
| `NavBar` | Bottom tab navigation |
|
||||
| `Button` | Action buttons |
|
||||
| `Card` | Content cards |
|
||||
| `Input` | Text inputs |
|
||||
| `Title`, `Subtitle`, `Text` | Typography |
|
||||
| `Avatar` | User avatars with initials |
|
||||
| `Badge` | Status/category badges |
|
||||
| `Toggle`, `Checkbox` | Form controls |
|
||||
| `ListItem` | List row items |
|
||||
| `Divider` | Section separators |
|
||||
| `Placeholder` | Image/content placeholders |
|
||||
| `PhoneFrame` | Phone device frame wrapper |
|
||||
| `BrokerBanner` | NextGraph connection status banner |
|
||||
| `NgStatus` | Connection indicator dot |
|
||||
|
||||
## Screen Patterns
|
||||
|
||||
All screens follow the same pattern:
|
||||
|
||||
```typescript
|
||||
import { Header, Button, ... } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import type { ScreenProps } from '../../../screens';
|
||||
|
||||
export function MyScreen({ navigate }: ScreenProps) {
|
||||
const { events, currentUser, ... } = useFestipodData();
|
||||
// render with sketchy components
|
||||
}
|
||||
```
|
||||
|
||||
Navigation between screens uses `navigate(screenId)` — the prototyping tool intercepts this to switch the displayed screen.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Test Layer Contracts
|
||||
|
||||
Each BDD test layer (`@ui`, `@data`, `@e2e`) answers a distinct question. Mixing concerns produces brittle tests that fail on refactors without catching real regressions.
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
/\ @e2e ~10 scénarios, parcours utilisateur critiques
|
||||
/ \
|
||||
/----\
|
||||
/ @data\ ~10 scénarios, mutations & persistance NG
|
||||
/--------\
|
||||
/ @ui \ ~60 scénarios, 1-5 par état d'écran × 15 écrans
|
||||
/____________\
|
||||
```
|
||||
|
||||
The pyramid reflects cost: `@ui` runs in-process (instant), `@data` boots a broker (~50s for the suite), `@e2e` boots broker + real app + navigates a real browser (~2min). Move every assertion to the lowest layer that can answer the question — UI rendering claims belong in `@ui`, not `@e2e`.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **`@ui` — display layer.** Renders a screen with `LocalDataProvider` (seed data) + happy-dom and asserts on the resulting DOM. Verifies that *given known data, the screen shows the expected text and elements*. Does **not** test navigation outcomes, mutations, or data persistence.
|
||||
|
||||
- **`@data` — data layer.** Drives ORM mutations through the real NextGraph broker via a headless test harness. No app UI involved. Verifies that *operations on shapes are correctly persisted and observable in the wallet*. See [data-layer-testing](./data-layer-testing.md).
|
||||
|
||||
- **`@e2e` — integration layer.** Boots the real app inside the broker iframe with a Playwright-controlled Chromium. Verifies that *layers collaborate to deliver a user journey* (e.g. create → list → modify → reload → still there). Sparse: 1 scenario per critical path; never duplicate `@ui` content checks here.
|
||||
|
||||
## Implementation
|
||||
|
||||
### `@ui` — rendering helper
|
||||
|
||||
`src/shared/test-harness/renderHelper.tsx` installs happy-dom globals and renders any screen wrapped in `LocalDataProvider` + `RouterProvider`. Called from `world.ts:renderCurrentScreen()` on every `navigateTo(...)`. Seed data (`src/shared/data/seedData.ts`) provides predictable fixtures — `Marie Dupont`/`@mariedupont` is `currentUser`, `Jean Durand`/`@jeandurand` exists in `users`, 5 seed events, etc.
|
||||
|
||||
**Good `@ui` assertion patterns:**
|
||||
|
||||
```ts
|
||||
// Text visible to the user
|
||||
expect(this.getDomText()).to.include('Marie Dupont');
|
||||
|
||||
// Element presence by class/role
|
||||
expect(this.renderedDoc!.querySelector('.app-avatar')).to.not.be.null;
|
||||
|
||||
// Conditional rendering (filled state vs empty state)
|
||||
const cards = this.renderedDoc!.querySelectorAll('.app-card');
|
||||
expect(cards.length).to.be.greaterThan(0);
|
||||
|
||||
// Required form fields rendered with their label + asterisk
|
||||
const labels = Array.from(this.renderedDoc!.querySelectorAll('p'))
|
||||
.map(p => p.textContent ?? '');
|
||||
expect(labels.some(t => t.includes("Nom de l'événement *"))).to.be.true;
|
||||
```
|
||||
|
||||
**Anti-patterns to remove:**
|
||||
|
||||
```ts
|
||||
// ❌ Regex on source: couples test to code structure, fails on refactor
|
||||
expect(/<Title[^>]*>Marie Dupont<\/Title>/.test(source)).to.be.true;
|
||||
|
||||
// ❌ Testing implementation details
|
||||
expect(/showDuplicateWarning/.test(source)).to.be.true;
|
||||
expect(/importableEvents/.test(source)).to.be.true;
|
||||
|
||||
// ❌ Testing JSX structure rather than rendered output
|
||||
expect(/<Avatar[^>]*initials="MD"[^>]*size="lg"/.test(source)).to.be.true;
|
||||
```
|
||||
|
||||
### `@data` — broker-only
|
||||
|
||||
Already isolated correctly. See [data-layer-testing](./data-layer-testing.md). Don't touch the DOM here; use the test harness bridge (`window.__testData`).
|
||||
|
||||
### `@e2e` — full stack
|
||||
|
||||
Path-based routing: navigate via `window.history.pushState` + `popstate` dispatch (`src/modules/auth/steps/e2e/connexion.steps.ts`). Assert on actual DOM text after `appFrame.waitForFunction`. **Do not** re-verify here what `@ui` already covers — `@e2e` should fail when *collaboration* between layers breaks, not when an icon changes.
|
||||
|
||||
## Migration Consequences
|
||||
|
||||
The current `@ui` suite predates this contract. The migration plan:
|
||||
|
||||
1. **Rewrite source-grep assertions** → DOM queries via the helper. The `world.ts:hasText/hasField/hasElement` methods already prefer the rendered DOM and fall back to source — so unmigrated steps still work during the transition.
|
||||
2. **Delete tests on implementation details** (`/showDuplicateWarning/`, `/importableEvents/`, regex on JSX). They protect nothing the user sees.
|
||||
3. **Move behavioral assertions to `@e2e`** when not already covered ("clicking Suivant advances the wizard" — exercise it via Playwright if it's not redundant with existing journeys).
|
||||
4. **Drop redundant `@e2e` content checks** that duplicate `@ui` (e.g. "screen contains 'Découvrir'" — let `@ui` own that).
|
||||
|
||||
`world.ts:screenFileMap`, `screenFieldDetectors`, `screenExpectedContent`, `screenRequiredFields` are vestiges of the source-analysis era. Once the migration is complete, they can be removed in favor of seed-data assertions on the rendered DOM.
|
||||
|
||||
## See Also
|
||||
|
||||
- [BDD Testing setup](./bdd-testing.md) — Cucumber config, file layout, scripts
|
||||
- [Data Layer](./data-layer.md) — NextGraph shapes, seed data, contexts
|
||||
- [Data-Layer Testing](./data-layer-testing.md) — broker harness, wallet setup, Playwright
|
||||
- [Architecture](./architecture.md) — module structure
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import type { Decorator } from '@storybook/react-webpack5';
|
||||
import { ThemeProvider } from '../src/shared/context/ThemeContext';
|
||||
import { NextGraphProvider } from '../src/shared/context/NextGraphContext';
|
||||
import { FestipodDataProvider } from '../src/shared/context/FestipodDataContext';
|
||||
import { RouterProvider } from '../src/app/router';
|
||||
|
||||
export const withProviders: Decorator = (Story) => (
|
||||
<ThemeProvider>
|
||||
<NextGraphProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div style={{ maxWidth: 375, margin: '0 auto', height: '100vh', background: 'var(--sketch-white)' }}>
|
||||
<Story />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
</NextGraphProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { StorybookConfig } from '@storybook/react-webpack5';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
"stories": [
|
||||
"../src/**/*.mdx",
|
||||
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
|
||||
],
|
||||
"addons": [
|
||||
"@storybook/addon-webpack5-compiler-swc",
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-docs",
|
||||
"@storybook/addon-onboarding"
|
||||
],
|
||||
"framework": "@storybook/react-webpack5"
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Preview } from '@storybook/react-webpack5'
|
||||
import '../src/index.css';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
viewport: {
|
||||
defaultViewport: 'mobile1',
|
||||
},
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -0,0 +1,75 @@
|
||||
# Festipod
|
||||
|
||||
Mobile-first web app for discovering and sharing festival/event recommendations through trusted networks.
|
||||
|
||||
## Architecture
|
||||
|
||||
Feature-based: code organized by business domain, not technical layer. See [architecture](.project/knowledge/architecture.md).
|
||||
|
||||
```
|
||||
src/modules/{event,user,home,auth,workshop,meeting,notification}/
|
||||
src/shared/ # Components, context, data — importable by all modules
|
||||
src/app/ # App shell (router, providers, entry point)
|
||||
src/screens/index.ts # Screen registry (used by Storybook)
|
||||
```
|
||||
|
||||
## Routing
|
||||
|
||||
Path-based routing with History API (custom router in `src/app/router.tsx`).
|
||||
|
||||
| Path | Screen |
|
||||
|------|--------|
|
||||
| `/` | WelcomeScreen |
|
||||
| `/login` | LoginScreen |
|
||||
| `/home` | HomeScreen |
|
||||
| `/events` | EventsScreen |
|
||||
| `/events/new` | CreateEventScreen |
|
||||
| `/events/:id` | EventDetailScreen |
|
||||
| `/events/:id/edit` | UpdateEventScreen |
|
||||
| `/events/:id/invite` | InviteScreen |
|
||||
| `/events/:id/participants` | ParticipantsListScreen |
|
||||
| `/events/:id/meeting-points` | MeetingPointsScreen |
|
||||
| `/profile` | ProfileScreen |
|
||||
| `/profile/edit` | UpdateProfileScreen |
|
||||
| `/profile/friends` | FriendsListScreen |
|
||||
| `/profile/share` | ShareProfileScreen |
|
||||
| `/users/:id` | UserProfileScreen |
|
||||
| `/settings` | SettingsScreen |
|
||||
|
||||
Screens use `useNavigate()` and `useParams()` hooks from the router — no prop drilling.
|
||||
|
||||
## Data Layer
|
||||
|
||||
NextGraph (P2P/local-first) with SHEX shapes and ORM. See [data-layer](.project/knowledge/data-layer.md).
|
||||
|
||||
## BDD Testing
|
||||
|
||||
Multi-layer Cucumber/Gherkin in French. See [bdd-testing](.project/knowledge/bdd-testing.md) for the setup and [test-layer-contracts](.project/knowledge/test-layer-contracts.md) for what each layer is allowed to test.
|
||||
|
||||
`@ui` scenarios render screens in-process (happy-dom + seed data) and assert on the DOM. `@data` scenarios test data operations through the real NextGraph broker. `@e2e` scenarios test the real app UI in the broker iframe. See [data-layer-testing](.project/knowledge/data-layer-testing.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
bun run dev # Dev server with HMR (port 3000)
|
||||
bun run build # Production build to dist/
|
||||
bun run storybook # Browse screens and components
|
||||
bun run test:cucumber # Run all BDD tests
|
||||
bun run features:parse # Regenerate features.ts from .feature files
|
||||
bun run steps:extract # Extract step definitions for tooltips
|
||||
bun run build:orm # Regenerate ORM from SHEX shapes
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture](.project/knowledge/architecture.md) — module structure, import rules, app shell
|
||||
- [Data Layer](.project/knowledge/data-layer.md) — NextGraph, shapes, context, seed data
|
||||
- [BDD Testing](.project/knowledge/bdd-testing.md) — Cucumber setup, step layers, feature files
|
||||
- [Test Layer Contracts](.project/knowledge/test-layer-contracts.md) — what each of `@ui`/`@data`/`@e2e` is allowed to test
|
||||
- [Screens](.project/knowledge/screens.md) — screen inventory, registry, sketchy components
|
||||
- [Data-Layer Testing](.project/knowledge/data-layer-testing.md) — real broker testing, wallet setup, Playwright harness, e2e layer
|
||||
|
||||
## Briefs (work not yet started)
|
||||
|
||||
- [Multi-store refactor](.project/briefs/multi-store-refactor.md) — passer du mono-store actuel à une structure de Group stores par communauté/event/RDV (prérequis multi-user)
|
||||
- [Matrice d'autorisations et requêtes](.project/briefs/authorization-matrix.md) — analyse qui doit guider la structure de stores cible
|
||||
@@ -1,44 +1,91 @@
|
||||
@AGENTS.md
|
||||
|
||||
# Festipod Project
|
||||
|
||||
This project has two parts:
|
||||
1. **Festipod App** - Mobile app mockups in `src/screens/` with sketchy hand-drawn UI
|
||||
2. **Prototyping Tool** - Web app to view mockups, user stories, and BDD specs
|
||||
Mobile-first web app for discovering and sharing festival/event recommendations through trusted networks. Uses a sketchy hand-drawn UI style.
|
||||
|
||||
## Architecture
|
||||
|
||||
Feature-based architecture: code is organized by business domain (module), not by technical layer. A module can only import from `shared/` — never from another module.
|
||||
|
||||
Multi-layer BDD: each module has `steps/ui/`, `steps/data/`, `steps/e2e/` directories. Shared step definitions live in `src/shared/steps/`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
screens/ # Mockup screens (HomeScreen, EventDetailScreen, etc.)
|
||||
components/
|
||||
sketchy/ # Hand-drawn UI components (Button, Card, Avatar, etc.)
|
||||
specs/ # Specs viewer (GherkinHighlighter, FeatureView, etc.)
|
||||
ui/ # Shadcn/Radix components
|
||||
data/
|
||||
index.ts # User stories definitions
|
||||
features.ts # Auto-generated from .feature files
|
||||
testResults.ts # Cucumber test results
|
||||
features/ # Gherkin .feature files (French)
|
||||
scripts/ # Build scripts for parsing features
|
||||
docs/ # Documentation
|
||||
modules/ # Business domain modules
|
||||
event/ # Events (create, discover, detail, update, invite, participants, meeting points)
|
||||
screens/ # EventsScreen, EventDetailScreen, CreateEventScreen, etc.
|
||||
features/ # Gherkin .feature files for this domain
|
||||
steps/ # BDD step definitions
|
||||
ui/ # UI-layer steps
|
||||
data/ # Data-layer steps
|
||||
e2e/ # E2E steps
|
||||
user/ # User profiles, friends, sharing
|
||||
screens/ # ProfileScreen, FriendsListScreen, ShareProfileScreen, etc.
|
||||
features/
|
||||
steps/
|
||||
home/ # Home dashboard, settings
|
||||
screens/ # HomeScreen, SettingsScreen
|
||||
auth/ # Authentication, onboarding
|
||||
screens/ # LoginScreen, WelcomeScreen
|
||||
workshop/ # Workshop/atelier specs (no screens yet)
|
||||
features/
|
||||
steps/
|
||||
meeting/ # Meeting point specs
|
||||
features/
|
||||
steps/
|
||||
notification/ # Notification specs
|
||||
features/
|
||||
steps/
|
||||
shared/ # Shared code (importable by all modules)
|
||||
components/
|
||||
sketchy/ # Hand-drawn UI components (Button, Card, Avatar, etc.)
|
||||
ui/ # Shadcn/Radix components
|
||||
context/ # ThemeContext, NextGraphContext, FestipodDataContext
|
||||
data/ # User stories, features.ts (auto-generated), testResults.ts
|
||||
hooks/ # Custom hooks (useShapeWithDefaults)
|
||||
shapes/ # SHEX shapes + ORM bindings (NextGraph)
|
||||
utils/ # ngSession, ngBootstrap
|
||||
steps/ # Shared BDD step definitions (cross-domain)
|
||||
ui/ # navigation.steps.ts, form.steps.ts, screen.steps.ts
|
||||
data/
|
||||
support/ # Cucumber hooks.ts, world.ts
|
||||
types/ # TypeScript type definitions
|
||||
lib/ # Utility functions (cn, etc.)
|
||||
app/ # App shell
|
||||
App.tsx # Root component with providers + route switch
|
||||
router.tsx # Path-based routing (History API)
|
||||
frontend.tsx # React entry point
|
||||
screens/
|
||||
index.ts # Screen registry (used by Storybook)
|
||||
scripts/ # Build scripts for parsing features
|
||||
docs/ # Documentation
|
||||
.storybook/ # Storybook configuration
|
||||
```
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
bun run dev # Start dev server with HMR
|
||||
bun run storybook # Browse screens and components in Storybook
|
||||
bun run test:cucumber # Run Cucumber tests
|
||||
bun run features:parse # Regenerate features.ts from .feature files
|
||||
bun run steps:extract # Extract step definitions for tooltips
|
||||
```
|
||||
|
||||
## Routing
|
||||
|
||||
Path-based routing via `src/app/router.tsx`. Screens use `useNavigate()` and `useParams()` hooks. See AGENTS.md for the full route table.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Gherkin specs are in French (Étant donné, Quand, Alors)
|
||||
- Gherkin specs are in French (Etant donne, Quand, Alors)
|
||||
- UI labels are in French
|
||||
- User stories are prefixed US-1 to US-26
|
||||
- Screens use the sketchy component library, not Tailwind
|
||||
- Specs pages use Tailwind + Shadcn components with system font (not sketchy font)
|
||||
- GherkinHighlighter uses card-based layout, not code/text style
|
||||
- Max app width: 768px (tablet portrait)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,44 +1,84 @@
|
||||
# Festipod
|
||||
|
||||
A prototyping tool for the Festipod mobile app - an event discovery and networking platform.
|
||||
**Festipod permet aux utilisateurs de créer des points de rencontre qui viennent se « greffer » sur des événements publics existants. L'objectif est de favoriser les rencontres autour de ces événements.**
|
||||
|
||||
## What's Inside
|
||||
L'événement public (festival, conférence, salon…) n'est qu'un *prétexte* et un *point d'ancrage temporel et géographique* : la valeur produite par l'app, c'est le **point de rencontre** que les utilisateurs viennent y greffer pour se retrouver.
|
||||
|
||||
- **Mobile App Mockups** - 13 interactive screens with hand-drawn "sketchy" UI
|
||||
- **User Stories** - 26 stories across 5 categories (Events, Workshops, Users, Meetings, Notifications)
|
||||
- **BDD Specifications** - Cucumber feature files in French with test integration
|
||||
Application web mobile-first. Stack : Bun + React + NextGraph (P2P, local-first, chiffré de bout en bout).
|
||||
|
||||
## Modèle fonctionnel
|
||||
|
||||
Tous les utilisateurs sont authentifiés — il n'y a pas d'accès anonyme à l'app.
|
||||
|
||||
### Acteurs
|
||||
|
||||
- **Utilisateur** — toute personne ayant un compte (un wallet NextGraph). Tous les acteurs ci-dessous sont des spécialisations d'un utilisateur dans un contexte donné.
|
||||
- **Connexion (« ami »)** — un autre utilisateur avec qui je suis connecté. Sert à scoper les listes (« mes amis qui participent à… ») et la confiance.
|
||||
- **Déclarant d'un événement** — l'utilisateur qui a inséré l'événement dans Festipod. *N'est pas (forcément) un organisateur* de l'événement réel : c'est juste quelqu'un qui le référence pour que d'autres puissent y attacher des points de rencontre.
|
||||
- **Hôte d'un point de rencontre** — l'utilisateur qui a créé un point de rencontre rattaché à un événement.
|
||||
- **Inscrit à un point de rencontre** — un utilisateur qui s'est inscrit à un point de rencontre. De fait, il devient participant à l'événement parent.
|
||||
- **Membre d'une communauté d'intérêt** — un utilisateur abonné à une communauté pour découvrir les événements qu'elle référence.
|
||||
|
||||
### Concepts métier
|
||||
|
||||
- **Point de rencontre** — *l'unité de valeur de l'app*. Un moment de rencontre proposé par un hôte à un endroit et à un horaire donnés, greffé sur un événement public. C'est ce à quoi on s'inscrit (on ne s'inscrit pas à un événement). Sans points de rencontre, un événement Festipod n'a pas d'intérêt.
|
||||
- **Événement** — l'ancrage. Un événement public réel (festival, conférence, salon, exposition…) référencé dans Festipod pour servir de support à des points de rencontre. C'est simplement un *prétexte* (titre, dates, lieu, thèmes) ; le déclarant n'est pas l'organisateur officiel de l'événement, juste celui qui l'a inscrit dans Festipod.
|
||||
- **Communauté d'intérêt** — un groupement thématique d'utilisateurs. Sert principalement à découvrir des événements (via abonnement) et à délimiter les périmètres de référencement.
|
||||
- **Liste curated** — une liste d'événements éditorialisée (par un utilisateur ou une communauté), distincte de « les événements que j'ai déclarés » ou « les événements de la communauté ». Permet d'organiser/recommander.
|
||||
- **Connexion** — lien de confiance entre deux utilisateurs (équivalent « ami »).
|
||||
|
||||
### Fonctionnalités actuelles
|
||||
|
||||
Implémentées dans le code (écrans visibles via le router) :
|
||||
|
||||
- Authentification via wallet NextGraph
|
||||
- Cycle de vie d'événement (déclaration, consultation, mise à jour)
|
||||
- Cycle de vie de point de rencontre (rattaché à un événement)
|
||||
- Inscription / désinscription à un point de rencontre
|
||||
- Liste des participants à un événement
|
||||
- Profil utilisateur, mise à jour, partage de profil
|
||||
- Liste d'amis (connexions)
|
||||
- Profil d'un autre utilisateur
|
||||
|
||||
Voir le tableau des routes dans [AGENTS.md](./AGENTS.md#routing) et l'inventaire des écrans dans [.project/knowledge/screens.md](./.project/knowledge/screens.md).
|
||||
|
||||
### Défis ouverts
|
||||
|
||||
- **Déduplication des événements en infra décentralisée.** NextGraph étant P2P, rien n'empêche deux utilisateurs de déclarer indépendamment le même événement public (par ex. « Eurockéennes 2027 ») et de produire deux entrées distinctes. La dispersion qui en résulte fragmente les points de rencontre greffés et réduit leur visibilité — ce qui va à l'encontre de la fonction première de l'app. Pistes envisagées, non tranchées :
|
||||
- proposer à l'utilisateur, lors de la déclaration, les événements déjà déclarés dans son réseau / ses communautés qui correspondent à sa saisie (recherche avant création) ;
|
||||
- utiliser un identifiant externe canonique (URL officielle de l'événement, Wikidata, schema.org/Event) pour reconnaître les doublons et les présenter comme un seul événement à l'affichage ;
|
||||
- laisser des curators (humains ou communautaires) fusionner / vetter les entrées canoniques.
|
||||
|
||||
### Évolutions à venir
|
||||
|
||||
Identifiées comme nécessaires (notamment pour la scalabilité et la découverte) mais pas encore implémentées :
|
||||
|
||||
- **Abonnement à une communauté d'intérêt** pour découvrir ses événements (mécanisme de discovery distribué).
|
||||
- **Abonnement à un utilisateur** pour suivre les événements qu'il déclare (sans nécessairement être ami).
|
||||
- **Listes curated** — créer et partager des sélections d'événements éditorialisées.
|
||||
- **Multi-utilisateurs collaboratif** : aujourd'hui chaque utilisateur a ses données isolées dans son wallet. Le passage en mode collaboratif (un point de rencontre vu par plusieurs personnes) suppose un refactor de la couche données vers les Group stores NextGraph. Voir [brief multi-store-refactor](./.project/briefs/multi-store-refactor.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev
|
||||
bun run dev # Dev server avec HMR (port 3000)
|
||||
```
|
||||
|
||||
Open http://localhost:3000
|
||||
|
||||
## Navigation
|
||||
|
||||
| Page | Route | Description |
|
||||
|------|-------|-------------|
|
||||
| Gallery | `#/` | Browse all mockup screens |
|
||||
| Demo | `#/demo/{screen}` | Interactive screen preview |
|
||||
| Stories | `#/stories` | User stories browser |
|
||||
| Specs | `#/specs` | BDD specifications with test status |
|
||||
|
||||
## Commands
|
||||
## Commandes utiles
|
||||
|
||||
```bash
|
||||
bun run dev # Start dev server with HMR
|
||||
bun run test:cucumber # Run Cucumber tests
|
||||
bun run features:parse # Regenerate features from .feature files
|
||||
bun run steps:extract # Extract step definitions
|
||||
bun run build # Build production vers dist/
|
||||
bun run storybook # Parcourir écrans et composants
|
||||
bun run test:cucumber # Tests BDD
|
||||
bun run features:parse # Régénérer features.ts depuis les .feature
|
||||
bun run steps:extract # Extraire les step definitions pour les tooltips
|
||||
bun run build:orm # Régénérer l'ORM depuis les SHEX shapes
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
See [docs/](./docs/) for detailed documentation:
|
||||
|
||||
- [Festipod App](./docs/festipod-app.md) - Mobile app design
|
||||
- [Prototyping Tool](./docs/prototyping-tool.md) - Web app architecture
|
||||
- [Cucumber Integration](./docs/cucumber-integration.md) - BDD testing setup
|
||||
- [AGENTS.md](./AGENTS.md) — architecture, routes, points d'entrée pour contribuer
|
||||
- [.project/knowledge/](./.project/knowledge/) — comment les choses fonctionnent (data layer, BDD, écrans…)
|
||||
- [.project/decisions/](./.project/decisions/) — choix techniques figés
|
||||
- [.project/briefs/](./.project/briefs/) — chantiers à venir, recherche préparatoire
|
||||
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"default": {
|
||||
"import": [
|
||||
"features/support/**/*.ts",
|
||||
"features/step_definitions/**/*.ts"
|
||||
"src/shared/support/**/*.ts",
|
||||
"src/shared/steps/**/*.ts",
|
||||
"src/modules/*/steps/**/*.ts"
|
||||
],
|
||||
"paths": ["features/**/*.feature"],
|
||||
"paths": ["src/modules/*/features/**/*.feature"],
|
||||
"format": [
|
||||
"progress-bar",
|
||||
"json:reports/cucumber-report.json",
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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é
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Given, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../support/world';
|
||||
|
||||
Given('l\'écran {string} est affiché', async function (this: FestipodWorld, screenName: string) {
|
||||
const screenId = screenName.toLowerCase().replace(/ /g, '-');
|
||||
this.navigateTo(`#/demo/${screenId}`);
|
||||
});
|
||||
|
||||
Given('le formulaire de création est vide', async function (this: FestipodWorld) {
|
||||
this.formFields.forEach((field, key) => {
|
||||
this.formFields.set(key, { ...field, value: '' });
|
||||
});
|
||||
});
|
||||
|
||||
// Steps removed: Form interaction steps (je remplis le champ, je laisse le champ vide, je soumets le formulaire)
|
||||
// require browser automation. Scenarios needing these use "* Scénario non implémenté" placeholder.
|
||||
|
||||
Then('le formulaire contient le champ obligatoire {string}', async function (this: FestipodWorld, fieldName: string) {
|
||||
// This step is for form screens only (create-event)
|
||||
expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');
|
||||
const source = this.getRenderedText();
|
||||
// CreateEventScreen.tsx: Required fields have " *" after label: >Label *<
|
||||
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) {
|
||||
// This step is for form screens only (create-event)
|
||||
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) => {
|
||||
// CreateEventScreen.tsx: Required fields have " *" after label: >Label *<
|
||||
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 champ {string} est facultatif', async function (this: FestipodWorld, fieldName: string) {
|
||||
const source = this.getRenderedText();
|
||||
// Optional fields have label without " *": >Label< followed by Input
|
||||
const escapedName = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// Check field exists but NOT marked as required
|
||||
const existsPattern = new RegExp(`>${escapedName}<`);
|
||||
const requiredPattern = new RegExp(`>${escapedName}\\s*\\*<`);
|
||||
expect(existsPattern.test(source), `Field "${fieldName}" should exist in screen`).to.be.true;
|
||||
expect(requiredPattern.test(source), `Field "${fieldName}" should NOT be marked as required`).to.be.false;
|
||||
});
|
||||
|
||||
Then('le champ {string} est présent', async function (this: FestipodWorld, fieldName: string) {
|
||||
const source = this.getRenderedText();
|
||||
// Check that field label exists in screen source
|
||||
const escapedName = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`>${escapedName}[^<]*<`);
|
||||
expect(pattern.test(source), `Field "${fieldName}" should be present in screen`).to.be.true;
|
||||
});
|
||||
|
||||
// Steps removed: Form display/validation steps (le champ affiche, erreur de validation, formulaire affiche N champs)
|
||||
// require browser automation. Scenarios needing these use "* Scénario non implémenté" placeholder.
|
||||
@@ -1,192 +0,0 @@
|
||||
import { Given, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../support/world';
|
||||
|
||||
Then('je peux voir la liste des participants', async function (this: FestipodWorld) {
|
||||
expect(this.currentScreenId).to.equal('event-detail');
|
||||
const source = this.getRenderedText();
|
||||
// EventDetailScreen.tsx has: <Avatar components and "Participants (12)" text
|
||||
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();
|
||||
// EventDetailScreen.tsx has: <Title>, 📅, 🕓, 📍 emojis, and "À propos" section
|
||||
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 section {string}', async function (this: FestipodWorld, sectionName: string) {
|
||||
const source = this.getRenderedText();
|
||||
// Detect section by text search
|
||||
const found = source.includes(sectionName);
|
||||
if (!found) {
|
||||
this.attach(`Looking for section: "${sectionName}"`, 'text/plain');
|
||||
this.attach(`Rendered text: ${source.substring(0, 500)}...`, 'text/plain');
|
||||
}
|
||||
expect(found, `Section "${sectionName}" should be visible on screen`).to.be.true;
|
||||
});
|
||||
|
||||
// Step removed: "la page affiche N éléments" requires browser automation.
|
||||
// Scenarios needing this use "* Scénario non implémenté" placeholder.
|
||||
|
||||
Then('je peux voir mon profil', async function (this: FestipodWorld) {
|
||||
expect(this.currentScreenId).to.equal('profile');
|
||||
const source = this.getRenderedText();
|
||||
// ProfileScreen.tsx has: <Avatar initials="MD" size="lg" />, <Title>Marie Dupont</Title>, @mariedupont
|
||||
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();
|
||||
// UserProfileScreen.tsx has: <Avatar initials="JD" size="lg" />, <Title>Jean Durand</Title>, @jeandurand
|
||||
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('je peux voir la liste des événements', async function (this: FestipodWorld) {
|
||||
const source = this.getRenderedText();
|
||||
if (this.currentScreenId === 'home') {
|
||||
// HomeScreen.tsx has: "Événements à venir" text and EventCard components
|
||||
expect(/Mes événements à venir/.test(source), 'Home screen should have "Événements à venir" text').to.be.true;
|
||||
} else if (this.currentScreenId === 'events') {
|
||||
// EventsScreen.tsx has: EventCard components with event data
|
||||
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();
|
||||
// HomeScreen.tsx and EventsScreen.tsx EventCard components display location as:
|
||||
// 📍 <span className="user-content">{location}</span>
|
||||
// Check that there's actual location text after the emoji
|
||||
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 voir le QR code', async function (this: FestipodWorld) {
|
||||
const source = this.getRenderedText();
|
||||
if (this.currentScreenId === 'share-profile') {
|
||||
// ShareProfileScreen.tsx has: "QR Code" comment and "Scannez pour me retrouver" text
|
||||
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') {
|
||||
// MeetingPointsScreen.tsx has: "Mon QR Code" text and "Scannez pour m'ajouter"
|
||||
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();
|
||||
// ShareProfileScreen.tsx has: "Mon lien de profil" text and profileLink variable
|
||||
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;
|
||||
});
|
||||
|
||||
// Steps removed: Data setup steps (un événement existe avec les données, un utilisateur existe avec les données)
|
||||
// require backend/database. Scenarios needing these use "* Scénario non implémenté" placeholder.
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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('l\'écran affiche les informations de l\'événement', async function (this: FestipodWorld) {
|
||||
expect(this.currentScreenId).to.equal('event-detail');
|
||||
const source = this.getRenderedText();
|
||||
// EventDetailScreen.tsx has: <Title>, 📅, 🕓, 📍 emojis, and "À propos" section
|
||||
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 du profil', async function (this: FestipodWorld) {
|
||||
const source = this.getRenderedText();
|
||||
if (this.currentScreenId === 'profile') {
|
||||
// ProfileScreen.tsx has: <Avatar initials="MD" size="lg" />, <Title>Marie Dupont</Title>, @mariedupont
|
||||
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') {
|
||||
// UserProfileScreen.tsx has: <Avatar initials="JD" size="lg" />, <Title>Jean Durand</Title>, @jeandurand
|
||||
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`);
|
||||
}
|
||||
});
|
||||
|
||||
// Steps removed: Feature steps not implemented in UI (commentaire, note, filtrer par période, modifier/supprimer commentaire)
|
||||
// Scenarios needing these use "* Scénario non implémenté" placeholder.
|
||||
|
||||
Then('je peux m\'inscrire à l\'événement', async function (this: FestipodWorld) {
|
||||
expect(this.currentScreenId).to.equal('event-detail');
|
||||
const source = this.getRenderedText();
|
||||
// EventDetailScreen.tsx line 49: {isJoined ? '✓ Inscrit' : 'Participer'}
|
||||
// The button shows "Participer" when not joined
|
||||
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();
|
||||
// EventDetailScreen.tsx line 49: {isJoined ? '✓ Inscrit' : 'Participer'}
|
||||
// Same button toggles - clicking "✓ Inscrit" will unregister
|
||||
const hasInscritButton = /isJoined \? '✓ Inscrit' : 'Participer'/.test(source);
|
||||
expect(hasInscritButton, 'Event detail should have Participer/Inscrit toggle button (click to unregister)').to.be.true;
|
||||
});
|
||||
|
||||
Then('je peux contacter l\'utilisateur', async function (this: FestipodWorld) {
|
||||
expect(this.currentScreenId).to.equal('user-profile');
|
||||
const source = this.getRenderedText();
|
||||
// UserProfileScreen.tsx line 44: <Button>Contacter</Button>
|
||||
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();
|
||||
// UserProfileScreen.tsx: "Événements à venir" and "Événements passés" sections
|
||||
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('je peux configurer mes notifications', async function (this: FestipodWorld) {
|
||||
expect(this.currentScreenId).to.equal('settings');
|
||||
const source = this.getRenderedText();
|
||||
// SettingsScreen.tsx line 25: <Text>Notifications</Text> with Toggle
|
||||
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;
|
||||
});
|
||||
|
||||
// Steps removed: Settings features not implemented in UI (rayon de notification, thématiques d'intérêt)
|
||||
// Scenarios needing these use "* Scénario non implémenté" placeholder.
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Before, After, BeforeAll, AfterAll, Status } from '@cucumber/cucumber';
|
||||
import type { FestipodWorld } from './world';
|
||||
|
||||
BeforeAll(async function () {
|
||||
console.log('Starting Festipod BDD tests...');
|
||||
});
|
||||
|
||||
Before(async function (this: FestipodWorld, scenario) {
|
||||
this.currentRoute = '#/';
|
||||
this.currentScreenId = null;
|
||||
this.formFields.clear();
|
||||
this.navigationHistory = [];
|
||||
this.isAuthenticated = false;
|
||||
this.screenSourceContent = '';
|
||||
this.currentScreen = null;
|
||||
|
||||
// Skipped scenarios use the "* Scénario non implémenté" placeholder step
|
||||
// which returns 'skipped' - no special handling needed in the hook
|
||||
});
|
||||
|
||||
After(async function (this: FestipodWorld, scenario) {
|
||||
if (scenario.result?.status === Status.FAILED) {
|
||||
this.attach(`Current route: ${this.currentRoute}`, 'text/plain');
|
||||
this.attach(`Current screen: ${this.currentScreenId}`, 'text/plain');
|
||||
this.attach(`Navigation history: ${JSON.stringify(this.navigationHistory)}`, 'text/plain');
|
||||
this.attach(`Form fields: ${JSON.stringify(Array.from(this.formFields.entries()))}`, 'text/plain');
|
||||
if (this.screenSourceContent) {
|
||||
// Show first 500 chars of source to help debug
|
||||
this.attach(`Screen source (first 500 chars): ${this.screenSourceContent.substring(0, 500)}...`, 'text/plain');
|
||||
}
|
||||
}
|
||||
// Clean up
|
||||
this.cleanup();
|
||||
});
|
||||
|
||||
AfterAll(async function () {
|
||||
console.log('Festipod BDD tests completed.');
|
||||
});
|
||||
+21
-4
@@ -1,19 +1,29 @@
|
||||
{
|
||||
"name": "bun-react-template",
|
||||
"name": "festipod",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --hot src/index.ts",
|
||||
"dev": "portless festipod bun --hot src/index.ts",
|
||||
"start": "NODE_ENV=production bun src/index.ts",
|
||||
"build": "bun run build.ts",
|
||||
"test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract",
|
||||
"cucumber:run": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json",
|
||||
"test:data": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json --tags @data",
|
||||
"test:auth-setup": "bun scripts/setup-test-auth.ts",
|
||||
"cucumber:report": "bun scripts/parse-test-results.ts",
|
||||
"features:parse": "bun scripts/parse-features.ts",
|
||||
"steps:extract": "bun scripts/extract-step-definitions.ts"
|
||||
"steps:extract": "bun scripts/extract-step-definitions.ts",
|
||||
"build:orm": "rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm",
|
||||
"build:ng": "bash scripts/build-ng-packages.sh",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
|
||||
"@ng-org/orm": "0.1.2-alpha.18",
|
||||
"@ng-org/shex-orm": "0.1.2-alpha.8",
|
||||
"@ng-org/web": "0.1.2-alpha.13",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
@@ -36,8 +46,15 @@
|
||||
"@types/react-dom": "^19",
|
||||
"chai": "^6.2.2",
|
||||
"happy-dom": "^16.6.0",
|
||||
"playwright": "^1.58.2",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"storybook": "^10.3.5",
|
||||
"@storybook/react-webpack5": "^10.3.5",
|
||||
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
|
||||
"@storybook/addon-a11y": "^10.3.5",
|
||||
"@storybook/addon-docs": "^10.3.5",
|
||||
"@storybook/addon-onboarding": "^10.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
diff --git a/node_modules/@ng-org/orm/.bun-tag-78937f1a8bb90c1e b/.bun-tag-78937f1a8bb90c1e
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/package.json b/package.json
|
||||
index 33023505142d0c7f1dfe9861e8f2adce15ffa2e0..226fd1f6eff39fc6891d8dd5ac2bade3670cae66 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -30,36 +30,47 @@
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
- "@astrojs/react": "4.3.0",
|
||||
- "@astrojs/svelte": "7.1.0",
|
||||
- "@astrojs/vue": "^5.1.0",
|
||||
- "@gn8/alien-signals-react": "^0.1.1",
|
||||
- "@gn8/alien-signals-solid": "^0.1.1",
|
||||
- "@gn8/alien-signals-svelte": "^0.1.1",
|
||||
- "@gn8/alien-signals-vue": "^0.1.1",
|
||||
- "@types/react": "19.1.10",
|
||||
- "@types/react-dom": "19.1.7",
|
||||
- "@types/shexj": "^2.1.7",
|
||||
"alien-signals": "^2.0.7",
|
||||
- "astro": "5.13.2",
|
||||
- "install": "^0.13.0",
|
||||
- "npm": "^11.5.2",
|
||||
- "prettier-eslint": "^16.4.2",
|
||||
- "react": "19.1.1",
|
||||
- "react-dom": "19.1.1",
|
||||
- "svelte": "5.39.12",
|
||||
- "vue": "3.5.19",
|
||||
"@ng-org/shex-orm": "0.1.2-alpha.2",
|
||||
- "@ng-org/alien-deepsignals": "0.1.2-alpha.3"
|
||||
+ "@ng-org/alien-deepsignals": "0.1.2-alpha.3",
|
||||
+ "@types/shexj": "^2.1.7"
|
||||
+ },
|
||||
+ "peerDependencies": {
|
||||
+ "react": ">=18",
|
||||
+ "react-dom": ">=18",
|
||||
+ "svelte": ">=4",
|
||||
+ "vue": ">=3",
|
||||
+ "@gn8/alien-signals-react": "^0.1.1",
|
||||
+ "@gn8/alien-signals-svelte": "^0.1.1",
|
||||
+ "@gn8/alien-signals-vue": "^0.1.1"
|
||||
+ },
|
||||
+ "peerDependenciesMeta": {
|
||||
+ "react": { "optional": true },
|
||||
+ "react-dom": { "optional": true },
|
||||
+ "svelte": { "optional": true },
|
||||
+ "vue": { "optional": true },
|
||||
+ "@gn8/alien-signals-react": { "optional": true },
|
||||
+ "@gn8/alien-signals-svelte": { "optional": true },
|
||||
+ "@gn8/alien-signals-vue": { "optional": true }
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@types/node": "24.3.0",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-dom": "19.1.7",
|
||||
+ "svelte": "5.39.12",
|
||||
+ "vue": "3.5.19",
|
||||
+ "astro": "5.13.2",
|
||||
+ "@astrojs/react": "4.3.0",
|
||||
+ "@astrojs/svelte": "7.1.0",
|
||||
+ "@astrojs/vue": "^5.1.0",
|
||||
+ "@gn8/alien-signals-react": "^0.1.1",
|
||||
+ "@gn8/alien-signals-svelte": "^0.1.1",
|
||||
+ "@gn8/alien-signals-vue": "^0.1.1",
|
||||
"vite": "7.1.3",
|
||||
"vitest": "^3.2.4",
|
||||
"typescript": "^5.3.0",
|
||||
+ "prettier-eslint": "^16.4.2",
|
||||
"@ng-org/lib-wasm": "0.1.2-alpha.1"
|
||||
},
|
||||
"files": [
|
||||
File diff suppressed because one or more lines are too long
+1341
-1370
File diff suppressed because it is too large
Load Diff
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Build and install @ng-org packages from local nextgraph-rs repo.
|
||||
#
|
||||
# This script:
|
||||
# 1. git pull on nextgraph-rs
|
||||
# 2. Installs monorepo deps (pnpm install)
|
||||
# 3. Builds the 4 packages we need (tsc / vite)
|
||||
# 4. Packs them into tarballs (applies publishConfig -> dist/)
|
||||
# 5. Installs the tarballs in festipod and updates package.json versions
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/build-ng-packages.sh
|
||||
#
|
||||
# Run this after any nextgraph-rs update, or as needed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
FESTIPOD_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
NEXTGRAPH_RS="${NEXTGRAPH_RS:-$(cd "$FESTIPOD_DIR/../../nextgraph/nextgraph-rs" && pwd)}"
|
||||
SDK_JS="$NEXTGRAPH_RS/sdk/js"
|
||||
TARBALLS_DIR="$FESTIPOD_DIR/.ng-tarballs"
|
||||
|
||||
# Packages to build (in dependency order)
|
||||
PACKAGES=(alien-deepsignals shex-orm web orm)
|
||||
|
||||
echo "=== @ng-org local build ==="
|
||||
echo " nextgraph-rs: $NEXTGRAPH_RS"
|
||||
echo " festipod: $FESTIPOD_DIR"
|
||||
echo ""
|
||||
|
||||
# --- Step 0: git pull ---
|
||||
echo "[0/5] Pulling latest nextgraph-rs..."
|
||||
cd "$NEXTGRAPH_RS"
|
||||
git pull --ff-only || echo " WARN: git pull failed (maybe uncommitted changes?) — continuing with current state"
|
||||
echo ""
|
||||
|
||||
# --- Step 1: Ensure lib-wasm/pkg stub exists ---
|
||||
LIB_WASM_PKG="$SDK_JS/lib-wasm/pkg"
|
||||
if [ ! -f "$LIB_WASM_PKG/package.json" ]; then
|
||||
echo "[1/5] Creating lib-wasm/pkg stub (type-only dependency)..."
|
||||
mkdir -p "$LIB_WASM_PKG"
|
||||
cat > "$LIB_WASM_PKG/package.json" << 'STUBEOF'
|
||||
{
|
||||
"name": "@ng-org/lib-wasm",
|
||||
"version": "0.0.0-stub",
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"types": "./index.d.ts"
|
||||
}
|
||||
STUBEOF
|
||||
echo "export {};" > "$LIB_WASM_PKG/index.js"
|
||||
# Wildcard type stub: all methods are accepted via index signature
|
||||
cat > "$LIB_WASM_PKG/index.d.ts" << 'DTSEOF'
|
||||
// Stub types for @ng-org/lib-wasm (real build requires Rust/WASM)
|
||||
export declare function orm_start_graph(...args: any[]): any;
|
||||
export declare function orm_start_discrete(...args: any[]): any;
|
||||
export declare function graph_orm_update(...args: any[]): any;
|
||||
export declare function discrete_orm_update(...args: any[]): any;
|
||||
export declare function doc_create(...args: any[]): any;
|
||||
export declare function doc_subscribe(...args: any[]): any;
|
||||
export declare function file_get(...args: any[]): any;
|
||||
export declare function app_request_stream(...args: any[]): any;
|
||||
// Catch-all for any other methods
|
||||
declare const _extra: { [key: string]: (...args: any[]) => any };
|
||||
export default _extra;
|
||||
DTSEOF
|
||||
else
|
||||
echo "[1/5] lib-wasm/pkg stub already exists"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# --- Step 2: Install monorepo deps ---
|
||||
echo "[2/5] Installing monorepo dependencies..."
|
||||
cd "$NEXTGRAPH_RS"
|
||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||
echo ""
|
||||
|
||||
# --- Step 3: Build each package ---
|
||||
echo "[3/5] Building packages..."
|
||||
for pkg in "${PACKAGES[@]}"; do
|
||||
PKG_DIR="$SDK_JS/$pkg"
|
||||
if [ ! -d "$PKG_DIR" ]; then
|
||||
echo " SKIP $pkg (directory not found)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if there's a build script
|
||||
HAS_BUILD=$(node -e "const p=require('$PKG_DIR/package.json'); process.stdout.write(p.scripts?.build ? '1' : '0')")
|
||||
if [ "$HAS_BUILD" = "0" ]; then
|
||||
# Try build:ts (orm, alien-deepsignals)
|
||||
HAS_BUILD_TS=$(node -e "const p=require('$PKG_DIR/package.json'); process.stdout.write(p.scripts?.['build:ts'] ? '1' : '0')")
|
||||
if [ "$HAS_BUILD_TS" = "1" ]; then
|
||||
echo " Building $pkg (build:ts)..."
|
||||
cd "$PKG_DIR"
|
||||
pnpm run build:ts
|
||||
else
|
||||
echo " SKIP $pkg (no build script)"
|
||||
fi
|
||||
else
|
||||
echo " Building $pkg (build)..."
|
||||
cd "$PKG_DIR"
|
||||
pnpm run build
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# --- Step 4: Pack each package ---
|
||||
echo "[4/5] Packing tarballs..."
|
||||
rm -rf "$TARBALLS_DIR"
|
||||
mkdir -p "$TARBALLS_DIR"
|
||||
|
||||
for pkg in "${PACKAGES[@]}"; do
|
||||
PKG_DIR="$SDK_JS/$pkg"
|
||||
if [ ! -d "$PKG_DIR/dist" ]; then
|
||||
echo " WARN: $pkg has no dist/ — skipping pack"
|
||||
continue
|
||||
fi
|
||||
cd "$PKG_DIR"
|
||||
TARBALL=$(pnpm pack --pack-destination "$TARBALLS_DIR" 2>/dev/null | tail -1)
|
||||
echo " Packed $pkg → $(basename "$TARBALL")"
|
||||
done
|
||||
echo ""
|
||||
|
||||
# --- Step 5: Install tarballs in festipod (one by one, in dependency order) ---
|
||||
echo "[5/5] Installing in festipod..."
|
||||
cd "$FESTIPOD_DIR"
|
||||
|
||||
# Install one by one to avoid Bun's dependency loop detection issue
|
||||
for pkg in "${PACKAGES[@]}"; do
|
||||
TGZ=$(ls "$TARBALLS_DIR"/ng-org-${pkg}-*.tgz 2>/dev/null | head -1)
|
||||
if [ -z "$TGZ" ]; then
|
||||
echo " WARN: no tarball for $pkg"
|
||||
continue
|
||||
fi
|
||||
echo " Installing @ng-org/$pkg..."
|
||||
bun add "$TGZ"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Done! All @ng-org packages installed from local build ==="
|
||||
echo ""
|
||||
echo "Installed versions:"
|
||||
for pkg in "${PACKAGES[@]}"; do
|
||||
VERSION=$(node -e "try{const p=require('$FESTIPOD_DIR/node_modules/@ng-org/$pkg/package.json');console.log(p.version)}catch{console.log('not found')}")
|
||||
echo " @ng-org/$pkg: $VERSION"
|
||||
done
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Manual debug tool: launches a headed Chromium to inspect NextGraph broker interactions.
|
||||
* Creates a temporary profile in `.playwright-profile-debug/` (gitignored).
|
||||
*
|
||||
* Usage: bun scripts/debug-browser.ts
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Browser launched. Navigating to nextgraph.net...');
|
||||
|
||||
page.on('pageerror', (err) => console.log('[pageerror]', err.message));
|
||||
page.on('close', () => console.log('[page closed]'));
|
||||
page.on('crash', () => console.log('[page crashed]'));
|
||||
browser.on('disconnected', () => console.log('[browser disconnected]'));
|
||||
|
||||
try {
|
||||
await page.goto('https://nextgraph.net/redir/#/?o=http%3A%2F%2F127.0.0.1%3A12345', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000,
|
||||
});
|
||||
console.log('Navigation done. Page URL:', page.url());
|
||||
} catch (err: any) {
|
||||
console.log('Navigation error (expected):', err.message);
|
||||
}
|
||||
|
||||
console.log('Waiting... close the browser manually when done.');
|
||||
|
||||
// Keep alive indefinitely
|
||||
await new Promise(() => {});
|
||||
@@ -11,11 +11,23 @@ interface StepDefinition {
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
const stepFiles = [
|
||||
'features/step_definitions/navigation.steps.ts',
|
||||
'features/step_definitions/form.steps.ts',
|
||||
'features/step_definitions/screen.steps.ts',
|
||||
];
|
||||
import { Glob } from 'bun';
|
||||
|
||||
// Discover all step definition files: shared + module-specific
|
||||
function discoverStepFiles(): string[] {
|
||||
const files: string[] = [];
|
||||
// Shared steps
|
||||
for (const f of new Glob('src/shared/steps/**/*.steps.ts').scanSync('.')) {
|
||||
files.push(f);
|
||||
}
|
||||
// Module steps
|
||||
for (const f of new Glob('src/modules/*/steps/**/*.steps.ts').scanSync('.')) {
|
||||
files.push(f);
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
const stepFiles = discoverStepFiles();
|
||||
|
||||
function extractStepDefinitions(): StepDefinition[] {
|
||||
const definitions: StepDefinition[] = [];
|
||||
@@ -115,7 +127,7 @@ export const stepDefinitions: StepDefinitionInfo[] = ${JSON.stringify(definition
|
||||
${findFunctionCode}
|
||||
`;
|
||||
|
||||
await Bun.write('src/data/stepDefinitions.ts', output);
|
||||
await Bun.write('src/shared/data/stepDefinitions.ts', output);
|
||||
console.log(`Generated ${definitions.length} step definitions`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Glob } from 'bun';
|
||||
import type { ParsedFeature, ParsedScenario, ParsedStep } from '../src/types/gherkin';
|
||||
import type { ParsedFeature, ParsedScenario, ParsedStep } from '../src/shared/types/gherkin';
|
||||
|
||||
// Map French screen names to screen IDs (same as navigation.steps.ts)
|
||||
const screenNameMap: Record<string, string> = {
|
||||
@@ -73,7 +73,7 @@ function extractScreenIdsFromSteps(steps: ParsedStep[]): Set<string> {
|
||||
}
|
||||
|
||||
async function parseFeatures(): Promise<ParsedFeature[]> {
|
||||
const glob = new Glob('features/**/*.feature');
|
||||
const glob = new Glob('src/modules/*/features/**/*.feature');
|
||||
const features: ParsedFeature[] = [];
|
||||
|
||||
for await (const filePath of glob.scan('.')) {
|
||||
@@ -116,7 +116,7 @@ export function getAllPriorities(): number[] {
|
||||
}
|
||||
`;
|
||||
|
||||
await Bun.write('src/data/features.ts', output);
|
||||
await Bun.write('src/shared/data/features.ts', output);
|
||||
console.log(`Parsed ${features.length} feature files`);
|
||||
return features;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FeatureTestStatus, ScenarioTestResult } from '../src/types/gherkin';
|
||||
import type { FeatureTestStatus, ScenarioTestResult } from '../src/shared/types/gherkin';
|
||||
|
||||
interface CucumberScenario {
|
||||
id: string;
|
||||
@@ -137,7 +137,7 @@ export function getTestSummary() {
|
||||
}
|
||||
`;
|
||||
|
||||
await Bun.write('src/data/testResults.ts', output);
|
||||
await Bun.write('src/shared/data/testResults.ts', output);
|
||||
console.log(`Generated test results for ${results.size} features`);
|
||||
|
||||
// Print summary
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* One-time auth setup for data-layer testing.
|
||||
*
|
||||
* Two-step flow:
|
||||
* Step 1: Opens nextgraph.net so you can create/import a wallet
|
||||
* Step 2: Navigates to the broker redirect URL to authenticate the test app
|
||||
*
|
||||
* Saves auth state to playwright/.auth/ng-state.json for reuse by test runs.
|
||||
*
|
||||
* Usage: bun run test:auth-setup
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import * as http from 'http';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as readline from 'readline';
|
||||
|
||||
const AUTH_STATE_PATH = path.resolve('playwright/.auth/ng-state.json');
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const SETUP_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>Festipod Test Auth Setup</title></head>
|
||||
<body style="font-family: system-ui; padding: 2rem; text-align: center;">
|
||||
<h2>Festipod — Test Auth Setup</h2>
|
||||
<p id="status">Waiting for NextGraph session...</p>
|
||||
<script type="module">
|
||||
import { init, ng } from "@ng-org/web";
|
||||
|
||||
await init(
|
||||
async (event) => {
|
||||
document.getElementById("status").innerHTML =
|
||||
'<span style="color: green; font-size: 1.5rem;">✓ Logged in!</span>' +
|
||||
'<br><br>You can now close this browser window.';
|
||||
console.log("[auth-setup] Session established:", event.session?.session_id);
|
||||
},
|
||||
true,
|
||||
[]
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
async function main() {
|
||||
console.log('=== NextGraph Auth Setup (2 steps) ===');
|
||||
console.log('');
|
||||
|
||||
// ---- Step 1: Create / open wallet ----
|
||||
console.log('STEP 1: Create or open your NextGraph wallet');
|
||||
console.log('A browser will open at nextgraph.net.');
|
||||
console.log('Create a wallet (or open an existing one), then come back here.');
|
||||
console.log('');
|
||||
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('https://nextgraph.net', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await prompt('Press ENTER here once your wallet is ready...');
|
||||
|
||||
// ---- Step 2: Authenticate the test app via broker redirect ----
|
||||
console.log('');
|
||||
console.log('STEP 2: Authenticating test app via broker...');
|
||||
|
||||
// Start a minimal HTTP server
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(SETUP_HTML);
|
||||
});
|
||||
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve((server.address() as { port: number }).port);
|
||||
});
|
||||
});
|
||||
|
||||
const appUrl = `http://127.0.0.1:${port}`;
|
||||
const brokerUrl = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
|
||||
|
||||
// Navigate the same page to the broker redirect
|
||||
await page.goto(brokerUrl, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
console.log('The broker should now show your wallet login.');
|
||||
console.log('Log in, wait for "✓ Logged in!", then close the browser.');
|
||||
console.log('');
|
||||
|
||||
// Wait for browser close
|
||||
await new Promise<void>((resolve) => {
|
||||
page.on('close', () => resolve());
|
||||
browser.on('disconnected', () => resolve());
|
||||
});
|
||||
|
||||
// Save storage state
|
||||
try {
|
||||
const state = await context.storageState();
|
||||
fs.mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true });
|
||||
fs.writeFileSync(AUTH_STATE_PATH, JSON.stringify(state, null, 2));
|
||||
console.log(`\nAuth state saved to: ${AUTH_STATE_PATH}`);
|
||||
console.log('You can now run: bun run test:data');
|
||||
} catch {
|
||||
console.log('\nCould not save auth state — browser may have closed too quickly.');
|
||||
}
|
||||
|
||||
try { await context.close(); } catch {}
|
||||
try { await browser.close(); } catch {}
|
||||
server.close();
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
import React from 'react';
|
||||
import { RouterProvider, useRouter } from './router';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { Gallery } from './components/Gallery';
|
||||
import { DemoMode } from './components/DemoMode';
|
||||
import { SpecsPage } from './components/specs';
|
||||
|
||||
function AppContent() {
|
||||
const { route, navigate, goBack } = useRouter();
|
||||
|
||||
if (route.page === 'demo') {
|
||||
return (
|
||||
<DemoMode
|
||||
initialScreenId={route.screenId}
|
||||
onBack={goBack}
|
||||
onNavigateToStory={(storyId) => navigate({ page: 'specs', storyId })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (route.page === 'specs') {
|
||||
return (
|
||||
<SpecsPage
|
||||
selectedFeatureId={route.featureId}
|
||||
selectedStoryId={route.storyId}
|
||||
onBack={goBack}
|
||||
onSelectScreen={(screenId) => navigate({ page: 'demo', screenId })}
|
||||
onSelectStory={(storyId) => navigate({ page: 'specs', storyId })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Gallery
|
||||
onSelectScreen={(screenId) => navigate({ page: 'demo', screenId })}
|
||||
onShowSpecs={() => navigate({ page: 'specs' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<RouterProvider>
|
||||
<AppContent />
|
||||
</RouterProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { RouterProvider, useRouter } from './router';
|
||||
import { ThemeProvider } from '../shared/context/ThemeContext';
|
||||
import { NextGraphProvider } from '../shared/context/NextGraphContext';
|
||||
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
|
||||
import { ToastContainer } from '../shared/components/sketchy';
|
||||
|
||||
// Auth
|
||||
import { WelcomeScreen } from '../modules/auth/screens/WelcomeScreen';
|
||||
import { LoginScreen } from '../modules/auth/screens/LoginScreen';
|
||||
|
||||
// Home
|
||||
import { HomeScreen } from '../modules/home/screens/HomeScreen';
|
||||
import { SettingsScreen } from '../modules/home/screens/SettingsScreen';
|
||||
|
||||
// Event
|
||||
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
|
||||
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';
|
||||
import { ConnectScreen } from '../modules/user/screens/ConnectScreen';
|
||||
|
||||
function AppContent() {
|
||||
const { route } = useRouter();
|
||||
|
||||
switch (route.page) {
|
||||
case 'welcome': return <WelcomeScreen />;
|
||||
case 'login': return <LoginScreen />;
|
||||
case 'home': return <HomeScreen />;
|
||||
case 'events': return <EventsScreen />;
|
||||
case 'create-event': return <CreateEventScreen />;
|
||||
case 'event-detail': return <EventDetailScreen />;
|
||||
case 'update-event': return <UpdateEventScreen />;
|
||||
case 'invite': return <InviteScreen />;
|
||||
case 'participants': return <ParticipantsListScreen />;
|
||||
case 'meeting-points': return <MeetingPointsScreen />;
|
||||
case 'profile': return <ProfileScreen />;
|
||||
case 'edit-profile': return <UpdateProfileScreen />;
|
||||
case 'friends': return <FriendsListScreen />;
|
||||
case 'share-profile': return <ShareProfileScreen />;
|
||||
case 'connect': return <ConnectScreen />;
|
||||
case 'user-profile': return <UserProfileScreen />;
|
||||
case 'settings': return <SettingsScreen />;
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<NextGraphProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AppContent />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
</NextGraphProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// ============================================================================
|
||||
// Route types
|
||||
// ============================================================================
|
||||
|
||||
type Route =
|
||||
| { page: 'welcome' }
|
||||
| { page: 'login' }
|
||||
| { page: 'home' }
|
||||
| { page: 'events' }
|
||||
| { page: 'create-event' }
|
||||
| { page: 'event-detail'; eventId: string }
|
||||
| { page: 'update-event'; eventId: string }
|
||||
| { page: 'invite'; eventId: string }
|
||||
| { page: 'participants'; eventId: string }
|
||||
| { page: 'meeting-points'; eventId: string }
|
||||
| { page: 'profile' }
|
||||
| { page: 'edit-profile' }
|
||||
| { page: 'friends' }
|
||||
| { page: 'share-profile' }
|
||||
| { page: 'connect' }
|
||||
| { page: 'user-profile'; userId: string }
|
||||
| { page: 'settings' };
|
||||
|
||||
export type { Route };
|
||||
|
||||
export interface RouteParams {
|
||||
eventId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path parsing & generation
|
||||
// ============================================================================
|
||||
|
||||
function parsePath(pathname: string): Route {
|
||||
const path = pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
if (path === '/' || path === '') return { page: 'welcome' };
|
||||
if (path === '/login') return { page: 'login' };
|
||||
if (path === '/home') return { page: 'home' };
|
||||
if (path === '/events') return { page: 'events' };
|
||||
if (path === '/events/new') return { page: 'create-event' };
|
||||
if (path === '/settings') return { page: 'settings' };
|
||||
if (path === '/profile') return { page: 'profile' };
|
||||
if (path === '/profile/edit') return { page: 'edit-profile' };
|
||||
if (path === '/profile/friends') return { page: 'friends' };
|
||||
if (path === '/profile/share') return { page: 'share-profile' };
|
||||
if (path === '/profile/connect') return { page: 'connect' };
|
||||
|
||||
// /events/:id/...
|
||||
const eventMatch = path.match(/^\/events\/([^/]+)(?:\/(.+))?$/);
|
||||
if (eventMatch) {
|
||||
const eventId = eventMatch[1]!;
|
||||
const sub = eventMatch[2];
|
||||
if (!sub) return { page: 'event-detail', eventId };
|
||||
if (sub === 'edit') return { page: 'update-event', eventId };
|
||||
if (sub === 'invite') return { page: 'invite', eventId };
|
||||
if (sub === 'participants') return { page: 'participants', eventId };
|
||||
if (sub === 'meeting-points') return { page: 'meeting-points', eventId };
|
||||
}
|
||||
|
||||
// /users/:id
|
||||
const userMatch = path.match(/^\/users\/([^/]+)$/);
|
||||
if (userMatch) {
|
||||
return { page: 'user-profile', userId: userMatch[1]! };
|
||||
}
|
||||
|
||||
return { page: 'welcome' };
|
||||
}
|
||||
|
||||
export function routeToPath(route: Route): string {
|
||||
switch (route.page) {
|
||||
case 'welcome': return '/';
|
||||
case 'login': return '/login';
|
||||
case 'home': return '/home';
|
||||
case 'events': return '/events';
|
||||
case 'create-event': return '/events/new';
|
||||
case 'event-detail': return `/events/${route.eventId}`;
|
||||
case 'update-event': return `/events/${route.eventId}/edit`;
|
||||
case 'invite': return `/events/${route.eventId}/invite`;
|
||||
case 'participants': return `/events/${route.eventId}/participants`;
|
||||
case 'meeting-points': return `/events/${route.eventId}/meeting-points`;
|
||||
case 'profile': return '/profile';
|
||||
case 'edit-profile': return '/profile/edit';
|
||||
case 'friends': return '/profile/friends';
|
||||
case 'share-profile': return '/profile/share';
|
||||
case 'connect': return '/profile/connect';
|
||||
case 'user-profile': return `/users/${route.userId}`;
|
||||
case 'settings': return '/settings';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Router context
|
||||
// ============================================================================
|
||||
|
||||
interface RouterContextValue {
|
||||
route: Route;
|
||||
navigate: (path: string) => void;
|
||||
goBack: () => void;
|
||||
params: RouteParams;
|
||||
}
|
||||
|
||||
const RouterContext = createContext<RouterContextValue | null>(null);
|
||||
|
||||
export function RouterProvider({ children }: { children: React.ReactNode }) {
|
||||
const [route, setRoute] = useState<Route>(() => parsePath(window.location.pathname));
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
setRoute(parsePath(window.location.pathname));
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
const navigate = useCallback((path: string) => {
|
||||
window.history.pushState(null, '', path);
|
||||
setRoute(parsePath(path));
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
window.history.back();
|
||||
}, []);
|
||||
|
||||
const params: RouteParams = {};
|
||||
if ('eventId' in route) params.eventId = route.eventId;
|
||||
if ('userId' in route) params.userId = route.userId;
|
||||
|
||||
return (
|
||||
<RouterContext.Provider value={{ route, navigate, goBack, params }}>
|
||||
{children}
|
||||
</RouterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
export function useRouter() {
|
||||
const context = useContext(RouterContext);
|
||||
if (!context) throw new Error('useRouter must be used within a RouterProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useNavigate() {
|
||||
return useRouter().navigate;
|
||||
}
|
||||
|
||||
export function useGoBack() {
|
||||
return useRouter().goBack;
|
||||
}
|
||||
|
||||
export function useParams(): RouteParams {
|
||||
return useRouter().params;
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PhoneFrame } from './sketchy';
|
||||
import { screens, getScreen } from '../screens';
|
||||
import { getStoriesForScreen, categoryLabels, categoryColors, priorityColors } from '../data';
|
||||
import { getStoryUrl } from '../router';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
interface DemoModeProps {
|
||||
initialScreenId: string;
|
||||
onBack: () => void;
|
||||
onNavigateToStory: (storyId: string) => void;
|
||||
}
|
||||
|
||||
export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoModeProps) {
|
||||
const [currentScreenId, setCurrentScreenId] = useState(initialScreenId);
|
||||
const [history, setHistory] = useState<string[]>([initialScreenId]);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const currentScreen = getScreen(currentScreenId);
|
||||
const ScreenComponent = currentScreen?.component;
|
||||
const linkedStories = getStoriesForScreen(currentScreenId);
|
||||
|
||||
const navigate = (screenId: string) => {
|
||||
const newHistory = [...history.slice(0, historyIndex + 1), screenId];
|
||||
setHistory(newHistory);
|
||||
setHistoryIndex(newHistory.length - 1);
|
||||
setCurrentScreenId(screenId);
|
||||
};
|
||||
|
||||
const canGoBack = historyIndex > 0;
|
||||
const canGoForward = historyIndex < history.length - 1;
|
||||
|
||||
const goBack = () => {
|
||||
if (canGoBack) {
|
||||
const newIndex = historyIndex - 1;
|
||||
setHistoryIndex(newIndex);
|
||||
const screenId = history[newIndex];
|
||||
if (screenId) setCurrentScreenId(screenId);
|
||||
}
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
if (canGoForward) {
|
||||
const newIndex = historyIndex + 1;
|
||||
setHistoryIndex(newIndex);
|
||||
const screenId = history[newIndex];
|
||||
if (screenId) setCurrentScreenId(screenId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
height: '100vh',
|
||||
background: 'var(--tool-bg)',
|
||||
overflow: 'hidden',
|
||||
transition: 'background-color 0.2s ease',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* Mobile overlay */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 40,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Left Sidebar */}
|
||||
<div style={{
|
||||
width: 280,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: '2px solid var(--tool-border)',
|
||||
background: 'var(--tool-surface)',
|
||||
transition: 'transform 0.3s ease, background-color 0.2s ease, border-color 0.2s ease',
|
||||
...(isMobile ? {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
zIndex: 50,
|
||||
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)',
|
||||
} : {}),
|
||||
}}>
|
||||
{/* Back button and theme toggle */}
|
||||
<div style={{ padding: 16, borderBottom: '1px solid var(--tool-border-light)', display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 16px',
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
← Galerie
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Current screen & navigation */}
|
||||
<div style={{ padding: 16, borderBottom: '1px solid var(--tool-border-light)' }}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
color: 'var(--tool-text-muted)',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
Écran actuel
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
{currentScreen?.name}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={goBack}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
opacity: canGoBack ? 1 : 0.4,
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
cursor: canGoBack ? 'pointer' : 'default',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
‹ Retour
|
||||
</button>
|
||||
<button
|
||||
onClick={goForward}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
opacity: canGoForward ? 1 : 0.4,
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
cursor: canGoForward ? 'pointer' : 'default',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
disabled={!canGoForward}
|
||||
>
|
||||
Suivant ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Stories for this screen */}
|
||||
{linkedStories.length > 0 && (
|
||||
<div style={{
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
maxHeight: '40%',
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
color: 'var(--tool-text-muted)',
|
||||
padding: '12px 16px 8px',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--tool-surface)',
|
||||
}}>
|
||||
User Stories ({linkedStories.length})
|
||||
</div>
|
||||
{linkedStories.map((story) => (
|
||||
<a
|
||||
key={story.id}
|
||||
href={getStoryUrl(story.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onNavigateToStory(story.id);
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
textDecoration: 'none',
|
||||
color: 'var(--tool-text)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '1px 6px',
|
||||
background: priorityColors[story.priority],
|
||||
color: 'white',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontSize: 9,
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
}}>
|
||||
P{story.priority}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '1px 6px',
|
||||
background: categoryColors[story.category],
|
||||
color: 'white',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontSize: 9,
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
}}>
|
||||
{categoryLabels[story.category]}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{story.title}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screen list */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '8px 0',
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
color: 'var(--tool-text-muted)',
|
||||
padding: '8px 16px',
|
||||
}}>
|
||||
Tous les écrans
|
||||
</div>
|
||||
{screens.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => navigate(s.id)}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
background: s.id === currentScreenId ? 'var(--tool-border-light)' : 'transparent',
|
||||
borderLeft: s.id === currentScreenId ? '3px solid var(--tool-text)' : '3px solid transparent',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone preview area */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Mobile header */}
|
||||
{isMobile && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
background: 'var(--tool-surface)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
☰ Menu
|
||||
</button>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--tool-text)',
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{currentScreen?.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: isMobile ? 12 : 24,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
maxHeight: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
transform: 'scale(var(--phone-scale, 1))',
|
||||
transformOrigin: 'center center',
|
||||
}}>
|
||||
<ScaledPhoneFrame isMobile={isMobile}>
|
||||
{ScreenComponent && <ScreenComponent navigate={navigate} />}
|
||||
</ScaledPhoneFrame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScaledPhoneFrame({ children, isMobile = false }: { children: React.ReactNode; isMobile?: boolean }) {
|
||||
const phoneWidth = 375;
|
||||
const phoneHeight = 812;
|
||||
|
||||
// Calculate scale to fit in viewport with some padding
|
||||
const [scale, setScale] = React.useState(1);
|
||||
|
||||
React.useEffect(() => {
|
||||
const calculateScale = () => {
|
||||
const mobileHeaderHeight = isMobile ? 56 : 0;
|
||||
const padding = isMobile ? 24 : 48;
|
||||
const sidebarWidth = isMobile ? 0 : 280;
|
||||
|
||||
const availableHeight = window.innerHeight - padding - mobileHeaderHeight;
|
||||
const availableWidth = window.innerWidth - sidebarWidth - padding;
|
||||
|
||||
const scaleByHeight = availableHeight / phoneHeight;
|
||||
const scaleByWidth = availableWidth / phoneWidth;
|
||||
|
||||
const newScale = Math.min(scaleByHeight, scaleByWidth, 1);
|
||||
setScale(Math.max(0.4, newScale)); // minimum 40% scale for mobile
|
||||
};
|
||||
|
||||
calculateScale();
|
||||
window.addEventListener('resize', calculateScale);
|
||||
return () => window.removeEventListener('resize', calculateScale);
|
||||
}, [isMobile]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: phoneWidth * scale,
|
||||
height: phoneHeight * scale,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
width: phoneWidth,
|
||||
height: phoneHeight,
|
||||
}}>
|
||||
<PhoneFrame>
|
||||
{children}
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PhoneFrame } from './sketchy';
|
||||
import { screenGroups, type Screen } from '../screens';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
interface GalleryProps {
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
onShowSpecs: () => void;
|
||||
}
|
||||
|
||||
const MIN_SCALE = 0.32;
|
||||
const MAX_SCALE = 0.75;
|
||||
const DEFAULT_SCALE = 0.5;
|
||||
|
||||
export function Gallery({ onSelectScreen, onShowSpecs }: GalleryProps) {
|
||||
const [scale, setScale] = useState(DEFAULT_SCALE);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
padding: isMobile ? '16px' : '24px 32px',
|
||||
borderBottom: '2px solid var(--tool-border)',
|
||||
background: 'var(--tool-surface)',
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'flex-start',
|
||||
gap: isMobile ? 16 : 0,
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 24 : 28,
|
||||
margin: 0,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
Festipod
|
||||
</h1>
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
color: 'var(--tool-text-muted)',
|
||||
margin: '8px 0 0 0',
|
||||
}}>
|
||||
Cliquez sur un écran pour le prévisualiser
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: isMobile ? 8 : 24,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* Specs BDD button */}
|
||||
<button
|
||||
onClick={onShowSpecs}
|
||||
style={{
|
||||
background: 'var(--tool-text)',
|
||||
color: 'var(--tool-bg)',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: isMobile ? '6px 12px' : '8px 16px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Specs BDD
|
||||
</button>
|
||||
|
||||
{/* Zoom control - hide on mobile */}
|
||||
{!isMobile && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
}}>
|
||||
<span style={{ fontSize: 14, color: 'var(--tool-text-muted)' }}>Zoom</span>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_SCALE * 100}
|
||||
max={MAX_SCALE * 100}
|
||||
value={scale * 100}
|
||||
onChange={(e) => setScale(Number(e.target.value) / 100)}
|
||||
style={{
|
||||
width: 100,
|
||||
accentColor: 'var(--tool-text)',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 14, width: 40 }}>{Math.round(scale * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme toggle */}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: isMobile ? '16px 0' : '24px 0' }}>
|
||||
{screenGroups.map((group) => (
|
||||
<div key={group.id} style={{ marginBottom: isMobile ? 24 : 32 }}>
|
||||
{/* Group header */}
|
||||
<h2 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 16 : 18,
|
||||
margin: isMobile ? '0 0 12px 16px' : '0 0 16px 32px',
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
{group.name}
|
||||
</h2>
|
||||
|
||||
{/* Horizontal scrolling row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: isMobile ? 12 : 24,
|
||||
paddingLeft: isMobile ? 16 : 32,
|
||||
paddingRight: isMobile ? 16 : 32,
|
||||
overflowX: 'auto',
|
||||
paddingBottom: 8,
|
||||
}}>
|
||||
{group.screens.map((screen) => (
|
||||
<GalleryItem
|
||||
key={screen.id}
|
||||
screen={screen}
|
||||
scale={isMobile ? 0.35 : scale}
|
||||
onClick={() => onSelectScreen(screen.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GalleryItemProps {
|
||||
screen: Screen;
|
||||
scale: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function GalleryItem({ screen, scale, onClick }: GalleryItemProps) {
|
||||
const ScreenComponent = screen.component;
|
||||
const phoneWidth = 375;
|
||||
const phoneHeight = 812;
|
||||
|
||||
return (
|
||||
<div className="gallery-item" onClick={onClick} style={{ flexShrink: 0 }}>
|
||||
<div style={{
|
||||
width: phoneWidth * scale,
|
||||
height: phoneHeight * scale,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
width: phoneWidth,
|
||||
height: phoneHeight,
|
||||
}}>
|
||||
<PhoneFrame>
|
||||
<ScreenComponent navigate={() => {}} />
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
{screen.name}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const cycleTheme = () => {
|
||||
if (theme === 'system') setTheme('light');
|
||||
else if (theme === 'light') setTheme('dark');
|
||||
else setTheme('system');
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
return <Sun size={18} />;
|
||||
case 'dark':
|
||||
return <Moon size={18} />;
|
||||
case 'system':
|
||||
return <Monitor size={18} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLabel = () => {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
return 'Clair';
|
||||
case 'dark':
|
||||
return 'Sombre';
|
||||
case 'system':
|
||||
return 'Auto';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={cycleTheme}
|
||||
title={`Mode: ${getLabel()} (cliquez pour changer)`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: '6px 12px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{getIcon()}
|
||||
<span>{getLabel()}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,577 +0,0 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import {
|
||||
userStories,
|
||||
categoryLabels,
|
||||
categoryColors,
|
||||
priorityLabels,
|
||||
priorityColors,
|
||||
getScreenIdsWithStories,
|
||||
type UserStory,
|
||||
type StoryCategory,
|
||||
} from '../data';
|
||||
import { getScreen, screens } from '../screens';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
interface UserStoriesPageProps {
|
||||
selectedStoryId?: string;
|
||||
onBack: () => void;
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
}
|
||||
|
||||
const categories: StoryCategory[] = ['WORKSHOP', 'EVENT', 'USER', 'MEETING', 'NOTIF'];
|
||||
|
||||
export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: UserStoriesPageProps) {
|
||||
const [selectedCategories, setSelectedCategories] = useState<Set<StoryCategory>>(new Set());
|
||||
const [selectedPriorities, setSelectedPriorities] = useState<Set<number>>(new Set());
|
||||
const [selectedScreens, setSelectedScreens] = useState<Set<string>>(new Set());
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||
const storyRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Scroll to selected story on mount
|
||||
useEffect(() => {
|
||||
if (selectedStoryId) {
|
||||
const element = storyRefs.current.get(selectedStoryId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, [selectedStoryId]);
|
||||
|
||||
// Get screens that have linked stories
|
||||
const screensWithStories = useMemo(() => {
|
||||
const screenIds = getScreenIdsWithStories();
|
||||
return screens.filter(s => screenIds.includes(s.id));
|
||||
}, []);
|
||||
|
||||
// Filter stories
|
||||
const filteredStories = useMemo(() => {
|
||||
return userStories.filter(story => {
|
||||
if (selectedCategories.size > 0 && !selectedCategories.has(story.category)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedPriorities.size > 0 && !selectedPriorities.has(story.priority)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedScreens.size > 0 && !story.screenIds.some(id => selectedScreens.has(id))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [selectedCategories, selectedPriorities, selectedScreens]);
|
||||
|
||||
const storiesByPriority = [0, 1, 2, 3].map(priority => ({
|
||||
priority,
|
||||
stories: filteredStories.filter(s => s.priority === priority),
|
||||
})).filter(({ stories }) => stories.length > 0);
|
||||
|
||||
const toggleCategory = (cat: StoryCategory) => {
|
||||
const newSet = new Set(selectedCategories);
|
||||
if (newSet.has(cat)) {
|
||||
newSet.delete(cat);
|
||||
} else {
|
||||
newSet.add(cat);
|
||||
}
|
||||
setSelectedCategories(newSet);
|
||||
};
|
||||
|
||||
const togglePriority = (p: number) => {
|
||||
const newSet = new Set(selectedPriorities);
|
||||
if (newSet.has(p)) {
|
||||
newSet.delete(p);
|
||||
} else {
|
||||
newSet.add(p);
|
||||
}
|
||||
setSelectedPriorities(newSet);
|
||||
};
|
||||
|
||||
const toggleScreen = (screenId: string) => {
|
||||
const newSet = new Set(selectedScreens);
|
||||
if (newSet.has(screenId)) {
|
||||
newSet.delete(screenId);
|
||||
} else {
|
||||
newSet.add(screenId);
|
||||
}
|
||||
setSelectedScreens(newSet);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSelectedCategories(new Set());
|
||||
setSelectedPriorities(new Set());
|
||||
setSelectedScreens(new Set());
|
||||
};
|
||||
|
||||
const hasFilters = selectedCategories.size > 0 || selectedPriorities.size > 0 || selectedScreens.size > 0;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--tool-bg)', transition: 'background-color 0.2s ease' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: isMobile ? '16px' : '24px 32px',
|
||||
borderBottom: '2px solid var(--tool-border)',
|
||||
background: 'var(--tool-surface)',
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'stretch' : 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: isMobile ? 12 : 0,
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: isMobile ? 'flex-start' : 'center', gap: isMobile ? 12 : 16, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: isMobile ? '100%' : 'auto', justifyContent: 'space-between' }}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: isMobile ? '6px 12px' : '8px 16px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
{isMobile && <ThemeToggle />}
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 22 : 28,
|
||||
margin: 0,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
User Stories
|
||||
</h1>
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 13 : 16,
|
||||
color: 'var(--tool-text-muted)',
|
||||
margin: '8px 0 0 0',
|
||||
}}>
|
||||
{filteredStories.length} / {userStories.length} stories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isMobile && <ThemeToggle />}
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{isMobile ? (
|
||||
/* Mobile: Collapsible filter bar */
|
||||
<div style={{
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
background: 'var(--tool-surface)',
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
{/* Filter toggle button */}
|
||||
<button
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>☰ Filtres</span>
|
||||
{hasFilters && (
|
||||
<span style={{
|
||||
background: 'var(--tool-text)',
|
||||
color: 'var(--tool-bg)',
|
||||
borderRadius: '50%',
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
}}>
|
||||
{selectedCategories.size + selectedPriorities.size}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{filtersExpanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{/* Expandable filter panel */}
|
||||
{filtersExpanded && (
|
||||
<div style={{
|
||||
padding: '0 16px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
borderTop: '1px solid var(--tool-border-light)',
|
||||
paddingTop: 12,
|
||||
}}>
|
||||
{/* Category filters */}
|
||||
<div>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 11,
|
||||
color: 'var(--tool-text-muted)',
|
||||
display: 'block',
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
Catégorie
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{categories.map(cat => (
|
||||
<FilterChip
|
||||
key={cat}
|
||||
label={categoryLabels[cat]}
|
||||
color={categoryColors[cat]}
|
||||
selected={selectedCategories.has(cat)}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 11,
|
||||
color: 'var(--tool-text-muted)',
|
||||
display: 'block',
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
Priorité
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<FilterChip
|
||||
key={p}
|
||||
label={`P${p}`}
|
||||
color={priorityColors[p] ?? '#888'}
|
||||
selected={selectedPriorities.has(p)}
|
||||
onClick={() => togglePriority(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
color: '#c00',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Effacer les filtres
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Desktop: Full filter bar */
|
||||
<div style={{
|
||||
padding: '16px 32px',
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
background: 'var(--tool-surface)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
{/* Category filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
minWidth: 70,
|
||||
}}>
|
||||
Catégorie
|
||||
</span>
|
||||
{categories.map(cat => (
|
||||
<FilterChip
|
||||
key={cat}
|
||||
label={categoryLabels[cat]}
|
||||
color={categoryColors[cat]}
|
||||
selected={selectedCategories.has(cat)}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
minWidth: 70,
|
||||
}}>
|
||||
Priorité
|
||||
</span>
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<FilterChip
|
||||
key={p}
|
||||
label={`P${p} ${priorityLabels[p]}`}
|
||||
color={priorityColors[p] ?? '#888'}
|
||||
selected={selectedPriorities.has(p)}
|
||||
onClick={() => togglePriority(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Screen filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
minWidth: 70,
|
||||
}}>
|
||||
Écran
|
||||
</span>
|
||||
{screensWithStories.map(screen => (
|
||||
<FilterChip
|
||||
key={screen.id}
|
||||
label={screen.name}
|
||||
color="var(--tool-text)"
|
||||
selected={selectedScreens.has(screen.id)}
|
||||
onClick={() => toggleScreen(screen.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: '#c00',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Effacer les filtres
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stories by priority */}
|
||||
<div style={{ padding: isMobile ? 16 : 32 }}>
|
||||
{storiesByPriority.length === 0 ? (
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 16,
|
||||
color: 'var(--tool-text-muted)',
|
||||
textAlign: 'center',
|
||||
padding: 40,
|
||||
}}>
|
||||
Aucune story ne correspond aux filtres sélectionnés
|
||||
</p>
|
||||
) : (
|
||||
storiesByPriority.map(({ priority, stories }) => (
|
||||
<div key={priority} style={{ marginBottom: 40 }}>
|
||||
<h2 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 20,
|
||||
margin: '0 0 16px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 12px',
|
||||
background: priorityColors[priority],
|
||||
color: 'white',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
P{priority}
|
||||
</span>
|
||||
Priorité {priorityLabels[priority]}
|
||||
<span style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--tool-text-muted)',
|
||||
fontWeight: 'normal',
|
||||
}}>
|
||||
({stories.length} stories)
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{stories.map(story => (
|
||||
<StoryCard
|
||||
key={story.id}
|
||||
ref={(el) => {
|
||||
if (el) storyRefs.current.set(story.id, el);
|
||||
}}
|
||||
story={story}
|
||||
isSelected={story.id === selectedStoryId}
|
||||
onSelectScreen={onSelectScreen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterChipProps {
|
||||
label: string;
|
||||
color: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function FilterChip({ label, color, selected, onClick }: FilterChipProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: selected ? color : 'transparent',
|
||||
color: selected ? 'white' : color,
|
||||
border: `1px solid ${color}`,
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: '4px 10px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface StoryCardProps {
|
||||
story: UserStory;
|
||||
isSelected: boolean;
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
}
|
||||
|
||||
const StoryCard = React.forwardRef<HTMLDivElement, StoryCardProps>(
|
||||
function StoryCard({ story, isSelected, onSelectScreen }, ref) {
|
||||
const linkedScreens = story.screenIds
|
||||
.map(id => ({ id, screen: getScreen(id) }))
|
||||
.filter(({ screen }) => screen !== undefined);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
border: isSelected ? '3px solid #2563eb' : '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: 16,
|
||||
background: isSelected ? '#eff6ff' : 'var(--tool-surface)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginBottom: 8 }}>
|
||||
{/* Category badge */}
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
background: categoryColors[story.category],
|
||||
color: 'white',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontSize: 11,
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{categoryLabels[story.category]}
|
||||
</span>
|
||||
<h3 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 16,
|
||||
margin: 0,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
{story.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
margin: '0 0 12px 0',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{story.description}
|
||||
</p>
|
||||
|
||||
{linkedScreens.length > 0 ? (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{linkedScreens.map(({ id, screen }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => onSelectScreen(id)}
|
||||
style={{
|
||||
background: 'var(--tool-border-light)',
|
||||
border: '1px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: '6px 12px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
→ {screen!.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
fontStyle: 'italic',
|
||||
margin: 0,
|
||||
}}>
|
||||
Pas encore de mockup
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AvatarProps {
|
||||
initials?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: 32,
|
||||
md: 40,
|
||||
lg: 56,
|
||||
};
|
||||
|
||||
export function Avatar({ initials = '?', size = 'md', className = '' }: AvatarProps) {
|
||||
const pixelSize = sizeMap[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-avatar ${className}`}
|
||||
style={{
|
||||
width: pixelSize,
|
||||
height: pixelSize,
|
||||
fontSize: pixelSize * 0.45,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function Badge({ children, className = '', style }: BadgeProps) {
|
||||
return (
|
||||
<span className={`sketchy-badge ${className}`} style={style}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'primary';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ variant = 'default', children, className = '', ...props }: ButtonProps) {
|
||||
const variantClass = variant === 'primary' ? 'sketchy-btn-primary' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`sketchy-btn ${variantClass} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', onClick, style }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-card ${className}`}
|
||||
onClick={onClick}
|
||||
style={{ ...(onClick ? { cursor: 'pointer' } : {}), ...style }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DividerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Divider({ className = '' }: DividerProps) {
|
||||
return <div className={`sketchy-divider ${className}`} />;
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PhoneFrameProps {
|
||||
children: React.ReactNode;
|
||||
scale?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PhoneFrame({ children, scale = 1, className = '' }: PhoneFrameProps) {
|
||||
// iPhone-like dimensions (375 x 812 logical pixels)
|
||||
const width = 375;
|
||||
const height = 812;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`phone-frame-wrapper ${className}`}
|
||||
style={{
|
||||
width: width * scale,
|
||||
height: height * scale,
|
||||
position: 'relative',
|
||||
background: 'var(--sketch-white)',
|
||||
borderRadius: 40 * scale,
|
||||
border: `${3 * scale}px solid var(--sketch-black)`,
|
||||
boxShadow: `${4 * scale}px ${4 * scale}px 0 var(--sketch-black)`,
|
||||
overflow: 'hidden',
|
||||
// Sketchy irregular border effect
|
||||
borderTopLeftRadius: `${42 * scale}px`,
|
||||
borderTopRightRadius: `${38 * scale}px`,
|
||||
borderBottomLeftRadius: `${39 * scale}px`,
|
||||
borderBottomRightRadius: `${41 * scale}px`,
|
||||
}}
|
||||
>
|
||||
{/* Notch */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 150 * scale,
|
||||
height: 28 * scale,
|
||||
background: 'var(--sketch-black)',
|
||||
borderBottomLeftRadius: 14 * scale,
|
||||
borderBottomRightRadius: 16 * scale,
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Screen content */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Status bar area */}
|
||||
<div
|
||||
style={{
|
||||
height: 44 * scale,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: `0 ${20 * scale}px`,
|
||||
fontSize: 12 * scale,
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
flexShrink: 0,
|
||||
color: 'var(--sketch-black)',
|
||||
}}
|
||||
>
|
||||
<span>9:41</span>
|
||||
<span style={{ display: 'flex', gap: 4 * scale }}>
|
||||
<span>~</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className="phone-screen"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Home indicator */}
|
||||
<div
|
||||
style={{
|
||||
height: 34 * scale,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 134 * scale,
|
||||
height: 5 * scale,
|
||||
background: 'var(--sketch-black)',
|
||||
borderRadius: 3 * scale,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Button } from '../ui/button';
|
||||
import { ChevronDown, ChevronUp, Filter } from 'lucide-react';
|
||||
import { categoryLabels, categoryColors, priorityLabels, priorityColors, type StoryCategory } from '../../data';
|
||||
|
||||
const categories: StoryCategory[] = ['WORKSHOP', 'EVENT', 'USER', 'MEETING', 'NOTIF'];
|
||||
|
||||
function useIsMobile(breakpoint = 640) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
interface ScreenInfo {
|
||||
id: string;
|
||||
screen: { id: string; name: string } | undefined;
|
||||
}
|
||||
|
||||
interface FeatureFilterProps {
|
||||
selectedCategories: Set<string>;
|
||||
onCategoriesChange: (categories: Set<string>) => void;
|
||||
selectedPriorities: Set<number>;
|
||||
onPrioritiesChange: (priorities: Set<number>) => void;
|
||||
selectedScreens: Set<string>;
|
||||
onScreensChange: (screens: Set<string>) => void;
|
||||
screensWithStories: ScreenInfo[];
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
}
|
||||
|
||||
export function FeatureFilter({
|
||||
selectedCategories,
|
||||
onCategoriesChange,
|
||||
selectedPriorities,
|
||||
onPrioritiesChange,
|
||||
selectedScreens,
|
||||
onScreensChange,
|
||||
screensWithStories,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: FeatureFilterProps) {
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const toggleCategory = (cat: string) => {
|
||||
const newSet = new Set(selectedCategories);
|
||||
if (newSet.has(cat)) {
|
||||
newSet.delete(cat);
|
||||
} else {
|
||||
newSet.add(cat);
|
||||
}
|
||||
onCategoriesChange(newSet);
|
||||
};
|
||||
|
||||
const togglePriority = (p: number) => {
|
||||
const newSet = new Set(selectedPriorities);
|
||||
if (newSet.has(p)) {
|
||||
newSet.delete(p);
|
||||
} else {
|
||||
newSet.add(p);
|
||||
}
|
||||
onPrioritiesChange(newSet);
|
||||
};
|
||||
|
||||
const toggleScreen = (screenId: string) => {
|
||||
const newSet = new Set(selectedScreens);
|
||||
if (newSet.has(screenId)) {
|
||||
newSet.delete(screenId);
|
||||
} else {
|
||||
newSet.add(screenId);
|
||||
}
|
||||
onScreensChange(newSet);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
onCategoriesChange(new Set());
|
||||
onPrioritiesChange(new Set());
|
||||
onScreensChange(new Set());
|
||||
onSearchChange('');
|
||||
};
|
||||
|
||||
const hasFilters = selectedCategories.size > 0 || selectedPriorities.size > 0 || selectedScreens.size > 0 || searchQuery;
|
||||
const activeFilterCount = selectedCategories.size + selectedPriorities.size + selectedScreens.size + (searchQuery ? 1 : 0);
|
||||
|
||||
// On mobile, show compact filter bar with expand button
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="border-b border-border bg-muted/30">
|
||||
{/* Compact header with search and filter toggle */}
|
||||
<div className="px-4 py-3 flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Rechercher..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="bg-background text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={activeFilterCount > 0 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
className="shrink-0 h-9"
|
||||
>
|
||||
<Filter className="w-4 h-4 mr-1" />
|
||||
{activeFilterCount > 0 ? activeFilterCount : ''}
|
||||
{filtersExpanded ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expandable filter panel */}
|
||||
{filtersExpanded && (
|
||||
<div className="px-4 pb-3 space-y-3 border-t border-border/50 pt-3">
|
||||
{/* Category filters */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block mb-2">Catégorie</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{categories.map(cat => (
|
||||
<Button
|
||||
key={cat}
|
||||
variant={selectedCategories.has(cat) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="text-xs px-2 h-7"
|
||||
style={{
|
||||
backgroundColor: selectedCategories.has(cat) ? categoryColors[cat] : 'transparent',
|
||||
borderColor: categoryColors[cat],
|
||||
color: selectedCategories.has(cat) ? 'white' : categoryColors[cat],
|
||||
}}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
>
|
||||
{categoryLabels[cat]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block mb-2">Priorité</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={selectedPriorities.has(p) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="text-xs px-2 h-7"
|
||||
style={{
|
||||
backgroundColor: selectedPriorities.has(p) ? priorityColors[p] : 'transparent',
|
||||
borderColor: priorityColors[p],
|
||||
color: selectedPriorities.has(p) ? 'white' : priorityColors[p],
|
||||
}}
|
||||
onClick={() => togglePriority(p)}
|
||||
>
|
||||
P{p}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Screen filters */}
|
||||
{screensWithStories.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block mb-2">Écran</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{screensWithStories.map(({ id, screen }) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant={selectedScreens.has(id) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="text-xs px-2 h-7"
|
||||
onClick={() => toggleScreen(id)}
|
||||
>
|
||||
{screen?.name || id}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-destructive hover:text-destructive text-xs p-0 h-auto">
|
||||
Effacer les filtres
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout
|
||||
return (
|
||||
<div className="border-b border-border bg-muted/30 px-8 py-4 space-y-4">
|
||||
{/* Search */}
|
||||
<div className="max-w-md">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Rechercher une fonctionnalité..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground w-20 shrink-0">Catégorie</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{categories.map(cat => (
|
||||
<Button
|
||||
key={cat}
|
||||
variant={selectedCategories.has(cat) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
style={{
|
||||
backgroundColor: selectedCategories.has(cat) ? categoryColors[cat] : 'transparent',
|
||||
borderColor: categoryColors[cat],
|
||||
color: selectedCategories.has(cat) ? 'white' : categoryColors[cat],
|
||||
}}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
>
|
||||
{categoryLabels[cat]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground w-20 shrink-0">Priorité</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={selectedPriorities.has(p) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
style={{
|
||||
backgroundColor: selectedPriorities.has(p) ? priorityColors[p] : 'transparent',
|
||||
borderColor: priorityColors[p],
|
||||
color: selectedPriorities.has(p) ? 'white' : priorityColors[p],
|
||||
}}
|
||||
onClick={() => togglePriority(p)}
|
||||
>
|
||||
P{p} - {priorityLabels[p]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Screen filters */}
|
||||
{screensWithStories.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground w-20 shrink-0">Écran</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{screensWithStories.map(({ id, screen }) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant={selectedScreens.has(id) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => toggleScreen(id)}
|
||||
>
|
||||
{screen?.name || id}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasFilters && (
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-destructive hover:text-destructive">
|
||||
Effacer les filtres
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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 { GherkinHighlighter } from './GherkinHighlighter';
|
||||
import { Button } from '../ui/button';
|
||||
import { ArrowLeft, Monitor, CheckCircle2, XCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface FeatureViewProps {
|
||||
feature: ParsedFeature;
|
||||
onBack: () => void;
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
onSelectStory: (storyId: string) => void;
|
||||
}
|
||||
|
||||
export function FeatureView({ feature, onBack, onSelectScreen, onSelectStory }: FeatureViewProps) {
|
||||
const linkedStory = getStoryById(feature.id);
|
||||
const linkedScreens = linkedStory?.screenIds
|
||||
.map(id => ({ id, screen: getScreen(id) }))
|
||||
.filter(s => s.screen) || [];
|
||||
const testStatus = getTestStatus(feature.id);
|
||||
const scenarioResults = getScenarioResults(feature.id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-4 sm:px-8 py-6 bg-card">
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Retour
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Test Results - Compact in header */}
|
||||
{testStatus && (
|
||||
<div className="flex items-center gap-3">
|
||||
{testStatus.failed > 0 ? (
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
) : testStatus.skipped > 0 ? (
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-green-600 font-medium">{testStatus.passed} passes</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-red-600 font-medium">{testStatus.failed} echecs</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-yellow-600 font-medium">{testStatus.skipped} ignores</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<span
|
||||
className="px-3 py-1 text-sm font-medium text-white rounded-md"
|
||||
style={{ backgroundColor: priorityColors[feature.priority] }}
|
||||
>
|
||||
P{feature.priority} - {priorityLabels[feature.priority]}
|
||||
</span>
|
||||
<span
|
||||
className="px-3 py-1 text-sm font-medium text-white rounded-md"
|
||||
style={{ backgroundColor: categoryColors[feature.category as StoryCategory] }}
|
||||
>
|
||||
{categoryLabels[feature.category as StoryCategory]}
|
||||
</span>
|
||||
{linkedStory ? (
|
||||
<button
|
||||
onClick={() => onSelectStory(linkedStory.id)}
|
||||
className="text-sm text-primary font-mono hover:underline cursor-pointer"
|
||||
>
|
||||
{feature.id.toUpperCase()}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground font-mono">
|
||||
{feature.id.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{feature.name.replace(/^US-\d+\s*/, '')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-8 py-6">
|
||||
{/* Linked screens - inline buttons */}
|
||||
{linkedScreens.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||
<Monitor className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Écrans:</span>
|
||||
{linkedScreens.map(({ id, screen }) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSelectScreen(id)}
|
||||
>
|
||||
{screen?.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content - Gherkin */}
|
||||
<GherkinHighlighter
|
||||
content={feature.rawContent}
|
||||
scenarioResults={scenarioResults}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,713 +0,0 @@
|
||||
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';
|
||||
|
||||
interface ScenarioResult {
|
||||
name: string;
|
||||
status: 'passed' | 'failed' | 'skipped' | 'unknown';
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface GherkinHighlighterProps {
|
||||
content: string;
|
||||
scenarioResults?: ScenarioResult[];
|
||||
}
|
||||
|
||||
interface ParsedBlock {
|
||||
type: 'header' | 'background' | 'scenario';
|
||||
lines: string[];
|
||||
startLine: number;
|
||||
name?: string;
|
||||
status?: 'passed' | 'failed' | 'skipped' | 'unknown';
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const keywords = {
|
||||
feature: ['Fonctionnalité:', 'Feature:'],
|
||||
background: ['Contexte:', 'Background:'],
|
||||
scenario: ['Scénario:', 'Scenario:', 'Plan du Scénario:', 'Scenario Outline:'],
|
||||
given: ['Étant donné que ', "Étant donné qu'", 'Étant donné', 'Etant donné que ', "Etant donné qu'", 'Etant donné', 'Given', 'Soit'],
|
||||
when: ['Quand', 'When', 'Lorsque'],
|
||||
then: ['Alors', 'Then'],
|
||||
and: ['Et', 'And', 'Mais', 'But', '* '],
|
||||
examples: ['Exemples:', 'Examples:'],
|
||||
};
|
||||
|
||||
// Placeholder step text for skipped/not-implemented scenarios
|
||||
const SKIP_PLACEHOLDER = 'Scénario non implémenté';
|
||||
|
||||
export function GherkinHighlighter({ content, scenarioResults = [] }: GherkinHighlighterProps) {
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Parse content into blocks
|
||||
const blocks = useMemo(() => parseBlocks(lines, scenarioResults), [lines, scenarioResults]);
|
||||
|
||||
// Determine initial collapsed state - scenarios collapsed by default (open if failed), background always open
|
||||
const initialCollapsed = useMemo(() => {
|
||||
const state: Record<number, boolean> = {};
|
||||
blocks.forEach((block, index) => {
|
||||
if (block.type === 'scenario') {
|
||||
state[index] = block.status !== 'failed';
|
||||
} else if (block.type === 'background') {
|
||||
// Background is always expanded
|
||||
state[index] = false;
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}, [blocks]);
|
||||
|
||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>(initialCollapsed);
|
||||
const [showDefinitions, setShowDefinitions] = useState(true);
|
||||
|
||||
const toggleBlock = (index: number) => {
|
||||
setCollapsed(prev => ({ ...prev, [index]: !prev[index] }));
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
const newState: Record<number, boolean> = {};
|
||||
blocks.forEach((_, index) => {
|
||||
newState[index] = false;
|
||||
});
|
||||
setCollapsed(newState);
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
const newState: Record<number, boolean> = {};
|
||||
blocks.forEach((block, index) => {
|
||||
if (block.type === 'scenario') {
|
||||
newState[index] = true;
|
||||
}
|
||||
// Background stays expanded
|
||||
});
|
||||
setCollapsed(newState);
|
||||
};
|
||||
|
||||
const scenarioCount = blocks.filter(b => b.type === 'scenario').length;
|
||||
const collapsedScenarioCount = blocks.filter((b, i) => b.type === 'scenario' && collapsed[i]).length;
|
||||
const allCollapsed = collapsedScenarioCount === scenarioCount;
|
||||
|
||||
return (
|
||||
<div className="space-y-2" style={{ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={allCollapsed ? expandAll : collapseAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{allCollapsed ? (
|
||||
<>
|
||||
<ChevronsUpDown className="w-3.5 h-3.5 mr-1" />
|
||||
<span className="hidden sm:inline">Tout déplier</span>
|
||||
<span className="sm:hidden">Déplier</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronsDownUp className="w-3.5 h-3.5 mr-1" />
|
||||
<span className="hidden sm:inline">Tout replier</span>
|
||||
<span className="sm:hidden">Replier</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showDefinitions ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowDefinitions(!showDefinitions)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Code2 className="w-3.5 h-3.5 mr-1" />
|
||||
<span className="hidden sm:inline">Définitions</span>
|
||||
<span className="sm:hidden">Déf.</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* All Blocks including header */}
|
||||
{blocks.map((block, blockIndex) => (
|
||||
<BlockRenderer
|
||||
key={blockIndex}
|
||||
block={block}
|
||||
isCollapsed={collapsed[blocks.indexOf(block)] ?? false}
|
||||
onToggle={() => toggleBlock(blocks.indexOf(block))}
|
||||
showDefinitions={showDefinitions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseBlocks(lines: string[], scenarioResults: ScenarioResult[]): ParsedBlock[] {
|
||||
const blocks: ParsedBlock[] = [];
|
||||
let currentBlock: ParsedBlock | null = null;
|
||||
|
||||
const resultMap = new Map(scenarioResults.map(r => [r.name.toLowerCase().trim(), { status: r.status, errorMessage: r.errorMessage }]));
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? '';
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check for scenario start
|
||||
const isScenario = keywords.scenario.some(kw => trimmed.startsWith(kw));
|
||||
const isBackground = keywords.background.some(kw => trimmed.startsWith(kw));
|
||||
const isFeature = keywords.feature.some(kw => trimmed.startsWith(kw));
|
||||
|
||||
if (isFeature || (currentBlock === null && !isScenario && !isBackground)) {
|
||||
// Header content (tags, language, feature line, description)
|
||||
if (!currentBlock || currentBlock.type !== 'header') {
|
||||
if (currentBlock) blocks.push(currentBlock);
|
||||
currentBlock = { type: 'header', lines: [], startLine: i };
|
||||
}
|
||||
currentBlock.lines.push(line);
|
||||
} else if (isBackground) {
|
||||
if (currentBlock) blocks.push(currentBlock);
|
||||
currentBlock = {
|
||||
type: 'background',
|
||||
lines: [line],
|
||||
startLine: i,
|
||||
name: extractName(trimmed, keywords.background),
|
||||
status: 'unknown'
|
||||
};
|
||||
} else if (isScenario) {
|
||||
if (currentBlock) blocks.push(currentBlock);
|
||||
const name = extractName(trimmed, keywords.scenario);
|
||||
const result = resultMap.get(name.toLowerCase().trim());
|
||||
currentBlock = {
|
||||
type: 'scenario',
|
||||
lines: [line],
|
||||
startLine: i,
|
||||
name,
|
||||
status: result?.status || 'unknown',
|
||||
errorMessage: result?.errorMessage
|
||||
};
|
||||
} else if (currentBlock) {
|
||||
currentBlock.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBlock) blocks.push(currentBlock);
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function extractName(line: string, keywords: string[]): string {
|
||||
for (const kw of keywords) {
|
||||
if (line.startsWith(kw)) {
|
||||
return line.slice(kw.length).trim();
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
interface BlockRendererProps {
|
||||
block: ParsedBlock;
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
showDefinitions: boolean;
|
||||
}
|
||||
|
||||
function BlockRenderer({ block, isCollapsed, onToggle, showDefinitions }: BlockRendererProps) {
|
||||
if (block.type === 'header') {
|
||||
// Extract user story lines (En tant que, Je peux/Je veux, Afin de)
|
||||
const userStoryLines = block.lines.filter(line => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.startsWith('En tant qu') ||
|
||||
trimmed.startsWith('Je peux') ||
|
||||
trimmed.startsWith('Je veux') ||
|
||||
trimmed.startsWith('Et ') ||
|
||||
trimmed.startsWith('Afin ');
|
||||
});
|
||||
|
||||
if (userStoryLines.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="border-l-4 border-l-violet-500 bg-violet-50/50 dark:bg-violet-950/20">
|
||||
<CardContent className="p-3">
|
||||
<div className="space-y-0.5">
|
||||
{userStoryLines.map((line, index) => (
|
||||
<div key={index} className="text-sm text-foreground">
|
||||
{line.trim()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const restLines = block.lines.slice(1);
|
||||
const isBackground = block.type === 'background';
|
||||
|
||||
// Parse steps from rest lines
|
||||
let parsedSteps = parseStepsFromLines(restLines);
|
||||
|
||||
// For skipped scenarios, filter out the placeholder step
|
||||
if (block.status === 'skipped') {
|
||||
parsedSteps = parsedSteps.filter(step => step.text !== SKIP_PLACEHOLDER);
|
||||
}
|
||||
|
||||
// Determine border color based on status
|
||||
const borderColor = block.status === 'passed' ? 'border-l-green-500' :
|
||||
block.status === 'failed' ? 'border-l-red-500' :
|
||||
block.status === 'skipped' ? 'border-l-yellow-500' :
|
||||
isBackground ? 'border-l-zinc-400' : 'border-l-cyan-500';
|
||||
|
||||
// Status icon
|
||||
const StatusIcon = () => {
|
||||
if (!block.status || block.status === 'unknown') return null;
|
||||
if (block.status === 'passed') return <CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />;
|
||||
if (block.status === 'failed') return <XCircle className="w-4 h-4 text-red-500 shrink-0" />;
|
||||
if (block.status === 'skipped') return <AlertCircle className="w-4 h-4 text-yellow-500 shrink-0" />;
|
||||
return <Clock className="w-4 h-4 text-zinc-400 shrink-0" />;
|
||||
};
|
||||
|
||||
// Skipped scenarios are not expandable (no steps to show)
|
||||
const isExpandable = block.status !== 'skipped' && parsedSteps.length > 0;
|
||||
|
||||
return (
|
||||
<Card className={`border-l-4 ${borderColor}`}>
|
||||
{/* Header - clickable only if expandable */}
|
||||
<CardHeader
|
||||
className={`p-2 ${isExpandable ? 'cursor-pointer hover:bg-muted/50' : ''} transition-colors`}
|
||||
onClick={isExpandable ? onToggle : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Show chevron only if expandable */}
|
||||
{isExpandable ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-4 shrink-0" /> // Spacer to maintain alignment
|
||||
)}
|
||||
<StatusIcon />
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5 flex-wrap">
|
||||
<span className={`text-xs font-medium px-1.5 py-0.5 rounded shrink-0 ${
|
||||
isBackground
|
||||
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400'
|
||||
: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-400'
|
||||
}`}>
|
||||
{isBackground ? 'Contexte' : 'Scénario'}
|
||||
</span>
|
||||
<span className="font-medium text-foreground text-sm truncate sm:whitespace-normal">
|
||||
{block.name}
|
||||
</span>
|
||||
</div>
|
||||
{/* Show step count only if expandable */}
|
||||
{isExpandable && parsedSteps.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 hidden sm:block">
|
||||
{parsedSteps.length} étapes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Collapsible content - only shown if expandable and not collapsed */}
|
||||
{isExpandable && !isCollapsed && (
|
||||
<CardContent className="pt-0 px-2 pb-2">
|
||||
<div className="space-y-0.5 ml-0 sm:ml-6">
|
||||
{parsedSteps.map((step, index) => (
|
||||
<StepRenderer
|
||||
key={index}
|
||||
step={step}
|
||||
showDefinitions={showDefinitions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error message for failed scenarios */}
|
||||
{block.status === 'failed' && block.errorMessage && (
|
||||
<div className="ml-0 sm:ml-6 mt-2 p-2 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">Erreur:</div>
|
||||
<pre className="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap break-words font-mono overflow-x-auto">
|
||||
{block.errorMessage}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParsedStep {
|
||||
type: 'given' | 'when' | 'then' | 'and' | 'examples' | 'table' | 'other';
|
||||
keyword: string;
|
||||
text: string;
|
||||
originalLine: string;
|
||||
tableRows?: string[][];
|
||||
}
|
||||
|
||||
function parseStepsFromLines(lines: string[]): ParsedStep[] {
|
||||
const steps: ParsedStep[] = [];
|
||||
let currentStep: ParsedStep | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Check for table row
|
||||
if (trimmed.startsWith('|')) {
|
||||
const cells = trimmed.split('|').slice(1, -1).map(c => c.trim());
|
||||
if (currentStep) {
|
||||
if (!currentStep.tableRows) currentStep.tableRows = [];
|
||||
currentStep.tableRows.push(cells);
|
||||
} else {
|
||||
// Standalone table row (shouldn't happen, but handle it)
|
||||
steps.push({
|
||||
type: 'table',
|
||||
keyword: '',
|
||||
text: trimmed,
|
||||
originalLine: line,
|
||||
tableRows: [cells]
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for step keywords
|
||||
let matched = false;
|
||||
|
||||
for (const kw of keywords.given) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'given', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const kw of keywords.when) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'when', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const kw of keywords.then) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'then', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const kw of keywords.and) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'and', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const kw of keywords.examples) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'examples', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched && trimmed) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'other', keyword: '', text: trimmed, originalLine: line };
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep) steps.push(currentStep);
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
interface StepRendererProps {
|
||||
step: ParsedStep;
|
||||
showDefinitions: boolean;
|
||||
}
|
||||
|
||||
function StepRenderer({ step, showDefinitions }: StepRendererProps) {
|
||||
// Always check for step definition to show dotted underline
|
||||
// Use step.text (without keyword) to match against step definition patterns
|
||||
const stepDef = step.type !== 'table' && step.type !== 'other' && step.type !== 'examples'
|
||||
? findStepDefinition(step.text)
|
||||
: null;
|
||||
|
||||
// Keyword colors
|
||||
const keywordColor = step.type === 'given' ? 'text-blue-600 dark:text-blue-400' :
|
||||
step.type === 'when' ? 'text-amber-600 dark:text-amber-400' :
|
||||
step.type === 'then' ? 'text-green-600 dark:text-green-400' :
|
||||
step.type === 'and' ? 'text-zinc-500 dark:text-zinc-400' :
|
||||
step.type === 'examples' ? 'text-purple-600 dark:text-purple-400' :
|
||||
'text-muted-foreground';
|
||||
|
||||
const keywordBg = step.type === 'given' ? 'bg-blue-50 dark:bg-blue-950/30' :
|
||||
step.type === 'when' ? 'bg-amber-50 dark:bg-amber-950/30' :
|
||||
step.type === 'then' ? 'bg-green-50 dark:bg-green-950/30' :
|
||||
step.type === 'and' ? 'bg-zinc-50 dark:bg-zinc-800/50' :
|
||||
step.type === 'examples' ? 'bg-purple-50 dark:bg-purple-950/30' :
|
||||
'';
|
||||
|
||||
if (step.type === 'table') {
|
||||
return (
|
||||
<div className="ml-2 sm:ml-4 my-2">
|
||||
<Table2 className="w-4 h-4 text-muted-foreground inline mr-2" />
|
||||
<span className="text-sm text-muted-foreground">{step.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show popover only when definitions mode is active, but always show dotted underline for steps with definitions
|
||||
const dottedUnderlineStyle = {
|
||||
borderBottom: '1.3px dashed',
|
||||
borderColor: 'rgb(161 161 170)', // zinc-400
|
||||
};
|
||||
const stepTextElement = stepDef ? (
|
||||
showDefinitions ? (
|
||||
<StepDefinitionPopover stepDef={stepDef}>
|
||||
{highlightStringsInText(step.text)}
|
||||
</StepDefinitionPopover>
|
||||
) : (
|
||||
<span style={dottedUnderlineStyle}>
|
||||
{highlightStringsInText(step.text)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span>{highlightStringsInText(step.text)}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="py-0.5">
|
||||
<div className={`flex items-start gap-1.5 px-1.5 py-0.5 rounded ${keywordBg}`}>
|
||||
{step.keyword && (
|
||||
<span className={`font-medium text-sm shrink-0 ${keywordColor}`}>
|
||||
{step.keyword}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-foreground break-words">
|
||||
{stepTextElement}
|
||||
</span>
|
||||
</div>
|
||||
{/* Render table if present */}
|
||||
{step.tableRows && step.tableRows.length > 0 && (
|
||||
<div className="ml-0 sm:ml-4 mt-1 overflow-x-auto -mx-1 px-1">
|
||||
<table className="text-sm border-collapse min-w-full">
|
||||
<tbody>
|
||||
{step.tableRows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className={rowIndex === 0 ? 'font-medium' : ''}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
key={cellIndex}
|
||||
className="px-2 py-1 border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50"
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function highlightStringsInText(text: string): React.ReactNode {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
const regex = /"[^"]*"/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(<span key={`text-${lastIndex}`}>{text.slice(lastIndex, match.index)}</span>);
|
||||
}
|
||||
parts.push(
|
||||
<span key={`string-${match.index}`} className="font-medium text-orange-600 dark:text-orange-400">
|
||||
{match[0]}
|
||||
</span>
|
||||
);
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(<span key={`text-${lastIndex}`}>{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? <>{parts}</> : text;
|
||||
}
|
||||
|
||||
// Click-based popover for step definitions (works on mobile and desktop)
|
||||
function StepDefinitionPopover({
|
||||
stepDef,
|
||||
children
|
||||
}: {
|
||||
stepDef: StepDefinitionInfo;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(e.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Close on escape key
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const dottedUnderlineStyle = {
|
||||
borderBottom: '1.3px dashed rgb(161 161 170)', // zinc-400
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="relative inline">
|
||||
<span
|
||||
ref={triggerRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="cursor-pointer"
|
||||
style={dottedUnderlineStyle}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute left-0 top-full mt-1 z-50 shadow-xl rounded-lg"
|
||||
style={{ minWidth: '300px', maxWidth: 'min(90vw, 500px)' }}
|
||||
>
|
||||
<SourceCodePopup stepDef={stepDef} />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceCodePopup({ stepDef }: { stepDef: StepDefinitionInfo }) {
|
||||
const lines = stepDef.sourceCode.split('\n');
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-lg overflow-hidden min-w-[300px]">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 bg-zinc-800 border-b border-zinc-700 flex items-center justify-between">
|
||||
<span className="text-xs text-zinc-400 font-medium">
|
||||
{stepDef.file}:{stepDef.lineNumber}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
stepDef.keyword === 'Given' ? 'bg-blue-500/20 text-blue-400' :
|
||||
stepDef.keyword === 'When' ? 'bg-amber-500/20 text-amber-400' :
|
||||
'bg-green-500/20 text-green-400'
|
||||
}`}>
|
||||
{stepDef.keyword}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Code */}
|
||||
<div className="p-3 overflow-x-auto">
|
||||
<pre className="text-xs leading-relaxed">
|
||||
<code>
|
||||
{lines.map((codeLine, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span className="w-6 text-right pr-2 text-zinc-600 select-none text-[10px]">
|
||||
{stepDef.lineNumber + i}
|
||||
</span>
|
||||
<span className="text-zinc-300">
|
||||
{highlightTypeScript(codeLine)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function highlightTypeScript(code: string): React.ReactNode {
|
||||
// Simple TypeScript syntax highlighting
|
||||
const parts: React.ReactNode[] = [];
|
||||
let remaining = code;
|
||||
let key = 0;
|
||||
|
||||
const patterns: Array<{ regex: RegExp; className: string }> = [
|
||||
// Keywords
|
||||
{ regex: /^(async|function|const|let|var|if|else|for|return|this|await|new|typeof|import|export|from)\b/, className: 'text-purple-400' },
|
||||
// Cucumber keywords
|
||||
{ regex: /^(Given|When|Then)\b/, className: 'text-amber-400 font-medium' },
|
||||
// Strings (single, double, backtick)
|
||||
{ regex: /^'(?:[^'\\]|\\.)*'/, className: 'text-green-400' },
|
||||
{ regex: /^"(?:[^"\\]|\\.)*"/, className: 'text-green-400' },
|
||||
{ regex: /^`(?:[^`\\]|\\.)*`/, className: 'text-green-400' },
|
||||
// Comments
|
||||
{ regex: /^\/\/.*$/, className: 'text-zinc-500 italic' },
|
||||
// Types after colon
|
||||
{ regex: /^:\s*[A-Z][a-zA-Z0-9]*/, className: 'text-cyan-400' },
|
||||
// Numbers
|
||||
{ regex: /^\d+/, className: 'text-orange-400' },
|
||||
// Booleans
|
||||
{ regex: /^(true|false|null|undefined)\b/, className: 'text-orange-400' },
|
||||
// Methods/functions
|
||||
{ regex: /^(\.[a-zA-Z_][a-zA-Z0-9_]*)\s*\(/, className: 'text-blue-300' },
|
||||
// Properties
|
||||
{ regex: /^(\.[a-zA-Z_][a-zA-Z0-9_]*)/, className: 'text-zinc-200' },
|
||||
// Arrows
|
||||
{ regex: /^=>/, className: 'text-purple-400' },
|
||||
// Brackets and operators
|
||||
{ regex: /^[{}()\[\];,]/, className: 'text-zinc-400' },
|
||||
];
|
||||
|
||||
while (remaining.length > 0) {
|
||||
let matched = false;
|
||||
|
||||
for (const { regex, className } of patterns) {
|
||||
const match = remaining.match(regex);
|
||||
if (match) {
|
||||
parts.push(
|
||||
<span key={key++} className={className}>
|
||||
{match[0]}
|
||||
</span>
|
||||
);
|
||||
remaining = remaining.slice(match[0].length);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
// No pattern matched, take one character
|
||||
parts.push(<span key={key++}>{remaining[0]}</span>);
|
||||
remaining = remaining.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
return <>{parts}</>;
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
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 { FeatureView } from './FeatureView';
|
||||
import { FeatureFilter } from './FeatureFilter';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { ArrowLeft, FileText, Monitor, CheckCircle2, XCircle, AlertCircle, ExternalLink } from 'lucide-react';
|
||||
import type { ParsedFeature } from '../../types/gherkin';
|
||||
import { ThemeToggle } from '../ThemeToggle';
|
||||
|
||||
interface SpecsPageProps {
|
||||
selectedFeatureId?: string;
|
||||
selectedStoryId?: string;
|
||||
onBack: () => void;
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
onSelectStory: (storyId: string) => void;
|
||||
}
|
||||
|
||||
export function SpecsPage({ selectedFeatureId, selectedStoryId, onBack, onSelectScreen, onSelectStory }: SpecsPageProps) {
|
||||
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set());
|
||||
const [selectedPriorities, setSelectedPriorities] = useState<Set<number>>(new Set());
|
||||
const [selectedScreens, setSelectedScreens] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const featureRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Get screens that have linked stories for the filter
|
||||
const screensWithStories = useMemo(() => {
|
||||
const screenIds = getScreenIdsWithStories();
|
||||
return screenIds
|
||||
.map(id => ({ id, screen: getScreen(id) }))
|
||||
.filter(({ screen }) => screen !== undefined);
|
||||
}, []);
|
||||
|
||||
// Scroll to selected story on mount
|
||||
useEffect(() => {
|
||||
if (selectedStoryId) {
|
||||
const element = featureRefs.current.get(selectedStoryId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, [selectedStoryId]);
|
||||
|
||||
// Filter features - must be before any conditional returns to respect hooks rules
|
||||
const filteredFeatures = useMemo(() => {
|
||||
return parsedFeatures.filter(feature => {
|
||||
if (selectedCategories.size > 0 && !selectedCategories.has(feature.category)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedPriorities.size > 0 && !selectedPriorities.has(feature.priority)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedScreens.size > 0) {
|
||||
const linkedStory = getStoryById(feature.id);
|
||||
if (!linkedStory || !linkedStory.screenIds.some(id => selectedScreens.has(id))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return feature.name.toLowerCase().includes(query) ||
|
||||
feature.description.toLowerCase().includes(query);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [selectedCategories, selectedPriorities, selectedScreens, searchQuery]);
|
||||
|
||||
// Group by priority
|
||||
const featuresByPriority = [0, 1, 2, 3].map(priority => ({
|
||||
priority,
|
||||
features: filteredFeatures.filter(f => f.priority === priority),
|
||||
})).filter(({ features }) => features.length > 0);
|
||||
|
||||
// If a feature is selected, show detail view
|
||||
if (selectedFeatureId) {
|
||||
const feature = getFeatureById(selectedFeatureId);
|
||||
if (feature) {
|
||||
return (
|
||||
<FeatureView
|
||||
feature={feature}
|
||||
onBack={onBack}
|
||||
onSelectScreen={onSelectScreen}
|
||||
onSelectStory={onSelectStory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const testSummary = getTestSummary();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-4 sm:px-8 py-4 sm:py-6 bg-card">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<Button variant="outline" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Retour</span>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold">Specs BDD</h1>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
|
||||
{filteredFeatures.length} / {parsedFeatures.length} fonctionnalités
|
||||
</p>
|
||||
</div>
|
||||
<div className="sm:hidden ml-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
{/* Test Results Summary */}
|
||||
{testSummary.totalScenarios > 0 && (
|
||||
<div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm flex-wrap">
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<CheckCircle2 className="w-3 h-3 sm:w-4 sm:h-4 text-green-500" />
|
||||
<span className="text-green-600 font-medium">{testSummary.passed}</span>
|
||||
</div>
|
||||
{testSummary.failed > 0 && (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<XCircle className="w-3 h-3 sm:w-4 sm:h-4 text-red-500" />
|
||||
<span className="text-red-600 font-medium">{testSummary.failed}</span>
|
||||
</div>
|
||||
)}
|
||||
{testSummary.skipped > 0 && (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<AlertCircle className="w-3 h-3 sm:w-4 sm:h-4 text-yellow-500" />
|
||||
<span className="text-yellow-600 font-medium">{testSummary.skipped}</span>
|
||||
</div>
|
||||
)}
|
||||
<a href="/reports/cucumber" target="_blank" rel="noopener noreferrer" className="hidden sm:block">
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Rapport
|
||||
</Button>
|
||||
</a>
|
||||
<div className="hidden sm:block">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{testSummary.totalScenarios === 0 && <div className="hidden sm:block"><ThemeToggle /></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<FeatureFilter
|
||||
selectedCategories={selectedCategories}
|
||||
onCategoriesChange={setSelectedCategories}
|
||||
selectedPriorities={selectedPriorities}
|
||||
onPrioritiesChange={setSelectedPriorities}
|
||||
selectedScreens={selectedScreens}
|
||||
onScreensChange={setSelectedScreens}
|
||||
screensWithStories={screensWithStories}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
|
||||
{/* Feature list */}
|
||||
<div className="px-4 sm:px-8 py-4 sm:py-6 space-y-6 sm:space-y-8">
|
||||
{featuresByPriority.map(({ priority, features }) => (
|
||||
<div key={priority}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span
|
||||
className="px-3 py-1 text-sm font-medium text-white rounded-md"
|
||||
style={{ backgroundColor: priorityColors[priority] }}
|
||||
>
|
||||
P{priority}
|
||||
</span>
|
||||
<h2 className="text-lg font-semibold">
|
||||
Priorite {priorityLabels[priority]}
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({features.length} fonctionnalites)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:gap-4">
|
||||
{features.map(feature => (
|
||||
<FeatureCard
|
||||
key={feature.id}
|
||||
ref={(el) => {
|
||||
if (el) featureRefs.current.set(feature.id, el);
|
||||
}}
|
||||
feature={feature}
|
||||
isSelected={feature.id === selectedStoryId}
|
||||
onClick={() => window.location.hash = `#/specs/${feature.id}`}
|
||||
onSelectScreen={onSelectScreen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{featuresByPriority.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
Aucune fonctionnalite ne correspond aux filtres selectionnes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split user story description into separate lines
|
||||
function formatUserStory(description: string): string[] {
|
||||
// Split on user story keywords while keeping the keywords
|
||||
return description
|
||||
.split(/(?=En tant qu|Je peux|Je veux|Et |Afin d)/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
interface FeatureCardProps {
|
||||
feature: ParsedFeature;
|
||||
isSelected?: boolean;
|
||||
onClick: () => void;
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
}
|
||||
|
||||
const FeatureCard = React.forwardRef<HTMLDivElement, FeatureCardProps>(
|
||||
function FeatureCard({ feature, isSelected, onClick, onSelectScreen }, ref) {
|
||||
const linkedStory = getStoryById(feature.id);
|
||||
const linkedScreens = linkedStory?.screenIds
|
||||
.map(id => ({ id, screen: getScreen(id) }))
|
||||
.filter(({ screen }) => screen !== undefined) || [];
|
||||
const testStatus = getTestStatus(feature.id);
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (!testStatus) return null;
|
||||
if (testStatus.failed > 0) {
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
}
|
||||
if (testStatus.skipped > 0) {
|
||||
return <AlertCircle className="w-4 h-4 text-yellow-500" />;
|
||||
}
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!testStatus) return null;
|
||||
if (testStatus.failed > 0) {
|
||||
return <span className="text-red-600">{testStatus.passed}/{testStatus.totalScenarios}</span>;
|
||||
}
|
||||
if (testStatus.skipped > 0) {
|
||||
return <span className="text-yellow-600">{testStatus.passed}/{testStatus.totalScenarios}</span>;
|
||||
}
|
||||
return <span className="text-green-600">{testStatus.passed}/{testStatus.totalScenarios}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
className={`cursor-pointer hover:border-primary hover:shadow-md transition-all ${
|
||||
isSelected ? 'border-2 border-blue-500 bg-blue-50 dark:bg-blue-950/20' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs font-medium text-white rounded"
|
||||
style={{ backgroundColor: categoryColors[feature.category as StoryCategory] }}
|
||||
>
|
||||
{categoryLabels[feature.category as StoryCategory]}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{feature.id.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{testStatus && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{getStatusIcon()}
|
||||
{getStatusText()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-base leading-tight line-clamp-2">
|
||||
{feature.name.replace(/^US-\d+\s*/, '')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{feature.description && (
|
||||
<div className="text-sm text-muted-foreground mb-3 space-y-1">
|
||||
{formatUserStory(feature.description).map((line, i) => (
|
||||
<div key={i} className="leading-snug">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground mb-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="w-3 h-3" />
|
||||
{feature.scenarios.length} scenarios
|
||||
</span>
|
||||
{linkedScreens.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Monitor className="w-3 h-3" />
|
||||
{linkedScreens.length} ecrans
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Screen buttons */}
|
||||
{linkedScreens.length > 0 ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{linkedScreens.map(({ id, screen }) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectScreen(id);
|
||||
}}
|
||||
>
|
||||
{screen!.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Pas encore de mockup
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
export { SpecsPage } from './SpecsPage';
|
||||
export { FeatureView } from './FeatureView';
|
||||
export { FeatureFilter } from './FeatureFilter';
|
||||
export { GherkinHighlighter } from './GherkinHighlighter';
|
||||
@@ -1,360 +0,0 @@
|
||||
// Auto-generated by scripts/extract-step-definitions.ts
|
||||
// Do not edit manually - run "bun run steps:extract" to regenerate
|
||||
|
||||
export interface StepDefinitionInfo {
|
||||
pattern: string;
|
||||
keyword: 'Given' | 'When' | 'Then';
|
||||
file: string;
|
||||
sourceCode: string;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
{
|
||||
"pattern": "Scénario non implémenté",
|
||||
"keyword": "Given",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Given('Scénario non implémenté', async function (this: FestipodWorld) {\n return 'skipped';\n});",
|
||||
"lineNumber": 7
|
||||
},
|
||||
{
|
||||
"pattern": "je suis sur la page {string}",
|
||||
"keyword": "Given",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Given('je suis sur la page {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n this.navigateTo(`#/demo/${screenId}`);\n});",
|
||||
"lineNumber": 38
|
||||
},
|
||||
{
|
||||
"pattern": "je suis connecté en tant qu'utilisateur",
|
||||
"keyword": "Given",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Given('je suis connecté en tant qu\\'utilisateur', async function (this: FestipodWorld) {\n this.isAuthenticated = true;\n});",
|
||||
"lineNumber": 43
|
||||
},
|
||||
{
|
||||
"pattern": "je suis connecté",
|
||||
"keyword": "Given",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Given('je suis connecté', async function (this: FestipodWorld) {\n this.isAuthenticated = true;\n});",
|
||||
"lineNumber": 47
|
||||
},
|
||||
{
|
||||
"pattern": "je ne suis pas connecté",
|
||||
"keyword": "Given",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Given('je ne suis pas connecté', async function (this: FestipodWorld) {\n this.isAuthenticated = false;\n});",
|
||||
"lineNumber": 51
|
||||
},
|
||||
{
|
||||
"pattern": "je navigue vers {string}",
|
||||
"keyword": "When",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "When('je navigue vers {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n this.navigateTo(`#/demo/${screenId}`);\n});",
|
||||
"lineNumber": 55
|
||||
},
|
||||
{
|
||||
"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});",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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": "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
|
||||
},
|
||||
{
|
||||
"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": "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": 183
|
||||
}
|
||||
];
|
||||
|
||||
export function findStepDefinition(stepText: string): StepDefinitionInfo | null {
|
||||
for (const def of stepDefinitions) {
|
||||
// Convert Cucumber expression to regex
|
||||
// {string} -> "[^"]+"
|
||||
// {int} -> \\d+
|
||||
const regexPattern = def.pattern
|
||||
.replace(/\{string\}/g, '"[^"]+"')
|
||||
.replace(/\{int\}/g, '\\d+');
|
||||
|
||||
try {
|
||||
const regex = new RegExp(regexPattern);
|
||||
if (regex.test(stepText)) {
|
||||
return def;
|
||||
}
|
||||
} catch {
|
||||
// If pattern fails, try simple includes
|
||||
const simplified = def.pattern.replace(/\{string\}/g, '').replace(/\{int\}/g, '').trim();
|
||||
if (stepText.includes(simplified)) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+239
-270
@@ -1,377 +1,346 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Sketchy wireframe theme - Font loaded via link tag in HTML */
|
||||
/* Modern clean theme - DM Sans */
|
||||
:root {
|
||||
--sketch-black: #2d2d2d;
|
||||
--sketch-gray: #666;
|
||||
--sketch-light-gray: #e5e5e5;
|
||||
--sketch-bg: #fafafa;
|
||||
--sketch-white: #ffffff;
|
||||
--sketch-accent: #4a90d9;
|
||||
--sketch-line-width: 2px;
|
||||
--font-sketch: 'Ubuntu', sans-serif;
|
||||
|
||||
/* Prototyping tool theme (outer app) */
|
||||
--tool-bg: #fafafa;
|
||||
--tool-surface: #ffffff;
|
||||
--tool-text: #2d2d2d;
|
||||
--tool-text-muted: #666;
|
||||
--tool-border: #2d2d2d;
|
||||
--tool-border-light: #e5e5e5;
|
||||
}
|
||||
|
||||
/* Dark mode for prototyping tool only */
|
||||
.dark {
|
||||
--tool-bg: #1a1a1a;
|
||||
--tool-surface: #2d2d2d;
|
||||
--tool-text: #f5f5f5;
|
||||
--tool-text-muted: #a0a0a0;
|
||||
--tool-border: #4a4a4a;
|
||||
--tool-border-light: #3a3a3a;
|
||||
--app-black: #1a1a1a;
|
||||
--app-gray: #888;
|
||||
--app-light-gray: #f0f0f0;
|
||||
--app-bg: #ffffff;
|
||||
--app-white: #ffffff;
|
||||
--app-accent: #E8590C;
|
||||
--app-accent-light: #FFF7ED;
|
||||
--app-accent-border: #FDDCB5;
|
||||
--app-accent-dark: #C05621;
|
||||
--app-green: #22543D;
|
||||
--app-green-light: #f7fff7;
|
||||
--app-green-border: #c6f6d5;
|
||||
--app-green-text: #68D391;
|
||||
--app-radius: 16px;
|
||||
--app-radius-sm: 12px;
|
||||
--app-radius-xs: 8px;
|
||||
--font-app: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sketch);
|
||||
background-color: var(--tool-bg);
|
||||
color: var(--tool-text);
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Sketchy border effect using border-radius variations */
|
||||
.sketchy-border {
|
||||
border: var(--sketch-line-width) solid var(--sketch-black);
|
||||
border-radius: 255px 15px 225px 15px/15px 225px 15px 255px;
|
||||
box-shadow:
|
||||
2px 2px 0 var(--sketch-black),
|
||||
-1px -1px 0 var(--sketch-black);
|
||||
body {
|
||||
font-family: var(--font-app);
|
||||
background-color: var(--app-bg);
|
||||
color: var(--app-black);
|
||||
}
|
||||
|
||||
/* Alternative sketchy border - more subtle */
|
||||
.sketchy-border-light {
|
||||
border: 1.5px solid var(--sketch-black);
|
||||
border-radius: 3px 15px 4px 12px/12px 4px 15px 3px;
|
||||
}
|
||||
|
||||
/* Hand-drawn line effect */
|
||||
.sketchy-line {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sketchy-line::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--sketch-black);
|
||||
transform: rotate(-0.5deg);
|
||||
}
|
||||
|
||||
/* Sketchy button styles */
|
||||
.sketchy-btn {
|
||||
font-family: var(--font-sketch);
|
||||
font-size: 16px;
|
||||
padding: 10px 20px;
|
||||
background: var(--sketch-white);
|
||||
border: var(--sketch-line-width) solid var(--sketch-black);
|
||||
border-radius: 255px 15px 225px 15px/15px 225px 15px 255px;
|
||||
/* Modern button styles */
|
||||
.app-btn {
|
||||
font-family: var(--font-app);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 12px 20px;
|
||||
background: var(--app-white);
|
||||
border: 1.5px solid #e0e0e0;
|
||||
border-radius: var(--app-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
color: var(--app-black);
|
||||
}
|
||||
|
||||
.sketchy-btn:hover {
|
||||
transform: translate(-1px, -1px);
|
||||
box-shadow: 3px 3px 0 var(--sketch-black);
|
||||
.app-btn:hover {
|
||||
background: #f9f9f9;
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
.sketchy-btn:active {
|
||||
transform: translate(1px, 1px);
|
||||
box-shadow: none;
|
||||
.app-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.sketchy-btn-primary {
|
||||
background: var(--sketch-black);
|
||||
color: var(--sketch-white);
|
||||
.app-btn-primary {
|
||||
background: var(--app-accent);
|
||||
color: var(--app-white);
|
||||
border-color: var(--app-accent);
|
||||
}
|
||||
|
||||
.sketchy-btn-primary:hover {
|
||||
background: var(--sketch-gray);
|
||||
.app-btn-primary:hover {
|
||||
background: #d14e0a;
|
||||
border-color: #d14e0a;
|
||||
}
|
||||
|
||||
/* Sketchy input styles */
|
||||
.sketchy-input {
|
||||
font-family: var(--font-sketch);
|
||||
font-size: 16px;
|
||||
padding: 10px 14px;
|
||||
background: var(--sketch-white);
|
||||
border: var(--sketch-line-width) solid var(--sketch-black);
|
||||
border-radius: 3px 15px 4px 12px/12px 4px 15px 3px;
|
||||
.app-btn-green {
|
||||
background: var(--app-green);
|
||||
color: var(--app-white);
|
||||
border-color: var(--app-green);
|
||||
}
|
||||
|
||||
/* Modern input styles */
|
||||
.app-input {
|
||||
font-family: var(--font-app);
|
||||
font-size: 14px;
|
||||
padding: 12px 14px;
|
||||
background: var(--app-white);
|
||||
border: 1.5px solid #e0e0e0;
|
||||
border-radius: var(--app-radius-sm);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
color: var(--app-black);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.sketchy-input:focus {
|
||||
box-shadow: 2px 2px 0 var(--sketch-black);
|
||||
.app-input:focus {
|
||||
border-color: var(--app-accent);
|
||||
box-shadow: 0 0 0 3px rgba(232, 89, 12, 0.1);
|
||||
}
|
||||
|
||||
.sketchy-input::placeholder {
|
||||
color: var(--sketch-gray);
|
||||
opacity: 0.7;
|
||||
.app-input::placeholder {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* Sketchy card */
|
||||
.sketchy-card {
|
||||
background: var(--sketch-white);
|
||||
border: var(--sketch-line-width) solid var(--sketch-black);
|
||||
border-radius: 255px 15px 225px 15px/15px 225px 15px 255px;
|
||||
padding: 20px;
|
||||
/* Modern card */
|
||||
.app-card {
|
||||
background: var(--app-white);
|
||||
border: 1px solid #eee;
|
||||
border-radius: var(--app-radius);
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
/* Sketchy text styles */
|
||||
.sketchy-title {
|
||||
font-family: var(--font-sketch);
|
||||
font-size: 24px;
|
||||
font-weight: normal;
|
||||
.app-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Text styles */
|
||||
.app-title {
|
||||
font-family: var(--font-app);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 10px 0;
|
||||
color: var(--app-black);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.sketchy-subtitle {
|
||||
font-family: var(--font-sketch);
|
||||
font-size: 18px;
|
||||
color: var(--sketch-gray);
|
||||
.app-subtitle {
|
||||
font-family: var(--font-app);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--app-black);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.sketchy-text {
|
||||
font-family: var(--font-sketch);
|
||||
font-size: 16px;
|
||||
.app-text {
|
||||
font-family: var(--font-app);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--app-black);
|
||||
}
|
||||
|
||||
/* Sketchy placeholder box (for images/content) */
|
||||
.sketchy-placeholder {
|
||||
background: var(--sketch-light-gray);
|
||||
border: 2px dashed var(--sketch-gray);
|
||||
border-radius: 4px;
|
||||
/* Placeholder box */
|
||||
.app-placeholder {
|
||||
background: var(--app-light-gray);
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: var(--app-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--sketch-gray);
|
||||
font-family: var(--font-sketch);
|
||||
}
|
||||
|
||||
/* Sketchy divider */
|
||||
.sketchy-divider {
|
||||
height: 2px;
|
||||
background: var(--sketch-black);
|
||||
margin: 16px 0;
|
||||
transform: rotate(-0.3deg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Sketchy checkbox */
|
||||
.sketchy-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--sketch-black);
|
||||
border-radius: 2px 6px 3px 5px/5px 3px 6px 2px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--sketch-white);
|
||||
}
|
||||
|
||||
.sketchy-checkbox.checked::after {
|
||||
content: '✓';
|
||||
color: var(--app-gray);
|
||||
font-family: var(--font-app);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Sketchy toggle/switch */
|
||||
.sketchy-toggle {
|
||||
/* Divider */
|
||||
.app-divider {
|
||||
height: 1px;
|
||||
background: #f0f0f0;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
.app-toggle {
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
border: 2px solid var(--sketch-black);
|
||||
border-radius: 255px 15px 225px 15px/15px 225px 15px 255px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background: var(--sketch-white);
|
||||
background: #e0e0e0;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.sketchy-toggle::after {
|
||||
.app-toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--sketch-black);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: var(--app-white);
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: left 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.sketchy-toggle.on::after {
|
||||
.app-toggle.on {
|
||||
background: var(--app-accent);
|
||||
}
|
||||
|
||||
.app-toggle.on::after {
|
||||
left: 25px;
|
||||
}
|
||||
|
||||
/* Sketchy icon placeholder */
|
||||
.sketchy-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
/* Checkbox */
|
||||
.app-checkbox {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--app-white);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
/* Sketchy nav bar */
|
||||
.sketchy-navbar {
|
||||
.app-checkbox.checked {
|
||||
background: var(--app-accent);
|
||||
border-color: var(--app-accent);
|
||||
}
|
||||
|
||||
.app-checkbox.checked::after {
|
||||
content: '✓';
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Nav bar */
|
||||
.app-navbar {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 12px 8px;
|
||||
background: var(--sketch-white);
|
||||
border-top: 2px solid var(--sketch-black);
|
||||
align-items: center;
|
||||
padding: 10px 0 20px;
|
||||
background: var(--app-white);
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
/* Sketchy header */
|
||||
.sketchy-header {
|
||||
/* Header */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--sketch-white);
|
||||
border-bottom: 2px solid var(--sketch-black);
|
||||
padding: 8px 16px;
|
||||
background: var(--app-white);
|
||||
}
|
||||
|
||||
/* Sketchy list item */
|
||||
.sketchy-list-item {
|
||||
/* List item */
|
||||
.app-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--sketch-light-gray);
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sketchy-list-item:hover {
|
||||
background: var(--sketch-light-gray);
|
||||
.app-list-item:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* Sketchy avatar */
|
||||
.sketchy-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid var(--sketch-black);
|
||||
/* Avatar */
|
||||
.app-avatar {
|
||||
border-radius: 50%;
|
||||
background: var(--sketch-light-gray);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: -0.3px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Sketchy badge */
|
||||
.sketchy-badge {
|
||||
/* Badge / Tag */
|
||||
.app-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border: 1.5px solid var(--sketch-black);
|
||||
border-radius: 255px 15px 225px 15px/15px 225px 15px 255px;
|
||||
background: var(--sketch-white);
|
||||
padding: 3px 10px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
border-radius: 20px;
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* App layout */
|
||||
.app-tag {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: #5a3e00;
|
||||
background: #fff3d6;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
|
||||
/* Section label */
|
||||
.app-section-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.app-tab {
|
||||
flex: 1;
|
||||
padding: 10px 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2.5px solid transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
font-family: var(--font-app);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.app-tab.active {
|
||||
color: var(--app-accent);
|
||||
border-bottom-color: var(--app-accent);
|
||||
}
|
||||
|
||||
/* App layout — max-width for tablet portrait */
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Gallery grid */
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.gallery-item:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* Phone frame thumbnail */
|
||||
.phone-thumbnail {
|
||||
width: 100%;
|
||||
aspect-ratio: 9/16;
|
||||
border: 2px solid var(--sketch-black);
|
||||
border-radius: 20px;
|
||||
background: var(--app-white);
|
||||
overflow: hidden;
|
||||
background: var(--sketch-white);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Mobile Screen Accent Colors (Blue Pen Style)
|
||||
Only applies within phone frames
|
||||
|
||||
Concept:
|
||||
- Black = structural UI (labels, counters, buttons, borders)
|
||||
- Blue = user-provided content only (names, event titles, descriptions, usernames)
|
||||
=========================================== */
|
||||
|
||||
/* Force phone screen to always use light mode colors */
|
||||
.phone-screen {
|
||||
color: var(--sketch-black);
|
||||
background: var(--sketch-white);
|
||||
/* Make the screen's outer div fill the container so BottomNav sticks at bottom */
|
||||
.app-container > div:first-child {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Dark mode: add outer glow to phone frame for visibility */
|
||||
.dark .phone-frame-wrapper {
|
||||
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15);
|
||||
border-radius: 44px;
|
||||
}
|
||||
|
||||
/* User content - displayed in blue */
|
||||
.phone-screen .user-content {
|
||||
color: var(--sketch-accent);
|
||||
}
|
||||
|
||||
/* Avatar initials (user's initials = user content) */
|
||||
.phone-screen .sketchy-avatar {
|
||||
color: var(--sketch-accent);
|
||||
border-color: var(--sketch-black);
|
||||
}
|
||||
|
||||
/* Input text - only text inputs show blue (user types content) */
|
||||
/* Date/time inputs show default values which are not user content */
|
||||
.phone-screen .sketchy-input[type="text"],
|
||||
.phone-screen .sketchy-input:not([type]) {
|
||||
color: var(--sketch-accent);
|
||||
}
|
||||
|
||||
/* Date and time inputs show placeholder-like default values */
|
||||
.phone-screen .sketchy-input[type="date"],
|
||||
.phone-screen .sketchy-input[type="time"] {
|
||||
color: var(--sketch-gray);
|
||||
}
|
||||
|
||||
/* Textarea also uses gray for placeholder state */
|
||||
.phone-screen textarea.sketchy-input {
|
||||
color: var(--sketch-gray);
|
||||
}
|
||||
|
||||
.phone-screen .sketchy-input::placeholder {
|
||||
color: var(--sketch-gray);
|
||||
/* Online indicator on avatar */
|
||||
.app-avatar .online-dot {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #34C759;
|
||||
border: 2.5px solid #fff;
|
||||
}
|
||||
|
||||
+3
-3
@@ -5,11 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<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>
|
||||
<title>Festipod</title>
|
||||
<script type="module" src="./app/frontend.tsx" async></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# language: fr
|
||||
@AUTH @priority-1
|
||||
Fonctionnalité: Connexion NextGraph et chargement des données
|
||||
En tant qu'utilisateur
|
||||
Je peux me connecter à mon portefeuille NextGraph
|
||||
Et charger les données de test dans mon portefeuille
|
||||
Afin d'utiliser l'application avec mes propres données
|
||||
|
||||
# --- UI layer: écran de connexion ---
|
||||
|
||||
@ui
|
||||
Scénario: L'écran de connexion affiche le bouton NextGraph
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran contient un bouton "Se connecter avec NextGraph"
|
||||
|
||||
@ui @wip
|
||||
# Behavioral: requires simulating an NG status change. Better tested at the
|
||||
# @e2e layer where a real connected session triggers the redirect.
|
||||
Scénario: L'écran de connexion redirige automatiquement quand connecté
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran gère la redirection automatique après connexion
|
||||
|
||||
@ui
|
||||
Scénario: L'état initial est "en cours" quand une connexion est en attente
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran gère l'état de connexion en cours
|
||||
|
||||
@ui
|
||||
Scénario: Aucune donnée de démonstration n'est visible pendant la connexion
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran n'importe pas de données de démonstration
|
||||
|
||||
# --- Data layer: comportement du portefeuille ---
|
||||
|
||||
@data
|
||||
Scénario: Un portefeuille connecté est vide par défaut
|
||||
Alors le portefeuille est connecté
|
||||
Et le portefeuille ne contient aucun événement de démonstration
|
||||
|
||||
@data
|
||||
Scénario: Charger les données de test dans le portefeuille
|
||||
Étant donné que le portefeuille est vide
|
||||
Quand je charge les données de test
|
||||
Alors le portefeuille contient des événements
|
||||
Et le portefeuille contient des utilisateurs
|
||||
|
||||
@data
|
||||
Scénario: Les données de test ne sont pas rechargées si le portefeuille contient déjà des données
|
||||
Étant donné que le portefeuille contient déjà des événements
|
||||
Quand je charge les données de test
|
||||
Alors le nombre d'événements n'a pas changé
|
||||
|
||||
@data
|
||||
Scénario: Les données du portefeuille sont distinctes des données par défaut
|
||||
Étant donné que le portefeuille est vide
|
||||
Quand je charge les données de test
|
||||
Alors les événements ont des identifiants NextGraph
|
||||
Et les utilisateurs ont des identifiants NextGraph
|
||||
|
||||
# --- E2E layer: comportement réel dans le navigateur ---
|
||||
|
||||
@e2e
|
||||
Scénario: L'écran de connexion redirige vers l'accueil si déjà connecté
|
||||
Quand l'utilisateur navigue vers l'écran "login"
|
||||
Alors l'application affiche l'écran "home"
|
||||
|
||||
@e2e
|
||||
Scénario: La navigation interne met à jour l'URL
|
||||
Quand l'utilisateur navigue vers l'écran "events"
|
||||
Alors l'URL contient "/events"
|
||||
|
||||
@e2e
|
||||
Scénario: L'application ne redirige pas vers le broker quand elle est dans l'iframe
|
||||
Alors l'application est toujours dans l'iframe
|
||||
|
||||
@e2e
|
||||
Scénario: La liste des événements est peuplée après connexion
|
||||
Quand l'utilisateur navigue vers l'écran "events"
|
||||
Alors l'écran d'accueil affiche des événements
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
import { withProviders } from '../../../../.storybook/decorators';
|
||||
|
||||
const meta: Meta<typeof LoginScreen> = {
|
||||
title: 'Screens/Auth/LoginScreen',
|
||||
component: LoginScreen,
|
||||
decorators: [withProviders],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof LoginScreen>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Input, Title, Text, Divider } from '../../../shared/components/sketchy';
|
||||
import { useNextGraph } from '../../../shared/context/NextGraphContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
|
||||
export function LoginScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { status, connect } = useNextGraph();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'connected') {
|
||||
navigate('/home');
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
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, color: '#888' }}>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: '#22543D', 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: '#888', marginTop: 8 }}>
|
||||
NextGraph non disponible — mode démonstration
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#888', margin: '16px 0' }}>
|
||||
ou connexion classique (démo)
|
||||
</Text>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<Text style={{ marginBottom: 4, fontSize: 13, color: '#888' }}>Email</Text>
|
||||
<Input type="email" placeholder="vous@exemple.com" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text style={{ marginBottom: 4, fontSize: 13, color: '#888' }}>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: '#E8590C' }}>
|
||||
Mot de passe oublié ?
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#888' }}>
|
||||
Pas encore de compte ? <span style={{ color: '#E8590C', cursor: 'pointer' }}>S'inscrire</span>
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { WelcomeScreen } from './WelcomeScreen';
|
||||
import { withProviders } from '../../../../.storybook/decorators';
|
||||
|
||||
const meta: Meta<typeof WelcomeScreen> = {
|
||||
title: 'Screens/Auth/WelcomeScreen',
|
||||
component: WelcomeScreen,
|
||||
decorators: [withProviders],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof WelcomeScreen>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -1,24 +1,24 @@
|
||||
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 { useNavigate } from '../../../app/router';
|
||||
|
||||
export function WelcomeScreen({ navigate }: ScreenProps) {
|
||||
export function WelcomeScreen() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 24 }}>Festipod</Title>
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 18, marginBottom: 32, lineHeight: 1.5 }}>
|
||||
<Text style={{ textAlign: 'center', fontSize: 18, marginBottom: 32, lineHeight: 1.5, color: '#555' }}>
|
||||
Découvrez des événements près de chez vous, relayés par des gens de confiance.
|
||||
</Text>
|
||||
|
||||
<div style={{
|
||||
background: 'var(--sketch-light-gray)',
|
||||
background: '#f9f9f9',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
borderRadius: 16,
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
<Text style={{ margin: 0, fontSize: 14, lineHeight: 1.6, color: 'var(--sketch-gray)' }}>
|
||||
<Text style={{ margin: 0, fontSize: 14, lineHeight: 1.6, color: '#888' }}>
|
||||
Festipod est un projet collaboratif en construction. Nous croyons qu'on découvre
|
||||
les meilleurs événements grâce au bouche-à-oreille, pas via des algorithmes.
|
||||
Rejoignez les premiers utilisateurs et aidez-nous à créer une alternative
|
||||
@@ -41,16 +41,16 @@ export function WelcomeScreen({ navigate }: ScreenProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" onClick={() => navigate('login')} style={{ marginBottom: 12 }}>
|
||||
<Button variant="primary" onClick={() => navigate('/login')} style={{ marginBottom: 12 }}>
|
||||
Rejoindre la communauté
|
||||
</Button>
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 13, color: 'var(--sketch-gray)' }}>
|
||||
Déjà membre ? Se connecter
|
||||
<Text style={{ textAlign: 'center', fontSize: 13, color: '#888' }}>
|
||||
Déjà membre ? <span onClick={() => navigate('/login')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion</span>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 12, color: 'var(--sketch-gray)' }}>
|
||||
<Text style={{ textAlign: 'center', fontSize: 12, color: '#bbb' }}>
|
||||
Version beta - 127 membres actifs
|
||||
</Text>
|
||||
</div>
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Seed data matching what bootstrapWallet uses
|
||||
import { seedEvents, seedUsers } from '../../../../shared/data/seedData';
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
Given('le portefeuille est vide', async function (this: FestipodWorld) {
|
||||
// Verify starting state: the harness graph should have its own seeded data.
|
||||
// We clear events/users/participations to simulate a truly empty wallet.
|
||||
await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
// Delete all events
|
||||
for (const e of [...td.events]) td.events.delete(e);
|
||||
// Delete all users
|
||||
for (const u of [...td.users]) td.users.delete(u);
|
||||
// Delete all participations
|
||||
for (const p of [...td.participations]) td.participations.delete(p);
|
||||
});
|
||||
|
||||
// Verify empty
|
||||
const counts = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return { events: td.events.size, users: td.users.size, participations: td.participations.size };
|
||||
});
|
||||
expect(counts.events, 'Events should be empty').to.equal(0);
|
||||
expect(counts.users, 'Users should be empty').to.equal(0);
|
||||
});
|
||||
|
||||
Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {
|
||||
const count = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size;
|
||||
});
|
||||
// If empty, seed some data first so the precondition holds
|
||||
if (count === 0) {
|
||||
await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
td.loadTestData();
|
||||
});
|
||||
// Wait for data to propagate
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__testData.events.size > 0,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
When('je charge les données de test', async function (this: FestipodWorld) {
|
||||
// Store count before loading for the idempotency test
|
||||
const countBefore = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size;
|
||||
});
|
||||
(this as any)._eventCountBefore = countBefore;
|
||||
|
||||
await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
td.loadTestData();
|
||||
});
|
||||
|
||||
// Wait for data to propagate (if wallet was empty, data should appear)
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const td = (window as any).__testData;
|
||||
// Either data was already there, or it should appear after loading
|
||||
return td.events.size > 0 || td._loadResult?.seeded === false;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
).catch(() => {
|
||||
// Timeout is OK if wallet was already populated (idempotent case)
|
||||
});
|
||||
});
|
||||
|
||||
// --- Assertions ---
|
||||
|
||||
Then('le portefeuille est connecté', async function (this: FestipodWorld) {
|
||||
const hasSession = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.session !== undefined && td.session !== null;
|
||||
});
|
||||
expect(hasSession, 'Wallet should have an active NG session').to.be.true;
|
||||
});
|
||||
|
||||
Then('le portefeuille ne contient aucun événement de démonstration', async function (this: FestipodWorld) {
|
||||
// Wallet should be empty — no auto-seeding.
|
||||
// Also verify through the app's data context (same view as screens).
|
||||
const result = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return {
|
||||
walletEvents: td.events.size,
|
||||
appEvents: td.appData?.events?.length ?? -1,
|
||||
ngStatus: td.ngStatus,
|
||||
};
|
||||
});
|
||||
expect(result.walletEvents, 'Wallet should have no events').to.equal(0);
|
||||
// App-level view should also show no events (providers working correctly)
|
||||
expect(result.appEvents, 'App data context should show no events').to.equal(0);
|
||||
expect(result.ngStatus, 'NG status should be connected').to.equal('connected');
|
||||
});
|
||||
|
||||
Then('le portefeuille contient des événements', async function (this: FestipodWorld) {
|
||||
const count = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size;
|
||||
});
|
||||
expect(count, 'Wallet should contain events').to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
Then('le portefeuille contient des utilisateurs', async function (this: FestipodWorld) {
|
||||
const count = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.users.size;
|
||||
});
|
||||
expect(count, 'Wallet should contain users').to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
Then('le nombre d\'événements n\'a pas changé', async function (this: FestipodWorld) {
|
||||
const countBefore = (this as any)._eventCountBefore as number;
|
||||
const countAfter = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size;
|
||||
});
|
||||
expect(countAfter, 'Event count should not change after reload').to.equal(countBefore);
|
||||
});
|
||||
|
||||
Then('les événements ont des identifiants NextGraph', async function (this: FestipodWorld) {
|
||||
const ids = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return [...td.events].map((e: any) => e['@id']);
|
||||
});
|
||||
expect(ids.length, 'Should have events').to.be.greaterThan(0);
|
||||
for (const id of ids) {
|
||||
expect(id, `Event ID "${id}" should be a NextGraph IRI`).to.match(/^did:ng:/);
|
||||
}
|
||||
});
|
||||
|
||||
Then('les utilisateurs ont des identifiants NextGraph', async function (this: FestipodWorld) {
|
||||
const ids = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return [...td.users].map((u: any) => u['@id']);
|
||||
});
|
||||
expect(ids.length, 'Should have users').to.be.greaterThan(0);
|
||||
for (const id of ids) {
|
||||
expect(id, `User ID "${id}" should be a NextGraph IRI`).to.match(/^did:ng:/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// --- E2E step definitions ---
|
||||
// These interact with the REAL app running in the browser (via broker iframe),
|
||||
// not the test harness bridge. They test actual UI behavior.
|
||||
//
|
||||
// The app uses path-based routing (History API). To navigate from a test we
|
||||
// push the new path and dispatch a popstate event, which the router listens to.
|
||||
|
||||
// Screen content markers — text that uniquely identifies each screen
|
||||
const SCREEN_MARKERS: Record<string, string> = {
|
||||
'home': 'Festipod',
|
||||
'events': 'Découvrir',
|
||||
'login': 'connecter',
|
||||
'profile': 'Mon profil',
|
||||
'create-event': "Relayer un événement",
|
||||
'settings': 'Paramètres',
|
||||
'event-detail': 'Participants',
|
||||
'update-event': "Modifier l'événement",
|
||||
'friends-list': 'Mon réseau',
|
||||
'invite': 'Inviter',
|
||||
'meeting-points': 'Point de rencontre',
|
||||
'share-profile': 'Partager mon profil',
|
||||
'connect': 'Se connecter',
|
||||
'user-profile': 'Profil',
|
||||
'edit-profile': 'Modifier le profil',
|
||||
};
|
||||
|
||||
// Map a screen id to a path. Some screens require an id (like event-detail);
|
||||
// for those we accept a placeholder and rely on the existing test data.
|
||||
function pathForScreen(screenId: string): string {
|
||||
switch (screenId) {
|
||||
case 'home': return '/home';
|
||||
case 'events': return '/events';
|
||||
case 'create-event': return '/events/new';
|
||||
case 'login': return '/login';
|
||||
case 'profile': return '/profile';
|
||||
case 'edit-profile': return '/profile/edit';
|
||||
case 'friends-list': return '/profile/friends';
|
||||
case 'share-profile': return '/profile/share';
|
||||
case 'connect': return '/profile/connect';
|
||||
case 'settings': return '/settings';
|
||||
// Screens that need a real id are usually reached via in-app clicks rather
|
||||
// than direct navigation in e2e tests. The fallback "/events/$id$" lets the
|
||||
// test author override later if needed.
|
||||
case 'event-detail': return '/events/$id$';
|
||||
case 'update-event': return '/events/$id$/edit';
|
||||
case 'invite': return '/events/$id$/invite';
|
||||
case 'meeting-points': return '/events/$id$/meeting-points';
|
||||
case 'participants-list': return '/events/$id$/participants';
|
||||
case 'user-profile': return '/users/$id$';
|
||||
default: return '/' + screenId;
|
||||
}
|
||||
}
|
||||
|
||||
When('l\'utilisateur navigue vers l\'écran {string}', async function (this: FestipodWorld, screenId: string) {
|
||||
const target = pathForScreen(screenId);
|
||||
await this.appFrame!.evaluate((path: string) => {
|
||||
window.history.pushState(null, '', path);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}, target);
|
||||
await this.appFrame!.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
When('l\'utilisateur navigue vers l\'écran {string} sans historique', async function (this: FestipodWorld, screenId: string) {
|
||||
const target = pathForScreen(screenId);
|
||||
await this.appFrame!.evaluate((path: string) => {
|
||||
window.history.replaceState(null, '', path);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}, target);
|
||||
await this.appFrame!.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
Then('l\'application est toujours dans l\'iframe', async function (this: FestipodWorld) {
|
||||
const url = await this.appFrame!.evaluate(() => window.location.href);
|
||||
expect(url, 'App should still be on localhost, not redirected to broker').to.include('127.0.0.1');
|
||||
|
||||
const hasContent = await this.appFrame!.evaluate(() => {
|
||||
const root = document.getElementById('root');
|
||||
return root && root.innerHTML.length > 100;
|
||||
});
|
||||
expect(hasContent, 'App should have rendered content').to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'application affiche l\'écran {string}', async function (this: FestipodWorld, expectedScreenId: string) {
|
||||
const marker = SCREEN_MARKERS[expectedScreenId];
|
||||
const expectedPath = pathForScreen(expectedScreenId).replace('$id$', '');
|
||||
|
||||
const appeared = await this.appFrame!.waitForFunction(
|
||||
([path, markerText]: [string, string | undefined]) => {
|
||||
const current = window.location.pathname;
|
||||
// Allow for paths with dynamic ids — match the prefix
|
||||
const matchesPath = path.endsWith('/')
|
||||
? current.startsWith(path)
|
||||
: current === path || current.startsWith(path + '/');
|
||||
if (!matchesPath) return false;
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root || root.innerHTML.length < 100) return false;
|
||||
|
||||
if (markerText) {
|
||||
return root.textContent?.includes(markerText) ?? false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[expectedPath, marker] as [string, string | undefined],
|
||||
{ timeout: 10000 },
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!appeared) {
|
||||
const debug = await this.appFrame!.evaluate(() => ({
|
||||
pathname: window.location.pathname,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 300),
|
||||
}));
|
||||
expect.fail(
|
||||
`Expected screen "${expectedScreenId}" (path "${expectedPath}", marker "${marker}") ` +
|
||||
`but got pathname="${debug.pathname}", content: "${debug.rootText}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Then('l\'URL contient {string}', async function (this: FestipodWorld, expected: string) {
|
||||
const pathname = await this.appFrame!.evaluate(() => window.location.pathname);
|
||||
expect(pathname, `URL pathname should contain "${expected}"`).to.include(expected);
|
||||
});
|
||||
|
||||
When('l\'utilisateur clique sur le bouton {string}', async function (this: FestipodWorld, buttonText: string) {
|
||||
const button = this.appFrame!.locator(`button`, { hasText: buttonText });
|
||||
await button.first().click();
|
||||
await this.appFrame!.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
When('l\'utilisateur attend la fin du chargement', async function (this: FestipodWorld) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
return !buttons.some(b => b.textContent?.includes('Chargement...'));
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await this.appFrame!.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
Then('l\'écran d\'accueil affiche des événements', async function (this: FestipodWorld) {
|
||||
// Navigate to the events screen (path-based) and verify cards are rendered.
|
||||
// Home shows only events the current user participates in, which depends
|
||||
// on participations hydrating from NG — flaky for a basic data check.
|
||||
await this.appFrame!.evaluate(() => {
|
||||
window.history.pushState(null, '', '/events');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
|
||||
const appeared = await this.appFrame!.waitForFunction(
|
||||
() => document.querySelectorAll('.app-card').length > 0,
|
||||
{ timeout: 15000 },
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!appeared) {
|
||||
const debug = await this.appFrame!.evaluate(() => ({
|
||||
pathname: window.location.pathname,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 500),
|
||||
}));
|
||||
expect.fail(
|
||||
`Expected events screen with cards but got pathname="${debug.pathname}", ` +
|
||||
`content: "${debug.rootText}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
Then('l\'écran gère la redirection automatique après connexion', async function (this: FestipodWorld) {
|
||||
// Behavioral — covered by the @e2e scenario
|
||||
// "L'écran de connexion redirige vers l'accueil si déjà connecté".
|
||||
// At the @ui layer we only verify the screen mounts cleanly.
|
||||
expect(this.currentScreenId).to.equal('login');
|
||||
expect(this.renderedDoc, 'Login screen should render').to.not.be.null;
|
||||
});
|
||||
|
||||
Then('l\'écran gère l\'état de connexion en cours', async function (this: FestipodWorld) {
|
||||
const source = this.getRenderedText();
|
||||
const hasConnectingState =
|
||||
source.includes("status === 'connecting'") ||
|
||||
source.includes("Connexion NextGraph en cours");
|
||||
expect(hasConnectingState, 'LoginScreen should handle connecting state').to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'écran n\'importe pas de données de démonstration', async function (this: FestipodWorld) {
|
||||
const source = this.getRenderedText();
|
||||
const importsSeedData = source.includes('seedData') || source.includes('seedEvents');
|
||||
const usesFestipodData = source.includes('useFestipodData');
|
||||
expect(importsSeedData, 'LoginScreen should not import seed data').to.be.false;
|
||||
expect(usesFestipodData, 'LoginScreen should not use FestipodData context').to.be.false;
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1
|
||||
Fonctionnalité: Cycle de vie d'un événement
|
||||
En tant qu'utilisateur connecté
|
||||
Je peux créer, consulter, modifier et participer à des événements
|
||||
Et ces actions persistent dans mon portefeuille NextGraph
|
||||
|
||||
Contexte:
|
||||
Étant donné que le portefeuille contient des données de test
|
||||
|
||||
# --- Création et persistance ---
|
||||
|
||||
@e2e
|
||||
Scénario: Créer un événement et vérifier qu'il apparaît sur l'accueil
|
||||
Quand l'utilisateur navigue vers l'écran "create-event"
|
||||
Et l'utilisateur remplit le formulaire de création d'événement:
|
||||
| champ | valeur |
|
||||
| Nom de l'événement | Pique-nique au parc |
|
||||
| Date de début | 2026-06-15 |
|
||||
| Heure de début | 14:00 |
|
||||
| Lieu | Parc Bordelais, Bordeaux |
|
||||
Et l'utilisateur clique sur le bouton "Relayer l'événement"
|
||||
Alors l'application affiche l'écran "event-detail"
|
||||
Et l'écran contient le texte "Pique-nique au parc"
|
||||
Quand l'utilisateur navigue vers l'écran "home"
|
||||
Alors l'écran contient le texte "Pique-nique au parc"
|
||||
|
||||
@e2e
|
||||
Scénario: L'événement créé persiste après reconnexion
|
||||
Alors l'écran d'accueil contient le texte "Pique-nique au parc"
|
||||
|
||||
# --- Consultation ---
|
||||
|
||||
@e2e
|
||||
Scénario: Consulter le détail d'un événement depuis l'accueil
|
||||
Quand l'utilisateur clique sur un événement de l'accueil
|
||||
Alors l'application affiche l'écran "event-detail"
|
||||
Et l'écran contient le texte "Participants"
|
||||
|
||||
# --- Inscription / Désinscription ---
|
||||
|
||||
@e2e
|
||||
Scénario: S'inscrire à un événement
|
||||
Quand l'utilisateur navigue vers l'écran "events"
|
||||
Et l'utilisateur clique sur un événement de la liste
|
||||
Et l'utilisateur attend que l'écran "event-detail" soit affiché
|
||||
Et l'utilisateur clique sur le bouton "J'y serai" si visible
|
||||
Alors l'écran contient le texte "Je participe"
|
||||
|
||||
# ngSet.delete() updates UI but doesn't persist — NG ORM limitation.
|
||||
@e2e @wip
|
||||
Scénario: Se désinscrire d'un événement
|
||||
Quand l'utilisateur navigue vers l'écran "events"
|
||||
Et l'utilisateur clique sur un événement de la liste
|
||||
Et l'utilisateur attend que l'écran "event-detail" soit affiché
|
||||
Et l'utilisateur clique sur le bouton "Je participe"
|
||||
Alors l'écran contient le texte "J'y serai"
|
||||
|
||||
@e2e @wip
|
||||
Scénario: La désinscription persiste après reconnexion
|
||||
Quand l'utilisateur navigue vers l'écran "events"
|
||||
Et l'utilisateur clique sur un événement de la liste
|
||||
Et l'utilisateur attend que l'écran "event-detail" soit affiché
|
||||
Alors l'écran contient le texte "J'y serai"
|
||||
|
||||
# --- Modification ---
|
||||
|
||||
@e2e
|
||||
Scénario: Modifier un événement et vérifier la persistance
|
||||
Quand l'utilisateur navigue vers l'écran "home"
|
||||
Et l'utilisateur clique sur un événement de l'accueil
|
||||
Et l'utilisateur attend que l'écran "event-detail" soit affiché
|
||||
Et l'utilisateur clique sur le bouton de modification
|
||||
Et l'utilisateur attend que l'écran "update-event" soit affiché
|
||||
Et l'utilisateur modifie le champ lieu avec "Jardin Public, Bordeaux"
|
||||
Et l'utilisateur clique sur le bouton "Enregistrer les modifications"
|
||||
Alors l'application affiche l'écran "event-detail"
|
||||
Et l'écran contient le texte "Jardin Public"
|
||||
+1
-4
@@ -19,13 +19,10 @@ Fonctionnalité: US-13 Relayer/Modifier/Supprimer un événement
|
||||
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"
|
||||
Alors l'écran contient un bouton "Suivant"
|
||||
|
||||
Scénario: Pouvoir annuler le relai d'événement
|
||||
Étant donné que je suis sur la page "relayer un événement"
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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
|
||||
|
||||
# --- UI ---
|
||||
|
||||
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: Voir le bouton d'inscription sur l'écran
|
||||
Étant donné que je suis sur la page "détail événement"
|
||||
Alors je peux m'inscrire à l'événement
|
||||
|
||||
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
|
||||
|
||||
# --- Data ---
|
||||
|
||||
@data
|
||||
Scénario: S'inscrire à un événement
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" a 8 participants au départ
|
||||
Quand l'utilisateur s'inscrit à l'événement "Formation CNV"
|
||||
Alors l'utilisateur est participant de l'événement "Formation CNV"
|
||||
Et l'utilisateur apparaît dans la liste des participants de l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" compte 9 participants
|
||||
|
||||
@data
|
||||
Scénario: Se désinscrire d'un événement
|
||||
Étant donné un événement "Résidence Reconnexion" existe
|
||||
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" a 12 participants au départ
|
||||
Quand l'utilisateur se désinscrit de l'événement "Résidence Reconnexion"
|
||||
Alors l'utilisateur n'est plus participant de l'événement "Résidence Reconnexion"
|
||||
Et l'utilisateur n'apparaît plus dans la liste des participants de l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" compte 11 participants
|
||||
|
||||
@data
|
||||
Scénario: L'inscription est idempotente
|
||||
Étant donné un événement "Résidence Reconnexion" existe
|
||||
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" a 12 participants au départ
|
||||
Quand l'utilisateur essaie de s'inscrire une seconde fois à l'événement "Résidence Reconnexion"
|
||||
Alors l'inscription est idempotente pour l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" compte 12 participants
|
||||
|
||||
@data
|
||||
Scénario: Se désinscrire d'un événement auquel on n'est pas inscrit
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" a 8 participants au départ
|
||||
Quand l'utilisateur se désinscrit de l'événement "Formation CNV"
|
||||
Alors l'utilisateur n'est plus participant de l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" compte 8 participants
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { CreateEventScreen } from './CreateEventScreen';
|
||||
import { withProviders } from '../../../../.storybook/decorators';
|
||||
|
||||
const meta: Meta<typeof CreateEventScreen> = {
|
||||
title: 'Screens/Event/CreateEventScreen',
|
||||
component: CreateEventScreen,
|
||||
decorators: [withProviders],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof CreateEventScreen>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,319 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
|
||||
const importableEvents = [
|
||||
{
|
||||
name: 'Festival des Utopies Concrètes',
|
||||
source: 'Mobilizon',
|
||||
date: '2026-03-15',
|
||||
location: 'Paris, Parc de la Villette',
|
||||
description: 'Festival annuel présentant des alternatives concrètes pour un monde durable.',
|
||||
},
|
||||
{
|
||||
name: "Rencontres de l'Écologie",
|
||||
source: 'Transiscope',
|
||||
date: '2026-04-20',
|
||||
location: 'Lyon, Halle Tony Garnier',
|
||||
description: 'Deux jours de conférences et ateliers sur la transition écologique.',
|
||||
},
|
||||
];
|
||||
|
||||
type Step = 1 | 2 | 3;
|
||||
|
||||
export function CreateEventScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { events, createEvent } = useFestipodData();
|
||||
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
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 [importedFrom, setImportedFrom] = useState<string | null>(null);
|
||||
|
||||
const similarExisting = useMemo(() => {
|
||||
if (name.trim().length < 3) return [];
|
||||
const lower = name.toLowerCase();
|
||||
return events.filter(e => e.title.toLowerCase().includes(lower.slice(0, 3)));
|
||||
}, [name, events]);
|
||||
|
||||
const importCandidate = useMemo(() => {
|
||||
if (importedFrom) return null;
|
||||
if (name.trim().length < 3) return null;
|
||||
const lower = name.toLowerCase();
|
||||
return importableEvents.find(e => e.name.toLowerCase().includes(lower.slice(0, 3))) ?? null;
|
||||
}, [name, importedFrom]);
|
||||
|
||||
const applyImport = (candidate: typeof importableEvents[number]) => {
|
||||
setName(candidate.name);
|
||||
setStartDate(candidate.date);
|
||||
setLocation(candidate.location);
|
||||
setDescription(candidate.description);
|
||||
setImportedFrom(candidate.source);
|
||||
showToast(`Données importées depuis ${candidate.source}`, 'info');
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (step === 1) {
|
||||
if (similarExisting.length > 0) setStep(2);
|
||||
else setStep(3);
|
||||
} else if (step === 2) {
|
||||
setStep(3);
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (step === 1) return navigate('/home');
|
||||
if (step === 3 && similarExisting.length === 0) return setStep(1);
|
||||
setStep((step - 1) as Step);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
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: ['Social'],
|
||||
hostName: 'Moi',
|
||||
hostInitials: 'MD',
|
||||
});
|
||||
showToast('Événement relayé', 'success');
|
||||
navigate(`/events/${newEvent.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Relayer un événement"
|
||||
left={
|
||||
step === 1 ? (
|
||||
<span onClick={goBack} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>
|
||||
) : (
|
||||
<ArrowLeft size={20} onClick={goBack} style={{ cursor: 'pointer' }} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, padding: '10px 16px 0' }}>
|
||||
{[1, 2, 3].map(s => (
|
||||
<div
|
||||
key={s}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background: s <= step ? '#E8590C' : '#eee',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
|
||||
{importCandidate && step === 1 && (
|
||||
<div
|
||||
style={{
|
||||
background: '#EFF6FF',
|
||||
border: '1.5px solid #93C5FD',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ margin: 0, fontWeight: 'bold', fontSize: 14, marginBottom: 4 }}>
|
||||
Données trouvées sur {importCandidate.source}
|
||||
</Text>
|
||||
<Text style={{ margin: 0, fontSize: 13, lineHeight: 1.5 }}>
|
||||
« {importCandidate.name} » — {importCandidate.location}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<Button variant="primary" style={{ padding: '6px 12px', fontSize: 13 }} onClick={() => applyImport(importCandidate)}>
|
||||
Importer
|
||||
</Button>
|
||||
<Button style={{ padding: '6px 12px', fontSize: 13 }} onClick={() => setImportedFrom('dismissed')}>
|
||||
Ignorer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Text style={{ fontSize: 13, color: '#888', margin: 0 }}>
|
||||
Commençons par l'essentiel : le nom et les dates.
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<Text style={{ marginBottom: 6, fontSize: 13, color: '#888' }}>Nom de l'événement *</Text>
|
||||
<Input
|
||||
placeholder="Donnez un nom à votre événement"
|
||||
value={name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(e.target.value);
|
||||
if (importedFrom === 'dismissed') setImportedFrom(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text style={{ marginBottom: 6, fontSize: 13, color: '#888' }}>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: 13, color: '#888' }}>Date de fin</Text>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
background: '#FEF3C7',
|
||||
border: '1.5px solid #F59E0B',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ margin: 0, fontWeight: 'bold', fontSize: 14, marginBottom: 4 }}>
|
||||
Événement similaire détecté
|
||||
</Text>
|
||||
<Text style={{ margin: 0, fontSize: 13, lineHeight: 1.5 }}>
|
||||
Un événement similaire a déjà été relayé. Peut-être s'agit-il du même ?
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{similarExisting.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
style={{
|
||||
padding: 14,
|
||||
border: '1.5px solid #eee',
|
||||
borderRadius: 14,
|
||||
}}
|
||||
>
|
||||
<Text style={{ margin: 0, fontWeight: 'bold', fontSize: 15 }}>{ev.title}</Text>
|
||||
<Text style={{ margin: '4px 0', fontSize: 13, color: '#888' }}>
|
||||
{ev.date} · {ev.location}
|
||||
</Text>
|
||||
{ev.hostName && (
|
||||
<Text style={{ margin: '0 0 10px 0', fontSize: 12, color: '#888' }}>
|
||||
Relayé par {ev.hostName}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ width: '100%', padding: 10, fontSize: 13 }}
|
||||
onClick={() => navigate(`/events/${ev.id}`)}
|
||||
>
|
||||
Voir cet événement
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Text style={{ fontSize: 12, color: '#888', margin: '8px 0 0 0', textAlign: 'center' }}>
|
||||
Aucun de ces événements ne correspond ? Continuez pour en créer un nouveau.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Placeholder
|
||||
height={140}
|
||||
label="+ Ajouter une photo"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text style={{ marginBottom: 6, fontSize: 13, color: '#888' }}>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: 13, color: '#888' }}>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: 13, color: '#888' }}>Lieu *</Text>
|
||||
<Input
|
||||
placeholder="Ajouter un lieu"
|
||||
value={location}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLocation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text style={{ marginBottom: 6, fontSize: 13, color: '#888' }}>Description</Text>
|
||||
<textarea
|
||||
className="app-input"
|
||||
placeholder="Décrivez votre événement..."
|
||||
rows={4}
|
||||
style={{ resize: 'none' }}
|
||||
value={description}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 16, borderTop: '1px solid #f0f0f0' }}>
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ width: '100%' }}
|
||||
onClick={goNext}
|
||||
disabled={step === 1 && (!name.trim() || !startDate)}
|
||||
>
|
||||
Suivant
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ width: '100%' }}
|
||||
onClick={submit}
|
||||
>
|
||||
Relayer l'événement
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { EventDetailScreen } from './EventDetailScreen';
|
||||
import { withProviders } from '../../../../.storybook/decorators';
|
||||
|
||||
const meta: Meta<typeof EventDetailScreen> = {
|
||||
title: 'Screens/Event/EventDetailScreen',
|
||||
component: EventDetailScreen,
|
||||
decorators: [withProviders],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof EventDetailScreen>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,175 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button, Avatar, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
|
||||
export function EventDetailScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { eventId } = useParams();
|
||||
const {
|
||||
getEvent,
|
||||
currentUserId,
|
||||
isParticipating,
|
||||
joinEvent,
|
||||
leaveEvent,
|
||||
getEventParticipants,
|
||||
getEventMeetingPoints,
|
||||
} = useFestipodData();
|
||||
|
||||
const event = eventId ? getEvent(eventId) : undefined;
|
||||
const joined = eventId ? isParticipating(eventId) : false;
|
||||
const participants = eventId ? getEventParticipants(eventId) : [];
|
||||
const meetingPointsRaw = eventId ? getEventMeetingPoints(eventId) : [];
|
||||
|
||||
const meetingPoints: MeetingPointData[] = meetingPointsRaw.map(mp => ({
|
||||
id: mp.id,
|
||||
title: mp.location,
|
||||
when: mp.time,
|
||||
duration: '~60 min',
|
||||
lieu: mp.location,
|
||||
}));
|
||||
|
||||
const isOwner = true;
|
||||
const knownParticipants = participants.filter(p => p.id !== currentUserId);
|
||||
|
||||
const handleToggleJoin = () => {
|
||||
if (!eventId) return;
|
||||
if (joined) {
|
||||
leaveEvent(eventId);
|
||||
showToast('Participation annulée', 'info');
|
||||
} else {
|
||||
joinEvent(eventId);
|
||||
showToast('Tu participes à cet événement', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button onClick={() => navigate('/events')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center' }}><ArrowLeft size={20} /></button>
|
||||
<span style={{ fontSize: 18, fontWeight: 700 }}>Événement</span>
|
||||
</div>
|
||||
<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%' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<div style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button onClick={() => navigate('/events')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#333', padding: 0, display: 'flex', alignItems: 'center' }}><ArrowLeft size={20} /></button>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="user-content" style={{ fontSize: 18, fontWeight: 700 }}>{event.title}</div>
|
||||
{event.distance != null && (
|
||||
<div style={{ fontSize: 12, color: '#888' }}>{event.distance} km</div>
|
||||
)}
|
||||
</div>
|
||||
{isOwner && (
|
||||
<span onClick={() => navigate(`/events/${eventId}/edit`)} style={{ cursor: 'pointer', fontSize: 18, color: '#888' }}>✎</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 16px 12px' }}>
|
||||
<EventCover eventId={event.id} height={100} />
|
||||
</div>
|
||||
|
||||
<div style={{ margin: '0 16px 12px', padding: 14, background: '#fafafa', borderRadius: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<span style={{ fontSize: 15 }}>📅</span>
|
||||
<div>
|
||||
<div className="user-content" style={{ fontSize: 13, fontWeight: 600 }}>{event.date}</div>
|
||||
{event.startTime && (
|
||||
<div style={{ fontSize: 12, color: '#888' }}>
|
||||
{event.startTime}{event.endTime ? ` – ${event.endTime}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 15 }}>📍</span>
|
||||
<span className="user-content" style={{ fontSize: 13 }}>{event.location}</span>
|
||||
</div>
|
||||
{event.description && (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<span style={{ fontSize: 15 }}>📝</span>
|
||||
<span className="user-content" style={{ fontSize: 13, color: '#555', lineHeight: 1.5 }}>
|
||||
{event.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ margin: '4px 16px 16px', display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
variant={joined ? 'green' : 'primary'}
|
||||
onClick={handleToggleJoin}
|
||||
style={{ flex: 1, padding: '12px 0' }}
|
||||
>
|
||||
{joined ? '✓ Je participe' : "J'y serai"}
|
||||
</Button>
|
||||
{joined && (
|
||||
<Button
|
||||
onClick={() => navigate(`/events/${eventId}/invite`)}
|
||||
style={{ flex: 1, padding: '12px 0' }}
|
||||
>
|
||||
Inviter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meetingPoints.length > 0 && (
|
||||
<div style={{ padding: '0 16px 8px' }}>
|
||||
<EventMeetingPoints
|
||||
points={meetingPoints}
|
||||
joinedIds={new Set()}
|
||||
onToggle={() => {}}
|
||||
expanded
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ padding: '0 16px 8px' }}>
|
||||
<button
|
||||
onClick={() => navigate(`/events/${eventId}/meeting-points`)}
|
||||
style={{ width: '100%', padding: 12, border: '2px dashed #ddd', borderRadius: 12, background: 'none', fontSize: 13, fontWeight: 600, color: '#999', cursor: 'pointer', marginTop: 4, fontFamily: 'var(--font-app)' }}
|
||||
>
|
||||
+ Proposer un point de rencontre
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: '#999', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 10 }}>
|
||||
Participants ({event.participantCount})
|
||||
</div>
|
||||
{knownParticipants.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => navigate(`/users/${p.id}`)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5', cursor: 'pointer' }}
|
||||
>
|
||||
<Avatar initials={p.initials} size={38} color="#2B6CB0" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="user-content" style={{ fontSize: 14, fontWeight: 600 }}>{p.name}</div>
|
||||
{p.username && (
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{p.username}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{knownParticipants.length < event.participantCount && (
|
||||
<div
|
||||
style={{ marginTop: 12, padding: 12, background: '#f9f9f9', borderRadius: 12, textAlign: 'center', cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/events/${eventId}/participants`)}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>Voir tous les participants →</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { EventsScreen } from './EventsScreen';
|
||||
import { withProviders } from '../../../../.storybook/decorators';
|
||||
|
||||
const meta: Meta<typeof EventsScreen> = {
|
||||
title: 'Screens/Event/EventsScreen',
|
||||
component: EventsScreen,
|
||||
decorators: [withProviders],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof EventsScreen>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Input, Card, Badge, BottomNav, AvatarStack, getEventPhotoUrl, Text } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
|
||||
const PEOPLE = [
|
||||
{ name: 'Marie Leroy', color: '#E8590C' },
|
||||
{ name: 'Jean Morel', color: '#2B6CB0' },
|
||||
{ name: 'Alice Duval', color: '#9C4DC7' },
|
||||
{ name: 'Thomas Bazin', color: '#38A169' },
|
||||
{ name: 'Camille Noir', color: '#D69E2E' },
|
||||
];
|
||||
|
||||
const EVENT_COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E'];
|
||||
|
||||
function EventCard({
|
||||
event,
|
||||
onClick,
|
||||
}: {
|
||||
event: { id: string; title: string; date: string; location: string; distance?: number; participantCount: number };
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const color = EVENT_COLORS[Number(event.id.replace(/\D/g, '') || 0) % EVENT_COLORS.length] ?? '#E8590C';
|
||||
const people = PEOPLE.slice(0, Math.min(5, Math.max(1, event.participantCount)));
|
||||
return (
|
||||
<Card onClick={onClick} style={{ marginBottom: 12, padding: 0, overflow: 'hidden' }} accentColor={color}>
|
||||
<div
|
||||
style={{
|
||||
height: 100,
|
||||
backgroundImage: `url(${getEventPhotoUrl(event.id)})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: 14 }}>
|
||||
<div className="user-content" style={{ fontSize: 15.5, fontWeight: 700, lineHeight: 1.3, marginBottom: 6 }}>{event.title}</div>
|
||||
<div style={{ fontSize: 12.5, color: '#888', marginBottom: 10 }}>
|
||||
{event.date} · {event.location}{event.distance != null && ` · ${event.distance} km`}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<AvatarStack people={people} size={26} />
|
||||
<span style={{ fontSize: 12, color: '#666' }}>{event.participantCount} inscrits</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function EventsScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { events } = useFestipodData();
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Découvrir"
|
||||
left={<ArrowLeft size={20} onClick={() => navigate('/home')} style={{ cursor: 'pointer' }} />}
|
||||
/>
|
||||
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Input placeholder="Rechercher un événement..." />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Badge style={{ background: '#1a1a1a', color: '#fff' }}>Tous</Badge>
|
||||
<Badge>Cette semaine</Badge>
|
||||
<Badge>Proches</Badge>
|
||||
<Badge>Amis</Badge>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
|
||||
{events.length === 0 && (
|
||||
<Text style={{ textAlign: 'center', color: '#888', marginTop: 32 }}>
|
||||
Aucun événement à afficher
|
||||
</Text>
|
||||
)}
|
||||
{events.map(event => (
|
||||
<EventCard key={event.id} event={event} onClick={() => navigate(`/events/${event.id}`)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<BottomNav active="discover" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Input, Text, Avatar, Checkbox, Button, showToast } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
|
||||
export function InviteScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { eventId } = useParams();
|
||||
const { getFriends } = useFestipodData();
|
||||
const friends = getFriends();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [step, setStep] = useState<'select' | 'message'>('select');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const toggleFriend = (id: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const selectedFriends = friends.filter(f => selected.has(f.id));
|
||||
|
||||
const send = () => {
|
||||
showToast(`${selected.size} invitation${selected.size > 1 ? 's' : ''} envoyée${selected.size > 1 ? 's' : ''}`, 'success');
|
||||
navigate(`/events/${eventId}`);
|
||||
};
|
||||
|
||||
if (step === 'message') {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Ajouter un message"
|
||||
left={<ArrowLeft size={20} onClick={() => setStep('select')} style={{ cursor: 'pointer' }} />}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text style={{ margin: '0 0 8px', fontSize: 13, color: '#888', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
Invitations pour
|
||||
</Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedFriends.map((friend) => (
|
||||
<div
|
||||
key={friend.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: '#FFF7ED',
|
||||
border: '1.5px solid #FBD38D',
|
||||
borderRadius: 20,
|
||||
padding: '4px 10px',
|
||||
}}
|
||||
>
|
||||
<Avatar initials={friend.initials} color="#E8590C" size={22} />
|
||||
<Text style={{ margin: 0, fontSize: 13, color: '#C05621', fontWeight: 600 }}>
|
||||
{friend.name.split(' ')[0]}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text style={{ margin: '0 0 8px', fontSize: 13, color: '#888', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
Message <span style={{ fontWeight: 400, fontSize: 12 }}>(optionnel)</span>
|
||||
</Text>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Ajouter un message personnalisé à votre invitation..."
|
||||
rows={5}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontSize: 15,
|
||||
fontFamily: 'inherit',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: 8,
|
||||
resize: 'none',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
color: '#2d3748',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 16, borderTop: '1px solid #f0f0f0', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ width: '100%' }}
|
||||
onClick={send}
|
||||
>
|
||||
Envoyer {selected.size} invitation{selected.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
{message.trim() === '' && (
|
||||
<button
|
||||
onClick={send}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
Envoyer sans message
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Inviter des amis"
|
||||
left={<ArrowLeft size={20} onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer' }} />}
|
||||
/>
|
||||
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Input placeholder="Rechercher un ami..." />
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div style={{
|
||||
padding: '8px 16px',
|
||||
background: '#FFF7ED',
|
||||
fontSize: 14,
|
||||
color: '#C05621',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{selected.size} ami{selected.size > 1 ? 's' : ''} sélectionné{selected.size > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{friends.map((friend) => (
|
||||
<div
|
||||
key={friend.id}
|
||||
onClick={() => toggleFriend(friend.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #f5f5f5',
|
||||
cursor: 'pointer',
|
||||
background: selected.has(friend.id) ? '#FFF7ED' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Avatar initials={friend.initials} color="#2B6CB0" size="sm" />
|
||||
<div style={{ flex: 1, marginLeft: 12 }}>
|
||||
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{friend.name}</Text>
|
||||
{friend.username && (
|
||||
<Text style={{ margin: 0, fontSize: 13, color: '#888' }}>{friend.username}</Text>
|
||||
)}
|
||||
</div>
|
||||
<Checkbox checked={selected.has(friend.id)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 16, borderTop: '1px solid #f0f0f0' }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => setStep('message')}
|
||||
disabled={selected.size === 0}
|
||||
>
|
||||
Suivant →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button, Avatar, Input, showToast } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
|
||||
export function MeetingPointsScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { eventId } = useParams();
|
||||
const { addMeetingPoint, currentUser, getFriends } = useFestipodData();
|
||||
const friends = getFriends();
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [when, setWhen] = useState('');
|
||||
const [duration, setDuration] = useState('~30 min');
|
||||
const [lieu, setLieu] = useState('');
|
||||
const [invited, setInvited] = useState<string[]>(friends.slice(0, 3).map(f => f.id));
|
||||
|
||||
const removeInvited = (id: string) => {
|
||||
setInvited(invited.filter(i => i !== id));
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!eventId) return;
|
||||
addMeetingPoint({
|
||||
eventId,
|
||||
location: title || lieu || 'Point de rencontre',
|
||||
time: when || duration,
|
||||
hostName: currentUser?.name?.split(' ')[0] ?? 'Moi',
|
||||
hostInitials: currentUser?.initials ?? '?',
|
||||
});
|
||||
showToast(title ? `Point de rencontre créé : ${title}` : 'Point de rencontre créé', 'success');
|
||||
navigate(`/events/${eventId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button onClick={() => navigate(`/events/${eventId}`)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#333', padding: 0, display: 'flex', alignItems: 'center' }}><ArrowLeft size={20} /></button>
|
||||
<span style={{ fontSize: 17, fontWeight: 700 }}>Point de rencontre</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
|
||||
<div style={{ fontSize: 13, color: '#888', marginBottom: 20, lineHeight: 1.5 }}>
|
||||
Un moment de rencontre autour de l'événement. Un titre peut signaler une intention ; laissez-le vide pour un moment informel.
|
||||
</div>
|
||||
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 6 }}>Titre (optionnel)</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)}
|
||||
placeholder="Ex : Gouvernance coopérative — retours SCIC"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 6 }}>Quand</label>
|
||||
<Input
|
||||
value={when}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setWhen(e.target.value)}
|
||||
placeholder="Ven. 22 · 9h00"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 6 }}>Durée</label>
|
||||
<Input
|
||||
value={duration}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDuration(e.target.value)}
|
||||
placeholder="~30 min"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 6 }}>Lieu</label>
|
||||
<Input
|
||||
value={lieu}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLieu(e.target.value)}
|
||||
placeholder="Café en face du tiers-lieu"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 8 }}>Inviter</label>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 20 }}>
|
||||
{invited.map(id => {
|
||||
const friend = friends.find(f => f.id === id);
|
||||
if (!friend) return null;
|
||||
return (
|
||||
<div key={id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px 6px 6px', borderRadius: 20, background: '#f5f5f5' }}>
|
||||
<Avatar initials={friend.initials} color="#2B6CB0" size={22} />
|
||||
<span style={{ fontSize: 12, fontWeight: 500 }}>{friend.name.split(' ')[0]}</span>
|
||||
<span onClick={() => removeInvited(id)} style={{ fontSize: 14, color: '#bbb', cursor: 'pointer' }}>×</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button style={{ padding: '6px 14px', borderRadius: 20, border: '1.5px dashed #ccc', background: 'none', fontSize: 12, cursor: 'pointer', color: '#999', fontFamily: 'var(--font-app)' }}>+ ajouter</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ width: '100%', padding: 14, fontSize: 15 }}
|
||||
onClick={submit}
|
||||
>
|
||||
Créer le point de rencontre
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Avatar, Text, Input } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
|
||||
const COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E'];
|
||||
|
||||
export function ParticipantsListScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { eventId } = useParams();
|
||||
const { getEvent, getEventParticipants } = useFestipodData();
|
||||
const event = eventId ? getEvent(eventId) : undefined;
|
||||
const participants = eventId ? getEventParticipants(eventId) : [];
|
||||
|
||||
const totalCount = event?.participantCount ?? participants.length;
|
||||
const unknownCount = Math.max(0, totalCount - participants.length);
|
||||
|
||||
const rows = [
|
||||
...participants.map((p, i) => ({
|
||||
key: p.id,
|
||||
initials: p.initials,
|
||||
name: p.name,
|
||||
username: p.username,
|
||||
color: COLORS[i % COLORS.length] ?? '#888',
|
||||
known: true,
|
||||
})),
|
||||
...Array.from({ length: unknownCount }, (_, i) => ({
|
||||
key: `unknown-${i}`,
|
||||
initials: '?',
|
||||
name: '',
|
||||
username: '',
|
||||
color: '#ccc',
|
||||
known: false,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title={`Participants (${totalCount})`}
|
||||
left={<ArrowLeft size={20} onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer' }} />}
|
||||
/>
|
||||
|
||||
<div style={{ padding: 16, borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Input placeholder="Rechercher un participant..." />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{rows.map((p) => (
|
||||
<div
|
||||
key={p.key}
|
||||
onClick={p.known ? () => navigate(`/users/${p.key}`) : undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 16px',
|
||||
cursor: p.known ? 'pointer' : 'default',
|
||||
borderBottom: '1px solid #f5f5f5',
|
||||
}}
|
||||
>
|
||||
<Avatar initials={p.initials} color={p.color} size="sm" />
|
||||
<div style={{ flex: 1 }}>
|
||||
{p.known ? (
|
||||
<>
|
||||
<Text style={{ margin: 0, fontWeight: 'bold' }}>{p.name}</Text>
|
||||
{p.username && (
|
||||
<Text style={{ margin: 0, fontSize: 13, color: '#888' }}>{p.username}</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text style={{ margin: 0, color: '#999' }}>Participant inconnu</Text>
|
||||
)}
|
||||
</div>
|
||||
{p.known && <Text style={{ margin: 0, fontSize: 20, color: '#ccc' }}>›</Text>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate, useParams } from '../../../app/router';
|
||||
|
||||
export function UpdateEventScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { eventId } = useParams();
|
||||
const { getEvent, updateEvent } = useFestipodData();
|
||||
const event = eventId ? getEvent(eventId) : undefined;
|
||||
|
||||
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 save = () => {
|
||||
if (!eventId) return;
|
||||
const dateLabel = startDate
|
||||
? (endDate ? `${startDate} - ${endDate}` : startDate)
|
||||
: event?.date ?? '';
|
||||
updateEvent(eventId, {
|
||||
title,
|
||||
date: dateLabel,
|
||||
startDate,
|
||||
endDate,
|
||||
startTime,
|
||||
endTime,
|
||||
location,
|
||||
description,
|
||||
});
|
||||
showToast('Événement mis à jour', 'success');
|
||||
navigate(`/events/${eventId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Modifier l'événement"
|
||||
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
|
||||
<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: 13, color: '#888' }}>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: 13, color: '#888' }}>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: 13, color: '#888' }}>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: 13, color: '#888' }}>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: 13, color: '#888' }}>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: 13, color: '#888' }}>Lieu *</Text>
|
||||
<Input value={location} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLocation(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text style={{ marginBottom: 6, fontSize: 13, color: '#888' }}>Description</Text>
|
||||
<textarea
|
||||
className="app-input"
|
||||
value={description}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 16, borderTop: '1px solid #f0f0f0' }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ width: '100%' }}
|
||||
onClick={save}
|
||||
>
|
||||
Enregistrer les modifications
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Data-layer steps: operate via Playwright + window.__testData bridge.
|
||||
// The harness exposes DeepSignalSets and helper methods directly.
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {
|
||||
// Ensure wallet has data (seed if empty)
|
||||
await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
if (td.events.size === 0) td.loadTestData();
|
||||
});
|
||||
// Wait for event to appear
|
||||
await this.appFrame!.waitForFunction(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
return [...td.events].some((e: any) => e.title === title);
|
||||
},
|
||||
eventTitle,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
|
||||
Given('l\'utilisateur n\'est pas inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.leaveEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
});
|
||||
|
||||
Given('l\'utilisateur est inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.joinEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
});
|
||||
|
||||
Given('l\'événement {string} a {int} participants au départ', async function (this: FestipodWorld, eventTitle: string, count: number) {
|
||||
await this.appFrame!.evaluate(
|
||||
([title, c]: [string, number]) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.updateEvent(event['@id'], { participantCount: c });
|
||||
},
|
||||
[eventTitle, count] as [string, number],
|
||||
);
|
||||
});
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
When('l\'utilisateur s\'inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.joinEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
});
|
||||
|
||||
When('l\'utilisateur se désinscrit de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.leaveEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
});
|
||||
|
||||
When('l\'utilisateur essaie de s\'inscrire une seconde fois à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.joinEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
});
|
||||
|
||||
// --- Assertions ---
|
||||
|
||||
Then('l\'utilisateur est participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const participating = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
return td.isParticipating(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(participating, `User should be participating in "${eventTitle}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'utilisateur n\'est plus participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const participating = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
return td.isParticipating(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(participating, `User should NOT be participating in "${eventTitle}"`).to.be.false;
|
||||
});
|
||||
|
||||
Then('l\'événement {string} compte {int} participants', async function (this: FestipodWorld, eventTitle: string, expectedCount: number) {
|
||||
const count = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
return event?.participantCount ?? -1;
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(count, `Event "${eventTitle}" participant count`).to.equal(expectedCount);
|
||||
});
|
||||
|
||||
Then('l\'utilisateur apparaît dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const found = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(found, `User should appear in participants of "${eventTitle}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const found = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(found, `User should NOT appear in participants of "${eventTitle}"`).to.be.false;
|
||||
});
|
||||
|
||||
Then('l\'inscription est idempotente pour l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const count = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return 0;
|
||||
return td.getEventParticipants(event['@id']).filter((p: any) => p.user === td.currentUserId).length;
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(count, 'User should have exactly one participation record').to.equal(1);
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// --- Background: ensure wallet has test data ---
|
||||
//
|
||||
// The app loads test data automatically on first NG connection (handled inside
|
||||
// FestipodDataProvider). We just navigate to /home and wait for events to
|
||||
// appear in the UI.
|
||||
|
||||
Given('le portefeuille contient des données de test', async function (this: FestipodWorld) {
|
||||
// Navigate to /events to verify the wallet has events. We use /events
|
||||
// rather than /home because home filters by the current user's
|
||||
// participations, which may hydrate after a longer delay in NG mode.
|
||||
await this.appFrame!.evaluate(() => {
|
||||
window.history.pushState(null, '', '/events');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
|
||||
// EventsScreen renders Card components (class app-card) when events load.
|
||||
const hasData = await this.appFrame!.waitForFunction(
|
||||
() => document.querySelectorAll('.app-card').length > 0,
|
||||
{ timeout: 30000 },
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!hasData) {
|
||||
const debug = await this.appFrame!.evaluate(() => ({
|
||||
pathname: window.location.pathname,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 500),
|
||||
}));
|
||||
throw new Error(`No events on events screen. Path: ${debug.pathname}, content: ${debug.rootText}`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Wait helpers ---
|
||||
|
||||
When('l\'utilisateur attend que l\'écran {string} soit affiché', async function (this: FestipodWorld, screenId: string) {
|
||||
// We match on pathname prefix to allow for dynamic ids (event-detail etc.).
|
||||
const expectedPath = screenId === 'event-detail' ? '/events/' :
|
||||
screenId === 'update-event' ? '/edit' :
|
||||
screenId === 'create-event' ? '/events/new' :
|
||||
screenId === 'home' ? '/home' :
|
||||
screenId === 'events' ? '/events' :
|
||||
'/' + screenId;
|
||||
|
||||
await this.appFrame!.waitForFunction(
|
||||
(path: string) => {
|
||||
const current = window.location.pathname;
|
||||
if (path === '/edit') return current.endsWith('/edit');
|
||||
return current.startsWith(path);
|
||||
},
|
||||
expectedPath,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await this.appFrame!.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
// --- Form interaction ---
|
||||
|
||||
When('l\'utilisateur remplit le formulaire de création d\'événement:', async function (this: FestipodWorld, dataTable: any) {
|
||||
const rows = dataTable.hashes() as { champ: string; valeur: string }[];
|
||||
|
||||
// The new CreateEventScreen is a 3-step wizard:
|
||||
// Step 1: name + dates
|
||||
// Step 2: similar-event warning (skipped if none)
|
||||
// Step 3: location + description + times
|
||||
//
|
||||
// We'll fill Step 1 fields first, click Next, then fill remaining fields.
|
||||
|
||||
const formReady = await this.appFrame!.waitForFunction(
|
||||
() => !!document.querySelector('input[placeholder="Donnez un nom à votre événement"]'),
|
||||
{ timeout: 10000 },
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!formReady) {
|
||||
const debug = await this.appFrame!.evaluate(() => ({
|
||||
pathname: window.location.pathname,
|
||||
inputs: Array.from(document.querySelectorAll('input')).map(i => i.placeholder),
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 300),
|
||||
}));
|
||||
throw new Error(`Create form not found. Path: ${debug.pathname}, inputs: ${JSON.stringify(debug.inputs)}, content: ${debug.rootText}`);
|
||||
}
|
||||
|
||||
const byChamp: Record<string, string> = {};
|
||||
for (const { champ, valeur } of rows) byChamp[champ] = valeur;
|
||||
|
||||
// Step 1: name + start/end date
|
||||
if (byChamp['Nom de l\'événement']) {
|
||||
const input = this.appFrame!.locator('input[placeholder="Donnez un nom à votre événement"]');
|
||||
await input.fill(byChamp['Nom de l\'événement']);
|
||||
}
|
||||
if (byChamp['Date de début']) {
|
||||
await this.appFrame!.locator('input[type="date"]').first().fill(byChamp['Date de début']);
|
||||
}
|
||||
if (byChamp['Date de fin']) {
|
||||
await this.appFrame!.locator('input[type="date"]').nth(1).fill(byChamp['Date de fin']);
|
||||
}
|
||||
|
||||
// Advance to step 3 (may pass through step 2 if a similar event matches)
|
||||
let stepBtn = this.appFrame!.locator('button', { hasText: 'Suivant' });
|
||||
await stepBtn.first().click();
|
||||
await this.appFrame!.waitForTimeout(500);
|
||||
|
||||
// If we're on step 2 (warning), click Next again
|
||||
const onStep2 = await this.appFrame!.evaluate(
|
||||
() => document.body.textContent?.includes('Événement similaire détecté') ?? false,
|
||||
);
|
||||
if (onStep2) {
|
||||
stepBtn = this.appFrame!.locator('button', { hasText: 'Suivant' });
|
||||
await stepBtn.first().click();
|
||||
await this.appFrame!.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Step 3 inputs
|
||||
if (byChamp['Heure de début']) {
|
||||
await this.appFrame!.locator('input[type="time"]').first().fill(byChamp['Heure de début']);
|
||||
}
|
||||
if (byChamp['Heure de fin']) {
|
||||
await this.appFrame!.locator('input[type="time"]').nth(1).fill(byChamp['Heure de fin']);
|
||||
}
|
||||
if (byChamp['Lieu']) {
|
||||
await this.appFrame!.locator('input[placeholder="Ajouter un lieu"]').fill(byChamp['Lieu']);
|
||||
}
|
||||
if (byChamp['Description']) {
|
||||
await this.appFrame!.locator('textarea').fill(byChamp['Description']);
|
||||
}
|
||||
});
|
||||
|
||||
When('l\'utilisateur modifie le champ lieu avec {string}', async function (this: FestipodWorld, valeur: string) {
|
||||
// UpdateEventScreen has a "Lieu *" label followed by an Input. Find the input
|
||||
// adjacent to that label.
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => document.getElementById('root')?.textContent?.includes('Lieu') ?? false,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await this.appFrame!.evaluate((val: string) => {
|
||||
const labels = document.querySelectorAll('*');
|
||||
for (const el of labels) {
|
||||
if (el.textContent?.trim() === 'Lieu *' && el.tagName !== 'DIV') {
|
||||
const parent = el.parentElement;
|
||||
const input = parent?.querySelector('input');
|
||||
if (input) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
nativeInputValueSetter.call(input, val);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, valeur);
|
||||
await this.appFrame!.waitForTimeout(500);
|
||||
});
|
||||
|
||||
// --- Event navigation ---
|
||||
|
||||
When('l\'utilisateur clique sur un événement de l\'accueil', async function (this: FestipodWorld) {
|
||||
// HomeScreen renders only events the current user participates in. If
|
||||
// participations haven't hydrated from NG yet, the screen is empty — fall
|
||||
// back to /events (no participation filter).
|
||||
await this.appFrame!.evaluate(() => {
|
||||
window.history.pushState(null, '', '/home');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
const homeHasCards = await this.appFrame!.waitForFunction(
|
||||
() => document.querySelectorAll('.app-card').length > 0,
|
||||
{ timeout: 5000 },
|
||||
).then(() => true).catch(() => false);
|
||||
if (!homeHasCards) {
|
||||
await this.appFrame!.evaluate(() => {
|
||||
window.history.pushState(null, '', '/events');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => document.querySelectorAll('.app-card').length > 0,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
const clicked = await this.appFrame!.evaluate(() => {
|
||||
// EventCard has class app-card and onClick navigates to /events/:id
|
||||
const cards = document.querySelectorAll('.app-card');
|
||||
for (const card of cards) {
|
||||
const el = card as HTMLElement;
|
||||
// Skip cards without cursor pointer (non-interactive)
|
||||
if (el.style.cursor === 'pointer' || window.getComputedStyle(el).cursor === 'pointer') {
|
||||
el.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Fallback: any cursor:pointer element with location marker
|
||||
const anyClickable = document.querySelectorAll('[style*="cursor"]');
|
||||
for (const el of anyClickable) {
|
||||
const e = el as HTMLElement;
|
||||
if (e.textContent && e.textContent.length > 20 && e.querySelector('.app-card, [class*="card"]') === null) {
|
||||
// Skip — find an actual card
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!clicked) {
|
||||
const debug = await this.appFrame!.evaluate(() => ({
|
||||
pathname: window.location.pathname,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 500),
|
||||
}));
|
||||
expect.fail(`No event card found on home screen. Path: ${debug.pathname}, content: ${debug.rootText}`);
|
||||
}
|
||||
await this.appFrame!.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
When('l\'utilisateur clique sur un événement de la liste', async function (this: FestipodWorld) {
|
||||
// EventsScreen also uses Card with .app-card class.
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => document.querySelectorAll('.app-card').length > 0,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const clicked = await this.appFrame!.evaluate(() => {
|
||||
const cards = document.querySelectorAll('.app-card');
|
||||
for (const card of cards) {
|
||||
const el = card as HTMLElement;
|
||||
if (el.style.cursor === 'pointer' || window.getComputedStyle(el).cursor === 'pointer') {
|
||||
el.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!clicked) {
|
||||
expect.fail('No event card found on events screen');
|
||||
}
|
||||
await this.appFrame!.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
When('l\'utilisateur clique sur le bouton de modification', async function (this: FestipodWorld) {
|
||||
// The edit button shows "✎" in the header — only visible if user is event owner
|
||||
const editBtn = this.appFrame!.locator('text=✎').first();
|
||||
await editBtn.click();
|
||||
await this.appFrame!.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
When('l\'utilisateur clique sur le bouton {string} si visible', async function (this: FestipodWorld, buttonText: string) {
|
||||
const button = this.appFrame!.locator('button', { hasText: buttonText }).first();
|
||||
if (await button.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await button.click();
|
||||
await this.appFrame!.waitForTimeout(1000);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Text assertions ---
|
||||
|
||||
Then('l\'écran contient le texte {string}', async function (this: FestipodWorld, expectedText: string) {
|
||||
const appeared = await this.appFrame!.waitForFunction(
|
||||
(text: string) => document.getElementById('root')?.textContent?.includes(text) ?? false,
|
||||
expectedText,
|
||||
{ timeout: 10000 },
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!appeared) {
|
||||
const debug = await this.appFrame!.evaluate(() => ({
|
||||
pathname: window.location.pathname,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 500),
|
||||
}));
|
||||
expect.fail(
|
||||
`Expected text "${expectedText}" not found. Path: "${debug.pathname}", content: "${debug.rootText}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Then('l\'écran ne contient pas le texte {string}', async function (this: FestipodWorld, unexpectedText: string) {
|
||||
await this.appFrame!.waitForTimeout(500);
|
||||
const found = await this.appFrame!.evaluate(
|
||||
(text: string) => document.getElementById('root')?.textContent?.includes(text) ?? false,
|
||||
unexpectedText,
|
||||
);
|
||||
expect(found, `Text "${unexpectedText}" should NOT be present`).to.be.false;
|
||||
});
|
||||
|
||||
Then('l\'écran d\'accueil contient le texte {string}', async function (this: FestipodWorld, expectedText: string) {
|
||||
await this.appFrame!.evaluate(() => {
|
||||
window.history.pushState(null, '', '/home');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
const appeared = await this.appFrame!.waitForFunction(
|
||||
(text: string) => {
|
||||
const root = document.getElementById('root');
|
||||
const txt = root?.textContent ?? '';
|
||||
const hasEvents = txt.includes('En cours') || txt.includes('À venir');
|
||||
return hasEvents && txt.includes(text);
|
||||
},
|
||||
expectedText,
|
||||
{ timeout: 15000 },
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!appeared) {
|
||||
const debug = await this.appFrame!.evaluate(() => ({
|
||||
pathname: window.location.pathname,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 500),
|
||||
}));
|
||||
expect.fail(
|
||||
`Expected "${expectedText}" on home screen. Path: "${debug.pathname}", content: "${debug.rootText}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
await this.navigateTo('#/demo/event-detail');
|
||||
});
|
||||
|
||||
Given('je visualise l\'événement {string}', async function (this: FestipodWorld, eventName: string) {
|
||||
await 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 doc = this.renderedDoc;
|
||||
expect(doc, 'Screen should be rendered').to.not.be.null;
|
||||
const text = doc!.body.textContent ?? '';
|
||||
expect(text.includes('✕'), 'Create event step 1 should expose a ✕ close button').to.be.true;
|
||||
});
|
||||
|
||||
Then('je peux voir la liste des participants', async function (this: FestipodWorld) {
|
||||
expect(this.currentScreenId).to.equal('event-detail');
|
||||
const doc = this.renderedDoc;
|
||||
expect(doc, 'Screen should be rendered').to.not.be.null;
|
||||
const avatars = doc!.querySelectorAll('.app-avatar');
|
||||
expect(avatars.length, 'Event detail should render at least one participant avatar').to.be.greaterThan(0);
|
||||
const text = doc!.body.textContent ?? '';
|
||||
expect(/Participants\s*\(\d+\)/.test(text), 'Event detail should show a "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 doc = this.renderedDoc;
|
||||
expect(doc, 'Screen should be rendered').to.not.be.null;
|
||||
const text = doc!.body.textContent ?? '';
|
||||
expect(text.includes('📅'), 'Event detail should show a date icon').to.be.true;
|
||||
expect(text.includes('📍'), 'Event detail should show a location icon').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 doc = this.renderedDoc;
|
||||
expect(doc, 'Screen should be rendered').to.not.be.null;
|
||||
const text = doc!.body.textContent ?? '';
|
||||
expect(text.includes('📅'), 'Event detail should show a date icon').to.be.true;
|
||||
expect(text.includes('📍'), 'Event detail should show a location icon').to.be.true;
|
||||
expect(text.length, 'Event detail body should contain content').to.be.greaterThan(50);
|
||||
});
|
||||
|
||||
Then('je peux voir la liste des événements', async function (this: FestipodWorld) {
|
||||
const doc = this.renderedDoc;
|
||||
expect(doc, 'Screen should be rendered').to.not.be.null;
|
||||
const text = doc!.body.textContent ?? '';
|
||||
if (this.currentScreenId === 'home') {
|
||||
expect(text.includes('En cours') || text.includes('À venir'),
|
||||
'Home should show event section headers').to.be.true;
|
||||
} else if (this.currentScreenId === 'events') {
|
||||
const cards = doc!.querySelectorAll('.app-card');
|
||||
expect(cards.length, 'Events screen should render at least one event card').to.be.greaterThan(0);
|
||||
} 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 doc = this.renderedDoc;
|
||||
expect(doc, 'Screen should be rendered').to.not.be.null;
|
||||
const text = doc!.body.textContent ?? '';
|
||||
// List/discover cards show "<date> · <location>" without an icon. Detail
|
||||
// screen uses 📍. Accept either signal — the assertion is that location
|
||||
// text is visible on event cards.
|
||||
const seedLocations = ['Le Revel', 'La Maison du Vélo', "Tiers-lieu L'Hermitage", 'MJC Montplaisir'];
|
||||
const hasSeedLocation = seedLocations.some(loc => text.includes(loc));
|
||||
expect(hasSeedLocation || text.includes('📍'),
|
||||
'Event cards should display a location (text or 📍 icon)').to.be.true;
|
||||
});
|
||||
|
||||
Then('je peux m\'inscrire à l\'événement', async function (this: FestipodWorld) {
|
||||
expect(this.currentScreenId).to.equal('event-detail');
|
||||
const doc = this.renderedDoc;
|
||||
expect(doc, 'Screen should be rendered').to.not.be.null;
|
||||
const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');
|
||||
const hasToggle = buttons.some(t => t.includes("J'y serai") || t.includes('Je participe'));
|
||||
expect(hasToggle, 'Event detail should expose a join/leave 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 doc = this.renderedDoc;
|
||||
expect(doc, 'Screen should be rendered').to.not.be.null;
|
||||
const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');
|
||||
const hasToggle = buttons.some(t => t.includes("J'y serai") || t.includes('Je participe'));
|
||||
expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true;
|
||||
});
|
||||
|
||||
// --- Create-event form assertions ---
|
||||
|
||||
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');
|
||||
expectRequiredField(this, fieldName);
|
||||
});
|
||||
|
||||
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 expectedFields = dataTable.raw().flat();
|
||||
expectedFields.forEach((fieldName: string) => expectRequiredField(this, fieldName));
|
||||
});
|
||||
|
||||
function expectRequiredField(world: FestipodWorld, fieldName: string) {
|
||||
// Required-field labels end with " *" — read paragraph labels and check.
|
||||
const doc = world.renderedDoc;
|
||||
expect(doc, 'Screen should be rendered').to.not.be.null;
|
||||
const labels = Array.from(doc!.querySelectorAll('p'))
|
||||
.map(p => (p.textContent ?? '').trim());
|
||||
if (labels.some(t => t === `${fieldName} *` || t.startsWith(`${fieldName} *`))) return;
|
||||
// The wizard reveals some fields only after the first step. Falling back to
|
||||
// a body-text scan still proves the form *intends* to require this field.
|
||||
const body = doc!.body.textContent ?? '';
|
||||
expect(body.includes(`${fieldName} *`),
|
||||
`Field "${fieldName}" should be marked as required (with *) in create-event screen`,
|
||||
).to.be.true;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { HomeScreen } from './HomeScreen';
|
||||
import { withProviders } from '../../../../.storybook/decorators';
|
||||
|
||||
const meta: Meta<typeof HomeScreen> = {
|
||||
title: 'Screens/Home/HomeScreen',
|
||||
component: HomeScreen,
|
||||
decorators: [withProviders],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof HomeScreen>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import { Title, Card, AvatarStack, BottomNav, EventCover, EventMeetingPoints, type MeetingPointData } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
|
||||
const PEOPLE = [
|
||||
{ name: 'Marie Leroy', color: '#E8590C' },
|
||||
{ name: 'Jean Morel', color: '#2B6CB0' },
|
||||
{ name: 'Alice Duval', color: '#9C4DC7' },
|
||||
{ name: 'Thomas Bazin', color: '#38A169' },
|
||||
{ name: 'Camille Noir', color: '#D69E2E' },
|
||||
];
|
||||
|
||||
const EVENT_COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E'];
|
||||
|
||||
function EventCardBody({
|
||||
event,
|
||||
joinedIds,
|
||||
onToggle,
|
||||
onClick,
|
||||
isOngoing = false,
|
||||
}: {
|
||||
event: { id: string; title: string; date: string; location: string; meetingPoints?: MeetingPointData[] };
|
||||
joinedIds: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
onClick: () => void;
|
||||
isOngoing?: boolean;
|
||||
}) {
|
||||
const color = EVENT_COLORS[Number(event.id.replace(/\D/g, '') || 0) % EVENT_COLORS.length] ?? '#E8590C';
|
||||
const borderStyle = isOngoing ? '2px solid #c6f6d5' : undefined;
|
||||
const meetingPoints = event.meetingPoints ?? [];
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
style={{ marginBottom: 14, padding: 0, overflow: 'hidden', border: borderStyle }}
|
||||
accentColor={color}
|
||||
>
|
||||
<EventCover eventId={event.id} height={110} borderRadius={0} />
|
||||
<div style={{ padding: 14 }}>
|
||||
<div className="user-content" style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>
|
||||
{event.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: '#888', marginBottom: 10 }}>
|
||||
{event.date} · {event.location}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
<AvatarStack people={PEOPLE} size={26} />
|
||||
<span style={{ fontSize: 12, color: '#666' }}>
|
||||
{PEOPLE.length} {isOngoing ? 'connexions présentes' : 'connexions'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{meetingPoints.length > 0 && (
|
||||
<EventMeetingPoints
|
||||
points={meetingPoints}
|
||||
joinedIds={joinedIds}
|
||||
onToggle={(id) => onToggle(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { getUserEvents, currentUserId, getEventMeetingPoints } = useFestipodData();
|
||||
const [joinedIds, setJoinedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const myEvents = getUserEvents(currentUserId);
|
||||
const ongoing = myEvents.slice(0, 1);
|
||||
const upcoming = myEvents.slice(1, 4);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setJoinedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const withMeetingPoints = (event: { id: string; title: string; date: string; location: string }) => ({
|
||||
...event,
|
||||
meetingPoints: getEventMeetingPoints(event.id).map(mp => ({
|
||||
id: mp.id,
|
||||
title: mp.location,
|
||||
when: mp.time,
|
||||
duration: '~60 min',
|
||||
lieu: mp.location,
|
||||
})),
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<div style={{ padding: '12px 16px 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title style={{ margin: 0 }}>Festipod</Title>
|
||||
<button
|
||||
onClick={() => navigate('/events/new')}
|
||||
aria-label="Relayer un événement"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '8px 14px',
|
||||
border: 'none',
|
||||
borderRadius: 20,
|
||||
background: '#E8590C',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-app)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16, lineHeight: 1 }}>+</span> Relayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
{ongoing.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, color: '#22543D', marginBottom: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#38A169', display: 'inline-block' }} />
|
||||
En cours
|
||||
</div>
|
||||
|
||||
{ongoing.map(event => (
|
||||
<EventCardBody
|
||||
key={event.id}
|
||||
event={withMeetingPoints(event)}
|
||||
joinedIds={joinedIds}
|
||||
onToggle={toggle}
|
||||
onClick={() => navigate(`/events/${event.id}`)}
|
||||
isOngoing
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{upcoming.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, color: '#E8590C', margin: '20px 0 10px' }}>
|
||||
À venir
|
||||
</div>
|
||||
|
||||
{upcoming.map(event => (
|
||||
<EventCardBody
|
||||
key={event.id}
|
||||
event={withMeetingPoints(event)}
|
||||
joinedIds={joinedIds}
|
||||
onToggle={toggle}
|
||||
onClick={() => navigate(`/events/${event.id}`)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 8 }} />
|
||||
</div>
|
||||
|
||||
<BottomNav active="home" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { SettingsScreen } from './SettingsScreen';
|
||||
import { withProviders } from '../../../../.storybook/decorators';
|
||||
|
||||
const meta: Meta<typeof SettingsScreen> = {
|
||||
title: 'Screens/Home/SettingsScreen',
|
||||
component: SettingsScreen,
|
||||
decorators: [withProviders],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof SettingsScreen>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Header, Text, ListItem, Toggle, Divider, NavBar } from '../components/sketchy';
|
||||
import type { ScreenProps } from './index';
|
||||
import { useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Text, ListItem, Toggle, Divider, BottomNav } from '../../../shared/components/sketchy';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
|
||||
export function SettingsScreen({ navigate }: ScreenProps) {
|
||||
export function SettingsScreen() {
|
||||
const navigate = useNavigate();
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [location, setLocation] = useState(true);
|
||||
@@ -11,19 +13,18 @@ export function SettingsScreen({ navigate }: ScreenProps) {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Header
|
||||
title="Paramètres"
|
||||
left={<span onClick={() => navigate('home')} style={{ cursor: 'pointer' }}>←</span>}
|
||||
left={<ArrowLeft size={20} onClick={() => navigate('/profile')} style={{ cursor: 'pointer' }} />}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<Text style={{ padding: '16px 16px 8px', fontSize: 14, color: 'var(--sketch-gray)', margin: 0 }}>
|
||||
PRÉFÉRENCES
|
||||
<Text style={{ padding: '16px 16px 8px', fontSize: 12, color: '#999', margin: 0, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
Préférences
|
||||
</Text>
|
||||
|
||||
<ListItem>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text style={{ margin: 0 }}>Notifications</Text>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: 'var(--sketch-gray)' }}>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#888' }}>
|
||||
Recevoir les invitations par e-mail
|
||||
</Text>
|
||||
</div>
|
||||
@@ -33,7 +34,7 @@ export function SettingsScreen({ navigate }: ScreenProps) {
|
||||
<ListItem>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text style={{ margin: 0 }}>Mode sombre</Text>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: 'var(--sketch-gray)' }}>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#888' }}>
|
||||
Activer le thème sombre
|
||||
</Text>
|
||||
</div>
|
||||
@@ -43,7 +44,7 @@ export function SettingsScreen({ navigate }: ScreenProps) {
|
||||
<ListItem>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text style={{ margin: 0 }}>Localisation</Text>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: 'var(--sketch-gray)' }}>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#888' }}>
|
||||
Autoriser l'accès à la position
|
||||
</Text>
|
||||
</div>
|
||||
@@ -52,41 +53,33 @@ export function SettingsScreen({ navigate }: ScreenProps) {
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text style={{ padding: '16px 16px 8px', fontSize: 14, color: 'var(--sketch-gray)', margin: 0 }}>
|
||||
COMPTE
|
||||
<Text style={{ padding: '16px 16px 8px', fontSize: 12, color: '#999', margin: 0, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
Compte
|
||||
</Text>
|
||||
|
||||
<ListItem onClick={() => navigate('profile')}>
|
||||
<ListItem onClick={() => navigate('/profile/edit')}>
|
||||
<Text style={{ margin: 0, flex: 1 }}>Modifier le profil</Text>
|
||||
<span>→</span>
|
||||
<span style={{ color: '#ccc' }}>›</span>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<Text style={{ margin: 0, flex: 1 }}>Changer le mot de passe</Text>
|
||||
<span>→</span>
|
||||
<span style={{ color: '#ccc' }}>›</span>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<Text style={{ margin: 0, flex: 1 }}>Confidentialité</Text>
|
||||
<span>→</span>
|
||||
<span style={{ color: '#ccc' }}>›</span>
|
||||
</ListItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ListItem onClick={() => navigate('login')}>
|
||||
<Text style={{ margin: 0, color: '#c00' }}>Se déconnecter</Text>
|
||||
<ListItem onClick={() => navigate('/login')}>
|
||||
<Text style={{ margin: 0, color: '#E53E3E' }}>Se déconnecter</Text>
|
||||
</ListItem>
|
||||
</div>
|
||||
|
||||
{/* Bottom Nav */}
|
||||
<NavBar
|
||||
items={[
|
||||
{ icon: '⌂', label: 'Accueil', onClick: () => navigate('home') },
|
||||
{ icon: '◎', label: 'Découvrir', onClick: () => navigate('events') },
|
||||
{ icon: '+', label: 'Relayer', onClick: () => navigate('create-event') },
|
||||
{ icon: '☺', label: 'Profil', onClick: () => navigate('profile') },
|
||||
]}
|
||||
/>
|
||||
<BottomNav active="profile" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
Then('je peux configurer mes notifications', async function (this: FestipodWorld) {
|
||||
expect(this.currentScreenId).to.equal('settings');
|
||||
const source = this.getRenderedText();
|
||||
expect(/>Notifications</.test(source), 'Settings should have "Notifications" text').to.be.true;
|
||||
expect(/<Toggle[^>]*checked=\{notifications\}/.test(source), 'Settings should have Toggle for notifications').to.be.true;
|
||||
});
|
||||
+4
-11
@@ -14,19 +14,12 @@ Fonctionnalité: US-16 Indiquer un ou plusieurs points de rencontre
|
||||
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
|
||||
Scénario: Voir le formulaire de proposition
|
||||
É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
|
||||
Scénario: Renseigner les détails du point 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é"
|
||||
Alors l'écran contient un champ "Quand"
|
||||
Et l'écran contient un champ "Durée"
|
||||
+1
-1
@@ -13,7 +13,7 @@ Fonctionnalité: US-19 Recevoir un récapitulatif des prochaines rencontres
|
||||
|
||||
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"
|
||||
Alors l'écran contient une section "À venir"
|
||||
|
||||
Scénario: Voir le récapitulatif par période
|
||||
* Scénario non implémenté
|
||||
+4
@@ -17,6 +17,10 @@ Fonctionnalité: US-10 Visualiser la fiche/le profil d'un 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
|
||||
+1
-1
@@ -17,7 +17,7 @@ Fonctionnalité: US-20 Voir le profil des personnes faisant partie de mon résea
|
||||
Scénario: Voir mon réseau
|
||||
Étant donné que je suis sur la page "mon profil"
|
||||
Alors l'écran contient un texte "Amis"
|
||||
Et l'écran contient un texte "Mes amis"
|
||||
Et l'écran contient un texte "Mon réseau"
|
||||
|
||||
Scénario: Voir un profil de mon réseau
|
||||
Étant donné que je suis sur la page "mon profil"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user