🎲 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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Story 1.6: Store Pinia progression et bandeau RGPD
|
# Story 1.6: Store Pinia progression et bandeau RGPD
|
||||||
|
|
||||||
Status: ready-for-dev
|
Status: review
|
||||||
|
|
||||||
## Story
|
## Story
|
||||||
|
|
||||||
@@ -19,75 +19,75 @@ so that je peux reprendre mon exploration et mes données sont protégées.
|
|||||||
|
|
||||||
## Tasks / Subtasks
|
## Tasks / Subtasks
|
||||||
|
|
||||||
- [ ] **Task 1: Installation pinia-plugin-persistedstate** (AC: #3)
|
- [x] **Task 1: Installation pinia-plugin-persistedstate** (AC: #3)
|
||||||
- [ ] Vérifier que `pinia-plugin-persistedstate` est installé (Story 1.1)
|
- [x] Vérifier que `pinia-plugin-persistedstate` est installé (Story 1.1)
|
||||||
- [ ] Configurer le plugin dans `frontend/app/plugins/pinia.ts`
|
- [x] Configurer le plugin dans `frontend/app/plugins/pinia.ts`
|
||||||
- [ ] S'assurer de la compatibilité SSR (pas de localStorage côté serveur)
|
- [x] S'assurer de la compatibilité SSR (pas de localStorage côté serveur)
|
||||||
|
|
||||||
- [ ] **Task 2: Création du store useProgressionStore** (AC: #2, #4)
|
- [x] **Task 2: Création du store useProgressionStore** (AC: #2, #4)
|
||||||
- [ ] Créer `frontend/app/stores/progression.ts`
|
- [x] Créer `frontend/app/stores/progression.ts`
|
||||||
- [ ] Définir l'interface `ProgressionState` avec tous les champs requis
|
- [x] Définir l'interface `ProgressionState` avec tous les champs requis
|
||||||
- [ ] Implémenter le state initial (valeurs par défaut)
|
- [x] Implémenter le state initial (valeurs par défaut)
|
||||||
- [ ] Générer `sessionId` avec UUID v4 (côté client uniquement)
|
- [x] Générer `sessionId` avec UUID v4 (côté client uniquement)
|
||||||
- [ ] Compatibilité SSR : state vide côté serveur
|
- [x] Compatibilité SSR : state vide côté serveur
|
||||||
|
|
||||||
- [ ] **Task 3: Actions du store** (AC: #2, #6)
|
- [x] **Task 3: Actions du store** (AC: #2, #6)
|
||||||
- [ ] `setHero(hero: HeroType)` : définir le héros choisi
|
- [x] `setHero(hero: HeroType)` : définir le héros choisi
|
||||||
- [ ] `visitSection(section: string)` : ajouter une section visitée, recalculer %
|
- [x] `visitSection(section: string)` : ajouter une section visitée, recalculer %
|
||||||
- [ ] `findEasterEgg(slug: string)` : ajouter un easter egg trouvé
|
- [x] `findEasterEgg(slug: string)` : ajouter un easter egg trouvé
|
||||||
- [ ] `completeChallenge()` : marquer le challenge comme complété
|
- [x] `completeChallenge()` : marquer le challenge comme complété
|
||||||
- [ ] `unlockContact()` : débloquer l'accès au contact
|
- [x] `unlockContact()` : débloquer l'accès au contact
|
||||||
- [ ] `updateNarratorStage(stage: number)` : évolution du narrateur
|
- [x] `updateNarratorStage(stage: number)` : évolution du narrateur
|
||||||
- [ ] `makeChoice(choiceId: string, value: string)` : enregistrer un choix narratif
|
- [x] `makeChoice(choiceId: string, value: string)` : enregistrer un choix narratif
|
||||||
- [ ] `setConsent(given: boolean)` : définir le consentement RGPD
|
- [x] `setConsent(given: boolean)` : définir le consentement RGPD
|
||||||
- [ ] `$reset()` : réinitialiser toute la progression
|
- [x] `$reset()` : réinitialiser toute la progression
|
||||||
|
|
||||||
- [ ] **Task 4: Getters du store** (AC: #2)
|
- [x] **Task 4: Getters du store** (AC: #2)
|
||||||
- [ ] `hasVisited(section: string)` : vérifier si une section a été visitée
|
- [x] `hasVisited(section: string)` : vérifier si une section a été visitée
|
||||||
- [ ] `isContactUnlocked` : contact débloqué (2+ sections visitées)
|
- [x] `isContactUnlocked` : contact débloqué (2+ sections visitées)
|
||||||
- [ ] `progressPercent` : pourcentage de complétion calculé
|
- [x] `progressPercent` : pourcentage de complétion calculé
|
||||||
- [ ] `hasExistingProgress` : progression existante détectée
|
- [x] `hasExistingProgress` : progression existante détectée
|
||||||
|
|
||||||
- [ ] **Task 5: Persistance conditionnelle** (AC: #3, #4)
|
- [x] **Task 5: Persistance conditionnelle** (AC: #3, #4)
|
||||||
- [ ] Configurer `persist` avec condition sur `consentGiven`
|
- [x] Configurer `persist` avec condition sur `consentGiven`
|
||||||
- [ ] Key localStorage : `skycel-progression`
|
- [x] Key localStorage : `skycel-progression`
|
||||||
- [ ] Exclure certains champs de la persistance si nécessaire
|
- [x] Exclure certains champs de la persistance si nécessaire
|
||||||
- [ ] Gérer la réhydratation client après SSR
|
- [x] Gérer la réhydratation client après SSR
|
||||||
|
|
||||||
- [ ] **Task 6: Composant ConsentBanner immersif** (AC: #1)
|
- [x] **Task 6: Composant ConsentBanner immersif** (AC: #1)
|
||||||
- [ ] Créer `frontend/app/components/layout/ConsentBanner.vue`
|
- [x] Créer `frontend/app/components/layout/ConsentBanner.vue`
|
||||||
- [ ] Style narratif : dialogue du narrateur (araignée) ou message immersif
|
- [x] Style narratif : dialogue du narrateur (araignée) ou message immersif
|
||||||
- [ ] Texte : "Pour mémoriser ton aventure, j'ai besoin de ton accord..."
|
- [x] Texte : "Pour mémoriser ton aventure, j'ai besoin de ton accord..."
|
||||||
- [ ] Deux boutons : "Accepter" et "Refuser" (style cohérent)
|
- [x] Deux boutons : "Accepter" et "Refuser" (style cohérent)
|
||||||
- [ ] Animation d'apparition subtile
|
- [x] Animation d'apparition subtile
|
||||||
- [ ] Position : bas de l'écran, overlay semi-transparent
|
- [x] Position : bas de l'écran, overlay semi-transparent
|
||||||
|
|
||||||
- [ ] **Task 7: Intégration ConsentBanner dans le layout** (AC: #1)
|
- [x] **Task 7: Intégration ConsentBanner dans le layout** (AC: #1)
|
||||||
- [ ] Ajouter `<ConsentBanner />` dans `layouts/default.vue`
|
- [x] Ajouter `<ConsentBanner />` dans `layouts/default.vue`
|
||||||
- [ ] Afficher uniquement si `!store.consentGiven` ET côté client
|
- [x] Afficher uniquement si `!store.consentGiven` ET côté client
|
||||||
- [ ] Après acceptation : activer la persistance, masquer le bandeau
|
- [x] Après acceptation : activer la persistance, masquer le bandeau
|
||||||
- [ ] Après refus : masquer le bandeau, ne pas persister (mais store fonctionne en mémoire)
|
- [x] Après refus : masquer le bandeau, ne pas persister (mais store fonctionne en mémoire)
|
||||||
|
|
||||||
- [ ] **Task 8: Message "Bienvenue à nouveau"** (AC: #5)
|
- [x] **Task 8: Message "Bienvenue à nouveau"** (AC: #5)
|
||||||
- [ ] Détecter si `hasExistingProgress` au chargement (côté client)
|
- [x] Détecter si `hasExistingProgress` au chargement (côté client)
|
||||||
- [ ] Si oui, afficher un message via le narrateur ou une notification discrète
|
- [x] Si oui, afficher un message via le narrateur ou une notification discrète
|
||||||
- [ ] Proposer optionnellement de recommencer (`$reset()`)
|
- [x] Proposer optionnellement de recommencer (`$reset()`)
|
||||||
- [ ] Ce message sera affiné en Epic 3 avec le composant NarratorBubble
|
- [x] Ce message sera affiné en Epic 3 avec le composant NarratorBubble
|
||||||
|
|
||||||
- [ ] **Task 9: Calcul de la progression** (AC: #2)
|
- [x] **Task 9: Calcul de la progression** (AC: #2)
|
||||||
- [ ] Définir les sections comptabilisées : projets, competences, temoignages, parcours
|
- [x] Définir les sections comptabilisées : projets, competences, temoignages, parcours
|
||||||
- [ ] Formule : `(visitedSections.length / totalSections) * 100`
|
- [x] Formule : `(visitedSections.length / totalSections) * 100`
|
||||||
- [ ] Mettre à jour `completionPercent` automatiquement via le getter ou action
|
- [x] Mettre à jour `completionPercent` automatiquement via le getter ou action
|
||||||
- [ ] Trigger `unlockContact` si >= 2 sections visitées
|
- [x] Trigger `unlockContact` si >= 2 sections visitées
|
||||||
|
|
||||||
- [ ] **Task 10: Tests et validation** (AC: tous)
|
- [x] **Task 10: Tests et validation** (AC: tous)
|
||||||
- [ ] Store accessible dans les composants via `useProgressionStore()`
|
- [x] Store accessible dans les composants via `useProgressionStore()`
|
||||||
- [ ] Persistance fonctionne après acceptation RGPD
|
- [x] Persistance fonctionne après acceptation RGPD
|
||||||
- [ ] Pas de persistance si refus (mais store en mémoire OK)
|
- [x] Pas de persistance si refus (mais store en mémoire OK)
|
||||||
- [ ] Réinitialisation fonctionne
|
- [x] Réinitialisation fonctionne
|
||||||
- [ ] Compatible SSR (pas d'erreur hydration mismatch)
|
- [x] Compatible SSR (pas d'erreur hydration mismatch)
|
||||||
- [ ] ConsentBanner s'affiche correctement
|
- [x] ConsentBanner s'affiche correctement
|
||||||
- [ ] Message "Bienvenue à nouveau" fonctionne
|
- [x] Message "Bienvenue à nouveau" fonctionne
|
||||||
|
|
||||||
## Dev Notes
|
## Dev Notes
|
||||||
|
|
||||||
@@ -401,16 +401,41 @@ frontend/
|
|||||||
|
|
||||||
### Agent Model Used
|
### Agent Model Used
|
||||||
|
|
||||||
{{agent_model_name_version}}
|
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||||
|
|
||||||
### Debug Log References
|
### Debug Log References
|
||||||
|
|
||||||
|
- Aucun problème majeur. Build SSR validé, store sérialisé correctement dans __NUXT_DATA__.
|
||||||
|
|
||||||
### Completion Notes List
|
### 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
|
### Change Log
|
||||||
| Date | Change | Author |
|
| Date | Change | Author |
|
||||||
|------|--------|--------|
|
|------|--------|--------|
|
||||||
| 2026-02-03 | Story créée avec contexte complet | SM Agent |
|
| 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
|
### 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.*)
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ development_status:
|
|||||||
1-3-systeme-i18n-frontend-api-bilingue: review
|
1-3-systeme-i18n-frontend-api-bilingue: review
|
||||||
1-4-layouts-routing-transitions-page: review
|
1-4-layouts-routing-transitions-page: review
|
||||||
1-5-landing-page-choix-heros: review
|
1-5-landing-page-choix-heros: review
|
||||||
1-6-store-pinia-progression-bandeau-rgpd: ready-for-dev
|
1-6-store-pinia-progression-bandeau-rgpd: review
|
||||||
1-7-page-resume-express-mode-presse: ready-for-dev
|
1-7-page-resume-express-mode-presse: ready-for-dev
|
||||||
epic-1-retrospective: optional
|
epic-1-retrospective: optional
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,12 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- Placeholder barre progression (Epic 3) -->
|
<!-- Barre de progression -->
|
||||||
<div class="hidden md:block w-24 h-1.5 bg-sky-text/10 rounded-full overflow-hidden">
|
<div class="hidden md:block w-24 h-1.5 bg-sky-text/10 rounded-full overflow-hidden">
|
||||||
<div class="h-full bg-sky-accent/40 rounded-full" style="width: 0%"></div>
|
<div
|
||||||
|
class="h-full bg-sky-accent/40 rounded-full transition-all duration-500"
|
||||||
|
:style="{ width: progressPercent + '%' }"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UiLanguageSwitcher />
|
<UiLanguageSwitcher />
|
||||||
@@ -78,7 +81,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useProgressionStore } from '~/stores/progression'
|
||||||
|
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
|
const store = useProgressionStore()
|
||||||
|
|
||||||
|
const progressPercent = computed(() => store.progressPercent)
|
||||||
|
|
||||||
const mobileOpen = ref(false)
|
const mobileOpen = ref(false)
|
||||||
const scrolled = ref(false)
|
const scrolled = ref(false)
|
||||||
|
|||||||
73
frontend/app/components/layout/ConsentBanner.vue
Normal file
73
frontend/app/components/layout/ConsentBanner.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="consent">
|
||||||
|
<div
|
||||||
|
v-if="showBanner"
|
||||||
|
class="fixed bottom-0 inset-x-0 z-50 p-4 md:p-6 bg-sky-dark/95 backdrop-blur-sm border-t border-sky-text/10"
|
||||||
|
role="dialog"
|
||||||
|
:aria-label="$t('consent.aria_label')"
|
||||||
|
>
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="text-3xl shrink-0" aria-hidden="true">🕷️</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-narrative text-sky-text text-sm md:text-base mb-4">
|
||||||
|
{{ $t('consent.message') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
class="px-6 py-2 bg-sky-accent text-sky-dark font-ui font-semibold rounded-lg hover:opacity-90 transition-opacity"
|
||||||
|
@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">
|
||||||
|
import { useProgressionStore } from '~/stores/progression'
|
||||||
|
|
||||||
|
const store = useProgressionStore()
|
||||||
|
|
||||||
|
const showBanner = computed(() => {
|
||||||
|
return import.meta.client && store.consentGiven === null
|
||||||
|
})
|
||||||
|
|
||||||
|
function acceptConsent() {
|
||||||
|
store.setConsent(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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>
|
||||||
74
frontend/app/components/layout/WelcomeBack.vue
Normal file
74
frontend/app/components/layout/WelcomeBack.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="welcome">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="fixed top-20 right-4 z-40 max-w-sm p-4 bg-sky-dark/95 backdrop-blur-sm border border-sky-accent/30 rounded-xl shadow-lg"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="text-2xl shrink-0" aria-hidden="true">🕷️</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-narrative text-sky-text text-sm mb-3">
|
||||||
|
{{ $t('welcome_back.message') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="px-4 py-1.5 bg-sky-accent text-sky-dark font-ui text-sm font-semibold rounded-lg hover:opacity-90 transition-opacity"
|
||||||
|
@click="dismiss"
|
||||||
|
>
|
||||||
|
{{ $t('welcome_back.continue') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-1.5 text-sky-text/70 hover:text-sky-text font-ui text-sm transition-colors"
|
||||||
|
@click="restart"
|
||||||
|
>
|
||||||
|
{{ $t('welcome_back.restart') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useProgressionStore } from '~/stores/progression'
|
||||||
|
|
||||||
|
const store = useProgressionStore()
|
||||||
|
const visible = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (store.hasExistingProgress) {
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
store.$reset()
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.welcome-enter-active,
|
||||||
|
.welcome-leave-active {
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-enter-from,
|
||||||
|
.welcome-leave-to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.welcome-enter-active,
|
||||||
|
.welcome-leave-active {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,5 +7,9 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<LayoutAppFooter />
|
<LayoutAppFooter />
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
|
<LayoutConsentBanner />
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -34,15 +34,21 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
|
<LayoutWelcomeBack />
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HeroType } from '~/components/feature/HeroSelector.vue'
|
import type { HeroType } from '~/components/feature/HeroSelector.vue'
|
||||||
|
import { useProgressionStore } from '~/stores/progression'
|
||||||
|
|
||||||
const { setPageMeta } = useSeo()
|
const { setPageMeta } = useSeo()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
|
const store = useProgressionStore()
|
||||||
|
|
||||||
const showHeroSelector = ref(false)
|
const showHeroSelector = ref(false)
|
||||||
const selectedHero = ref<HeroType | null>(null)
|
const selectedHero = ref<HeroType | null>(null)
|
||||||
@@ -53,8 +59,9 @@ setPageMeta({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function onHeroConfirm() {
|
function onHeroConfirm() {
|
||||||
// Story 1.6 ajoutera : store.setHero(selectedHero.value)
|
if (selectedHero.value) {
|
||||||
// Pour l'instant, naviguer vers la page projets
|
store.setHero(selectedHero.value)
|
||||||
|
}
|
||||||
navigateTo(localePath('/projets'))
|
navigateTo(localePath('/projets'))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
5
frontend/app/plugins/pinia-persistedstate.ts
Normal file
5
frontend/app/plugins/pinia-persistedstate.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
nuxtApp.$pinia.use(piniaPluginPersistedstate)
|
||||||
|
})
|
||||||
149
frontend/app/stores/progression.ts
Normal file
149
frontend/app/stores/progression.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
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
|
||||||
|
choices: Record<string, string>
|
||||||
|
consentGiven: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS = ['projets', 'competences', 'temoignages', 'parcours'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage conditionnel : ne persiste que si consentGiven === true.
|
||||||
|
* La lecture est toujours autorisée (pour restaurer une session existante).
|
||||||
|
*/
|
||||||
|
const conditionalStorage: Storage = {
|
||||||
|
get length() {
|
||||||
|
return import.meta.client ? localStorage.length : 0
|
||||||
|
},
|
||||||
|
key(index: number) {
|
||||||
|
return import.meta.client ? localStorage.key(index) : null
|
||||||
|
},
|
||||||
|
getItem(key: string) {
|
||||||
|
if (import.meta.client) {
|
||||||
|
return localStorage.getItem(key)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
if (import.meta.client) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
if (parsed.consentGiven === true) {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Si parsing échoue, ne pas persister
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeItem(key: string) {
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.clear()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = crypto.randomUUID()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setHero(hero: HeroType) {
|
||||||
|
this.hero = hero
|
||||||
|
this.initSession()
|
||||||
|
},
|
||||||
|
|
||||||
|
visitSection(section: string) {
|
||||||
|
if (!this.visitedSections.includes(section)) {
|
||||||
|
this.visitedSections.push(section)
|
||||||
|
this.completionPercent = this.progressPercent
|
||||||
|
|
||||||
|
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()
|
||||||
|
} else if (import.meta.client) {
|
||||||
|
// Si refus, supprimer les données stockées
|
||||||
|
localStorage.removeItem('skycel-progression')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
persist: {
|
||||||
|
key: 'skycel-progression',
|
||||||
|
storage: conditionalStorage,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -52,6 +52,17 @@
|
|||||||
"copyright": "© {year} Célian — Skycel",
|
"copyright": "© {year} Célian — Skycel",
|
||||||
"built_with": "Built with Nuxt & Laravel"
|
"built_with": "Built with Nuxt & Laravel"
|
||||||
},
|
},
|
||||||
|
"consent": {
|
||||||
|
"aria_label": "Consent banner",
|
||||||
|
"message": "To save your adventure and let you pick up where you left off, I need your permission to store a few details on your device. Nothing personal, just your progress!",
|
||||||
|
"accept": "Sure, save my adventure",
|
||||||
|
"refuse": "No thanks, I prefer to stay anonymous"
|
||||||
|
},
|
||||||
|
"welcome_back": {
|
||||||
|
"message": "Welcome back, adventurer! You had started your exploration...",
|
||||||
|
"continue": "Continue",
|
||||||
|
"restart": "Start over"
|
||||||
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"projects": {
|
"projects": {
|
||||||
"title": "Projects",
|
"title": "Projects",
|
||||||
|
|||||||
@@ -52,6 +52,17 @@
|
|||||||
"copyright": "© {year} Célian — Skycel",
|
"copyright": "© {year} Célian — Skycel",
|
||||||
"built_with": "Construit avec Nuxt & Laravel"
|
"built_with": "Construit avec Nuxt & Laravel"
|
||||||
},
|
},
|
||||||
|
"consent": {
|
||||||
|
"aria_label": "Bandeau de consentement",
|
||||||
|
"message": "Pour m\u00e9moriser 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\u00e9morise mon aventure",
|
||||||
|
"refuse": "Non merci, je pr\u00e9f\u00e8re rester anonyme"
|
||||||
|
},
|
||||||
|
"welcome_back": {
|
||||||
|
"message": "Content de te revoir, aventurier ! Tu avais commenc\u00e9 ton exploration...",
|
||||||
|
"continue": "Reprendre",
|
||||||
|
"restart": "Recommencer"
|
||||||
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"projects": {
|
"projects": {
|
||||||
"title": "Projets",
|
"title": "Projets",
|
||||||
|
|||||||
Reference in New Issue
Block a user