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>
609 lines
17 KiB
Markdown
609 lines
17 KiB
Markdown
# 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
|
|
|
|
1. **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
|
|
2. **And** un puzzle logique/code simple est présenté (réordonner, compléter, décoder)
|
|
3. **And** la difficulté est calibrée : 1-3 minutes pour résoudre
|
|
4. **And** le thème est lié au développement/code
|
|
5. **And** un système d'indices est disponible (bouton "Besoin d'aide ?")
|
|
6. **And** 3 niveaux d'indices progressifs sont proposés
|
|
7. **And** après 3 indices, une option "Passer" apparaît
|
|
8. **And** le challenge est TOUJOURS skippable (bouton discret "Passer directement au contact")
|
|
9. **And** une validation avec feedback clair indique succès/échec
|
|
10. **And** une animation de succès célèbre la réussite
|
|
11. **And** `challengeCompleted` est mis à `true` dans 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
|
|
|
|
- [ ] **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)
|
|
|
|
- [ ] **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 = true` dans 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.
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```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
|
|
|
|
```vue
|
|
<!-- 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
|
|
|
|
```vue
|
|
<!-- 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 :**
|
|
```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 :**
|
|
```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 |
|
|
|
|
### File List
|
|
|