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>
15 KiB
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
- Given le store
useProgressionStoreest actif When le visiteur visite une nouvelle zone Then la zone est ajoutée àvisitedSections - And le
completionPercentest recalculé automatiquement - And la progression est persistée en LocalStorage (si consentement RGPD donné)
- Given le visiteur a visité 2 zones ou plus When la condition est atteinte Then
contactUnlockedpasse àtrue - And le narrateur annonce le déblocage avec un message spécial
- And la zone Contact s'illumine sur la carte (si visible)
- And le visiteur peut continuer à explorer ou aller au contact
- Given le visiteur revient sur le site When une progression existe en LocalStorage Then le store est réhydraté avec l'état sauvegardé
- And le narrateur affiche "Bienvenue à nouveau"
- 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
- Créer
-
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 |