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>
17 KiB
Story 4.6: Page Challenge - Structure et puzzle
Status: ready-for-dev
Story
As a visiteur, I want relever un défi optionnel avant d'accéder au contact, so that l'accès au développeur est une récompense méritée (mais pas bloquante).
Acceptance Criteria
- Given le visiteur accède à
/challenge(après avoir débloqué le contact) When la page se charge Then une introduction narrative "Une dernière épreuve..." s'affiche - And un puzzle logique/code simple est présenté (réordonner, compléter, décoder)
- And la difficulté est calibrée : 1-3 minutes pour résoudre
- And le thème est lié au développement/code
- And un système d'indices est disponible (bouton "Besoin d'aide ?")
- And 3 niveaux d'indices progressifs sont proposés
- And après 3 indices, une option "Passer" apparaît
- And le challenge est TOUJOURS skippable (bouton discret "Passer directement au contact")
- And une validation avec feedback clair indique succès/échec
- And une animation de succès célèbre la réussite
- And
challengeCompletedest mis àtruedans le store si réussi
Tasks / Subtasks
-
Task 1: Créer la page challenge (AC: #1, #8)
- Créer
frontend/app/pages/challenge.vue - Vérifier que le contact est débloqué
- Introduction narrative avec Le Bug
- Bouton discret "Passer" visible en permanence
- Créer
-
Task 2: Concevoir le puzzle (AC: #2, #3, #4)
- Puzzle type "réordonner les lignes de code"
- Code simple : une fonction qui affiche un message
- 5-7 lignes à réordonner dans le bon ordre
- Thème : débloquer l'accès au développeur
-
Task 3: Créer le composant CodePuzzle (AC: #2, #9)
- Créer
frontend/app/components/feature/CodePuzzle.vue - Drag & drop des lignes de code
- Support tactile (mobile)
- Validation visuelle (vert/rouge)
- Créer
-
Task 4: Implémenter le système d'indices (AC: #5, #6, #7)
- Bouton "Besoin d'aide ?"
- 3 indices progressifs (révèlent de plus en plus)
- Après 3 indices : bouton "Passer" plus visible
- Indices traduits FR/EN
-
Task 5: Implémenter l'animation de succès (AC: #10, #11)
- Confettis ou effet visuel de célébration
- Message du narrateur
- Mettre
challengeCompleted = truedans le store - Navigation vers la révélation
-
Task 6: Gérer le skip (AC: #8)
- Skip visible en permanence (discret mais accessible)
- Skip après indices (plus visible)
- Dans les deux cas : navigation vers révélation
-
Task 7: Accessibilité
- Navigation clavier pour le drag & drop
- aria-labels descriptifs
- Instructions claires
-
Task 8: Tests et validation
- Tester le puzzle complet
- Tester les 3 indices
- Vérifier le skip
- Tester sur mobile (drag & drop tactile)
- Valider l'animation de succès
Dev Notes
Puzzle : Réordonner le code
Le puzzle consiste à remettre dans l'ordre les lignes d'une fonction JavaScript qui "débloque" l'accès au développeur.
// Solution correcte
function unlockDeveloper() {
const secret = "SKYCEL";
const key = decode(secret);
if (key === "ACCESS_GRANTED") {
return showDeveloper();
}
return "Keep exploring...";
}
Les lignes sont mélangées et le visiteur doit les réordonner.
Page challenge.vue
<!-- frontend/app/pages/challenge.vue -->
<script setup lang="ts">
const { t } = useI18n()
const router = useRouter()
const progressionStore = useProgressionStore()
const narrator = useNarrator()
// Vérifier que le contact est débloqué
if (!progressionStore.contactUnlocked) {
navigateTo('/')
}
// États
const showIntro = ref(true)
const puzzleCompleted = ref(false)
const hintsUsed = ref(0)
// Introduction narrative
onMounted(async () => {
await narrator.showMessage('challenge_intro')
})
function startPuzzle() {
showIntro.value = false
}
function handlePuzzleSolved() {
puzzleCompleted.value = true
progressionStore.setChallengeCompleted(true)
// Attendre l'animation puis naviguer
setTimeout(() => {
router.push('/revelation')
}, 3000)
}
function skipChallenge() {
// Skip ne marque pas comme complété
router.push('/revelation')
}
function useHint() {
hintsUsed.value++
}
</script>
<template>
<div class="challenge-page min-h-screen bg-sky-dark relative">
<!-- Bouton skip (toujours visible) -->
<button
v-if="!puzzleCompleted"
type="button"
class="absolute top-4 right-4 text-sky-text-muted hover:text-sky-text text-sm font-ui underline z-10"
:class="{ 'text-sky-accent': hintsUsed >= 3 }"
@click="skipChallenge"
>
{{ t('challenge.skip') }}
</button>
<!-- Introduction -->
<Transition name="fade" mode="out-in">
<div
v-if="showIntro"
class="flex flex-col items-center justify-center min-h-screen p-8"
>
<div class="max-w-lg text-center">
<img
src="/images/bug/bug-stage-4.svg"
alt="Le Bug"
class="w-24 h-24 mx-auto mb-6"
/>
<h1 class="text-3xl font-ui font-bold text-sky-text mb-4">
{{ t('challenge.title') }}
</h1>
<p class="font-narrative text-xl text-sky-text-muted mb-8">
{{ t('challenge.intro') }}
</p>
<button
type="button"
class="px-8 py-3 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors"
@click="startPuzzle"
>
{{ t('challenge.accept') }}
</button>
</div>
</div>
<!-- Puzzle -->
<div
v-else-if="!puzzleCompleted"
class="flex flex-col items-center justify-center min-h-screen p-8"
>
<div class="max-w-2xl w-full">
<h2 class="text-xl font-ui font-bold text-sky-text mb-2 text-center">
{{ t('challenge.puzzleTitle') }}
</h2>
<p class="text-sky-text-muted text-center mb-8">
{{ t('challenge.puzzleInstruction') }}
</p>
<CodePuzzle
@solved="handlePuzzleSolved"
@hint-used="useHint"
:hints-used="hintsUsed"
/>
</div>
</div>
<!-- Succès -->
<div
v-else
class="flex flex-col items-center justify-center min-h-screen p-8"
>
<ChallengeSuccess />
</div>
</Transition>
</div>
</template>
Composant CodePuzzle
<!-- frontend/app/components/feature/CodePuzzle.vue -->
<script setup lang="ts">
import { useDraggable } from '@vueuse/core'
const props = defineProps<{
hintsUsed: number
}>()
const emit = defineEmits<{
solved: []
hintUsed: []
}>()
const { t } = useI18n()
// Lignes de code (solution)
const solution = [
'function unlockDeveloper() {',
' const secret = "SKYCEL";',
' const key = decode(secret);',
' if (key === "ACCESS_GRANTED") {',
' return showDeveloper();',
' }',
' return "Keep exploring...";',
'}',
]
// Lignes mélangées au départ
const shuffledLines = ref<string[]>([])
const isValidating = ref(false)
const validationResult = ref<boolean | null>(null)
// Mélanger au montage
onMounted(() => {
shuffledLines.value = [...solution].sort(() => Math.random() - 0.5)
})
// Indices progressifs
const hints = [
() => t('challenge.hint1'), // "La fonction commence par 'function'"
() => t('challenge.hint2'), // "La variable 'secret' est définie en premier"
() => t('challenge.hint3'), // "Le return final est 'Keep exploring...'"
]
const currentHint = computed(() => {
if (props.hintsUsed === 0) return null
return hints[Math.min(props.hintsUsed - 1, hints.length - 1)]()
})
// Drag & Drop
function onDragStart(e: DragEvent, index: number) {
e.dataTransfer?.setData('text/plain', index.toString())
}
function onDrop(e: DragEvent, targetIndex: number) {
e.preventDefault()
const sourceIndex = parseInt(e.dataTransfer?.getData('text/plain') || '-1')
if (sourceIndex === -1) return
// Swap les lignes
const newLines = [...shuffledLines.value]
const temp = newLines[sourceIndex]
newLines[sourceIndex] = newLines[targetIndex]
newLines[targetIndex] = temp
shuffledLines.value = newLines
}
function onDragOver(e: DragEvent) {
e.preventDefault()
}
// Validation
function validateSolution() {
isValidating.value = true
validationResult.value = null
setTimeout(() => {
const isCorrect = shuffledLines.value.every((line, i) => line === solution[i])
validationResult.value = isCorrect
if (isCorrect) {
emit('solved')
} else {
// Reset après 2s
setTimeout(() => {
validationResult.value = null
isValidating.value = false
}, 2000)
}
}, 500)
}
function requestHint() {
if (props.hintsUsed < 3) {
emit('hintUsed')
}
}
// Navigation clavier
function moveLineUp(index: number) {
if (index === 0) return
const newLines = [...shuffledLines.value]
const temp = newLines[index - 1]
newLines[index - 1] = newLines[index]
newLines[index] = temp
shuffledLines.value = newLines
}
function moveLineDown(index: number) {
if (index === shuffledLines.value.length - 1) return
const newLines = [...shuffledLines.value]
const temp = newLines[index + 1]
newLines[index + 1] = newLines[index]
newLines[index] = temp
shuffledLines.value = newLines
}
</script>
<template>
<div class="code-puzzle">
<!-- Zone de code -->
<div class="bg-sky-dark rounded-lg border border-sky-dark-100 p-4 font-mono text-sm mb-6">
<div
v-for="(line, index) in shuffledLines"
:key="index"
class="code-line flex items-center gap-2 p-2 rounded cursor-grab transition-all"
:class="[
validationResult === true && 'bg-green-500/20 border-green-500/50',
validationResult === false && line !== solution[index] && 'bg-red-500/20 border-red-500/50',
]"
draggable="true"
@dragstart="onDragStart($event, index)"
@drop="onDrop($event, index)"
@dragover="onDragOver"
>
<!-- Numéro de ligne -->
<span class="text-sky-text-muted select-none w-6 text-right">{{ index + 1 }}</span>
<!-- Poignée de drag -->
<span class="text-sky-text-muted cursor-grab">⋮⋮</span>
<!-- Code -->
<code class="flex-1 text-sky-accent">{{ line }}</code>
<!-- Boutons clavier (accessibilité) -->
<div class="flex gap-1">
<button
type="button"
class="p-1 text-sky-text-muted hover:text-sky-text"
:disabled="index === 0"
@click="moveLineUp(index)"
:aria-label="t('challenge.moveUp')"
>
↑
</button>
<button
type="button"
class="p-1 text-sky-text-muted hover:text-sky-text"
:disabled="index === shuffledLines.length - 1"
@click="moveLineDown(index)"
:aria-label="t('challenge.moveDown')"
>
↓
</button>
</div>
</div>
</div>
<!-- Indice actuel -->
<div
v-if="currentHint"
class="bg-sky-accent/10 border border-sky-accent/30 rounded-lg p-4 mb-6"
>
<p class="text-sm text-sky-accent">
<span class="font-semibold">{{ t('challenge.hintLabel') }}:</span>
{{ currentHint }}
</p>
</div>
<!-- Actions -->
<div class="flex flex-wrap gap-4 justify-center">
<!-- Bouton valider -->
<button
type="button"
class="px-6 py-3 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors disabled:opacity-50"
:disabled="isValidating"
@click="validateSolution"
>
{{ isValidating ? t('challenge.validating') : t('challenge.validate') }}
</button>
<!-- Bouton indice -->
<button
v-if="hintsUsed < 3"
type="button"
class="px-6 py-3 border border-sky-dark-100 text-sky-text font-ui rounded-lg hover:bg-sky-dark-50 transition-colors"
@click="requestHint"
>
{{ t('challenge.needHint') }} ({{ hintsUsed }}/3)
</button>
</div>
<!-- Message d'erreur -->
<Transition name="fade">
<p
v-if="validationResult === false"
class="text-red-400 text-center mt-4 font-ui"
>
{{ t('challenge.wrongOrder') }}
</p>
</Transition>
</div>
</template>
<style scoped>
.code-line {
border: 1px solid transparent;
}
.code-line:hover {
background-color: rgba(250, 120, 79, 0.1);
}
.code-line:active {
cursor: grabbing;
}
</style>
Composant ChallengeSuccess
<!-- frontend/app/components/feature/ChallengeSuccess.vue -->
<script setup lang="ts">
import confetti from 'canvas-confetti'
const { t } = useI18n()
onMounted(() => {
// Lancer les confettis
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
colors: ['#fa784f', '#3b82f6', '#10b981', '#f59e0b'],
})
})
</script>
<template>
<div class="challenge-success text-center">
<div class="text-6xl mb-4 animate-bounce">🎉</div>
<h2 class="text-3xl font-ui font-bold text-sky-accent mb-4">
{{ t('challenge.success') }}
</h2>
<p class="font-narrative text-xl text-sky-text mb-8">
{{ t('challenge.successMessage') }}
</p>
<p class="text-sky-text-muted">
{{ t('challenge.redirecting') }}
</p>
</div>
</template>
Clés i18n
fr.json :
{
"challenge": {
"title": "Une dernière épreuve...",
"intro": "Avant de rencontrer le développeur, prouve que tu maîtrises les bases du code. Rien de bien méchant, promis.",
"accept": "Relever le défi",
"skip": "Passer directement au contact",
"puzzleTitle": "Remets le code dans l'ordre",
"puzzleInstruction": "Glisse les lignes pour reconstituer la fonction qui débloque l'accès au développeur.",
"hint1": "La fonction commence par 'function unlockDeveloper() {'",
"hint2": "La variable 'secret' est définie juste après l'accolade ouvrante",
"hint3": "La dernière ligne avant l'accolade fermante est 'return \"Keep exploring...\";'",
"hintLabel": "Indice",
"needHint": "Besoin d'aide ?",
"validate": "Vérifier",
"validating": "Vérification...",
"wrongOrder": "Ce n'est pas le bon ordre... Essaie encore !",
"moveUp": "Monter",
"moveDown": "Descendre",
"success": "Bravo !",
"successMessage": "Tu as prouvé ta valeur. Le chemin vers le développeur est maintenant ouvert...",
"redirecting": "Redirection en cours..."
}
}
en.json :
{
"challenge": {
"title": "One last challenge...",
"intro": "Before meeting the developer, prove you understand the basics of code. Nothing too hard, I promise.",
"accept": "Accept the challenge",
"skip": "Skip to contact",
"puzzleTitle": "Put the code in order",
"puzzleInstruction": "Drag the lines to reconstruct the function that unlocks access to the developer.",
"hint1": "The function starts with 'function unlockDeveloper() {'",
"hint2": "The 'secret' variable is defined right after the opening brace",
"hint3": "The last line before the closing brace is 'return \"Keep exploring...\";'",
"hintLabel": "Hint",
"needHint": "Need help?",
"validate": "Check",
"validating": "Checking...",
"wrongOrder": "That's not the right order... Try again!",
"moveUp": "Move up",
"moveDown": "Move down",
"success": "Well done!",
"successMessage": "You've proven your worth. The path to the developer is now open...",
"redirecting": "Redirecting..."
}
}
Dépendances
Cette story nécessite :
- Story 3.5 : Store de progression (contactUnlocked, challengeCompleted)
- Story 3.3 : useNarrator
Cette story prépare pour :
- Story 4.7 : Révélation (destination après le challenge)
Project Structure Notes
Fichiers à créer :
frontend/app/
├── pages/
│ └── challenge.vue # CRÉER
└── components/feature/
├── CodePuzzle.vue # CRÉER
└── ChallengeSuccess.vue # CRÉER
Fichiers à modifier :
frontend/app/stores/progression.ts # AJOUTER challengeCompleted
frontend/package.json # AJOUTER canvas-confetti
frontend/i18n/fr.json # AJOUTER challenge.*
frontend/i18n/en.json # AJOUTER challenge.*
References
- [Source: docs/planning-artifacts/epics.md#Story-4.6]
- [Source: docs/planning-artifacts/ux-design-specification.md#Challenge]
- [Source: docs/brainstorming-gamification-2026-01-26.md#Challenge]
Technical Requirements
| Requirement | Value | Source |
|---|---|---|
| Durée puzzle | 1-3 minutes | Epics |
| Indices | 3 niveaux progressifs | Epics |
| Skip | Toujours disponible | Epics |
| Thème | Code/développement | 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 |