diff --git a/api/app/Http/Controllers/Api/NarratorController.php b/api/app/Http/Controllers/Api/NarratorController.php new file mode 100644 index 0000000..851ad7f --- /dev/null +++ b/api/app/Http/Controllers/Api/NarratorController.php @@ -0,0 +1,74 @@ +json([ + 'error' => [ + 'code' => 'INVALID_CONTEXT', + 'message' => 'Invalid narrator context', + 'valid_contexts' => self::VALID_CONTEXTS, + ], + ], 404); + } + + $lang = app()->getLocale(); + $heroType = $request->query('hero'); + + // Valider hero_type + if ($heroType && !in_array($heroType, self::VALID_HERO_TYPES)) { + $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, + 'hero_type' => $narratorText->hero_type, + ], + 'meta' => [ + 'lang' => $lang, + ], + ]); + } +} diff --git a/api/app/Models/NarratorText.php b/api/app/Models/NarratorText.php new file mode 100644 index 0000000..58b59ab --- /dev/null +++ b/api/app/Models/NarratorText.php @@ -0,0 +1,48 @@ +where('context', $context); + } + + public function scopeForHero(Builder $query, ?string $heroType): Builder + { + 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(); + } +} diff --git a/api/database/migrations/2026_02_07_000001_create_narrator_texts_table.php b/api/database/migrations/2026_02_07_000001_create_narrator_texts_table.php new file mode 100644 index 0000000..a23c158 --- /dev/null +++ b/api/database/migrations/2026_02_07_000001_create_narrator_texts_table.php @@ -0,0 +1,28 @@ +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'); + } +}; diff --git a/api/database/seeders/DatabaseSeeder.php b/api/database/seeders/DatabaseSeeder.php index bca373d..e295add 100644 --- a/api/database/seeders/DatabaseSeeder.php +++ b/api/database/seeders/DatabaseSeeder.php @@ -17,6 +17,7 @@ class DatabaseSeeder extends Seeder ProjectSeeder::class, SkillProjectSeeder::class, TestimonialSeeder::class, + NarratorTextSeeder::class, ]); } } diff --git a/api/database/seeders/NarratorTextSeeder.php b/api/database/seeders/NarratorTextSeeder.php new file mode 100644 index 0000000..60ae09d --- /dev/null +++ b/api/database/seeders/NarratorTextSeeder.php @@ -0,0 +1,230 @@ + '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 (tutoiement) + ['context' => 'intro', 'text_key' => 'narrator.intro.casual.1', 'variant' => 1, 'hero_type' => 'client'], + ['context' => 'intro', 'text_key' => 'narrator.intro.casual.2', 'variant' => 2, 'hero_type' => 'client'], + + // INTRO - Dev (tutoiement) + ['context' => 'intro', 'text_key' => 'narrator.intro.dev.1', 'variant' => 1, 'hero_type' => 'dev'], + ['context' => 'intro', 'text_key' => 'narrator.intro.dev.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_testimonials', 'text_key' => 'narrator.transition.testimonials.2', 'variant' => 2, 'hero_type' => null], + ['context' => 'transition_journey', 'text_key' => 'narrator.transition.journey.1', 'variant' => 1, 'hero_type' => null], + ['context' => 'transition_journey', 'text_key' => 'narrator.transition.journey.2', 'variant' => 2, '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_25', 'text_key' => 'narrator.encouragement.25.2', 'variant' => 2, 'hero_type' => null], + ['context' => 'encouragement_50', 'text_key' => 'narrator.encouragement.50.1', 'variant' => 1, 'hero_type' => null], + ['context' => 'encouragement_50', 'text_key' => 'narrator.encouragement.50.2', 'variant' => 2, 'hero_type' => null], + ['context' => 'encouragement_75', 'text_key' => 'narrator.encouragement.75.1', 'variant' => 1, 'hero_type' => null], + ['context' => 'encouragement_75', 'text_key' => 'narrator.encouragement.75.2', 'variant' => 2, 'hero_type' => null], + + // CONTACT UNLOCKED + ['context' => 'contact_unlocked', 'text_key' => 'narrator.contact_unlocked.1', 'variant' => 1, 'hero_type' => null], + ['context' => 'contact_unlocked', 'text_key' => 'narrator.contact_unlocked.2', 'variant' => 2, '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::firstOrCreate( + ['text_key' => $data['text_key'], 'hero_type' => $data['hero_type']], + $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 (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...", + ], + + // Intro Dev (tutoiement technique) + [ + 'key' => 'narrator.intro.dev.1', + 'fr' => "Oh, un collègue développeur ! Tu vas apprécier ce qui se cache ici... Du code propre, des architectures soignées. Explore !", + 'en' => "Oh, a fellow developer! You're going to appreciate what's hidden here... Clean code, careful architectures. Explore!", + ], + [ + 'key' => 'narrator.intro.dev.2', + 'fr' => "Bienvenue dans la tanière d'un passionné de code ! Vue.js, Laravel, TypeScript... Tu connais la chanson. Fais comme chez toi !", + 'en' => "Welcome to a code enthusiast's den! Vue.js, Laravel, TypeScript... You know the drill. Make yourself at home!", + ], + + // 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.testimonials.2', + 'fr' => "Les échos du passé... Ceux qui ont travaillé avec lui ont quelque chose à dire.", + 'en' => "Echoes from the past... Those who worked with him have something to say.", + ], + [ + '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.", + ], + [ + 'key' => 'narrator.transition.journey.2', + 'fr' => "Une histoire en plusieurs chapitres... Du premier Hello World à aujourd'hui.", + 'en' => "A story in several chapters... From the first Hello World to today.", + ], + + // 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.25.2', + 'fr' => "25% du chemin parcouru ! Tu commences à comprendre comment ça fonctionne ici.", + 'en' => "25% of the way there! You're starting to understand how things work here.", + ], + [ + '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.50.2', + 'fr' => "La moitié de l'aventure est derrière toi. Impressionné par ce que tu as vu ?", + 'en' => "Half the adventure is behind you. Impressed by what you've seen?", + ], + [ + '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...", + ], + [ + 'key' => 'narrator.encouragement.75.2', + 'fr' => "75% ! Tu es un vrai explorateur. La fin approche...", + 'en' => "75%! You're a true explorer. The end is near...", + ], + + // 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!", + ], + [ + 'key' => 'narrator.contact_unlocked.2', + 'fr' => "Bravo, aventurier ! Tu as prouvé ta curiosité. Le développeur t'attend maintenant...", + 'en' => "Well done, adventurer! You've proven your curiosity. The developer is now waiting for you...", + ], + + // 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']] + ); + } + } +} diff --git a/api/routes/api.php b/api/routes/api.php index f0d540b..cf4bd6a 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -1,5 +1,6 @@ { + try { + const url = heroType + ? `/narrator/${context}?hero=${heroType}` + : `/narrator/${context}` + + const response = await $fetch(url, { + baseURL: config.public.apiUrl as string, + headers: { + 'X-API-Key': config.public.apiKey as string, + 'Accept-Language': locale.value, + }, + }) + + return response.data + } catch { + return null + } + } + + return { fetchText } +}