- 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>
465 lines
19 KiB
Markdown
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
|
|
|