🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)

Setup complet de l'infrastructure projet :
- Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS)
- Backend Laravel 12 API-only avec middleware X-API-Key et CORS
- Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter)
- Documentation planning et implementation artifacts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 02:08:56 +01:00
commit ec1ae92799
116 changed files with 55669 additions and 0 deletions

View File

@@ -0,0 +1,662 @@
# Story 4.5: Easter eggs - Implémentation UI et collection
Status: ready-for-dev
## Story
As a visiteur curieux,
I want découvrir des surprises cachées et voir ma collection,
so that l'exploration est récompensée.
## Acceptance Criteria
1. **Given** des easter eggs sont placés sur différentes pages **When** le visiteur déclenche un easter egg (clic, hover, konami, scroll, sequence) **Then** une animation de découverte s'affiche (popup, effet visuel)
2. **And** la récompense est affichée (snippet de code, anecdote, image, badge)
3. **And** le narrateur réagit avec enthousiasme
4. **And** une notification "Easter egg trouvé ! (X/Y)" s'affiche
5. **And** le slug est ajouté à `easterEggsFound` dans le store
6. **And** un bouton permet de fermer et continuer
7. **Given** le visiteur accède à sa collection (via paramètres ou zone dédiée) **When** la collection s'affiche **Then** une grille montre les easter eggs trouvés et des silhouettes mystère pour les non-trouvés
8. **And** les détails sont visibles pour les découverts
9. **And** un compteur X/Y indique la progression
10. **And** un badge spécial s'affiche si 100% trouvés
## Tasks / Subtasks
- [ ] **Task 1: Créer le composable useEasterEggDetection** (AC: #1)
- [ ] Créer `frontend/app/composables/useEasterEggDetection.ts`
- [ ] Détecter les différents types de triggers
- [ ] Hook pour écouter le Konami code
- [ ] Hook pour séquences de clics
- [ ] Détecter scroll en bas de page
- [ ] **Task 2: Créer le composant EasterEggPopup** (AC: #1, #2, #6)
- [ ] Créer `frontend/app/components/feature/EasterEggPopup.vue`
- [ ] Modal avec animation de découverte
- [ ] Afficher la récompense selon le type (snippet, anecdote, image, badge)
- [ ] Bouton fermer
- [ ] **Task 3: Créer le composant EasterEggNotification** (AC: #4)
- [ ] Créer `frontend/app/components/feature/EasterEggNotification.vue`
- [ ] Toast notification "Easter egg trouvé ! (X/Y)"
- [ ] Animation d'apparition/disparition
- [ ] Position non-bloquante
- [ ] **Task 4: Intégrer le narrateur** (AC: #3)
- [ ] Ajouter contexte `easter_egg_found` dans l'API narrateur
- [ ] Le narrateur réagit avec enthousiasme
- [ ] Message différent selon le type de récompense
- [ ] **Task 5: Créer le composant EasterEggCollection** (AC: #7, #8, #9, #10)
- [ ] Créer `frontend/app/components/feature/EasterEggCollection.vue`
- [ ] Grille d'easter eggs (trouvés vs mystères)
- [ ] Compteur X/Y
- [ ] Badge spécial si 100%
- [ ] **Task 6: Placer les détecteurs sur les pages** (AC: #1)
- [ ] Header : araignée cachée (click)
- [ ] Landing : Konami code
- [ ] Projets : commentaire secret (hover)
- [ ] Parcours : scroll bottom + hover date
- [ ] Compétences : séquence tech
- [ ] Global : clics logo
- [ ] **Task 7: Intégrer dans les paramètres/settings** (AC: #7)
- [ ] Ajouter un onglet ou section "Collection"
- [ ] Accessible depuis le drawer des paramètres mobile
- [ ] Accessible depuis le menu desktop
- [ ] **Task 8: Tests et validation**
- [ ] Tester chaque type de trigger
- [ ] Vérifier l'affichage des récompenses
- [ ] Tester la collection
- [ ] Valider le compteur
- [ ] Tester le badge 100%
## Dev Notes
### Composable useEasterEggDetection
```typescript
// frontend/app/composables/useEasterEggDetection.ts
import type { EasterEggMeta } from './useFetchEasterEggs'
interface UseEasterEggDetectionOptions {
onFound: (slug: string) => void
}
// Konami Code : ↑↑↓↓←→←→BA
const KONAMI_CODE = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'KeyB', 'KeyA']
export function useEasterEggDetection(options: UseEasterEggDetectionOptions) {
const { fetchList, getByLocation } = useFetchEasterEggs()
const progressionStore = useProgressionStore()
// État
const konamiIndex = ref(0)
const clickSequence = ref<string[]>([])
// Charger les easter eggs au montage
onMounted(() => {
fetchList()
initKonamiListener()
})
// === Konami Code ===
function initKonamiListener() {
window.addEventListener('keydown', handleKonamiKey)
}
function handleKonamiKey(e: KeyboardEvent) {
if (e.code === KONAMI_CODE[konamiIndex.value]) {
konamiIndex.value++
if (konamiIndex.value === KONAMI_CODE.length) {
triggerEasterEgg('konami-master')
konamiIndex.value = 0
}
} else {
konamiIndex.value = 0
}
}
// === Click Detection ===
function detectClick(elementId: string, targetSlug: string, requiredClicks: number = 1) {
const clicks = ref(0)
function handleClick() {
clicks.value++
if (clicks.value >= requiredClicks) {
triggerEasterEgg(targetSlug)
clicks.value = 0
}
}
return { handleClick, clicks }
}
// === Hover Detection ===
function detectHover(targetSlug: string, hoverTime: number = 2000) {
let timeoutId: ReturnType<typeof setTimeout> | null = null
function handleMouseEnter() {
timeoutId = setTimeout(() => {
triggerEasterEgg(targetSlug)
}, hoverTime)
}
function handleMouseLeave() {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
}
return { handleMouseEnter, handleMouseLeave }
}
// === Scroll Detection ===
function detectScrollBottom(targetSlug: string) {
function checkScroll() {
const scrollTop = window.scrollY
const windowHeight = window.innerHeight
const docHeight = document.documentElement.scrollHeight
if (scrollTop + windowHeight >= docHeight - 50) {
triggerEasterEgg(targetSlug)
}
}
onMounted(() => {
window.addEventListener('scroll', checkScroll, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('scroll', checkScroll)
})
}
// === Sequence Detection ===
function detectSequence(expectedSequence: string[], targetSlug: string) {
function addToSequence(item: string) {
clickSequence.value.push(item)
// Garder seulement les N derniers
if (clickSequence.value.length > expectedSequence.length) {
clickSequence.value.shift()
}
// Vérifier si la séquence correspond
if (clickSequence.value.length === expectedSequence.length) {
const match = clickSequence.value.every((val, idx) => val === expectedSequence[idx])
if (match) {
triggerEasterEgg(targetSlug)
clickSequence.value = []
}
}
}
return { addToSequence }
}
// === Trigger Easter Egg ===
async function triggerEasterEgg(slug: string) {
// Vérifier si déjà trouvé
if (progressionStore.easterEggsFound.includes(slug)) {
return
}
// Marquer comme trouvé
progressionStore.markEasterEggFound(slug)
// Notifier
options.onFound(slug)
}
onUnmounted(() => {
window.removeEventListener('keydown', handleKonamiKey)
})
return {
detectClick,
detectHover,
detectScrollBottom,
detectSequence,
triggerEasterEgg,
}
}
```
### Composant EasterEggPopup
```vue
<!-- frontend/app/components/feature/EasterEggPopup.vue -->
<script setup lang="ts">
interface EasterEggReward {
slug: string
reward_type: 'snippet' | 'anecdote' | 'image' | 'badge'
reward: string
difficulty: number
}
const props = defineProps<{
visible: boolean
reward: EasterEggReward | null
}>()
const emit = defineEmits<{
close: []
}>()
const { t } = useI18n()
const progressionStore = useProgressionStore()
const { availableEasterEggs } = useFetchEasterEggs()
const totalEasterEggs = computed(() => availableEasterEggs.value.length || 8)
const foundCount = computed(() => progressionStore.easterEggsFoundCount)
// Icône selon le type
const rewardIcon = computed(() => {
if (!props.reward) return '🎁'
const icons: Record<string, string> = {
snippet: '💻',
anecdote: '📖',
image: '🖼️',
badge: '🏆',
}
return icons[props.reward.reward_type] || '🎁'
})
</script>
<template>
<Teleport to="body">
<Transition name="popup">
<div
v-if="visible && reward"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- Overlay -->
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
@click="emit('close')"
></div>
<!-- Modal -->
<div class="relative bg-sky-dark-50 rounded-2xl p-8 max-w-md w-full border border-sky-accent/50 shadow-2xl shadow-sky-accent/20 animate-bounce-in">
<!-- Effet confetti/sparkles -->
<div class="absolute -top-4 left-1/2 -translate-x-1/2 text-4xl animate-bounce">
🎉
</div>
<!-- Icône du type -->
<div class="text-6xl text-center mb-4">
{{ rewardIcon }}
</div>
<!-- Titre -->
<h2 class="text-2xl font-ui font-bold text-sky-accent text-center mb-2">
{{ t('easterEgg.found') }}
</h2>
<!-- Compteur -->
<p class="text-sm text-sky-text-muted text-center mb-6">
{{ t('easterEgg.count', { found: foundCount, total: totalEasterEggs }) }}
</p>
<!-- Récompense -->
<div class="bg-sky-dark rounded-lg p-4 mb-6">
<!-- Snippet de code -->
<pre
v-if="reward.reward_type === 'snippet'"
class="font-mono text-sm text-sky-accent overflow-x-auto"
><code>{{ reward.reward }}</code></pre>
<!-- Anecdote ou texte -->
<p
v-else-if="reward.reward_type === 'anecdote'"
class="font-narrative text-sky-text italic"
>
{{ reward.reward }}
</p>
<!-- Badge -->
<div
v-else-if="reward.reward_type === 'badge'"
class="text-center"
>
<p class="font-ui text-sky-text">{{ reward.reward }}</p>
</div>
<!-- Image -->
<div
v-else-if="reward.reward_type === 'image'"
class="text-center"
>
<p class="font-ui text-sky-text">{{ reward.reward }}</p>
</div>
</div>
<!-- Difficulté -->
<div class="flex items-center justify-center gap-1 mb-6">
<span class="text-xs text-sky-text-muted mr-2">{{ t('easterEgg.difficulty') }}:</span>
<span
v-for="i in 5"
:key="i"
class="text-sm"
:class="i <= reward.difficulty ? 'text-sky-accent' : 'text-sky-dark-100'"
>
</span>
</div>
<!-- Bouton fermer -->
<button
type="button"
class="w-full py-3 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors"
@click="emit('close')"
>
{{ t('common.continue') }}
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.popup-enter-active,
.popup-leave-active {
transition: all 0.3s ease;
}
.popup-enter-from,
.popup-leave-to {
opacity: 0;
}
.popup-enter-from .relative,
.popup-leave-to .relative {
transform: scale(0.9);
}
@keyframes bounce-in {
0% {
transform: scale(0.5);
opacity: 0;
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-bounce-in {
animation: bounce-in 0.4s ease-out;
}
</style>
```
### Composant EasterEggCollection
```vue
<!-- frontend/app/components/feature/EasterEggCollection.vue -->
<script setup lang="ts">
const { t } = useI18n()
const progressionStore = useProgressionStore()
const { availableEasterEggs, fetchList } = useFetchEasterEggs()
onMounted(() => {
fetchList()
})
const totalEasterEggs = computed(() => availableEasterEggs.value.length || 8)
const foundCount = computed(() => progressionStore.easterEggsFoundCount)
const isComplete = computed(() => foundCount.value >= totalEasterEggs.value)
function isFound(slug: string): boolean {
return progressionStore.easterEggsFound.includes(slug)
}
// Icône selon difficulté
function getDifficultyStars(difficulty: number): string {
return '⭐'.repeat(difficulty) + '☆'.repeat(5 - difficulty)
}
</script>
<template>
<div class="easter-egg-collection">
<!-- Header avec compteur -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-ui font-bold text-sky-text">
{{ t('easterEgg.collection') }}
</h2>
<div class="flex items-center gap-2">
<span class="text-sky-accent font-ui font-bold">{{ foundCount }}</span>
<span class="text-sky-text-muted">/</span>
<span class="text-sky-text-muted">{{ totalEasterEggs }}</span>
</div>
</div>
<!-- Badge 100% -->
<div
v-if="isComplete"
class="bg-gradient-to-r from-sky-accent to-amber-500 rounded-lg p-4 mb-6 text-center"
>
<span class="text-2xl">🏆</span>
<p class="text-white font-ui font-bold mt-2">
{{ t('easterEgg.allFound') }}
</p>
</div>
<!-- Barre de progression -->
<div class="h-2 bg-sky-dark-100 rounded-full mb-6 overflow-hidden">
<div
class="h-full bg-sky-accent transition-all duration-500"
:style="{ width: `${(foundCount / totalEasterEggs) * 100}%` }"
></div>
</div>
<!-- Grille des easter eggs -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div
v-for="egg in availableEasterEggs"
:key="egg.slug"
class="easter-egg-card p-4 rounded-lg border transition-all"
:class="[
isFound(egg.slug)
? 'bg-sky-dark-50 border-sky-accent/50'
: 'bg-sky-dark border-sky-dark-100 opacity-50'
]"
>
<!-- Icône ou mystère -->
<div class="text-3xl text-center mb-2">
{{ isFound(egg.slug) ? getTriggerIcon(egg.trigger_type) : '❓' }}
</div>
<!-- Nom ou mystère -->
<p class="text-sm font-ui text-center truncate" :class="isFound(egg.slug) ? 'text-sky-text' : 'text-sky-text-muted'">
{{ isFound(egg.slug) ? formatSlug(egg.slug) : '???' }}
</p>
<!-- Difficulté -->
<p class="text-xs text-center mt-1 text-sky-text-muted">
{{ getDifficultyStars(egg.difficulty) }}
</p>
</div>
</div>
<!-- Indice si pas tous trouvés -->
<p
v-if="!isComplete"
class="text-sm text-sky-text-muted text-center mt-6 font-narrative italic"
>
{{ t('easterEgg.hint') }}
</p>
</div>
</template>
<script lang="ts">
function getTriggerIcon(trigger: string): string {
const icons: Record<string, string> = {
click: '👆',
hover: '👀',
konami: '🎮',
scroll: '📜',
sequence: '🔢',
}
return icons[trigger] || '🎁'
}
function formatSlug(slug: string): string {
return slug
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
</script>
```
### Clés i18n
**fr.json :**
```json
{
"easterEgg": {
"found": "Easter Egg trouvé !",
"count": "{found} / {total} découverts",
"difficulty": "Difficulté",
"collection": "Ma Collection",
"allFound": "Collection complète ! Tu es un vrai explorateur !",
"hint": "Continue d'explorer... des surprises sont cachées partout !"
}
}
```
**en.json :**
```json
{
"easterEgg": {
"found": "Easter Egg found!",
"count": "{found} / {total} discovered",
"difficulty": "Difficulty",
"collection": "My Collection",
"allFound": "Collection complete! You're a true explorer!",
"hint": "Keep exploring... surprises are hidden everywhere!"
}
}
```
### Intégration dans une page (exemple)
```vue
<!-- frontend/app/pages/projets.vue (extrait) -->
<script setup>
const showEasterEggPopup = ref(false)
const currentReward = ref(null)
const { validate } = useFetchEasterEggs()
const narrator = useNarrator()
const { detectHover } = useEasterEggDetection({
onFound: async (slug) => {
const reward = await validate(slug)
if (reward) {
currentReward.value = reward
showEasterEggPopup.value = true
narrator.showEasterEggFound()
}
}
})
// Hover sur le commentaire secret
const secretCommentHover = detectHover('secret-comment', 2000)
</script>
<template>
<!-- ... contenu de la page ... -->
<!-- Élément avec easter egg hover -->
<span
class="cursor-help"
@mouseenter="secretCommentHover.handleMouseEnter"
@mouseleave="secretCommentHover.handleMouseLeave"
>
/* ... */
</span>
<!-- Popup easter egg -->
<EasterEggPopup
:visible="showEasterEggPopup"
:reward="currentReward"
@close="showEasterEggPopup = false"
/>
</template>
```
### Dépendances
**Cette story nécessite :**
- Story 4.4 : API et store des easter eggs
- Story 3.3 : useNarrator (réaction du narrateur)
**Cette story prépare pour :**
- Story 4.8 : Page contact (statistiques de collection)
### Project Structure Notes
**Fichiers à créer :**
```
frontend/app/
├── composables/
│ └── useEasterEggDetection.ts # CRÉER
└── components/feature/
├── EasterEggPopup.vue # CRÉER
├── EasterEggNotification.vue # CRÉER
└── EasterEggCollection.vue # CRÉER
```
**Fichiers à modifier :**
```
frontend/app/pages/projets.vue # AJOUTER détecteurs
frontend/app/pages/parcours.vue # AJOUTER détecteurs
frontend/app/pages/competences.vue # AJOUTER détecteurs
frontend/app/components/layout/AppHeader.vue # AJOUTER araignée cachée
frontend/app/components/feature/SettingsDrawer.vue # AJOUTER collection
frontend/i18n/fr.json # AJOUTER easterEgg.*
frontend/i18n/en.json # AJOUTER easterEgg.*
```
### References
- [Source: docs/planning-artifacts/epics.md#Story-4.5]
- [Source: docs/planning-artifacts/ux-design-specification.md#Easter-Eggs-UI]
- [Source: docs/brainstorming-gamification-2026-01-26.md#Easter-Eggs]
### Technical Requirements
| Requirement | Value | Source |
|-------------|-------|--------|
| Types de triggers | click, hover, konami, scroll, sequence | Epics |
| Types de récompenses | snippet, anecdote, image, badge | Epics |
| Collection | Grille avec mystères | Epics |
| Badge 100% | Affiché si complet | Epics |
## Dev Agent Record
### Agent Model Used
{{agent_model_name_version}}
### Debug Log References
### Completion Notes List
### Change Log
| Date | Change | Author |
|------|--------|--------|
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
### File List