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,80 @@
<template>
<div class="challenge-success text-center relative">
<!-- Celebration particles -->
<div class="particles absolute inset-0 overflow-hidden pointer-events-none">
<span
v-for="i in 20"
:key="i"
class="particle"
:style="{ '--delay': `${i * 0.1}s`, '--x': `${Math.random() * 100}%` }"
/>
</div>
<div class="relative z-10">
<div class="text-6xl mb-4 animate-bounce" aria-hidden="true">*</div>
<h2 class="text-3xl font-ui font-bold text-sky-accent mb-4">
{{ $t('challenge.success') }}
</h2>
<p class="font-narrative text-xl text-sky-text mb-8">
{{ $t('challenge.successMessage') }}
</p>
<p class="text-sky-text/60">
{{ $t('challenge.redirecting') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
// No dependencies needed - pure CSS animation
</script>
<style scoped>
.particles {
position: absolute;
width: 100%;
height: 100%;
}
.particle {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
animation: confetti 2s ease-out forwards;
animation-delay: var(--delay, 0s);
left: var(--x, 50%);
top: 50%;
opacity: 0;
}
.particle:nth-child(odd) {
background-color: var(--sky-accent, #fa784f);
}
.particle:nth-child(even) {
background-color: #3b82f6;
}
.particle:nth-child(3n) {
background-color: #10b981;
}
.particle:nth-child(4n) {
background-color: #f59e0b;
}
@keyframes confetti {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(-200px) rotate(720deg);
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<button
type="button"
class="choice-card group relative flex flex-col items-center p-6 rounded-xl border-2 transition-all duration-300 focus:outline-none"
:class="[
selected
? 'border-sky-accent bg-sky-accent/10 scale-105 shadow-lg shadow-sky-accent/20'
: 'border-sky-text/20 bg-sky-dark/50 hover:border-sky-accent/50 hover:bg-sky-dark/80',
disabled && 'opacity-50 cursor-not-allowed',
]"
:style="{ '--zone-color': choice.zoneColor }"
:disabled="disabled"
:aria-checked="selected"
role="radio"
@click="emit('select')"
>
<!-- Glow effect au hover -->
<div
class="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
:style="{ boxShadow: `0 0 30px ${choice.zoneColor}40` }"
/>
<!-- Icône -->
<div
class="w-16 h-16 rounded-full flex items-center justify-center text-4xl mb-4"
:style="{ backgroundColor: `${choice.zoneColor}20` }"
>
{{ choice.icon }}
</div>
<!-- Texte narratif -->
<p class="font-narrative text-lg text-sky-text text-center leading-relaxed">
{{ text }}
</p>
<!-- Indicateur de sélection -->
<div
v-if="selected"
class="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-sky-accent flex items-center justify-center"
>
<span class="text-white text-sm">&#x2713;</span>
</div>
</button>
</template>
<script setup lang="ts">
import type { Choice } from '~/types/choice'
const props = defineProps<{
choice: Choice
selected: boolean
disabled: boolean
}>()
const emit = defineEmits<{
select: []
}>()
const { locale } = useI18n()
const text = computed(() => {
return locale.value === 'fr' ? props.choice.textFr : props.choice.textEn
})
</script>
<style scoped>
.choice-card:focus-visible {
outline: 2px solid var(--sky-accent, #38bdf8);
outline-offset: 4px;
}
.choice-card:not(:disabled):hover {
transform: translateY(-4px);
}
@media (prefers-reduced-motion: reduce) {
.choice-card,
.choice-card * {
transition: none !important;
animation: none !important;
transform: none !important;
}
.choice-card:not(:disabled):hover {
transform: none !important;
}
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div
class="choice-cards-container"
:class="{ 'transitioning': isTransitioning }"
>
<!-- Question du narrateur -->
<p class="font-narrative text-xl text-sky-text text-center mb-8 italic">
{{ question }}
</p>
<!-- Cards de choix -->
<div
ref="containerRef"
class="choice-cards grid grid-cols-1 md:grid-cols-2 gap-6 max-w-2xl mx-auto"
role="radiogroup"
:aria-label="question"
tabindex="0"
@keydown="handleKeydown"
>
<ChoiceCard
v-for="(choice, index) in choicePoint.choices"
:key="choice.id"
:ref="(el) => setCardRef(el, index)"
:choice="choice"
:selected="selectedChoice?.id === choice.id"
:disabled="isTransitioning"
@select="handleSelect(choice)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { ChoicePoint, Choice } from '~/types/choice'
const props = defineProps<{
choicePoint: ChoicePoint
}>()
const emit = defineEmits<{
selected: [choice: Choice]
}>()
const { locale } = useI18n()
const router = useRouter()
const localePath = useLocalePath()
const progressionStore = useProgressionStore()
const reducedMotion = useReducedMotion()
const selectedChoice = ref<Choice | null>(null)
const isTransitioning = ref(false)
const containerRef = ref<HTMLElement | null>(null)
const cardRefs = ref<(ComponentPublicInstance | null)[]>([])
const question = computed(() => {
return locale.value === 'fr' ? props.choicePoint.questionFr : props.choicePoint.questionEn
})
function setCardRef(el: Element | ComponentPublicInstance | null, index: number) {
cardRefs.value[index] = el as ComponentPublicInstance | null
}
function handleSelect(choice: Choice) {
if (isTransitioning.value) return
selectedChoice.value = choice
// Enregistrer le choix dans le store
progressionStore.makeChoice(props.choicePoint.id, choice.id)
// Émettre l'événement
emit('selected', choice)
// Animation puis navigation
isTransitioning.value = true
const delay = reducedMotion.value ? 100 : 800
setTimeout(() => {
router.push(localePath(choice.destination))
}, delay)
}
// Navigation clavier
function handleKeydown(e: KeyboardEvent) {
const choices = props.choicePoint.choices
const currentIndex = selectedChoice.value
? choices.findIndex(c => c.id === selectedChoice.value?.id)
: -1
let newIndex = -1
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
newIndex = currentIndex <= 0 ? choices.length - 1 : currentIndex - 1
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
newIndex = currentIndex >= choices.length - 1 ? 0 : currentIndex + 1
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const choiceToSelect = currentIndex >= 0 ? choices[currentIndex] : choices[0]
if (choiceToSelect) {
handleSelect(choiceToSelect)
}
return
}
if (newIndex >= 0) {
const newChoice = choices[newIndex]
if (newChoice) {
selectedChoice.value = newChoice
// Focus sur la nouvelle card
const cardEl = cardRefs.value[newIndex]
if (cardEl && '$el' in cardEl) {
(cardEl.$el as HTMLElement)?.focus()
}
}
}
}
</script>
<style scoped>
.choice-cards-container.transitioning {
animation: fadeOut 0.5s ease-out forwards;
}
@keyframes fadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
@media (prefers-reduced-motion: reduce) {
.choice-cards-container.transitioning {
animation: fadeOutSimple 0.1s ease-out forwards;
}
@keyframes fadeOutSimple {
from { opacity: 1; }
to { opacity: 0; }
}
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<div class="code-puzzle">
<!-- Code zone -->
<div class="bg-sky-dark rounded-lg border border-sky-dark-100 p-4 font-mono text-sm mb-6">
<div
v-for="(line, index) in shuffledLines"
:key="index"
class="code-line flex items-center gap-2 p-2 rounded cursor-grab transition-all"
:class="[
validationResult === true && 'bg-green-500/20 border-green-500/50',
validationResult === false && line !== solution[index] && 'bg-red-500/20 border-red-500/50',
]"
draggable="true"
@dragstart="onDragStart($event, index)"
@drop="onDrop($event, index)"
@dragover="onDragOver"
>
<!-- Line number -->
<span class="text-sky-text/60 select-none w-6 text-right">{{ index + 1 }}</span>
<!-- Drag handle -->
<span class="text-sky-text/60 cursor-grab select-none">::</span>
<!-- Code -->
<code class="flex-1 text-sky-accent">{{ line }}</code>
<!-- Keyboard buttons (accessibility) -->
<div class="flex gap-1">
<button
type="button"
class="p-1 text-sky-text/60 hover:text-sky-text disabled:opacity-30"
:disabled="index === 0"
:aria-label="$t('challenge.moveUp')"
@click="moveLineUp(index)"
>
^
</button>
<button
type="button"
class="p-1 text-sky-text/60 hover:text-sky-text disabled:opacity-30"
:disabled="index === shuffledLines.length - 1"
:aria-label="$t('challenge.moveDown')"
@click="moveLineDown(index)"
>
v
</button>
</div>
</div>
</div>
<!-- Current hint -->
<div
v-if="currentHint"
class="bg-sky-accent/10 border border-sky-accent/30 rounded-lg p-4 mb-6"
>
<p class="text-sm text-sky-accent">
<span class="font-semibold">{{ $t('challenge.hintLabel') }}:</span>
{{ currentHint }}
</p>
</div>
<!-- Actions -->
<div class="flex flex-wrap gap-4 justify-center">
<!-- Validate button -->
<button
type="button"
class="px-6 py-3 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors disabled:opacity-50"
:disabled="isValidating"
@click="validateSolution"
>
{{ isValidating ? $t('challenge.validating') : $t('challenge.validate') }}
</button>
<!-- Hint button -->
<button
v-if="props.hintsUsed < 3"
type="button"
class="px-6 py-3 border border-sky-dark-100 text-sky-text font-ui rounded-lg hover:bg-sky-dark-50 transition-colors"
@click="requestHint"
>
{{ $t('challenge.needHint') }} ({{ props.hintsUsed }}/3)
</button>
</div>
<!-- Error message -->
<Transition name="fade">
<p
v-if="validationResult === false"
class="text-red-400 text-center mt-4 font-ui"
>
{{ $t('challenge.wrongOrder') }}
</p>
</Transition>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
hintsUsed: number
}>()
const emit = defineEmits<{
solved: []
hintUsed: []
}>()
const { t } = useI18n()
// Code lines (solution)
const solution = [
'function unlockDeveloper() {',
' const secret = "SKYCEL";',
' const key = decode(secret);',
' if (key === "ACCESS_GRANTED") {',
' return showDeveloper();',
' }',
' return "Keep exploring...";',
'}',
]
// Shuffled lines at start
const shuffledLines = ref<string[]>([])
const isValidating = ref(false)
const validationResult = ref<boolean | null>(null)
// Shuffle on mount
onMounted(() => {
shuffledLines.value = [...solution].sort(() => Math.random() - 0.5)
})
// Progressive hints
const hints = computed(() => [
t('challenge.hint1'),
t('challenge.hint2'),
t('challenge.hint3'),
])
const currentHint = computed(() => {
if (props.hintsUsed === 0) return null
return hints.value[Math.min(props.hintsUsed - 1, hints.value.length - 1)]
})
// Drag & Drop
function onDragStart(e: DragEvent, index: number) {
e.dataTransfer?.setData('text/plain', index.toString())
}
function onDrop(e: DragEvent, targetIndex: number) {
e.preventDefault()
const sourceIndex = parseInt(e.dataTransfer?.getData('text/plain') || '-1')
if (sourceIndex === -1) return
// Swap lines
const newLines = [...shuffledLines.value]
const temp = newLines[sourceIndex]
newLines[sourceIndex] = newLines[targetIndex]
newLines[targetIndex] = temp
shuffledLines.value = newLines
}
function onDragOver(e: DragEvent) {
e.preventDefault()
}
// Validation
function validateSolution() {
isValidating.value = true
validationResult.value = null
setTimeout(() => {
const isCorrect = shuffledLines.value.every((line, i) => line === solution[i])
validationResult.value = isCorrect
if (isCorrect) {
emit('solved')
} else {
// Reset after 2s
setTimeout(() => {
validationResult.value = null
isValidating.value = false
}, 2000)
}
}, 500)
}
function requestHint() {
if (props.hintsUsed < 3) {
emit('hintUsed')
}
}
// Keyboard navigation
function moveLineUp(index: number) {
if (index === 0) return
const newLines = [...shuffledLines.value]
const temp = newLines[index - 1]
newLines[index - 1] = newLines[index]
newLines[index] = temp
shuffledLines.value = newLines
}
function moveLineDown(index: number) {
if (index === shuffledLines.value.length - 1) return
const newLines = [...shuffledLines.value]
const temp = newLines[index + 1]
newLines[index + 1] = newLines[index]
newLines[index] = temp
shuffledLines.value = newLines
}
</script>
<style scoped>
.code-line {
border: 1px solid transparent;
}
.code-line:hover {
background-color: rgba(250, 120, 79, 0.1);
}
.code-line:active {
cursor: grabbing;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<div class="code-world font-mono text-sm md:text-base leading-relaxed">
<pre
class="text-sky-accent whitespace-pre overflow-x-auto max-w-full"
:class="{ 'animate-reveal': !reducedMotion }"
aria-hidden="true"
><code>{{ visibleCode }}</code></pre>
<!-- Screen reader alternative -->
<p class="sr-only">
{{ $t('revelation.codeWorldAlt') }}
</p>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
complete: []
}>()
const reducedMotion = usePreferredReducedMotion()
const asciiArt = `
* . *
* . . *
___
/ \\
/ ^ \\
/ ^ \\
/____/ \\____\\
| | | |
| | | |
___| |___| |___
{ YOU }
{ FOUND ME! }
___________________
`
const visibleCode = ref('')
const lines = asciiArt.split('\n')
let currentLine = 0
onMounted(() => {
if (reducedMotion.value === 'reduce') {
visibleCode.value = asciiArt
emit('complete')
} else {
revealLines()
}
})
function revealLines() {
if (currentLine < lines.length) {
visibleCode.value += lines[currentLine] + '\n'
currentLine++
setTimeout(revealLines, 100)
} else {
setTimeout(() => {
emit('complete')
}, 500)
}
}
</script>
<style scoped>
.code-world {
text-align: center;
}
pre {
display: inline-block;
text-align: left;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-reveal {
animation: reveal 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<div class="easter-egg-collection">
<!-- Header with counter -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-ui font-bold text-sky-text">
{{ $t('easterEgg.collection') }}
</h2>
<div class="flex items-center gap-2">
<span class="text-sky-accent font-ui font-bold">{{ foundCount }}</span>
<span class="text-sky-text/60">/</span>
<span class="text-sky-text/60">{{ totalEasterEggs }}</span>
</div>
</div>
<!-- 100% badge -->
<div
v-if="isComplete"
class="bg-gradient-to-r from-sky-accent to-amber-500 rounded-lg p-4 mb-6 text-center"
>
<span class="text-2xl" aria-hidden="true">*</span>
<p class="text-white font-ui font-bold mt-2">
{{ $t('easterEgg.allFound') }}
</p>
</div>
<!-- Progress bar -->
<div class="h-2 bg-sky-dark-100 rounded-full mb-6 overflow-hidden">
<div
class="h-full bg-sky-accent transition-all duration-500"
:style="{ width: `${(foundCount / totalEasterEggs) * 100}%` }"
/>
</div>
<!-- Easter eggs grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div
v-for="egg in availableEasterEggs"
:key="egg.slug"
class="easter-egg-card p-4 rounded-lg border transition-all"
:class="[
isFound(egg.slug)
? 'bg-sky-dark-50 border-sky-accent/50'
: 'bg-sky-dark border-sky-dark-100 opacity-50'
]"
>
<!-- Icon or mystery -->
<div class="text-3xl text-center mb-2">
{{ isFound(egg.slug) ? getTriggerIcon(egg.trigger_type) : '?' }}
</div>
<!-- Name or mystery -->
<p
class="text-sm font-ui text-center truncate"
:class="isFound(egg.slug) ? 'text-sky-text' : 'text-sky-text/60'"
>
{{ isFound(egg.slug) ? formatSlug(egg.slug) : '???' }}
</p>
<!-- Difficulty -->
<p class="text-xs text-center mt-1 text-sky-text/60">
{{ getDifficultyStars(egg.difficulty) }}
</p>
</div>
</div>
<!-- Hint if not all found -->
<p
v-if="!isComplete"
class="text-sm text-sky-text/60 text-center mt-6 font-narrative italic"
>
{{ $t('easterEgg.hint') }}
</p>
</div>
</template>
<script setup lang="ts">
import type { TriggerType } from '~/composables/useFetchEasterEggs'
const progressionStore = useProgressionStore()
const { availableEasterEggs, fetchList } = useFetchEasterEggs()
onMounted(() => {
fetchList()
})
const totalEasterEggs = computed(() => availableEasterEggs.value.length || 8)
const foundCount = computed(() => progressionStore.easterEggsFoundCount)
const isComplete = computed(() => foundCount.value >= totalEasterEggs.value && totalEasterEggs.value > 0)
function isFound(slug: string): boolean {
return progressionStore.easterEggsFound.includes(slug)
}
function getTriggerIcon(trigger: TriggerType): string {
const icons: Record<TriggerType, string> = {
click: '^',
hover: 'o',
konami: '#',
scroll: 'v',
sequence: '1',
}
return icons[trigger] || '?'
}
function formatSlug(slug: string): string {
return slug
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
function getDifficultyStars(difficulty: number): string {
return '*'.repeat(difficulty) + '.'.repeat(5 - difficulty)
}
</script>

View File

@@ -0,0 +1,49 @@
<template>
<Teleport to="body">
<Transition name="toast">
<div
v-if="visible"
class="fixed top-4 right-4 z-50 bg-sky-dark-50 border border-sky-accent/50 rounded-lg px-4 py-3 shadow-lg shadow-sky-accent/20 flex items-center gap-3"
>
<span class="text-2xl" aria-hidden="true">*</span>
<div>
<p class="font-ui font-semibold text-sky-accent">
{{ $t('easterEgg.found') }}
</p>
<p class="text-sm text-sky-text/60">
{{ $t('easterEgg.count', { found: foundCount, total: totalEasterEggs }) }}
</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
visible: boolean
}>()
const progressionStore = useProgressionStore()
const { availableEasterEggs } = useFetchEasterEggs()
const totalEasterEggs = computed(() => availableEasterEggs.value.length || 8)
const foundCount = computed(() => progressionStore.easterEggsFoundCount)
</script>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100px);
}
.toast-leave-to {
opacity: 0;
transform: translateY(-20px);
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<Teleport to="body">
<Transition name="popup">
<div
v-if="visible && reward"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- Overlay -->
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
@click="emit('close')"
/>
<!-- Modal -->
<div
class="relative bg-sky-dark-50 rounded-2xl p-8 max-w-md w-full border border-sky-accent/50 shadow-2xl shadow-sky-accent/20 animate-bounce-in"
>
<!-- Confetti -->
<div class="absolute -top-4 left-1/2 -translate-x-1/2 text-4xl animate-bounce">
<span aria-hidden="true">*</span>
</div>
<!-- Type icon -->
<div class="text-6xl text-center mb-4">
{{ rewardIcon }}
</div>
<!-- Title -->
<h2 class="text-2xl font-ui font-bold text-sky-accent text-center mb-2">
{{ $t('easterEgg.found') }}
</h2>
<!-- Counter -->
<p class="text-sm text-sky-text/60 text-center mb-6">
{{ $t('easterEgg.count', { found: foundCount, total: totalEasterEggs }) }}
</p>
<!-- Reward -->
<div class="bg-sky-dark rounded-lg p-4 mb-6">
<!-- Code snippet -->
<pre
v-if="reward.reward_type === 'snippet'"
class="font-mono text-sm text-sky-accent overflow-x-auto whitespace-pre-wrap"
><code>{{ reward.reward }}</code></pre>
<!-- Anecdote -->
<p
v-else-if="reward.reward_type === 'anecdote'"
class="font-narrative text-sky-text italic"
>
{{ reward.reward }}
</p>
<!-- Badge -->
<div
v-else-if="reward.reward_type === 'badge'"
class="text-center"
>
<p class="font-ui text-sky-text">{{ reward.reward }}</p>
</div>
<!-- Image -->
<div
v-else-if="reward.reward_type === 'image'"
class="text-center"
>
<p class="font-ui text-sky-text">{{ reward.reward }}</p>
</div>
</div>
<!-- Difficulty -->
<div class="flex items-center justify-center gap-1 mb-6">
<span class="text-xs text-sky-text/60 mr-2">{{ $t('easterEgg.difficulty') }}:</span>
<span
v-for="i in 5"
:key="i"
class="text-sm"
:class="i <= reward.difficulty ? 'text-sky-accent' : 'text-sky-dark-100'"
>
*
</span>
</div>
<!-- Close button -->
<button
type="button"
class="w-full py-3 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors"
@click="emit('close')"
>
{{ $t('common.continue') }}
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { EasterEggReward } from '~/composables/useFetchEasterEggs'
const props = defineProps<{
visible: boolean
reward: EasterEggReward | null
}>()
const emit = defineEmits<{
close: []
}>()
const progressionStore = useProgressionStore()
const { availableEasterEggs } = useFetchEasterEggs()
const totalEasterEggs = computed(() => availableEasterEggs.value.length || 8)
const foundCount = computed(() => progressionStore.easterEggsFoundCount)
// Icon based on reward type
const rewardIcon = computed(() => {
if (!props.reward) return ''
const icons: Record<string, string> = {
snippet: '</>', // Code icon
anecdote: '!', // Story icon
image: '[]', // Image icon
badge: '*', // Trophy icon
}
return icons[props.reward.reward_type] || ''
})
</script>
<style scoped>
.popup-enter-active,
.popup-leave-active {
transition: all 0.3s ease;
}
.popup-enter-from,
.popup-leave-to {
opacity: 0;
}
.popup-enter-from .relative,
.popup-leave-to .relative {
transform: scale(0.9);
}
@keyframes bounce-in {
0% {
transform: scale(0.5);
opacity: 0;
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-bounce-in {
animation: bounce-in 0.4s ease-out;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="intro-background absolute inset-0 overflow-hidden">
<!-- Gradient de fond -->
<div class="absolute inset-0 bg-gradient-to-b from-sky-dark via-sky-dark-50 to-sky-dark" />
<!-- Particules flottantes (code fragments) -->
<div
v-if="!reducedMotion"
class="particles absolute inset-0"
>
<div
v-for="i in 20"
:key="i"
class="particle absolute text-sky-accent/10 font-mono text-xs"
:style="{
left: `${particlePositions[i - 1]?.x ?? 0}%`,
top: `${particlePositions[i - 1]?.y ?? 0}%`,
animationDelay: `${particlePositions[i - 1]?.delay ?? 0}s`,
animationDuration: `${10 + (particlePositions[i - 1]?.duration ?? 0)}s`,
}"
>
{{ codeSymbols[(i - 1) % codeSymbols.length] }}
</div>
</div>
<!-- Toile d'araignée stylisée (SVG) -->
<svg
class="absolute top-0 right-0 w-64 h-64 text-sky-dark-100/30"
viewBox="0 0 200 200"
>
<path
d="M100,100 L100,0 M100,100 L200,100 M100,100 L100,200 M100,100 L0,100 M100,100 L170,30 M100,100 L170,170 M100,100 L30,170 M100,100 L30,30"
stroke="currentColor"
stroke-width="1"
fill="none"
/>
<circle cx="100" cy="100" r="30" stroke="currentColor" stroke-width="1" fill="none" />
<circle cx="100" cy="100" r="60" stroke="currentColor" stroke-width="1" fill="none" />
<circle cx="100" cy="100" r="90" stroke="currentColor" stroke-width="1" fill="none" />
</svg>
<!-- Toile en bas à gauche -->
<svg
class="absolute bottom-0 left-0 w-48 h-48 text-sky-dark-100/20 transform rotate-180"
viewBox="0 0 200 200"
>
<path
d="M100,100 L100,0 M100,100 L200,100 M100,100 L170,30 M100,100 L170,170"
stroke="currentColor"
stroke-width="1"
fill="none"
/>
<circle cx="100" cy="100" r="40" stroke="currentColor" stroke-width="1" fill="none" />
<circle cx="100" cy="100" r="80" stroke="currentColor" stroke-width="1" fill="none" />
</svg>
</div>
</template>
<script setup lang="ts">
const reducedMotion = useReducedMotion()
const codeSymbols = ['</', '/>', '{}', '[]', '()', '=>', '&&', '||']
// Générer des positions aléatoires côté serveur-safe
const particlePositions = Array.from({ length: 20 }, (_, i) => ({
x: ((i * 17 + 7) % 100),
y: ((i * 23 + 13) % 100),
delay: (i * 0.3) % 5,
duration: (i % 10),
}))
</script>
<style scoped>
@keyframes float-up {
from {
transform: translateY(100vh) rotate(0deg);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
to {
transform: translateY(-100vh) rotate(360deg);
opacity: 0;
}
}
.particle {
animation: float-up linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.particle {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<div
class="intro-sequence cursor-pointer"
tabindex="0"
@click="handleInteraction"
@keydown="handleKeydown"
>
<!-- Avatar du Bug -->
<div class="mb-8">
<img
src="/images/bug/bug-stage-1.svg"
alt="Le Bug"
class="w-32 h-32 mx-auto"
:class="{ 'animate-float': !reducedMotion }"
/>
</div>
<!-- Texte avec typewriter -->
<div class="bg-sky-dark-50/80 backdrop-blur rounded-xl p-8 border border-sky-dark-100">
<p class="font-narrative text-xl md:text-2xl text-sky-text leading-relaxed min-h-[4rem]">
{{ displayedText }}
<span
v-if="isTyping"
class="inline-block w-0.5 h-6 bg-sky-accent ml-1"
:class="{ 'animate-blink': !reducedMotion }"
/>
</p>
<!-- Indication pour skip -->
<p
v-if="isTyping"
class="text-sm text-sky-text/50 mt-4 font-ui"
>
{{ $t('narrator.clickToSkip') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
text: string
}>()
const emit = defineEmits<{
complete: []
skip: []
}>()
const reducedMotion = useReducedMotion()
const textRef = computed(() => props.text)
const { displayedText, isTyping, skip, start } = useTypewriter(textRef, { speed: 35 })
// Démarrer le typewriter quand le texte change
watch(() => props.text, (newText) => {
if (newText) {
start()
}
}, { immediate: true })
// Watcher pour détecter quand le texte est complet
watch(isTyping, (typing) => {
if (!typing && displayedText.value === props.text) {
emit('complete')
}
})
function handleInteraction() {
if (isTyping.value) {
skip()
emit('skip')
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.code === 'Space' || e.code === 'Enter') {
e.preventDefault()
handleInteraction()
}
}
</script>
<style scoped>
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.animate-blink {
animation: blink 1s infinite;
}
.intro-sequence:focus {
outline: none;
}
@media (prefers-reduced-motion: reduce) {
.animate-float,
.animate-blink {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<div class="zone-end-choice py-16 px-4 border-t border-sky-dark-100 mt-16">
<div class="max-w-2xl mx-auto">
<!-- Choix binaire -->
<FeatureChoiceCards
v-if="choicePoint && choicePoint.choices[0].id !== choicePoint.choices[1].id"
:choice-point="choicePoint"
/>
<!-- Si une seule destination (contact) -->
<div
v-else-if="choicePoint"
class="text-center"
>
<p class="font-narrative text-xl text-sky-text mb-8 italic">
{{ locale === 'fr' ? choicePoint.questionFr : choicePoint.questionEn }}
</p>
<NuxtLink
:to="localePath(choicePoint.choices[0].destination)"
class="inline-flex items-center gap-3 px-8 py-4 bg-sky-accent text-sky-dark font-ui font-semibold rounded-xl hover:opacity-90 transition-opacity focus-visible:outline-2 focus-visible:outline-sky-accent focus-visible:outline-offset-2"
>
<span class="text-2xl">{{ choicePoint.choices[0].icon }}</span>
<span>{{ locale === 'fr' ? choicePoint.choices[0].textFr : choicePoint.choices[0].textEn }}</span>
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { getNextChoicePoint } = useNarrativePath()
const { locale } = useI18n()
const localePath = useLocalePath()
const choicePoint = computed(() => getNextChoicePoint())
</script>