From 7e87a341a200469c0bc8ec38fe17b60a919de75e Mon Sep 17 00:00:00 2001 From: skycel Date: Sun, 8 Feb 2026 13:35:12 +0100 Subject: [PATCH] :sparkles: feat(epic-4): chemins narratifs, easter eggs, challenge et contact Epic 4: Chemins Narratifs, Challenge & Contact Stories implementees: - 4.1: Composant ChoiceCards pour choix narratifs binaires - 4.2: Sequence d'intro narrative avec Le Bug - 4.3: Chemins narratifs differencies avec useNarrativePath - 4.4: Table easter_eggs et systeme de detection (API + composable) - 4.5: Easter eggs UI (popup, notification, collection) - 4.6: Page challenge avec puzzle de code - 4.7: Page revelation "Monde de Code" - 4.8: Page contact avec formulaire et stats Fichiers crees: - Frontend: ChoiceCards, IntroSequence, ZoneEndChoice, EasterEggPopup, CodePuzzle, ChallengeSuccess, CodeWorld, et pages intro/challenge/revelation - API: EasterEggController, Model, Migration, Seeder Co-Authored-By: Claude Opus 4.5 --- .../Controllers/Api/EasterEggController.php | 57 +++++ api/app/Models/EasterEgg.php | 39 +++ ..._02_08_000001_create_easter_eggs_table.php | 31 +++ api/database/seeders/DatabaseSeeder.php | 1 + api/database/seeders/EasterEggSeeder.php | 155 ++++++++++++ api/database/seeders/NarratorTextSeeder.php | 37 +++ api/routes/api.php | 3 + ...1-composant-choicecards-choix-narratifs.md | 84 ++++--- .../4-2-intro-narrative-premier-choix.md | 87 ++++--- .../components/feature/ChallengeSuccess.vue | 80 ++++++ .../app/components/feature/ChoiceCard.vue | 88 +++++++ .../app/components/feature/ChoiceCards.vue | 147 +++++++++++ .../app/components/feature/CodePuzzle.vue | 234 ++++++++++++++++++ frontend/app/components/feature/CodeWorld.vue | 89 +++++++ .../feature/EasterEggCollection.vue | 115 +++++++++ .../feature/EasterEggNotification.vue | 49 ++++ .../app/components/feature/EasterEggPopup.vue | 162 ++++++++++++ .../components/feature/IntroBackground.vue | 100 ++++++++ .../app/components/feature/IntroSequence.vue | 112 +++++++++ .../app/components/feature/ZoneEndChoice.vue | 37 +++ .../app/composables/useEasterEggDetection.ts | 174 +++++++++++++ .../app/composables/useFetchEasterEggs.ts | 83 +++++++ .../app/composables/useFetchNarratorText.ts | 3 + frontend/app/composables/useNarrativePath.ts | 153 ++++++++++++ frontend/app/data/narrativePaths.ts | 85 +++++++ frontend/app/pages/challenge.vue | 134 ++++++++++ frontend/app/pages/competences.vue | 3 + frontend/app/pages/contact.vue | 213 +++++++++++++++- frontend/app/pages/index.vue | 2 +- frontend/app/pages/intro.vue | 154 ++++++++++++ frontend/app/pages/parcours.vue | 5 +- frontend/app/pages/projets/index.vue | 3 + frontend/app/pages/revelation.vue | 179 ++++++++++++++ frontend/app/pages/temoignages.vue | 20 +- frontend/app/stores/progression.ts | 12 + frontend/app/types/choice.ts | 67 +++++ frontend/i18n/en.json | 68 +++++ frontend/i18n/fr.json | 68 +++++ 38 files changed, 3037 insertions(+), 96 deletions(-) create mode 100644 api/app/Http/Controllers/Api/EasterEggController.php create mode 100644 api/app/Models/EasterEgg.php create mode 100644 api/database/migrations/2026_02_08_000001_create_easter_eggs_table.php create mode 100644 api/database/seeders/EasterEggSeeder.php create mode 100644 frontend/app/components/feature/ChallengeSuccess.vue create mode 100644 frontend/app/components/feature/ChoiceCard.vue create mode 100644 frontend/app/components/feature/ChoiceCards.vue create mode 100644 frontend/app/components/feature/CodePuzzle.vue create mode 100644 frontend/app/components/feature/CodeWorld.vue create mode 100644 frontend/app/components/feature/EasterEggCollection.vue create mode 100644 frontend/app/components/feature/EasterEggNotification.vue create mode 100644 frontend/app/components/feature/EasterEggPopup.vue create mode 100644 frontend/app/components/feature/IntroBackground.vue create mode 100644 frontend/app/components/feature/IntroSequence.vue create mode 100644 frontend/app/components/feature/ZoneEndChoice.vue create mode 100644 frontend/app/composables/useEasterEggDetection.ts create mode 100644 frontend/app/composables/useFetchEasterEggs.ts create mode 100644 frontend/app/composables/useNarrativePath.ts create mode 100644 frontend/app/data/narrativePaths.ts create mode 100644 frontend/app/pages/challenge.vue create mode 100644 frontend/app/pages/intro.vue create mode 100644 frontend/app/pages/revelation.vue create mode 100644 frontend/app/types/choice.ts diff --git a/api/app/Http/Controllers/Api/EasterEggController.php b/api/app/Http/Controllers/Api/EasterEggController.php new file mode 100644 index 0000000..777ea40 --- /dev/null +++ b/api/app/Http/Controllers/Api/EasterEggController.php @@ -0,0 +1,57 @@ +select('slug', 'location', 'trigger_type', 'difficulty') + ->get(); + + return response()->json([ + 'data' => $easterEggs, + 'meta' => [ + 'total' => $easterEggs->count(), + ], + ]); + } + + /** + * Valide un easter egg et retourne la récompense + */ + public function validate(Request $request, string $slug): JsonResponse + { + $easterEgg = EasterEgg::active()->where('slug', $slug)->first(); + + if (!$easterEgg) { + return response()->json([ + 'error' => [ + 'code' => 'EASTER_EGG_NOT_FOUND', + 'message' => 'Easter egg not found or inactive', + ], + ], 404); + } + + $lang = app()->getLocale(); + $reward = $easterEgg->getReward($lang); + + return response()->json([ + 'data' => [ + 'slug' => $easterEgg->slug, + 'reward_type' => $easterEgg->reward_type, + 'reward' => $reward, + 'difficulty' => $easterEgg->difficulty, + ], + ]); + } +} diff --git a/api/app/Models/EasterEgg.php b/api/app/Models/EasterEgg.php new file mode 100644 index 0000000..5f99e8a --- /dev/null +++ b/api/app/Models/EasterEgg.php @@ -0,0 +1,39 @@ + 'integer', + 'is_active' => 'boolean', + ]; + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + public function scopeByLocation(Builder $query, string $location): Builder + { + return $query->where('location', $location); + } + + public function getReward(string $lang = 'fr'): ?string + { + return Translation::getTranslation($this->reward_key, $lang); + } +} diff --git a/api/database/migrations/2026_02_08_000001_create_easter_eggs_table.php b/api/database/migrations/2026_02_08_000001_create_easter_eggs_table.php new file mode 100644 index 0000000..6811cba --- /dev/null +++ b/api/database/migrations/2026_02_08_000001_create_easter_eggs_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('slug')->unique(); + $table->string('location'); // Page ou zone où se trouve l'easter egg + $table->enum('trigger_type', ['click', 'hover', 'konami', 'scroll', 'sequence']); + $table->enum('reward_type', ['snippet', 'anecdote', 'image', 'badge']); + $table->string('reward_key'); // Clé de traduction pour la récompense + $table->unsignedTinyInteger('difficulty')->default(1); // 1-5 + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index('is_active'); + $table->index('location'); + }); + } + + public function down(): void + { + Schema::dropIfExists('easter_eggs'); + } +}; diff --git a/api/database/seeders/DatabaseSeeder.php b/api/database/seeders/DatabaseSeeder.php index e295add..8550aa4 100644 --- a/api/database/seeders/DatabaseSeeder.php +++ b/api/database/seeders/DatabaseSeeder.php @@ -18,6 +18,7 @@ class DatabaseSeeder extends Seeder SkillProjectSeeder::class, TestimonialSeeder::class, NarratorTextSeeder::class, + EasterEggSeeder::class, ]); } } diff --git a/api/database/seeders/EasterEggSeeder.php b/api/database/seeders/EasterEggSeeder.php new file mode 100644 index 0000000..79bd0db --- /dev/null +++ b/api/database/seeders/EasterEggSeeder.php @@ -0,0 +1,155 @@ + 'konami-master', + 'location' => 'landing', + 'trigger_type' => 'konami', + 'reward_type' => 'badge', + 'reward_key' => 'easter.konami.reward', + 'difficulty' => 3, + ], + // 2. Clic sur l'araignée cachée (header) + [ + 'slug' => 'hidden-spider', + 'location' => 'header', + 'trigger_type' => 'click', + 'reward_type' => 'anecdote', + 'reward_key' => 'easter.spider.reward', + 'difficulty' => 2, + ], + // 3. Hover sur un caractère spécial dans le code (page projets) + [ + 'slug' => 'secret-comment', + 'location' => 'projects', + 'trigger_type' => 'hover', + 'reward_type' => 'snippet', + 'reward_key' => 'easter.comment.reward', + 'difficulty' => 2, + ], + // 4. Scroll jusqu'en bas de la page parcours + [ + 'slug' => 'journey-end', + 'location' => 'journey', + 'trigger_type' => 'scroll', + 'reward_type' => 'anecdote', + 'reward_key' => 'easter.journey_end.reward', + 'difficulty' => 1, + ], + // 5. Séquence de clics sur les compétences (Vue, Laravel, TypeScript) + [ + 'slug' => 'tech-sequence', + 'location' => 'skills', + 'trigger_type' => 'sequence', + 'reward_type' => 'snippet', + 'reward_key' => 'easter.tech_seq.reward', + 'difficulty' => 4, + ], + // 6. Clic sur le logo Skycel 5 fois + [ + 'slug' => 'logo-clicks', + 'location' => 'global', + 'trigger_type' => 'click', + 'reward_type' => 'image', + 'reward_key' => 'easter.logo.reward', + 'difficulty' => 2, + ], + // 7. Hover sur la date "2022" dans le parcours + [ + 'slug' => 'founding-date', + 'location' => 'journey', + 'trigger_type' => 'hover', + 'reward_type' => 'anecdote', + 'reward_key' => 'easter.founding.reward', + 'difficulty' => 2, + ], + // 8. Console.log dans les devtools + [ + 'slug' => 'dev-console', + 'location' => 'global', + 'trigger_type' => 'sequence', + 'reward_type' => 'badge', + 'reward_key' => 'easter.console.reward', + 'difficulty' => 3, + ], + ]; + + foreach ($easterEggs as $egg) { + EasterEgg::firstOrCreate(['slug' => $egg['slug']], $egg); + } + + // Traductions des récompenses + $translations = [ + // Konami + [ + 'key' => 'easter.konami.reward', + 'fr' => "Badge 'Gamer' debloque ! Tu connais les classiques.", + 'en' => "'Gamer' badge unlocked! You know the classics.", + ], + // Spider + [ + 'key' => 'easter.spider.reward', + 'fr' => "Tu m'as trouve ! Je me cache partout sur ce site... Le Bug te surveille toujours.", + 'en' => "You found me! I hide everywhere on this site... The Bug is always watching.", + ], + // Comment + [ + 'key' => 'easter.comment.reward', + 'fr' => "// Premier code ecrit : console.log('Hello World'); // Tout a commence la...", + 'en' => "// First code written: console.log('Hello World'); // It all started there...", + ], + // Journey end + [ + 'key' => 'easter.journey_end.reward', + 'fr' => "Tu as lu jusqu'au bout ? Respect. Le prochain chapitre s'ecrit peut-etre avec toi.", + 'en' => "You read all the way? Respect. The next chapter might be written with you.", + ], + // Tech sequence + [ + 'key' => 'easter.tech_seq.reward', + 'fr' => "const stack = ['Vue', 'Laravel', 'TypeScript'];\n// La sainte trinite du dev moderne", + 'en' => "const stack = ['Vue', 'Laravel', 'TypeScript'];\n// The holy trinity of modern dev", + ], + // Logo + [ + 'key' => 'easter.logo.reward', + 'fr' => "Image secrete debloquee : La premiere version du logo Skycel (spoiler: c'etait moche)", + 'en' => "Secret image unlocked: The first version of the Skycel logo (spoiler: it was ugly)", + ], + // Founding + [ + 'key' => 'easter.founding.reward', + 'fr' => "2022 : l'annee ou Le Bug est ne. Litteralement un bug dans le code qui m'a donne l'idee de la mascotte.", + 'en' => "2022: the year The Bug was born. Literally a bug in the code that gave me the mascot idea.", + ], + // Console + [ + 'key' => 'easter.console.reward', + 'fr' => "Badge 'Developpeur' debloque ! Tu as verifie la console comme un vrai dev.", + 'en' => "'Developer' badge unlocked! You checked the console like a real dev.", + ], + ]; + + 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/database/seeders/NarratorTextSeeder.php b/api/database/seeders/NarratorTextSeeder.php index 60ae09d..6570bc8 100644 --- a/api/database/seeders/NarratorTextSeeder.php +++ b/api/database/seeders/NarratorTextSeeder.php @@ -23,6 +23,18 @@ class NarratorTextSeeder extends Seeder ['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'], + // INTRO SEQUENCE 1 - Recruteur + ['context' => 'intro_sequence_1', 'text_key' => 'narrator.intro_seq.1.recruteur', 'variant' => 1, 'hero_type' => 'recruteur'], + // INTRO SEQUENCE 1 - Client/Dev + ['context' => 'intro_sequence_1', 'text_key' => 'narrator.intro_seq.1.casual', 'variant' => 1, 'hero_type' => 'client'], + ['context' => 'intro_sequence_1', 'text_key' => 'narrator.intro_seq.1.casual', 'variant' => 1, 'hero_type' => 'dev'], + + // INTRO SEQUENCE 2 - Tous + ['context' => 'intro_sequence_2', 'text_key' => 'narrator.intro_seq.2', 'variant' => 1, 'hero_type' => null], + + // INTRO SEQUENCE 3 - Tous + ['context' => 'intro_sequence_3', 'text_key' => 'narrator.intro_seq.3', 'variant' => 1, 'hero_type' => null], + // 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], @@ -214,6 +226,31 @@ class NarratorTextSeeder extends Seeder 'fr' => "Tiens, un visage familier ! Content de te revoir, voyageur.", 'en' => "Well, a familiar face! Good to see you again, traveler.", ], + + // Intro Sequence 1 - Recruteur + [ + 'key' => 'narrator.intro_seq.1.recruteur', + 'fr' => "Bienvenue dans mon domaine, voyageur... Je suis Le Bug, et je vais vous guider dans cette aventure.", + 'en' => "Welcome to my domain, traveler... I am The Bug, and I will guide you through this adventure.", + ], + // Intro Sequence 1 - Casual + [ + 'key' => 'narrator.intro_seq.1.casual', + 'fr' => "Hey ! Bienvenue chez moi. Je suis Le Bug, ton guide pour cette aventure.", + 'en' => "Hey! Welcome to my place. I'm The Bug, your guide for this adventure.", + ], + // Intro Sequence 2 + [ + 'key' => 'narrator.intro_seq.2', + 'fr' => "Il y a quelqu'un ici que tu cherches... Un développeur mystérieux qui a créé tout ce que tu vois autour de toi.", + 'en' => "There's someone here you're looking for... A mysterious developer who created everything you see around you.", + ], + // Intro Sequence 3 + [ + 'key' => 'narrator.intro_seq.3', + 'fr' => "Pour le trouver, tu devras explorer ce monde. Chaque zone cache une partie de son histoire. Es-tu prêt ?", + 'en' => "To find them, you'll have to explore this world. Each zone hides a part of their story. Are you ready?", + ], ]; foreach ($translations as $t) { diff --git a/api/routes/api.php b/api/routes/api.php index cf4bd6a..82eb96f 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -1,5 +1,6 @@ +
+ +
+ +
+ +
+ + +

