Files
Portfolio-Game/docs/implementation-artifacts/3-1-table-narrator-texts-api-narrateur.md
skycel c572af3072 Add narrator texts infrastructure with API (Story 3.1)
- Create narrator_texts table migration with context/hero_type indexes
- Add NarratorText model with getRandomText() for variant selection
- Add NarratorTextSeeder with 30+ texts for 11 contexts
- Implement vouvoiement (recruteur) vs tutoiement (client/dev)
- Create NarratorController with GET /api/narrator/{context}
- Add useFetchNarratorText composable for frontend

Contexts: intro, transitions, hints, encouragements, contact_unlocked, welcome_back

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 02:45:05 +01:00

465 lines
19 KiB
Markdown

# 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