Files
Portfolio-Game/docs/implementation-artifacts/4-5-easter-eggs-implementation-ui-collection.md
skycel ec1ae92799 🎉 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>
2026-02-05 02:08:56 +01:00

18 KiB

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

// 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

<!-- 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

<!-- 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 :

{
  "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 :

{
  "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)

<!-- 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