# Story 3.1: Table narrator_texts et API narrateur Status: ready-for-dev ## Story As a développeur, I want une infrastructure pour stocker et servir les textes du narrateur, so that le narrateur peut afficher des messages contextuels variés. ## Acceptance Criteria 1. **Given** les migrations Laravel sont exécutées **When** `php artisan migrate` est lancé **Then** la table `narrator_texts` est créée (id, context, text_key, variant, timestamps) 2. **And** les contextes définis incluent : intro, transition_projects, transition_skills, transition_testimonials, transition_journey, hint, encouragement_25, encouragement_50, encouragement_75, contact_unlocked, welcome_back 3. **And** plusieurs variantes par contexte permettent une sélection aléatoire 4. **And** les seeders insèrent les textes de base en FR et EN dans la table `translations` 5. **Given** l'API `/api/narrator/{context}` est appelée **When** un contexte valide est fourni **Then** un texte aléatoire parmi les variantes de ce contexte est retourné 6. **And** le texte est traduit selon le header `Accept-Language` 7. **And** le ton est adapté au héros (vouvoiement pour Recruteur, tutoiement pour Client/Dev) ## Tasks / Subtasks - [ ] **Task 1: Créer la migration table narrator_texts** (AC: #1, #2, #3) - [ ] Créer migration `create_narrator_texts_table` - [ ] Colonnes : id, context (string), text_key (string), variant (integer), hero_type (enum nullable: recruteur, client, dev), timestamps - [ ] Index sur context pour le filtrage - [ ] Index composite sur (context, hero_type) pour les requêtes - [ ] **Task 2: Créer le Model NarratorText** (AC: #3) - [ ] Créer `app/Models/NarratorText.php` - [ ] Définir les fillable : context, text_key, variant, hero_type - [ ] Scope `scopeForContext($query, $context)` pour filtrer par contexte - [ ] Scope `scopeForHero($query, $heroType)` pour filtrer par héros - [ ] Méthode statique `getRandomText($context, $heroType = null)` pour récupérer un texte aléatoire - [ ] **Task 3: Créer le Seeder des textes narrateur** (AC: #4) - [ ] Créer `database/seeders/NarratorTextSeeder.php` - [ ] Créer les textes pour chaque contexte avec 2-3 variantes - [ ] Créer des variantes spécifiques par héros quand nécessaire (vouvoiement/tutoiement) - [ ] Ajouter les traductions FR et EN dans TranslationSeeder - [ ] **Task 4: Créer l'endpoint API narrateur** (AC: #5, #6, #7) - [ ] Créer `app/Http/Controllers/Api/NarratorController.php` - [ ] Méthode `getText($context)` pour récupérer un texte aléatoire - [ ] Paramètre query optionnel `?hero=recruteur|client|dev` - [ ] Joindre les traductions selon `Accept-Language` - [ ] Retourner 404 si contexte invalide - [ ] **Task 5: Créer le composable useFetchNarratorText** (AC: #5) - [ ] Créer `frontend/app/composables/useFetchNarratorText.ts` - [ ] Accepter le contexte et le type de héros en paramètres - [ ] Gérer les états loading, error, data - [ ] **Task 6: Tests et validation** - [ ] Exécuter les migrations - [ ] Vérifier le seeding des données - [ ] Tester l'API avec différents contextes - [ ] Vérifier le vouvoiement/tutoiement selon le héros - [ ] Tester les variantes aléatoires ## Dev Notes ### Migration narrator_texts ```php id(); $table->string('context'); $table->string('text_key'); $table->integer('variant')->default(1); $table->enum('hero_type', ['recruteur', 'client', 'dev'])->nullable(); $table->timestamps(); $table->index('context'); $table->index(['context', 'hero_type']); }); } public function down(): void { Schema::dropIfExists('narrator_texts'); } }; ``` ### Model NarratorText ```php where('context', $context); } public function scopeForHero($query, ?string $heroType) { if ($heroType) { return $query->where(function ($q) use ($heroType) { $q->where('hero_type', $heroType) ->orWhereNull('hero_type'); }); } return $query->whereNull('hero_type'); } public static function getRandomText(string $context, ?string $heroType = null): ?self { $query = static::forContext($context); if ($heroType) { // Priorité aux textes spécifiques au héros, sinon textes génériques $heroSpecific = (clone $query)->where('hero_type', $heroType)->inRandomOrder()->first(); if ($heroSpecific) { return $heroSpecific; } } return $query->whereNull('hero_type')->inRandomOrder()->first(); } } ``` ### Contextes du narrateur | Contexte | Description | Variantes | |----------|-------------|-----------| | `intro` | Message d'accueil initial | 3 par héros | | `transition_projects` | Arrivée sur la page Projets | 2 génériques | | `transition_skills` | Arrivée sur la page Compétences | 2 génériques | | `transition_testimonials` | Arrivée sur la page Témoignages | 2 génériques | | `transition_journey` | Arrivée sur la page Parcours | 2 génériques | | `hint` | Indices si inactif > 30s | 3 génériques | | `encouragement_25` | Progression à 25% | 2 génériques | | `encouragement_50` | Progression à 50% | 2 génériques | | `encouragement_75` | Progression à 75% | 2 génériques | | `contact_unlocked` | Déblocage du contact | 2 génériques | | `welcome_back` | Retour d'un visiteur | 2 génériques | ### Seeder des textes narrateur ```php 'intro', 'text_key' => 'narrator.intro.recruteur.1', 'variant' => 1, 'hero_type' => 'recruteur'], ['context' => 'intro', 'text_key' => 'narrator.intro.recruteur.2', 'variant' => 2, 'hero_type' => 'recruteur'], // INTRO - Client/Dev (tutoiement) ['context' => 'intro', 'text_key' => 'narrator.intro.casual.1', 'variant' => 1, 'hero_type' => 'client'], ['context' => 'intro', 'text_key' => 'narrator.intro.casual.1', 'variant' => 1, 'hero_type' => 'dev'], ['context' => 'intro', 'text_key' => 'narrator.intro.casual.2', 'variant' => 2, 'hero_type' => 'client'], ['context' => 'intro', 'text_key' => 'narrator.intro.casual.2', 'variant' => 2, 'hero_type' => 'dev'], // TRANSITIONS ['context' => 'transition_projects', 'text_key' => 'narrator.transition.projects.1', 'variant' => 1, 'hero_type' => null], ['context' => 'transition_projects', 'text_key' => 'narrator.transition.projects.2', 'variant' => 2, 'hero_type' => null], ['context' => 'transition_skills', 'text_key' => 'narrator.transition.skills.1', 'variant' => 1, 'hero_type' => null], ['context' => 'transition_skills', 'text_key' => 'narrator.transition.skills.2', 'variant' => 2, 'hero_type' => null], ['context' => 'transition_testimonials', 'text_key' => 'narrator.transition.testimonials.1', 'variant' => 1, 'hero_type' => null], ['context' => 'transition_journey', 'text_key' => 'narrator.transition.journey.1', 'variant' => 1, 'hero_type' => null], // HINTS ['context' => 'hint', 'text_key' => 'narrator.hint.1', 'variant' => 1, 'hero_type' => null], ['context' => 'hint', 'text_key' => 'narrator.hint.2', 'variant' => 2, 'hero_type' => null], ['context' => 'hint', 'text_key' => 'narrator.hint.3', 'variant' => 3, 'hero_type' => null], // ENCOURAGEMENTS ['context' => 'encouragement_25', 'text_key' => 'narrator.encouragement.25.1', 'variant' => 1, 'hero_type' => null], ['context' => 'encouragement_50', 'text_key' => 'narrator.encouragement.50.1', 'variant' => 1, 'hero_type' => null], ['context' => 'encouragement_75', 'text_key' => 'narrator.encouragement.75.1', 'variant' => 1, 'hero_type' => null], // CONTACT UNLOCKED ['context' => 'contact_unlocked', 'text_key' => 'narrator.contact_unlocked.1', 'variant' => 1, 'hero_type' => null], // WELCOME BACK ['context' => 'welcome_back', 'text_key' => 'narrator.welcome_back.1', 'variant' => 1, 'hero_type' => null], ['context' => 'welcome_back', 'text_key' => 'narrator.welcome_back.2', 'variant' => 2, 'hero_type' => null], ]; foreach ($texts as $data) { NarratorText::create($data); } // Traductions $translations = [ // Intro Recruteur (vouvoiement) ['key' => 'narrator.intro.recruteur.1', 'fr' => "Bienvenue, voyageur... Vous voilà arrivé en terre inconnue. Un développeur mystérieux se cache quelque part ici. Saurez-vous le trouver ?", 'en' => "Welcome, traveler... You have arrived in unknown lands. A mysterious developer hides somewhere here. Will you be able to find them?"], ['key' => 'narrator.intro.recruteur.2', 'fr' => "Ah, un visiteur distingué... Je sens que vous cherchez quelqu'un de particulier. Laissez-moi vous guider dans cette aventure.", 'en' => "Ah, a distinguished visitor... I sense you're looking for someone special. Let me guide you through this adventure."], // Intro Client/Dev (tutoiement) ['key' => 'narrator.intro.casual.1', 'fr' => "Tiens tiens... Un nouveau venu ! Tu tombes bien, j'ai quelqu'un à te présenter. Mais d'abord, un peu d'exploration s'impose...", 'en' => "Well well... A newcomer! You're just in time, I have someone to introduce you to. But first, a bit of exploration is in order..."], ['key' => 'narrator.intro.casual.2', 'fr' => "Salut l'ami ! Bienvenue dans mon monde. Tu cherches le développeur qui a créé tout ça ? Suis-moi, je connais le chemin...", 'en' => "Hey friend! Welcome to my world. Looking for the developer who created all this? Follow me, I know the way..."], // Transitions ['key' => 'narrator.transition.projects.1', 'fr' => "Voici les créations du développeur... Chaque projet raconte une histoire. Laquelle vas-tu explorer ?", 'en' => "Here are the developer's creations... Each project tells a story. Which one will you explore?"], ['key' => 'narrator.transition.projects.2', 'fr' => "Bienvenue dans la galerie des projets. C'est ici que le code prend vie...", 'en' => "Welcome to the project gallery. This is where code comes to life..."], ['key' => 'narrator.transition.skills.1', 'fr' => "L'arbre des compétences... Chaque branche représente un savoir acquis au fil du temps.", 'en' => "The skill tree... Each branch represents knowledge acquired over time."], ['key' => 'narrator.transition.skills.2', 'fr' => "Voici les outils de notre ami développeur. Impressionnant, n'est-ce pas ?", 'en' => "Here are our developer friend's tools. Impressive, isn't it?"], ['key' => 'narrator.transition.testimonials.1', 'fr' => "D'autres voyageurs sont passés par ici avant toi. Écoute leurs histoires...", 'en' => "Other travelers have passed through here before you. Listen to their stories..."], ['key' => 'narrator.transition.journey.1', 'fr' => "Le chemin parcouru... Chaque étape a façonné le développeur que tu cherches.", 'en' => "The path traveled... Each step has shaped the developer you're looking for."], // Hints ['key' => 'narrator.hint.1', 'fr' => "Tu sembles perdu... N'hésite pas à explorer les différentes zones !", 'en' => "You seem lost... Don't hesitate to explore the different areas!"], ['key' => 'narrator.hint.2', 'fr' => "Psst... Il reste encore tant de choses à découvrir ici...", 'en' => "Psst... There's still so much to discover here..."], ['key' => 'narrator.hint.3', 'fr' => "La carte peut t'aider à naviguer. Clique dessus !", 'en' => "The map can help you navigate. Click on it!"], // Encouragements ['key' => 'narrator.encouragement.25.1', 'fr' => "Beau début ! Tu as exploré un quart du territoire. Continue comme ça...", 'en' => "Great start! You've explored a quarter of the territory. Keep it up..."], ['key' => 'narrator.encouragement.50.1', 'fr' => "À mi-chemin ! Tu commences vraiment à connaître cet endroit.", 'en' => "Halfway there! You're really starting to know this place."], ['key' => 'narrator.encouragement.75.1', 'fr' => "Impressionnant ! Plus que quelques zones et tu auras tout vu...", 'en' => "Impressive! Just a few more areas and you'll have seen everything..."], // Contact unlocked ['key' => 'narrator.contact_unlocked.1', 'fr' => "Tu as assez exploré pour mériter une rencontre... Le chemin vers le développeur est maintenant ouvert !", 'en' => "You've explored enough to deserve a meeting... The path to the developer is now open!"], // Welcome back ['key' => 'narrator.welcome_back.1', 'fr' => "Te revoilà ! Tu m'avais manqué... On reprend là où on s'était arrêtés ?", 'en' => "You're back! I missed you... Shall we pick up where we left off?"], ['key' => 'narrator.welcome_back.2', 'fr' => "Tiens, un visage familier ! Content de te revoir, voyageur.", 'en' => "Well, a familiar face! Good to see you again, traveler."], ]; foreach ($translations as $t) { Translation::firstOrCreate( ['lang' => 'fr', 'key_name' => $t['key']], ['value' => $t['fr']] ); Translation::firstOrCreate( ['lang' => 'en', 'key_name' => $t['key']], ['value' => $t['en']] ); } } } ``` ### Controller API ```php json([ 'error' => [ 'code' => 'INVALID_CONTEXT', 'message' => 'Invalid narrator context', 'valid_contexts' => self::VALID_CONTEXTS, ] ], 404); } $lang = $request->header('Accept-Language', 'fr'); $heroType = $request->query('hero'); // Valider hero_type if ($heroType && !in_array($heroType, ['recruteur', 'client', 'dev'])) { $heroType = null; } $narratorText = NarratorText::getRandomText($context, $heroType); if (!$narratorText) { return response()->json([ 'error' => [ 'code' => 'NO_TEXT_FOUND', 'message' => 'No narrator text found for this context', ] ], 404); } $text = Translation::getTranslation($narratorText->text_key, $lang); return response()->json([ 'data' => [ 'context' => $context, 'text' => $text, 'variant' => $narratorText->variant, 'heroType' => $narratorText->hero_type, ], 'meta' => [ 'lang' => $lang, ], ]); } } ``` ```php // api/routes/api.php Route::get('/narrator/{context}', [NarratorController::class, 'getText']); ``` ### Composable useFetchNarratorText ```typescript // frontend/app/composables/useFetchNarratorText.ts type NarratorContext = | 'intro' | 'transition_projects' | 'transition_skills' | 'transition_testimonials' | 'transition_journey' | 'hint' | 'encouragement_25' | 'encouragement_50' | 'encouragement_75' | 'contact_unlocked' | 'welcome_back' type HeroType = 'recruteur' | 'client' | 'dev' interface NarratorTextResponse { data: { context: string text: string variant: number heroType: HeroType | null } meta: { lang: string } } export function useFetchNarratorText() { const config = useRuntimeConfig() const { locale } = useI18n() async function fetchText(context: NarratorContext, heroType?: HeroType) { const url = heroType ? `/narrator/${context}?hero=${heroType}` : `/narrator/${context}` return await $fetch(url, { baseURL: config.public.apiUrl, headers: { 'X-API-Key': config.public.apiKey, 'Accept-Language': locale.value, }, }) } return { fetchText } } ``` ### Dépendances **Cette story nécessite :** - Story 1.2 : Table translations et système de traduction **Cette story prépare pour :** - Story 3.2 : Composant NarratorBubble (consomme l'API) - Story 3.3 : Textes contextuels (utilise les contextes) ### Project Structure Notes **Fichiers à créer :** ``` api/ ├── app/Models/ │ └── NarratorText.php # CRÉER ├── app/Http/Controllers/Api/ │ └── NarratorController.php # CRÉER └── database/ ├── migrations/ │ └── 2026_02_04_000002_create_narrator_texts_table.php # CRÉER └── seeders/ └── NarratorTextSeeder.php # CRÉER frontend/app/composables/ └── useFetchNarratorText.ts # CRÉER ``` ### References - [Source: docs/planning-artifacts/epics.md#Story-3.1] - [Source: docs/planning-artifacts/ux-design-specification.md#NarratorBubble] - [Source: docs/planning-artifacts/ux-design-specification.md#Hero-System] - [Source: docs/brainstorming-gamification-2026-01-26.md#Narrateur] ### Technical Requirements | Requirement | Value | Source | |-------------|-------|--------| | Contextes | 11 types différents | Epics | | Variantes | 2-3 par contexte | Epics | | Ton héros | vouvoiement/tutoiement | UX Spec | | API endpoint | GET /api/narrator/{context} | Architecture | ## Dev Agent Record ### Agent Model Used {{agent_model_name_version}} ### Debug Log References ### Completion Notes List ### Change Log | Date | Change | Author | |------|--------|--------| | 2026-02-04 | Story créée avec contexte complet | SM Agent | ### File List