Files
Portfolio-Game/docs/implementation-artifacts/1-6-store-pinia-progression-bandeau-rgpd.md
skycel 9fd66def12 🎲 Add Pinia progression store & GDPR consent banner (Story 1.6)
Implements useProgressionStore with conditional localStorage persistence
(only after RGPD consent), immersive ConsentBanner with narrator style,
WelcomeBack component for returning visitors, and connects progress bar
in header to store.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:00:49 +01:00

15 KiB

Story 1.6: Store Pinia progression et bandeau RGPD

Status: review

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

  1. 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)
  2. And le store Pinia useProgressionStore est initialisé avec : sessionId (UUID v4), hero, currentPath, visitedSections, completionPercent, easterEggsFound, challengeCompleted, contactUnlocked, narratorStage, choices, consentGiven
  3. And la persistance LocalStorage est activée via pinia-plugin-persistedstate (uniquement après consentement)
  4. And le store est compatible SSR (initialisation vide côté serveur, réhydratation client)
  5. And si une progression existante est détectée, un message "Bienvenue à nouveau" est affiché
  6. 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-persistedstate est 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)
  • Task 2: Création du store useProgressionStore (AC: #2, #4)

    • Créer frontend/app/stores/progression.ts
    • Définir l'interface ProgressionState avec tous les champs requis
    • Implémenter le state initial (valeurs par défaut)
    • Générer sessionId avec UUID v4 (côté client uniquement)
    • Compatibilité SSR : state vide côté serveur
  • Task 3: Actions du store (AC: #2, #6)

    • setHero(hero: HeroType) : définir le héros choisi
    • visitSection(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 contact
    • updateNarratorStage(stage: number) : évolution du narrateur
    • makeChoice(choiceId: string, value: string) : enregistrer un choix narratif
    • setConsent(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ée
    • isContactUnlocked : 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 persist avec condition sur consentGiven
    • Key localStorage : skycel-progression
    • Exclure certains champs de la persistance si nécessaire
    • Gérer la réhydratation client après SSR
  • 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
  • Task 7: Intégration ConsentBanner dans le layout (AC: #1)

    • Ajouter <ConsentBanner /> dans layouts/default.vue
    • Afficher uniquement si !store.consentGiven ET 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)
  • Task 8: Message "Bienvenue à nouveau" (AC: #5)

    • Détecter si hasExistingProgress au 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
  • 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 completionPercent automatiquement via le getter ou action
    • Trigger unlockContact si >= 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

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 :

  1. UUID : Générer uniquement côté client (import.meta.client)
  2. localStorage : Non disponible côté serveur, utiliser undefined comme storage
  3. ConsentBanner : Wrapper dans <ClientOnly> pour éviter les mismatches
  4. 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

Claude Opus 4.5 (claude-opus-4-5-20251101)

Debug Log References

  • Aucun problème majeur. Build SSR validé, store sérialisé correctement dans NUXT_DATA.

Completion Notes List

  • Plugin pinia-persistedstate configuré via Nuxt plugin (accès à $pinia existant de @pinia/nuxt)
  • Store useProgressionStore avec state complet (sessionId, hero, visitedSections, etc.)
  • Persistance conditionnelle : custom Storage qui ne persiste que si consentGiven === true
  • UUID via crypto.randomUUID() (natif, pas de dépendance externe)
  • ConsentBanner immersif avec emoji araignée narrateur, style dialogue, animation slide-up
  • WelcomeBack composant pour visiteurs de retour avec option "Reprendre" ou "Recommencer"
  • Barre de progression dans AppHeader connectée au store (progressPercent)
  • Intégration store dans landing page (setHero au confirm)
  • ClientOnly pour ConsentBanner et WelcomeBack (évite hydration mismatch)
  • prefers-reduced-motion respecté sur les animations du consent banner
  • Traductions FR/EN ajoutées pour consent.* et welcome_back.*

Change Log

Date Change Author
2026-02-03 Story créée avec contexte complet SM Agent
2026-02-05 Tasks 1-10 implémentées et validées Dev Agent (Claude Opus 4.5)

File List

  • frontend/app/plugins/pinia-persistedstate.ts — CRÉÉ
  • frontend/app/stores/progression.ts — CRÉÉ
  • frontend/app/components/layout/ConsentBanner.vue — CRÉÉ
  • frontend/app/components/layout/WelcomeBack.vue — CRÉÉ
  • frontend/app/layouts/default.vue — MODIFIÉ (ajout ConsentBanner)
  • frontend/app/pages/index.vue — MODIFIÉ (intégration store)
  • frontend/app/components/layout/AppHeader.vue — MODIFIÉ (barre progression connectée au store)
  • frontend/i18n/fr.json — MODIFIÉ (ajout consent., welcome_back.)
  • frontend/i18n/en.json — MODIFIÉ (ajout consent., welcome_back.)