🎲 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:
2026-02-05 21:00:49 +01:00
parent dc3456bb1b
commit 9fd66def12
11 changed files with 434 additions and 67 deletions

View File

@@ -22,9 +22,12 @@
</nav>
<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="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>
<UiLanguageSwitcher />
@@ -78,7 +81,12 @@
</template>
<script setup lang="ts">
import { useProgressionStore } from '~/stores/progression'
const localePath = useLocalePath()
const store = useProgressionStore()
const progressPercent = computed(() => store.progressPercent)
const mobileOpen = ref(false)
const scrolled = ref(false)

View 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">&#x1f577;&#xfe0f;</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>

View 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">&#x1f577;&#xfe0f;</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>

View File

@@ -7,5 +7,9 @@
</main>
<LayoutAppFooter />
<ClientOnly>
<LayoutConsentBanner />
</ClientOnly>
</div>
</template>

View File

@@ -34,15 +34,21 @@
/>
</div>
</Transition>
<ClientOnly>
<LayoutWelcomeBack />
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import type { HeroType } from '~/components/feature/HeroSelector.vue'
import { useProgressionStore } from '~/stores/progression'
const { setPageMeta } = useSeo()
const { t } = useI18n()
const localePath = useLocalePath()
const store = useProgressionStore()
const showHeroSelector = ref(false)
const selectedHero = ref<HeroType | null>(null)
@@ -53,8 +59,9 @@ setPageMeta({
})
function onHeroConfirm() {
// Story 1.6 ajoutera : store.setHero(selectedHero.value)
// Pour l'instant, naviguer vers la page projets
if (selectedHero.value) {
store.setHero(selectedHero.value)
}
navigateTo(localePath('/projets'))
}
</script>

View File

@@ -0,0 +1,5 @@
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.$pinia.use(piniaPluginPersistedstate)
})

View 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,
},
})

View File

@@ -52,6 +52,17 @@
"copyright": "© {year} Célian — Skycel",
"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": {
"projects": {
"title": "Projects",

View File

@@ -52,6 +52,17 @@
"copyright": "© {year} Célian — Skycel",
"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": {
"projects": {
"title": "Projets",