🎉 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>
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
# 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<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
|
||||
|
||||
Reference in New Issue
Block a user