+ {{ $t('challenge.success') }} +

+ +

+ {{ $t('challenge.successMessage') }} +

+ +

+ {{ $t('challenge.redirecting') }} +

+
+
+ + + + + diff --git a/frontend/app/components/feature/ChoiceCard.vue b/frontend/app/components/feature/ChoiceCard.vue new file mode 100644 index 0000000..52cb51c --- /dev/null +++ b/frontend/app/components/feature/ChoiceCard.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/frontend/app/components/feature/ChoiceCards.vue b/frontend/app/components/feature/ChoiceCards.vue new file mode 100644 index 0000000..0426e78 --- /dev/null +++ b/frontend/app/components/feature/ChoiceCards.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/frontend/app/components/feature/CodePuzzle.vue b/frontend/app/components/feature/CodePuzzle.vue new file mode 100644 index 0000000..ed0cfb0 --- /dev/null +++ b/frontend/app/components/feature/CodePuzzle.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/frontend/app/components/feature/CodeWorld.vue b/frontend/app/components/feature/CodeWorld.vue new file mode 100644 index 0000000..be9b907 --- /dev/null +++ b/frontend/app/components/feature/CodeWorld.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/frontend/app/components/feature/EasterEggCollection.vue b/frontend/app/components/feature/EasterEggCollection.vue new file mode 100644 index 0000000..d8bb028 --- /dev/null +++ b/frontend/app/components/feature/EasterEggCollection.vue @@ -0,0 +1,115 @@ + + + diff --git a/frontend/app/components/feature/EasterEggNotification.vue b/frontend/app/components/feature/EasterEggNotification.vue new file mode 100644 index 0000000..52fff4b --- /dev/null +++ b/frontend/app/components/feature/EasterEggNotification.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/frontend/app/components/feature/EasterEggPopup.vue b/frontend/app/components/feature/EasterEggPopup.vue new file mode 100644 index 0000000..b738772 --- /dev/null +++ b/frontend/app/components/feature/EasterEggPopup.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/frontend/app/components/feature/IntroBackground.vue b/frontend/app/components/feature/IntroBackground.vue new file mode 100644 index 0000000..0026fa0 --- /dev/null +++ b/frontend/app/components/feature/IntroBackground.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/app/components/feature/IntroSequence.vue b/frontend/app/components/feature/IntroSequence.vue new file mode 100644 index 0000000..54c3a1b --- /dev/null +++ b/frontend/app/components/feature/IntroSequence.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/app/components/feature/ZoneEndChoice.vue b/frontend/app/components/feature/ZoneEndChoice.vue new file mode 100644 index 0000000..08f8e6a --- /dev/null +++ b/frontend/app/components/feature/ZoneEndChoice.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/app/composables/useEasterEggDetection.ts b/frontend/app/composables/useEasterEggDetection.ts new file mode 100644 index 0000000..8a23790 --- /dev/null +++ b/frontend/app/composables/useEasterEggDetection.ts @@ -0,0 +1,174 @@ +import type { EasterEggMeta } from './useFetchEasterEggs' + +interface UseEasterEggDetectionOptions { + onFound: (slug: string) => void +} + +// Konami Code +const KONAMI_CODE = [ + 'ArrowUp', + 'ArrowUp', + 'ArrowDown', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'ArrowLeft', + 'ArrowRight', + 'KeyB', + 'KeyA', +] + +export function useEasterEggDetection(options: UseEasterEggDetectionOptions) { + const { fetchList } = useFetchEasterEggs() + const progressionStore = useProgressionStore() + + // State + const konamiIndex = ref(0) + const clickSequence = ref([]) + let konamiListenerAdded = false + + // Load easter eggs on mount + onMounted(() => { + fetchList() + initKonamiListener() + }) + + // === Konami Code === + function initKonamiListener() { + if (import.meta.client && !konamiListenerAdded) { + window.addEventListener('keydown', handleKonamiKey) + konamiListenerAdded = true + } + } + + function handleKonamiKey(e: KeyboardEvent) { + if (e.code === KONAMI_CODE[konamiIndex.value]) { + konamiIndex.value++ + if (konamiIndex.value === KONAMI_CODE.length) { + triggerEasterEgg('konami-master') + konamiIndex.value = 0 + } + } else { + konamiIndex.value = 0 + } + } + + // === Click Detection === + function detectClick(targetSlug: string, requiredClicks: number = 1) { + const clicks = ref(0) + + function handleClick() { + clicks.value++ + if (clicks.value >= requiredClicks) { + triggerEasterEgg(targetSlug) + clicks.value = 0 + } + } + + return { handleClick, clicks } + } + + // === Hover Detection === + function detectHover(targetSlug: string, hoverTime: number = 2000) { + let timeoutId: ReturnType | null = null + + function handleMouseEnter() { + timeoutId = setTimeout(() => { + triggerEasterEgg(targetSlug) + }, hoverTime) + } + + function handleMouseLeave() { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + } + + return { handleMouseEnter, handleMouseLeave } + } + + // === Scroll Detection === + function detectScrollBottom(targetSlug: string) { + let triggered = false + + function checkScroll() { + if (triggered) return + + const scrollTop = window.scrollY + const windowHeight = window.innerHeight + const docHeight = document.documentElement.scrollHeight + + if (scrollTop + windowHeight >= docHeight - 50) { + triggerEasterEgg(targetSlug) + triggered = true + } + } + + onMounted(() => { + if (import.meta.client) { + window.addEventListener('scroll', checkScroll, { passive: true }) + } + }) + + onUnmounted(() => { + if (import.meta.client) { + window.removeEventListener('scroll', checkScroll) + } + }) + } + + // === Sequence Detection === + function detectSequence(expectedSequence: string[], targetSlug: string) { + function addToSequence(item: string) { + clickSequence.value.push(item) + + // Keep only the last N items + if (clickSequence.value.length > expectedSequence.length) { + clickSequence.value.shift() + } + + // Check if sequence matches + if (clickSequence.value.length === expectedSequence.length) { + const match = clickSequence.value.every( + (val, idx) => val === expectedSequence[idx] + ) + if (match) { + triggerEasterEgg(targetSlug) + clickSequence.value = [] + } + } + } + + return { addToSequence } + } + + // === Trigger Easter Egg === + function triggerEasterEgg(slug: string) { + // Check if already found + if (progressionStore.easterEggsFound.includes(slug)) { + return + } + + // Mark as found + progressionStore.markEasterEggFound(slug) + + // Notify + options.onFound(slug) + } + + onUnmounted(() => { + if (import.meta.client && konamiListenerAdded) { + window.removeEventListener('keydown', handleKonamiKey) + konamiListenerAdded = false + } + }) + + return { + detectClick, + detectHover, + detectScrollBottom, + detectSequence, + triggerEasterEgg, + } +} diff --git a/frontend/app/composables/useFetchEasterEggs.ts b/frontend/app/composables/useFetchEasterEggs.ts new file mode 100644 index 0000000..f5fc4d9 --- /dev/null +++ b/frontend/app/composables/useFetchEasterEggs.ts @@ -0,0 +1,83 @@ +export type TriggerType = 'click' | 'hover' | 'konami' | 'scroll' | 'sequence' +export type RewardType = 'snippet' | 'anecdote' | 'image' | 'badge' + +export interface EasterEggMeta { + slug: string + location: string + trigger_type: TriggerType + difficulty: number +} + +export interface EasterEggReward { + slug: string + reward_type: RewardType + reward: string + difficulty: number +} + +interface EasterEggListResponse { + data: EasterEggMeta[] + meta: { total: number } +} + +interface EasterEggValidateResponse { + data: EasterEggReward +} + +export function useFetchEasterEggs() { + const config = useRuntimeConfig() + const { locale } = useI18n() + + // Cache des easter eggs disponibles + const availableEasterEggs = ref([]) + const isLoaded = ref(false) + + async function fetchList(): Promise { + if (isLoaded.value) return availableEasterEggs.value + + try { + const response = await $fetch('/easter-eggs', { + baseURL: config.public.apiUrl as string, + headers: { + 'X-API-Key': config.public.apiKey as string, + }, + }) + + availableEasterEggs.value = response.data + isLoaded.value = true + return response.data + } catch { + return [] + } + } + + async function validate(slug: string): Promise { + try { + const response = await $fetch(`/easter-eggs/${slug}/validate`, { + method: 'POST', + 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 + } + } + + function getByLocation(location: string): EasterEggMeta[] { + return availableEasterEggs.value.filter( + e => e.location === location || e.location === 'global' + ) + } + + return { + availableEasterEggs: readonly(availableEasterEggs), + isLoaded: readonly(isLoaded), + fetchList, + validate, + getByLocation, + } +} diff --git a/frontend/app/composables/useFetchNarratorText.ts b/frontend/app/composables/useFetchNarratorText.ts index 3dc1b1c..a8782a7 100644 --- a/frontend/app/composables/useFetchNarratorText.ts +++ b/frontend/app/composables/useFetchNarratorText.ts @@ -1,5 +1,8 @@ export type NarratorContext = | 'intro' + | 'intro_sequence_1' + | 'intro_sequence_2' + | 'intro_sequence_3' | 'transition_projects' | 'transition_skills' | 'transition_testimonials' diff --git a/frontend/app/composables/useNarrativePath.ts b/frontend/app/composables/useNarrativePath.ts new file mode 100644 index 0000000..4297cb7 --- /dev/null +++ b/frontend/app/composables/useNarrativePath.ts @@ -0,0 +1,153 @@ +import { ZONES, NARRATIVE_PATHS, choiceIdToZone, type ZoneKey } from '~/data/narrativePaths' +import type { Choice, ChoicePoint } from '~/types/choice' + +export function useNarrativePath() { + const progressionStore = useProgressionStore() + const { locale } = useI18n() + const route = useRoute() + + // Déterminer le chemin actuel basé sur les choix + const currentPath = computed(() => { + const choices = progressionStore.choices + + // Premier choix (intro) + const introChoice = choices['intro_first_choice'] + if (!introChoice) return null + + const startZone = choiceIdToZone(introChoice) + if (!startZone) return null + + // Filtrer les chemins qui commencent par cette zone + let possiblePaths = NARRATIVE_PATHS.filter(path => path[0] === startZone) + + // Affiner avec les choix suivants si disponibles + const afterFirstZoneChoice = choices[`after_${startZone}`] + if (afterFirstZoneChoice && possiblePaths.length > 1) { + const secondZone = choiceIdToZone(afterFirstZoneChoice) + if (secondZone) { + possiblePaths = possiblePaths.filter(path => path[1] === secondZone) + } + } + + return possiblePaths[0] || null + }) + + // Zone actuelle basée sur la route + const currentZone = computed(() => { + const path = route.path.toLowerCase() + + if (path.includes('projets') || path.includes('projects')) return 'projects' + if (path.includes('competences') || path.includes('skills')) return 'skills' + if (path.includes('temoignages') || path.includes('testimonials')) return 'testimonials' + if (path.includes('parcours') || path.includes('journey')) return 'journey' + if (path.includes('contact')) return 'contact' + + return null + }) + + // Index de la zone actuelle dans le chemin + const currentZoneIndex = computed(() => { + if (!currentPath.value || !currentZone.value) return -1 + return currentPath.value.indexOf(currentZone.value) + }) + + // Prochaine zone suggérée + const suggestedNextZone = computed(() => { + if (!currentPath.value || currentZoneIndex.value === -1) return null + + const nextIndex = currentZoneIndex.value + 1 + if (nextIndex >= currentPath.value.length) return null + + return currentPath.value[nextIndex] + }) + + // Zones restantes à visiter (excluant contact) + const remainingZones = computed(() => { + const mainZones: ZoneKey[] = ['projects', 'skills', 'testimonials', 'journey'] + const visited = progressionStore.visitedSections + + return mainZones.filter(zone => !visited.includes(zone)) + }) + + // Obtenir la route pour une zone + function getZoneRoute(zone: ZoneKey): string { + const zoneInfo = ZONES[zone] + if (!zoneInfo) return '/' + return locale.value === 'fr' ? zoneInfo.routeFr : zoneInfo.routeEn + } + + // Générer le ChoicePoint pour après la zone actuelle + function getNextChoicePoint(): ChoicePoint | null { + if (remainingZones.value.length === 0) { + // Plus de zones, proposer le contact uniquement + return { + id: 'go_to_contact', + questionFr: 'Tu as tout exploré ! Prêt à me rencontrer ?', + questionEn: 'You explored everything! Ready to meet me?', + choices: [ + createChoice('contact'), + createChoice('contact'), // Dupliqué car ChoicePoint attend 2 choices + ], + context: 'contact_ready', + } + } + + // Proposer les 2 prochaines zones non visitées + const nextTwo = remainingZones.value.slice(0, 2) + + if (nextTwo.length === 1) { + // Une seule zone restante, proposer zone + contact + return { + id: `after_${currentZone.value}`, + questionFr: 'Où vas-tu ensuite ?', + questionEn: 'Where to next?', + choices: [ + createChoice(nextTwo[0]), + createChoice('contact'), + ], + context: 'one_zone_left', + } + } + + return { + id: `after_${currentZone.value}`, + questionFr: 'Où vas-tu ensuite ?', + questionEn: 'Where to next?', + choices: [ + createChoice(nextTwo[0]), + createChoice(nextTwo[1]), + ], + context: 'zone_choice', + } + } + + // Créer un objet Choice pour une zone + function createChoice(zone: ZoneKey): Choice { + const zoneInfo = ZONES[zone] + return { + id: `choice_${zone}`, + textFr: zoneInfo.labelFr, + textEn: zoneInfo.labelEn, + icon: zoneInfo.icon, + destination: getZoneRoute(zone), + zoneColor: zoneInfo.color, + } + } + + // Vérifier si le contact est débloqué + const isContactReady = computed(() => { + return remainingZones.value.length === 0 || progressionStore.isContactUnlocked + }) + + return { + currentPath, + currentZone, + currentZoneIndex, + suggestedNextZone, + remainingZones, + isContactReady, + getZoneRoute, + getNextChoicePoint, + createChoice, + } +} diff --git a/frontend/app/data/narrativePaths.ts b/frontend/app/data/narrativePaths.ts new file mode 100644 index 0000000..de5ea70 --- /dev/null +++ b/frontend/app/data/narrativePaths.ts @@ -0,0 +1,85 @@ +export type ZoneKey = 'projects' | 'skills' | 'testimonials' | 'journey' | 'contact' + +export interface ZoneInfo { + labelFr: string + labelEn: string + icon: string + color: string + routeFr: string + routeEn: string +} + +export const ZONES: Record = { + projects: { + labelFr: 'Découvrir les créations', + labelEn: 'Discover the creations', + icon: '💻', + color: '#3b82f6', + routeFr: '/projets', + routeEn: '/en/projects', + }, + skills: { + labelFr: 'Explorer les compétences', + labelEn: 'Explore the skills', + icon: '⚡', + color: '#10b981', + routeFr: '/competences', + routeEn: '/en/skills', + }, + testimonials: { + labelFr: 'Écouter les témoignages', + labelEn: 'Listen to testimonials', + icon: '💬', + color: '#f59e0b', + routeFr: '/temoignages', + routeEn: '/en/testimonials', + }, + journey: { + labelFr: 'Suivre le parcours', + labelEn: 'Follow the journey', + icon: '📍', + color: '#8b5cf6', + routeFr: '/parcours', + routeEn: '/en/journey', + }, + contact: { + labelFr: 'Rencontrer le développeur', + labelEn: 'Meet the developer', + icon: '📧', + color: '#fa784f', + routeFr: '/contact', + routeEn: '/en/contact', + }, +} + +// Chemins possibles (8 combinaisons basées sur 3 points de choix) +// Tous les chemins passent par toutes les zones et mènent au contact +export const NARRATIVE_PATHS: ZoneKey[][] = [ + // Chemin 1-4 : Commençant par Projets + ['projects', 'testimonials', 'skills', 'journey', 'contact'], + ['projects', 'testimonials', 'journey', 'skills', 'contact'], + ['projects', 'journey', 'testimonials', 'skills', 'contact'], + ['projects', 'journey', 'skills', 'testimonials', 'contact'], + // Chemin 5-8 : Commençant par Compétences + ['skills', 'testimonials', 'projects', 'journey', 'contact'], + ['skills', 'testimonials', 'journey', 'projects', 'contact'], + ['skills', 'journey', 'testimonials', 'projects', 'contact'], + ['skills', 'journey', 'projects', 'testimonials', 'contact'], +] + +// Points de choix et leurs options +export const CHOICE_POINT_OPTIONS: Record = { + intro_first_choice: { optionA: 'projects', optionB: 'skills' }, + after_projects: { optionA: 'testimonials', optionB: 'journey' }, + after_skills: { optionA: 'testimonials', optionB: 'journey' }, +} + +// Mapper choice ID vers zone +export function choiceIdToZone(choiceId: string): ZoneKey | null { + if (choiceId.includes('projects')) return 'projects' + if (choiceId.includes('skills')) return 'skills' + if (choiceId.includes('testimonials')) return 'testimonials' + if (choiceId.includes('journey')) return 'journey' + if (choiceId.includes('contact')) return 'contact' + return null +} diff --git a/frontend/app/pages/challenge.vue b/frontend/app/pages/challenge.vue new file mode 100644 index 0000000..b457e15 --- /dev/null +++ b/frontend/app/pages/challenge.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/app/pages/competences.vue b/frontend/app/pages/competences.vue index 7a85ea4..d09df61 100644 --- a/frontend/app/pages/competences.vue +++ b/frontend/app/pages/competences.vue @@ -67,6 +67,9 @@ + + + -
-

{{ $t('pages.contact.title') }}

-

{{ $t('pages.contact.description') }}

+
+ +
+

+ {{ $t('contact.statsTitle') }} +

+
+
+

{{ stats.zonesVisited }}/{{ stats.zonesTotal }}

+

{{ $t('contact.zones') }}

+
+
+

{{ stats.easterEggsFound }}/{{ stats.easterEggsTotal }}

+

{{ $t('contact.easterEggs') }}

+
+
+

+ {{ stats.challengeCompleted ? 'OK' : '-' }} +

+

{{ $t('contact.challenge') }}

+
+
+
+ + +
+

+ {{ $t('contact.title') }} +

+

+ {{ $t('contact.subtitle') }} +

+
+ + + +
+ +

+ {{ $t('contact.success') }} +

+

+ {{ $t('contact.successMessage') }} +

+
+
+ + +
+ + + + +
+ + +
+ + +
+ + +
+ + +
+ +