✨ 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:
147
frontend/app/components/feature/ChoiceCards.vue
Normal file
147
frontend/app/components/feature/ChoiceCards.vue
Normal 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>
|
||||
Reference in New Issue
Block a user