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 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 13:35:12 +01:00
parent 64b1a33d10
commit 7e87a341a2
38 changed files with 3037 additions and 96 deletions

View File

@@ -0,0 +1,134 @@
<template>
<div class="challenge-page min-h-screen bg-sky-dark relative">
<!-- Skip button (always visible) -->
<button
v-if="!puzzleCompleted"
type="button"
class="absolute top-4 right-4 text-sky-text/60 hover:text-sky-text text-sm font-ui underline z-10"
:class="{ 'text-sky-accent': hintsUsed >= 3 }"
@click="skipChallenge"
>
{{ $t('challenge.skip') }}
</button>
<!-- Introduction -->
<Transition name="fade" mode="out-in">
<div
v-if="showIntro"
key="intro"
class="flex flex-col items-center justify-center min-h-screen p-8"
>
<div class="max-w-lg text-center">
<div class="w-24 h-24 mx-auto mb-6 bg-sky-accent/20 rounded-full flex items-center justify-center">
<span class="text-4xl" aria-hidden="true">?</span>
</div>
<h1 class="text-3xl font-ui font-bold text-sky-text mb-4">
{{ $t('challenge.title') }}
</h1>
<p class="font-narrative text-xl text-sky-text/60 mb-8">
{{ $t('challenge.intro') }}
</p>
<button
type="button"
class="px-8 py-3 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors"
@click="startPuzzle"
>
{{ $t('challenge.accept') }}
</button>
</div>
</div>
<!-- Puzzle -->
<div
v-else-if="!puzzleCompleted"
key="puzzle"
class="flex flex-col items-center justify-center min-h-screen p-8"
>
<div class="max-w-2xl w-full">
<h2 class="text-xl font-ui font-bold text-sky-text mb-2 text-center">
{{ $t('challenge.puzzleTitle') }}
</h2>
<p class="text-sky-text/60 text-center mb-8">
{{ $t('challenge.puzzleInstruction') }}
</p>
<FeatureCodePuzzle
:hints-used="hintsUsed"
@solved="handlePuzzleSolved"
@hint-used="useHint"
/>
</div>
</div>
<!-- Success -->
<div
v-else
key="success"
class="flex flex-col items-center justify-center min-h-screen p-8"
>
<FeatureChallengeSuccess />
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'adventure',
})
const router = useRouter()
const progressionStore = useProgressionStore()
const localePath = useLocalePath()
// Check if contact is unlocked
onMounted(() => {
if (!progressionStore.contactUnlocked) {
navigateTo(localePath('/'))
}
})
// States
const showIntro = ref(true)
const puzzleCompleted = ref(false)
const hintsUsed = ref(0)
function startPuzzle() {
showIntro.value = false
}
function handlePuzzleSolved() {
puzzleCompleted.value = true
progressionStore.completeChallenge()
// Wait for animation then navigate
setTimeout(() => {
navigateTo(localePath('/revelation'))
}, 3000)
}
function skipChallenge() {
// Skip does not mark as completed
navigateTo(localePath('/revelation'))
}
function useHint() {
hintsUsed.value++
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -67,6 +67,9 @@
</div>
</div>
<!-- Choice for next zone -->
<FeatureZoneEndChoice />
<!-- Skill projects modal -->
<FeatureSkillProjectsModal
:is-open="isModalOpen"

View File

@@ -1,16 +1,221 @@
<template>
<div class="min-h-screen p-8">
<h1 class="text-3xl font-narrative text-sky-text">{{ $t('pages.contact.title') }}</h1>
<p class="mt-4 text-sky-text/70">{{ $t('pages.contact.description') }}</p>
<div class="max-w-2xl mx-auto px-4 py-8 md:py-12">
<!-- Stats du parcours -->
<div class="bg-sky-dark-50 rounded-xl p-6 mb-8">
<h2 class="text-xl font-ui font-bold text-sky-text mb-4">
{{ $t('contact.statsTitle') }}
</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div class="text-center">
<p class="text-3xl font-ui font-bold text-sky-accent">{{ stats.zonesVisited }}/{{ stats.zonesTotal }}</p>
<p class="text-sm text-sky-text/60">{{ $t('contact.zones') }}</p>
</div>
<div class="text-center">
<p class="text-3xl font-ui font-bold text-sky-accent">{{ stats.easterEggsFound }}/{{ stats.easterEggsTotal }}</p>
<p class="text-sm text-sky-text/60">{{ $t('contact.easterEggs') }}</p>
</div>
<div class="text-center">
<p class="text-3xl font-ui font-bold" :class="stats.challengeCompleted ? 'text-green-400' : 'text-sky-text/40'">
{{ stats.challengeCompleted ? 'OK' : '-' }}
</p>
<p class="text-sm text-sky-text/60">{{ $t('contact.challenge') }}</p>
</div>
</div>
</div>
<!-- Message de congratulations -->
<div class="text-center mb-8">
<h1 class="text-3xl md:text-4xl font-ui font-bold text-sky-text mb-4">
{{ $t('contact.title') }}
</h1>
<p class="font-narrative text-sky-text/60 text-lg">
{{ $t('contact.subtitle') }}
</p>
</div>
<!-- Success message -->
<Transition name="fade">
<div
v-if="isSubmitted"
class="bg-green-500/10 border border-green-500/50 rounded-xl p-8 text-center"
>
<div class="text-4xl mb-4" aria-hidden="true">!</div>
<h2 class="text-2xl font-ui font-bold text-green-400 mb-2">
{{ $t('contact.success') }}
</h2>
<p class="text-sky-text/60 font-narrative">
{{ $t('contact.successMessage') }}
</p>
</div>
</Transition>
<!-- Formulaire -->
<form
v-if="!isSubmitted"
class="space-y-6"
@submit.prevent="handleSubmit"
>
<!-- Honeypot (anti-spam) -->
<input
v-model="form.website"
type="text"
name="website"
class="hidden"
tabindex="-1"
autocomplete="off"
/>
<!-- Nom -->
<div>
<label for="name" class="block font-ui font-semibold text-sky-text mb-2">
{{ $t('contact.name') }} *
</label>
<input
id="name"
v-model="form.name"
type="text"
required
class="w-full px-4 py-3 bg-sky-dark border border-sky-dark-100 rounded-lg text-sky-text focus:border-sky-accent focus:outline-none transition-colors"
:placeholder="$t('contact.namePlaceholder')"
/>
</div>
<!-- Email -->
<div>
<label for="email" class="block font-ui font-semibold text-sky-text mb-2">
{{ $t('contact.email') }} *
</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="w-full px-4 py-3 bg-sky-dark border border-sky-dark-100 rounded-lg text-sky-text focus:border-sky-accent focus:outline-none transition-colors"
:placeholder="$t('contact.emailPlaceholder')"
/>
</div>
<!-- Message -->
<div>
<label for="message" class="block font-ui font-semibold text-sky-text mb-2">
{{ $t('contact.message') }} *
</label>
<textarea
id="message"
v-model="form.message"
required
rows="5"
class="w-full px-4 py-3 bg-sky-dark border border-sky-dark-100 rounded-lg text-sky-text focus:border-sky-accent focus:outline-none transition-colors resize-none"
:placeholder="$t('contact.messagePlaceholder')"
/>
</div>
<!-- Error message -->
<p v-if="errorMessage" class="text-red-400 font-ui text-sm">
{{ errorMessage }}
</p>
<!-- Submit -->
<button
type="submit"
class="w-full py-4 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
:disabled="isSubmitting"
>
{{ isSubmitting ? $t('contact.sending') : $t('contact.send') }}
</button>
</form>
<!-- Choice for next zone -->
<FeatureZoneEndChoice />
</div>
</template>
<script setup lang="ts">
const { setPageMeta } = useSeo()
definePageMeta({
layout: 'adventure',
})
const { t } = useI18n()
const { setPageMeta } = useSeo()
const config = useRuntimeConfig()
const progressionStore = useProgressionStore()
const { availableEasterEggs } = useFetchEasterEggs()
setPageMeta({
title: t('pages.contact.title'),
description: t('pages.contact.description'),
})
onMounted(() => {
progressionStore.visitSection('contact')
})
// Stats
const stats = computed(() => ({
zonesVisited: progressionStore.visitedSections.length,
zonesTotal: 4,
easterEggsFound: progressionStore.easterEggsFoundCount,
easterEggsTotal: availableEasterEggs.value.length || 8,
challengeCompleted: progressionStore.challengeCompleted,
}))
// Form state
const form = reactive({
name: '',
email: '',
message: '',
website: '', // Honeypot
})
const isSubmitting = ref(false)
const isSubmitted = ref(false)
const errorMessage = ref('')
async function handleSubmit() {
// Check honeypot
if (form.website) {
return
}
isSubmitting.value = true
errorMessage.value = ''
try {
await $fetch('/contact', {
method: 'POST',
baseURL: config.public.apiUrl as string,
headers: {
'X-API-Key': config.public.apiKey as string,
},
body: {
name: form.name,
email: form.email,
message: form.message,
},
})
isSubmitted.value = true
} catch (error: unknown) {
const err = error as { statusCode?: number }
if (err.statusCode === 429) {
errorMessage.value = t('contact.rateLimitError')
} else {
errorMessage.value = t('contact.error')
}
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -62,6 +62,6 @@ function onHeroConfirm() {
if (selectedHero.value) {
store.setHero(selectedHero.value)
}
navigateTo(localePath('/projets'))
navigateTo(localePath('/intro'))
}
</script>

View File

@@ -0,0 +1,154 @@
<template>
<div class="intro-page min-h-screen bg-sky-dark relative overflow-hidden">
<!-- Fond d'ambiance -->
<FeatureIntroBackground />
<!-- Contenu principal -->
<div class="relative z-10 flex flex-col items-center justify-center min-h-screen p-8">
<!-- Séquence narrative -->
<Transition name="fade" mode="out-in">
<div
v-if="!isChoiceStep"
:key="currentStep"
class="max-w-2xl mx-auto text-center"
>
<FeatureIntroSequence
:text="currentText"
@complete="handleTextComplete"
@skip="handleTextComplete"
/>
<!-- Bouton continuer -->
<Transition name="fade">
<button
v-if="isTextComplete"
type="button"
class="mt-8 px-8 py-3 bg-sky-accent text-sky-dark font-ui font-semibold rounded-lg hover:opacity-90 transition-opacity focus-visible:outline-2 focus-visible:outline-sky-accent focus-visible:outline-offset-2"
@click="nextStep"
>
{{ isLastTextStep ? $t('intro.startExploring') : $t('intro.continue') }}
</button>
</Transition>
</div>
<!-- Choix après l'intro -->
<div v-else key="choice" class="w-full max-w-3xl mx-auto">
<FeatureChoiceCards :choice-point="introChoicePoint" />
</div>
</Transition>
<!-- Bouton skip (toujours visible sauf sur le choix) -->
<button
v-if="!isChoiceStep"
type="button"
class="absolute bottom-8 right-8 text-sky-text/50 hover:text-sky-text text-sm font-ui underline transition-colors focus-visible:outline-2 focus-visible:outline-sky-accent"
@click="skipIntro"
>
{{ $t('intro.skip') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { CHOICE_POINTS } from '~/types/choice'
import type { NarratorContext } from '~/composables/useFetchNarratorText'
definePageMeta({
layout: 'adventure',
})
const { t } = useI18n()
const localePath = useLocalePath()
const progressionStore = useProgressionStore()
const { fetchText } = useFetchNarratorText()
// Rediriger si pas de héros sélectionné
onMounted(() => {
if (!progressionStore.hero) {
navigateTo(localePath('/'))
}
})
// Étapes de la séquence
const steps: (NarratorContext | 'choice')[] = ['intro_sequence_1', 'intro_sequence_2', 'intro_sequence_3', 'choice']
const currentStepIndex = ref(0)
const currentStep = computed(() => steps[currentStepIndex.value])
const isLastTextStep = computed(() => currentStepIndex.value === steps.length - 2)
const isChoiceStep = computed(() => currentStep.value === 'choice')
// Texte actuel
const currentText = ref('')
const isTextComplete = ref(false)
// Point de choix pour l'intro
const introChoicePoint = CHOICE_POINTS.intro_first_choice
// Fallback texts
const fallbackTexts: Record<string, string> = {
intro_sequence_1: t('intro.fallback.seq1'),
intro_sequence_2: t('intro.fallback.seq2'),
intro_sequence_3: t('intro.fallback.seq3'),
}
async function loadCurrentText() {
if (isChoiceStep.value) return
const context = currentStep.value as NarratorContext
const response = await fetchText(context, progressionStore.hero || undefined)
if (response?.text) {
currentText.value = response.text
} else {
// Fallback si l'API n'est pas disponible
currentText.value = fallbackTexts[context] || ''
}
}
function handleTextComplete() {
isTextComplete.value = true
}
function nextStep() {
if (currentStepIndex.value < steps.length - 1) {
currentStepIndex.value++
isTextComplete.value = false
loadCurrentText()
}
}
function skipIntro() {
currentStepIndex.value = steps.length - 1 // Aller directement au choix
progressionStore.setIntroSeen(true)
}
// Charger le premier texte
onMounted(() => {
loadCurrentText()
})
// Marquer l'intro comme vue quand on quitte
onBeforeUnmount(() => {
progressionStore.setIntroSeen(true)
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.fade-enter-active,
.fade-leave-active {
transition: none;
}
}
</style>

View File

@@ -43,6 +43,9 @@
</p>
</div>
</section>
<!-- Choice for next zone -->
<FeatureZoneEndChoice />
</div>
</template>
@@ -55,7 +58,7 @@ definePageMeta({
const { setPageMeta } = useSeo()
const { t, tm } = useI18n()
const progressStore = useProgressStore()
const progressStore = useProgressionStore()
setPageMeta({
title: t('journey.page_title'),

View File

@@ -48,6 +48,9 @@
:style="{ '--animation-delay': `${index * 80}ms` }"
/>
</div>
<!-- Choice for next zone -->
<FeatureZoneEndChoice />
</div>
</template>

View File

@@ -0,0 +1,179 @@
<template>
<div class="revelation-page min-h-screen bg-sky-dark flex flex-col items-center justify-center p-8">
<!-- Transition phase -->
<Transition name="fade" mode="out-in">
<div
v-if="currentPhase === 'transition'"
key="transition"
class="text-center"
>
<div class="animate-pulse text-4xl mb-4" aria-hidden="true">...</div>
<p class="text-sky-text/60 font-narrative">{{ $t('revelation.loading') }}</p>
</div>
<!-- Code World phase -->
<div
v-else-if="currentPhase === 'codeworld'"
key="codeworld"
class="text-center"
>
<FeatureCodeWorld @complete="advancePhase" />
</div>
<!-- Avatar and message phase -->
<div
v-else-if="currentPhase === 'avatar' || currentPhase === 'message' || currentPhase === 'complete'"
key="complete"
class="text-center max-w-2xl"
>
<!-- Avatar -->
<div class="relative mb-8">
<div
class="w-32 h-32 mx-auto bg-gradient-to-br from-sky-accent to-amber-500 rounded-full flex items-center justify-center shadow-xl shadow-sky-accent/30"
:class="{ 'animate-bounce-in': !reducedMotion }"
>
<span class="text-5xl text-white font-ui font-bold">C</span>
</div>
<!-- Celebration particles -->
<div v-if="!reducedMotion" class="absolute inset-0">
<span
v-for="i in 8"
:key="i"
class="celebration-particle"
:style="{ '--delay': `${i * 0.1}s`, '--angle': `${i * 45}deg` }"
/>
</div>
</div>
<!-- Bug says -->
<p class="text-lg text-sky-accent font-narrative mb-2">
{{ $t('revelation.bugSays') }}
</p>
<!-- Main message -->
<h1 class="text-4xl md:text-5xl font-ui font-bold text-sky-text mb-4">
{{ $t('revelation.foundMe') }}
</h1>
<p class="text-sky-text/60 font-narrative text-lg mb-8">
{{ $t('revelation.message') }}
</p>
<!-- Signature -->
<p class="text-sky-accent font-narrative italic mb-12">
- Celian
</p>
<!-- CTA -->
<NuxtLink
:to="localePath('/contact')"
class="inline-flex items-center gap-3 px-8 py-4 bg-sky-accent text-white font-ui font-semibold rounded-xl hover:opacity-90 transition-opacity"
>
{{ $t('revelation.contactCta') }}
<span aria-hidden="true">-></span>
</NuxtLink>
</div>
</Transition>
<!-- Screen reader description -->
<div class="sr-only" role="status" aria-live="polite">
{{ $t('revelation.srDescription') }}
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'adventure',
})
const localePath = useLocalePath()
const progressionStore = useProgressionStore()
const reducedMotion = usePreferredReducedMotion()
// Check if contact is unlocked
onMounted(() => {
if (!progressionStore.contactUnlocked) {
navigateTo(localePath('/'))
}
})
// Phases
type Phase = 'transition' | 'codeworld' | 'avatar' | 'message' | 'complete'
const currentPhase = ref<Phase>('transition')
function advancePhase() {
const phases: Phase[] = ['transition', 'codeworld', 'avatar', 'message', 'complete']
const currentIndex = phases.indexOf(currentPhase.value)
if (currentIndex < phases.length - 1) {
currentPhase.value = phases[currentIndex + 1]
}
}
// Start sequence
onMounted(() => {
if (reducedMotion.value === 'reduce') {
// Static version
currentPhase.value = 'complete'
} else {
// Animated version
setTimeout(() => {
advancePhase()
}, 1500)
}
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@keyframes bounce-in {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-bounce-in {
animation: bounce-in 0.6s ease-out;
}
.celebration-particle {
position: absolute;
width: 8px;
height: 8px;
background: var(--sky-accent, #fa784f);
border-radius: 50%;
top: 50%;
left: 50%;
animation: explode 1s ease-out forwards;
animation-delay: var(--delay, 0s);
}
@keyframes explode {
0% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-80px);
opacity: 0;
}
}
</style>

View File

@@ -97,22 +97,8 @@
</section>
<!-- CTA section -->
<section class="container mx-auto px-4 pb-16">
<div class="rounded-xl bg-gradient-to-r from-sky-500/10 to-purple-500/10 p-8 text-center">
<h2 class="text-2xl font-semibold text-white">
{{ $t('testimonials.cta_title') }}
</h2>
<p class="mt-2 text-gray-400">
{{ $t('testimonials.cta_description') }}
</p>
<NuxtLink
to="/contact"
class="mt-4 inline-block rounded-lg bg-sky-500 px-6 py-3 font-medium text-white transition-colors hover:bg-sky-400"
>
{{ $t('testimonials.cta_button') }}
</NuxtLink>
</div>
</section>
<!-- Choice for next zone -->
<FeatureZoneEndChoice />
</div>
</template>
@@ -125,7 +111,7 @@ definePageMeta({
const { setPageMeta } = useSeo()
const { t } = useI18n()
const progressStore = useProgressStore()
const progressStore = useProgressionStore()
setPageMeta({
title: t('testimonials.page_title'),