✨ feat(mobile): add BottomBar navigation and CheminLibre drawer (Story 3.7)
- Add ZoneCard component for zone display with status indicators - Add CheminLibre drawer with vertical zone cards and path decoration - Add BottomBar with Map, Progress, and Settings buttons - Add ProgressDetail modal showing visited sections - Add SettingsDrawer with language, consent, and reset options - Add i18n translations for zone, cheminLibre, bottomBar, settings - Add --bottom-bar-height CSS variable for spacing - Modify layouts to include BottomBar on mobile (< 768px) - Support safe-area-inset for iOS devices - Touch targets minimum 48x48px for WCAG compliance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
100
frontend/app/components/feature/CheminLibre.vue
Normal file
100
frontend/app/components/feature/CheminLibre.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { mapZones } from '~/data/mapZones'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
navigate: [route: string]
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const progressionStore = useProgressionStore()
|
||||
|
||||
function handleZoneSelect(zone: (typeof mapZones)[0]) {
|
||||
if (zone.id === 'contact' && !progressionStore.contactUnlocked) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetRoute = locale.value === 'fr' ? zone.route.fr : zone.route.en
|
||||
router.push(targetRoute)
|
||||
emit('navigate', targetRoute)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function isVisited(zoneId: string): boolean {
|
||||
return zoneId !== 'contact' && progressionStore.visitedSections.includes(zoneId)
|
||||
}
|
||||
|
||||
function isLocked(zoneId: string): boolean {
|
||||
return zoneId === 'contact' && !progressionStore.contactUnlocked
|
||||
}
|
||||
|
||||
function isCurrent(zoneId: string): boolean {
|
||||
const path = route.path.toLowerCase()
|
||||
|
||||
const routeMap: Record<string, string[]> = {
|
||||
projets: ['projets', 'projects'],
|
||||
competences: ['competences', 'skills'],
|
||||
temoignages: ['temoignages', 'testimonials'],
|
||||
parcours: ['parcours', 'journey'],
|
||||
contact: ['contact'],
|
||||
}
|
||||
|
||||
return routeMap[zoneId]?.some((segment) => path.includes(segment)) ?? false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chemin-libre h-full overflow-y-auto pb-safe">
|
||||
<!-- Header avec handle -->
|
||||
<div class="sticky top-0 bg-sky-dark-50 pt-4 pb-2 z-10">
|
||||
<div class="w-12 h-1 bg-sky-dark-100 rounded-full mx-auto mb-4" />
|
||||
<h2 class="text-xl font-ui font-bold text-sky-text text-center mb-4">
|
||||
{{ t('cheminLibre.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Liste des zones avec ligne de connexion -->
|
||||
<div class="relative px-4 pb-8">
|
||||
<!-- Ligne de connexion verticale -->
|
||||
<div class="absolute left-12 top-8 bottom-8 w-0.5 bg-sky-dark-100" />
|
||||
|
||||
<!-- Zones -->
|
||||
<div class="space-y-4 relative z-10">
|
||||
<div
|
||||
v-for="zone in mapZones"
|
||||
:key="zone.id"
|
||||
class="relative"
|
||||
>
|
||||
<!-- Point sur la ligne -->
|
||||
<div
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 z-10"
|
||||
:class="[
|
||||
isVisited(zone.id) ? 'bg-green-400 border-green-400' : '',
|
||||
isLocked(zone.id) ? 'bg-gray-500 border-gray-500' : '',
|
||||
!isVisited(zone.id) && !isLocked(zone.id) ? 'bg-sky-dark border-sky-accent' : '',
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- Card avec padding gauche pour la ligne -->
|
||||
<div class="pl-12">
|
||||
<ZoneCard
|
||||
:zone="zone"
|
||||
:is-visited="isVisited(zone.id)"
|
||||
:is-locked="isLocked(zone.id)"
|
||||
:is-current="isCurrent(zone.id)"
|
||||
@select="handleZoneSelect(zone)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom, 1rem);
|
||||
}
|
||||
</style>
|
||||
83
frontend/app/components/feature/ProgressDetail.vue
Normal file
83
frontend/app/components/feature/ProgressDetail.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const progressionStore = useProgressionStore()
|
||||
|
||||
const sections = computed(() => [
|
||||
{
|
||||
key: 'projets',
|
||||
name: t('progress.sections.projects'),
|
||||
visited: progressionStore.visitedSections.includes('projets'),
|
||||
},
|
||||
{
|
||||
key: 'competences',
|
||||
name: t('progress.sections.skills'),
|
||||
visited: progressionStore.visitedSections.includes('competences'),
|
||||
},
|
||||
{
|
||||
key: 'temoignages',
|
||||
name: t('progress.sections.testimonials'),
|
||||
visited: progressionStore.visitedSections.includes('temoignages'),
|
||||
},
|
||||
{
|
||||
key: 'parcours',
|
||||
name: t('progress.sections.journey'),
|
||||
visited: progressionStore.visitedSections.includes('parcours'),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="progress-detail">
|
||||
<!-- Handle -->
|
||||
<div class="w-12 h-1 bg-sky-dark-100 rounded-full mx-auto mb-4 md:hidden" />
|
||||
|
||||
<h2 class="text-xl font-ui font-bold text-sky-text mb-4">
|
||||
{{ t('progress.title') }}
|
||||
</h2>
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div class="mb-6">
|
||||
<ProgressBar
|
||||
:percent="progressionStore.completionPercent"
|
||||
:show-tooltip="false"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Liste des sections -->
|
||||
<ul class="space-y-3 mb-6">
|
||||
<li
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<span
|
||||
class="shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-sm"
|
||||
:class="
|
||||
section.visited
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-sky-dark-100 text-sky-text-muted'
|
||||
"
|
||||
>
|
||||
{{ section.visited ? '✓' : '○' }}
|
||||
</span>
|
||||
<span :class="section.visited ? 'text-sky-text' : 'text-sky-text-muted'">
|
||||
{{ section.name }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Bouton fermer -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-3 bg-sky-dark-100 rounded-lg text-sky-text font-ui font-medium"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
112
frontend/app/components/feature/SettingsDrawer.vue
Normal file
112
frontend/app/components/feature/SettingsDrawer.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { locale, setLocale, t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const router = useRouter()
|
||||
const progressionStore = useProgressionStore()
|
||||
|
||||
function toggleLanguage() {
|
||||
const newLocale = locale.value === 'fr' ? 'en' : 'fr'
|
||||
setLocale(newLocale)
|
||||
}
|
||||
|
||||
function resetProgress() {
|
||||
if (confirm(t('settings.confirmReset'))) {
|
||||
progressionStore.$reset()
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function revokeConsent() {
|
||||
progressionStore.setConsent(false)
|
||||
}
|
||||
|
||||
function giveConsent() {
|
||||
progressionStore.setConsent(true)
|
||||
}
|
||||
|
||||
function goToResume() {
|
||||
router.push(localePath('/resume'))
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-drawer">
|
||||
<!-- Handle -->
|
||||
<div class="w-12 h-1 bg-sky-dark-100 rounded-full mx-auto mb-4" />
|
||||
|
||||
<h2 class="text-xl font-ui font-bold text-sky-text mb-6">
|
||||
{{ t('settings.title') }}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Langue -->
|
||||
<div class="flex items-center justify-between py-3 border-b border-sky-dark-100">
|
||||
<span class="text-sky-text">{{ t('settings.language') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-sky-dark-100 rounded-lg text-sky-text font-ui"
|
||||
@click="toggleLanguage"
|
||||
>
|
||||
{{ locale === 'fr' ? 'English' : 'Français' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mode Express -->
|
||||
<div class="flex items-center justify-between py-3 border-b border-sky-dark-100">
|
||||
<div>
|
||||
<span class="text-sky-text block">{{ t('settings.expressMode') }}</span>
|
||||
<span class="text-xs text-sky-text-muted">{{ t('settings.expressModeDesc') }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-sky-dark-100 rounded-lg text-sky-text font-ui text-sm"
|
||||
@click="goToResume"
|
||||
>
|
||||
{{ t('settings.goToResume') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- RGPD -->
|
||||
<div class="flex items-center justify-between py-3 border-b border-sky-dark-100">
|
||||
<div>
|
||||
<span class="text-sky-text block">{{ t('settings.saveProgress') }}</span>
|
||||
<span class="text-xs text-sky-text-muted">{{ t('settings.saveProgressDesc') }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-12 h-6 rounded-full transition-colors"
|
||||
:class="progressionStore.consentGiven ? 'bg-sky-accent' : 'bg-sky-dark-100'"
|
||||
@click="progressionStore.consentGiven ? revokeConsent() : giveConsent()"
|
||||
>
|
||||
<span
|
||||
class="block w-5 h-5 rounded-full bg-white shadow transition-transform"
|
||||
:class="progressionStore.consentGiven ? 'translate-x-6' : 'translate-x-0.5'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Réinitialiser -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-3 bg-red-500/20 text-red-400 rounded-lg font-ui font-medium mt-4"
|
||||
@click="resetProgress"
|
||||
>
|
||||
{{ t('settings.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Fermer -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-3 bg-sky-dark-100 rounded-lg text-sky-text font-ui font-medium mt-6"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
103
frontend/app/components/feature/ZoneCard.vue
Normal file
103
frontend/app/components/feature/ZoneCard.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import type { MapZone } from '~/data/mapZones'
|
||||
|
||||
const props = defineProps<{
|
||||
zone: MapZone
|
||||
isVisited: boolean
|
||||
isLocked: boolean
|
||||
isCurrent: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: []
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const zoneName = computed(() => {
|
||||
return locale.value === 'fr' ? props.zone.label.fr : props.zone.label.en
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (props.isLocked) return t('zone.locked')
|
||||
if (props.isVisited) return t('zone.visited')
|
||||
return t('zone.new')
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (props.isLocked) return 'text-gray-500'
|
||||
if (props.isVisited) return 'text-green-400'
|
||||
return 'text-sky-accent'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="zone-card relative w-full flex items-center gap-4 p-4 bg-sky-dark-50 rounded-xl border border-sky-dark-100 transition-all active:scale-98"
|
||||
:class="[
|
||||
isCurrent && 'ring-2 ring-sky-accent',
|
||||
isLocked && 'opacity-60',
|
||||
]"
|
||||
:disabled="isLocked"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<!-- Illustration / Emoji -->
|
||||
<div
|
||||
class="shrink-0 w-16 h-16 rounded-lg flex items-center justify-center text-3xl"
|
||||
:style="{ backgroundColor: `${zone.color}20` }"
|
||||
>
|
||||
<template v-if="isLocked">
|
||||
🔒
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ zone.emoji }}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="flex-1 text-left">
|
||||
<h3 class="font-ui font-semibold text-sky-text text-lg">
|
||||
{{ zoneName }}
|
||||
</h3>
|
||||
<p class="text-sm" :class="statusClass">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Indicateurs -->
|
||||
<div class="shrink-0">
|
||||
<!-- Checkmark si visité -->
|
||||
<span
|
||||
v-if="isVisited && !isLocked"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-green-500/20 text-green-400"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<!-- Badge "Nouveau" si non visité et non verrouillé -->
|
||||
<span
|
||||
v-else-if="!isVisited && !isLocked"
|
||||
class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-sky-accent/20 text-sky-accent text-xs font-ui font-medium"
|
||||
>
|
||||
{{ t('zone.newBadge') }}
|
||||
</span>
|
||||
<!-- Cadenas si verrouillé -->
|
||||
<span
|
||||
v-else-if="isLocked"
|
||||
class="inline-flex items-center justify-center w-8 h-8 text-gray-500"
|
||||
>
|
||||
🔒
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.zone-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.zone-card:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user