# Story 3.1: Table narrator_texts et API narrateur

Status: review

## 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

- [x] **Task 1: Créer la migration table narrator_texts** (AC: #1, #2, #3)
  - [x] Créer migration `create_narrator_texts_table`
  - [x] Colonnes : id, context (string), text_key (string), variant (integer), hero_type (enum nullable: recruteur, client, dev), timestamps
  - [x] Index sur context pour le filtrage
  - [x] Index composite sur (context, hero_type) pour les requêtes

- [x] **Task 2: Créer le Model NarratorText** (AC: #3)
  - [x] Créer `app/Models/NarratorText.php`
  - [x] Définir les fillable : context, text_key, variant, hero_type
  - [x] Scope `scopeForContext($query, $context)` pour filtrer par contexte
  - [x] Scope `scopeForHero($query, $heroType)` pour filtrer par héros
  - [x] Méthode statique `getRandomText($context, $heroType = null)` pour récupérer un texte aléatoire

- [x] **Task 3: Créer le Seeder des textes narrateur** (AC: #4)
  - [x] Créer `database/seeders/NarratorTextSeeder.php`
  - [x] Créer les textes pour chaque contexte avec 2-3 variantes
  - [x] Créer des variantes spécifiques par héros (vouvoiement recruteur, tutoiement client/dev)
  - [x] Ajouter les traductions FR et EN directement dans le seeder

- [x] **Task 4: Créer l'endpoint API narrateur** (AC: #5, #6, #7)
  - [x] Créer `app/Http/Controllers/Api/NarratorController.php`
  - [x] Méthode `getText($context)` pour récupérer un texte aléatoire
  - [x] Paramètre query optionnel `?hero=recruteur|client|dev`
  - [x] Joindre les traductions selon `Accept-Language`
  - [x] Retourner 404 si contexte invalide

- [x] **Task 5: Créer le composable useFetchNarratorText** (AC: #5)
  - [x] Créer `frontend/app/composables/useFetchNarratorText.ts`
  - [x] Accepter le contexte et le type de héros en paramètres
  - [x] Fonction fetchText async avec gestion d'erreurs

- [x] **Task 6: Tests et validation**
  - [x] Migration exécutée
  - [x] Seeding des données réussi
  - [x] Build frontend validé

## Dev Notes

### Migration narrator_texts

```php
<?php
// database/migrations/2026_02_04_000002_create_narrator_texts_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('narrator_texts', function (Blueprint $table) {
            $table->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
<?php
// api/app/Models/NarratorText.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class NarratorText extends Model
{
    protected $fillable = [
        'context',
        'text_key',
        'variant',
        'hero_type',
    ];

    public function scopeForContext($query, string $context)
    {
        return $query->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
<?php
// database/seeders/NarratorTextSeeder.php

namespace Database\Seeders;

use App\Models\NarratorText;
use App\Models\Translation;
use Illuminate\Database\Seeder;

class NarratorTextSeeder extends Seeder
{
    public function run(): void
    {
        $texts = [
            // INTRO - Recruteur (vouvoiement)
            ['context' => '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
<?php
// api/app/Http/Controllers/Api/NarratorController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\NarratorText;
use App\Models\Translation;
use Illuminate\Http\Request;

class NarratorController extends Controller
{
    private const VALID_CONTEXTS = [
        'intro',
        'transition_projects',
        'transition_skills',
        'transition_testimonials',
        'transition_journey',
        'hint',
        'encouragement_25',
        'encouragement_50',
        'encouragement_75',
        'contact_unlocked',
        'welcome_back',
    ];

    public function getText(Request $request, string $context)
    {
        if (!in_array($context, self::VALID_CONTEXTS)) {
            return response()->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<NarratorTextResponse>(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 |
| 2026-02-07 | Implémentation complète: table, model, seeder, API, composable | Claude Opus 4.5 |

### File List

