🎲 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:
@@ -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)
|
||||
|
||||
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>
|
||||
|
||||
<LayoutAppFooter />
|
||||
|
||||
<ClientOnly>
|
||||
<LayoutConsentBanner />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user