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>
451 lines
12 KiB
Markdown
451 lines
12 KiB
Markdown
# Story 4.1: Composant ChoiceCards et choix narratifs
|
|
|
|
Status: done
|
|
|
|
## Story
|
|
|
|
As a visiteur,
|
|
I want faire des choix qui influencent mon parcours,
|
|
so that mon expérience est unique et personnalisée.
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. **Given** le composant `ChoiceCards` est implémenté **When** le narrateur propose un choix **Then** 2 cards s'affichent côte à côte (desktop) ou empilées (mobile)
|
|
2. **And** chaque card affiche : icône, texte narratif du choix
|
|
3. **And** un hover/focus highlight la card sélectionnable
|
|
4. **And** un clic enregistre le choix dans `choices` du store Pinia
|
|
5. **And** une transition animée mène vers la destination choisie
|
|
6. **And** le composant est accessible (`role="radiogroup"`, navigation clavier, focus visible)
|
|
7. **And** `prefers-reduced-motion` simplifie les animations
|
|
8. **And** le style est cohérent avec l'univers narratif (police serif, couleurs des zones)
|
|
|
|
## Tasks / Subtasks
|
|
|
|
- [x] **Task 1: Définir les types de choix** (AC: #2, #4)
|
|
- [x] Créer `frontend/app/types/choice.ts`
|
|
- [x] Interface Choice : id, textFr, textEn, icon, destination, zoneColor
|
|
- [x] Interface ChoicePoint : id, choices (2 options), context
|
|
|
|
- [x] **Task 2: Créer le composant ChoiceCard** (AC: #2, #3, #8)
|
|
- [x] Créer `frontend/app/components/feature/ChoiceCard.vue`
|
|
- [x] Props : choice (Choice), selected (boolean), disabled (boolean)
|
|
- [x] Afficher icône + texte narratif
|
|
- [x] Effet hover/focus avec highlight
|
|
- [x] Police serif narrative pour le texte
|
|
|
|
- [x] **Task 3: Créer le composant ChoiceCards** (AC: #1, #4, #5, #6)
|
|
- [x] Créer `frontend/app/components/feature/ChoiceCards.vue`
|
|
- [x] Props : choicePoint (ChoicePoint)
|
|
- [x] Emit : select (choice)
|
|
- [x] Layout côte à côte desktop, empilé mobile
|
|
- [x] Gérer la sélection et enregistrer dans le store
|
|
- [x] Animation de transition vers la destination
|
|
|
|
- [x] **Task 4: Implémenter l'accessibilité** (AC: #6)
|
|
- [x] role="radiogroup" sur le conteneur
|
|
- [x] role="radio" sur chaque card
|
|
- [x] aria-checked pour indiquer la sélection
|
|
- [x] Navigation clavier (flèches gauche/droite)
|
|
- [x] Focus visible conforme WCAG
|
|
|
|
- [x] **Task 5: Gérer les animations** (AC: #5, #7)
|
|
- [x] Animation de sélection (scale + glow)
|
|
- [x] Transition vers la destination (fade-out)
|
|
- [x] Respecter prefers-reduced-motion
|
|
|
|
- [x] **Task 6: Intégrer avec le store** (AC: #4)
|
|
- [x] Appeler `progressionStore.makeChoice(id, value)` à la sélection
|
|
- [x] Les choix sont persistés avec le reste de la progression
|
|
|
|
- [x] **Task 7: Tests et validation**
|
|
- [x] Build production réussi
|
|
- [x] Validation TypeScript des composants
|
|
|
|
## Dev Notes
|
|
|
|
### Types des choix
|
|
|
|
```typescript
|
|
// frontend/app/types/choice.ts
|
|
export interface Choice {
|
|
id: string
|
|
textFr: string
|
|
textEn: string
|
|
icon: string // emoji ou URL d'image
|
|
destination: string // route vers laquelle naviguer
|
|
zoneColor: string // couleur de la zone associée
|
|
}
|
|
|
|
export interface ChoicePoint {
|
|
id: string
|
|
questionFr: string
|
|
questionEn: string
|
|
choices: [Choice, Choice] // Toujours 2 choix binaires
|
|
context: string // contexte narratif (intro, after_projects, etc.)
|
|
}
|
|
|
|
// Exemple de point de choix
|
|
export const CHOICE_POINTS: Record<string, ChoicePoint> = {
|
|
intro_first_choice: {
|
|
id: 'intro_first_choice',
|
|
questionFr: 'Par où veux-tu commencer ton exploration ?',
|
|
questionEn: 'Where do you want to start your exploration?',
|
|
choices: [
|
|
{
|
|
id: 'choice_projects_first',
|
|
textFr: 'Découvrir les créations',
|
|
textEn: 'Discover the creations',
|
|
icon: '💻',
|
|
destination: '/projets',
|
|
zoneColor: '#3b82f6',
|
|
},
|
|
{
|
|
id: 'choice_skills_first',
|
|
textFr: 'Explorer les compétences',
|
|
textEn: 'Explore the skills',
|
|
icon: '⚡',
|
|
destination: '/competences',
|
|
zoneColor: '#10b981',
|
|
},
|
|
],
|
|
context: 'intro',
|
|
},
|
|
after_projects: {
|
|
id: 'after_projects',
|
|
questionFr: 'Quelle sera ta prochaine étape ?',
|
|
questionEn: 'What will be your next step?',
|
|
choices: [
|
|
{
|
|
id: 'choice_testimonials',
|
|
textFr: "Écouter ceux qui l'ont rencontré",
|
|
textEn: 'Listen to those who met him',
|
|
icon: '💬',
|
|
destination: '/temoignages',
|
|
zoneColor: '#f59e0b',
|
|
},
|
|
{
|
|
id: 'choice_journey',
|
|
textFr: 'Suivre son parcours',
|
|
textEn: 'Follow his journey',
|
|
icon: '📍',
|
|
destination: '/parcours',
|
|
zoneColor: '#8b5cf6',
|
|
},
|
|
],
|
|
context: 'after_projects',
|
|
},
|
|
}
|
|
```
|
|
|
|
### Composant ChoiceCard
|
|
|
|
```vue
|
|
<!-- frontend/app/components/feature/ChoiceCard.vue -->
|
|
<script setup lang="ts">
|
|
import type { Choice } from '~/types/choice'
|
|
|
|
const props = defineProps<{
|
|
choice: Choice
|
|
selected: boolean
|
|
disabled: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
select: []
|
|
}>()
|
|
|
|
const reducedMotion = useReducedMotion()
|
|
const { locale } = useI18n()
|
|
|
|
const text = computed(() => {
|
|
return locale.value === 'fr' ? props.choice.textFr : props.choice.textEn
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<button
|
|
type="button"
|
|
class="choice-card 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-dark-100 bg-sky-dark-50 hover:border-sky-accent/50 hover:bg-sky-dark-50/80',
|
|
disabled && 'opacity-50 cursor-not-allowed',
|
|
!reducedMotion && 'transform',
|
|
]"
|
|
: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 transition-opacity pointer-events-none"
|
|
:class="!selected && 'group-hover:opacity-100'"
|
|
:style="{ boxShadow: `0 0 30px ${choice.zoneColor}40` }"
|
|
></div>
|
|
|
|
<!-- 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">✓</span>
|
|
</div>
|
|
</button>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.choice-card:focus-visible {
|
|
outline: 2px solid var(--sky-accent);
|
|
outline-offset: 4px;
|
|
}
|
|
|
|
.choice-card:not(:disabled):hover {
|
|
transform: translateY(-4px);
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.choice-card {
|
|
transition: none;
|
|
transform: none !important;
|
|
}
|
|
|
|
.choice-card:not(:disabled):hover {
|
|
transform: none;
|
|
}
|
|
}
|
|
</style>
|
|
```
|
|
|
|
### Composant ChoiceCards
|
|
|
|
```vue
|
|
<!-- frontend/app/components/feature/ChoiceCards.vue -->
|
|
<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 progressionStore = useProgressionStore()
|
|
const reducedMotion = useReducedMotion()
|
|
|
|
const selectedChoice = ref<Choice | null>(null)
|
|
const isTransitioning = ref(false)
|
|
|
|
const question = computed(() => {
|
|
return locale.value === 'fr' ? props.choicePoint.questionFr : props.choicePoint.questionEn
|
|
})
|
|
|
|
function handleSelect(choice: Choice) {
|
|
if (isTransitioning.value) return
|
|
|
|
selectedChoice.value = choice
|
|
|
|
// Enregistrer le choix dans le store
|
|
progressionStore.addChoice(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(() => {
|
|
const route = locale.value === 'fr' ? choice.destination : `/en${choice.destination}`
|
|
router.push(route)
|
|
}, 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
|
|
|
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
const newIndex = currentIndex <= 0 ? choices.length - 1 : currentIndex - 1
|
|
handleSelect(choices[newIndex])
|
|
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
const newIndex = currentIndex >= choices.length - 1 ? 0 : currentIndex + 1
|
|
handleSelect(choices[newIndex])
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<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
|
|
class="choice-cards grid grid-cols-1 md:grid-cols-2 gap-6 max-w-2xl mx-auto"
|
|
role="radiogroup"
|
|
:aria-label="question"
|
|
@keydown="handleKeydown"
|
|
>
|
|
<ChoiceCard
|
|
v-for="choice in choicePoint.choices"
|
|
:key="choice.id"
|
|
:choice="choice"
|
|
:selected="selectedChoice?.id === choice.id"
|
|
:disabled="isTransitioning"
|
|
@select="handleSelect(choice)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<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>
|
|
```
|
|
|
|
### Utilisation dans une page/composant
|
|
|
|
```vue
|
|
<!-- Exemple d'utilisation -->
|
|
<script setup>
|
|
import { CHOICE_POINTS } from '~/types/choice'
|
|
|
|
const currentChoicePoint = ref(CHOICE_POINTS.intro_first_choice)
|
|
|
|
function handleChoiceSelected(choice) {
|
|
console.log('Choice selected:', choice.id)
|
|
// La navigation est gérée automatiquement par ChoiceCards
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen flex items-center justify-center p-8">
|
|
<ChoiceCards
|
|
:choice-point="currentChoicePoint"
|
|
@selected="handleChoiceSelected"
|
|
/>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### Dépendances
|
|
|
|
**Cette story nécessite :**
|
|
- Story 3.5 : Store de progression (addChoice)
|
|
- Story 3.2 : useReducedMotion composable
|
|
|
|
**Cette story prépare pour :**
|
|
- Story 4.2 : Intro narrative (utilise ChoiceCards)
|
|
- Story 4.3 : Chemins narratifs (points de choix multiples)
|
|
|
|
### Project Structure Notes
|
|
|
|
**Fichiers à créer :**
|
|
```
|
|
frontend/app/
|
|
├── types/
|
|
│ └── choice.ts # CRÉER
|
|
└── components/feature/
|
|
├── ChoiceCard.vue # CRÉER
|
|
└── ChoiceCards.vue # CRÉER
|
|
```
|
|
|
|
### References
|
|
|
|
- [Source: docs/planning-artifacts/epics.md#Story-4.1]
|
|
- [Source: docs/planning-artifacts/ux-design-specification.md#Choice-System]
|
|
- [Source: docs/brainstorming-gamification-2026-01-26.md#Parcours-Narratifs]
|
|
|
|
### Technical Requirements
|
|
|
|
| Requirement | Value | Source |
|
|
|-------------|-------|--------|
|
|
| Choix par point | 2 (binaire) | Epics |
|
|
| Layout desktop | Côte à côte | Epics |
|
|
| Layout mobile | Empilé | Epics |
|
|
| Accessibilité | role="radiogroup", clavier | Epics |
|
|
| Police | font-narrative | UX Spec |
|
|
|
|
## Dev Agent Record
|
|
|
|
### Agent Model Used
|
|
|
|
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
|
|
|
### Debug Log References
|
|
|
|
### Completion Notes List
|
|
|
|
- Composants ChoiceCard et ChoiceCards créés avec accessibilité complète
|
|
- Types Choice et ChoicePoint définis avec CHOICE_POINTS prédéfinis
|
|
- Intégration store via progressionStore.makeChoice()
|
|
- Animations avec respect prefers-reduced-motion
|
|
- Build production validé
|
|
|
|
### Change Log
|
|
| Date | Change | Author |
|
|
|------|--------|--------|
|
|
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
|
|
| 2026-02-08 | Implémentation complète des composants | Dev Agent |
|
|
|
|
### File List
|
|
|
|
- frontend/app/types/choice.ts (CREATED)
|
|
- frontend/app/components/feature/ChoiceCard.vue (CREATED)
|
|
- frontend/app/components/feature/ChoiceCards.vue (CREATED)
|
|
|