Files
Portfolio-Game/docs/implementation-artifacts/3-5-logique-progression-deblocage-contact.md
skycel ec1ae92799 🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)
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>
2026-02-05 02:08:56 +01:00

15 KiB

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

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