Setup complet de l'infrastructure projet : - Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS) - Backend Laravel 12 API-only avec middleware X-API-Key et CORS - Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter) - Documentation planning et implementation artifacts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
19 KiB
19 KiB
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
- Given les migrations Laravel sont exécutées When
php artisan migrateest lancé Then la tablenarrator_textsest créée (id, context, text_key, variant, timestamps) - 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
- And plusieurs variantes par contexte permettent une sélection aléatoire
- And les seeders insèrent les textes de base en FR et EN dans la table
translations - 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é - And le texte est traduit selon le header
Accept-Language - 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
- Créer migration
-
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
- Créer
-
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
- Créer
-
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
- Créer
-
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
- Créer
-
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
// 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
// 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
// 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
// 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,
],
]);
}
}
// api/routes/api.php
Route::get('/narrator/{context}', [NarratorController::class, 'getText']);
Composable useFetchNarratorText
// 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 |