# Story 3.5: Logique de progression et déblocage contact Status: ready-for-dev ## 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 - [ ] **Task 1: Compléter le store useProgressionStore** (AC: #1, #2, #4) - [ ] État : visitedSections, completionPercent, contactUnlocked, heroType, expressMode, narratorStage, choices - [ ] Action : visitSection(section) pour enregistrer une visite - [ ] Getter : contactUnlocked = visitedSections.length >= 2 - [ ] Getter : narratorStage basé sur completionPercent - [ ] **Task 2: Implémenter la persistance LocalStorage** (AC: #3, #8) - [ ] Créer `frontend/app/composables/useProgressionPersistence.ts` - [ ] Vérifier le consentement RGPD avant de persister - [ ] Clé LocalStorage : `skycel_progression` - [ ] Sérialiser : visitedSections, heroType, choices - [ ] Réhydrater au chargement - [ ] **Task 3: Détecter les visites de sections** (AC: #1) - [ ] Créer un plugin ou middleware qui détecte la route actuelle - [ ] Mapper les routes aux sections : /projets → projets, etc. - [ ] Appeler `visitSection()` automatiquement - [ ] **Task 4: Implémenter le déblocage du contact** (AC: #4, #5, #6, #7) - [ ] Le contact est débloqué après 2 sections visitées - [ ] Émettre un événement ou watcher pour déclencher le narrateur - [ ] Permettre l'accès au contact même si bloqué (UX non frustrante) - [ ] Marquer visuellement sur la carte - [ ] **Task 5: Réhydratation au retour** (AC: #8, #9, #10) - [ ] Au montage de l'app, vérifier LocalStorage - [ ] Si progression existante, réhydrater le store - [ ] Déclencher le message "Bienvenue à nouveau" via useNarrator - [ ] La carte reflète l'état correct - [ ] **Task 6: Gestion du consentement RGPD** (AC: #3) - [ ] Lire l'état du consentement depuis le store ou cookie - [ ] Si pas de consentement, ne pas persister - [ ] Si consentement retiré, supprimer les données - [ ] **Task 7: Tests et validation** - [ ] Tester l'ajout de sections visitées - [ ] Vérifier le calcul automatique du pourcentage - [ ] Tester le déblocage à 2 sections - [ ] Valider la persistance LocalStorage - [ ] Tester la réhydratation au rechargement - [ ] 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([]) const heroType = ref(null) const expressMode = ref(false) const choices = ref([]) 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) { 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 = { '/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(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