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>
13 KiB
Story 1.6: Store Pinia progression et bandeau RGPD
Status: ready-for-dev
Story
As a visiteur, I want que ma progression soit sauvegardée et que mon consentement soit respecté, so that je peux reprendre mon exploration et mes données sont protégées.
Acceptance Criteria
- Given le visiteur accède au site When le consentement RGPD n'a pas encore été donné Then un bandeau de consentement immersif s'affiche (style narratif/dialogue, pas un bandeau classique)
- And le store Pinia
useProgressionStoreest initialisé avec : sessionId (UUID v4), hero, currentPath, visitedSections, completionPercent, easterEggsFound, challengeCompleted, contactUnlocked, narratorStage, choices, consentGiven - And la persistance LocalStorage est activée via
pinia-plugin-persistedstate(uniquement après consentement) - And le store est compatible SSR (initialisation vide côté serveur, réhydratation client)
- And si une progression existante est détectée, un message "Bienvenue à nouveau" est affiché
- And l'action
$reset()permet de réinitialiser la progression
Tasks / Subtasks
-
Task 1: Installation pinia-plugin-persistedstate (AC: #3)
- Vérifier que
pinia-plugin-persistedstateest installé (Story 1.1) - Configurer le plugin dans
frontend/app/plugins/pinia.ts - S'assurer de la compatibilité SSR (pas de localStorage côté serveur)
- Vérifier que
-
Task 2: Création du store useProgressionStore (AC: #2, #4)
- Créer
frontend/app/stores/progression.ts - Définir l'interface
ProgressionStateavec tous les champs requis - Implémenter le state initial (valeurs par défaut)
- Générer
sessionIdavec UUID v4 (côté client uniquement) - Compatibilité SSR : state vide côté serveur
- Créer
-
Task 3: Actions du store (AC: #2, #6)
setHero(hero: HeroType): définir le héros choisivisitSection(section: string): ajouter une section visitée, recalculer %findEasterEgg(slug: string): ajouter un easter egg trouvécompleteChallenge(): marquer le challenge comme complétéunlockContact(): débloquer l'accès au contactupdateNarratorStage(stage: number): évolution du narrateurmakeChoice(choiceId: string, value: string): enregistrer un choix narratifsetConsent(given: boolean): définir le consentement RGPD$reset(): réinitialiser toute la progression
-
Task 4: Getters du store (AC: #2)
hasVisited(section: string): vérifier si une section a été visitéeisContactUnlocked: contact débloqué (2+ sections visitées)progressPercent: pourcentage de complétion calculéhasExistingProgress: progression existante détectée
-
Task 5: Persistance conditionnelle (AC: #3, #4)
- Configurer
persistavec condition surconsentGiven - Key localStorage :
skycel-progression - Exclure certains champs de la persistance si nécessaire
- Gérer la réhydratation client après SSR
- Configurer
-
Task 6: Composant ConsentBanner immersif (AC: #1)
- Créer
frontend/app/components/layout/ConsentBanner.vue - Style narratif : dialogue du narrateur (araignée) ou message immersif
- Texte : "Pour mémoriser ton aventure, j'ai besoin de ton accord..."
- Deux boutons : "Accepter" et "Refuser" (style cohérent)
- Animation d'apparition subtile
- Position : bas de l'écran, overlay semi-transparent
- Créer
-
Task 7: Intégration ConsentBanner dans le layout (AC: #1)
- Ajouter
<ConsentBanner />danslayouts/default.vue - Afficher uniquement si
!store.consentGivenET côté client - Après acceptation : activer la persistance, masquer le bandeau
- Après refus : masquer le bandeau, ne pas persister (mais store fonctionne en mémoire)
- Ajouter
-
Task 8: Message "Bienvenue à nouveau" (AC: #5)
- Détecter si
hasExistingProgressau chargement (côté client) - Si oui, afficher un message via le narrateur ou une notification discrète
- Proposer optionnellement de recommencer (
$reset()) - Ce message sera affiné en Epic 3 avec le composant NarratorBubble
- Détecter si
-
Task 9: Calcul de la progression (AC: #2)
- Définir les sections comptabilisées : projets, competences, temoignages, parcours
- Formule :
(visitedSections.length / totalSections) * 100 - Mettre à jour
completionPercentautomatiquement via le getter ou action - Trigger
unlockContactsi >= 2 sections visitées
-
Task 10: Tests et validation (AC: tous)
- Store accessible dans les composants via
useProgressionStore() - Persistance fonctionne après acceptation RGPD
- Pas de persistance si refus (mais store en mémoire OK)
- Réinitialisation fonctionne
- Compatible SSR (pas d'erreur hydration mismatch)
- ConsentBanner s'affiche correctement
- Message "Bienvenue à nouveau" fonctionne
- Store accessible dans les composants via
Dev Notes
Interface du store
// frontend/app/stores/progression.ts
import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
export type HeroType = 'recruteur' | 'client' | 'dev'
export interface ProgressionState {
sessionId: string
hero: HeroType | null
currentPath: string
visitedSections: string[]
completionPercent: number
easterEggsFound: string[]
challengeCompleted: boolean
contactUnlocked: boolean
narratorStage: number // 1-5
choices: Record<string, string>
consentGiven: boolean | null // null = pas encore demandé
}
const SECTIONS = ['projets', 'competences', 'temoignages', 'parcours']
export const useProgressionStore = defineStore('progression', {
state: (): ProgressionState => ({
sessionId: '',
hero: null,
currentPath: 'start',
visitedSections: [],
completionPercent: 0,
easterEggsFound: [],
challengeCompleted: false,
contactUnlocked: false,
narratorStage: 1,
choices: {},
consentGiven: null,
}),
getters: {
hasVisited: (state) => (section: string) => state.visitedSections.includes(section),
isContactUnlocked: (state) => state.visitedSections.length >= 2 || state.contactUnlocked,
progressPercent: (state) => Math.round((state.visitedSections.length / SECTIONS.length) * 100),
hasExistingProgress: (state) => state.visitedSections.length > 0 || state.hero !== null,
},
actions: {
initSession() {
if (!this.sessionId && import.meta.client) {
this.sessionId = uuidv4()
}
},
setHero(hero: HeroType) {
this.hero = hero
},
visitSection(section: string) {
if (!this.visitedSections.includes(section)) {
this.visitedSections.push(section)
this.completionPercent = this.progressPercent
// Auto-unlock contact after 2 sections
if (this.visitedSections.length >= 2) {
this.contactUnlocked = true
}
}
},
findEasterEgg(slug: string) {
if (!this.easterEggsFound.includes(slug)) {
this.easterEggsFound.push(slug)
}
},
completeChallenge() {
this.challengeCompleted = true
},
unlockContact() {
this.contactUnlocked = true
},
updateNarratorStage(stage: number) {
if (stage >= 1 && stage <= 5) {
this.narratorStage = stage
}
},
makeChoice(choiceId: string, value: string) {
this.choices[choiceId] = value
},
setConsent(given: boolean) {
this.consentGiven = given
if (given) {
this.initSession()
}
},
},
persist: {
key: 'skycel-progression',
storage: import.meta.client ? localStorage : undefined,
// Persister uniquement si consentement donné
beforeRestore: (ctx) => {
// La restauration se fera côté client uniquement
},
},
})
Plugin Pinia avec persistedstate
// frontend/app/plugins/pinia.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export default defineNuxtPlugin((nuxtApp) => {
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
nuxtApp.vueApp.use(pinia)
})
Composant ConsentBanner
<!-- frontend/app/components/layout/ConsentBanner.vue -->
<template>
<Transition name="consent">
<div
v-if="showBanner"
class="fixed bottom-0 inset-x-0 z-50 p-4 bg-sky-dark-50/95 backdrop-blur-sm border-t border-sky-text/10"
>
<div class="max-w-2xl mx-auto">
<!-- Style narratif - comme si le narrateur parlait -->
<div class="flex items-start gap-4">
<div class="text-3xl">🕷️</div>
<div class="flex-1">
<p class="font-narrative text-sky-text mb-4">
{{ $t('consent.message') }}
</p>
<div class="flex gap-3">
<button
class="px-6 py-2 bg-sky-accent text-sky-dark font-ui font-semibold rounded-lg hover:bg-sky-accent-hover transition-colors"
@click="acceptConsent"
>
{{ $t('consent.accept') }}
</button>
<button
class="px-6 py-2 text-sky-text/70 hover:text-sky-text font-ui transition-colors"
@click="refuseConsent"
>
{{ $t('consent.refuse') }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
const store = useProgressionStore()
const showBanner = computed(() => {
// Afficher uniquement côté client et si pas encore de choix
return import.meta.client && store.consentGiven === null
})
const acceptConsent = () => {
store.setConsent(true)
}
const refuseConsent = () => {
store.setConsent(false)
}
</script>
<style scoped>
.consent-enter-active,
.consent-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.consent-enter-from,
.consent-leave-to {
transform: translateY(100%);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.consent-enter-active,
.consent-leave-active {
transition: none;
}
}
</style>
Traductions à ajouter
// frontend/i18n/fr.json
{
"consent": {
"message": "Pour mémoriser ton aventure et te permettre de la reprendre plus tard, j'ai besoin de ton accord pour stocker quelques informations sur ton appareil. Rien de personnel, juste ta progression !",
"accept": "D'accord, mémorise mon aventure",
"refuse": "Non merci, je préfère rester anonyme"
},
"welcome_back": {
"message": "Content de te revoir, aventurier ! Tu avais commencé ton exploration...",
"continue": "Reprendre",
"restart": "Recommencer"
}
}
Intégration dans le layout
<!-- frontend/app/layouts/default.vue -->
<template>
<div class="min-h-screen bg-sky-dark text-sky-text flex flex-col">
<AppHeader />
<main class="flex-1">
<slot />
</main>
<AppFooter />
<!-- Bandeau RGPD -->
<ClientOnly>
<ConsentBanner />
</ClientOnly>
</div>
</template>
Gestion SSR
Points critiques pour la compatibilité SSR :
- UUID : Générer uniquement côté client (
import.meta.client) - localStorage : Non disponible côté serveur, utiliser
undefinedcomme storage - ConsentBanner : Wrapper dans
<ClientOnly>pour éviter les mismatches - Getters avec computed : Fonctionnent côté serveur avec valeurs par défaut
Dépendances
Cette story DÉPEND de :
- Story 1.1 : pinia-plugin-persistedstate installé
- Story 1.4 : Layout default.vue créé
Cette story PRÉPARE pour :
- Story 1.5 : setHero() appelé après sélection du héros
- Story 3.x : visitSection(), progressPercent, narratorStage utilisés
- Story 4.x : choices, findEasterEgg(), completeChallenge() utilisés
Project Structure Notes
Fichiers à créer :
frontend/app/
├── stores/
│ └── progression.ts # CRÉER
├── plugins/
│ └── pinia.ts # CRÉER (ou modifier si existe)
└── components/
└── layout/
└── ConsentBanner.vue # CRÉER
Fichiers à modifier :
frontend/
├── app/layouts/default.vue # MODIFIER (ajouter ConsentBanner)
├── i18n/fr.json # MODIFIER (ajouter traductions)
└── i18n/en.json # MODIFIER (ajouter traductions)
References
- [Source: docs/planning-artifacts/architecture.md#Store-Pinia]
- [Source: docs/planning-artifacts/architecture.md#RGPD]
- [Source: docs/planning-artifacts/ux-design-specification.md#Consent]
- [Source: docs/planning-artifacts/epics.md#Story-1.6]
- [Source: docs/prd-gamification.md#FR12]
Technical Requirements
| Requirement | Value | Source |
|---|---|---|
| Persistance | LocalStorage via pinia-plugin-persistedstate | Architecture |
| SSR compatible | Required | Architecture |
| RGPD compliant | Consentement avant persistance | Architecture |
| Session ID | UUID v4 | Architecture |
Dev Agent Record
Agent Model Used
{{agent_model_name_version}}
Debug Log References
Completion Notes List
Change Log
| Date | Change | Author |
|---|---|---|
| 2026-02-03 | Story créée avec contexte complet | SM Agent |