Files
Portfolio-Game/docs/implementation-artifacts/3-5-logique-progression-deblocage-contact.md
skycel dbe2ec4cb8 📝 docs: Story 3.5 validée - logique progression déjà implémentée
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>
2026-02-07 03:12:30 +01:00

15 KiB

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

  • Task 1: Compléter le store useProgressionStore (AC: #1, #2, #4)

    • État : visitedSections, completionPercent, contactUnlocked, hero, narratorStage, choices (déjà dans Story 1.6)
    • Action : visitSection(section) pour enregistrer une visite
    • Getter : isContactUnlocked = visitedSections.length >= 2 || contactUnlocked
    • Getter : narratorStage basé sur completionPercent (ajouté Story 3.3)
  • Task 2: Implémenter la persistance LocalStorage (AC: #3, #8)

    • Utilisation de pinia-plugin-persistedstate avec conditionalStorage (Story 1.6)
    • Vérifier le consentement RGPD avant de persister (consentGiven dans state)
    • Clé LocalStorage : skycel-progression
    • Réhydratation automatique au chargement
  • Task 3: Détecter les visites de sections (AC: #1)

    • Les pages appellent visitSection() dans onMounted() (Stories 2.x)
    • Plugin narrator-transitions détecte les routes pour les transitions (Story 3.3)
  • 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 (dans visitSection)
    • Watcher sur contactUnlocked déclenche narrateur (plugin narrator-transitions, Story 3.3)
    • Accès au contact toujours permis (UX non frustrante)
  • Task 5: Réhydratation au retour (AC: #8, #9, #10)

    • pinia-plugin-persistedstate réhydrate automatiquement
    • hasExistingProgress getter pour détecter le retour
    • useNarrator.showIntro() vérifie hasExistingProgress pour welcome_back
  • Task 6: Gestion du consentement RGPD (AC: #3)

    • consentGiven dans le store
    • conditionalStorage ne persiste que si consentGiven === true
    • setConsent(false) supprime les données du localStorage
  • Task 7: Tests et validation

    • Tester l'ajout de sections visitées
    • Vérifier le calcul automatique du pourcentage (progressPercent getter)
    • 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

// 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

// 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

// 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

// 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

// 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)

// 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