Setup complet de l'infrastructure projet : - Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS) - Backend Laravel 12 API-only avec middleware X-API-Key et CORS - Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter) - Documentation planning et implementation artifacts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
424 lines
14 KiB
Markdown
424 lines
14 KiB
Markdown
# Story 4.3: Chemins narratifs différenciés
|
|
|
|
Status: ready-for-dev
|
|
|
|
## Story
|
|
|
|
As a visiteur,
|
|
I want que mes choix aient un impact visible sur mon parcours,
|
|
so that je sens que mon expérience est vraiment personnalisée.
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. **Given** le visiteur fait des choix tout au long de l'aventure **When** il navigue entre les zones **Then** 2-3 points de choix binaires créent 4-8 parcours possibles
|
|
2. **And** chaque choix est enregistré dans `choices` du store
|
|
3. **And** l'ordre suggéré des zones varie selon le chemin choisi
|
|
4. **And** les textes du narrateur s'adaptent au chemin (transitions contextuelles)
|
|
5. **And** tous les chemins permettent de visiter tout le contenu
|
|
6. **And** tous les chemins mènent au contact (pas de "mauvais" choix)
|
|
7. **And** le `currentPath` du store reflète le chemin actuel
|
|
8. **And** à la fin de chaque zone, le narrateur propose un choix vers la suite
|
|
|
|
## Tasks / Subtasks
|
|
|
|
- [ ] **Task 1: Définir l'arbre des chemins** (AC: #1, #5, #6)
|
|
- [ ] Créer `frontend/app/data/narrativePaths.ts`
|
|
- [ ] Définir 2-3 points de choix créant 4-8 parcours
|
|
- [ ] S'assurer que tous les chemins visitent toutes les zones
|
|
- [ ] S'assurer que tous les chemins mènent au contact
|
|
|
|
- [ ] **Task 2: Créer le composable useNarrativePath** (AC: #2, #3, #7)
|
|
- [ ] Créer `frontend/app/composables/useNarrativePath.ts`
|
|
- [ ] Calculer le chemin actuel basé sur les choix
|
|
- [ ] Exposer la prochaine zone suggérée
|
|
- [ ] Exposer les zones restantes dans l'ordre
|
|
|
|
- [ ] **Task 3: Ajouter les textes de transition contextuels** (AC: #4)
|
|
- [ ] Créer des contextes spécifiques : `transition_after_projects_to_skills`, etc.
|
|
- [ ] Variantes selon le chemin pris
|
|
- [ ] Commentaires du narrateur sur les choix précédents
|
|
|
|
- [ ] **Task 4: Intégrer les choix après chaque zone** (AC: #8)
|
|
- [ ] Composant `ZoneEndChoice.vue` affiché à la fin de chaque page de zone
|
|
- [ ] Proposer les options de destination selon le chemin
|
|
- [ ] Utiliser ChoiceCards pour la présentation
|
|
|
|
- [ ] **Task 5: Mettre à jour le store** (AC: #2, #7)
|
|
- [ ] Ajouter `currentPath` computed au store
|
|
- [ ] Ajouter `suggestedNextZone` computed
|
|
- [ ] Méthode pour obtenir le choix à un point donné
|
|
|
|
- [ ] **Task 6: Créer l'API pour les transitions contextuelles** (AC: #4)
|
|
- [ ] Endpoint `/api/narrator/transition-contextual`
|
|
- [ ] Paramètres : from_zone, to_zone, path_choices
|
|
- [ ] Retourner un texte adapté au contexte
|
|
|
|
- [ ] **Task 7: Tests et validation**
|
|
- [ ] Tester tous les chemins possibles (4-8)
|
|
- [ ] Vérifier que tous mènent au contact
|
|
- [ ] Valider les textes contextuels
|
|
- [ ] Tester la suggestion de zone suivante
|
|
|
|
## Dev Notes
|
|
|
|
### Arbre des chemins narratifs
|
|
|
|
```typescript
|
|
// frontend/app/data/narrativePaths.ts
|
|
|
|
// Points de choix dans l'aventure
|
|
export const NARRATIVE_CHOICE_POINTS = {
|
|
// Point 1 : Après l'intro
|
|
intro: {
|
|
id: 'intro',
|
|
options: ['projects', 'skills'],
|
|
},
|
|
// Point 2 : Après la première zone
|
|
after_first_zone: {
|
|
id: 'after_first_zone',
|
|
options: ['testimonials', 'journey'],
|
|
},
|
|
// Point 3 : Après la deuxième zone
|
|
after_second_zone: {
|
|
id: 'after_second_zone',
|
|
// Les options dépendent de ce qui reste
|
|
},
|
|
}
|
|
|
|
// Chemins possibles (4-8 combinaisons)
|
|
// Format : intro_choice -> after_first -> after_second -> contact
|
|
export const NARRATIVE_PATHS = [
|
|
// Chemin 1 : Projets → Témoignages → Compétences → Parcours → Contact
|
|
['projects', 'testimonials', 'skills', 'journey', 'contact'],
|
|
// Chemin 2 : Projets → Témoignages → Parcours → Compétences → Contact
|
|
['projects', 'testimonials', 'journey', 'skills', 'contact'],
|
|
// Chemin 3 : Projets → Parcours → Témoignages → Compétences → Contact
|
|
['projects', 'journey', 'testimonials', 'skills', 'contact'],
|
|
// Chemin 4 : Projets → Parcours → Compétences → Témoignages → Contact
|
|
['projects', 'journey', 'skills', 'testimonials', 'contact'],
|
|
// Chemin 5 : Compétences → Témoignages → Projets → Parcours → Contact
|
|
['skills', 'testimonials', 'projects', 'journey', 'contact'],
|
|
// Chemin 6 : Compétences → Témoignages → Parcours → Projets → Contact
|
|
['skills', 'testimonials', 'journey', 'projects', 'contact'],
|
|
// Chemin 7 : Compétences → Parcours → Témoignages → Projets → Contact
|
|
['skills', 'journey', 'testimonials', 'projects', 'contact'],
|
|
// Chemin 8 : Compétences → Parcours → Projets → Témoignages → Contact
|
|
['skills', 'journey', 'projects', 'testimonials', 'contact'],
|
|
]
|
|
|
|
// Mapper zone key -> route
|
|
export const ZONE_ROUTES: Record<string, { fr: string; en: string }> = {
|
|
projects: { fr: '/projets', en: '/en/projects' },
|
|
skills: { fr: '/competences', en: '/en/skills' },
|
|
testimonials: { fr: '/temoignages', en: '/en/testimonials' },
|
|
journey: { fr: '/parcours', en: '/en/journey' },
|
|
contact: { fr: '/contact', en: '/en/contact' },
|
|
}
|
|
```
|
|
|
|
### Composable useNarrativePath
|
|
|
|
```typescript
|
|
// frontend/app/composables/useNarrativePath.ts
|
|
import { NARRATIVE_PATHS, ZONE_ROUTES } from '~/data/narrativePaths'
|
|
|
|
export function useNarrativePath() {
|
|
const progressionStore = useProgressionStore()
|
|
const { locale } = useI18n()
|
|
|
|
// Déterminer le chemin actuel basé sur les choix
|
|
const currentPath = computed(() => {
|
|
const choices = progressionStore.choices
|
|
|
|
// Trouver le premier choix (intro)
|
|
const introChoice = choices.find(c => c.id === 'intro_first_choice')
|
|
if (!introChoice) return null
|
|
|
|
const startZone = introChoice.value === 'choice_projects_first' ? 'projects' : 'skills'
|
|
|
|
// Filtrer les chemins qui commencent par cette zone
|
|
let possiblePaths = NARRATIVE_PATHS.filter(path => path[0] === startZone)
|
|
|
|
// Affiner avec les choix suivants
|
|
const afterFirstChoice = choices.find(c => c.id === 'after_first_zone')
|
|
if (afterFirstChoice && possiblePaths.length > 1) {
|
|
const secondZone = afterFirstChoice.value.includes('testimonials') ? 'testimonials' : 'journey'
|
|
possiblePaths = possiblePaths.filter(path => path[1] === secondZone)
|
|
}
|
|
|
|
return possiblePaths[0] || null
|
|
})
|
|
|
|
// Zone actuelle basée sur la route
|
|
const currentZone = computed(() => {
|
|
const route = useRoute()
|
|
const path = route.path.toLowerCase()
|
|
|
|
for (const [zone, routes] of Object.entries(ZONE_ROUTES)) {
|
|
if (path.includes(routes.fr.slice(1)) || path.includes(routes.en.slice(4))) {
|
|
return zone
|
|
}
|
|
}
|
|
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
|
|
const remainingZones = computed(() => {
|
|
if (!currentPath.value) return []
|
|
|
|
const visited = progressionStore.visitedSections
|
|
return currentPath.value.filter(zone =>
|
|
zone !== 'contact' && !visited.includes(zone as any)
|
|
)
|
|
})
|
|
|
|
// Obtenir la route pour une zone
|
|
function getZoneRoute(zone: string): string {
|
|
const routes = ZONE_ROUTES[zone]
|
|
if (!routes) return '/'
|
|
return locale.value === 'fr' ? routes.fr : routes.en
|
|
}
|
|
|
|
// Générer le choix pour après la zone actuelle
|
|
function getNextChoicePoint() {
|
|
if (!remainingZones.value.length) {
|
|
// Plus de zones, aller au contact
|
|
return {
|
|
id: 'go_to_contact',
|
|
choices: [
|
|
{
|
|
id: 'contact',
|
|
textFr: 'Rencontrer le développeur',
|
|
textEn: 'Meet the developer',
|
|
icon: '📧',
|
|
destination: getZoneRoute('contact'),
|
|
zoneColor: '#fa784f',
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
// Proposer les 2 prochaines zones
|
|
const nextTwo = remainingZones.value.slice(0, 2)
|
|
|
|
return {
|
|
id: `after_${currentZone.value}`,
|
|
questionFr: 'Où vas-tu ensuite ?',
|
|
questionEn: 'Where to next?',
|
|
choices: nextTwo.map(zone => ({
|
|
id: `choice_${zone}`,
|
|
textFr: getZoneLabel(zone, 'fr'),
|
|
textEn: getZoneLabel(zone, 'en'),
|
|
icon: getZoneIcon(zone),
|
|
destination: getZoneRoute(zone),
|
|
zoneColor: getZoneColor(zone),
|
|
})),
|
|
}
|
|
}
|
|
|
|
return {
|
|
currentPath,
|
|
currentZone,
|
|
suggestedNextZone,
|
|
remainingZones,
|
|
getZoneRoute,
|
|
getNextChoicePoint,
|
|
}
|
|
}
|
|
|
|
// Helpers
|
|
function getZoneLabel(zone: string, locale: string): string {
|
|
const labels: Record<string, { fr: string; en: string }> = {
|
|
projects: { fr: 'Découvrir les créations', en: 'Discover the creations' },
|
|
skills: { fr: 'Explorer les compétences', en: 'Explore the skills' },
|
|
testimonials: { fr: 'Écouter les témoignages', en: 'Listen to testimonials' },
|
|
journey: { fr: 'Suivre le parcours', en: 'Follow the journey' },
|
|
}
|
|
return labels[zone]?.[locale] || zone
|
|
}
|
|
|
|
function getZoneIcon(zone: string): string {
|
|
const icons: Record<string, string> = {
|
|
projects: '💻',
|
|
skills: '⚡',
|
|
testimonials: '💬',
|
|
journey: '📍',
|
|
}
|
|
return icons[zone] || '?'
|
|
}
|
|
|
|
function getZoneColor(zone: string): string {
|
|
const colors: Record<string, string> = {
|
|
projects: '#3b82f6',
|
|
skills: '#10b981',
|
|
testimonials: '#f59e0b',
|
|
journey: '#8b5cf6',
|
|
}
|
|
return colors[zone] || '#fa784f'
|
|
}
|
|
```
|
|
|
|
### Composant ZoneEndChoice
|
|
|
|
```vue
|
|
<!-- frontend/app/components/feature/ZoneEndChoice.vue -->
|
|
<script setup lang="ts">
|
|
const { getNextChoicePoint, remainingZones } = useNarrativePath()
|
|
const narrator = useNarrator()
|
|
|
|
const choicePoint = computed(() => getNextChoicePoint())
|
|
|
|
// Afficher un message du narrateur avant le choix
|
|
onMounted(async () => {
|
|
if (remainingZones.value.length > 0) {
|
|
await narrator.showTransitionChoice()
|
|
} else {
|
|
await narrator.showContactReady()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<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">
|
|
<!-- Message narratif -->
|
|
<p class="font-narrative text-xl text-sky-text text-center mb-8 italic">
|
|
{{ $t('narrative.whatNext') }}
|
|
</p>
|
|
|
|
<!-- Choix -->
|
|
<ChoiceCards
|
|
v-if="choicePoint.choices?.length"
|
|
:choice-point="choicePoint"
|
|
/>
|
|
|
|
<!-- Si une seule option (contact) -->
|
|
<div
|
|
v-else-if="choicePoint.choices?.length === 1"
|
|
class="text-center"
|
|
>
|
|
<NuxtLink
|
|
:to="choicePoint.choices[0].destination"
|
|
class="inline-flex items-center gap-3 px-8 py-4 bg-sky-accent text-white font-ui font-semibold rounded-xl hover:bg-sky-accent/90 transition-colors"
|
|
>
|
|
<span class="text-2xl">{{ choicePoint.choices[0].icon }}</span>
|
|
<span>{{ $i18n.locale === 'fr' ? choicePoint.choices[0].textFr : choicePoint.choices[0].textEn }}</span>
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### Schéma des chemins narratifs
|
|
|
|
```
|
|
┌─────────────┐
|
|
│ INTRO │
|
|
└──────┬──────┘
|
|
│
|
|
┌────────────┴────────────┐
|
|
▼ ▼
|
|
┌──────────┐ ┌──────────┐
|
|
│ PROJETS │ │ COMPÉT. │
|
|
└────┬─────┘ └────┬─────┘
|
|
│ │
|
|
┌───────┴───────┐ ┌───────┴───────┐
|
|
▼ ▼ ▼ ▼
|
|
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
|
|
│TÉMOIGN.│ │PARCOURS│ │TÉMOIGN.│ │PARCOURS│
|
|
└───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘
|
|
│ │ │ │
|
|
▼ ▼ ▼ ▼
|
|
(suite) (suite) (suite) (suite)
|
|
│ │ │ │
|
|
└──────┬──────┴───────────┴──────┬──────┘
|
|
│ │
|
|
▼ ▼
|
|
┌──────────────────────────────────────┐
|
|
│ CONTACT │
|
|
│ (tous les chemins y mènent) │
|
|
└──────────────────────────────────────┘
|
|
```
|
|
|
|
### Dépendances
|
|
|
|
**Cette story nécessite :**
|
|
- Story 4.1 : ChoiceCards
|
|
- Story 4.2 : Intro narrative (premier choix)
|
|
- Story 3.5 : Store de progression (choices)
|
|
|
|
**Cette story prépare pour :**
|
|
- Story 4.7 : Révélation (fin des chemins)
|
|
- Story 4.8 : Page contact (destination finale)
|
|
|
|
### Project Structure Notes
|
|
|
|
**Fichiers à créer :**
|
|
```
|
|
frontend/app/
|
|
├── data/
|
|
│ └── narrativePaths.ts # CRÉER
|
|
├── composables/
|
|
│ └── useNarrativePath.ts # CRÉER
|
|
└── components/feature/
|
|
└── ZoneEndChoice.vue # CRÉER
|
|
```
|
|
|
|
**Fichiers à modifier :**
|
|
```
|
|
frontend/app/pages/projets.vue # AJOUTER ZoneEndChoice
|
|
frontend/app/pages/competences.vue # AJOUTER ZoneEndChoice
|
|
frontend/app/pages/temoignages.vue # AJOUTER ZoneEndChoice
|
|
frontend/app/pages/parcours.vue # AJOUTER ZoneEndChoice
|
|
```
|
|
|
|
### References
|
|
|
|
- [Source: docs/planning-artifacts/epics.md#Story-4.3]
|
|
- [Source: docs/planning-artifacts/ux-design-specification.md#Narrative-Paths]
|
|
- [Source: docs/brainstorming-gamification-2026-01-26.md#Parcours-Narratifs]
|
|
|
|
### Technical Requirements
|
|
|
|
| Requirement | Value | Source |
|
|
|-------------|-------|--------|
|
|
| Points de choix | 2-3 | Epics |
|
|
| Parcours possibles | 4-8 | Epics |
|
|
| Toutes zones visitables | Oui | Epics |
|
|
| Tous chemins → contact | Oui | Epics |
|
|
|
|
## Dev Agent Record
|
|
|
|
### Agent Model Used
|
|
|
|
{{agent_model_name_version}}
|
|
|
|
### Debug Log References
|
|
|
|
### Completion Notes List
|
|
|
|
### Change Log
|
|
| Date | Change | Author |
|
|
|------|--------|--------|
|
|
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
|
|
|
|
### File List
|
|
|