Story 3.5 : Logique de progression et déblocage contact Cette story était déjà implémentée par les stories précédentes : - Store progression avec visitSection(), contactUnlocked (Story 1.6) - Persistance RGPD avec conditionalStorage (Story 1.6) - Plugin narrator-transitions avec watcher contactUnlocked (Story 3.3) - Layout adventure avec useNarrator.showIntro() pour welcome_back (Story 3.3) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
482 lines
15 KiB
Markdown
482 lines
15 KiB
Markdown
# Story 3.5: Logique de progression et déblocage contact
|
|
|
|
Status: review
|
|
|
|
## Story
|
|
|
|
As a visiteur,
|
|
I want que ma progression débloque l'accès au contact,
|
|
so that l'exploration est récompensée sans être frustrante.
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. **Given** le store `useProgressionStore` est actif **When** le visiteur visite une nouvelle zone **Then** la zone est ajoutée à `visitedSections`
|
|
2. **And** le `completionPercent` est recalculé automatiquement
|
|
3. **And** la progression est persistée en LocalStorage (si consentement RGPD donné)
|
|
4. **Given** le visiteur a visité 2 zones ou plus **When** la condition est atteinte **Then** `contactUnlocked` passe à `true`
|
|
5. **And** le narrateur annonce le déblocage avec un message spécial
|
|
6. **And** la zone Contact s'illumine sur la carte (si visible)
|
|
7. **And** le visiteur peut continuer à explorer ou aller au contact
|
|
8. **Given** le visiteur revient sur le site **When** une progression existe en LocalStorage **Then** le store est réhydraté avec l'état sauvegardé
|
|
9. **And** le narrateur affiche "Bienvenue à nouveau"
|
|
10. **And** la carte affiche l'état correct des zones visitées
|
|
|
|
## Tasks / Subtasks
|
|
|
|
- [x] **Task 1: Compléter le store useProgressionStore** (AC: #1, #2, #4)
|
|
- [x] État : visitedSections, completionPercent, contactUnlocked, hero, narratorStage, choices (déjà dans Story 1.6)
|
|
- [x] Action : visitSection(section) pour enregistrer une visite
|
|
- [x] Getter : isContactUnlocked = visitedSections.length >= 2 || contactUnlocked
|
|
- [x] Getter : narratorStage basé sur completionPercent (ajouté Story 3.3)
|
|
|
|
- [x] **Task 2: Implémenter la persistance LocalStorage** (AC: #3, #8)
|
|
- [x] Utilisation de pinia-plugin-persistedstate avec conditionalStorage (Story 1.6)
|
|
- [x] Vérifier le consentement RGPD avant de persister (consentGiven dans state)
|
|
- [x] Clé LocalStorage : `skycel-progression`
|
|
- [x] Réhydratation automatique au chargement
|
|
|
|
- [x] **Task 3: Détecter les visites de sections** (AC: #1)
|
|
- [x] Les pages appellent visitSection() dans onMounted() (Stories 2.x)
|
|
- [x] Plugin narrator-transitions détecte les routes pour les transitions (Story 3.3)
|
|
|
|
- [x] **Task 4: Implémenter le déblocage du contact** (AC: #4, #5, #6, #7)
|
|
- [x] Le contact est débloqué après 2 sections visitées (dans visitSection)
|
|
- [x] Watcher sur contactUnlocked déclenche narrateur (plugin narrator-transitions, Story 3.3)
|
|
- [x] Accès au contact toujours permis (UX non frustrante)
|
|
|
|
- [x] **Task 5: Réhydratation au retour** (AC: #8, #9, #10)
|
|
- [x] pinia-plugin-persistedstate réhydrate automatiquement
|
|
- [x] hasExistingProgress getter pour détecter le retour
|
|
- [x] useNarrator.showIntro() vérifie hasExistingProgress pour welcome_back
|
|
|
|
- [x] **Task 6: Gestion du consentement RGPD** (AC: #3)
|
|
- [x] consentGiven dans le store
|
|
- [x] conditionalStorage ne persiste que si consentGiven === true
|
|
- [x] setConsent(false) supprime les données du localStorage
|
|
|
|
- [x] **Task 7: Tests et validation**
|
|
- [x] Tester l'ajout de sections visitées
|
|
- [x] Vérifier le calcul automatique du pourcentage (progressPercent getter)
|
|
- [x] Tester le déblocage à 2 sections
|
|
- [x] Valider la persistance LocalStorage
|
|
- [x] Tester la réhydratation au rechargement
|
|
- [x] Vérifier le comportement sans consentement RGPD
|
|
|
|
## Dev Notes
|
|
|
|
### Store useProgressionStore complet
|
|
|
|
```typescript
|
|
// frontend/app/stores/progression.ts
|
|
import { defineStore } from 'pinia'
|
|
|
|
// Types
|
|
export type Section = 'projets' | 'competences' | 'temoignages' | 'parcours'
|
|
export type HeroType = 'recruteur' | 'client' | 'dev' | null
|
|
export type Choice = { id: string; value: string; timestamp: number }
|
|
|
|
// Constantes
|
|
const AVAILABLE_SECTIONS: Section[] = ['projets', 'competences', 'temoignages', 'parcours']
|
|
const CONTACT_UNLOCK_THRESHOLD = 2
|
|
const NARRATOR_STAGE_THRESHOLDS = [0, 20, 40, 60, 80] // 5 stages
|
|
|
|
export const useProgressionStore = defineStore('progression', () => {
|
|
// === État ===
|
|
const visitedSections = ref<Section[]>([])
|
|
const heroType = ref<HeroType>(null)
|
|
const expressMode = ref(false)
|
|
const choices = ref<Choice[]>([])
|
|
const hasReturned = ref(false) // Pour savoir si c'est un retour
|
|
|
|
// === Getters ===
|
|
const completionPercent = computed(() => {
|
|
return Math.round((visitedSections.value.length / AVAILABLE_SECTIONS.length) * 100)
|
|
})
|
|
|
|
const contactUnlocked = computed(() => {
|
|
return visitedSections.value.length >= CONTACT_UNLOCK_THRESHOLD
|
|
})
|
|
|
|
const narratorStage = computed(() => {
|
|
const percent = completionPercent.value
|
|
for (let i = NARRATOR_STAGE_THRESHOLDS.length - 1; i >= 0; i--) {
|
|
if (percent >= NARRATOR_STAGE_THRESHOLDS[i]) {
|
|
return i + 1 // Stages 1-5
|
|
}
|
|
}
|
|
return 1
|
|
})
|
|
|
|
const remainingSections = computed(() => {
|
|
return AVAILABLE_SECTIONS.filter(s => !visitedSections.value.includes(s))
|
|
})
|
|
|
|
// === Actions ===
|
|
function visitSection(section: Section) {
|
|
if (!visitedSections.value.includes(section)) {
|
|
visitedSections.value.push(section)
|
|
}
|
|
}
|
|
|
|
function setHeroType(type: HeroType) {
|
|
heroType.value = type
|
|
}
|
|
|
|
function setExpressMode(enabled: boolean) {
|
|
expressMode.value = enabled
|
|
}
|
|
|
|
function addChoice(id: string, value: string) {
|
|
choices.value.push({
|
|
id,
|
|
value,
|
|
timestamp: Date.now(),
|
|
})
|
|
}
|
|
|
|
function markAsReturned() {
|
|
hasReturned.value = true
|
|
}
|
|
|
|
function reset() {
|
|
visitedSections.value = []
|
|
heroType.value = null
|
|
expressMode.value = false
|
|
choices.value = []
|
|
hasReturned.value = false
|
|
}
|
|
|
|
// === Sérialisation pour persistance ===
|
|
function getSerializableState() {
|
|
return {
|
|
visitedSections: visitedSections.value,
|
|
heroType: heroType.value,
|
|
choices: choices.value,
|
|
}
|
|
}
|
|
|
|
function hydrateFromState(state: ReturnType<typeof getSerializableState>) {
|
|
if (state.visitedSections?.length) {
|
|
visitedSections.value = state.visitedSections
|
|
markAsReturned()
|
|
}
|
|
if (state.heroType) {
|
|
heroType.value = state.heroType
|
|
}
|
|
if (state.choices?.length) {
|
|
choices.value = state.choices
|
|
}
|
|
}
|
|
|
|
return {
|
|
// État
|
|
visitedSections,
|
|
heroType,
|
|
expressMode,
|
|
choices,
|
|
hasReturned,
|
|
// Getters
|
|
completionPercent,
|
|
contactUnlocked,
|
|
narratorStage,
|
|
remainingSections,
|
|
// Actions
|
|
visitSection,
|
|
setHeroType,
|
|
setExpressMode,
|
|
addChoice,
|
|
markAsReturned,
|
|
reset,
|
|
// Sérialisation
|
|
getSerializableState,
|
|
hydrateFromState,
|
|
}
|
|
})
|
|
```
|
|
|
|
### Composable useProgressionPersistence
|
|
|
|
```typescript
|
|
// frontend/app/composables/useProgressionPersistence.ts
|
|
const STORAGE_KEY = 'skycel_progression'
|
|
|
|
export function useProgressionPersistence() {
|
|
const progressionStore = useProgressionStore()
|
|
const consentStore = useConsentStore() // Supposé existant depuis Story 1.6
|
|
|
|
// Sauvegarder dans LocalStorage
|
|
function persist() {
|
|
// Vérifier le consentement RGPD
|
|
if (!consentStore.hasConsent) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const state = progressionStore.getSerializableState()
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
|
} catch (error) {
|
|
console.warn('Failed to persist progression:', error)
|
|
}
|
|
}
|
|
|
|
// Charger depuis LocalStorage
|
|
function hydrate() {
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY)
|
|
if (stored) {
|
|
const state = JSON.parse(stored)
|
|
progressionStore.hydrateFromState(state)
|
|
return true // Retourne true si des données ont été trouvées
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to hydrate progression:', error)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Supprimer les données
|
|
function clear() {
|
|
try {
|
|
localStorage.removeItem(STORAGE_KEY)
|
|
} catch (error) {
|
|
console.warn('Failed to clear progression:', error)
|
|
}
|
|
}
|
|
|
|
// Watcher pour persister automatiquement
|
|
watch(
|
|
() => progressionStore.getSerializableState(),
|
|
() => {
|
|
persist()
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
// Watcher sur le consentement pour supprimer si retiré
|
|
watch(
|
|
() => consentStore.hasConsent,
|
|
(hasConsent) => {
|
|
if (!hasConsent) {
|
|
clear()
|
|
}
|
|
}
|
|
)
|
|
|
|
return {
|
|
persist,
|
|
hydrate,
|
|
clear,
|
|
}
|
|
}
|
|
```
|
|
|
|
### Plugin de détection des visites
|
|
|
|
```typescript
|
|
// frontend/app/plugins/progression-tracker.client.ts
|
|
export default defineNuxtPlugin((nuxtApp) => {
|
|
const progressionStore = useProgressionStore()
|
|
const router = useRouter()
|
|
|
|
// Map des routes vers les sections
|
|
const routeSectionMap: Record<string, Section> = {
|
|
'/projets': 'projets',
|
|
'/en/projects': 'projets',
|
|
'/competences': 'competences',
|
|
'/en/skills': 'competences',
|
|
'/temoignages': 'temoignages',
|
|
'/en/testimonials': 'temoignages',
|
|
'/parcours': 'parcours',
|
|
'/en/journey': 'parcours',
|
|
}
|
|
|
|
// Détecter les changements de route
|
|
router.afterEach((to) => {
|
|
const section = routeSectionMap[to.path]
|
|
if (section) {
|
|
progressionStore.visitSection(section)
|
|
}
|
|
})
|
|
})
|
|
```
|
|
|
|
### Plugin d'initialisation de la progression
|
|
|
|
```typescript
|
|
// frontend/app/plugins/progression-init.client.ts
|
|
export default defineNuxtPlugin(async (nuxtApp) => {
|
|
const { hydrate } = useProgressionPersistence()
|
|
const progressionStore = useProgressionStore()
|
|
const narrator = useNarrator()
|
|
|
|
// Attendre que l'app soit montée
|
|
nuxtApp.hook('app:mounted', () => {
|
|
// Réhydrater la progression
|
|
const hasExistingProgress = hydrate()
|
|
|
|
// Si le visiteur revient avec une progression existante
|
|
if (hasExistingProgress && progressionStore.hasReturned) {
|
|
// Le message "Bienvenue à nouveau" sera déclenché via useNarrator
|
|
// dans le layout adventure.vue
|
|
}
|
|
})
|
|
})
|
|
```
|
|
|
|
### Intégration avec le narrateur
|
|
|
|
```typescript
|
|
// Dans frontend/app/layouts/adventure.vue ou composable useNarrator
|
|
// Déclencher le message de déblocage du contact
|
|
|
|
// Watcher sur contactUnlocked
|
|
const unwatchContact = watch(
|
|
() => progressionStore.contactUnlocked,
|
|
(isUnlocked, wasUnlocked) => {
|
|
if (isUnlocked && !wasUnlocked) {
|
|
narrator.showContactUnlocked()
|
|
// Notification visuelle optionnelle
|
|
}
|
|
}
|
|
)
|
|
|
|
// Au montage, vérifier si c'est un retour
|
|
onMounted(async () => {
|
|
if (progressionStore.hasReturned) {
|
|
await narrator.showWelcomeBack()
|
|
} else if (progressionStore.heroType) {
|
|
await narrator.showIntro()
|
|
}
|
|
})
|
|
```
|
|
|
|
### Interface du consentement (référence)
|
|
|
|
```typescript
|
|
// frontend/app/stores/consent.ts (supposé existant depuis Story 1.6)
|
|
export const useConsentStore = defineStore('consent', () => {
|
|
const hasConsent = ref(false)
|
|
const consentDate = ref<number | null>(null)
|
|
|
|
function giveConsent() {
|
|
hasConsent.value = true
|
|
consentDate.value = Date.now()
|
|
}
|
|
|
|
function revokeConsent() {
|
|
hasConsent.value = false
|
|
consentDate.value = null
|
|
}
|
|
|
|
return {
|
|
hasConsent,
|
|
consentDate,
|
|
giveConsent,
|
|
revokeConsent,
|
|
}
|
|
})
|
|
```
|
|
|
|
### Schéma du flux de progression
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ FLUX DE PROGRESSION │
|
|
├─────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ 1. PREMIÈRE VISITE │
|
|
│ └─> Landing page │
|
|
│ └─> Choix du héros (recruteur/client/dev) │
|
|
│ └─> heroType = 'recruteur' | 'client' | 'dev' │
|
|
│ │
|
|
│ 2. NAVIGATION │
|
|
│ └─> Visite /projets │
|
|
│ └─> visitSection('projets') │
|
|
│ └─> visitedSections = ['projets'] │
|
|
│ └─> completionPercent = 25% │
|
|
│ └─> narratorStage = 2 (>= 20%) │
|
|
│ │
|
|
│ 3. DÉBLOCAGE CONTACT │
|
|
│ └─> Visite /competences (2ème section) │
|
|
│ └─> visitedSections = ['projets', 'competences'] │
|
|
│ └─> completionPercent = 50% │
|
|
│ └─> contactUnlocked = true (>= 2 sections) │
|
|
│ └─> Trigger: narrator.showContactUnlocked() │
|
|
│ │
|
|
│ 4. PERSISTANCE │
|
|
│ └─> Si consentement RGPD │
|
|
│ └─> localStorage.setItem('skycel_progression', {...}) │
|
|
│ │
|
|
│ 5. RETOUR DU VISITEUR │
|
|
│ └─> Au chargement │
|
|
│ └─> Lire localStorage │
|
|
│ └─> hydrateFromState() │
|
|
│ └─> hasReturned = true │
|
|
│ └─> Trigger: narrator.showWelcomeBack() │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Dépendances
|
|
|
|
**Cette story nécessite :**
|
|
- Story 1.6 : Store Pinia de base + consentement RGPD
|
|
- Story 3.3 : useNarrator pour les messages de déblocage et retour
|
|
|
|
**Cette story prépare pour :**
|
|
- Story 3.6 : Carte interactive (affiche l'état des zones)
|
|
- Story 3.7 : Navigation mobile (même logique)
|
|
- Story 4.3 : Chemins narratifs (utilise choices)
|
|
|
|
### Project Structure Notes
|
|
|
|
**Fichiers à créer :**
|
|
```
|
|
frontend/app/
|
|
├── stores/
|
|
│ └── progression.ts # CRÉER (complet)
|
|
├── composables/
|
|
│ └── useProgressionPersistence.ts # CRÉER
|
|
└── plugins/
|
|
├── progression-tracker.client.ts # CRÉER
|
|
└── progression-init.client.ts # CRÉER
|
|
```
|
|
|
|
**Fichiers à modifier :**
|
|
```
|
|
frontend/app/layouts/adventure.vue # INTÉGRER la logique de retour
|
|
```
|
|
|
|
### References
|
|
|
|
- [Source: docs/planning-artifacts/epics.md#Story-3.5]
|
|
- [Source: docs/planning-artifacts/ux-design-specification.md#Progression-System]
|
|
- [Source: docs/planning-artifacts/architecture.md#State-Management]
|
|
|
|
### Technical Requirements
|
|
|
|
| Requirement | Value | Source |
|
|
|-------------|-------|--------|
|
|
| Sections pour progression | 4 (projets, competences, temoignages, parcours) | Epics |
|
|
| Seuil déblocage contact | 2 sections visitées | Epics |
|
|
| Clé LocalStorage | skycel_progression | Décision technique |
|
|
| Condition persistance | Consentement RGPD | RGPD |
|
|
|
|
## 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
|
|
|