🎉 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:
@@ -0,0 +1,421 @@
|
||||
# Story 1.1: Initialisation du monorepo et infrastructure
|
||||
|
||||
Status: review
|
||||
|
||||
## Story
|
||||
|
||||
As a développeur,
|
||||
I want un projet monorepo Nuxt 4 + Laravel 12 initialisé avec les configurations de base,
|
||||
so that le développement peut commencer sur des fondations solides.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** un nouveau repository Git **When** le projet est initialisé **Then** la structure monorepo `frontend/` (Nuxt 4) + `api/` (Laravel 12) est en place
|
||||
2. **And** Nuxt 4 est configuré avec SSR activé, TypeScript, et les modules `@nuxtjs/i18n`, `@nuxtjs/tailwindcss`, `@pinia/nuxt`, `nuxt/image`, `@nuxtjs/sitemap`
|
||||
3. **And** Laravel 12 est configuré en mode API-only avec CORS autorisant le domaine frontend
|
||||
4. **And** le middleware API Key (`X-API-Key`) est en place sur les routes API
|
||||
5. **And** les fichiers `.env.example` existent pour frontend et backend
|
||||
6. **And** TailwindCSS est configuré avec les design tokens (`sky-dark`, `sky-accent` #fa784f, `sky-text`)
|
||||
7. **And** les polices sont définies (serif narrateur + sans-serif UI)
|
||||
8. **And** le `.gitignore` est approprié pour les deux applications
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] **Task 1: Initialisation structure monorepo** (AC: #1)
|
||||
- [x] Créer le dossier racine `skycel/` avec README.md
|
||||
- [x] Configurer `.gitignore` global (node_modules, vendor, .env, etc.)
|
||||
- [x] Initialiser Git repository
|
||||
|
||||
- [x] **Task 2: Setup Frontend Nuxt 4** (AC: #1, #2)
|
||||
- [x] Exécuter `npx nuxi@latest init frontend`
|
||||
- [x] Confirmer structure Nuxt 4 avec dossier `app/`
|
||||
- [x] Activer TypeScript (déjà par défaut dans Nuxt 4)
|
||||
- [x] Installer modules: `@nuxtjs/i18n`, `@nuxtjs/tailwindcss`, `@pinia/nuxt`, `@nuxt/image`, `@nuxtjs/sitemap`
|
||||
- [x] Configurer `nuxt.config.ts` avec SSR activé
|
||||
- [x] Créer `frontend/.env.example`
|
||||
|
||||
- [x] **Task 3: Setup Backend Laravel 12** (AC: #1, #3, #4)
|
||||
- [x] Exécuter `composer create-project laravel/laravel api`
|
||||
- [x] Configurer en mode API-only (supprimer views Blade inutiles)
|
||||
- [x] Configurer CORS dans `config/cors.php` pour autoriser le domaine frontend
|
||||
- [x] Créer middleware `VerifyApiKey` pour vérifier header `X-API-Key`
|
||||
- [x] Enregistrer le middleware sur les routes API
|
||||
- [x] Créer `api/.env.example`
|
||||
|
||||
- [x] **Task 4: Configuration TailwindCSS avec design tokens** (AC: #6)
|
||||
- [x] Configurer `tailwind.config.js` avec thème custom
|
||||
- [x] Définir tokens couleurs: `sky-dark` (noir→bleu), `sky-accent` (#fa784f), `sky-text` (blanc cassé)
|
||||
- [x] Définir variantes hover/focus pour l'accent
|
||||
- [x] Configurer purge pour production
|
||||
|
||||
- [x] **Task 5: Configuration des polices** (AC: #7)
|
||||
- [x] Choisir police serif élégante pour narrateur/PNJ (ex: Merriweather, Lora, Playfair Display)
|
||||
- [x] Choisir police sans-serif moderne pour UI (ex: Inter, Open Sans, Nunito)
|
||||
- [x] Configurer les polices dans `tailwind.config.js` (fontFamily)
|
||||
- [x] Importer les polices via Google Fonts ou fichiers locaux
|
||||
|
||||
- [x] **Task 6: Fichiers .env.example** (AC: #5)
|
||||
- [x] `frontend/.env.example` avec: `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_API_KEY`
|
||||
- [x] `api/.env.example` avec: `APP_KEY`, `DB_*`, `API_KEY`, `CORS_ALLOWED_ORIGINS`
|
||||
|
||||
- [x] **Task 7: Validation finale** (AC: tous)
|
||||
- [x] `cd frontend && npm run dev` fonctionne
|
||||
- [x] `cd api && php artisan serve` fonctionne
|
||||
- [x] Requête API avec header `X-API-Key` valide retourne 200
|
||||
- [x] Requête API sans header retourne 401
|
||||
- [x] Structure des dossiers conforme
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Architecture Monorepo
|
||||
|
||||
```
|
||||
skycel/
|
||||
├── frontend/ # Application Nuxt 4
|
||||
│ ├── app/ # Code applicatif (structure Nuxt 4)
|
||||
│ │ ├── pages/
|
||||
│ │ ├── components/
|
||||
│ │ ├── composables/
|
||||
│ │ ├── stores/
|
||||
│ │ ├── layouts/
|
||||
│ │ ├── plugins/
|
||||
│ │ ├── assets/
|
||||
│ │ └── app.vue
|
||||
│ ├── server/ # Server routes/API Nuxt (si besoin)
|
||||
│ ├── public/
|
||||
│ ├── i18n/
|
||||
│ ├── nuxt.config.ts
|
||||
│ ├── tailwind.config.js
|
||||
│ ├── .env.example
|
||||
│ └── package.json
|
||||
├── api/ # Backend Laravel 12
|
||||
│ ├── app/
|
||||
│ │ ├── Http/
|
||||
│ │ │ ├── Controllers/
|
||||
│ │ │ ├── Middleware/
|
||||
│ │ │ │ └── VerifyApiKey.php # CRÉER
|
||||
│ │ │ ├── Requests/
|
||||
│ │ │ └── Resources/
|
||||
│ │ └── Models/
|
||||
│ ├── database/
|
||||
│ ├── routes/
|
||||
│ │ └── api.php
|
||||
│ ├── config/
|
||||
│ │ └── cors.php # CONFIGURER
|
||||
│ ├── bootstrap/
|
||||
│ │ └── app.php # Enregistrer middleware
|
||||
│ ├── .env.example
|
||||
│ └── composer.json
|
||||
├── docs/ # Documentation projet (existe déjà)
|
||||
├── .gitignore
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Commandes d'initialisation
|
||||
|
||||
```bash
|
||||
# Frontend Nuxt 4
|
||||
npx nuxi@latest init frontend
|
||||
cd frontend
|
||||
npm install @nuxtjs/i18n @nuxtjs/tailwindcss @pinia/nuxt @nuxt/image @nuxtjs/sitemap pinia-plugin-persistedstate
|
||||
|
||||
# Backend Laravel 12
|
||||
composer create-project laravel/laravel api
|
||||
cd api
|
||||
# Pas de packages supplémentaires pour cette story
|
||||
```
|
||||
|
||||
### Configuration nuxt.config.ts
|
||||
|
||||
```typescript
|
||||
// frontend/nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
devtools: { enabled: true },
|
||||
|
||||
// SSR activé (défaut)
|
||||
ssr: true,
|
||||
|
||||
// Structure Nuxt 4
|
||||
future: {
|
||||
compatibilityVersion: 4,
|
||||
},
|
||||
|
||||
modules: [
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@pinia/nuxt',
|
||||
'@nuxt/image',
|
||||
'@nuxtjs/sitemap',
|
||||
],
|
||||
|
||||
// i18n sera configuré en Story 1.3
|
||||
i18n: {
|
||||
locales: ['fr', 'en'],
|
||||
defaultLocale: 'fr',
|
||||
strategy: 'prefix_except_default',
|
||||
},
|
||||
|
||||
// Transitions de page (configuré en Story 1.4)
|
||||
app: {
|
||||
pageTransition: { name: 'page', mode: 'out-in' },
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiUrl: process.env.NUXT_PUBLIC_API_URL || 'http://localhost:8000/api',
|
||||
apiKey: process.env.NUXT_PUBLIC_API_KEY || '',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Design Tokens TailwindCSS
|
||||
|
||||
```javascript
|
||||
// frontend/tailwind.config.js
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./app/**/*.{vue,js,ts}',
|
||||
'./components/**/*.{vue,js,ts}',
|
||||
'./layouts/**/*.{vue,js,ts}',
|
||||
'./pages/**/*.{vue,js,ts}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'sky-dark': {
|
||||
DEFAULT: '#0a0e1a', // Noir tirant vers le bleu
|
||||
50: '#1a1f2e',
|
||||
100: '#151a28',
|
||||
200: '#10141f',
|
||||
300: '#0c1019',
|
||||
400: '#080c14',
|
||||
500: '#0a0e1a', // Base
|
||||
600: '#060810',
|
||||
700: '#04060c',
|
||||
800: '#020408',
|
||||
900: '#010204',
|
||||
},
|
||||
'sky-accent': {
|
||||
DEFAULT: '#fa784f', // Orange chaud
|
||||
hover: '#fb8c68',
|
||||
active: '#f96436',
|
||||
50: '#fff4f0',
|
||||
100: '#ffe8e0',
|
||||
200: '#ffd1c1',
|
||||
300: '#ffb9a2',
|
||||
400: '#fca283',
|
||||
500: '#fa784f', // Base
|
||||
600: '#e86940',
|
||||
700: '#d65a31',
|
||||
800: '#c44b22',
|
||||
900: '#b23c13',
|
||||
},
|
||||
'sky-text': {
|
||||
DEFAULT: '#f5f0e6', // Blanc cassé tirant vers jaune
|
||||
muted: '#b8b3a8',
|
||||
50: '#fdfcfa',
|
||||
100: '#fbf9f5',
|
||||
200: '#f7f3eb',
|
||||
300: '#f5f0e6', // Base
|
||||
400: '#e8e3d9',
|
||||
500: '#dbd6cc',
|
||||
600: '#cec9bf',
|
||||
700: '#c1bcb2',
|
||||
800: '#b4afa5',
|
||||
900: '#a7a298',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
// Police narrative (serif) - pour narrateur, PNJ, dialogues
|
||||
'narrative': ['Merriweather', 'Georgia', 'serif'],
|
||||
// Police UI (sans-serif) - pour interface, boutons, labels
|
||||
'ui': ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware Laravel VerifyApiKey
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Middleware/VerifyApiKey.php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class VerifyApiKey
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$apiKey = $request->header('X-API-Key');
|
||||
|
||||
if (!$apiKey || $apiKey !== config('app.api_key')) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'INVALID_API_KEY',
|
||||
'message' => 'Invalid or missing API key',
|
||||
]
|
||||
], 401);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration CORS Laravel
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/config/cors.php
|
||||
|
||||
return [
|
||||
'paths' => ['api/*'],
|
||||
'allowed_methods' => ['*'],
|
||||
'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', 'http://localhost:3000')),
|
||||
'allowed_origins_patterns' => [],
|
||||
'allowed_headers' => ['*'],
|
||||
'exposed_headers' => [],
|
||||
'max_age' => 0,
|
||||
'supports_credentials' => false,
|
||||
];
|
||||
```
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
**frontend/.env.example:**
|
||||
```env
|
||||
# API Configuration
|
||||
NUXT_PUBLIC_API_URL=http://localhost:8000/api
|
||||
NUXT_PUBLIC_API_KEY=your-api-key-here
|
||||
|
||||
# Site URL (for sitemap, SEO)
|
||||
NUXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
**api/.env.example:**
|
||||
```env
|
||||
APP_NAME=Skycel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
# Database
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=skycel
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
|
||||
# API Security
|
||||
API_KEY=your-api-key-here
|
||||
|
||||
# CORS
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||
```
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
- **Nuxt 4** utilise la nouvelle structure `app/` (pas `src/`)
|
||||
- Les composants dans `app/components/` sont auto-importés
|
||||
- Les composables dans `app/composables/` sont auto-importés
|
||||
- Les stores Pinia dans `app/stores/` sont accessibles via auto-import
|
||||
- Composants client-only: utiliser le suffixe `.client.vue` (pas de SSR)
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/architecture.md#Starter-Template-Evaluation]
|
||||
- [Source: docs/planning-artifacts/architecture.md#Structure-Monorepo]
|
||||
- [Source: docs/planning-artifacts/architecture.md#Authentication-&-Security]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Color-System]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Typography-System]
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-1.1]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Nuxt version | 4.x (latest) | Architecture |
|
||||
| Laravel version | 12.x | Architecture |
|
||||
| Node.js | 18+ | Nuxt 4 requirement |
|
||||
| PHP | 8.2+ | Laravel 12 requirement |
|
||||
| TypeScript | Enabled | Architecture |
|
||||
| SSR | Enabled | Architecture, NFR5 |
|
||||
|
||||
### Libraries to Install
|
||||
|
||||
**Frontend (npm):**
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| @nuxtjs/i18n | 8.x | Internationalisation |
|
||||
| @nuxtjs/tailwindcss | 6.x | Styling |
|
||||
| @pinia/nuxt | 0.5.x | State management |
|
||||
| @nuxt/image | 1.x | Image optimization |
|
||||
| @nuxtjs/sitemap | 5.x | SEO sitemap |
|
||||
| pinia-plugin-persistedstate | 3.x | LocalStorage persistence |
|
||||
|
||||
**Backend (composer):**
|
||||
- Aucun package supplémentaire pour cette story (Laravel de base suffit)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
- TailwindCSS: Le module `@nuxtjs/tailwindcss` v6.14 provoquait une erreur PostCSS `Cannot use 'import.meta' outside a module` sur Node.js 18. Résolu en remplaçant le module par une configuration PostCSS directe dans `nuxt.config.ts`.
|
||||
- PHP: Le PHP en PATH (8.0.3) est incompatible avec Laravel 12. Utilisation de PHP 8.2.29 disponible dans Laragon pour la création du projet et l'exécution.
|
||||
- pinia-plugin-persistedstate: La v4 requiert pinia 3+, incompatible avec @pinia/nuxt 0.9.0 (pinia 2). Downgrade vers v3.2.
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- Structure monorepo `frontend/` + `api/` créée et fonctionnelle
|
||||
- Nuxt 4 (3.17.5) configuré avec SSR, TypeScript, i18n, Pinia, @nuxt/image, sitemap
|
||||
- TailwindCSS v3 configuré via PostCSS avec design tokens (sky-dark, sky-accent, sky-text)
|
||||
- Polices Merriweather (narrative) et Inter (UI) importées via Google Fonts
|
||||
- Laravel 12.50 installé en mode API-only avec CORS et middleware VerifyApiKey
|
||||
- Middleware API Key vérifié : 401 sans clé, 200 avec clé valide
|
||||
- Fichiers .env.example créés pour frontend et backend
|
||||
|
||||
### Change Log
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2026-02-03 | Story créée avec contexte complet | SM Agent |
|
||||
| 2026-02-05 | Implémentation complète de toutes les tâches (Tasks 1-7) | Dev Agent (Claude Opus 4.5) |
|
||||
|
||||
### File List
|
||||
|
||||
**Nouveaux fichiers :**
|
||||
- `README.md` - Documentation racine du monorepo
|
||||
- `.gitignore` - Gitignore global
|
||||
- `frontend/package.json` - Dependencies Nuxt 4
|
||||
- `frontend/nuxt.config.ts` - Configuration Nuxt 4 avec SSR, modules, PostCSS
|
||||
- `frontend/tsconfig.json` - Config TypeScript
|
||||
- `frontend/tailwind.config.js` - Design tokens TailwindCSS
|
||||
- `frontend/app/app.vue` - Composant racine Vue
|
||||
- `frontend/app/pages/index.vue` - Page d'accueil placeholder
|
||||
- `frontend/app/assets/css/main.css` - CSS global avec import polices
|
||||
- `frontend/.env.example` - Variables d'environnement frontend
|
||||
- `api/` - Projet Laravel 12 complet (via composer create-project)
|
||||
- `api/app/Http/Middleware/VerifyApiKey.php` - Middleware authentification API Key
|
||||
- `api/config/cors.php` - Configuration CORS
|
||||
- `api/routes/api.php` - Routes API avec endpoint /health
|
||||
|
||||
**Fichiers modifiés :**
|
||||
- `api/bootstrap/app.php` - Routing API-only, enregistrement middleware VerifyApiKey
|
||||
- `api/config/app.php` - Ajout config api_key
|
||||
- `api/.env.example` - Ajout APP_NAME=Skycel, DB config MySQL, API_KEY, CORS_ALLOWED_ORIGINS
|
||||
- `api/.env` - Mêmes ajouts que .env.example avec valeurs dev
|
||||
- `api/routes/web.php` - Vidé (mode API-only)
|
||||
@@ -0,0 +1,274 @@
|
||||
# Story 1.2: Base de données et migrations initiales
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a développeur,
|
||||
I want le schéma de base de données MariaDB avec les tables nécessaires à l'Epic 1,
|
||||
so that l'API peut servir du contenu bilingue.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** une connexion MariaDB configurée dans Laravel **When** `php artisan migrate` est exécuté **Then** la table `translations` est créée (id, lang, key_name, value, timestamps) avec index unique (lang, key_name)
|
||||
2. **And** la table `projects` est créée (id, slug, title_key, description_key, short_description_key, image, url, github_url, date_completed, is_featured, display_order, timestamps)
|
||||
3. **And** la table `skills` est créée (id, slug, name_key, description_key, icon, category, max_level, display_order)
|
||||
4. **And** la table `skill_project` est créée (id, skill_id, project_id, level_before, level_after, level_description_key) avec foreign keys
|
||||
5. **And** les Models Eloquent sont définis avec leurs relations (Project belongsToMany Skill, etc.)
|
||||
6. **And** des Seeders de base sont disponibles avec données de test en FR et EN
|
||||
7. **And** `php artisan db:seed` fonctionne correctement
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Configuration connexion MariaDB** (AC: #1)
|
||||
- [ ] Vérifier que MariaDB est installé et accessible
|
||||
- [ ] Créer la base de données `skycel` si elle n'existe pas
|
||||
- [ ] Configurer `api/.env` avec les variables DB_* correctes
|
||||
- [ ] Tester la connexion avec `php artisan db:show`
|
||||
|
||||
- [ ] **Task 2: Migration table translations** (AC: #1)
|
||||
- [ ] Créer migration `create_translations_table`
|
||||
- [ ] Colonnes: id, lang (VARCHAR 5), key_name (VARCHAR 255), value (TEXT), timestamps
|
||||
- [ ] Index unique composite sur (lang, key_name)
|
||||
- [ ] Index simple sur lang pour les requêtes par langue
|
||||
|
||||
- [ ] **Task 3: Migration table projects** (AC: #2)
|
||||
- [ ] Créer migration `create_projects_table`
|
||||
- [ ] Colonnes: id, slug (unique), title_key, description_key, short_description_key, image, url (nullable), github_url (nullable), date_completed (date), is_featured (boolean, default false), display_order (integer, default 0), timestamps
|
||||
- [ ] Index sur slug (unique)
|
||||
- [ ] Index sur display_order pour le tri
|
||||
|
||||
- [ ] **Task 4: Migration table skills** (AC: #3)
|
||||
- [ ] Créer migration `create_skills_table`
|
||||
- [ ] Colonnes: id, slug (unique), name_key, description_key, icon (nullable), category (enum ou string: Frontend, Backend, Tools, Soft skills), max_level (integer, default 5), display_order (integer, default 0), timestamps
|
||||
- [ ] Index sur slug (unique)
|
||||
- [ ] Index sur category pour le filtrage
|
||||
|
||||
- [ ] **Task 5: Migration table pivot skill_project** (AC: #4)
|
||||
- [ ] Créer migration `create_skill_project_table`
|
||||
- [ ] Colonnes: id, skill_id (FK), project_id (FK), level_before (integer), level_after (integer), level_description_key (nullable), timestamps
|
||||
- [ ] Foreign key skill_id → skills.id avec ON DELETE CASCADE
|
||||
- [ ] Foreign key project_id → projects.id avec ON DELETE CASCADE
|
||||
- [ ] Index composite sur (skill_id, project_id) pour éviter les doublons
|
||||
|
||||
- [ ] **Task 6: Model Translation** (AC: #5)
|
||||
- [ ] Créer `app/Models/Translation.php`
|
||||
- [ ] Propriétés fillable: lang, key_name, value
|
||||
- [ ] Scope `scopeForLang($query, $lang)` pour filtrer par langue
|
||||
- [ ] Méthode statique `getTranslation($key, $lang, $fallback = 'fr')`
|
||||
|
||||
- [ ] **Task 7: Model Project avec relations** (AC: #5)
|
||||
- [ ] Créer `app/Models/Project.php`
|
||||
- [ ] Propriétés fillable: slug, title_key, description_key, short_description_key, image, url, github_url, date_completed, is_featured, display_order
|
||||
- [ ] Casts: date_completed → date, is_featured → boolean
|
||||
- [ ] Relation `skills()`: belongsToMany(Skill::class)->withPivot(['level_before', 'level_after', 'level_description_key'])->withTimestamps()
|
||||
- [ ] Scope `scopeFeatured($query)` pour les projets mis en avant
|
||||
- [ ] Scope `scopeOrdered($query)` pour le tri par display_order
|
||||
|
||||
- [ ] **Task 8: Model Skill avec relations** (AC: #5)
|
||||
- [ ] Créer `app/Models/Skill.php`
|
||||
- [ ] Propriétés fillable: slug, name_key, description_key, icon, category, max_level, display_order
|
||||
- [ ] Relation `projects()`: belongsToMany(Project::class)->withPivot(['level_before', 'level_after', 'level_description_key'])->withTimestamps()
|
||||
- [ ] Scope `scopeByCategory($query, $category)` pour filtrer par catégorie
|
||||
- [ ] Scope `scopeOrdered($query)` pour le tri par display_order
|
||||
|
||||
- [ ] **Task 9: Seeders de base** (AC: #6, #7)
|
||||
- [ ] Créer `database/seeders/TranslationSeeder.php` avec traductions FR et EN de test
|
||||
- [ ] Créer `database/seeders/SkillSeeder.php` avec 8-10 compétences de test (Frontend, Backend, Tools)
|
||||
- [ ] Créer `database/seeders/ProjectSeeder.php` avec 3-4 projets de test
|
||||
- [ ] Créer `database/seeders/SkillProjectSeeder.php` pour lier compétences et projets
|
||||
- [ ] Mettre à jour `DatabaseSeeder.php` pour appeler les seeders dans l'ordre correct (translations → skills → projects → skill_project)
|
||||
|
||||
- [ ] **Task 10: Validation finale** (AC: tous)
|
||||
- [ ] `php artisan migrate:fresh` fonctionne sans erreur
|
||||
- [ ] `php artisan db:seed` fonctionne sans erreur
|
||||
- [ ] Vérifier en BDD que les tables sont créées avec les bons schémas
|
||||
- [ ] Vérifier que les relations fonctionnent: `Project::first()->skills` et `Skill::first()->projects`
|
||||
- [ ] Vérifier que les traductions fonctionnent: `Translation::getTranslation('project.skycel.title', 'fr')`
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Schéma de base de données
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ translations │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ id (PK) │ lang │ key_name │ value │ created_at │ updated_at │
|
||||
│ │ VARCHAR(5) │ VARCHAR(255) │ TEXT │ │
|
||||
│ │ UNIQUE(lang, key_name) │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ projects │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ id │ slug │ title_key │ description_key │ short_description_key │ image │
|
||||
│ │ url │ github_url │ date_completed │ is_featured │ display_order │
|
||||
│ │ created_at │ updated_at │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ belongsToMany
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ skill_project │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ id │ skill_id (FK) │ project_id (FK) │ level_before │ level_after │
|
||||
│ │ level_description_key │ created_at │ updated_at │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ belongsToMany
|
||||
│
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ skills │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ id │ slug │ name_key │ description_key │ icon │ category │ max_level │
|
||||
│ │ display_order │ created_at │ updated_at │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Convention de nommage des clés i18n
|
||||
|
||||
Les colonnes `*_key` contiennent des clés de traduction, pas des valeurs directes.
|
||||
|
||||
**Format des clés :** `{table}.{slug}.{champ}`
|
||||
|
||||
Exemples :
|
||||
- `project.skycel.title` → "Skycel Portfolio"
|
||||
- `project.skycel.description` → "Mon portfolio gamifié..."
|
||||
- `skill.vuejs.name` → "Vue.js"
|
||||
- `skill.vuejs.description` → "Framework JavaScript progressif"
|
||||
|
||||
### Données de test recommandées
|
||||
|
||||
**Skills de test :**
|
||||
| Category | Skills |
|
||||
|----------|--------|
|
||||
| Frontend | Vue.js, Nuxt, TypeScript, TailwindCSS |
|
||||
| Backend | Laravel, PHP, Node.js |
|
||||
| Tools | Git, Docker |
|
||||
| Soft skills | Communication |
|
||||
|
||||
**Projets de test :**
|
||||
1. Skycel Portfolio (ce projet)
|
||||
2. Projet fictif e-commerce
|
||||
3. Projet fictif dashboard
|
||||
|
||||
### Migration SQL de référence (table translations)
|
||||
|
||||
```sql
|
||||
-- Extrait de l'architecture pour référence
|
||||
CREATE TABLE translations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
lang VARCHAR(5) NOT NULL,
|
||||
key_name VARCHAR(255) NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_translation (lang, key_name),
|
||||
INDEX idx_lang (lang)
|
||||
);
|
||||
```
|
||||
|
||||
### Commandes Laravel utiles
|
||||
|
||||
```bash
|
||||
# Créer les migrations
|
||||
php artisan make:migration create_translations_table
|
||||
php artisan make:migration create_projects_table
|
||||
php artisan make:migration create_skills_table
|
||||
php artisan make:migration create_skill_project_table
|
||||
|
||||
# Créer les models
|
||||
php artisan make:model Translation
|
||||
php artisan make:model Project
|
||||
php artisan make:model Skill
|
||||
|
||||
# Créer les seeders
|
||||
php artisan make:seeder TranslationSeeder
|
||||
php artisan make:seeder SkillSeeder
|
||||
php artisan make:seeder ProjectSeeder
|
||||
php artisan make:seeder SkillProjectSeeder
|
||||
|
||||
# Exécuter
|
||||
php artisan migrate:fresh --seed
|
||||
```
|
||||
|
||||
### Dépendances avec Story 1.1
|
||||
|
||||
Cette story DÉPEND de :
|
||||
- Structure `api/` créée (Laravel 12 initialisé)
|
||||
- Fichier `api/.env` avec variables DB_* configurées
|
||||
|
||||
Cette story PRÉPARE pour :
|
||||
- Story 1.3 (i18n) : La table `translations` sera utilisée pour le contenu dynamique
|
||||
- Story 2.x (Projets, Compétences) : Les models et tables seront consommés par les endpoints API
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer dans `api/` :**
|
||||
```
|
||||
api/
|
||||
├── app/Models/
|
||||
│ ├── Translation.php # CRÉER
|
||||
│ ├── Project.php # CRÉER
|
||||
│ └── Skill.php # CRÉER
|
||||
├── database/
|
||||
│ ├── migrations/
|
||||
│ │ ├── 2026_02_03_000001_create_translations_table.php # CRÉER
|
||||
│ │ ├── 2026_02_03_000002_create_projects_table.php # CRÉER
|
||||
│ │ ├── 2026_02_03_000003_create_skills_table.php # CRÉER
|
||||
│ │ └── 2026_02_03_000004_create_skill_project_table.php # CRÉER
|
||||
│ └── seeders/
|
||||
│ ├── DatabaseSeeder.php # MODIFIER
|
||||
│ ├── TranslationSeeder.php # CRÉER
|
||||
│ ├── SkillSeeder.php # CRÉER
|
||||
│ ├── ProjectSeeder.php # CRÉER
|
||||
│ └── SkillProjectSeeder.php # CRÉER
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/architecture.md#Data-Architecture]
|
||||
- [Source: docs/planning-artifacts/architecture.md#Stratégie-i18n]
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-1.2]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Schema-BDD]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Database | MariaDB | Architecture |
|
||||
| ORM | Eloquent | Architecture |
|
||||
| PHP | 8.2+ | Laravel 12 |
|
||||
| Charset | utf8mb4 | Laravel default |
|
||||
| Collation | utf8mb4_unicode_ci | Laravel default |
|
||||
|
||||
### Previous Story Intelligence (Story 1.1)
|
||||
|
||||
**Learnings from Story 1.1:**
|
||||
- Structure monorepo avec `frontend/` et `api/`
|
||||
- Laravel 12 configuré en mode API-only
|
||||
- Fichier `.env.example` créé avec variables DB_*
|
||||
- Middleware VerifyApiKey en place
|
||||
|
||||
**Files created in Story 1.1:**
|
||||
- `api/.env.example` avec configuration DB de base
|
||||
- Structure Laravel standard dans `api/`
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
{{agent_model_name_version}}
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
### Change Log
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2026-02-03 | Story créée avec contexte complet | SM Agent |
|
||||
|
||||
### File List
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
# Story 1.3: Système i18n frontend + API bilingue
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want voir le site dans ma langue (FR ou EN),
|
||||
so that je comprends le contenu.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le module `@nuxtjs/i18n` configuré avec stratégie `prefix_except_default` **When** le visiteur accède à `/` ou `/en` **Then** le contenu statique UI est affiché dans la langue correspondante via fichiers JSON (`i18n/fr.json`, `i18n/en.json`)
|
||||
2. **And** les URLs FR sont par défaut (`/`, `/projets`, `/competences`, `/contact`)
|
||||
3. **And** les URLs EN sont préfixées (`/en`, `/en/projects`, `/en/skills`, `/en/contact`)
|
||||
4. **And** `useI18n()`, `$t()`, `localePath()`, `switchLocalePath()` fonctionnent en SSR
|
||||
5. **And** les tags `hreflang` sont générés automatiquement dans le `<head>`
|
||||
6. **And** l'attribut `lang` du `<html>` est dynamique (fr/en)
|
||||
7. **And** le middleware Laravel extrait `Accept-Language` et joint la table `translations` pour le contenu dynamique
|
||||
8. **And** les API Resources Laravel renvoient le contenu traduit selon la langue demandée
|
||||
9. **And** le fallback est FR si langue non supportée
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Configuration @nuxtjs/i18n** (AC: #1, #2, #3, #4)
|
||||
- [ ] Vérifier que `@nuxtjs/i18n` est installé (Story 1.1)
|
||||
- [ ] Créer la structure `frontend/i18n/` pour les fichiers de traduction
|
||||
- [ ] Configurer `nuxt.config.ts` avec i18n complet :
|
||||
- locales: ['fr', 'en']
|
||||
- defaultLocale: 'fr'
|
||||
- strategy: 'prefix_except_default'
|
||||
- detectBrowserLanguage: false (on utilise l'URL)
|
||||
- [ ] Activer `vueI18n` pour le composant `<i18n-t>`
|
||||
|
||||
- [ ] **Task 2: Fichiers de traduction JSON** (AC: #1)
|
||||
- [ ] Créer `frontend/i18n/fr.json` avec structure de base
|
||||
- [ ] Créer `frontend/i18n/en.json` avec structure de base
|
||||
- [ ] Inclure les traductions pour :
|
||||
- Navigation (Accueil, Projets, Compétences, Témoignages, Parcours, Contact)
|
||||
- Boutons communs (Continuer, Retour, Découvrir, Fermer)
|
||||
- Messages d'erreur (404, erreur générique)
|
||||
- Landing page (accroche, CTA Aventure, CTA Express)
|
||||
- Footer et metadata
|
||||
|
||||
- [ ] **Task 3: Routes localisées Nuxt** (AC: #2, #3)
|
||||
- [ ] Configurer `i18n.pages` dans nuxt.config.ts pour les routes custom :
|
||||
```
|
||||
pages: {
|
||||
'projets/[slug]': { en: '/projects/[slug]' },
|
||||
'competences': { en: '/skills' },
|
||||
'temoignages': { en: '/testimonials' },
|
||||
'parcours': { en: '/journey' },
|
||||
'contact': { en: '/contact' }
|
||||
}
|
||||
```
|
||||
- [ ] Vérifier que les routes FR fonctionnent sans préfixe
|
||||
- [ ] Vérifier que les routes EN fonctionnent avec préfixe `/en`
|
||||
|
||||
- [ ] **Task 4: Helpers i18n et composables** (AC: #4)
|
||||
- [ ] Créer un composable `frontend/app/composables/useLocale.ts` pour centraliser la logique i18n
|
||||
- [ ] Exposer : `currentLocale`, `switchLocale()`, `localizedPath()`
|
||||
- [ ] Tester `useI18n()` dans un composant
|
||||
- [ ] Tester `$t('key')` dans un template
|
||||
- [ ] Tester `localePath('/projets')` pour les liens
|
||||
- [ ] Tester `switchLocalePath('en')` pour le switcher de langue
|
||||
|
||||
- [ ] **Task 5: SEO et balises hreflang** (AC: #5, #6)
|
||||
- [ ] Configurer `i18n.head` dans nuxt.config.ts pour les balises SEO
|
||||
- [ ] Vérifier que `<html lang="fr">` ou `<html lang="en">` est dynamique
|
||||
- [ ] Vérifier les balises `<link rel="alternate" hreflang="fr" href="..." />`
|
||||
- [ ] Vérifier les balises `<link rel="alternate" hreflang="en" href="..." />`
|
||||
- [ ] Vérifier `<link rel="alternate" hreflang="x-default" href="..." />`
|
||||
|
||||
- [ ] **Task 6: Composant LanguageSwitcher** (AC: #4)
|
||||
- [ ] Créer `frontend/app/components/ui/LanguageSwitcher.vue`
|
||||
- [ ] Afficher les langues disponibles (FR / EN)
|
||||
- [ ] Utiliser `switchLocalePath()` pour la navigation
|
||||
- [ ] Highlight de la langue active
|
||||
- [ ] Accessible au clavier (boutons ou liens)
|
||||
- [ ] Style cohérent avec le design system (sky-accent pour actif)
|
||||
|
||||
- [ ] **Task 7: Middleware Laravel SetLocale** (AC: #7, #9)
|
||||
- [ ] Créer `api/app/Http/Middleware/SetLocale.php`
|
||||
- [ ] Extraire la langue depuis le header `Accept-Language`
|
||||
- [ ] Parser le header (ex: `fr-FR,fr;q=0.9,en;q=0.8` → `fr`)
|
||||
- [ ] Valider que la langue est supportée (fr, en)
|
||||
- [ ] Fallback vers `fr` si langue non supportée
|
||||
- [ ] Stocker la langue dans `app()->setLocale($lang)`
|
||||
- [ ] Passer la langue via `$request->attributes->set('lang', $lang)`
|
||||
- [ ] Enregistrer le middleware dans `bootstrap/app.php` pour les routes API
|
||||
|
||||
- [ ] **Task 8: Trait HasTranslations pour les Models** (AC: #8)
|
||||
- [ ] Créer `api/app/Traits/HasTranslations.php`
|
||||
- [ ] Méthode `getTranslated($keyField, $lang = null)` qui :
|
||||
- Récupère la clé depuis le champ (ex: `$this->title_key`)
|
||||
- Joint la table `translations` pour obtenir la valeur
|
||||
- Utilise la langue du request ou le fallback
|
||||
- [ ] Appliquer le trait aux models : Project, Skill
|
||||
- [ ] Tester : `$project->getTranslated('title_key', 'fr')`
|
||||
|
||||
- [ ] **Task 9: API Resources avec traductions** (AC: #8)
|
||||
- [ ] Créer `api/app/Http/Resources/ProjectResource.php`
|
||||
- [ ] Transformer les champs `*_key` en valeurs traduites :
|
||||
```php
|
||||
'title' => $this->getTranslated('title_key'),
|
||||
'description' => $this->getTranslated('description_key'),
|
||||
```
|
||||
- [ ] Créer `api/app/Http/Resources/SkillResource.php` de même
|
||||
- [ ] Inclure `meta.lang` dans les réponses pour debug/vérification
|
||||
|
||||
- [ ] **Task 10: Endpoints API avec traductions** (AC: #7, #8)
|
||||
- [ ] Créer `api/app/Http/Controllers/Api/ProjectController.php`
|
||||
- [ ] Endpoint `GET /api/projects` retournant la liste traduite
|
||||
- [ ] Endpoint `GET /api/projects/{slug}` retournant le détail traduit
|
||||
- [ ] Créer `api/app/Http/Controllers/Api/SkillController.php`
|
||||
- [ ] Endpoint `GET /api/skills` retournant la liste traduite par catégorie
|
||||
- [ ] Enregistrer les routes dans `routes/api.php`
|
||||
|
||||
- [ ] **Task 11: Intégration frontend-backend** (AC: tous)
|
||||
- [ ] Créer composable `frontend/app/composables/useApi.ts` qui :
|
||||
- Utilise `$fetch` ou `useFetch` de Nuxt
|
||||
- Ajoute automatiquement le header `X-API-Key`
|
||||
- Ajoute automatiquement le header `Accept-Language` selon la locale courante
|
||||
- [ ] Tester un appel API depuis une page Nuxt
|
||||
- [ ] Vérifier que le contenu retourné est dans la bonne langue
|
||||
|
||||
- [ ] **Task 12: Validation finale** (AC: tous)
|
||||
- [ ] Accéder à `/` → contenu FR
|
||||
- [ ] Accéder à `/en` → contenu EN
|
||||
- [ ] Cliquer sur le switcher FR → EN → URL change vers `/en`
|
||||
- [ ] API call avec `Accept-Language: en` → réponse en anglais
|
||||
- [ ] API call avec `Accept-Language: de` → fallback FR
|
||||
- [ ] Vérifier les balises hreflang dans le code source HTML
|
||||
- [ ] Vérifier `<html lang="...">` dynamique
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Architecture i18n Hybride
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND (Nuxt 4) │
|
||||
├────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Contenu statique UI → Fichiers JSON (i18n/fr.json, i18n/en.json) │
|
||||
│ - Labels, boutons, navigation, messages d'erreur │
|
||||
│ - Déployé avec le frontend │
|
||||
│ - Accès via $t('key') ou useI18n() │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ API calls avec Accept-Language header
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ BACKEND (Laravel 12) │
|
||||
├────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Contenu dynamique → Table translations (MariaDB) │
|
||||
│ - Titres, descriptions projets/skills │
|
||||
│ - Textes narrateur, dialogues PNJ │
|
||||
│ - Géré via API/BDD │
|
||||
│ - Accès via HasTranslations trait + API Resources │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Configuration nuxt.config.ts complète pour i18n
|
||||
|
||||
```typescript
|
||||
// frontend/nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
modules: [
|
||||
'@nuxtjs/i18n',
|
||||
// ... autres modules
|
||||
],
|
||||
|
||||
i18n: {
|
||||
locales: [
|
||||
{ code: 'fr', iso: 'fr-FR', file: 'fr.json', name: 'Français' },
|
||||
{ code: 'en', iso: 'en-US', file: 'en.json', name: 'English' },
|
||||
],
|
||||
defaultLocale: 'fr',
|
||||
strategy: 'prefix_except_default',
|
||||
lazy: true,
|
||||
langDir: 'i18n/',
|
||||
detectBrowserLanguage: false, // On utilise l'URL uniquement
|
||||
|
||||
// Routes personnalisées
|
||||
pages: {
|
||||
'projets/index': { en: '/projects' },
|
||||
'projets/[slug]': { en: '/projects/[slug]' },
|
||||
'competences': { en: '/skills' },
|
||||
'temoignages': { en: '/testimonials' },
|
||||
'parcours': { en: '/journey' },
|
||||
'contact': { en: '/contact' },
|
||||
'resume': { en: '/resume' },
|
||||
},
|
||||
|
||||
// SEO
|
||||
baseUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://skycel.fr',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Structure des fichiers de traduction
|
||||
|
||||
```json
|
||||
// frontend/i18n/fr.json
|
||||
{
|
||||
"nav": {
|
||||
"home": "Accueil",
|
||||
"projects": "Projets",
|
||||
"skills": "Compétences",
|
||||
"testimonials": "Témoignages",
|
||||
"journey": "Parcours",
|
||||
"contact": "Contact"
|
||||
},
|
||||
"common": {
|
||||
"continue": "Continuer",
|
||||
"back": "Retour",
|
||||
"discover": "Découvrir",
|
||||
"close": "Fermer",
|
||||
"loading": "Chargement..."
|
||||
},
|
||||
"landing": {
|
||||
"title": "Bienvenue dans mon univers",
|
||||
"subtitle": "Développeur Full-Stack",
|
||||
"cta_adventure": "Partir à l'aventure",
|
||||
"cta_express": "Mode express"
|
||||
},
|
||||
"error": {
|
||||
"404": "Page non trouvée",
|
||||
"generic": "Une erreur est survenue"
|
||||
},
|
||||
"meta": {
|
||||
"title": "Skycel - Portfolio de Célian",
|
||||
"description": "Découvrez mon portfolio interactif et gamifié"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware Laravel SetLocale
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Middleware/SetLocale.php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
protected array $supportedLocales = ['fr', 'en'];
|
||||
protected string $fallbackLocale = 'fr';
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$locale = $this->parseAcceptLanguage($request->header('Accept-Language'));
|
||||
|
||||
app()->setLocale($locale);
|
||||
$request->attributes->set('locale', $locale);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
protected function parseAcceptLanguage(?string $header): string
|
||||
{
|
||||
if (!$header) {
|
||||
return $this->fallbackLocale;
|
||||
}
|
||||
|
||||
// Parse "fr-FR,fr;q=0.9,en;q=0.8" → ['fr', 'en']
|
||||
$locales = [];
|
||||
foreach (explode(',', $header) as $part) {
|
||||
$part = trim($part);
|
||||
if (preg_match('/^([a-z]{2})(?:-[A-Z]{2})?(?:;q=([0-9.]+))?$/i', $part, $matches)) {
|
||||
$lang = strtolower($matches[1]);
|
||||
$quality = isset($matches[2]) ? (float)$matches[2] : 1.0;
|
||||
$locales[$lang] = $quality;
|
||||
}
|
||||
}
|
||||
|
||||
arsort($locales);
|
||||
|
||||
foreach (array_keys($locales) as $lang) {
|
||||
if (in_array($lang, $this->supportedLocales)) {
|
||||
return $lang;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->fallbackLocale;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Trait HasTranslations
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Traits/HasTranslations.php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\Translation;
|
||||
|
||||
trait HasTranslations
|
||||
{
|
||||
public function getTranslated(string $keyField, ?string $lang = null): ?string
|
||||
{
|
||||
$lang = $lang ?? request()->attributes->get('locale', 'fr');
|
||||
$key = $this->{$keyField};
|
||||
|
||||
if (!$key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Translation::getTranslation($key, $lang);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Resource avec traductions
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Resources/ProjectResource.php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ProjectResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->slug,
|
||||
'title' => $this->getTranslated('title_key'),
|
||||
'description' => $this->getTranslated('description_key'),
|
||||
'short_description' => $this->getTranslated('short_description_key'),
|
||||
'image' => $this->image,
|
||||
'url' => $this->url,
|
||||
'github_url' => $this->github_url,
|
||||
'date_completed' => $this->date_completed?->format('Y-m-d'),
|
||||
'is_featured' => $this->is_featured,
|
||||
'skills' => SkillResource::collection($this->whenLoaded('skills')),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Composable useApi
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useApi.ts
|
||||
export const useApi = () => {
|
||||
const config = useRuntimeConfig()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const apiFetch = async <T>(endpoint: string, options: any = {}) => {
|
||||
return await $fetch<T>(`${config.public.apiUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'X-API-Key': config.public.apiKey,
|
||||
'Accept-Language': locale.value,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { apiFetch }
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances avec Stories précédentes
|
||||
|
||||
**Cette story DÉPEND de :**
|
||||
- Story 1.1 : Module @nuxtjs/i18n installé
|
||||
- Story 1.2 : Table `translations` créée, Models Project et Skill avec relations
|
||||
|
||||
**Cette story PRÉPARE pour :**
|
||||
- Story 1.4 : Layouts utiliseront $t() pour les labels
|
||||
- Story 1.5 : Landing page avec contenu bilingue
|
||||
- Story 2.x : Tous les contenus projets/skills seront traduits
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer dans `frontend/` :**
|
||||
```
|
||||
frontend/
|
||||
├── i18n/
|
||||
│ ├── fr.json # CRÉER
|
||||
│ └── en.json # CRÉER
|
||||
├── app/
|
||||
│ ├── components/
|
||||
│ │ └── ui/
|
||||
│ │ └── LanguageSwitcher.vue # CRÉER
|
||||
│ └── composables/
|
||||
│ ├── useLocale.ts # CRÉER
|
||||
│ └── useApi.ts # CRÉER
|
||||
└── nuxt.config.ts # MODIFIER (i18n config)
|
||||
```
|
||||
|
||||
**Fichiers à créer dans `api/` :**
|
||||
```
|
||||
api/
|
||||
├── app/
|
||||
│ ├── Http/
|
||||
│ │ ├── Controllers/
|
||||
│ │ │ └── Api/
|
||||
│ │ │ ├── ProjectController.php # CRÉER
|
||||
│ │ │ └── SkillController.php # CRÉER
|
||||
│ │ ├── Middleware/
|
||||
│ │ │ └── SetLocale.php # CRÉER
|
||||
│ │ └── Resources/
|
||||
│ │ ├── ProjectResource.php # CRÉER
|
||||
│ │ └── SkillResource.php # CRÉER
|
||||
│ └── Traits/
|
||||
│ └── HasTranslations.php # CRÉER
|
||||
├── bootstrap/
|
||||
│ └── app.php # MODIFIER (middleware)
|
||||
└── routes/
|
||||
└── api.php # MODIFIER (routes)
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/architecture.md#Data-Architecture]
|
||||
- [Source: docs/planning-artifacts/architecture.md#Stratégie-i18n]
|
||||
- [Source: docs/planning-artifacts/architecture.md#API-Communication-Patterns]
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-1.3]
|
||||
- [Source: docs/prd-gamification.md#NFR7]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| i18n module | @nuxtjs/i18n 8.x | Architecture |
|
||||
| Strategy | prefix_except_default | Architecture |
|
||||
| Default locale | fr | PRD |
|
||||
| Supported locales | fr, en | PRD |
|
||||
| SSR | Required | NFR5, NFR7 |
|
||||
| SEO hreflang | Required | NFR5 |
|
||||
|
||||
### Previous Story Intelligence (Story 1.2)
|
||||
|
||||
**Files created in Story 1.2:**
|
||||
- Table `translations` avec clés i18n
|
||||
- Models Project, Skill avec colonnes `*_key`
|
||||
- Seeders avec données FR et EN
|
||||
|
||||
**Pattern established:**
|
||||
- Convention de nommage des clés : `{table}.{slug}.{field}`
|
||||
- Ex: `project.skycel.title`, `skill.vuejs.name`
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
{{agent_model_name_version}}
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
### Change Log
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2026-02-03 | Story créée avec contexte complet | SM Agent |
|
||||
|
||||
### File List
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
# Story 1.4: Layouts, routing et transitions de page
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want une navigation fluide entre les pages avec des transitions immersives,
|
||||
so that l'expérience ressemble à un changement de zone, pas à un rechargement.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** la structure de pages Nuxt 4 (`app/pages/`) **When** le visiteur navigue entre les pages **Then** les transitions de page sont animées (fade + slide) via `pageTransition` dans `nuxt.config.ts`
|
||||
2. **And** la navigation utilise `<NuxtLink>` pour l'hydration SPA (pas de rechargement)
|
||||
3. **And** le layout par défaut (`default.vue`) inclut le header avec barre de progression (placeholder) et sélecteur de langue
|
||||
4. **And** un layout `minimal.vue` existe pour le mode express
|
||||
5. **And** le `scrollBehavior` est personnalisé (smooth scroll, retour position sauvegardée)
|
||||
6. **And** `prefers-reduced-motion` désactive les animations de transition via media query CSS
|
||||
7. **And** une page 404 (`error.vue`) bilingue est en place
|
||||
8. **And** les meta tags SEO dynamiques fonctionnent via `useHead()` et `useSeoMeta()`
|
||||
9. **And** le favicon est configuré
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Structure des pages Nuxt 4** (AC: #1, #2)
|
||||
- [ ] Créer la structure `frontend/app/pages/` avec les pages de base :
|
||||
- `index.vue` (landing page - placeholder)
|
||||
- `projets/index.vue` (liste projets - placeholder)
|
||||
- `projets/[slug].vue` (détail projet - placeholder)
|
||||
- `competences.vue` (skills - placeholder)
|
||||
- `temoignages.vue` (testimonials - placeholder)
|
||||
- `parcours.vue` (journey - placeholder)
|
||||
- `contact.vue` (contact - placeholder)
|
||||
- `resume.vue` (mode express - placeholder)
|
||||
- [ ] Vérifier que le routing fonctionne avec les URLs localisées (Story 1.3)
|
||||
|
||||
- [ ] **Task 2: Layout default.vue** (AC: #3)
|
||||
- [ ] Créer `frontend/app/layouts/default.vue`
|
||||
- [ ] Inclure le composant `AppHeader` (à créer)
|
||||
- [ ] Inclure le slot `<slot />` pour le contenu de page
|
||||
- [ ] Inclure le composant `AppFooter` (à créer)
|
||||
- [ ] Ajouter le wrapper pour les transitions de page
|
||||
|
||||
- [ ] **Task 3: Composant AppHeader** (AC: #3)
|
||||
- [ ] Créer `frontend/app/components/layout/AppHeader.vue`
|
||||
- [ ] Navigation principale avec liens localisés (`localePath()`)
|
||||
- [ ] Placeholder pour la barre de progression (implémentée en Epic 3)
|
||||
- [ ] Intégrer le `LanguageSwitcher` (Story 1.3)
|
||||
- [ ] Logo/nom du site cliquable vers accueil
|
||||
- [ ] Version mobile : hamburger menu ou navigation adaptée
|
||||
- [ ] Sticky header avec fond semi-transparent sur scroll
|
||||
|
||||
- [ ] **Task 4: Composant AppFooter** (AC: #3)
|
||||
- [ ] Créer `frontend/app/components/layout/AppFooter.vue`
|
||||
- [ ] Liens sociaux (GitHub, LinkedIn, etc.) - configurables via runtimeConfig
|
||||
- [ ] Copyright avec année dynamique
|
||||
- [ ] Liens secondaires (mentions légales si nécessaire)
|
||||
- [ ] Style cohérent avec sky-dark / sky-text
|
||||
|
||||
- [ ] **Task 5: Layout minimal.vue** (AC: #4)
|
||||
- [ ] Créer `frontend/app/layouts/minimal.vue`
|
||||
- [ ] Header simplifié (logo + retour vers aventure)
|
||||
- [ ] Pas de barre de progression
|
||||
- [ ] Footer minimaliste
|
||||
- [ ] Utilisé pour `/resume` et potentiellement d'autres pages express
|
||||
|
||||
- [ ] **Task 6: Transitions de page** (AC: #1, #6)
|
||||
- [ ] Configurer `pageTransition` dans `nuxt.config.ts` :
|
||||
```typescript
|
||||
app: {
|
||||
pageTransition: { name: 'page', mode: 'out-in' }
|
||||
}
|
||||
```
|
||||
- [ ] Créer les styles CSS pour la transition `page` dans `assets/css/transitions.css`
|
||||
- [ ] Animation : fade-in/out + léger slide vertical (effet "changement de zone")
|
||||
- [ ] Durée : 300-400ms
|
||||
- [ ] Respecter `prefers-reduced-motion` : transition instantanée si activé
|
||||
|
||||
- [ ] **Task 7: CSS des transitions** (AC: #1, #6)
|
||||
- [ ] Créer `frontend/app/assets/css/transitions.css`
|
||||
- [ ] Classes `.page-enter-active`, `.page-leave-active`
|
||||
- [ ] Classes `.page-enter-from`, `.page-leave-to`
|
||||
- [ ] Media query `@media (prefers-reduced-motion: reduce)` pour désactiver
|
||||
- [ ] Importer dans `nuxt.config.ts` via `css: ['~/assets/css/transitions.css']`
|
||||
|
||||
- [ ] **Task 8: Scroll behavior personnalisé** (AC: #5)
|
||||
- [ ] Créer `frontend/app/router.options.ts` pour personnaliser le router
|
||||
- [ ] `scrollBehavior` : smooth scroll vers le haut pour nouvelle page
|
||||
- [ ] Sauvegarder et restaurer la position pour navigation back/forward
|
||||
- [ ] Gestion des ancres (`#section`) avec smooth scroll
|
||||
|
||||
- [ ] **Task 9: Page d'erreur 404** (AC: #7)
|
||||
- [ ] Créer `frontend/app/error.vue`
|
||||
- [ ] Message d'erreur bilingue via `$t('error.404')`
|
||||
- [ ] Style immersif cohérent avec le thème (le narrateur pourrait commenter)
|
||||
- [ ] Bouton retour vers l'accueil (`localePath('/')`)
|
||||
- [ ] Gérer différents codes d'erreur (404, 500, etc.)
|
||||
|
||||
- [ ] **Task 10: Meta tags SEO dynamiques** (AC: #8)
|
||||
- [ ] Créer composable `frontend/app/composables/useSeo.ts`
|
||||
- [ ] Méthode `setPageMeta({ title, description, image })` utilisant `useHead()` et `useSeoMeta()`
|
||||
- [ ] Inclure Open Graph tags (og:title, og:description, og:image, og:url)
|
||||
- [ ] Inclure Twitter Card tags
|
||||
- [ ] Utiliser dans chaque page avec des valeurs traduites
|
||||
|
||||
- [ ] **Task 11: Favicon et assets statiques** (AC: #9)
|
||||
- [ ] Ajouter favicon dans `frontend/public/favicon.ico`
|
||||
- [ ] Ajouter favicon PNG 192x192 et 512x512 pour PWA
|
||||
- [ ] Configurer dans `nuxt.config.ts` via `app.head.link`
|
||||
- [ ] Optionnel : apple-touch-icon pour iOS
|
||||
|
||||
- [ ] **Task 12: Validation finale** (AC: tous)
|
||||
- [ ] Navigation entre toutes les pages sans rechargement
|
||||
- [ ] Transitions visibles et fluides
|
||||
- [ ] `prefers-reduced-motion` respecté (tester dans DevTools)
|
||||
- [ ] Header sticky avec langue switcher fonctionnel
|
||||
- [ ] Layout minimal sur `/resume`
|
||||
- [ ] Page 404 accessible via URL invalide
|
||||
- [ ] Meta tags visibles dans le code source HTML
|
||||
- [ ] Favicon affiché dans l'onglet du navigateur
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Structure des layouts et pages
|
||||
|
||||
```
|
||||
frontend/app/
|
||||
├── layouts/
|
||||
│ ├── default.vue # Layout principal (header, footer, transitions)
|
||||
│ └── minimal.vue # Layout simplifié (mode express)
|
||||
├── pages/
|
||||
│ ├── index.vue # Landing page
|
||||
│ ├── projets/
|
||||
│ │ ├── index.vue # Liste des projets
|
||||
│ │ └── [slug].vue # Détail projet
|
||||
│ ├── competences.vue # Page compétences
|
||||
│ ├── temoignages.vue # Page témoignages
|
||||
│ ├── parcours.vue # Page parcours
|
||||
│ ├── contact.vue # Page contact
|
||||
│ └── resume.vue # Mode express (layout minimal)
|
||||
├── components/
|
||||
│ └── layout/
|
||||
│ ├── AppHeader.vue # Header avec navigation
|
||||
│ └── AppFooter.vue # Footer
|
||||
├── error.vue # Page d'erreur globale
|
||||
└── assets/
|
||||
└── css/
|
||||
└── transitions.css # Styles des transitions
|
||||
```
|
||||
|
||||
### Configuration nuxt.config.ts pour les transitions
|
||||
|
||||
```typescript
|
||||
// frontend/nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
// ... autres config
|
||||
|
||||
app: {
|
||||
head: {
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||
{ rel: 'icon', type: 'image/png', sizes: '192x192', href: '/favicon-192.png' },
|
||||
{ rel: 'apple-touch-icon', href: '/apple-touch-icon.png' },
|
||||
],
|
||||
},
|
||||
pageTransition: {
|
||||
name: 'page',
|
||||
mode: 'out-in',
|
||||
},
|
||||
layoutTransition: {
|
||||
name: 'layout',
|
||||
mode: 'out-in',
|
||||
},
|
||||
},
|
||||
|
||||
css: [
|
||||
'~/assets/css/transitions.css',
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### CSS des transitions de page
|
||||
|
||||
```css
|
||||
/* frontend/app/assets/css/transitions.css */
|
||||
|
||||
/* Transition de page - effet "changement de zone" */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
/* Transition de layout */
|
||||
.layout-enter-active,
|
||||
.layout-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.layout-enter-from,
|
||||
.layout-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Respect de prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-enter-active,
|
||||
.page-leave-active,
|
||||
.layout-enter-active,
|
||||
.layout-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.page-enter-from,
|
||||
.page-leave-to,
|
||||
.layout-enter-from,
|
||||
.layout-leave-to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Router options pour scroll behavior
|
||||
|
||||
```typescript
|
||||
// frontend/app/router.options.ts
|
||||
import type { RouterConfig } from '@nuxt/schema'
|
||||
|
||||
export default <RouterConfig>{
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// Si on revient en arrière, restaurer la position
|
||||
if (savedPosition) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(savedPosition)
|
||||
}, 350) // Attendre la fin de la transition
|
||||
})
|
||||
}
|
||||
|
||||
// Si on a une ancre, scroll vers l'ancre
|
||||
if (to.hash) {
|
||||
return {
|
||||
el: to.hash,
|
||||
behavior: 'smooth',
|
||||
top: 80, // Offset pour le header sticky
|
||||
}
|
||||
}
|
||||
|
||||
// Sinon, scroll en haut
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ top: 0, behavior: 'smooth' })
|
||||
}, 350)
|
||||
})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Layout default.vue
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/layouts/default.vue -->
|
||||
<template>
|
||||
<div class="min-h-screen bg-sky-dark text-sky-text flex flex-col">
|
||||
<AppHeader />
|
||||
|
||||
<main class="flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Layout par défaut avec header, contenu, footer
|
||||
</script>
|
||||
```
|
||||
|
||||
### Layout minimal.vue
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/layouts/minimal.vue -->
|
||||
<template>
|
||||
<div class="min-h-screen bg-sky-dark text-sky-text flex flex-col">
|
||||
<header class="p-4 flex justify-between items-center">
|
||||
<NuxtLink :to="localePath('/')" class="text-sky-accent font-ui font-bold">
|
||||
Skycel
|
||||
</NuxtLink>
|
||||
<NuxtLink :to="localePath('/')" class="text-sky-text/70 hover:text-sky-accent text-sm">
|
||||
{{ $t('common.back_to_adventure') }}
|
||||
</NuxtLink>
|
||||
</header>
|
||||
|
||||
<main class="flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="p-4 text-center text-sky-text/50 text-sm">
|
||||
© {{ new Date().getFullYear() }} Célian
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const localePath = useLocalePath()
|
||||
</script>
|
||||
```
|
||||
|
||||
### Composable useSeo
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useSeo.ts
|
||||
interface SeoOptions {
|
||||
title: string
|
||||
description?: string
|
||||
image?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export const useSeo = () => {
|
||||
const config = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const setPageMeta = (options: SeoOptions) => {
|
||||
const fullUrl = `${config.public.siteUrl}${route.fullPath}`
|
||||
const imageUrl = options.image || `${config.public.siteUrl}/og-image.jpg`
|
||||
|
||||
useHead({
|
||||
title: options.title,
|
||||
htmlAttrs: {
|
||||
lang: locale.value,
|
||||
},
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
ogTitle: options.title,
|
||||
ogDescription: options.description,
|
||||
ogImage: imageUrl,
|
||||
ogUrl: options.url || fullUrl,
|
||||
ogLocale: locale.value === 'fr' ? 'fr_FR' : 'en_US',
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterTitle: options.title,
|
||||
twitterDescription: options.description,
|
||||
twitterImage: imageUrl,
|
||||
})
|
||||
}
|
||||
|
||||
return { setPageMeta }
|
||||
}
|
||||
```
|
||||
|
||||
### Page d'erreur
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/error.vue -->
|
||||
<template>
|
||||
<div class="min-h-screen bg-sky-dark text-sky-text flex flex-col items-center justify-center p-8">
|
||||
<h1 class="text-6xl font-bold text-sky-accent mb-4">
|
||||
{{ error?.statusCode || 500 }}
|
||||
</h1>
|
||||
|
||||
<p class="text-xl font-narrative mb-8 text-center max-w-md">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<NuxtLink
|
||||
:to="localePath('/')"
|
||||
class="px-6 py-3 bg-sky-accent text-sky-dark font-ui font-semibold rounded-lg hover:bg-sky-accent-hover transition-colors"
|
||||
>
|
||||
{{ $t('common.back_home') }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
error: {
|
||||
statusCode: number
|
||||
message: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const errorMessage = computed(() => {
|
||||
if (props.error?.statusCode === 404) {
|
||||
return t('error.404')
|
||||
}
|
||||
return t('error.generic')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Traductions à ajouter (i18n)
|
||||
|
||||
```json
|
||||
// Ajouter dans frontend/i18n/fr.json
|
||||
{
|
||||
"common": {
|
||||
"back_home": "Retour à l'accueil",
|
||||
"back_to_adventure": "Retour à l'aventure"
|
||||
},
|
||||
"error": {
|
||||
"404": "Oups ! Cette page semble s'être perdue dans les méandres du code...",
|
||||
"generic": "Une erreur inattendue s'est produite. Le Bug enquête..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances avec Stories précédentes
|
||||
|
||||
**Cette story DÉPEND de :**
|
||||
- Story 1.1 : Nuxt 4 initialisé avec TailwindCSS et design tokens
|
||||
- Story 1.3 : Système i18n configuré, LanguageSwitcher créé
|
||||
|
||||
**Cette story PRÉPARE pour :**
|
||||
- Story 1.5 : Landing page utilisera le layout default
|
||||
- Story 1.6 : Store Pinia intégrera la barre de progression dans AppHeader
|
||||
- Story 1.7 : Page résumé utilisera le layout minimal
|
||||
- Epic 2-4 : Toutes les pages utiliseront ces layouts
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── layouts/
|
||||
│ ├── default.vue # CRÉER
|
||||
│ └── minimal.vue # CRÉER
|
||||
├── pages/
|
||||
│ ├── index.vue # CRÉER (placeholder)
|
||||
│ ├── projets/
|
||||
│ │ ├── index.vue # CRÉER (placeholder)
|
||||
│ │ └── [slug].vue # CRÉER (placeholder)
|
||||
│ ├── competences.vue # CRÉER (placeholder)
|
||||
│ ├── temoignages.vue # CRÉER (placeholder)
|
||||
│ ├── parcours.vue # CRÉER (placeholder)
|
||||
│ ├── contact.vue # CRÉER (placeholder)
|
||||
│ └── resume.vue # CRÉER (placeholder)
|
||||
├── components/
|
||||
│ └── layout/
|
||||
│ ├── AppHeader.vue # CRÉER
|
||||
│ └── AppFooter.vue # CRÉER
|
||||
├── composables/
|
||||
│ └── useSeo.ts # CRÉER
|
||||
├── error.vue # CRÉER
|
||||
├── router.options.ts # CRÉER
|
||||
└── assets/
|
||||
└── css/
|
||||
└── transitions.css # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/
|
||||
├── nuxt.config.ts # MODIFIER (transitions, css, head)
|
||||
├── i18n/fr.json # MODIFIER (ajouter traductions)
|
||||
└── i18n/en.json # MODIFIER (ajouter traductions)
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/architecture.md#Frontend-Architecture]
|
||||
- [Source: docs/planning-artifacts/architecture.md#Transitions-et-animations]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Navigation]
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-1.4]
|
||||
- [Source: docs/prd-gamification.md#FR2]
|
||||
- [Source: docs/prd-gamification.md#NFR6]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Page transitions | fade + slide | FR2 |
|
||||
| Reduced motion | Required | NFR6, WCAG AA |
|
||||
| Sticky header | Yes | UX Design |
|
||||
| SEO meta tags | Required | NFR5 |
|
||||
| Layout switching | default / minimal | Architecture |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
{{agent_model_name_version}}
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
### Change Log
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2026-02-03 | Story créée avec contexte complet | SM Agent |
|
||||
|
||||
### File List
|
||||
|
||||
359
docs/implementation-artifacts/1-5-landing-page-choix-heros.md
Normal file
359
docs/implementation-artifacts/1-5-landing-page-choix-heros.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# Story 1.5: Landing page et choix du héros
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want choisir entre l'aventure et le mode express, puis sélectionner mon héros,
|
||||
so that mon expérience est adaptée à mon profil et mon temps disponible.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur arrive sur la landing page (`/`) **When** la page se charge **Then** deux CTA distincts sont visibles : "Partir à l'aventure" et "Mode express"
|
||||
2. **And** un texte d'accroche intrigant bilingue est affiché
|
||||
3. **And** une animation d'entrée subtile est présente (respectant `prefers-reduced-motion`)
|
||||
4. **And** le design est responsive (mobile + desktop)
|
||||
5. **And** au clic sur "Partir à l'aventure", le composant `HeroSelector` s'affiche avec 3 cards illustrées (Recruteur, Client, Développeur) avec nom et description courte
|
||||
6. **And** le héros sélectionné est stocké dans le store Pinia `useProgressionStore` (champ `hero`)
|
||||
7. **And** au clic sur "Mode express", le visiteur est redirigé vers la page résumé
|
||||
8. **And** le `HeroSelector` est accessible au clavier (`role="radiogroup"`, flèches pour naviguer, Enter pour sélectionner)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Structure de la landing page** (AC: #1, #2, #4)
|
||||
- [ ] Implémenter `frontend/app/pages/index.vue`
|
||||
- [ ] Section hero avec texte d'accroche bilingue (`$t('landing.title')`, `$t('landing.subtitle')`)
|
||||
- [ ] Deux boutons CTA côte à côte (desktop) ou empilés (mobile)
|
||||
- [ ] Utiliser les couleurs du design system (sky-accent pour CTA principal)
|
||||
- [ ] Layout responsive : centré verticalement, max-width pour le contenu
|
||||
|
||||
- [ ] **Task 2: Animations d'entrée** (AC: #3)
|
||||
- [ ] Animation fade-in + slide-up pour le texte d'accroche
|
||||
- [ ] Animation staggered pour les CTA (apparition décalée)
|
||||
- [ ] Utiliser CSS animations ou GSAP (lazy-loaded)
|
||||
- [ ] Media query `prefers-reduced-motion` : animations désactivées
|
||||
- [ ] Durée totale : ~1s max
|
||||
|
||||
- [ ] **Task 3: Composant HeroSelector** (AC: #5, #8)
|
||||
- [ ] Créer `frontend/app/components/feature/HeroSelector.vue`
|
||||
- [ ] Props : `modelValue` (héros sélectionné), emit `update:modelValue`
|
||||
- [ ] Afficher 3 cards : Recruteur, Client, Développeur
|
||||
- [ ] Chaque card : illustration/icône, nom traduit, description courte traduite
|
||||
- [ ] État visuel : card sélectionnée avec bordure accent
|
||||
- [ ] Accessibilité : `role="radiogroup"`, `role="radio"` sur chaque card
|
||||
- [ ] Navigation clavier : flèches gauche/droite, Enter pour confirmer
|
||||
- [ ] Focus visible sur la card active
|
||||
|
||||
- [ ] **Task 4: Données des héros** (AC: #5)
|
||||
- [ ] Définir les 3 héros dans un fichier de config ou composable
|
||||
- [ ] Structure : `{ id: 'recruteur' | 'client' | 'dev', nameKey, descriptionKey, icon }`
|
||||
- [ ] Traductions dans `i18n/fr.json` et `i18n/en.json` :
|
||||
- `hero.recruteur.name`: "Recruteur"
|
||||
- `hero.recruteur.description`: "Je cherche un talent pour mon équipe"
|
||||
- `hero.client.name`: "Client"
|
||||
- `hero.client.description`: "J'ai un projet à réaliser"
|
||||
- `hero.dev.name`: "Développeur"
|
||||
- `hero.dev.description`: "Je suis curieux de voir ton travail"
|
||||
|
||||
- [ ] **Task 5: Intégration avec le store Pinia** (AC: #6)
|
||||
- [ ] Importer `useProgressionStore` (créé en Story 1.6, mais interface définie ici)
|
||||
- [ ] Au choix du héros : `store.setHero(heroId)`
|
||||
- [ ] Après sélection : naviguer vers la première zone ou afficher l'intro narrative
|
||||
- [ ] Si store non disponible (Story 1.6 pas encore faite) : utiliser un state local temporaire
|
||||
|
||||
- [ ] **Task 6: Flow de sélection** (AC: #5, #6)
|
||||
- [ ] État initial : CTA visibles, HeroSelector masqué
|
||||
- [ ] Clic "Partir à l'aventure" : transition vers HeroSelector (fade/slide)
|
||||
- [ ] Clic sur un héros : sélection visuelle
|
||||
- [ ] Bouton "Confirmer" ou double-clic : valider et naviguer
|
||||
- [ ] Bouton "Retour" pour revenir aux CTA
|
||||
- [ ] Animation de transition fluide entre les états
|
||||
|
||||
- [ ] **Task 7: Redirection Mode Express** (AC: #7)
|
||||
- [ ] Clic "Mode express" : `navigateTo(localePath('/resume'))`
|
||||
- [ ] Pas de sélection de héros requise pour le mode express
|
||||
- [ ] Le store peut rester sans héros défini (mode anonyme)
|
||||
|
||||
- [ ] **Task 8: SEO et meta tags** (AC: #1)
|
||||
- [ ] Utiliser `useSeo()` pour définir les meta tags de la landing
|
||||
- [ ] Title : "Skycel - Portfolio interactif de Célian"
|
||||
- [ ] Description : "Découvrez mon portfolio gamifié..."
|
||||
- [ ] Open Graph image : image de preview attractive
|
||||
|
||||
- [ ] **Task 9: Validation finale** (AC: tous)
|
||||
- [ ] Page accessible en FR (`/`) et EN (`/en`)
|
||||
- [ ] Textes traduits correctement
|
||||
- [ ] CTA fonctionnels
|
||||
- [ ] HeroSelector s'affiche et fonctionne
|
||||
- [ ] Navigation clavier complète
|
||||
- [ ] Animations fluides (et désactivées si reduced-motion)
|
||||
- [ ] Responsive : mobile et desktop
|
||||
- [ ] Redirection vers `/resume` fonctionne
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Structure de la landing page
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ LANDING PAGE │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [Logo ou titre animé] │
|
||||
│ │
|
||||
│ "Bienvenue dans mon univers" │
|
||||
│ Développeur Full-Stack │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Partir à │ │ Mode express │ │
|
||||
│ │ l'aventure │ │ (30 secondes) │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
↓ Clic "Aventure"
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ HERO SELECTOR │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ "Qui êtes-vous, voyageur ?" │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 👔 │ │ 💼 │ │ 💻 │ │
|
||||
│ │Recruteur│ │ Client │ │ Dev │ │
|
||||
│ │ "Je..." │ │ "J'ai..." │ │"Je suis.."│ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ [Retour] [Confirmer] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Composant HeroSelector
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/HeroSelector.vue -->
|
||||
<template>
|
||||
<div class="hero-selector">
|
||||
<h2 class="text-2xl font-narrative text-center mb-8">
|
||||
{{ $t('hero.question') }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
:aria-label="$t('hero.select_label')"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-6"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<button
|
||||
v-for="(hero, index) in heroes"
|
||||
:key="hero.id"
|
||||
role="radio"
|
||||
:aria-checked="modelValue === hero.id"
|
||||
:tabindex="modelValue === hero.id || (!modelValue && index === 0) ? 0 : -1"
|
||||
:class="[
|
||||
'hero-card p-6 rounded-xl border-2 transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-sky-accent focus:ring-offset-2 focus:ring-offset-sky-dark',
|
||||
modelValue === hero.id
|
||||
? 'border-sky-accent bg-sky-dark-50'
|
||||
: 'border-sky-text/20 hover:border-sky-text/40'
|
||||
]"
|
||||
@click="selectHero(hero.id)"
|
||||
@keydown.enter="confirmSelection"
|
||||
>
|
||||
<div class="text-4xl mb-4">{{ hero.icon }}</div>
|
||||
<h3 class="text-xl font-ui font-semibold mb-2">
|
||||
{{ $t(hero.nameKey) }}
|
||||
</h3>
|
||||
<p class="text-sky-text/70 font-narrative text-sm">
|
||||
{{ $t(hero.descriptionKey) }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center gap-4 mt-8">
|
||||
<button
|
||||
class="px-6 py-2 text-sky-text/70 hover:text-sky-text transition-colors"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
{{ $t('common.back') }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="!modelValue"
|
||||
:class="[
|
||||
'px-8 py-3 rounded-lg font-ui font-semibold transition-all',
|
||||
modelValue
|
||||
? 'bg-sky-accent text-sky-dark hover:bg-sky-accent-hover'
|
||||
: 'bg-sky-text/20 text-sky-text/50 cursor-not-allowed'
|
||||
]"
|
||||
@click="confirmSelection"
|
||||
>
|
||||
{{ $t('common.continue') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type HeroType = 'recruteur' | 'client' | 'dev'
|
||||
|
||||
interface Hero {
|
||||
id: HeroType
|
||||
nameKey: string
|
||||
descriptionKey: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: HeroType | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: HeroType]
|
||||
'confirm': []
|
||||
'back': []
|
||||
}>()
|
||||
|
||||
const heroes: Hero[] = [
|
||||
{ id: 'recruteur', nameKey: 'hero.recruteur.name', descriptionKey: 'hero.recruteur.description', icon: '👔' },
|
||||
{ id: 'client', nameKey: 'hero.client.name', descriptionKey: 'hero.client.description', icon: '💼' },
|
||||
{ id: 'dev', nameKey: 'hero.dev.name', descriptionKey: 'hero.dev.description', icon: '💻' },
|
||||
]
|
||||
|
||||
const selectHero = (id: HeroType) => {
|
||||
emit('update:modelValue', id)
|
||||
}
|
||||
|
||||
const confirmSelection = () => {
|
||||
if (props.modelValue) {
|
||||
emit('confirm')
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
const currentIndex = heroes.findIndex(h => h.id === props.modelValue)
|
||||
let newIndex = currentIndex
|
||||
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
newIndex = (currentIndex + 1) % heroes.length
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
newIndex = (currentIndex - 1 + heroes.length) % heroes.length
|
||||
}
|
||||
|
||||
if (newIndex !== currentIndex) {
|
||||
emit('update:modelValue', heroes[newIndex].id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Traductions à ajouter
|
||||
|
||||
```json
|
||||
// frontend/i18n/fr.json
|
||||
{
|
||||
"landing": {
|
||||
"title": "Bienvenue dans mon univers",
|
||||
"subtitle": "Développeur Full-Stack passionné",
|
||||
"cta_adventure": "Partir à l'aventure",
|
||||
"cta_express": "Mode express (30s)"
|
||||
},
|
||||
"hero": {
|
||||
"question": "Qui êtes-vous, voyageur ?",
|
||||
"select_label": "Sélectionnez votre profil",
|
||||
"recruteur": {
|
||||
"name": "Recruteur",
|
||||
"description": "Je cherche un talent pour rejoindre mon équipe"
|
||||
},
|
||||
"client": {
|
||||
"name": "Client",
|
||||
"description": "J'ai un projet à réaliser et je cherche le bon développeur"
|
||||
},
|
||||
"dev": {
|
||||
"name": "Développeur",
|
||||
"description": "Je suis curieux de découvrir ton travail et tes compétences"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Animations CSS
|
||||
|
||||
```css
|
||||
/* Animations d'entrée pour la landing */
|
||||
@keyframes fadeSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-slide-up {
|
||||
animation: fadeSlideUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-delay-100 { animation-delay: 0.1s; }
|
||||
.animate-delay-200 { animation-delay: 0.2s; }
|
||||
.animate-delay-300 { animation-delay: 0.3s; }
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-fade-slide-up {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story DÉPEND de :**
|
||||
- Story 1.3 : Système i18n pour les traductions
|
||||
- Story 1.4 : Layout default, transitions de page, useSeo()
|
||||
|
||||
**Cette story PRÉPARE pour :**
|
||||
- Story 1.6 : Le store Pinia stockera le héros sélectionné
|
||||
- Story 4.2 : L'intro narrative suivra la sélection du héros
|
||||
|
||||
**Note :** Si Story 1.6 n'est pas encore implémentée, utiliser un state local (`ref`) comme placeholder.
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-1.5]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Hero-System]
|
||||
- [Source: docs/planning-artifacts/architecture.md#Store-Pinia]
|
||||
- [Source: docs/prd-gamification.md#FR1]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Héros disponibles | Recruteur, Client, Dev | UX Design |
|
||||
| Accessibilité | WCAG AA, keyboard nav | NFR6 |
|
||||
| Animations | Respecter reduced-motion | NFR6 |
|
||||
| Responsive | Mobile + Desktop | NFR3 |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
{{agent_model_name_version}}
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
### Change Log
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2026-02-03 | Story créée avec contexte complet | SM Agent |
|
||||
|
||||
### File List
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
# Story 1.6: Store Pinia progression et bandeau RGPD
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want que ma progression soit sauvegardée et que mon consentement soit respecté,
|
||||
so that je peux reprendre mon exploration et mes données sont protégées.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur accède au site **When** le consentement RGPD n'a pas encore été donné **Then** un bandeau de consentement immersif s'affiche (style narratif/dialogue, pas un bandeau classique)
|
||||
2. **And** le store Pinia `useProgressionStore` est initialisé avec : sessionId (UUID v4), hero, currentPath, visitedSections, completionPercent, easterEggsFound, challengeCompleted, contactUnlocked, narratorStage, choices, consentGiven
|
||||
3. **And** la persistance LocalStorage est activée via `pinia-plugin-persistedstate` (uniquement après consentement)
|
||||
4. **And** le store est compatible SSR (initialisation vide côté serveur, réhydratation client)
|
||||
5. **And** si une progression existante est détectée, un message "Bienvenue à nouveau" est affiché
|
||||
6. **And** l'action `$reset()` permet de réinitialiser la progression
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Installation pinia-plugin-persistedstate** (AC: #3)
|
||||
- [ ] Vérifier que `pinia-plugin-persistedstate` est installé (Story 1.1)
|
||||
- [ ] Configurer le plugin dans `frontend/app/plugins/pinia.ts`
|
||||
- [ ] S'assurer de la compatibilité SSR (pas de localStorage côté serveur)
|
||||
|
||||
- [ ] **Task 2: Création du store useProgressionStore** (AC: #2, #4)
|
||||
- [ ] Créer `frontend/app/stores/progression.ts`
|
||||
- [ ] Définir l'interface `ProgressionState` avec tous les champs requis
|
||||
- [ ] Implémenter le state initial (valeurs par défaut)
|
||||
- [ ] Générer `sessionId` avec UUID v4 (côté client uniquement)
|
||||
- [ ] Compatibilité SSR : state vide côté serveur
|
||||
|
||||
- [ ] **Task 3: Actions du store** (AC: #2, #6)
|
||||
- [ ] `setHero(hero: HeroType)` : définir le héros choisi
|
||||
- [ ] `visitSection(section: string)` : ajouter une section visitée, recalculer %
|
||||
- [ ] `findEasterEgg(slug: string)` : ajouter un easter egg trouvé
|
||||
- [ ] `completeChallenge()` : marquer le challenge comme complété
|
||||
- [ ] `unlockContact()` : débloquer l'accès au contact
|
||||
- [ ] `updateNarratorStage(stage: number)` : évolution du narrateur
|
||||
- [ ] `makeChoice(choiceId: string, value: string)` : enregistrer un choix narratif
|
||||
- [ ] `setConsent(given: boolean)` : définir le consentement RGPD
|
||||
- [ ] `$reset()` : réinitialiser toute la progression
|
||||
|
||||
- [ ] **Task 4: Getters du store** (AC: #2)
|
||||
- [ ] `hasVisited(section: string)` : vérifier si une section a été visitée
|
||||
- [ ] `isContactUnlocked` : contact débloqué (2+ sections visitées)
|
||||
- [ ] `progressPercent` : pourcentage de complétion calculé
|
||||
- [ ] `hasExistingProgress` : progression existante détectée
|
||||
|
||||
- [ ] **Task 5: Persistance conditionnelle** (AC: #3, #4)
|
||||
- [ ] Configurer `persist` avec condition sur `consentGiven`
|
||||
- [ ] Key localStorage : `skycel-progression`
|
||||
- [ ] Exclure certains champs de la persistance si nécessaire
|
||||
- [ ] Gérer la réhydratation client après SSR
|
||||
|
||||
- [ ] **Task 6: Composant ConsentBanner immersif** (AC: #1)
|
||||
- [ ] Créer `frontend/app/components/layout/ConsentBanner.vue`
|
||||
- [ ] Style narratif : dialogue du narrateur (araignée) ou message immersif
|
||||
- [ ] Texte : "Pour mémoriser ton aventure, j'ai besoin de ton accord..."
|
||||
- [ ] Deux boutons : "Accepter" et "Refuser" (style cohérent)
|
||||
- [ ] Animation d'apparition subtile
|
||||
- [ ] Position : bas de l'écran, overlay semi-transparent
|
||||
|
||||
- [ ] **Task 7: Intégration ConsentBanner dans le layout** (AC: #1)
|
||||
- [ ] Ajouter `<ConsentBanner />` dans `layouts/default.vue`
|
||||
- [ ] Afficher uniquement si `!store.consentGiven` ET côté client
|
||||
- [ ] Après acceptation : activer la persistance, masquer le bandeau
|
||||
- [ ] Après refus : masquer le bandeau, ne pas persister (mais store fonctionne en mémoire)
|
||||
|
||||
- [ ] **Task 8: Message "Bienvenue à nouveau"** (AC: #5)
|
||||
- [ ] Détecter si `hasExistingProgress` au chargement (côté client)
|
||||
- [ ] Si oui, afficher un message via le narrateur ou une notification discrète
|
||||
- [ ] Proposer optionnellement de recommencer (`$reset()`)
|
||||
- [ ] Ce message sera affiné en Epic 3 avec le composant NarratorBubble
|
||||
|
||||
- [ ] **Task 9: Calcul de la progression** (AC: #2)
|
||||
- [ ] Définir les sections comptabilisées : projets, competences, temoignages, parcours
|
||||
- [ ] Formule : `(visitedSections.length / totalSections) * 100`
|
||||
- [ ] Mettre à jour `completionPercent` automatiquement via le getter ou action
|
||||
- [ ] Trigger `unlockContact` si >= 2 sections visitées
|
||||
|
||||
- [ ] **Task 10: Tests et validation** (AC: tous)
|
||||
- [ ] Store accessible dans les composants via `useProgressionStore()`
|
||||
- [ ] Persistance fonctionne après acceptation RGPD
|
||||
- [ ] Pas de persistance si refus (mais store en mémoire OK)
|
||||
- [ ] Réinitialisation fonctionne
|
||||
- [ ] Compatible SSR (pas d'erreur hydration mismatch)
|
||||
- [ ] ConsentBanner s'affiche correctement
|
||||
- [ ] Message "Bienvenue à nouveau" fonctionne
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Interface du store
|
||||
|
||||
```typescript
|
||||
// frontend/app/stores/progression.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export type HeroType = 'recruteur' | 'client' | 'dev'
|
||||
|
||||
export interface ProgressionState {
|
||||
sessionId: string
|
||||
hero: HeroType | null
|
||||
currentPath: string
|
||||
visitedSections: string[]
|
||||
completionPercent: number
|
||||
easterEggsFound: string[]
|
||||
challengeCompleted: boolean
|
||||
contactUnlocked: boolean
|
||||
narratorStage: number // 1-5
|
||||
choices: Record<string, string>
|
||||
consentGiven: boolean | null // null = pas encore demandé
|
||||
}
|
||||
|
||||
const SECTIONS = ['projets', 'competences', 'temoignages', 'parcours']
|
||||
|
||||
export const useProgressionStore = defineStore('progression', {
|
||||
state: (): ProgressionState => ({
|
||||
sessionId: '',
|
||||
hero: null,
|
||||
currentPath: 'start',
|
||||
visitedSections: [],
|
||||
completionPercent: 0,
|
||||
easterEggsFound: [],
|
||||
challengeCompleted: false,
|
||||
contactUnlocked: false,
|
||||
narratorStage: 1,
|
||||
choices: {},
|
||||
consentGiven: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
hasVisited: (state) => (section: string) => state.visitedSections.includes(section),
|
||||
|
||||
isContactUnlocked: (state) => state.visitedSections.length >= 2 || state.contactUnlocked,
|
||||
|
||||
progressPercent: (state) => Math.round((state.visitedSections.length / SECTIONS.length) * 100),
|
||||
|
||||
hasExistingProgress: (state) => state.visitedSections.length > 0 || state.hero !== null,
|
||||
},
|
||||
|
||||
actions: {
|
||||
initSession() {
|
||||
if (!this.sessionId && import.meta.client) {
|
||||
this.sessionId = uuidv4()
|
||||
}
|
||||
},
|
||||
|
||||
setHero(hero: HeroType) {
|
||||
this.hero = hero
|
||||
},
|
||||
|
||||
visitSection(section: string) {
|
||||
if (!this.visitedSections.includes(section)) {
|
||||
this.visitedSections.push(section)
|
||||
this.completionPercent = this.progressPercent
|
||||
|
||||
// Auto-unlock contact after 2 sections
|
||||
if (this.visitedSections.length >= 2) {
|
||||
this.contactUnlocked = true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
findEasterEgg(slug: string) {
|
||||
if (!this.easterEggsFound.includes(slug)) {
|
||||
this.easterEggsFound.push(slug)
|
||||
}
|
||||
},
|
||||
|
||||
completeChallenge() {
|
||||
this.challengeCompleted = true
|
||||
},
|
||||
|
||||
unlockContact() {
|
||||
this.contactUnlocked = true
|
||||
},
|
||||
|
||||
updateNarratorStage(stage: number) {
|
||||
if (stage >= 1 && stage <= 5) {
|
||||
this.narratorStage = stage
|
||||
}
|
||||
},
|
||||
|
||||
makeChoice(choiceId: string, value: string) {
|
||||
this.choices[choiceId] = value
|
||||
},
|
||||
|
||||
setConsent(given: boolean) {
|
||||
this.consentGiven = given
|
||||
if (given) {
|
||||
this.initSession()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
persist: {
|
||||
key: 'skycel-progression',
|
||||
storage: import.meta.client ? localStorage : undefined,
|
||||
// Persister uniquement si consentement donné
|
||||
beforeRestore: (ctx) => {
|
||||
// La restauration se fera côté client uniquement
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Plugin Pinia avec persistedstate
|
||||
|
||||
```typescript
|
||||
// frontend/app/plugins/pinia.ts
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
nuxtApp.vueApp.use(pinia)
|
||||
})
|
||||
```
|
||||
|
||||
### Composant ConsentBanner
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/layout/ConsentBanner.vue -->
|
||||
<template>
|
||||
<Transition name="consent">
|
||||
<div
|
||||
v-if="showBanner"
|
||||
class="fixed bottom-0 inset-x-0 z-50 p-4 bg-sky-dark-50/95 backdrop-blur-sm border-t border-sky-text/10"
|
||||
>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Style narratif - comme si le narrateur parlait -->
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="text-3xl">🕷️</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-narrative text-sky-text mb-4">
|
||||
{{ $t('consent.message') }}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="px-6 py-2 bg-sky-accent text-sky-dark font-ui font-semibold rounded-lg hover:bg-sky-accent-hover transition-colors"
|
||||
@click="acceptConsent"
|
||||
>
|
||||
{{ $t('consent.accept') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2 text-sky-text/70 hover:text-sky-text font-ui transition-colors"
|
||||
@click="refuseConsent"
|
||||
>
|
||||
{{ $t('consent.refuse') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const store = useProgressionStore()
|
||||
|
||||
const showBanner = computed(() => {
|
||||
// Afficher uniquement côté client et si pas encore de choix
|
||||
return import.meta.client && store.consentGiven === null
|
||||
})
|
||||
|
||||
const acceptConsent = () => {
|
||||
store.setConsent(true)
|
||||
}
|
||||
|
||||
const refuseConsent = () => {
|
||||
store.setConsent(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.consent-enter-active,
|
||||
.consent-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.consent-enter-from,
|
||||
.consent-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.consent-enter-active,
|
||||
.consent-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Traductions à ajouter
|
||||
|
||||
```json
|
||||
// frontend/i18n/fr.json
|
||||
{
|
||||
"consent": {
|
||||
"message": "Pour mémoriser ton aventure et te permettre de la reprendre plus tard, j'ai besoin de ton accord pour stocker quelques informations sur ton appareil. Rien de personnel, juste ta progression !",
|
||||
"accept": "D'accord, mémorise mon aventure",
|
||||
"refuse": "Non merci, je préfère rester anonyme"
|
||||
},
|
||||
"welcome_back": {
|
||||
"message": "Content de te revoir, aventurier ! Tu avais commencé ton exploration...",
|
||||
"continue": "Reprendre",
|
||||
"restart": "Recommencer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Intégration dans le layout
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/layouts/default.vue -->
|
||||
<template>
|
||||
<div class="min-h-screen bg-sky-dark text-sky-text flex flex-col">
|
||||
<AppHeader />
|
||||
|
||||
<main class="flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
|
||||
<!-- Bandeau RGPD -->
|
||||
<ClientOnly>
|
||||
<ConsentBanner />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Gestion SSR
|
||||
|
||||
**Points critiques pour la compatibilité SSR :**
|
||||
|
||||
1. **UUID** : Générer uniquement côté client (`import.meta.client`)
|
||||
2. **localStorage** : Non disponible côté serveur, utiliser `undefined` comme storage
|
||||
3. **ConsentBanner** : Wrapper dans `<ClientOnly>` pour éviter les mismatches
|
||||
4. **Getters avec computed** : Fonctionnent côté serveur avec valeurs par défaut
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story DÉPEND de :**
|
||||
- Story 1.1 : pinia-plugin-persistedstate installé
|
||||
- Story 1.4 : Layout default.vue créé
|
||||
|
||||
**Cette story PRÉPARE pour :**
|
||||
- Story 1.5 : setHero() appelé après sélection du héros
|
||||
- Story 3.x : visitSection(), progressPercent, narratorStage utilisés
|
||||
- Story 4.x : choices, findEasterEgg(), completeChallenge() utilisés
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── stores/
|
||||
│ └── progression.ts # CRÉER
|
||||
├── plugins/
|
||||
│ └── pinia.ts # CRÉER (ou modifier si existe)
|
||||
└── components/
|
||||
└── layout/
|
||||
└── ConsentBanner.vue # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/
|
||||
├── app/layouts/default.vue # MODIFIER (ajouter ConsentBanner)
|
||||
├── i18n/fr.json # MODIFIER (ajouter traductions)
|
||||
└── i18n/en.json # MODIFIER (ajouter traductions)
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/architecture.md#Store-Pinia]
|
||||
- [Source: docs/planning-artifacts/architecture.md#RGPD]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Consent]
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-1.6]
|
||||
- [Source: docs/prd-gamification.md#FR12]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Persistance | LocalStorage via pinia-plugin-persistedstate | Architecture |
|
||||
| SSR compatible | Required | Architecture |
|
||||
| RGPD compliant | Consentement avant persistance | Architecture |
|
||||
| Session ID | UUID v4 | Architecture |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
{{agent_model_name_version}}
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
### Change Log
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2026-02-03 | Story créée avec contexte complet | SM Agent |
|
||||
|
||||
### File List
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
# Story 1.7: Page résumé express et mode pressé
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur pressé ou recruteur,
|
||||
I want une vue condensée de toutes les informations essentielles,
|
||||
so that je peux évaluer le développeur en 30 secondes.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur accède à `/resume` (FR) ou `/en/resume` (EN) directement ou via "Mode express" **When** la page se charge **Then** le contenu affiché comprend : nom, titre, photo/avatar, accroche (5s)
|
||||
2. **And** les compétences clés avec stack technique sont visibles (10s)
|
||||
3. **And** 3-4 projets highlights avec liens sont affichés (10s)
|
||||
4. **And** un CTA de contact direct est visible (5s)
|
||||
5. **And** un bouton discret "Voir l'aventure" invite à l'expérience complète
|
||||
6. **And** la page est fonctionnelle en FR et EN
|
||||
7. **And** les données sont chargées depuis l'API (projets, skills)
|
||||
8. **And** les meta tags SEO sont optimisés pour cette page
|
||||
9. **And** le layout `minimal.vue` est utilisé
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Structure de la page résumé** (AC: #1, #9)
|
||||
- [ ] Implémenter `frontend/app/pages/resume.vue`
|
||||
- [ ] Utiliser le layout minimal : `definePageMeta({ layout: 'minimal' })`
|
||||
- [ ] Structure en sections verticales : Hero → Skills → Projets → Contact
|
||||
- [ ] Design épuré, scannable en 30 secondes
|
||||
|
||||
- [ ] **Task 2: Section Hero (5s)** (AC: #1)
|
||||
- [ ] Photo/avatar de Célian (image optimisée via nuxt/image)
|
||||
- [ ] Nom : "Célian" (ou nom complet)
|
||||
- [ ] Titre : "Développeur Full-Stack"
|
||||
- [ ] Accroche courte : 1-2 phrases percutantes traduites
|
||||
- [ ] Liens sociaux : GitHub, LinkedIn (icônes cliquables)
|
||||
|
||||
- [ ] **Task 3: Section Compétences (10s)** (AC: #2, #7)
|
||||
- [ ] Titre de section : "Stack technique"
|
||||
- [ ] Afficher les compétences principales par catégorie (Frontend, Backend, Tools)
|
||||
- [ ] Format compact : badges ou liste avec icônes
|
||||
- [ ] Charger depuis l'API `/api/skills` (filtrer les principales)
|
||||
- [ ] Limiter à 8-12 compétences max pour la lisibilité
|
||||
|
||||
- [ ] **Task 4: Section Projets highlights (10s)** (AC: #3, #7)
|
||||
- [ ] Titre de section : "Projets récents"
|
||||
- [ ] Afficher 3-4 projets featured
|
||||
- [ ] Format compact : titre + 1 ligne description + lien
|
||||
- [ ] Charger depuis l'API `/api/projects?featured=true`
|
||||
- [ ] Liens vers les détails (ouvre dans nouvel onglet ou garde sur resume)
|
||||
|
||||
- [ ] **Task 5: Section Contact (5s)** (AC: #4)
|
||||
- [ ] CTA principal : "Me contacter" (lien vers `/contact` ou email direct)
|
||||
- [ ] Email visible (cliquable mailto:)
|
||||
- [ ] Optionnel : téléphone si souhaité
|
||||
- [ ] Style accent pour le CTA principal
|
||||
|
||||
- [ ] **Task 6: Bouton "Voir l'aventure"** (AC: #5)
|
||||
- [ ] Position discrète mais visible (en bas ou en sidebar)
|
||||
- [ ] Texte : "Envie d'explorer ? Découvrir l'aventure complète"
|
||||
- [ ] Lien vers `/` (landing page)
|
||||
- [ ] Style secondaire, pas en compétition avec le CTA contact
|
||||
|
||||
- [ ] **Task 7: Chargement des données API** (AC: #7)
|
||||
- [ ] Utiliser `useFetch` ou `useAsyncData` pour charger skills et projets
|
||||
- [ ] Gérer les états loading et error
|
||||
- [ ] Cache côté client pour éviter les appels répétés
|
||||
- [ ] SSR : données chargées côté serveur pour SEO
|
||||
|
||||
- [ ] **Task 8: Traductions bilingue** (AC: #6)
|
||||
- [ ] Ajouter toutes les traductions dans `i18n/fr.json` et `i18n/en.json`
|
||||
- [ ] Section titles, accroche, CTA labels
|
||||
- [ ] Le contenu API est déjà traduit (Story 1.3)
|
||||
|
||||
- [ ] **Task 9: Meta tags SEO optimisés** (AC: #8)
|
||||
- [ ] Utiliser `useSeo()` avec meta spécifiques
|
||||
- [ ] Title : "Célian - Développeur Full-Stack | CV Express"
|
||||
- [ ] Description : optimisée pour les recruteurs
|
||||
- [ ] Open Graph image : image de preview professionnelle
|
||||
- [ ] Structured data (JSON-LD) pour Person/Developer (optionnel)
|
||||
|
||||
- [ ] **Task 10: Responsive et accessibilité** (AC: #1)
|
||||
- [ ] Mobile : sections empilées verticalement
|
||||
- [ ] Desktop : layout plus aéré, possible 2 colonnes pour skills/projets
|
||||
- [ ] Contraste suffisant (WCAG AA)
|
||||
- [ ] Navigation clavier fluide
|
||||
- [ ] Skip link vers le contenu principal
|
||||
|
||||
- [ ] **Task 11: Validation finale** (AC: tous)
|
||||
- [ ] Page accessible via `/resume` (FR) et `/en/resume` (EN)
|
||||
- [ ] Chargement < 2s (données légères)
|
||||
- [ ] Toutes les sections visibles sans scroll excessif sur desktop
|
||||
- [ ] CTA contact fonctionnel
|
||||
- [ ] Lien vers aventure fonctionne
|
||||
- [ ] Layout minimal utilisé (pas de header complet)
|
||||
- [ ] SEO : vérifier meta tags dans le code source
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Structure de la page résumé
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PAGE RÉSUMÉ EXPRESS │
|
||||
│ (Layout minimal) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────┐ │
|
||||
│ │ Photo │ Célian │
|
||||
│ │ │ Développeur Full-Stack │
|
||||
│ └─────────┘ "Passionné par les expériences web innovantes" │
|
||||
│ [GitHub] [LinkedIn] │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ STACK TECHNIQUE │
|
||||
│ ┌────────────────────────────────────────────────────────────┐│
|
||||
│ │ Frontend: Vue.js • Nuxt • TypeScript • TailwindCSS ││
|
||||
│ │ Backend: Laravel • PHP • Node.js • MariaDB ││
|
||||
│ │ Tools: Git • Docker • CI/CD ││
|
||||
│ └────────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ PROJETS RÉCENTS │
|
||||
│ ┌────────────────────────────────────────────────────────────┐│
|
||||
│ │ • Skycel Portfolio - Portfolio gamifié interactif [→] ││
|
||||
│ │ • Projet E-commerce - Boutique en ligne moderne [→] ││
|
||||
│ │ • Dashboard Analytics - Interface de visualisation [→] ││
|
||||
│ └────────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ ME CONTACTER │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ contact@skycel.fr │
|
||||
│ │
|
||||
│ "Envie d'explorer ? Voir l'aventure complète →" │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Implémentation de la page
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/resume.vue -->
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto px-4 py-8">
|
||||
<!-- Section Hero -->
|
||||
<section class="text-center mb-12">
|
||||
<NuxtImg
|
||||
src="/images/avatar.jpg"
|
||||
alt="Célian"
|
||||
width="120"
|
||||
height="120"
|
||||
class="rounded-full mx-auto mb-4"
|
||||
/>
|
||||
<h1 class="text-3xl font-ui font-bold mb-2">Célian</h1>
|
||||
<p class="text-xl text-sky-accent mb-3">{{ $t('resume.title') }}</p>
|
||||
<p class="text-sky-text/80 font-narrative mb-4">{{ $t('resume.tagline') }}</p>
|
||||
|
||||
<div class="flex justify-center gap-4">
|
||||
<a href="https://github.com/celian" target="_blank" rel="noopener" class="text-sky-text/60 hover:text-sky-accent transition-colors">
|
||||
<span class="sr-only">GitHub</span>
|
||||
<!-- GitHub icon -->
|
||||
</a>
|
||||
<a href="https://linkedin.com/in/celian" target="_blank" rel="noopener" class="text-sky-text/60 hover:text-sky-accent transition-colors">
|
||||
<span class="sr-only">LinkedIn</span>
|
||||
<!-- LinkedIn icon -->
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section Skills -->
|
||||
<section class="mb-12">
|
||||
<h2 class="text-xl font-ui font-semibold mb-4 text-sky-accent">
|
||||
{{ $t('resume.skills_title') }}
|
||||
</h2>
|
||||
|
||||
<div v-if="skillsLoading" class="text-sky-text/50">{{ $t('common.loading') }}</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="category in skillsByCategory" :key="category.name">
|
||||
<span class="text-sky-text/60 text-sm">{{ category.name }}:</span>
|
||||
<span class="ml-2">
|
||||
<span
|
||||
v-for="(skill, i) in category.skills"
|
||||
:key="skill.slug"
|
||||
class="text-sky-text"
|
||||
>
|
||||
{{ skill.name }}<span v-if="i < category.skills.length - 1"> • </span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section Projets -->
|
||||
<section class="mb-12">
|
||||
<h2 class="text-xl font-ui font-semibold mb-4 text-sky-accent">
|
||||
{{ $t('resume.projects_title') }}
|
||||
</h2>
|
||||
|
||||
<div v-if="projectsLoading" class="text-sky-text/50">{{ $t('common.loading') }}</div>
|
||||
|
||||
<ul v-else class="space-y-3">
|
||||
<li v-for="project in featuredProjects" :key="project.slug" class="flex items-start gap-2">
|
||||
<span class="text-sky-accent">•</span>
|
||||
<div>
|
||||
<NuxtLink
|
||||
:to="localePath(`/projets/${project.slug}`)"
|
||||
class="font-semibold hover:text-sky-accent transition-colors"
|
||||
>
|
||||
{{ project.title }}
|
||||
</NuxtLink>
|
||||
<span class="text-sky-text/60 text-sm ml-2">{{ project.short_description }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Section Contact -->
|
||||
<section class="text-center mb-8">
|
||||
<NuxtLink
|
||||
:to="localePath('/contact')"
|
||||
class="inline-block px-8 py-4 bg-sky-accent text-sky-dark font-ui font-bold rounded-lg hover:bg-sky-accent-hover transition-colors"
|
||||
>
|
||||
{{ $t('resume.cta_contact') }}
|
||||
</NuxtLink>
|
||||
|
||||
<p class="mt-4 text-sky-text/60">
|
||||
<a href="mailto:contact@skycel.fr" class="hover:text-sky-accent transition-colors">
|
||||
contact@skycel.fr
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Lien vers aventure -->
|
||||
<div class="text-center border-t border-sky-text/10 pt-6">
|
||||
<NuxtLink
|
||||
:to="localePath('/')"
|
||||
class="text-sky-text/50 hover:text-sky-accent text-sm transition-colors"
|
||||
>
|
||||
{{ $t('resume.adventure_link') }} →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'minimal',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const { apiFetch } = useApi()
|
||||
const { setPageMeta } = useSeo()
|
||||
|
||||
// SEO
|
||||
setPageMeta({
|
||||
title: t('resume.meta_title'),
|
||||
description: t('resume.meta_description'),
|
||||
})
|
||||
|
||||
// Chargement des skills
|
||||
const { data: skills, pending: skillsLoading } = await useFetch('/api/skills', {
|
||||
baseURL: useRuntimeConfig().public.apiUrl,
|
||||
headers: {
|
||||
'X-API-Key': useRuntimeConfig().public.apiKey,
|
||||
'Accept-Language': useI18n().locale.value,
|
||||
},
|
||||
})
|
||||
|
||||
// Chargement des projets featured
|
||||
const { data: projects, pending: projectsLoading } = await useFetch('/api/projects', {
|
||||
baseURL: useRuntimeConfig().public.apiUrl,
|
||||
headers: {
|
||||
'X-API-Key': useRuntimeConfig().public.apiKey,
|
||||
'Accept-Language': useI18n().locale.value,
|
||||
},
|
||||
query: { featured: true },
|
||||
})
|
||||
|
||||
// Grouper les skills par catégorie
|
||||
const skillsByCategory = computed(() => {
|
||||
if (!skills.value?.data) return []
|
||||
|
||||
const categories = ['Frontend', 'Backend', 'Tools']
|
||||
return categories.map(cat => ({
|
||||
name: cat,
|
||||
skills: skills.value.data.filter((s: any) => s.category === cat).slice(0, 4),
|
||||
})).filter(c => c.skills.length > 0)
|
||||
})
|
||||
|
||||
// Projets featured (max 4)
|
||||
const featuredProjects = computed(() => {
|
||||
return projects.value?.data?.slice(0, 4) || []
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Traductions à ajouter
|
||||
|
||||
```json
|
||||
// frontend/i18n/fr.json
|
||||
{
|
||||
"resume": {
|
||||
"title": "Développeur Full-Stack",
|
||||
"tagline": "Passionné par les expériences web innovantes et immersives",
|
||||
"skills_title": "Stack technique",
|
||||
"projects_title": "Projets récents",
|
||||
"cta_contact": "Me contacter",
|
||||
"adventure_link": "Envie d'explorer ? Découvrir l'aventure complète",
|
||||
"meta_title": "Célian - Développeur Full-Stack | CV Express",
|
||||
"meta_description": "Développeur Full-Stack spécialisé en Vue.js, Nuxt, Laravel. Découvrez mon profil et mes projets en 30 secondes."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// frontend/i18n/en.json
|
||||
{
|
||||
"resume": {
|
||||
"title": "Full-Stack Developer",
|
||||
"tagline": "Passionate about innovative and immersive web experiences",
|
||||
"skills_title": "Tech Stack",
|
||||
"projects_title": "Recent Projects",
|
||||
"cta_contact": "Contact Me",
|
||||
"adventure_link": "Want to explore? Discover the full adventure",
|
||||
"meta_title": "Célian - Full-Stack Developer | Quick Resume",
|
||||
"meta_description": "Full-Stack Developer specialized in Vue.js, Nuxt, Laravel. Discover my profile and projects in 30 seconds."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story DÉPEND de :**
|
||||
- Story 1.3 : API bilingue, useApi composable
|
||||
- Story 1.4 : Layout minimal.vue, useSeo composable
|
||||
- Story 1.2 : API projects et skills fonctionnels
|
||||
|
||||
**Cette story PRÉPARE pour :**
|
||||
- URL directe pour candidatures (usage recruteurs)
|
||||
- Alternative à l'expérience gamifiée
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer/modifier :**
|
||||
```
|
||||
frontend/app/
|
||||
├── pages/
|
||||
│ └── resume.vue # CRÉER
|
||||
├── public/
|
||||
│ └── images/
|
||||
│ └── avatar.jpg # AJOUTER (photo Célian)
|
||||
└── i18n/
|
||||
├── fr.json # MODIFIER (ajouter resume.*)
|
||||
└── en.json # MODIFIER (ajouter resume.*)
|
||||
```
|
||||
|
||||
### Performance
|
||||
|
||||
- **Budget temps** : Chargement < 2s
|
||||
- **Données légères** : Skills (8-12 items), Projets (3-4 items)
|
||||
- **SSR** : Données chargées côté serveur pour SEO optimal
|
||||
- **Images** : Avatar optimisé via nuxt/image (WebP, dimensions fixes)
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-1.7]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Page-Resume]
|
||||
- [Source: docs/prd-gamification.md#FR1]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Layout | minimal.vue | Architecture |
|
||||
| Temps lecture | ~30 secondes | UX Design |
|
||||
| Projets affichés | 3-4 featured | UX Design |
|
||||
| Skills affichés | 8-12 max | UX Design |
|
||||
| SSR | Required | NFR5 |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
{{agent_model_name_version}}
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
### Change Log
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2026-02-03 | Story créée avec contexte complet | SM Agent |
|
||||
|
||||
### File List
|
||||
|
||||
285
docs/implementation-artifacts/2-1-composant-projectcard.md
Normal file
285
docs/implementation-artifacts/2-1-composant-projectcard.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Story 2.1: Composant ProjectCard
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a développeur,
|
||||
I want un composant réutilisable de card de projet,
|
||||
so that je peux afficher les projets de manière cohérente sur la galerie et ailleurs dans le site.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le composant `ProjectCard` est implémenté **When** il reçoit les données d'un projet en props **Then** il affiche l'image du projet (WebP, lazy loading)
|
||||
2. **And** il affiche le titre traduit selon la langue courante
|
||||
3. **And** il affiche la description courte traduite
|
||||
4. **And** un hover effect révèle un CTA "Découvrir" avec animation subtile
|
||||
5. **And** le composant est cliquable et navigue vers `/projets/{slug}` (ou `/en/projects/{slug}`)
|
||||
6. **And** le composant respecte `prefers-reduced-motion` pour les animations
|
||||
7. **And** le composant est responsive (adaptation mobile/desktop)
|
||||
8. **And** le composant est accessible (focus visible, `role` approprié)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer le composant ProjectCard.vue** (AC: #1, #2, #3)
|
||||
- [ ] Créer le fichier `frontend/app/components/feature/ProjectCard.vue`
|
||||
- [ ] Définir les props TypeScript : `project` (object avec slug, image, title, shortDescription)
|
||||
- [ ] Utiliser `<NuxtImg>` pour l'image avec format WebP et lazy loading
|
||||
- [ ] Intégrer `useI18n()` pour le titre et la description traduits
|
||||
- [ ] Afficher titre (`project.title`) et description courte (`project.shortDescription`)
|
||||
|
||||
- [ ] **Task 2: Implémenter le hover effect et CTA** (AC: #4)
|
||||
- [ ] Créer un overlay qui apparaît au hover avec transition CSS
|
||||
- [ ] Ajouter un CTA "Découvrir" (traduit via i18n) centré dans l'overlay
|
||||
- [ ] Animation subtile : fade-in + léger scale (0.98 → 1)
|
||||
- [ ] Utiliser les classes Tailwind pour les transitions
|
||||
|
||||
- [ ] **Task 3: Implémenter la navigation** (AC: #5)
|
||||
- [ ] Rendre la card entièrement cliquable avec `<NuxtLink>`
|
||||
- [ ] Utiliser `localePath()` pour générer l'URL correcte selon la langue
|
||||
- [ ] URL pattern : `/projets/{slug}` (FR) ou `/en/projects/{slug}` (EN)
|
||||
|
||||
- [ ] **Task 4: Gérer `prefers-reduced-motion`** (AC: #6)
|
||||
- [ ] Créer une media query CSS pour détecter `prefers-reduced-motion: reduce`
|
||||
- [ ] Désactiver les transitions et animations si motion réduite
|
||||
- [ ] Le hover effect reste visible mais sans animation
|
||||
|
||||
- [ ] **Task 5: Rendre le composant responsive** (AC: #7)
|
||||
- [ ] Mobile : card pleine largeur, hauteur fixe ou aspect-ratio
|
||||
- [ ] Desktop : card avec largeur flexible pour grille (min 280px, max 400px)
|
||||
- [ ] Image qui remplit la card avec `object-cover`
|
||||
- [ ] Texte tronqué si trop long (ellipsis)
|
||||
|
||||
- [ ] **Task 6: Accessibilité** (AC: #8)
|
||||
- [ ] Focus visible sur la card (outline accent)
|
||||
- [ ] `role="article"` sur la card container
|
||||
- [ ] `alt` descriptif sur l'image (utiliser le titre du projet)
|
||||
- [ ] Navigation au clavier fonctionnelle (Tab, Enter)
|
||||
|
||||
- [ ] **Task 7: Tests et validation**
|
||||
- [ ] Tester le composant avec des données de projet fictives
|
||||
- [ ] Vérifier l'affichage en FR et EN
|
||||
- [ ] Vérifier le hover effect et la navigation
|
||||
- [ ] Tester sur mobile et desktop
|
||||
- [ ] Valider l'accessibilité avec axe DevTools
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Structure du composant
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/ProjectCard.vue -->
|
||||
<script setup lang="ts">
|
||||
interface Project {
|
||||
slug: string
|
||||
image: string
|
||||
title: string
|
||||
shortDescription: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
project: Project
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="localePath(`/projets/${project.slug}`)"
|
||||
class="project-card group"
|
||||
role="article"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-lg">
|
||||
<!-- Image avec lazy loading -->
|
||||
<NuxtImg
|
||||
:src="project.image"
|
||||
:alt="project.title"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
<!-- Overlay au hover -->
|
||||
<div class="absolute inset-0 bg-sky-dark/70 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
<span class="text-sky-accent font-ui text-lg">
|
||||
{{ t('projects.discover') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contenu texte -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-ui text-lg text-sky-text font-semibold truncate">
|
||||
{{ project.title }}
|
||||
</h3>
|
||||
<p class="font-ui text-sm text-sky-text-muted line-clamp-2 mt-1">
|
||||
{{ project.shortDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.project-card * {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.project-card:hover img {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus visible */
|
||||
.project-card:focus-visible {
|
||||
outline: 2px solid theme('colors.sky-accent.DEFAULT');
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Interface TypeScript pour Project
|
||||
|
||||
```typescript
|
||||
// frontend/app/types/project.ts
|
||||
export interface Project {
|
||||
id: number
|
||||
slug: string
|
||||
image: string
|
||||
title: string // Déjà traduit par l'API
|
||||
description: string // Déjà traduit par l'API
|
||||
shortDescription: string // Déjà traduit par l'API
|
||||
url?: string
|
||||
githubUrl?: string
|
||||
dateCompleted: string
|
||||
isFeatured: boolean
|
||||
displayOrder: number
|
||||
skills?: ProjectSkill[]
|
||||
}
|
||||
|
||||
export interface ProjectSkill {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
levelBefore: number
|
||||
levelAfter: number
|
||||
}
|
||||
```
|
||||
|
||||
### Clés i18n nécessaires
|
||||
|
||||
Ajouter dans `frontend/i18n/fr.json` et `frontend/i18n/en.json` :
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"discover": "Découvrir"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"discover": "Discover"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Design Tokens utilisés
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `sky-dark` | Fond sombre | Overlay au hover |
|
||||
| `sky-accent` | #fa784f | CTA "Découvrir" |
|
||||
| `sky-text` | Blanc cassé | Titre projet |
|
||||
| `sky-text-muted` | Variante atténuée | Description courte |
|
||||
| `font-ui` | Inter | Tout le texte du composant |
|
||||
|
||||
### Comportement responsive
|
||||
|
||||
| Breakpoint | Comportement |
|
||||
|------------|--------------|
|
||||
| Mobile (< 768px) | Card pleine largeur, hauteur image 180px |
|
||||
| Tablette (768px+) | Cards en grille 2 colonnes |
|
||||
| Desktop (1024px+) | Cards en grille 3-4 colonnes |
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Ce composant nécessite :**
|
||||
- Story 1.1 : Nuxt 4 initialisé avec `@nuxt/image`, `@nuxtjs/i18n`, TailwindCSS
|
||||
- Story 1.2 : Model Project avec structure de données
|
||||
- Story 1.3 : Système i18n configuré
|
||||
|
||||
**Ce composant sera utilisé par :**
|
||||
- Story 2.2 : Page Projets - Galerie
|
||||
- Story 1.7 : Page Résumé Express (projets highlights)
|
||||
- Story 2.5 : Compétences cliquables (liste des projets liés)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── components/
|
||||
│ └── feature/
|
||||
│ └── ProjectCard.vue # CRÉER
|
||||
├── types/
|
||||
│ └── project.ts # CRÉER (si n'existe pas)
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/i18n/
|
||||
├── fr.json # AJOUTER clés projects.*
|
||||
└── en.json # AJOUTER clés projects.*
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-2.1]
|
||||
- [Source: docs/planning-artifacts/architecture.md#Frontend-Architecture]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Visual-Design-Foundation]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Accessibility-Strategy]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Image format | WebP avec fallback | NFR8 |
|
||||
| Lazy loading | Native via NuxtImg | NFR1, NFR2 |
|
||||
| Animations | Respect prefers-reduced-motion | NFR6 |
|
||||
| Accessibilité | WCAG AA | UX Spec |
|
||||
| Responsive | Mobile-first | UX Spec |
|
||||
|
||||
### Previous Story Intelligence (Epic 1)
|
||||
|
||||
**Patterns établis à suivre :**
|
||||
- Composants feature dans `app/components/feature/`
|
||||
- Types TypeScript dans `app/types/`
|
||||
- Design tokens TailwindCSS : `sky-dark`, `sky-accent`, `sky-text`
|
||||
- Polices : `font-ui` (sans-serif), `font-narrative` (serif)
|
||||
- i18n via `useI18n()` et `localePath()`
|
||||
|
||||
## 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
|
||||
|
||||
387
docs/implementation-artifacts/2-2-page-projets-galerie.md
Normal file
387
docs/implementation-artifacts/2-2-page-projets-galerie.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# Story 2.2: Page Projets - Galerie
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want voir la liste des projets réalisés par le développeur,
|
||||
so that je peux évaluer son expérience et choisir lesquels explorer en détail.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur accède à `/projets` (FR) ou `/en/projects` (EN) **When** la page se charge **Then** une grille responsive de `ProjectCard` s'affiche
|
||||
2. **And** les projets sont triés par date avec les "featured" en tête
|
||||
3. **And** une animation d'entrée progressive des cards est présente (respectant `prefers-reduced-motion`)
|
||||
4. **And** les données sont chargées depuis l'API `/api/projects` avec le contenu traduit
|
||||
5. **And** les meta tags SEO sont dynamiques pour cette page
|
||||
6. **And** le layout s'adapte : grille sur desktop, cards empilées sur mobile
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer l'endpoint API Laravel** (AC: #4)
|
||||
- [ ] Créer `app/Http/Controllers/Api/ProjectController.php`
|
||||
- [ ] Créer la méthode `index()` pour lister tous les projets
|
||||
- [ ] Implémenter le tri : featured en premier, puis par date_completed DESC
|
||||
- [ ] Joindre les traductions selon le header `Accept-Language`
|
||||
- [ ] Créer `app/Http/Resources/ProjectResource.php` pour formater la réponse
|
||||
- [ ] Ajouter la route `GET /api/projects` dans `routes/api.php`
|
||||
|
||||
- [ ] **Task 2: Créer le composable useFetchProjects** (AC: #4)
|
||||
- [ ] Créer `frontend/app/composables/useFetchProjects.ts`
|
||||
- [ ] Utiliser `useFetch()` pour appeler l'API avec le header `Accept-Language`
|
||||
- [ ] Gérer les états loading, error, data
|
||||
- [ ] Typer la réponse avec l'interface Project[]
|
||||
|
||||
- [ ] **Task 3: Créer la page projets.vue** (AC: #1, #6)
|
||||
- [ ] Créer `frontend/app/pages/projets.vue`
|
||||
- [ ] Utiliser le composable `useFetchProjects()` pour charger les données
|
||||
- [ ] Afficher une grille de `ProjectCard` avec les données
|
||||
- [ ] Implémenter le layout responsive : 1 colonne mobile, 2 tablette, 3-4 desktop
|
||||
|
||||
- [ ] **Task 4: Implémenter l'animation d'entrée** (AC: #3)
|
||||
- [ ] Animer l'apparition progressive des cards (stagger animation)
|
||||
- [ ] Utiliser CSS animations ou GSAP pour un effet fade-in + slide-up
|
||||
- [ ] Respecter `prefers-reduced-motion` : pas d'animation si activé
|
||||
- [ ] Délai de 50-100ms entre chaque card
|
||||
|
||||
- [ ] **Task 5: Tri des projets** (AC: #2)
|
||||
- [ ] S'assurer que l'API retourne les projets dans le bon ordre
|
||||
- [ ] Vérifier côté frontend que l'ordre est respecté
|
||||
- [ ] Les projets `is_featured: true` apparaissent en premier
|
||||
- [ ] Puis tri par `date_completed` DESC
|
||||
|
||||
- [ ] **Task 6: Meta tags SEO** (AC: #5)
|
||||
- [ ] Utiliser `useHead()` pour définir le titre dynamique
|
||||
- [ ] Utiliser `useSeoMeta()` pour les meta description, og:title, og:description
|
||||
- [ ] Ajouter les clés i18n pour titre et description de la page
|
||||
- [ ] Exemple titre : "Projets | Skycel" / "Projects | Skycel"
|
||||
|
||||
- [ ] **Task 7: État loading et erreur**
|
||||
- [ ] Afficher un skeleton/loading state pendant le chargement
|
||||
- [ ] Afficher un message d'erreur narratif si l'API échoue
|
||||
- [ ] Bouton "Réessayer" en cas d'erreur
|
||||
|
||||
- [ ] **Task 8: Tests et validation**
|
||||
- [ ] Tester la page en FR et EN
|
||||
- [ ] Vérifier le tri des projets
|
||||
- [ ] Tester l'animation d'entrée
|
||||
- [ ] Valider le responsive sur mobile/tablette/desktop
|
||||
- [ ] Vérifier les meta tags avec l'inspecteur
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Endpoint API Laravel
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Controllers/Api/ProjectController.php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$lang = $request->header('Accept-Language', 'fr');
|
||||
|
||||
$projects = Project::query()
|
||||
->with(['skills'])
|
||||
->orderByDesc('is_featured')
|
||||
->orderByDesc('date_completed')
|
||||
->get();
|
||||
|
||||
return ProjectResource::collection($projects)
|
||||
->additional(['meta' => ['lang' => $lang]]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Resources/ProjectResource.php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Translation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ProjectResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$lang = $request->header('Accept-Language', 'fr');
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->slug,
|
||||
'title' => Translation::getTranslation($this->title_key, $lang),
|
||||
'description' => Translation::getTranslation($this->description_key, $lang),
|
||||
'shortDescription' => Translation::getTranslation($this->short_description_key, $lang),
|
||||
'image' => $this->image,
|
||||
'url' => $this->url,
|
||||
'githubUrl' => $this->github_url,
|
||||
'dateCompleted' => $this->date_completed?->format('Y-m-d'),
|
||||
'isFeatured' => $this->is_featured,
|
||||
'displayOrder' => $this->display_order,
|
||||
'skills' => $this->whenLoaded('skills', function () use ($lang) {
|
||||
return $this->skills->map(fn ($skill) => [
|
||||
'id' => $skill->id,
|
||||
'slug' => $skill->slug,
|
||||
'name' => Translation::getTranslation($skill->name_key, $lang),
|
||||
'levelBefore' => $skill->pivot->level_before,
|
||||
'levelAfter' => $skill->pivot->level_after,
|
||||
]);
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// api/routes/api.php
|
||||
Route::get('/projects', [ProjectController::class, 'index']);
|
||||
```
|
||||
|
||||
### Composable useFetchProjects
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useFetchProjects.ts
|
||||
import type { Project } from '~/types/project'
|
||||
|
||||
export function useFetchProjects() {
|
||||
const config = useRuntimeConfig()
|
||||
const { locale } = useI18n()
|
||||
|
||||
return useFetch<{ data: Project[], meta: { lang: string } }>('/projects', {
|
||||
baseURL: config.public.apiUrl,
|
||||
headers: {
|
||||
'X-API-Key': config.public.apiKey,
|
||||
'Accept-Language': locale.value,
|
||||
},
|
||||
transform: (response) => response.data,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Page Projets
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/projets.vue -->
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const { data: projects, pending, error, refresh } = useFetchProjects()
|
||||
|
||||
// SEO
|
||||
useHead({
|
||||
title: () => t('projects.pageTitle'),
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('projects.pageTitle'),
|
||||
description: () => t('projects.pageDescription'),
|
||||
ogTitle: () => t('projects.pageTitle'),
|
||||
ogDescription: () => t('projects.pageDescription'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-ui font-bold text-sky-text mb-8">
|
||||
{{ t('projects.title') }}
|
||||
</h1>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="pending" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div v-for="i in 6" :key="i" class="animate-pulse">
|
||||
<div class="bg-sky-dark-50 rounded-lg h-48"></div>
|
||||
<div class="p-4">
|
||||
<div class="bg-sky-dark-50 h-6 rounded w-3/4 mb-2"></div>
|
||||
<div class="bg-sky-dark-50 h-4 rounded w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<p class="text-sky-text-muted mb-4">{{ t('projects.loadError') }}</p>
|
||||
<button
|
||||
@click="refresh()"
|
||||
class="bg-sky-accent text-white px-6 py-2 rounded-lg hover:bg-sky-accent-hover"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Projects grid -->
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
<ProjectCard
|
||||
v-for="(project, index) in projects"
|
||||
:key="project.id"
|
||||
:project="project"
|
||||
class="project-card-animated"
|
||||
:style="{ '--animation-delay': `${index * 100}ms` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-card-animated {
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
animation-delay: var(--animation-delay, 0ms);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.project-card-animated {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Clés i18n nécessaires
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"title": "Mes Projets",
|
||||
"pageTitle": "Projets | Skycel",
|
||||
"pageDescription": "Découvrez les projets réalisés par Célian, développeur web full-stack.",
|
||||
"discover": "Découvrir",
|
||||
"loadError": "Impossible de charger les projets..."
|
||||
},
|
||||
"common": {
|
||||
"retry": "Réessayer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"title": "My Projects",
|
||||
"pageTitle": "Projects | Skycel",
|
||||
"pageDescription": "Discover projects created by Célian, full-stack web developer.",
|
||||
"discover": "Discover",
|
||||
"loadError": "Unable to load projects..."
|
||||
},
|
||||
"common": {
|
||||
"retry": "Retry"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Layout responsive
|
||||
|
||||
| Breakpoint | Colonnes | Gap |
|
||||
|------------|----------|-----|
|
||||
| Mobile (< 768px) | 1 | 24px |
|
||||
| Tablette (768px - 1023px) | 2 | 24px |
|
||||
| Desktop (1024px - 1279px) | 3 | 24px |
|
||||
| Large (≥ 1280px) | 4 | 24px |
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 1.1 : Nuxt 4 + Laravel 12 initialisés
|
||||
- Story 1.2 : Table projects, Model Project avec relations
|
||||
- Story 1.3 : Système i18n configuré
|
||||
- Story 1.4 : Layouts et routing
|
||||
- Story 2.1 : Composant ProjectCard
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 2.3 : Page Projet - Détail (navigation depuis la galerie)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
api/app/Http/
|
||||
├── Controllers/Api/
|
||||
│ └── ProjectController.php # CRÉER
|
||||
└── Resources/
|
||||
└── ProjectResource.php # CRÉER
|
||||
|
||||
frontend/app/
|
||||
├── pages/
|
||||
│ └── projets.vue # CRÉER
|
||||
└── composables/
|
||||
└── useFetchProjects.ts # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
api/routes/api.php # AJOUTER route /projects
|
||||
frontend/i18n/fr.json # AJOUTER clés projects.*
|
||||
frontend/i18n/en.json # AJOUTER clés projects.*
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-2.2]
|
||||
- [Source: docs/planning-artifacts/architecture.md#API-&-Communication-Patterns]
|
||||
- [Source: docs/planning-artifacts/architecture.md#Frontend-Architecture]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Screen-Architecture-Summary]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| API endpoint | GET /api/projects | Architecture |
|
||||
| Response format | { data: [], meta: {} } | Architecture |
|
||||
| Header langue | Accept-Language | Architecture |
|
||||
| Animation | Stagger fade-in | Epics |
|
||||
| SEO | Meta tags dynamiques | NFR5 |
|
||||
|
||||
### Previous Story Intelligence
|
||||
|
||||
**Patterns établis à suivre :**
|
||||
- Controllers API dans `app/Http/Controllers/Api/`
|
||||
- Resources dans `app/Http/Resources/`
|
||||
- Composables dans `app/composables/`
|
||||
- Pages dans `app/pages/`
|
||||
- Utiliser `useFetch()` avec `baseURL` et headers
|
||||
|
||||
## 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
|
||||
|
||||
452
docs/implementation-artifacts/2-3-page-projet-detail.md
Normal file
452
docs/implementation-artifacts/2-3-page-projet-detail.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# Story 2.3: Page Projet - Détail
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want voir les détails d'un projet spécifique,
|
||||
so that je comprends le travail réalisé et les technologies utilisées.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur accède à `/projets/{slug}` (FR) ou `/en/projects/{slug}` (EN) **When** la page se charge **Then** le titre, la description complète et l'image principale du projet s'affichent
|
||||
2. **And** la date de réalisation est visible
|
||||
3. **And** la liste des compétences utilisées s'affiche avec leurs niveaux (avant/après le projet)
|
||||
4. **And** les liens externes sont présents : URL du projet live (si existe), repository GitHub (si existe)
|
||||
5. **And** une navigation "Projet précédent / Projet suivant" permet de parcourir les projets
|
||||
6. **And** un bouton retour vers la galerie est visible
|
||||
7. **And** les meta tags SEO sont dynamiques (titre, description, image Open Graph)
|
||||
8. **And** si le slug n'existe pas, une page 404 appropriée s'affiche
|
||||
9. **And** le design est responsive (adaptation mobile/desktop)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer l'endpoint API pour le détail du projet** (AC: #1, #2, #3, #4, #8)
|
||||
- [ ] Ajouter la méthode `show($slug)` dans `ProjectController`
|
||||
- [ ] Charger le projet avec ses compétences (eager loading)
|
||||
- [ ] Retourner 404 si le slug n'existe pas
|
||||
- [ ] Inclure les données de traduction selon `Accept-Language`
|
||||
|
||||
- [ ] **Task 2: Créer l'endpoint API pour la navigation prev/next** (AC: #5)
|
||||
- [ ] Ajouter une méthode `navigation($slug)` ou inclure dans `show()`
|
||||
- [ ] Retourner le projet précédent et suivant (basé sur l'ordre de tri)
|
||||
- [ ] Si premier projet : prev = null, si dernier : next = null
|
||||
|
||||
- [ ] **Task 3: Créer le composable useFetchProject** (AC: #1)
|
||||
- [ ] Créer `frontend/app/composables/useFetchProject.ts`
|
||||
- [ ] Accepter le slug en paramètre
|
||||
- [ ] Gérer les états loading, error, data
|
||||
- [ ] Gérer l'erreur 404
|
||||
|
||||
- [ ] **Task 4: Créer la page [slug].vue** (AC: #1, #2, #3, #4, #6, #9)
|
||||
- [ ] Créer `frontend/app/pages/projets/[slug].vue`
|
||||
- [ ] Afficher l'image principale en grand format
|
||||
- [ ] Afficher le titre et la description complète
|
||||
- [ ] Afficher la date de réalisation formatée
|
||||
- [ ] Afficher la liste des compétences avec progression (avant → après)
|
||||
- [ ] Afficher les liens externes (site live, GitHub) si présents
|
||||
- [ ] Ajouter un bouton "Retour à la galerie"
|
||||
|
||||
- [ ] **Task 5: Implémenter la navigation prev/next** (AC: #5)
|
||||
- [ ] Ajouter les boutons "Projet précédent" et "Projet suivant"
|
||||
- [ ] Utiliser NuxtLink pour la navigation
|
||||
- [ ] Afficher le titre du projet dans le bouton
|
||||
- [ ] Désactiver/masquer si pas de prev ou next
|
||||
|
||||
- [ ] **Task 6: Meta tags SEO dynamiques** (AC: #7)
|
||||
- [ ] Utiliser `useHead()` avec le titre du projet
|
||||
- [ ] Utiliser `useSeoMeta()` pour description, og:title, og:description, og:image
|
||||
- [ ] L'image OG doit être l'image du projet
|
||||
|
||||
- [ ] **Task 7: Gestion de l'erreur 404** (AC: #8)
|
||||
- [ ] Détecter si le projet n'existe pas
|
||||
- [ ] Afficher un message approprié avec le narrateur
|
||||
- [ ] Proposer de retourner à la galerie
|
||||
|
||||
- [ ] **Task 8: Design responsive** (AC: #9)
|
||||
- [ ] Mobile : layout vertical, image pleine largeur
|
||||
- [ ] Desktop : layout 2 colonnes (image + contenu) ou grande image + contenu dessous
|
||||
- [ ] Liste des compétences responsive (flex wrap)
|
||||
|
||||
- [ ] **Task 9: Tests et validation**
|
||||
- [ ] Tester avec différents slugs de projets
|
||||
- [ ] Tester la navigation prev/next
|
||||
- [ ] Tester le 404 avec un slug inexistant
|
||||
- [ ] Valider les meta tags SEO
|
||||
- [ ] Tester le responsive
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Endpoint API Laravel
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Controllers/Api/ProjectController.php
|
||||
|
||||
public function show(Request $request, string $slug)
|
||||
{
|
||||
$lang = $request->header('Accept-Language', 'fr');
|
||||
|
||||
$project = Project::with('skills')
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if (!$project) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'PROJECT_NOT_FOUND',
|
||||
'message' => 'Project not found',
|
||||
]
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Navigation prev/next
|
||||
$allProjects = Project::orderByDesc('is_featured')
|
||||
->orderByDesc('date_completed')
|
||||
->get(['id', 'slug', 'title_key']);
|
||||
|
||||
$currentIndex = $allProjects->search(fn ($p) => $p->slug === $slug);
|
||||
|
||||
$prev = $currentIndex > 0 ? $allProjects[$currentIndex - 1] : null;
|
||||
$next = $currentIndex < $allProjects->count() - 1 ? $allProjects[$currentIndex + 1] : null;
|
||||
|
||||
return (new ProjectResource($project))->additional([
|
||||
'meta' => [
|
||||
'lang' => $lang,
|
||||
],
|
||||
'navigation' => [
|
||||
'prev' => $prev ? [
|
||||
'slug' => $prev->slug,
|
||||
'title' => Translation::getTranslation($prev->title_key, $lang),
|
||||
] : null,
|
||||
'next' => $next ? [
|
||||
'slug' => $next->slug,
|
||||
'title' => Translation::getTranslation($next->title_key, $lang),
|
||||
] : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// api/routes/api.php
|
||||
Route::get('/projects/{slug}', [ProjectController::class, 'show']);
|
||||
```
|
||||
|
||||
### Composable useFetchProject
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useFetchProject.ts
|
||||
import type { Project } from '~/types/project'
|
||||
|
||||
interface ProjectNavigation {
|
||||
prev: { slug: string; title: string } | null
|
||||
next: { slug: string; title: string } | null
|
||||
}
|
||||
|
||||
interface ProjectResponse {
|
||||
data: Project
|
||||
meta: { lang: string }
|
||||
navigation: ProjectNavigation
|
||||
}
|
||||
|
||||
export function useFetchProject(slug: string | Ref<string>) {
|
||||
const config = useRuntimeConfig()
|
||||
const { locale } = useI18n()
|
||||
const slugValue = toValue(slug)
|
||||
|
||||
return useFetch<ProjectResponse>(`/projects/${slugValue}`, {
|
||||
baseURL: config.public.apiUrl,
|
||||
headers: {
|
||||
'X-API-Key': config.public.apiKey,
|
||||
'Accept-Language': locale.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Page [slug].vue
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/projets/[slug].vue -->
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const { t, d } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
const { data, pending, error } = useFetchProject(slug)
|
||||
|
||||
const project = computed(() => data.value?.data)
|
||||
const navigation = computed(() => data.value?.navigation)
|
||||
|
||||
// SEO dynamique
|
||||
useHead({
|
||||
title: () => project.value?.title ? `${project.value.title} | Skycel` : t('projects.loading'),
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: () => project.value?.title,
|
||||
description: () => project.value?.shortDescription,
|
||||
ogTitle: () => project.value?.title,
|
||||
ogDescription: () => project.value?.shortDescription,
|
||||
ogImage: () => project.value?.image,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Loading -->
|
||||
<div v-if="pending" class="animate-pulse">
|
||||
<div class="bg-sky-dark-50 rounded-lg h-64 mb-6"></div>
|
||||
<div class="bg-sky-dark-50 h-10 rounded w-1/2 mb-4"></div>
|
||||
<div class="bg-sky-dark-50 h-4 rounded w-full mb-2"></div>
|
||||
<div class="bg-sky-dark-50 h-4 rounded w-3/4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error 404 -->
|
||||
<div v-else-if="error" class="text-center py-16">
|
||||
<h1 class="text-2xl font-ui font-bold text-sky-text mb-4">
|
||||
{{ t('projects.notFound') }}
|
||||
</h1>
|
||||
<p class="text-sky-text-muted mb-6">
|
||||
{{ t('projects.notFoundDescription') }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
:to="localePath('/projets')"
|
||||
class="bg-sky-accent text-white px-6 py-3 rounded-lg hover:bg-sky-accent-hover inline-block"
|
||||
>
|
||||
{{ t('projects.backToGallery') }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Project content -->
|
||||
<article v-else-if="project">
|
||||
<!-- Retour galerie -->
|
||||
<NuxtLink
|
||||
:to="localePath('/projets')"
|
||||
class="inline-flex items-center text-sky-text-muted hover:text-sky-accent mb-6 transition-colors"
|
||||
>
|
||||
<span class="mr-2">←</span>
|
||||
{{ t('projects.backToGallery') }}
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Image principale -->
|
||||
<div class="mb-8">
|
||||
<NuxtImg
|
||||
:src="project.image"
|
||||
:alt="project.title"
|
||||
format="webp"
|
||||
class="w-full h-auto max-h-96 object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Titre et date -->
|
||||
<header class="mb-6">
|
||||
<h1 class="text-3xl md:text-4xl font-ui font-bold text-sky-text mb-2">
|
||||
{{ project.title }}
|
||||
</h1>
|
||||
<p class="text-sky-text-muted">
|
||||
{{ t('projects.completedOn') }} {{ d(new Date(project.dateCompleted), 'long') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="prose prose-invert max-w-none mb-8">
|
||||
<p class="text-sky-text text-lg leading-relaxed">
|
||||
{{ project.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Liens externes -->
|
||||
<div v-if="project.url || project.githubUrl" class="flex flex-wrap gap-4 mb-8">
|
||||
<a
|
||||
v-if="project.url"
|
||||
:href="project.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center bg-sky-accent text-white px-6 py-3 rounded-lg hover:bg-sky-accent-hover transition-colors"
|
||||
>
|
||||
🌐 {{ t('projects.visitSite') }}
|
||||
</a>
|
||||
<a
|
||||
v-if="project.githubUrl"
|
||||
:href="project.githubUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center bg-sky-dark-50 text-sky-text px-6 py-3 rounded-lg hover:bg-sky-dark-100 transition-colors border border-sky-dark-100"
|
||||
>
|
||||
💻 {{ t('projects.viewCode') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Compétences utilisées -->
|
||||
<section v-if="project.skills?.length" class="mb-12">
|
||||
<h2 class="text-xl font-ui font-semibold text-sky-text mb-4">
|
||||
{{ t('projects.skillsUsed') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="skill in project.skills"
|
||||
:key="skill.id"
|
||||
class="bg-sky-dark-50 rounded-lg p-4"
|
||||
>
|
||||
<div class="font-ui font-medium text-sky-text">{{ skill.name }}</div>
|
||||
<div class="text-sm text-sky-text-muted mt-1">
|
||||
{{ t('projects.skillLevel') }}:
|
||||
<span class="text-sky-accent">{{ skill.levelBefore }}</span>
|
||||
→
|
||||
<span class="text-sky-accent font-semibold">{{ skill.levelAfter }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Navigation prev/next -->
|
||||
<nav class="flex justify-between items-center border-t border-sky-dark-100 pt-8 mt-8">
|
||||
<NuxtLink
|
||||
v-if="navigation?.prev"
|
||||
:to="localePath(`/projets/${navigation.prev.slug}`)"
|
||||
class="flex flex-col text-left hover:text-sky-accent transition-colors"
|
||||
>
|
||||
<span class="text-sm text-sky-text-muted">{{ t('projects.previous') }}</span>
|
||||
<span class="text-sky-text">← {{ navigation.prev.title }}</span>
|
||||
</NuxtLink>
|
||||
<div v-else></div>
|
||||
|
||||
<NuxtLink
|
||||
v-if="navigation?.next"
|
||||
:to="localePath(`/projets/${navigation.next.slug}`)"
|
||||
class="flex flex-col text-right hover:text-sky-accent transition-colors"
|
||||
>
|
||||
<span class="text-sm text-sky-text-muted">{{ t('projects.next') }}</span>
|
||||
<span class="text-sky-text">{{ navigation.next.title }} →</span>
|
||||
</NuxtLink>
|
||||
<div v-else></div>
|
||||
</nav>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Clés i18n nécessaires
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"loading": "Chargement...",
|
||||
"notFound": "Projet introuvable",
|
||||
"notFoundDescription": "Ce projet n'existe pas ou a été supprimé.",
|
||||
"backToGallery": "Retour à la galerie",
|
||||
"completedOn": "Réalisé le",
|
||||
"visitSite": "Voir le site",
|
||||
"viewCode": "Voir le code",
|
||||
"skillsUsed": "Compétences utilisées",
|
||||
"skillLevel": "Niveau",
|
||||
"previous": "Projet précédent",
|
||||
"next": "Projet suivant"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"loading": "Loading...",
|
||||
"notFound": "Project not found",
|
||||
"notFoundDescription": "This project doesn't exist or has been removed.",
|
||||
"backToGallery": "Back to gallery",
|
||||
"completedOn": "Completed on",
|
||||
"visitSite": "Visit site",
|
||||
"viewCode": "View code",
|
||||
"skillsUsed": "Skills used",
|
||||
"skillLevel": "Level",
|
||||
"previous": "Previous project",
|
||||
"next": "Next project"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration i18n pour les dates
|
||||
|
||||
Ajouter dans `nuxt.config.ts` la configuration des formats de date :
|
||||
|
||||
```typescript
|
||||
i18n: {
|
||||
datetimeFormats: {
|
||||
fr: {
|
||||
long: { year: 'numeric', month: 'long', day: 'numeric' }
|
||||
},
|
||||
en: {
|
||||
long: { year: 'numeric', month: 'long', day: 'numeric' }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 2.1 : Composant ProjectCard
|
||||
- Story 2.2 : Endpoint API `/api/projects` et page galerie
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 2.5 : Compétences cliquables (liens vers projets)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── pages/
|
||||
│ └── projets/
|
||||
│ └── [slug].vue # CRÉER
|
||||
└── composables/
|
||||
└── useFetchProject.ts # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
api/app/Http/Controllers/Api/ProjectController.php # AJOUTER show()
|
||||
api/routes/api.php # AJOUTER route
|
||||
frontend/i18n/fr.json # AJOUTER clés
|
||||
frontend/i18n/en.json # AJOUTER clés
|
||||
frontend/nuxt.config.ts # AJOUTER datetimeFormats
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-2.3]
|
||||
- [Source: docs/planning-artifacts/architecture.md#API-&-Communication-Patterns]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Responsive-Strategy]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Route dynamique | /projets/[slug] | Nuxt routing |
|
||||
| API endpoint | GET /api/projects/{slug} | Architecture |
|
||||
| Navigation | prev/next avec titres | Epics |
|
||||
| SEO | Meta dynamiques + OG image | NFR5 |
|
||||
| 404 | Message approprié | 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
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
# Story 2.4: Page Compétences - Affichage par catégories
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want voir les compétences du développeur organisées par catégorie,
|
||||
so that je comprends son profil technique global.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur accède à `/competences` (FR) ou `/en/skills` (EN) **When** la page se charge **Then** les compétences sont affichées groupées par catégorie (Frontend, Backend, Tools, Soft skills)
|
||||
2. **And** chaque compétence affiche : icône, nom traduit, niveau actuel (représentation visuelle)
|
||||
3. **And** les données sont chargées depuis l'API `/api/skills` avec le contenu traduit
|
||||
4. **And** une animation d'entrée des éléments est présente (respectant `prefers-reduced-motion`)
|
||||
5. **And** sur desktop : préparé pour accueillir le skill tree vis.js (Epic 3)
|
||||
6. **And** sur mobile : liste groupée par catégorie avec design adapté
|
||||
7. **And** les meta tags SEO sont dynamiques pour cette page
|
||||
8. **And** chaque compétence est visuellement cliquable (affordance)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer l'endpoint API Laravel pour les skills** (AC: #3)
|
||||
- [ ] Créer `app/Http/Controllers/Api/SkillController.php`
|
||||
- [ ] Créer la méthode `index()` pour lister toutes les compétences
|
||||
- [ ] Grouper les compétences par catégorie
|
||||
- [ ] Joindre les traductions selon `Accept-Language`
|
||||
- [ ] Créer `app/Http/Resources/SkillResource.php`
|
||||
- [ ] Ajouter la route `GET /api/skills` dans `routes/api.php`
|
||||
|
||||
- [ ] **Task 2: Créer le composable useFetchSkills** (AC: #3)
|
||||
- [ ] Créer `frontend/app/composables/useFetchSkills.ts`
|
||||
- [ ] Gérer les états loading, error, data
|
||||
- [ ] Typer la réponse avec interface Skill[]
|
||||
|
||||
- [ ] **Task 3: Créer le composant SkillCard** (AC: #2, #8)
|
||||
- [ ] Créer `frontend/app/components/feature/SkillCard.vue`
|
||||
- [ ] Props : skill (avec name, icon, level, maxLevel)
|
||||
- [ ] Afficher l'icône (si présente) ou un placeholder
|
||||
- [ ] Afficher le nom traduit
|
||||
- [ ] Afficher le niveau avec une barre de progression
|
||||
- [ ] Style cliquable (hover effect, cursor pointer)
|
||||
|
||||
- [ ] **Task 4: Créer la page competences.vue** (AC: #1, #6)
|
||||
- [ ] Créer `frontend/app/pages/competences.vue`
|
||||
- [ ] Charger les données avec `useFetchSkills()`
|
||||
- [ ] Grouper les skills par catégorie côté frontend
|
||||
- [ ] Afficher chaque catégorie comme une section avec titre
|
||||
- [ ] Grille de SkillCard dans chaque section
|
||||
|
||||
- [ ] **Task 5: Implémenter l'animation d'entrée** (AC: #4)
|
||||
- [ ] Animation stagger pour les SkillCards (comme ProjectCard)
|
||||
- [ ] Animation fade-in pour les titres de catégories
|
||||
- [ ] Respecter `prefers-reduced-motion`
|
||||
|
||||
- [ ] **Task 6: Design responsive** (AC: #5, #6)
|
||||
- [ ] Mobile : 2 colonnes de SkillCards par catégorie
|
||||
- [ ] Desktop : 4 colonnes, espace réservé pour vis.js (Epic 3)
|
||||
- [ ] Catégories empilées verticalement
|
||||
|
||||
- [ ] **Task 7: Représentation visuelle du niveau** (AC: #2)
|
||||
- [ ] Créer une barre de progression stylisée (style RPG/XP)
|
||||
- [ ] Utiliser `sky-accent` pour la partie remplie
|
||||
- [ ] Afficher le ratio (ex: 4/5 ou 80%)
|
||||
- [ ] Animation subtile au chargement (remplissage progressif)
|
||||
|
||||
- [ ] **Task 8: Meta tags SEO** (AC: #7)
|
||||
- [ ] Titre dynamique : "Compétences | Skycel"
|
||||
- [ ] Description : compétences de Célian
|
||||
- [ ] og:title et og:description
|
||||
|
||||
- [ ] **Task 9: Tests et validation**
|
||||
- [ ] Tester en FR et EN
|
||||
- [ ] Vérifier le groupement par catégorie
|
||||
- [ ] Valider les animations
|
||||
- [ ] Tester le responsive
|
||||
- [ ] Vérifier que les skills sont cliquables (préparation Story 2.5)
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Endpoint API Laravel
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Controllers/Api/SkillController.php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\SkillResource;
|
||||
use App\Models\Skill;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SkillController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$lang = $request->header('Accept-Language', 'fr');
|
||||
|
||||
$skills = Skill::with('projects')
|
||||
->orderBy('category')
|
||||
->orderBy('display_order')
|
||||
->get();
|
||||
|
||||
// Grouper par catégorie
|
||||
$grouped = $skills->groupBy('category');
|
||||
|
||||
return response()->json([
|
||||
'data' => $grouped->map(function ($categorySkills, $category) use ($lang) {
|
||||
return [
|
||||
'category' => $category,
|
||||
'categoryLabel' => $this->getCategoryLabel($category, $lang),
|
||||
'skills' => SkillResource::collection($categorySkills),
|
||||
];
|
||||
})->values(),
|
||||
'meta' => [
|
||||
'lang' => $lang,
|
||||
'total' => $skills->count(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function getCategoryLabel(string $category, string $lang): string
|
||||
{
|
||||
$labels = [
|
||||
'frontend' => ['fr' => 'Frontend', 'en' => 'Frontend'],
|
||||
'backend' => ['fr' => 'Backend', 'en' => 'Backend'],
|
||||
'tools' => ['fr' => 'Outils', 'en' => 'Tools'],
|
||||
'soft_skills' => ['fr' => 'Soft Skills', 'en' => 'Soft Skills'],
|
||||
];
|
||||
|
||||
return $labels[strtolower($category)][$lang] ?? $category;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Resources/SkillResource.php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Translation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class SkillResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$lang = $request->header('Accept-Language', 'fr');
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->slug,
|
||||
'name' => Translation::getTranslation($this->name_key, $lang),
|
||||
'description' => Translation::getTranslation($this->description_key, $lang),
|
||||
'icon' => $this->icon,
|
||||
'category' => $this->category,
|
||||
'level' => $this->getCurrentLevel(),
|
||||
'maxLevel' => $this->max_level,
|
||||
'displayOrder' => $this->display_order,
|
||||
'projectCount' => $this->whenLoaded('projects', fn () => $this->projects->count()),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// api/app/Models/Skill.php - Ajouter méthode
|
||||
public function getCurrentLevel(): int
|
||||
{
|
||||
// Retourne le niveau max atteint dans tous les projets
|
||||
$maxLevelAfter = $this->projects()
|
||||
->max('skill_project.level_after');
|
||||
|
||||
return $maxLevelAfter ?? 1;
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// api/routes/api.php
|
||||
Route::get('/skills', [SkillController::class, 'index']);
|
||||
```
|
||||
|
||||
### Composable useFetchSkills
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useFetchSkills.ts
|
||||
import type { Skill, SkillCategory } from '~/types/skill'
|
||||
|
||||
interface SkillsResponse {
|
||||
data: SkillCategory[]
|
||||
meta: { lang: string; total: number }
|
||||
}
|
||||
|
||||
export function useFetchSkills() {
|
||||
const config = useRuntimeConfig()
|
||||
const { locale } = useI18n()
|
||||
|
||||
return useFetch<SkillsResponse>('/skills', {
|
||||
baseURL: config.public.apiUrl,
|
||||
headers: {
|
||||
'X-API-Key': config.public.apiKey,
|
||||
'Accept-Language': locale.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Types TypeScript
|
||||
|
||||
```typescript
|
||||
// frontend/app/types/skill.ts
|
||||
export interface Skill {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string | null
|
||||
category: string
|
||||
level: number
|
||||
maxLevel: number
|
||||
displayOrder: number
|
||||
projectCount?: number
|
||||
}
|
||||
|
||||
export interface SkillCategory {
|
||||
category: string
|
||||
categoryLabel: string
|
||||
skills: Skill[]
|
||||
}
|
||||
```
|
||||
|
||||
### Composant SkillCard
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/SkillCard.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { Skill } from '~/types/skill'
|
||||
|
||||
const props = defineProps<{
|
||||
skill: Skill
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [skill: Skill]
|
||||
}>()
|
||||
|
||||
const progressPercent = computed(() =>
|
||||
Math.round((props.skill.level / props.skill.maxLevel) * 100)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="skill-card group w-full text-left bg-sky-dark-50 rounded-lg p-4 hover:bg-sky-dark-100 transition-colors cursor-pointer"
|
||||
@click="emit('click', skill)"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<!-- Icône -->
|
||||
<div class="w-10 h-10 flex items-center justify-center bg-sky-dark rounded-lg">
|
||||
<span v-if="skill.icon" class="text-2xl">{{ skill.icon }}</span>
|
||||
<span v-else class="text-sky-text-muted">💻</span>
|
||||
</div>
|
||||
|
||||
<!-- Nom -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-ui font-medium text-sky-text truncate group-hover:text-sky-accent transition-colors">
|
||||
{{ skill.name }}
|
||||
</h3>
|
||||
<p v-if="skill.projectCount" class="text-xs text-sky-text-muted">
|
||||
{{ skill.projectCount }} {{ skill.projectCount > 1 ? 'projets' : 'projet' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div class="relative h-2 bg-sky-dark rounded-full overflow-hidden">
|
||||
<div
|
||||
class="skill-progress absolute left-0 top-0 h-full bg-sky-accent rounded-full"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Niveau -->
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<span class="text-xs text-sky-text-muted">Niveau</span>
|
||||
<span class="text-sm font-medium text-sky-accent">
|
||||
{{ skill.level }}/{{ skill.maxLevel }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-card:focus-visible {
|
||||
outline: 2px solid theme('colors.sky-accent.DEFAULT');
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skill-progress {
|
||||
animation: fillProgress 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fillProgress {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skill-progress {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Page competences.vue
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/competences.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { Skill } from '~/types/skill'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { data, pending, error, refresh } = useFetchSkills()
|
||||
|
||||
const categories = computed(() => data.value?.data ?? [])
|
||||
|
||||
// SEO
|
||||
useHead({
|
||||
title: () => t('skills.pageTitle'),
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('skills.pageTitle'),
|
||||
description: () => t('skills.pageDescription'),
|
||||
ogTitle: () => t('skills.pageTitle'),
|
||||
ogDescription: () => t('skills.pageDescription'),
|
||||
})
|
||||
|
||||
// Gestion du clic sur une compétence (préparation Story 2.5)
|
||||
function handleSkillClick(skill: Skill) {
|
||||
// Sera implémenté en Story 2.5 - modal avec projets liés
|
||||
console.log('Skill clicked:', skill.slug)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-ui font-bold text-sky-text mb-8">
|
||||
{{ t('skills.title') }}
|
||||
</h1>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="pending" class="space-y-8">
|
||||
<div v-for="i in 4" :key="i">
|
||||
<div class="bg-sky-dark-50 h-8 rounded w-32 mb-4"></div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div v-for="j in 4" :key="j" class="bg-sky-dark-50 rounded-lg h-24 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<p class="text-sky-text-muted mb-4">{{ t('skills.loadError') }}</p>
|
||||
<button
|
||||
@click="refresh()"
|
||||
class="bg-sky-accent text-white px-6 py-2 rounded-lg hover:bg-sky-accent-hover"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Skills par catégorie -->
|
||||
<div v-else class="space-y-12">
|
||||
<section
|
||||
v-for="(category, categoryIndex) in categories"
|
||||
:key="category.category"
|
||||
class="category-section"
|
||||
:style="{ '--category-delay': `${categoryIndex * 150}ms` }"
|
||||
>
|
||||
<h2 class="text-xl font-ui font-semibold text-sky-text mb-4">
|
||||
{{ category.categoryLabel }}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<SkillCard
|
||||
v-for="(skill, skillIndex) in category.skills"
|
||||
:key="skill.id"
|
||||
:skill="skill"
|
||||
class="skill-card-animated"
|
||||
:style="{ '--animation-delay': `${categoryIndex * 150 + skillIndex * 50}ms` }"
|
||||
@click="handleSkillClick"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Placeholder pour vis.js (Epic 3) - Desktop only -->
|
||||
<div class="hidden lg:block mt-12 p-8 border-2 border-dashed border-sky-dark-100 rounded-lg text-center">
|
||||
<p class="text-sky-text-muted">
|
||||
{{ t('skills.skillTreePlaceholder') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.category-section {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
animation-delay: var(--category-delay, 0ms);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.skill-card-animated {
|
||||
animation: fadeInUp 0.4s ease-out forwards;
|
||||
animation-delay: var(--animation-delay, 0ms);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.category-section,
|
||||
.skill-card-animated {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Clés i18n nécessaires
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"title": "Mes Compétences",
|
||||
"pageTitle": "Compétences | Skycel",
|
||||
"pageDescription": "Découvrez les compétences techniques et soft skills de Célian, développeur web full-stack.",
|
||||
"loadError": "Impossible de charger les compétences...",
|
||||
"skillTreePlaceholder": "Arbre de compétences interactif (bientôt disponible)",
|
||||
"level": "Niveau",
|
||||
"projects": "projets"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"title": "My Skills",
|
||||
"pageTitle": "Skills | Skycel",
|
||||
"pageDescription": "Discover the technical skills and soft skills of Célian, full-stack web developer.",
|
||||
"loadError": "Unable to load skills...",
|
||||
"skillTreePlaceholder": "Interactive skill tree (coming soon)",
|
||||
"level": "Level",
|
||||
"projects": "projects"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Catégories de compétences
|
||||
|
||||
| Catégorie | Couleur de zone (Future) | Exemples |
|
||||
|-----------|-------------------------|----------|
|
||||
| Frontend | Teinte bleue | Vue.js, Nuxt, TypeScript, TailwindCSS |
|
||||
| Backend | Teinte verte | Laravel, PHP, Node.js, MySQL |
|
||||
| Tools | Teinte jaune | Git, Docker, VS Code |
|
||||
| Soft Skills | Teinte violette | Communication, Gestion de projet |
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 1.2 : Table skills, Model Skill avec relations
|
||||
- Story 1.3 : Système i18n configuré
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 2.5 : Compétences cliquables → Projets liés (le clic est déjà émis)
|
||||
- Story 3.5 : Skill tree vis.js (Epic 3) - placeholder préparé
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
api/app/Http/
|
||||
├── Controllers/Api/
|
||||
│ └── SkillController.php # CRÉER
|
||||
└── Resources/
|
||||
└── SkillResource.php # CRÉER
|
||||
|
||||
frontend/app/
|
||||
├── pages/
|
||||
│ └── competences.vue # CRÉER
|
||||
├── components/
|
||||
│ └── feature/
|
||||
│ └── SkillCard.vue # CRÉER
|
||||
├── composables/
|
||||
│ └── useFetchSkills.ts # CRÉER
|
||||
└── types/
|
||||
└── skill.ts # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
api/app/Models/Skill.php # AJOUTER getCurrentLevel()
|
||||
api/routes/api.php # AJOUTER route /skills
|
||||
frontend/i18n/fr.json # AJOUTER clés skills.*
|
||||
frontend/i18n/en.json # AJOUTER clés skills.*
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-2.4]
|
||||
- [Source: docs/planning-artifacts/architecture.md#API-&-Communication-Patterns]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Component-Strategy]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#SkillTree]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| API endpoint | GET /api/skills | Architecture |
|
||||
| Groupement | Par catégorie | Epics |
|
||||
| Niveau visuel | Barre de progression | Epics |
|
||||
| Placeholder vis.js | Desktop only | Epics |
|
||||
| Animation | Stagger + respect motion | NFR6 |
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
# Story 2.5: Compétences cliquables → Projets liés
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want cliquer sur une compétence pour voir les projets qui l'utilisent,
|
||||
so that je peux voir des preuves concrètes de maîtrise.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur est sur la page Compétences **When** il clique sur une compétence **Then** un panneau/modal s'ouvre avec la liste des projets liés à cette compétence
|
||||
2. **And** pour chaque projet lié : titre, description courte, lien vers le détail
|
||||
3. **And** l'indication du niveau avant/après chaque projet est visible (progression)
|
||||
4. **And** une animation d'ouverture/fermeture fluide est présente (respectant `prefers-reduced-motion`)
|
||||
5. **And** la fermeture est possible par clic extérieur, bouton close, ou touche Escape
|
||||
6. **And** le panneau/modal utilise Headless UI pour l'accessibilité
|
||||
7. **And** la navigation au clavier est fonctionnelle (Tab, Escape, Enter)
|
||||
8. **And** le focus est piégé dans le modal quand ouvert (`focus trap`)
|
||||
9. **And** les données viennent de la relation `skill_project` via l'API
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer l'endpoint API pour les projets d'une compétence** (AC: #9)
|
||||
- [ ] Ajouter méthode `projects($slug)` dans `SkillController`
|
||||
- [ ] Charger les projets avec leur pivot (level_before, level_after)
|
||||
- [ ] Retourner 404 si le skill n'existe pas
|
||||
- [ ] Joindre les traductions
|
||||
|
||||
- [ ] **Task 2: Installer et configurer Headless UI** (AC: #6)
|
||||
- [ ] Installer `@headlessui/vue` dans le frontend
|
||||
- [ ] Vérifier la compatibilité avec Vue 3 / Nuxt 4
|
||||
|
||||
- [ ] **Task 3: Créer le composant SkillProjectsModal** (AC: #1, #2, #3, #5, #6, #7, #8)
|
||||
- [ ] Créer `frontend/app/components/feature/SkillProjectsModal.vue`
|
||||
- [ ] Utiliser `Dialog` de Headless UI
|
||||
- [ ] Props : isOpen, skill (avec name, description)
|
||||
- [ ] Emit : close
|
||||
- [ ] Afficher le titre de la compétence
|
||||
- [ ] Afficher la description de la compétence
|
||||
- [ ] Liste des projets liés
|
||||
|
||||
- [ ] **Task 4: Créer le composant ProjectListItem** (AC: #2, #3)
|
||||
- [ ] Créer `frontend/app/components/feature/ProjectListItem.vue`
|
||||
- [ ] Afficher titre, description courte, niveau avant/après
|
||||
- [ ] Lien vers la page détail du projet
|
||||
- [ ] Visualisation de la progression (flèche niveau)
|
||||
|
||||
- [ ] **Task 5: Charger les projets au clic** (AC: #9)
|
||||
- [ ] Créer composable `useFetchSkillProjects(slug)`
|
||||
- [ ] Appeler l'API quand le modal s'ouvre
|
||||
- [ ] Gérer l'état loading/error dans le modal
|
||||
|
||||
- [ ] **Task 6: Implémenter les animations** (AC: #4)
|
||||
- [ ] Animation d'ouverture : fade-in + scale
|
||||
- [ ] Animation de fermeture : fade-out + scale
|
||||
- [ ] Overlay avec backdrop blur
|
||||
- [ ] Respecter `prefers-reduced-motion`
|
||||
|
||||
- [ ] **Task 7: Fermeture du modal** (AC: #5)
|
||||
- [ ] Clic sur l'overlay ferme le modal
|
||||
- [ ] Bouton close (X) en haut à droite
|
||||
- [ ] Touche Escape ferme le modal
|
||||
- [ ] Restaurer le focus à l'élément précédent
|
||||
|
||||
- [ ] **Task 8: Intégrer dans la page Compétences** (AC: #1)
|
||||
- [ ] Modifier `competences.vue` pour ouvrir le modal
|
||||
- [ ] Gérer l'état du modal (isOpen, selectedSkill)
|
||||
- [ ] Passer les props au modal
|
||||
|
||||
- [ ] **Task 9: Tests et validation**
|
||||
- [ ] Tester l'ouverture/fermeture
|
||||
- [ ] Valider la navigation clavier (Tab, Escape)
|
||||
- [ ] Tester le focus trap
|
||||
- [ ] Vérifier l'accessibilité avec axe DevTools
|
||||
- [ ] Tester en FR et EN
|
||||
- [ ] Valider les animations
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Endpoint API Laravel
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Controllers/Api/SkillController.php
|
||||
|
||||
public function projects(Request $request, string $slug)
|
||||
{
|
||||
$lang = $request->header('Accept-Language', 'fr');
|
||||
|
||||
$skill = Skill::with('projects')
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if (!$skill) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'SKILL_NOT_FOUND',
|
||||
'message' => 'Skill not found',
|
||||
]
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'skill' => [
|
||||
'id' => $skill->id,
|
||||
'slug' => $skill->slug,
|
||||
'name' => Translation::getTranslation($skill->name_key, $lang),
|
||||
'description' => Translation::getTranslation($skill->description_key, $lang),
|
||||
'level' => $skill->getCurrentLevel(),
|
||||
'maxLevel' => $skill->max_level,
|
||||
],
|
||||
'projects' => $skill->projects->map(function ($project) use ($lang) {
|
||||
return [
|
||||
'id' => $project->id,
|
||||
'slug' => $project->slug,
|
||||
'title' => Translation::getTranslation($project->title_key, $lang),
|
||||
'shortDescription' => Translation::getTranslation($project->short_description_key, $lang),
|
||||
'image' => $project->image,
|
||||
'dateCompleted' => $project->date_completed?->format('Y-m-d'),
|
||||
'levelBefore' => $project->pivot->level_before,
|
||||
'levelAfter' => $project->pivot->level_after,
|
||||
'levelDescription' => $project->pivot->level_description_key
|
||||
? Translation::getTranslation($project->pivot->level_description_key, $lang)
|
||||
: null,
|
||||
];
|
||||
}),
|
||||
],
|
||||
'meta' => ['lang' => $lang],
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// api/routes/api.php
|
||||
Route::get('/skills/{slug}/projects', [SkillController::class, 'projects']);
|
||||
```
|
||||
|
||||
### Installation Headless UI
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install @headlessui/vue
|
||||
```
|
||||
|
||||
### Composable useFetchSkillProjects
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useFetchSkillProjects.ts
|
||||
import type { Skill } from '~/types/skill'
|
||||
import type { Project } from '~/types/project'
|
||||
|
||||
interface SkillProjectsResponse {
|
||||
data: {
|
||||
skill: Skill
|
||||
projects: (Project & { levelBefore: number; levelAfter: number; levelDescription?: string })[]
|
||||
}
|
||||
meta: { lang: string }
|
||||
}
|
||||
|
||||
export function useFetchSkillProjects(slug: Ref<string | null>) {
|
||||
const config = useRuntimeConfig()
|
||||
const { locale } = useI18n()
|
||||
|
||||
return useFetch<SkillProjectsResponse>(
|
||||
() => slug.value ? `/skills/${slug.value}/projects` : null,
|
||||
{
|
||||
baseURL: config.public.apiUrl,
|
||||
headers: {
|
||||
'X-API-Key': config.public.apiKey,
|
||||
'Accept-Language': locale.value,
|
||||
},
|
||||
immediate: false,
|
||||
watch: false,
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Composant SkillProjectsModal
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/SkillProjectsModal.vue -->
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
TransitionRoot,
|
||||
TransitionChild,
|
||||
} from '@headlessui/vue'
|
||||
import type { Skill } from '~/types/skill'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
skill: Skill | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const skillSlug = computed(() => props.skill?.slug ?? null)
|
||||
|
||||
const { data, pending, error, execute } = useFetchSkillProjects(skillSlug)
|
||||
|
||||
// Charger les projets quand le modal s'ouvre
|
||||
watch(() => props.isOpen, (isOpen) => {
|
||||
if (isOpen && props.skill) {
|
||||
execute()
|
||||
}
|
||||
})
|
||||
|
||||
const projects = computed(() => data.value?.data.projects ?? [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TransitionRoot :show="isOpen" as="template">
|
||||
<Dialog @close="emit('close')" class="relative z-50">
|
||||
<!-- Backdrop -->
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="duration-300 ease-out"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="duration-200 ease-in"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-sky-dark/80 backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
|
||||
<!-- Modal container -->
|
||||
<div class="fixed inset-0 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="duration-300 ease-out"
|
||||
enter-from="opacity-0 scale-95"
|
||||
enter-to="opacity-100 scale-100"
|
||||
leave="duration-200 ease-in"
|
||||
leave-from="opacity-100 scale-100"
|
||||
leave-to="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel class="w-full max-w-2xl bg-sky-dark-50 rounded-xl shadow-xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between p-6 border-b border-sky-dark-100">
|
||||
<div>
|
||||
<DialogTitle class="text-xl font-ui font-bold text-sky-text">
|
||||
{{ skill?.name }}
|
||||
</DialogTitle>
|
||||
<p v-if="skill?.description" class="mt-1 text-sm text-sky-text-muted">
|
||||
{{ skill.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
type="button"
|
||||
class="text-sky-text-muted hover:text-sky-text transition-colors p-2 -mr-2 -mt-2"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<span class="sr-only">{{ t('common.close') }}</span>
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
<h3 class="text-sm font-ui font-medium text-sky-text-muted uppercase tracking-wide mb-4">
|
||||
{{ t('skills.relatedProjects') }}
|
||||
</h3>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="pending" class="space-y-4">
|
||||
<div v-for="i in 3" :key="i" class="bg-sky-dark rounded-lg p-4 animate-pulse">
|
||||
<div class="h-5 bg-sky-dark-100 rounded w-1/2 mb-2"></div>
|
||||
<div class="h-4 bg-sky-dark-100 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<p class="text-sky-text-muted">{{ t('skills.loadProjectsError') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- No projects -->
|
||||
<div v-else-if="projects.length === 0" class="text-center py-8">
|
||||
<p class="text-sky-text-muted">{{ t('skills.noProjects') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Projects list -->
|
||||
<div v-else class="space-y-4">
|
||||
<ProjectListItem
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:project="project"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:deep([data-headlessui-state]) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Composant ProjectListItem
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/ProjectListItem.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/types/project'
|
||||
|
||||
interface ProjectWithLevel extends Project {
|
||||
levelBefore: number
|
||||
levelAfter: number
|
||||
levelDescription?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
project: ProjectWithLevel
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const levelProgress = computed(() => {
|
||||
const diff = props.project.levelAfter - props.project.levelBefore
|
||||
return diff > 0 ? `+${diff}` : diff.toString()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="localePath(`/projets/${project.slug}`)"
|
||||
class="block bg-sky-dark rounded-lg p-4 hover:bg-sky-dark-100 transition-colors group"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Image thumbnail -->
|
||||
<NuxtImg
|
||||
v-if="project.image"
|
||||
:src="project.image"
|
||||
:alt="project.title"
|
||||
format="webp"
|
||||
width="80"
|
||||
height="60"
|
||||
class="w-20 h-15 object-cover rounded"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-ui font-medium text-sky-text group-hover:text-sky-accent transition-colors truncate">
|
||||
{{ project.title }}
|
||||
</h4>
|
||||
<p class="text-sm text-sky-text-muted line-clamp-2 mt-1">
|
||||
{{ project.shortDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Level progress -->
|
||||
<div class="flex-shrink-0 text-right">
|
||||
<div class="text-xs text-sky-text-muted">{{ t('skills.level') }}</div>
|
||||
<div class="flex items-center gap-1 mt-1">
|
||||
<span class="text-sky-text">{{ project.levelBefore }}</span>
|
||||
<span class="text-sky-accent">→</span>
|
||||
<span class="text-sky-accent font-semibold">{{ project.levelAfter }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-sky-accent font-medium">
|
||||
({{ levelProgress }})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Level description if available -->
|
||||
<p v-if="project.levelDescription" class="mt-2 text-xs text-sky-text-muted italic">
|
||||
{{ project.levelDescription }}
|
||||
</p>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Modification de competences.vue
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/competences.vue - Modifications -->
|
||||
<script setup lang="ts">
|
||||
import type { Skill } from '~/types/skill'
|
||||
|
||||
// ... code existant ...
|
||||
|
||||
// État du modal
|
||||
const isModalOpen = ref(false)
|
||||
const selectedSkill = ref<Skill | null>(null)
|
||||
|
||||
function handleSkillClick(skill: Skill) {
|
||||
selectedSkill.value = skill
|
||||
isModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
isModalOpen.value = false
|
||||
// Garder selectedSkill pour l'animation de fermeture
|
||||
setTimeout(() => {
|
||||
selectedSkill.value = null
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- ... code existant ... -->
|
||||
|
||||
<!-- Modal des projets liés -->
|
||||
<SkillProjectsModal
|
||||
:is-open="isModalOpen"
|
||||
:skill="selectedSkill"
|
||||
@close="closeModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Clés i18n nécessaires
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"relatedProjects": "Projets utilisant cette compétence",
|
||||
"loadProjectsError": "Impossible de charger les projets liés",
|
||||
"noProjects": "Aucun projet n'utilise encore cette compétence",
|
||||
"level": "Niveau"
|
||||
},
|
||||
"common": {
|
||||
"close": "Fermer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"relatedProjects": "Projects using this skill",
|
||||
"loadProjectsError": "Unable to load related projects",
|
||||
"noProjects": "No projects use this skill yet",
|
||||
"level": "Level"
|
||||
},
|
||||
"common": {
|
||||
"close": "Close"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Accessibilité
|
||||
|
||||
| Requirement | Implementation |
|
||||
|-------------|----------------|
|
||||
| Focus trap | Géré automatiquement par Headless UI Dialog |
|
||||
| Keyboard navigation | Tab entre les éléments, Escape pour fermer |
|
||||
| Screen reader | DialogTitle annoncé, aria-modal="true" |
|
||||
| Fermeture externe | Clic overlay, bouton X, Escape |
|
||||
| Focus restoration | Automatique par Headless UI |
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 1.2 : Table skill_project avec relations
|
||||
- Story 2.4 : Page Compétences avec SkillCard cliquable
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Aucune dépendance directe
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/components/feature/
|
||||
├── SkillProjectsModal.vue # CRÉER
|
||||
└── ProjectListItem.vue # CRÉER
|
||||
|
||||
frontend/app/composables/
|
||||
└── useFetchSkillProjects.ts # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
api/app/Http/Controllers/Api/SkillController.php # AJOUTER projects()
|
||||
api/routes/api.php # AJOUTER route
|
||||
frontend/app/pages/competences.vue # AJOUTER modal
|
||||
frontend/i18n/fr.json # AJOUTER clés
|
||||
frontend/i18n/en.json # AJOUTER clés
|
||||
frontend/package.json # AJOUTER @headlessui/vue
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-2.5]
|
||||
- [Source: docs/planning-artifacts/architecture.md#Design-System-Components]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Design-System-Components-Headless]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Accessibility-Strategy]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| UI Library | Headless UI Dialog | Architecture |
|
||||
| Focus trap | Required | WCAG AA |
|
||||
| Keyboard nav | Tab, Escape, Enter | WCAG AA |
|
||||
| Animation | Respect prefers-reduced-motion | NFR6 |
|
||||
| API endpoint | GET /api/skills/{slug}/projects | Architecture |
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
# Story 2.6: Page Témoignages et migrations BDD
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want voir les témoignages des personnes ayant travaillé avec le développeur,
|
||||
so that j'ai une validation sociale de ses compétences.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** les migrations Laravel sont exécutées **When** `php artisan migrate` est lancé **Then** la table `testimonials` est créée (id, name, role, company, avatar, text_key, personality ENUM, project_id FK nullable, display_order, is_active, timestamps)
|
||||
2. **And** les seeders de test sont disponibles avec des témoignages en FR et EN
|
||||
3. **Given** le visiteur accède à `/temoignages` (FR) ou `/en/testimonials` (EN) **When** la page se charge **Then** la liste des témoignages s'affiche depuis l'API `/api/testimonials`
|
||||
4. **And** chaque témoignage affiche : nom, rôle, entreprise, avatar, texte traduit
|
||||
5. **And** la personnalité de chaque PNJ est indiquée visuellement (style différent selon personality)
|
||||
6. **And** un lien vers le projet associé est présent si pertinent
|
||||
7. **And** l'ordre d'affichage respecte `display_order`
|
||||
8. **And** le design est préparé pour accueillir le composant DialoguePNJ (story suivante)
|
||||
9. **And** les meta tags SEO sont dynamiques pour cette page
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer la migration table testimonials** (AC: #1)
|
||||
- [ ] Créer migration `create_testimonials_table`
|
||||
- [ ] Colonnes : id, name, role, company, avatar, text_key, personality (ENUM: sage, sarcastique, enthousiaste, professionnel), project_id (FK nullable), display_order, is_active (boolean), timestamps
|
||||
- [ ] Foreign key project_id → projects.id (nullable, ON DELETE SET NULL)
|
||||
- [ ] Index sur display_order pour le tri
|
||||
- [ ] Index sur is_active pour le filtrage
|
||||
|
||||
- [ ] **Task 2: Créer le Model Testimonial** (AC: #1)
|
||||
- [ ] Créer `app/Models/Testimonial.php`
|
||||
- [ ] Définir les fillable : name, role, company, avatar, text_key, personality, project_id, display_order, is_active
|
||||
- [ ] Casts : is_active → boolean
|
||||
- [ ] Relation `project()` : belongsTo(Project::class)
|
||||
- [ ] Scope `scopeActive($query)` pour filtrer les actifs
|
||||
- [ ] Scope `scopeOrdered($query)` pour le tri
|
||||
|
||||
- [ ] **Task 3: Créer le Seeder des témoignages** (AC: #2)
|
||||
- [ ] Créer `database/seeders/TestimonialSeeder.php`
|
||||
- [ ] Ajouter 4-5 témoignages de test avec différentes personnalités
|
||||
- [ ] Ajouter les traductions FR et EN dans TranslationSeeder
|
||||
- [ ] Lier certains témoignages à des projets existants
|
||||
- [ ] Mettre à jour `DatabaseSeeder.php`
|
||||
|
||||
- [ ] **Task 4: Créer l'endpoint API testimonials** (AC: #3, #4, #6, #7)
|
||||
- [ ] Créer `app/Http/Controllers/Api/TestimonialController.php`
|
||||
- [ ] Méthode `index()` pour lister les témoignages actifs
|
||||
- [ ] Créer `app/Http/Resources/TestimonialResource.php`
|
||||
- [ ] Inclure le projet lié (si existe) avec titre traduit
|
||||
- [ ] Trier par display_order
|
||||
- [ ] Ajouter la route `GET /api/testimonials`
|
||||
|
||||
- [ ] **Task 5: Créer le composable useFetchTestimonials** (AC: #3)
|
||||
- [ ] Créer `frontend/app/composables/useFetchTestimonials.ts`
|
||||
- [ ] Typer la réponse avec interface Testimonial[]
|
||||
|
||||
- [ ] **Task 6: Créer la page temoignages.vue** (AC: #3, #4, #5, #8)
|
||||
- [ ] Créer `frontend/app/pages/temoignages.vue`
|
||||
- [ ] Charger les données avec le composable
|
||||
- [ ] Afficher chaque témoignage comme une card
|
||||
- [ ] Appliquer un style visuel selon la personnalité
|
||||
- [ ] Préparer l'emplacement pour DialoguePNJ
|
||||
|
||||
- [ ] **Task 7: Créer le composant TestimonialCard** (AC: #4, #5, #6)
|
||||
- [ ] Créer `frontend/app/components/feature/TestimonialCard.vue`
|
||||
- [ ] Props : testimonial (avec name, role, company, avatar, text, personality, project)
|
||||
- [ ] Afficher l'avatar, le nom, le rôle, l'entreprise
|
||||
- [ ] Afficher le texte du témoignage
|
||||
- [ ] Style de bulle selon la personnalité
|
||||
- [ ] Lien vers le projet si présent
|
||||
|
||||
- [ ] **Task 8: Styles visuels par personnalité** (AC: #5)
|
||||
- [ ] Définir 4 styles de bulles/cards selon personality :
|
||||
- sage : style calme, bordure subtile
|
||||
- sarcastique : style décalé, accent différent
|
||||
- enthousiaste : style vif, couleurs plus marquées
|
||||
- professionnel : style sobre, formel
|
||||
- [ ] Classes CSS ou Tailwind variants
|
||||
|
||||
- [ ] **Task 9: Meta tags SEO** (AC: #9)
|
||||
- [ ] Titre : "Témoignages | Skycel"
|
||||
- [ ] Description dynamique
|
||||
|
||||
- [ ] **Task 10: Tests et validation**
|
||||
- [ ] Exécuter les migrations
|
||||
- [ ] Vérifier le seeding des données
|
||||
- [ ] Tester l'API en FR et EN
|
||||
- [ ] Valider l'affichage de la page
|
||||
- [ ] Vérifier les liens vers projets
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Migration testimonials
|
||||
|
||||
```php
|
||||
<?php
|
||||
// database/migrations/2026_02_04_000001_create_testimonials_table.php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('testimonials', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('role');
|
||||
$table->string('company')->nullable();
|
||||
$table->string('avatar')->nullable();
|
||||
$table->string('text_key');
|
||||
$table->enum('personality', ['sage', 'sarcastique', 'enthousiaste', 'professionnel'])->default('professionnel');
|
||||
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->integer('display_order')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('display_order');
|
||||
$table->index('is_active');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('testimonials');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Model Testimonial
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Models/Testimonial.php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Testimonial extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'role',
|
||||
'company',
|
||||
'avatar',
|
||||
'text_key',
|
||||
'personality',
|
||||
'project_id',
|
||||
'display_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('display_order');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Seeder des témoignages
|
||||
|
||||
```php
|
||||
<?php
|
||||
// database/seeders/TestimonialSeeder.php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Testimonial;
|
||||
use App\Models\Translation;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class TestimonialSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$testimonials = [
|
||||
[
|
||||
'name' => 'Marie Dupont',
|
||||
'role' => 'CTO',
|
||||
'company' => 'TechStartup',
|
||||
'avatar' => '/images/testimonials/marie.jpg',
|
||||
'text_key' => 'testimonial.marie.text',
|
||||
'personality' => 'enthousiaste',
|
||||
'project_id' => 1,
|
||||
'display_order' => 1,
|
||||
],
|
||||
[
|
||||
'name' => 'Pierre Martin',
|
||||
'role' => 'Lead Developer',
|
||||
'company' => 'DevAgency',
|
||||
'avatar' => '/images/testimonials/pierre.jpg',
|
||||
'text_key' => 'testimonial.pierre.text',
|
||||
'personality' => 'professionnel',
|
||||
'project_id' => 2,
|
||||
'display_order' => 2,
|
||||
],
|
||||
[
|
||||
'name' => 'Sophie Bernard',
|
||||
'role' => 'Product Manager',
|
||||
'company' => 'InnovateCorp',
|
||||
'avatar' => '/images/testimonials/sophie.jpg',
|
||||
'text_key' => 'testimonial.sophie.text',
|
||||
'personality' => 'sage',
|
||||
'project_id' => null,
|
||||
'display_order' => 3,
|
||||
],
|
||||
[
|
||||
'name' => 'Thomas Leroy',
|
||||
'role' => 'Freelance Designer',
|
||||
'company' => null,
|
||||
'avatar' => '/images/testimonials/thomas.jpg',
|
||||
'text_key' => 'testimonial.thomas.text',
|
||||
'personality' => 'sarcastique',
|
||||
'project_id' => null,
|
||||
'display_order' => 4,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($testimonials as $data) {
|
||||
Testimonial::create($data);
|
||||
}
|
||||
|
||||
// Traductions
|
||||
$translations = [
|
||||
['key' => 'testimonial.marie.text', 'fr' => "Travailler avec Célian a été une révélation ! Son approche créative et sa maîtrise technique ont transformé notre projet. Je recommande sans hésitation !", 'en' => "Working with Célian was a revelation! His creative approach and technical mastery transformed our project. I highly recommend!"],
|
||||
['key' => 'testimonial.pierre.text', 'fr' => "Code propre, architecture solide, communication claire. Célian sait exactement ce qu'il fait et le fait bien.", 'en' => "Clean code, solid architecture, clear communication. Célian knows exactly what he's doing and does it well."],
|
||||
['key' => 'testimonial.sophie.text', 'fr' => "Une personne rare qui combine vision produit et excellence technique. Les retours utilisateurs parlent d'eux-mêmes.", 'en' => "A rare person who combines product vision and technical excellence. User feedback speaks for itself."],
|
||||
['key' => 'testimonial.thomas.text', 'fr' => "Bon, j'avoue, au début je pensais que les devs ne comprenaient rien au design. Célian m'a prouvé le contraire. Presque agaçant.", 'en' => "Okay, I admit, at first I thought devs didn't understand design. Célian proved me wrong. Almost annoying."],
|
||||
];
|
||||
|
||||
foreach ($translations as $t) {
|
||||
Translation::create(['lang' => 'fr', 'key_name' => $t['key'], 'value' => $t['fr']]);
|
||||
Translation::create(['lang' => 'en', 'key_name' => $t['key'], 'value' => $t['en']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Controller et Resource
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Controllers/Api/TestimonialController.php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\TestimonialResource;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TestimonialController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$lang = $request->header('Accept-Language', 'fr');
|
||||
|
||||
$testimonials = Testimonial::with('project')
|
||||
->active()
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
return TestimonialResource::collection($testimonials)
|
||||
->additional(['meta' => ['lang' => $lang]]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Resources/TestimonialResource.php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Translation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class TestimonialResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$lang = $request->header('Accept-Language', 'fr');
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'role' => $this->role,
|
||||
'company' => $this->company,
|
||||
'avatar' => $this->avatar,
|
||||
'text' => Translation::getTranslation($this->text_key, $lang),
|
||||
'personality' => $this->personality,
|
||||
'displayOrder' => $this->display_order,
|
||||
'project' => $this->whenLoaded('project', function () use ($lang) {
|
||||
return $this->project ? [
|
||||
'id' => $this->project->id,
|
||||
'slug' => $this->project->slug,
|
||||
'title' => Translation::getTranslation($this->project->title_key, $lang),
|
||||
] : null;
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// api/routes/api.php
|
||||
Route::get('/testimonials', [TestimonialController::class, 'index']);
|
||||
```
|
||||
|
||||
### Types TypeScript
|
||||
|
||||
```typescript
|
||||
// frontend/app/types/testimonial.ts
|
||||
export interface Testimonial {
|
||||
id: number
|
||||
name: string
|
||||
role: string
|
||||
company: string | null
|
||||
avatar: string | null
|
||||
text: string
|
||||
personality: 'sage' | 'sarcastique' | 'enthousiaste' | 'professionnel'
|
||||
displayOrder: number
|
||||
project?: {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
} | null
|
||||
}
|
||||
```
|
||||
|
||||
### Composant TestimonialCard
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/TestimonialCard.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { Testimonial } from '~/types/testimonial'
|
||||
|
||||
const props = defineProps<{
|
||||
testimonial: Testimonial
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
// Styles selon la personnalité
|
||||
const personalityStyles = {
|
||||
sage: 'border-l-4 border-blue-400 bg-blue-400/5',
|
||||
sarcastique: 'border-l-4 border-purple-400 bg-purple-400/5 italic',
|
||||
enthousiaste: 'border-l-4 border-sky-accent bg-sky-accent/5',
|
||||
professionnel: 'border-l-4 border-gray-400 bg-gray-400/5',
|
||||
}
|
||||
|
||||
const bubbleStyle = computed(() => personalityStyles[props.testimonial.personality])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="testimonial-card bg-sky-dark-50 rounded-lg overflow-hidden">
|
||||
<!-- Header avec avatar et info -->
|
||||
<div class="flex items-center gap-4 p-4 border-b border-sky-dark-100">
|
||||
<!-- Avatar -->
|
||||
<div class="w-16 h-16 rounded-full overflow-hidden bg-sky-dark flex-shrink-0">
|
||||
<NuxtImg
|
||||
v-if="testimonial.avatar"
|
||||
:src="testimonial.avatar"
|
||||
:alt="testimonial.name"
|
||||
format="webp"
|
||||
width="64"
|
||||
height="64"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-2xl text-sky-text-muted">
|
||||
👤
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-ui font-semibold text-sky-text truncate">
|
||||
{{ testimonial.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-sky-text-muted">
|
||||
{{ testimonial.role }}
|
||||
<span v-if="testimonial.company">
|
||||
@ {{ testimonial.company }}
|
||||
</span>
|
||||
</p>
|
||||
<!-- Badge personnalité -->
|
||||
<span
|
||||
class="inline-block mt-1 text-xs px-2 py-0.5 rounded-full"
|
||||
:class="{
|
||||
'bg-blue-400/20 text-blue-300': testimonial.personality === 'sage',
|
||||
'bg-purple-400/20 text-purple-300': testimonial.personality === 'sarcastique',
|
||||
'bg-sky-accent/20 text-sky-accent': testimonial.personality === 'enthousiaste',
|
||||
'bg-gray-400/20 text-gray-300': testimonial.personality === 'professionnel',
|
||||
}"
|
||||
>
|
||||
{{ t(`testimonials.personality.${testimonial.personality}`) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Texte du témoignage -->
|
||||
<div class="p-4" :class="bubbleStyle">
|
||||
<p class="font-narrative text-sky-text leading-relaxed">
|
||||
"{{ testimonial.text }}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Lien vers le projet -->
|
||||
<div v-if="testimonial.project" class="px-4 pb-4">
|
||||
<NuxtLink
|
||||
:to="localePath(`/projets/${testimonial.project.slug}`)"
|
||||
class="inline-flex items-center text-sm text-sky-accent hover:underline"
|
||||
>
|
||||
📁 {{ t('testimonials.relatedProject') }}: {{ testimonial.project.title }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Page temoignages.vue
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/temoignages.vue -->
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const { data, pending, error, refresh } = useFetchTestimonials()
|
||||
|
||||
const testimonials = computed(() => data.value?.data ?? [])
|
||||
|
||||
// SEO
|
||||
useHead({
|
||||
title: () => t('testimonials.pageTitle'),
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('testimonials.pageTitle'),
|
||||
description: () => t('testimonials.pageDescription'),
|
||||
ogTitle: () => t('testimonials.pageTitle'),
|
||||
ogDescription: () => t('testimonials.pageDescription'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-ui font-bold text-sky-text mb-8">
|
||||
{{ t('testimonials.title') }}
|
||||
</h1>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="pending" class="space-y-6">
|
||||
<div v-for="i in 4" :key="i" class="bg-sky-dark-50 rounded-lg p-6 animate-pulse">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="w-16 h-16 bg-sky-dark-100 rounded-full"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-5 bg-sky-dark-100 rounded w-32 mb-2"></div>
|
||||
<div class="h-4 bg-sky-dark-100 rounded w-48"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-4 bg-sky-dark-100 rounded w-full mb-2"></div>
|
||||
<div class="h-4 bg-sky-dark-100 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<p class="text-sky-text-muted mb-4">{{ t('testimonials.loadError') }}</p>
|
||||
<button
|
||||
@click="refresh()"
|
||||
class="bg-sky-accent text-white px-6 py-2 rounded-lg hover:bg-sky-accent-hover"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Testimonials list -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Placeholder pour DialoguePNJ (Story 2.7) -->
|
||||
<div class="hidden">
|
||||
<!-- <DialoguePNJ :testimonials="testimonials" /> -->
|
||||
</div>
|
||||
|
||||
<!-- Affichage en cards (sera remplacé par DialoguePNJ) -->
|
||||
<TestimonialCard
|
||||
v-for="testimonial in testimonials"
|
||||
:key="testimonial.id"
|
||||
:testimonial="testimonial"
|
||||
class="testimonial-animated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.testimonial-animated {
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.testimonial-animated:nth-child(1) { animation-delay: 0ms; }
|
||||
.testimonial-animated:nth-child(2) { animation-delay: 100ms; }
|
||||
.testimonial-animated:nth-child(3) { animation-delay: 200ms; }
|
||||
.testimonial-animated:nth-child(4) { animation-delay: 300ms; }
|
||||
.testimonial-animated:nth-child(5) { animation-delay: 400ms; }
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.testimonial-animated {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Clés i18n
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"testimonials": {
|
||||
"title": "Témoignages",
|
||||
"pageTitle": "Témoignages | Skycel",
|
||||
"pageDescription": "Découvrez ce que disent les personnes qui ont travaillé avec Célian.",
|
||||
"loadError": "Impossible de charger les témoignages...",
|
||||
"relatedProject": "Projet associé",
|
||||
"personality": {
|
||||
"sage": "Sage",
|
||||
"sarcastique": "Sarcastique",
|
||||
"enthousiaste": "Enthousiaste",
|
||||
"professionnel": "Professionnel"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"testimonials": {
|
||||
"title": "Testimonials",
|
||||
"pageTitle": "Testimonials | Skycel",
|
||||
"pageDescription": "Discover what people who worked with Célian have to say.",
|
||||
"loadError": "Unable to load testimonials...",
|
||||
"relatedProject": "Related project",
|
||||
"personality": {
|
||||
"sage": "Wise",
|
||||
"sarcastique": "Sarcastic",
|
||||
"enthousiaste": "Enthusiastic",
|
||||
"professionnel": "Professional"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 1.2 : Table projects pour la FK
|
||||
- Story 1.3 : Système i18n configuré
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 2.7 : Composant DialoguePNJ
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
api/
|
||||
├── app/Models/
|
||||
│ └── Testimonial.php # CRÉER
|
||||
├── app/Http/Controllers/Api/
|
||||
│ └── TestimonialController.php # CRÉER
|
||||
├── app/Http/Resources/
|
||||
│ └── TestimonialResource.php # CRÉER
|
||||
└── database/
|
||||
├── migrations/
|
||||
│ └── 2026_02_04_000001_create_testimonials_table.php # CRÉER
|
||||
└── seeders/
|
||||
└── TestimonialSeeder.php # CRÉER
|
||||
|
||||
frontend/app/
|
||||
├── pages/
|
||||
│ └── temoignages.vue # CRÉER
|
||||
├── components/feature/
|
||||
│ └── TestimonialCard.vue # CRÉER
|
||||
├── composables/
|
||||
│ └── useFetchTestimonials.ts # CRÉER
|
||||
└── types/
|
||||
└── testimonial.ts # CRÉER
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-2.6]
|
||||
- [Source: docs/planning-artifacts/architecture.md#API-&-Communication-Patterns]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#DialoguePNJ]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Personnalites-PNJ]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Table | testimonials avec personality ENUM | Epics |
|
||||
| API endpoint | GET /api/testimonials | Architecture |
|
||||
| Personnalités | sage, sarcastique, enthousiaste, professionnel | Brainstorming |
|
||||
| FK project_id | Nullable, ON DELETE SET NULL | Architecture |
|
||||
|
||||
## 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
|
||||
|
||||
662
docs/implementation-artifacts/2-7-composant-dialogue-pnj.md
Normal file
662
docs/implementation-artifacts/2-7-composant-dialogue-pnj.md
Normal file
@@ -0,0 +1,662 @@
|
||||
# Story 2.7: Composant Dialogue PNJ
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want lire les témoignages comme des dialogues de personnages style Zelda,
|
||||
so that l'expérience est immersive et mémorable.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le composant `DialoguePNJ` est implémenté **When** il reçoit les données d'un témoignage en props **Then** l'avatar du PNJ s'affiche à gauche avec un style illustratif
|
||||
2. **And** une bulle de dialogue s'affiche à droite avec le texte
|
||||
3. **And** l'effet typewriter fait apparaître le texte lettre par lettre
|
||||
4. **And** un clic ou appui sur Espace accélère l'animation typewriter (x3-x5)
|
||||
5. **And** la personnalité du PNJ influence le style visuel de la bulle (sage, sarcastique, enthousiaste, professionnel)
|
||||
6. **And** la police serif narrative est utilisée pour le texte du dialogue
|
||||
7. **And** `prefers-reduced-motion` affiche le texte complet instantanément
|
||||
8. **And** le texte complet est accessible via `aria-label` pour les screen readers
|
||||
9. **And** une navigation entre témoignages est disponible (précédent/suivant)
|
||||
10. **And** une transition animée s'effectue entre les PNJ
|
||||
11. **And** un indicateur du témoignage actuel est visible (ex: 2/5)
|
||||
12. **And** la navigation au clavier est fonctionnelle (flèches gauche/droite)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer le composant DialoguePNJ** (AC: #1, #2, #5, #6)
|
||||
- [ ] Créer `frontend/app/components/feature/DialoguePNJ.vue`
|
||||
- [ ] Props : testimonials (array), initialIndex (number)
|
||||
- [ ] Layout : avatar à gauche, bulle de dialogue à droite
|
||||
- [ ] Styles différents selon personality
|
||||
|
||||
- [ ] **Task 2: Implémenter l'effet typewriter** (AC: #3, #4)
|
||||
- [ ] Créer un composable `useTypewriter` pour l'animation
|
||||
- [ ] Afficher le texte lettre par lettre (vitesse ~30-50ms)
|
||||
- [ ] Clic ou Espace accélère l'animation (x3-x5)
|
||||
- [ ] État : "typing" ou "complete"
|
||||
|
||||
- [ ] **Task 3: Gérer prefers-reduced-motion** (AC: #7)
|
||||
- [ ] Détecter la préférence via media query
|
||||
- [ ] Si activé, afficher le texte complet instantanément
|
||||
- [ ] Créer un composable `useReducedMotion()`
|
||||
|
||||
- [ ] **Task 4: Accessibilité** (AC: #8)
|
||||
- [ ] Ajouter `aria-label` avec le texte complet
|
||||
- [ ] `role="article"` sur le conteneur de dialogue
|
||||
- [ ] `aria-live="polite"` pour annoncer les changements
|
||||
|
||||
- [ ] **Task 5: Navigation entre témoignages** (AC: #9, #10, #11, #12)
|
||||
- [ ] Boutons précédent/suivant
|
||||
- [ ] Indicateur de position (2/5)
|
||||
- [ ] Transition animée entre les PNJ (fade/slide)
|
||||
- [ ] Navigation clavier : flèches gauche/droite
|
||||
- [ ] Focus trap sur le composant
|
||||
|
||||
- [ ] **Task 6: Intégrer dans la page Témoignages** (AC: tous)
|
||||
- [ ] Remplacer les TestimonialCards par DialoguePNJ
|
||||
- [ ] Mode "dialogue" pour l'expérience immersive
|
||||
- [ ] Option pour revenir à la vue "liste"
|
||||
|
||||
- [ ] **Task 7: Styles visuels par personnalité** (AC: #5)
|
||||
- [ ] sage : bulle bleutée, bordure calme
|
||||
- [ ] sarcastique : bulle violacée, italique
|
||||
- [ ] enthousiaste : bulle orange accent, texte dynamique
|
||||
- [ ] professionnel : bulle grise, sobre
|
||||
|
||||
- [ ] **Task 8: Tests et validation**
|
||||
- [ ] Tester l'effet typewriter
|
||||
- [ ] Valider l'accélération au clic/Espace
|
||||
- [ ] Tester prefers-reduced-motion
|
||||
- [ ] Valider la navigation clavier
|
||||
- [ ] Vérifier l'accessibilité avec screen reader
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Composable useTypewriter
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useTypewriter.ts
|
||||
export interface UseTypewriterOptions {
|
||||
text: string
|
||||
speed?: number // ms entre chaque caractère
|
||||
speedMultiplier?: number // facteur d'accélération
|
||||
}
|
||||
|
||||
export function useTypewriter(options: UseTypewriterOptions) {
|
||||
const { text, speed = 40, speedMultiplier = 5 } = options
|
||||
|
||||
const displayedText = ref('')
|
||||
const isTyping = ref(true)
|
||||
const isAccelerated = ref(false)
|
||||
let timeoutId: NodeJS.Timeout | null = null
|
||||
let currentIndex = 0
|
||||
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
function typeNextChar() {
|
||||
if (currentIndex < text.length) {
|
||||
displayedText.value += text[currentIndex]
|
||||
currentIndex++
|
||||
|
||||
const currentSpeed = isAccelerated.value ? speed / speedMultiplier : speed
|
||||
timeoutId = setTimeout(typeNextChar, currentSpeed)
|
||||
} else {
|
||||
isTyping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (reducedMotion.value) {
|
||||
// Afficher tout le texte immédiatement
|
||||
displayedText.value = text
|
||||
isTyping.value = false
|
||||
return
|
||||
}
|
||||
|
||||
displayedText.value = ''
|
||||
currentIndex = 0
|
||||
isTyping.value = true
|
||||
isAccelerated.value = false
|
||||
typeNextChar()
|
||||
}
|
||||
|
||||
function accelerate() {
|
||||
isAccelerated.value = true
|
||||
}
|
||||
|
||||
function skip() {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
displayedText.value = text
|
||||
isTyping.value = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
displayedText.value = ''
|
||||
currentIndex = 0
|
||||
isTyping.value = true
|
||||
isAccelerated.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
start()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
})
|
||||
|
||||
return {
|
||||
displayedText: readonly(displayedText),
|
||||
isTyping: readonly(isTyping),
|
||||
accelerate,
|
||||
skip,
|
||||
reset,
|
||||
start,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Composable useReducedMotion
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useReducedMotion.ts
|
||||
export function useReducedMotion() {
|
||||
const reducedMotion = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
reducedMotion.value = mediaQuery.matches
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
reducedMotion.value = e.matches
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handler)
|
||||
|
||||
onUnmounted(() => {
|
||||
mediaQuery.removeEventListener('change', handler)
|
||||
})
|
||||
})
|
||||
|
||||
return readonly(reducedMotion)
|
||||
}
|
||||
```
|
||||
|
||||
### Composant DialoguePNJ
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/DialoguePNJ.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { Testimonial } from '~/types/testimonial'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
testimonials: Testimonial[]
|
||||
initialIndex?: number
|
||||
}>(), {
|
||||
initialIndex: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
// État du dialogue actuel
|
||||
const currentIndex = ref(props.initialIndex)
|
||||
const currentTestimonial = computed(() => props.testimonials[currentIndex.value])
|
||||
const totalCount = computed(() => props.testimonials.length)
|
||||
|
||||
// Typewriter
|
||||
const typewriterKey = ref(0) // Pour forcer le reset
|
||||
const { displayedText, isTyping, accelerate, skip, start } = useTypewriter({
|
||||
text: computed(() => currentTestimonial.value?.text ?? ''),
|
||||
})
|
||||
|
||||
// Watch pour restart le typewriter quand le témoignage change
|
||||
watch(currentIndex, () => {
|
||||
typewriterKey.value++
|
||||
nextTick(() => start())
|
||||
})
|
||||
|
||||
// Navigation
|
||||
function goToPrevious() {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
function goToNext() {
|
||||
if (currentIndex.value < totalCount.value - 1) {
|
||||
currentIndex.value++
|
||||
} else {
|
||||
emit('complete')
|
||||
}
|
||||
}
|
||||
|
||||
// Interaction clavier
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
goToPrevious()
|
||||
break
|
||||
case 'ArrowRight':
|
||||
if (!isTyping.value) goToNext()
|
||||
break
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
if (isTyping.value) {
|
||||
accelerate()
|
||||
} else {
|
||||
goToNext()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Interaction clic
|
||||
function handleClick() {
|
||||
if (isTyping.value) {
|
||||
accelerate()
|
||||
}
|
||||
}
|
||||
|
||||
// Styles selon personnalité
|
||||
const personalityStyles = {
|
||||
sage: {
|
||||
bubble: 'bg-blue-400/10 border-l-4 border-blue-400',
|
||||
text: 'text-sky-text',
|
||||
},
|
||||
sarcastique: {
|
||||
bubble: 'bg-purple-400/10 border-l-4 border-purple-400',
|
||||
text: 'text-sky-text italic',
|
||||
},
|
||||
enthousiaste: {
|
||||
bubble: 'bg-sky-accent/10 border-l-4 border-sky-accent',
|
||||
text: 'text-sky-text',
|
||||
},
|
||||
professionnel: {
|
||||
bubble: 'bg-gray-400/10 border-l-4 border-gray-400',
|
||||
text: 'text-sky-text',
|
||||
},
|
||||
}
|
||||
|
||||
const currentStyle = computed(() =>
|
||||
personalityStyles[currentTestimonial.value?.personality ?? 'professionnel']
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="dialogue-pnj"
|
||||
tabindex="0"
|
||||
role="article"
|
||||
:aria-label="currentTestimonial?.text"
|
||||
@keydown="handleKeydown"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div :key="currentIndex" class="flex items-start gap-6">
|
||||
<!-- Avatar PNJ -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-24 h-24 md:w-32 md:h-32 rounded-full overflow-hidden bg-sky-dark-50 border-4 border-sky-dark-100 shadow-lg">
|
||||
<NuxtImg
|
||||
v-if="currentTestimonial?.avatar"
|
||||
:src="currentTestimonial.avatar"
|
||||
:alt="currentTestimonial.name"
|
||||
format="webp"
|
||||
width="128"
|
||||
height="128"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-4xl text-sky-text-muted">
|
||||
👤
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info PNJ sous l'avatar -->
|
||||
<div class="mt-3 text-center">
|
||||
<p class="font-ui font-semibold text-sky-text text-sm">
|
||||
{{ currentTestimonial?.name }}
|
||||
</p>
|
||||
<p class="font-ui text-xs text-sky-text-muted">
|
||||
{{ currentTestimonial?.role }}
|
||||
</p>
|
||||
<p v-if="currentTestimonial?.company" class="font-ui text-xs text-sky-text-muted">
|
||||
@ {{ currentTestimonial.company }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulle de dialogue -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="relative p-6 rounded-lg"
|
||||
:class="currentStyle.bubble"
|
||||
aria-live="polite"
|
||||
>
|
||||
<!-- Triangle de la bulle -->
|
||||
<div
|
||||
class="absolute left-0 top-8 w-0 h-0 -translate-x-full"
|
||||
:class="{
|
||||
'border-t-8 border-r-8 border-b-8 border-transparent border-r-blue-400/10': currentTestimonial?.personality === 'sage',
|
||||
'border-t-8 border-r-8 border-b-8 border-transparent border-r-purple-400/10': currentTestimonial?.personality === 'sarcastique',
|
||||
'border-t-8 border-r-8 border-b-8 border-transparent border-r-sky-accent/10': currentTestimonial?.personality === 'enthousiaste',
|
||||
'border-t-8 border-r-8 border-b-8 border-transparent border-r-gray-400/10': currentTestimonial?.personality === 'professionnel',
|
||||
}"
|
||||
></div>
|
||||
|
||||
<!-- Texte avec typewriter -->
|
||||
<p
|
||||
:key="typewriterKey"
|
||||
class="font-narrative text-lg leading-relaxed min-h-[4rem]"
|
||||
:class="currentStyle.text"
|
||||
>
|
||||
"{{ displayedText }}"
|
||||
<span v-if="isTyping" class="animate-blink">|</span>
|
||||
</p>
|
||||
|
||||
<!-- Indicateur pour continuer -->
|
||||
<div
|
||||
v-if="!isTyping"
|
||||
class="mt-4 text-sm text-sky-text-muted animate-pulse"
|
||||
>
|
||||
{{ t('testimonials.clickToContinue') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lien projet si existant -->
|
||||
<NuxtLink
|
||||
v-if="currentTestimonial?.project"
|
||||
:to="localePath(`/projets/${currentTestimonial.project.slug}`)"
|
||||
class="inline-flex items-center mt-3 text-sm text-sky-accent hover:underline"
|
||||
>
|
||||
📁 {{ currentTestimonial.project.title }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Navigation et indicateur -->
|
||||
<div class="flex items-center justify-between mt-8">
|
||||
<!-- Bouton précédent -->
|
||||
<button
|
||||
type="button"
|
||||
:disabled="currentIndex === 0"
|
||||
class="px-4 py-2 text-sky-text-muted hover:text-sky-text disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
@click.stop="goToPrevious"
|
||||
>
|
||||
← {{ t('testimonials.previous') }}
|
||||
</button>
|
||||
|
||||
<!-- Indicateur position -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-for="(_, idx) in testimonials"
|
||||
:key="idx"
|
||||
class="w-2 h-2 rounded-full transition-colors"
|
||||
:class="idx === currentIndex ? 'bg-sky-accent' : 'bg-sky-dark-100'"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- Bouton suivant -->
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isTyping"
|
||||
class="px-4 py-2 text-sky-text-muted hover:text-sky-text disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
@click.stop="goToNext"
|
||||
>
|
||||
{{ currentIndex === totalCount - 1 ? t('testimonials.finish') : t('testimonials.next') }} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Instructions clavier -->
|
||||
<p class="mt-4 text-xs text-sky-text-muted text-center">
|
||||
{{ t('testimonials.keyboardHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialogue-pnj:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dialogue-pnj:focus-visible {
|
||||
outline: 2px solid theme('colors.sky-accent.DEFAULT');
|
||||
outline-offset: 4px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: blink 0.7s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-blink {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Modification de la page Témoignages
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/temoignages.vue - Version avec DialoguePNJ -->
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const { data, pending, error, refresh } = useFetchTestimonials()
|
||||
|
||||
const testimonials = computed(() => data.value?.data ?? [])
|
||||
|
||||
// Mode d'affichage
|
||||
const viewMode = ref<'dialogue' | 'list'>('dialogue')
|
||||
|
||||
// SEO
|
||||
useHead({
|
||||
title: () => t('testimonials.pageTitle'),
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('testimonials.pageTitle'),
|
||||
description: () => t('testimonials.pageDescription'),
|
||||
ogTitle: () => t('testimonials.pageTitle'),
|
||||
ogDescription: () => t('testimonials.pageDescription'),
|
||||
})
|
||||
|
||||
function handleDialogueComplete() {
|
||||
// Optionnel : action à la fin du dialogue
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-ui font-bold text-sky-text">
|
||||
{{ t('testimonials.title') }}
|
||||
</h1>
|
||||
|
||||
<!-- Toggle vue -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="viewMode === 'dialogue' ? 'bg-sky-accent text-white' : 'bg-sky-dark-50 text-sky-text-muted'"
|
||||
class="px-4 py-2 rounded-lg text-sm transition-colors"
|
||||
@click="viewMode = 'dialogue'"
|
||||
>
|
||||
💬 {{ t('testimonials.dialogueMode') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="viewMode === 'list' ? 'bg-sky-accent text-white' : 'bg-sky-dark-50 text-sky-text-muted'"
|
||||
class="px-4 py-2 rounded-lg text-sm transition-colors"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
📋 {{ t('testimonials.listMode') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="pending" class="flex items-center justify-center py-16">
|
||||
<div class="animate-spin w-8 h-8 border-4 border-sky-accent border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<p class="text-sky-text-muted mb-4">{{ t('testimonials.loadError') }}</p>
|
||||
<button
|
||||
@click="refresh()"
|
||||
class="bg-sky-accent text-white px-6 py-2 rounded-lg hover:bg-sky-accent-hover"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else>
|
||||
<!-- Mode Dialogue -->
|
||||
<DialoguePNJ
|
||||
v-if="viewMode === 'dialogue'"
|
||||
:testimonials="testimonials"
|
||||
@complete="handleDialogueComplete"
|
||||
/>
|
||||
|
||||
<!-- Mode Liste -->
|
||||
<div v-else class="space-y-6">
|
||||
<TestimonialCard
|
||||
v-for="testimonial in testimonials"
|
||||
:key="testimonial.id"
|
||||
:testimonial="testimonial"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Clés i18n nécessaires
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"testimonials": {
|
||||
"clickToContinue": "Cliquez ou appuyez sur Espace pour continuer...",
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"finish": "Terminer",
|
||||
"keyboardHint": "Utilisez les flèches ← → pour naviguer, Espace pour accélérer",
|
||||
"dialogueMode": "Dialogue",
|
||||
"listMode": "Liste"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"testimonials": {
|
||||
"clickToContinue": "Click or press Space to continue...",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"finish": "Finish",
|
||||
"keyboardHint": "Use ← → arrows to navigate, Space to speed up",
|
||||
"dialogueMode": "Dialogue",
|
||||
"listMode": "List"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 2.6 : Table testimonials, API, type Testimonial
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 3.2 : NarratorBubble (pattern similaire typewriter)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── components/feature/
|
||||
│ └── DialoguePNJ.vue # CRÉER
|
||||
└── composables/
|
||||
├── useTypewriter.ts # CRÉER
|
||||
└── useReducedMotion.ts # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/app/pages/temoignages.vue # MODIFIER pour intégrer DialoguePNJ
|
||||
frontend/i18n/fr.json # AJOUTER clés
|
||||
frontend/i18n/en.json # AJOUTER clés
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-2.7]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#DialoguePNJ]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Accessibility-Strategy]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Typography-System]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Typewriter speed | 30-50ms par caractère | UX Spec |
|
||||
| Accélération | x3-x5 | Epics |
|
||||
| Police | font-narrative (serif) | UX Spec |
|
||||
| prefers-reduced-motion | Texte instantané | NFR6 |
|
||||
| Accessibilité | aria-label, keyboard nav | WCAG AA |
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
# Story 2.8: Page Parcours - Timeline narrative
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want découvrir le parcours professionnel du développeur sous forme de timeline,
|
||||
so that je comprends son évolution et son expérience.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur accède à `/parcours` (FR) ou `/en/journey` (EN) **When** la page se charge **Then** une timeline verticale affiche les étapes chronologiques du parcours
|
||||
2. **And** chaque étape affiche : date, titre, description narrative traduite
|
||||
3. **And** sur desktop : les étapes alternent gauche/droite pour un effet visuel dynamique
|
||||
4. **And** sur mobile : les étapes sont linéaires (toutes du même côté)
|
||||
5. **And** une animation d'apparition au scroll est présente (respectant `prefers-reduced-motion`)
|
||||
6. **And** des icônes ou images illustrent les étapes clés
|
||||
7. **And** le contenu est bilingue (FR/EN) et chargé depuis l'API ou fichiers i18n
|
||||
8. **And** les meta tags SEO sont dynamiques pour cette page
|
||||
9. **And** la police serif narrative est utilisée pour les descriptions
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Décider de la source de données** (AC: #7)
|
||||
- [ ] Option A : Fichiers i18n (données statiques)
|
||||
- [ ] Option B : Table BDD + API (données dynamiques)
|
||||
- [ ] Recommandation : Fichiers i18n (le parcours change rarement, pas besoin de CRUD)
|
||||
|
||||
- [ ] **Task 2: Créer les données du parcours dans i18n** (AC: #2, #7)
|
||||
- [ ] Ajouter les clés `journey.milestones` dans fr.json et en.json
|
||||
- [ ] Structure : date, title, description, icon
|
||||
- [ ] 5-8 étapes du parcours professionnel
|
||||
|
||||
- [ ] **Task 3: Créer le composant TimelineItem** (AC: #2, #6, #9)
|
||||
- [ ] Créer `frontend/app/components/feature/TimelineItem.vue`
|
||||
- [ ] Props : milestone (date, title, description, icon)
|
||||
- [ ] Afficher l'icône/image, la date, le titre et la description
|
||||
- [ ] Utiliser font-narrative pour la description
|
||||
|
||||
- [ ] **Task 4: Créer la page parcours.vue** (AC: #1, #3, #4)
|
||||
- [ ] Créer `frontend/app/pages/parcours.vue`
|
||||
- [ ] Charger les milestones depuis i18n
|
||||
- [ ] Layout timeline vertical avec ligne centrale
|
||||
- [ ] Desktop : alternance gauche/droite
|
||||
- [ ] Mobile : toutes les étapes à droite
|
||||
|
||||
- [ ] **Task 5: Implémenter l'animation au scroll** (AC: #5)
|
||||
- [ ] Utiliser IntersectionObserver pour détecter l'entrée dans le viewport
|
||||
- [ ] Animation fade-in + slide-up pour chaque étape
|
||||
- [ ] Respecter prefers-reduced-motion
|
||||
- [ ] Créer un composable `useIntersectionObserver()`
|
||||
|
||||
- [ ] **Task 6: Design de la timeline** (AC: #3, #4)
|
||||
- [ ] Ligne centrale verticale (sky-dark-100)
|
||||
- [ ] Points de connexion sur la ligne (circles sky-accent)
|
||||
- [ ] Cards avec flèche vers la ligne centrale
|
||||
- [ ] Responsive : adaptation mobile
|
||||
|
||||
- [ ] **Task 7: Meta tags SEO** (AC: #8)
|
||||
- [ ] Titre : "Mon Parcours | Skycel"
|
||||
- [ ] Description du parcours
|
||||
|
||||
- [ ] **Task 8: Tests et validation**
|
||||
- [ ] Tester en FR et EN
|
||||
- [ ] Valider l'alternance desktop
|
||||
- [ ] Vérifier le layout mobile
|
||||
- [ ] Tester l'animation au scroll
|
||||
- [ ] Valider prefers-reduced-motion
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Structure des données dans i18n
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"journey": {
|
||||
"title": "Mon Parcours",
|
||||
"pageTitle": "Parcours | Skycel",
|
||||
"pageDescription": "Découvrez le parcours professionnel de Célian, de ses débuts à aujourd'hui.",
|
||||
"milestones": [
|
||||
{
|
||||
"date": "2018",
|
||||
"title": "Premiers pas en développement",
|
||||
"description": "Découverte du code à travers des projets personnels. HTML, CSS, JavaScript deviennent mes nouveaux compagnons de route. L'étincelle est là.",
|
||||
"icon": "🚀"
|
||||
},
|
||||
{
|
||||
"date": "2019",
|
||||
"title": "Formation intensive",
|
||||
"description": "Plongée dans le monde du développement web professionnel. Apprentissage de frameworks modernes, bonnes pratiques, et méthodologies agiles.",
|
||||
"icon": "📚"
|
||||
},
|
||||
{
|
||||
"date": "2020",
|
||||
"title": "Premiers clients",
|
||||
"description": "Lancement en freelance. Premiers projets concrets, premiers défis réels. Chaque client m'apprend quelque chose de nouveau.",
|
||||
"icon": "💼"
|
||||
},
|
||||
{
|
||||
"date": "2021",
|
||||
"title": "Spécialisation Vue.js & Laravel",
|
||||
"description": "Le duo qui change tout. Vue.js côté front, Laravel côté back. Une stack qui me permet de créer des expériences web complètes et performantes.",
|
||||
"icon": "⚡"
|
||||
},
|
||||
{
|
||||
"date": "2022",
|
||||
"title": "Création de la micro-entreprise",
|
||||
"description": "Officialisation de l'aventure entrepreneuriale. L'araignée devient la mascotte, le Bug devient le guide. L'identité Skycel prend forme.",
|
||||
"icon": "🕷️"
|
||||
},
|
||||
{
|
||||
"date": "2023-2024",
|
||||
"title": "Projets ambitieux",
|
||||
"description": "Des applications web complexes aux sites e-commerce, chaque projet repousse les limites. TypeScript, Nuxt 4, et une obsession pour la qualité.",
|
||||
"icon": "🎯"
|
||||
},
|
||||
{
|
||||
"date": "2025",
|
||||
"title": "Aujourd'hui",
|
||||
"description": "Ce portfolio que vous explorez. Une aventure en soi, qui reflète ma passion pour créer des expériences web mémorables. Et ce n'est que le début...",
|
||||
"icon": "✨"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"journey": {
|
||||
"title": "My Journey",
|
||||
"pageTitle": "Journey | Skycel",
|
||||
"pageDescription": "Discover Célian's professional journey, from the beginning to today.",
|
||||
"milestones": [
|
||||
{
|
||||
"date": "2018",
|
||||
"title": "First steps in development",
|
||||
"description": "Discovering code through personal projects. HTML, CSS, JavaScript became my new travel companions. The spark was there.",
|
||||
"icon": "🚀"
|
||||
},
|
||||
{
|
||||
"date": "2019",
|
||||
"title": "Intensive training",
|
||||
"description": "Deep dive into professional web development. Learning modern frameworks, best practices, and agile methodologies.",
|
||||
"icon": "📚"
|
||||
},
|
||||
{
|
||||
"date": "2020",
|
||||
"title": "First clients",
|
||||
"description": "Starting as a freelancer. First real projects, first real challenges. Each client teaches me something new.",
|
||||
"icon": "💼"
|
||||
},
|
||||
{
|
||||
"date": "2021",
|
||||
"title": "Specialization in Vue.js & Laravel",
|
||||
"description": "The game-changing duo. Vue.js on the front, Laravel on the back. A stack that allows me to create complete, performant web experiences.",
|
||||
"icon": "⚡"
|
||||
},
|
||||
{
|
||||
"date": "2022",
|
||||
"title": "Creating the micro-enterprise",
|
||||
"description": "Making the entrepreneurial adventure official. The spider becomes the mascot, the Bug becomes the guide. The Skycel identity takes shape.",
|
||||
"icon": "🕷️"
|
||||
},
|
||||
{
|
||||
"date": "2023-2024",
|
||||
"title": "Ambitious projects",
|
||||
"description": "From complex web applications to e-commerce sites, each project pushes boundaries. TypeScript, Nuxt 4, and an obsession with quality.",
|
||||
"icon": "🎯"
|
||||
},
|
||||
{
|
||||
"date": "2025",
|
||||
"title": "Today",
|
||||
"description": "This portfolio you're exploring. An adventure in itself, reflecting my passion for creating memorable web experiences. And this is just the beginning...",
|
||||
"icon": "✨"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Composable useIntersectionObserver
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useIntersectionObserver.ts
|
||||
export interface UseIntersectionObserverOptions {
|
||||
threshold?: number
|
||||
rootMargin?: string
|
||||
once?: boolean
|
||||
}
|
||||
|
||||
export function useIntersectionObserver(
|
||||
target: Ref<HTMLElement | null>,
|
||||
options: UseIntersectionObserverOptions = {}
|
||||
) {
|
||||
const { threshold = 0.1, rootMargin = '0px', once = true } = options
|
||||
|
||||
const isVisible = ref(false)
|
||||
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!target.value) return
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
isVisible.value = true
|
||||
if (once && observer) {
|
||||
observer.unobserve(entry.target)
|
||||
}
|
||||
} else if (!once) {
|
||||
isVisible.value = false
|
||||
}
|
||||
})
|
||||
},
|
||||
{ threshold, rootMargin }
|
||||
)
|
||||
|
||||
observer.observe(target.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
return { isVisible }
|
||||
}
|
||||
```
|
||||
|
||||
### Composant TimelineItem
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/TimelineItem.vue -->
|
||||
<script setup lang="ts">
|
||||
interface Milestone {
|
||||
date: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
milestone: Milestone
|
||||
index: number
|
||||
isLeft: boolean
|
||||
}>()
|
||||
|
||||
const itemRef = ref<HTMLElement | null>(null)
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
const { isVisible } = useIntersectionObserver(itemRef, {
|
||||
threshold: 0.2,
|
||||
rootMargin: '-50px',
|
||||
})
|
||||
|
||||
// Animation désactivée si prefers-reduced-motion
|
||||
const shouldAnimate = computed(() => !reducedMotion.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="itemRef"
|
||||
class="timeline-item relative flex"
|
||||
:class="[
|
||||
isLeft ? 'md:flex-row-reverse' : 'md:flex-row',
|
||||
'flex-row'
|
||||
]"
|
||||
>
|
||||
<!-- Contenu de l'étape -->
|
||||
<div
|
||||
class="timeline-content w-full md:w-1/2 px-4 md:px-8"
|
||||
:class="[
|
||||
shouldAnimate && isVisible ? 'animate-in' : '',
|
||||
shouldAnimate && !isVisible ? 'opacity-0 translate-y-4' : '',
|
||||
!shouldAnimate ? '' : ''
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="relative bg-sky-dark-50 rounded-lg p-6 shadow-lg"
|
||||
:class="[
|
||||
isLeft ? 'md:mr-8' : 'md:ml-8',
|
||||
'ml-8'
|
||||
]"
|
||||
>
|
||||
<!-- Flèche vers la ligne -->
|
||||
<div
|
||||
class="absolute top-6 w-4 h-4 bg-sky-dark-50 transform rotate-45"
|
||||
:class="[
|
||||
isLeft ? 'md:-right-2 md:left-auto -left-2' : 'md:-left-2 -left-2',
|
||||
]"
|
||||
></div>
|
||||
|
||||
<!-- Icône -->
|
||||
<div class="text-4xl mb-3">
|
||||
{{ milestone.icon }}
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<span class="inline-block px-3 py-1 bg-sky-accent/20 text-sky-accent text-sm font-ui font-medium rounded-full mb-3">
|
||||
{{ milestone.date }}
|
||||
</span>
|
||||
|
||||
<!-- Titre -->
|
||||
<h3 class="text-xl font-ui font-bold text-sky-text mb-2">
|
||||
{{ milestone.title }}
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="font-narrative text-sky-text-muted leading-relaxed">
|
||||
{{ milestone.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Point sur la ligne (visible uniquement côté desktop) -->
|
||||
<div class="timeline-dot absolute left-0 md:left-1/2 top-6 transform md:-translate-x-1/2 -translate-x-1/2">
|
||||
<div class="w-4 h-4 bg-sky-accent rounded-full ring-4 ring-sky-dark"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.animate-in {
|
||||
animation: fadeSlideUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.timeline-content {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Page parcours.vue
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/parcours.vue -->
|
||||
<script setup lang="ts">
|
||||
const { t, tm } = useI18n()
|
||||
|
||||
interface Milestone {
|
||||
date: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
// Charger les milestones depuis i18n
|
||||
const milestones = computed(() => {
|
||||
const data = tm('journey.milestones')
|
||||
if (Array.isArray(data)) {
|
||||
return data as Milestone[]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// SEO
|
||||
useHead({
|
||||
title: () => t('journey.pageTitle'),
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('journey.pageTitle'),
|
||||
description: () => t('journey.pageDescription'),
|
||||
ogTitle: () => t('journey.pageTitle'),
|
||||
ogDescription: () => t('journey.pageDescription'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-ui font-bold text-sky-text mb-12 text-center">
|
||||
{{ t('journey.title') }}
|
||||
</h1>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="relative">
|
||||
<!-- Ligne centrale (visible uniquement sur desktop) -->
|
||||
<div class="absolute left-0 md:left-1/2 top-0 bottom-0 w-0.5 bg-sky-dark-100 transform md:-translate-x-1/2"></div>
|
||||
|
||||
<!-- Étapes -->
|
||||
<div class="space-y-12">
|
||||
<TimelineItem
|
||||
v-for="(milestone, index) in milestones"
|
||||
:key="index"
|
||||
:milestone="milestone"
|
||||
:index="index"
|
||||
:is-left="index % 2 === 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message de fin -->
|
||||
<div class="mt-16 text-center">
|
||||
<p class="font-narrative text-xl text-sky-text-muted italic">
|
||||
{{ t('journey.endMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Clés i18n supplémentaires
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"journey": {
|
||||
"endMessage": "L'aventure continue... Qui sait où le code me mènera demain ?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"journey": {
|
||||
"endMessage": "The adventure continues... Who knows where code will take me tomorrow?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Design de la timeline
|
||||
|
||||
```
|
||||
DESKTOP (alternance gauche/droite) :
|
||||
|
||||
┌─────────────────┐
|
||||
│ 2018 │
|
||||
│ Description │──●──
|
||||
└─────────────────┘ │
|
||||
│
|
||||
──●────┼────┌─────────────────┐
|
||||
│ │ 2019 │
|
||||
│ │ Description │
|
||||
│ └─────────────────┘
|
||||
│
|
||||
┌─────────────────┐ │
|
||||
│ 2020 │──●──
|
||||
│ Description │ │
|
||||
└─────────────────┘ │
|
||||
|
||||
MOBILE (linéaire à droite) :
|
||||
|
||||
│ ┌─────────────────┐
|
||||
●──│ 2018 │
|
||||
│ │ Description │
|
||||
│ └─────────────────┘
|
||||
│
|
||||
│ ┌─────────────────┐
|
||||
●──│ 2019 │
|
||||
│ │ Description │
|
||||
│ └─────────────────┘
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 1.3 : Système i18n configuré
|
||||
- Story 1.4 : Layouts et routing
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Aucune dépendance directe (dernière story de l'Epic 2)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── pages/
|
||||
│ └── parcours.vue # CRÉER
|
||||
├── components/feature/
|
||||
│ └── TimelineItem.vue # CRÉER
|
||||
└── composables/
|
||||
└── useIntersectionObserver.ts # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/i18n/fr.json # AJOUTER journey.*
|
||||
frontend/i18n/en.json # AJOUTER journey.*
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-2.8]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Screen-Architecture-Summary]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Typography-System]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Source données | Fichiers i18n | Décision technique |
|
||||
| Layout desktop | Alternance gauche/droite | Epics |
|
||||
| Layout mobile | Linéaire à droite | Epics |
|
||||
| Animation | IntersectionObserver + fade-in | Epics |
|
||||
| Police | font-narrative pour descriptions | UX Spec |
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
# Story 3.1: Table narrator_texts et API narrateur
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a développeur,
|
||||
I want une infrastructure pour stocker et servir les textes du narrateur,
|
||||
so that le narrateur peut afficher des messages contextuels variés.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** les migrations Laravel sont exécutées **When** `php artisan migrate` est lancé **Then** la table `narrator_texts` est créée (id, context, text_key, variant, timestamps)
|
||||
2. **And** les contextes définis incluent : intro, transition_projects, transition_skills, transition_testimonials, transition_journey, hint, encouragement_25, encouragement_50, encouragement_75, contact_unlocked, welcome_back
|
||||
3. **And** plusieurs variantes par contexte permettent une sélection aléatoire
|
||||
4. **And** les seeders insèrent les textes de base en FR et EN dans la table `translations`
|
||||
5. **Given** l'API `/api/narrator/{context}` est appelée **When** un contexte valide est fourni **Then** un texte aléatoire parmi les variantes de ce contexte est retourné
|
||||
6. **And** le texte est traduit selon le header `Accept-Language`
|
||||
7. **And** le ton est adapté au héros (vouvoiement pour Recruteur, tutoiement pour Client/Dev)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer la migration table narrator_texts** (AC: #1, #2, #3)
|
||||
- [ ] Créer migration `create_narrator_texts_table`
|
||||
- [ ] Colonnes : id, context (string), text_key (string), variant (integer), hero_type (enum nullable: recruteur, client, dev), timestamps
|
||||
- [ ] Index sur context pour le filtrage
|
||||
- [ ] Index composite sur (context, hero_type) pour les requêtes
|
||||
|
||||
- [ ] **Task 2: Créer le Model NarratorText** (AC: #3)
|
||||
- [ ] Créer `app/Models/NarratorText.php`
|
||||
- [ ] Définir les fillable : context, text_key, variant, hero_type
|
||||
- [ ] Scope `scopeForContext($query, $context)` pour filtrer par contexte
|
||||
- [ ] Scope `scopeForHero($query, $heroType)` pour filtrer par héros
|
||||
- [ ] Méthode statique `getRandomText($context, $heroType = null)` pour récupérer un texte aléatoire
|
||||
|
||||
- [ ] **Task 3: Créer le Seeder des textes narrateur** (AC: #4)
|
||||
- [ ] Créer `database/seeders/NarratorTextSeeder.php`
|
||||
- [ ] Créer les textes pour chaque contexte avec 2-3 variantes
|
||||
- [ ] Créer des variantes spécifiques par héros quand nécessaire (vouvoiement/tutoiement)
|
||||
- [ ] Ajouter les traductions FR et EN dans TranslationSeeder
|
||||
|
||||
- [ ] **Task 4: Créer l'endpoint API narrateur** (AC: #5, #6, #7)
|
||||
- [ ] Créer `app/Http/Controllers/Api/NarratorController.php`
|
||||
- [ ] Méthode `getText($context)` pour récupérer un texte aléatoire
|
||||
- [ ] Paramètre query optionnel `?hero=recruteur|client|dev`
|
||||
- [ ] Joindre les traductions selon `Accept-Language`
|
||||
- [ ] Retourner 404 si contexte invalide
|
||||
|
||||
- [ ] **Task 5: Créer le composable useFetchNarratorText** (AC: #5)
|
||||
- [ ] Créer `frontend/app/composables/useFetchNarratorText.ts`
|
||||
- [ ] Accepter le contexte et le type de héros en paramètres
|
||||
- [ ] Gérer les états loading, error, data
|
||||
|
||||
- [ ] **Task 6: Tests et validation**
|
||||
- [ ] Exécuter les migrations
|
||||
- [ ] Vérifier le seeding des données
|
||||
- [ ] Tester l'API avec différents contextes
|
||||
- [ ] Vérifier le vouvoiement/tutoiement selon le héros
|
||||
- [ ] Tester les variantes aléatoires
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Migration narrator_texts
|
||||
|
||||
```php
|
||||
<?php
|
||||
// database/migrations/2026_02_04_000002_create_narrator_texts_table.php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('narrator_texts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('context');
|
||||
$table->string('text_key');
|
||||
$table->integer('variant')->default(1);
|
||||
$table->enum('hero_type', ['recruteur', 'client', 'dev'])->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('context');
|
||||
$table->index(['context', 'hero_type']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('narrator_texts');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Model NarratorText
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Models/NarratorText.php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NarratorText extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'context',
|
||||
'text_key',
|
||||
'variant',
|
||||
'hero_type',
|
||||
];
|
||||
|
||||
public function scopeForContext($query, string $context)
|
||||
{
|
||||
return $query->where('context', $context);
|
||||
}
|
||||
|
||||
public function scopeForHero($query, ?string $heroType)
|
||||
{
|
||||
if ($heroType) {
|
||||
return $query->where(function ($q) use ($heroType) {
|
||||
$q->where('hero_type', $heroType)
|
||||
->orWhereNull('hero_type');
|
||||
});
|
||||
}
|
||||
return $query->whereNull('hero_type');
|
||||
}
|
||||
|
||||
public static function getRandomText(string $context, ?string $heroType = null): ?self
|
||||
{
|
||||
$query = static::forContext($context);
|
||||
|
||||
if ($heroType) {
|
||||
// Priorité aux textes spécifiques au héros, sinon textes génériques
|
||||
$heroSpecific = (clone $query)->where('hero_type', $heroType)->inRandomOrder()->first();
|
||||
if ($heroSpecific) {
|
||||
return $heroSpecific;
|
||||
}
|
||||
}
|
||||
|
||||
return $query->whereNull('hero_type')->inRandomOrder()->first();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Contextes du narrateur
|
||||
|
||||
| Contexte | Description | Variantes |
|
||||
|----------|-------------|-----------|
|
||||
| `intro` | Message d'accueil initial | 3 par héros |
|
||||
| `transition_projects` | Arrivée sur la page Projets | 2 génériques |
|
||||
| `transition_skills` | Arrivée sur la page Compétences | 2 génériques |
|
||||
| `transition_testimonials` | Arrivée sur la page Témoignages | 2 génériques |
|
||||
| `transition_journey` | Arrivée sur la page Parcours | 2 génériques |
|
||||
| `hint` | Indices si inactif > 30s | 3 génériques |
|
||||
| `encouragement_25` | Progression à 25% | 2 génériques |
|
||||
| `encouragement_50` | Progression à 50% | 2 génériques |
|
||||
| `encouragement_75` | Progression à 75% | 2 génériques |
|
||||
| `contact_unlocked` | Déblocage du contact | 2 génériques |
|
||||
| `welcome_back` | Retour d'un visiteur | 2 génériques |
|
||||
|
||||
### Seeder des textes narrateur
|
||||
|
||||
```php
|
||||
<?php
|
||||
// database/seeders/NarratorTextSeeder.php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\NarratorText;
|
||||
use App\Models\Translation;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class NarratorTextSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$texts = [
|
||||
// INTRO - Recruteur (vouvoiement)
|
||||
['context' => 'intro', 'text_key' => 'narrator.intro.recruteur.1', 'variant' => 1, 'hero_type' => 'recruteur'],
|
||||
['context' => 'intro', 'text_key' => 'narrator.intro.recruteur.2', 'variant' => 2, 'hero_type' => 'recruteur'],
|
||||
|
||||
// INTRO - Client/Dev (tutoiement)
|
||||
['context' => 'intro', 'text_key' => 'narrator.intro.casual.1', 'variant' => 1, 'hero_type' => 'client'],
|
||||
['context' => 'intro', 'text_key' => 'narrator.intro.casual.1', 'variant' => 1, 'hero_type' => 'dev'],
|
||||
['context' => 'intro', 'text_key' => 'narrator.intro.casual.2', 'variant' => 2, 'hero_type' => 'client'],
|
||||
['context' => 'intro', 'text_key' => 'narrator.intro.casual.2', 'variant' => 2, 'hero_type' => 'dev'],
|
||||
|
||||
// TRANSITIONS
|
||||
['context' => 'transition_projects', 'text_key' => 'narrator.transition.projects.1', 'variant' => 1, 'hero_type' => null],
|
||||
['context' => 'transition_projects', 'text_key' => 'narrator.transition.projects.2', 'variant' => 2, 'hero_type' => null],
|
||||
['context' => 'transition_skills', 'text_key' => 'narrator.transition.skills.1', 'variant' => 1, 'hero_type' => null],
|
||||
['context' => 'transition_skills', 'text_key' => 'narrator.transition.skills.2', 'variant' => 2, 'hero_type' => null],
|
||||
['context' => 'transition_testimonials', 'text_key' => 'narrator.transition.testimonials.1', 'variant' => 1, 'hero_type' => null],
|
||||
['context' => 'transition_journey', 'text_key' => 'narrator.transition.journey.1', 'variant' => 1, 'hero_type' => null],
|
||||
|
||||
// HINTS
|
||||
['context' => 'hint', 'text_key' => 'narrator.hint.1', 'variant' => 1, 'hero_type' => null],
|
||||
['context' => 'hint', 'text_key' => 'narrator.hint.2', 'variant' => 2, 'hero_type' => null],
|
||||
['context' => 'hint', 'text_key' => 'narrator.hint.3', 'variant' => 3, 'hero_type' => null],
|
||||
|
||||
// ENCOURAGEMENTS
|
||||
['context' => 'encouragement_25', 'text_key' => 'narrator.encouragement.25.1', 'variant' => 1, 'hero_type' => null],
|
||||
['context' => 'encouragement_50', 'text_key' => 'narrator.encouragement.50.1', 'variant' => 1, 'hero_type' => null],
|
||||
['context' => 'encouragement_75', 'text_key' => 'narrator.encouragement.75.1', 'variant' => 1, 'hero_type' => null],
|
||||
|
||||
// CONTACT UNLOCKED
|
||||
['context' => 'contact_unlocked', 'text_key' => 'narrator.contact_unlocked.1', 'variant' => 1, 'hero_type' => null],
|
||||
|
||||
// WELCOME BACK
|
||||
['context' => 'welcome_back', 'text_key' => 'narrator.welcome_back.1', 'variant' => 1, 'hero_type' => null],
|
||||
['context' => 'welcome_back', 'text_key' => 'narrator.welcome_back.2', 'variant' => 2, 'hero_type' => null],
|
||||
];
|
||||
|
||||
foreach ($texts as $data) {
|
||||
NarratorText::create($data);
|
||||
}
|
||||
|
||||
// Traductions
|
||||
$translations = [
|
||||
// Intro Recruteur (vouvoiement)
|
||||
['key' => 'narrator.intro.recruteur.1', 'fr' => "Bienvenue, voyageur... Vous voilà arrivé en terre inconnue. Un développeur mystérieux se cache quelque part ici. Saurez-vous le trouver ?", 'en' => "Welcome, traveler... You have arrived in unknown lands. A mysterious developer hides somewhere here. Will you be able to find them?"],
|
||||
['key' => 'narrator.intro.recruteur.2', 'fr' => "Ah, un visiteur distingué... Je sens que vous cherchez quelqu'un de particulier. Laissez-moi vous guider dans cette aventure.", 'en' => "Ah, a distinguished visitor... I sense you're looking for someone special. Let me guide you through this adventure."],
|
||||
|
||||
// Intro Client/Dev (tutoiement)
|
||||
['key' => 'narrator.intro.casual.1', 'fr' => "Tiens tiens... Un nouveau venu ! Tu tombes bien, j'ai quelqu'un à te présenter. Mais d'abord, un peu d'exploration s'impose...", 'en' => "Well well... A newcomer! You're just in time, I have someone to introduce you to. But first, a bit of exploration is in order..."],
|
||||
['key' => 'narrator.intro.casual.2', 'fr' => "Salut l'ami ! Bienvenue dans mon monde. Tu cherches le développeur qui a créé tout ça ? Suis-moi, je connais le chemin...", 'en' => "Hey friend! Welcome to my world. Looking for the developer who created all this? Follow me, I know the way..."],
|
||||
|
||||
// Transitions
|
||||
['key' => 'narrator.transition.projects.1', 'fr' => "Voici les créations du développeur... Chaque projet raconte une histoire. Laquelle vas-tu explorer ?", 'en' => "Here are the developer's creations... Each project tells a story. Which one will you explore?"],
|
||||
['key' => 'narrator.transition.projects.2', 'fr' => "Bienvenue dans la galerie des projets. C'est ici que le code prend vie...", 'en' => "Welcome to the project gallery. This is where code comes to life..."],
|
||||
['key' => 'narrator.transition.skills.1', 'fr' => "L'arbre des compétences... Chaque branche représente un savoir acquis au fil du temps.", 'en' => "The skill tree... Each branch represents knowledge acquired over time."],
|
||||
['key' => 'narrator.transition.skills.2', 'fr' => "Voici les outils de notre ami développeur. Impressionnant, n'est-ce pas ?", 'en' => "Here are our developer friend's tools. Impressive, isn't it?"],
|
||||
['key' => 'narrator.transition.testimonials.1', 'fr' => "D'autres voyageurs sont passés par ici avant toi. Écoute leurs histoires...", 'en' => "Other travelers have passed through here before you. Listen to their stories..."],
|
||||
['key' => 'narrator.transition.journey.1', 'fr' => "Le chemin parcouru... Chaque étape a façonné le développeur que tu cherches.", 'en' => "The path traveled... Each step has shaped the developer you're looking for."],
|
||||
|
||||
// Hints
|
||||
['key' => 'narrator.hint.1', 'fr' => "Tu sembles perdu... N'hésite pas à explorer les différentes zones !", 'en' => "You seem lost... Don't hesitate to explore the different areas!"],
|
||||
['key' => 'narrator.hint.2', 'fr' => "Psst... Il reste encore tant de choses à découvrir ici...", 'en' => "Psst... There's still so much to discover here..."],
|
||||
['key' => 'narrator.hint.3', 'fr' => "La carte peut t'aider à naviguer. Clique dessus !", 'en' => "The map can help you navigate. Click on it!"],
|
||||
|
||||
// Encouragements
|
||||
['key' => 'narrator.encouragement.25.1', 'fr' => "Beau début ! Tu as exploré un quart du territoire. Continue comme ça...", 'en' => "Great start! You've explored a quarter of the territory. Keep it up..."],
|
||||
['key' => 'narrator.encouragement.50.1', 'fr' => "À mi-chemin ! Tu commences vraiment à connaître cet endroit.", 'en' => "Halfway there! You're really starting to know this place."],
|
||||
['key' => 'narrator.encouragement.75.1', 'fr' => "Impressionnant ! Plus que quelques zones et tu auras tout vu...", 'en' => "Impressive! Just a few more areas and you'll have seen everything..."],
|
||||
|
||||
// Contact unlocked
|
||||
['key' => 'narrator.contact_unlocked.1', 'fr' => "Tu as assez exploré pour mériter une rencontre... Le chemin vers le développeur est maintenant ouvert !", 'en' => "You've explored enough to deserve a meeting... The path to the developer is now open!"],
|
||||
|
||||
// Welcome back
|
||||
['key' => 'narrator.welcome_back.1', 'fr' => "Te revoilà ! Tu m'avais manqué... On reprend là où on s'était arrêtés ?", 'en' => "You're back! I missed you... Shall we pick up where we left off?"],
|
||||
['key' => 'narrator.welcome_back.2', 'fr' => "Tiens, un visage familier ! Content de te revoir, voyageur.", 'en' => "Well, a familiar face! Good to see you again, traveler."],
|
||||
];
|
||||
|
||||
foreach ($translations as $t) {
|
||||
Translation::firstOrCreate(
|
||||
['lang' => 'fr', 'key_name' => $t['key']],
|
||||
['value' => $t['fr']]
|
||||
);
|
||||
Translation::firstOrCreate(
|
||||
['lang' => 'en', 'key_name' => $t['key']],
|
||||
['value' => $t['en']]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Controller API
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Controllers/Api/NarratorController.php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\NarratorText;
|
||||
use App\Models\Translation;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NarratorController extends Controller
|
||||
{
|
||||
private const VALID_CONTEXTS = [
|
||||
'intro',
|
||||
'transition_projects',
|
||||
'transition_skills',
|
||||
'transition_testimonials',
|
||||
'transition_journey',
|
||||
'hint',
|
||||
'encouragement_25',
|
||||
'encouragement_50',
|
||||
'encouragement_75',
|
||||
'contact_unlocked',
|
||||
'welcome_back',
|
||||
];
|
||||
|
||||
public function getText(Request $request, string $context)
|
||||
{
|
||||
if (!in_array($context, self::VALID_CONTEXTS)) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'INVALID_CONTEXT',
|
||||
'message' => 'Invalid narrator context',
|
||||
'valid_contexts' => self::VALID_CONTEXTS,
|
||||
]
|
||||
], 404);
|
||||
}
|
||||
|
||||
$lang = $request->header('Accept-Language', 'fr');
|
||||
$heroType = $request->query('hero');
|
||||
|
||||
// Valider hero_type
|
||||
if ($heroType && !in_array($heroType, ['recruteur', 'client', 'dev'])) {
|
||||
$heroType = null;
|
||||
}
|
||||
|
||||
$narratorText = NarratorText::getRandomText($context, $heroType);
|
||||
|
||||
if (!$narratorText) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'NO_TEXT_FOUND',
|
||||
'message' => 'No narrator text found for this context',
|
||||
]
|
||||
], 404);
|
||||
}
|
||||
|
||||
$text = Translation::getTranslation($narratorText->text_key, $lang);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'context' => $context,
|
||||
'text' => $text,
|
||||
'variant' => $narratorText->variant,
|
||||
'heroType' => $narratorText->hero_type,
|
||||
],
|
||||
'meta' => [
|
||||
'lang' => $lang,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// api/routes/api.php
|
||||
Route::get('/narrator/{context}', [NarratorController::class, 'getText']);
|
||||
```
|
||||
|
||||
### Composable useFetchNarratorText
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useFetchNarratorText.ts
|
||||
type NarratorContext =
|
||||
| 'intro'
|
||||
| 'transition_projects'
|
||||
| 'transition_skills'
|
||||
| 'transition_testimonials'
|
||||
| 'transition_journey'
|
||||
| 'hint'
|
||||
| 'encouragement_25'
|
||||
| 'encouragement_50'
|
||||
| 'encouragement_75'
|
||||
| 'contact_unlocked'
|
||||
| 'welcome_back'
|
||||
|
||||
type HeroType = 'recruteur' | 'client' | 'dev'
|
||||
|
||||
interface NarratorTextResponse {
|
||||
data: {
|
||||
context: string
|
||||
text: string
|
||||
variant: number
|
||||
heroType: HeroType | null
|
||||
}
|
||||
meta: { lang: string }
|
||||
}
|
||||
|
||||
export function useFetchNarratorText() {
|
||||
const config = useRuntimeConfig()
|
||||
const { locale } = useI18n()
|
||||
|
||||
async function fetchText(context: NarratorContext, heroType?: HeroType) {
|
||||
const url = heroType
|
||||
? `/narrator/${context}?hero=${heroType}`
|
||||
: `/narrator/${context}`
|
||||
|
||||
return await $fetch<NarratorTextResponse>(url, {
|
||||
baseURL: config.public.apiUrl,
|
||||
headers: {
|
||||
'X-API-Key': config.public.apiKey,
|
||||
'Accept-Language': locale.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { fetchText }
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 1.2 : Table translations et système de traduction
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 3.2 : Composant NarratorBubble (consomme l'API)
|
||||
- Story 3.3 : Textes contextuels (utilise les contextes)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
api/
|
||||
├── app/Models/
|
||||
│ └── NarratorText.php # CRÉER
|
||||
├── app/Http/Controllers/Api/
|
||||
│ └── NarratorController.php # CRÉER
|
||||
└── database/
|
||||
├── migrations/
|
||||
│ └── 2026_02_04_000002_create_narrator_texts_table.php # CRÉER
|
||||
└── seeders/
|
||||
└── NarratorTextSeeder.php # CRÉER
|
||||
|
||||
frontend/app/composables/
|
||||
└── useFetchNarratorText.ts # CRÉER
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-3.1]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#NarratorBubble]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Hero-System]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Narrateur]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Contextes | 11 types différents | Epics |
|
||||
| Variantes | 2-3 par contexte | Epics |
|
||||
| Ton héros | vouvoiement/tutoiement | UX Spec |
|
||||
| API endpoint | GET /api/narrator/{context} | Architecture |
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
# Story 3.2: Composant NarratorBubble (Le Bug)
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want voir un narrateur-guide qui m'accompagne dans mon exploration,
|
||||
so that je me sens guidé et l'expérience est immersive.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le composant `NarratorBubble` est implémenté **When** le narrateur doit afficher un message **Then** une bulle apparaît en bas de l'écran (desktop) ou au-dessus de la bottom bar (mobile)
|
||||
2. **And** l'avatar du Bug (araignée) s'affiche avec son apparence selon le `narratorStage` du store
|
||||
3. **And** le texte apparaît avec effet typewriter (lettre par lettre)
|
||||
4. **And** un clic ou Espace accélère l'animation typewriter
|
||||
5. **And** la bulle peut être fermée/minimisée sans bloquer la navigation
|
||||
6. **And** le composant utilise `aria-live="polite"` et `role="status"` pour l'accessibilité
|
||||
7. **And** `prefers-reduced-motion` affiche le texte instantanément
|
||||
8. **And** la police serif narrative est utilisée pour le texte
|
||||
9. **And** l'animation d'apparition/disparition est fluide et non-bloquante
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer le composable useTypewriter** (AC: #3, #4, #7)
|
||||
- [ ] Créer `frontend/app/composables/useTypewriter.ts`
|
||||
- [ ] Accepter le texte en paramètre
|
||||
- [ ] Afficher lettre par lettre (30-50ms par lettre)
|
||||
- [ ] Exposer une méthode `skip()` pour afficher tout le texte instantanément
|
||||
- [ ] Respecter `prefers-reduced-motion`
|
||||
|
||||
- [ ] **Task 2: Créer les assets du Bug par stage** (AC: #2)
|
||||
- [ ] Préparer 5 images SVG ou PNG pour les 5 stades du Bug
|
||||
- [ ] Stage 1 : silhouette sombre floue
|
||||
- [ ] Stage 2 : forme vague avec yeux
|
||||
- [ ] Stage 3 : pattes visibles
|
||||
- [ ] Stage 4 : araignée reconnaissable
|
||||
- [ ] Stage 5 : mascotte complète révélée
|
||||
- [ ] Placer dans `frontend/public/images/bug/`
|
||||
|
||||
- [ ] **Task 3: Créer le composant NarratorBubble** (AC: #1, #2, #3, #4, #5, #8, #9)
|
||||
- [ ] Créer `frontend/app/components/feature/NarratorBubble.vue`
|
||||
- [ ] Props : message (string), visible (boolean)
|
||||
- [ ] Emit : close, skip
|
||||
- [ ] Afficher l'avatar du Bug selon `narratorStage` du store
|
||||
- [ ] Intégrer le composable useTypewriter
|
||||
- [ ] Bouton de fermeture/minimisation
|
||||
- [ ] Utiliser font-narrative pour le texte
|
||||
|
||||
- [ ] **Task 4: Implémenter l'accessibilité** (AC: #6, #7)
|
||||
- [ ] Ajouter `aria-live="polite"` sur le conteneur
|
||||
- [ ] Ajouter `role="status"` pour signaler les mises à jour
|
||||
- [ ] S'assurer que le texte complet est accessible même pendant l'animation
|
||||
- [ ] Tester avec prefers-reduced-motion
|
||||
|
||||
- [ ] **Task 5: Animation d'apparition/disparition** (AC: #9)
|
||||
- [ ] Slide-up pour l'apparition
|
||||
- [ ] Fade-out pour la disparition
|
||||
- [ ] Utiliser CSS transitions pour fluidité
|
||||
- [ ] Non-bloquante : ne pas empêcher les interactions avec le reste de la page
|
||||
|
||||
- [ ] **Task 6: Responsive design** (AC: #1)
|
||||
- [ ] Desktop : bulle en bas de l'écran (position fixed)
|
||||
- [ ] Mobile : au-dessus de la bottom bar (variable CSS pour le spacing)
|
||||
- [ ] Taille adaptée à l'écran
|
||||
|
||||
- [ ] **Task 7: Tests et validation**
|
||||
- [ ] Tester l'effet typewriter
|
||||
- [ ] Tester le skip au clic/Espace
|
||||
- [ ] Vérifier les 5 stades du Bug
|
||||
- [ ] Valider l'accessibilité (screen reader)
|
||||
- [ ] Tester prefers-reduced-motion
|
||||
- [ ] Valider responsive (desktop/mobile)
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Composable useTypewriter
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useTypewriter.ts
|
||||
export interface UseTypewriterOptions {
|
||||
speed?: number // ms par caractère
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export function useTypewriter(options: UseTypewriterOptions = {}) {
|
||||
const { speed = 40, onComplete } = options
|
||||
|
||||
const text = ref('')
|
||||
const displayedText = ref('')
|
||||
const isTyping = ref(false)
|
||||
const isComplete = ref(false)
|
||||
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let currentIndex = 0
|
||||
|
||||
function start(newText: string) {
|
||||
text.value = newText
|
||||
displayedText.value = ''
|
||||
currentIndex = 0
|
||||
isTyping.value = true
|
||||
isComplete.value = false
|
||||
|
||||
// Si prefers-reduced-motion, afficher tout instantanément
|
||||
if (reducedMotion.value) {
|
||||
skip()
|
||||
return
|
||||
}
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
if (currentIndex < text.value.length) {
|
||||
displayedText.value += text.value[currentIndex]
|
||||
currentIndex++
|
||||
} else {
|
||||
complete()
|
||||
}
|
||||
}, speed)
|
||||
}
|
||||
|
||||
function skip() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
displayedText.value = text.value
|
||||
complete()
|
||||
}
|
||||
|
||||
function complete() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
isTyping.value = false
|
||||
isComplete.value = true
|
||||
onComplete?.()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
text.value = ''
|
||||
displayedText.value = ''
|
||||
currentIndex = 0
|
||||
isTyping.value = false
|
||||
isComplete.value = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
text,
|
||||
displayedText,
|
||||
isTyping,
|
||||
isComplete,
|
||||
start,
|
||||
skip,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Composable useReducedMotion
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useReducedMotion.ts
|
||||
export function useReducedMotion() {
|
||||
const reducedMotion = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
reducedMotion.value = mediaQuery.matches
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
reducedMotion.value = e.matches
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handler)
|
||||
|
||||
onUnmounted(() => {
|
||||
mediaQuery.removeEventListener('change', handler)
|
||||
})
|
||||
})
|
||||
|
||||
return reducedMotion
|
||||
}
|
||||
```
|
||||
|
||||
### Composant NarratorBubble
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/NarratorBubble.vue -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
message: string
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const progressionStore = useProgressionStore()
|
||||
const { displayedText, isTyping, isComplete, start, skip } = useTypewriter({
|
||||
speed: 40,
|
||||
})
|
||||
|
||||
// Images du Bug par stage
|
||||
const bugImages: Record<number, string> = {
|
||||
1: '/images/bug/bug-stage-1.svg',
|
||||
2: '/images/bug/bug-stage-2.svg',
|
||||
3: '/images/bug/bug-stage-3.svg',
|
||||
4: '/images/bug/bug-stage-4.svg',
|
||||
5: '/images/bug/bug-stage-5.svg',
|
||||
}
|
||||
|
||||
const currentBugImage = computed(() => {
|
||||
return bugImages[progressionStore.narratorStage] || bugImages[1]
|
||||
})
|
||||
|
||||
// Démarrer l'animation quand le message change
|
||||
watch(() => props.message, (newMessage) => {
|
||||
if (newMessage && props.visible) {
|
||||
start(newMessage)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Écouter les clics et touches pour skip
|
||||
function handleInteraction() {
|
||||
if (isTyping.value) {
|
||||
skip()
|
||||
emit('skip')
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.code === 'Space' || e.code === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleInteraction()
|
||||
}
|
||||
if (e.code === 'Escape') {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="narrator-slide">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="narrator-bubble fixed bottom-4 left-4 right-4 md:left-auto md:right-8 md:max-w-md z-50"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@click="handleInteraction"
|
||||
@keydown="handleKeydown"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex items-start gap-4 bg-sky-dark-50 rounded-xl p-4 shadow-xl border border-sky-dark-100">
|
||||
<!-- Avatar du Bug -->
|
||||
<div class="shrink-0 w-16 h-16 md:w-20 md:h-20">
|
||||
<img
|
||||
:src="currentBugImage"
|
||||
:alt="`Le Bug - Stade ${progressionStore.narratorStage}`"
|
||||
class="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Texte avec typewriter -->
|
||||
<p class="font-narrative text-sky-text text-base md:text-lg leading-relaxed">
|
||||
{{ displayedText }}
|
||||
<span
|
||||
v-if="isTyping"
|
||||
class="inline-block w-0.5 h-5 bg-sky-accent animate-blink ml-0.5"
|
||||
></span>
|
||||
</p>
|
||||
|
||||
<!-- Texte complet pour screen readers (caché visuellement) -->
|
||||
<span class="sr-only">{{ message }}</span>
|
||||
|
||||
<!-- Indicateur de skip -->
|
||||
<p
|
||||
v-if="isTyping"
|
||||
class="text-xs text-sky-text-muted mt-2 font-ui"
|
||||
>
|
||||
{{ $t('narrator.clickToSkip') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bouton fermer -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 p-1 text-sky-text-muted hover:text-sky-text transition-colors"
|
||||
:aria-label="$t('common.close')"
|
||||
@click.stop="emit('close')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.narrator-slide-enter-active,
|
||||
.narrator-slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.narrator-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.narrator-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
/* Position mobile : au-dessus de la bottom bar */
|
||||
@media (max-width: 767px) {
|
||||
.narrator-bubble {
|
||||
bottom: calc(var(--bottom-bar-height, 64px) + 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prefers reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.narrator-slide-enter-active,
|
||||
.narrator-slide-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Clés i18n à ajouter
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"narrator": {
|
||||
"clickToSkip": "Cliquez ou appuyez sur Espace pour passer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"narrator": {
|
||||
"clickToSkip": "Click or press Space to skip"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Structure des assets du Bug
|
||||
|
||||
```
|
||||
frontend/public/images/bug/
|
||||
├── bug-stage-1.svg # Silhouette sombre floue
|
||||
├── bug-stage-2.svg # Forme vague avec yeux
|
||||
├── bug-stage-3.svg # Pattes visibles
|
||||
├── bug-stage-4.svg # Araignée reconnaissable
|
||||
└── bug-stage-5.svg # Mascotte complète révélée
|
||||
```
|
||||
|
||||
### Utilisation du composant
|
||||
|
||||
```vue
|
||||
<!-- Exemple d'utilisation dans un layout ou page -->
|
||||
<script setup>
|
||||
const showNarrator = ref(true)
|
||||
const narratorMessage = ref('')
|
||||
|
||||
const { fetchText } = useFetchNarratorText()
|
||||
const progressionStore = useProgressionStore()
|
||||
|
||||
async function showIntro() {
|
||||
const response = await fetchText('intro', progressionStore.heroType)
|
||||
narratorMessage.value = response.data.text
|
||||
showNarrator.value = true
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
showNarrator.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NarratorBubble
|
||||
:message="narratorMessage"
|
||||
:visible="showNarrator"
|
||||
@close="handleClose"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 3.1 : API narrateur pour les textes
|
||||
- Story 1.6 : Store Pinia (pour narratorStage)
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 3.3 : Textes contextuels (utilise ce composant)
|
||||
- Story 3.5 : Logique de progression (déclenche le narrateur)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── components/feature/
|
||||
│ └── NarratorBubble.vue # CRÉER
|
||||
├── composables/
|
||||
│ ├── useTypewriter.ts # CRÉER
|
||||
│ └── useReducedMotion.ts # CRÉER
|
||||
└── public/images/bug/
|
||||
├── bug-stage-1.svg # CRÉER (asset)
|
||||
├── bug-stage-2.svg # CRÉER (asset)
|
||||
├── bug-stage-3.svg # CRÉER (asset)
|
||||
├── bug-stage-4.svg # CRÉER (asset)
|
||||
└── bug-stage-5.svg # CRÉER (asset)
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/i18n/fr.json # AJOUTER narrator.clickToSkip
|
||||
frontend/i18n/en.json # AJOUTER narrator.clickToSkip
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-3.2]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#NarratorBubble]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Narrator-Revelation-Arc]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Mascotte-Le-Bug]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Effect typewriter | 30-50ms par lettre | Epics |
|
||||
| Stades du Bug | 5 apparences distinctes | UX Spec |
|
||||
| Position desktop | Bottom fixed | Epics |
|
||||
| Position mobile | Au-dessus bottom bar | Epics |
|
||||
| Accessibilité | aria-live + role="status" | Epics |
|
||||
| Police | font-narrative | UX Spec |
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
# Story 3.3: Textes narrateur contextuels et arc de révélation
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want que le narrateur réagisse à mes actions et évolue visuellement,
|
||||
so that l'expérience est personnalisée et le narrateur devient familier.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur navigue sur le site **When** il effectue des actions clés **Then** le narrateur affiche un message d'accueil à l'arrivée (adapté au héros choisi)
|
||||
2. **And** des messages de transition s'affichent entre les zones
|
||||
3. **And** des encouragements apparaissent à 25%, 50%, 75% de progression
|
||||
4. **And** des indices s'affichent si le visiteur semble inactif (> 30s sans action)
|
||||
5. **And** un message spécial "Bienvenue à nouveau" s'affiche si progression existante détectée
|
||||
6. **And** le message de déblocage du contact s'affiche après 2 zones visitées
|
||||
7. **Given** le visiteur progresse dans l'exploration **When** le `completionPercent` atteint certains seuils **Then** le `narratorStage` du store est mis à jour (1→5)
|
||||
8. **And** l'apparence du Bug évolue : silhouette sombre (1) → forme vague (2) → pattes visibles (3) → araignée reconnaissable (4) → mascotte complète révélée (5)
|
||||
9. **And** le ton du narrateur évolue de mystérieux à complice
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer le composable useNarrator** (AC: #1, #2, #3, #4, #5, #6)
|
||||
- [ ] Créer `frontend/app/composables/useNarrator.ts`
|
||||
- [ ] Centraliser la logique d'affichage du narrateur
|
||||
- [ ] Exposer les méthodes : showIntro, showTransition, showEncouragement, showHint, showWelcomeBack, showContactUnlocked
|
||||
- [ ] Gérer la queue de messages (ne pas interrompre un message en cours)
|
||||
- [ ] Intégrer le composable useFetchNarratorText
|
||||
|
||||
- [ ] **Task 2: Implémenter les déclencheurs de transition** (AC: #2)
|
||||
- [ ] Déclencher sur navigation vers /projets (transition_projects)
|
||||
- [ ] Déclencher sur navigation vers /competences (transition_skills)
|
||||
- [ ] Déclencher sur navigation vers /temoignages (transition_testimonials)
|
||||
- [ ] Déclencher sur navigation vers /parcours (transition_journey)
|
||||
- [ ] Utiliser un plugin Nuxt ou watcher sur la route
|
||||
|
||||
- [ ] **Task 3: Implémenter la détection d'inactivité** (AC: #4)
|
||||
- [ ] Créer `frontend/app/composables/useIdleDetection.ts`
|
||||
- [ ] Détecter l'absence d'interaction > 30 secondes
|
||||
- [ ] Écouter mouse, keyboard, touch, scroll
|
||||
- [ ] Déclencher `showHint()` quand idle détecté
|
||||
- [ ] Ne pas répéter les hints trop souvent (cooldown de 2min)
|
||||
|
||||
- [ ] **Task 4: Implémenter les encouragements basés sur la progression** (AC: #3)
|
||||
- [ ] Watcher sur `completionPercent` du store
|
||||
- [ ] Déclencher à 25%, 50%, 75%
|
||||
- [ ] Garder en mémoire les seuils déjà atteints (ne pas répéter)
|
||||
|
||||
- [ ] **Task 5: Implémenter l'arc de révélation du Bug** (AC: #7, #8, #9)
|
||||
- [ ] Définir les seuils de progression pour chaque stage :
|
||||
- Stage 1 : 0-19%
|
||||
- Stage 2 : 20-39%
|
||||
- Stage 3 : 40-59%
|
||||
- Stage 4 : 60-79%
|
||||
- Stage 5 : 80-100%
|
||||
- [ ] Mettre à jour `narratorStage` dans le store
|
||||
- [ ] L'image du Bug se met à jour automatiquement via NarratorBubble
|
||||
|
||||
- [ ] **Task 6: Implémenter le message "Bienvenue à nouveau"** (AC: #5)
|
||||
- [ ] Détecter au chargement si `visitedSections` n'est pas vide (progression existante)
|
||||
- [ ] Afficher le message `welcome_back` dans ce cas
|
||||
- [ ] Sinon afficher le message `intro` normal
|
||||
|
||||
- [ ] **Task 7: Implémenter le message de déblocage contact** (AC: #6)
|
||||
- [ ] Watcher sur `contactUnlocked` du store
|
||||
- [ ] Quand passe à `true`, afficher `contact_unlocked`
|
||||
|
||||
- [ ] **Task 8: Intégrer dans le layout principal**
|
||||
- [ ] Ajouter le NarratorBubble dans default.vue ou adventure.vue
|
||||
- [ ] Initialiser useNarrator dans le layout
|
||||
- [ ] Gérer l'état visible/hidden du narrateur
|
||||
|
||||
- [ ] **Task 9: Tests et validation**
|
||||
- [ ] Tester le message d'accueil adapté au héros
|
||||
- [ ] Tester les transitions entre pages
|
||||
- [ ] Vérifier les encouragements à 25/50/75%
|
||||
- [ ] Tester la détection d'inactivité
|
||||
- [ ] Valider l'évolution du Bug (5 stages)
|
||||
- [ ] Tester le "Bienvenue à nouveau"
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Composable useNarrator
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useNarrator.ts
|
||||
interface NarratorMessage {
|
||||
context: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
export function useNarrator() {
|
||||
const { fetchText } = useFetchNarratorText()
|
||||
const progressionStore = useProgressionStore()
|
||||
|
||||
const isVisible = ref(false)
|
||||
const currentMessage = ref('')
|
||||
const messageQueue = ref<NarratorMessage[]>([])
|
||||
const isProcessing = ref(false)
|
||||
|
||||
// Seuils d'encouragement déjà affichés
|
||||
const shownEncouragements = ref<Set<number>>(new Set())
|
||||
|
||||
// Cooldown pour les hints
|
||||
const lastHintTime = ref(0)
|
||||
const HINT_COOLDOWN = 120000 // 2 minutes
|
||||
|
||||
async function queueMessage(context: string, priority: number = 5) {
|
||||
messageQueue.value.push({ context, priority })
|
||||
messageQueue.value.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
if (!isProcessing.value) {
|
||||
processQueue()
|
||||
}
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
if (messageQueue.value.length === 0) {
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
isProcessing.value = true
|
||||
const next = messageQueue.value.shift()!
|
||||
|
||||
try {
|
||||
const response = await fetchText(next.context, progressionStore.heroType)
|
||||
currentMessage.value = response.data.text
|
||||
isVisible.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch narrator text:', error)
|
||||
processQueue() // Passer au suivant en cas d'erreur
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
isVisible.value = false
|
||||
// Attendre la fin de l'animation avant de traiter le suivant
|
||||
setTimeout(() => {
|
||||
processQueue()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// === Méthodes publiques ===
|
||||
|
||||
async function showIntro() {
|
||||
// Vérifier si le visiteur revient
|
||||
if (progressionStore.visitedSections.length > 0) {
|
||||
await queueMessage('welcome_back', 10)
|
||||
} else {
|
||||
await queueMessage('intro', 10)
|
||||
}
|
||||
}
|
||||
|
||||
async function showTransition(zone: 'projects' | 'skills' | 'testimonials' | 'journey') {
|
||||
const contextMap = {
|
||||
projects: 'transition_projects',
|
||||
skills: 'transition_skills',
|
||||
testimonials: 'transition_testimonials',
|
||||
journey: 'transition_journey',
|
||||
}
|
||||
await queueMessage(contextMap[zone], 7)
|
||||
}
|
||||
|
||||
async function showEncouragement(percent: number) {
|
||||
// Ne pas répéter les encouragements
|
||||
if (shownEncouragements.value.has(percent)) return
|
||||
|
||||
let context: string | null = null
|
||||
if (percent >= 75 && !shownEncouragements.value.has(75)) {
|
||||
context = 'encouragement_75'
|
||||
shownEncouragements.value.add(75)
|
||||
} else if (percent >= 50 && !shownEncouragements.value.has(50)) {
|
||||
context = 'encouragement_50'
|
||||
shownEncouragements.value.add(50)
|
||||
} else if (percent >= 25 && !shownEncouragements.value.has(25)) {
|
||||
context = 'encouragement_25'
|
||||
shownEncouragements.value.add(25)
|
||||
}
|
||||
|
||||
if (context) {
|
||||
await queueMessage(context, 5)
|
||||
}
|
||||
}
|
||||
|
||||
async function showHint() {
|
||||
const now = Date.now()
|
||||
if (now - lastHintTime.value < HINT_COOLDOWN) return
|
||||
|
||||
lastHintTime.value = now
|
||||
await queueMessage('hint', 3)
|
||||
}
|
||||
|
||||
async function showContactUnlocked() {
|
||||
await queueMessage('contact_unlocked', 8)
|
||||
}
|
||||
|
||||
async function showWelcomeBack() {
|
||||
await queueMessage('welcome_back', 10)
|
||||
}
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
currentMessage,
|
||||
hide,
|
||||
showIntro,
|
||||
showTransition,
|
||||
showEncouragement,
|
||||
showHint,
|
||||
showContactUnlocked,
|
||||
showWelcomeBack,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Composable useIdleDetection
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useIdleDetection.ts
|
||||
export interface UseIdleDetectionOptions {
|
||||
timeout?: number // ms avant de considérer comme idle
|
||||
onIdle?: () => void
|
||||
}
|
||||
|
||||
export function useIdleDetection(options: UseIdleDetectionOptions = {}) {
|
||||
const { timeout = 30000, onIdle } = options
|
||||
|
||||
const isIdle = ref(false)
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function resetTimer() {
|
||||
isIdle.value = false
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
timeoutId = setTimeout(() => {
|
||||
isIdle.value = true
|
||||
onIdle?.()
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
const events = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart']
|
||||
|
||||
onMounted(() => {
|
||||
events.forEach(event => {
|
||||
window.addEventListener(event, resetTimer, { passive: true })
|
||||
})
|
||||
resetTimer() // Démarrer le timer
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
events.forEach(event => {
|
||||
window.removeEventListener(event, resetTimer)
|
||||
})
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
})
|
||||
|
||||
return { isIdle }
|
||||
}
|
||||
```
|
||||
|
||||
### Logique de l'arc de révélation (dans useProgressionStore)
|
||||
|
||||
```typescript
|
||||
// Ajouter dans frontend/app/stores/progression.ts
|
||||
|
||||
// Seuils pour les stages du Bug
|
||||
const NARRATOR_STAGE_THRESHOLDS = [0, 20, 40, 60, 80] // 5 stages
|
||||
|
||||
function calculateNarratorStage(percent: number): number {
|
||||
for (let i = NARRATOR_STAGE_THRESHOLDS.length - 1; i >= 0; i--) {
|
||||
if (percent >= NARRATOR_STAGE_THRESHOLDS[i]) {
|
||||
return i + 1 // Stages 1-5
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// Dans le store
|
||||
export const useProgressionStore = defineStore('progression', () => {
|
||||
// ... autres propriétés existantes ...
|
||||
|
||||
const narratorStage = computed(() => {
|
||||
return calculateNarratorStage(completionPercent.value)
|
||||
})
|
||||
|
||||
return {
|
||||
// ... autres exports ...
|
||||
narratorStage,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Plugin de navigation pour les transitions
|
||||
|
||||
```typescript
|
||||
// frontend/app/plugins/narrator-transitions.client.ts
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const narrator = useNarrator()
|
||||
const router = useRouter()
|
||||
const progressionStore = useProgressionStore()
|
||||
|
||||
// Map des routes vers les contextes de transition
|
||||
const routeContextMap: Record<string, 'projects' | 'skills' | 'testimonials' | 'journey'> = {
|
||||
'/projets': 'projects',
|
||||
'/en/projects': 'projects',
|
||||
'/competences': 'skills',
|
||||
'/en/skills': 'skills',
|
||||
'/temoignages': 'testimonials',
|
||||
'/en/testimonials': 'testimonials',
|
||||
'/parcours': 'journey',
|
||||
'/en/journey': 'journey',
|
||||
}
|
||||
|
||||
// Sections déjà annoncées (pour ne pas répéter)
|
||||
const announcedSections = new Set<string>()
|
||||
|
||||
router.afterEach((to) => {
|
||||
const zone = routeContextMap[to.path]
|
||||
if (zone && !announcedSections.has(zone)) {
|
||||
announcedSections.add(zone)
|
||||
narrator.showTransition(zone)
|
||||
}
|
||||
})
|
||||
|
||||
// Watcher sur completionPercent pour les encouragements
|
||||
watch(
|
||||
() => progressionStore.completionPercent,
|
||||
(percent) => {
|
||||
narrator.showEncouragement(percent)
|
||||
}
|
||||
)
|
||||
|
||||
// Watcher sur contactUnlocked
|
||||
watch(
|
||||
() => progressionStore.contactUnlocked,
|
||||
(unlocked, wasUnlocked) => {
|
||||
if (unlocked && !wasUnlocked) {
|
||||
narrator.showContactUnlocked()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
### Intégration dans le layout
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/layouts/adventure.vue -->
|
||||
<script setup lang="ts">
|
||||
const narrator = useNarrator()
|
||||
|
||||
// Détection d'inactivité
|
||||
useIdleDetection({
|
||||
timeout: 30000,
|
||||
onIdle: () => {
|
||||
narrator.showHint()
|
||||
}
|
||||
})
|
||||
|
||||
// Afficher l'intro au montage
|
||||
onMounted(() => {
|
||||
// Délai pour laisser la page se charger
|
||||
setTimeout(() => {
|
||||
narrator.showIntro()
|
||||
}, 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="adventure-layout">
|
||||
<slot />
|
||||
|
||||
<!-- Narrateur -->
|
||||
<NarratorBubble
|
||||
:message="narrator.currentMessage.value"
|
||||
:visible="narrator.isVisible.value"
|
||||
@close="narrator.hide()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Tableau des stages du Bug
|
||||
|
||||
| Stage | Progression | Apparence | Ton du narrateur |
|
||||
|-------|-------------|-----------|------------------|
|
||||
| 1 | 0-19% | Silhouette sombre floue | Mystérieux, énigmatique |
|
||||
| 2 | 20-39% | Forme vague avec yeux brillants | Curieux, observateur |
|
||||
| 3 | 40-59% | Pattes visibles, forme d'araignée | Encourageant, guide |
|
||||
| 4 | 60-79% | Araignée reconnaissable | Amical, complice |
|
||||
| 5 | 80-100% | Mascotte complète révélée | Chaleureux, félicitations |
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 3.1 : API narrateur (contextes et textes)
|
||||
- Story 3.2 : Composant NarratorBubble
|
||||
- Story 1.6 : Store Pinia (pour progression et heroType)
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 3.5 : Logique de progression (déclenche les messages)
|
||||
- Story 4.2 : Intro narrative (utilise useNarrator)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── composables/
|
||||
│ ├── useNarrator.ts # CRÉER
|
||||
│ └── useIdleDetection.ts # CRÉER
|
||||
├── plugins/
|
||||
│ └── narrator-transitions.client.ts # CRÉER
|
||||
└── layouts/
|
||||
└── adventure.vue # CRÉER ou MODIFIER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/app/stores/progression.ts # AJOUTER narratorStage computed
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-3.3]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Narrator-Revelation-Arc]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Narrator-Contexts]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Arc-Revelation]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Stages du Bug | 5 (silhouette → mascotte) | UX Spec |
|
||||
| Seuils progression | 0/20/40/60/80% | Décision technique |
|
||||
| Timeout inactivité | 30 secondes | Epics |
|
||||
| Cooldown hints | 2 minutes | Décision technique |
|
||||
| Contextes transitions | 4 zones principales | 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
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
# Story 3.4: Barre de progression globale (XP bar)
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want voir ma progression dans l'exploration du site,
|
||||
so that je sais combien il me reste à découvrir.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur est en mode Aventure **When** il navigue sur le site **Then** une barre de progression discrète s'affiche dans le header
|
||||
2. **And** le pourcentage est calculé selon les sections visitées (Projets, Compétences, Témoignages, Parcours)
|
||||
3. **And** l'animation de la barre est fluide lors des mises à jour
|
||||
4. **And** un tooltip au hover indique les sections visitées et restantes
|
||||
5. **And** le design évoque une barre XP style RPG (cohérent avec `sky-accent`)
|
||||
6. **And** la barre respecte `prefers-reduced-motion` (pas d'animation si activé)
|
||||
7. **And** sur mobile, la progression est accessible via la bottom bar
|
||||
8. **And** la barre n'est pas visible en mode Express/Résumé
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer le composant ProgressBar** (AC: #1, #3, #5, #6)
|
||||
- [ ] Créer `frontend/app/components/feature/ProgressBar.vue`
|
||||
- [ ] Props : percent (number), showTooltip (boolean)
|
||||
- [ ] Design XP bar style RPG avec sky-accent
|
||||
- [ ] Animation fluide de remplissage (CSS transition)
|
||||
- [ ] Respecter prefers-reduced-motion
|
||||
|
||||
- [ ] **Task 2: Implémenter le tooltip des sections** (AC: #4)
|
||||
- [ ] Afficher au hover la liste des sections
|
||||
- [ ] Indiquer le statut : visitée (✓) ou à découvrir
|
||||
- [ ] Utiliser Headless UI Popover ou tooltip custom
|
||||
- [ ] Traductions FR/EN
|
||||
|
||||
- [ ] **Task 3: Intégrer dans le header** (AC: #1, #8)
|
||||
- [ ] Ajouter la ProgressBar dans le composant Header
|
||||
- [ ] Conditionner l'affichage : visible uniquement en mode Aventure
|
||||
- [ ] Masquer si `expressMode === true` dans le store
|
||||
- [ ] Position : à droite du header, avant le language switcher
|
||||
|
||||
- [ ] **Task 4: Calculer le pourcentage** (AC: #2)
|
||||
- [ ] Définir les 4 sections : projets, competences, temoignages, parcours
|
||||
- [ ] Chaque section visitée = 25%
|
||||
- [ ] Lire depuis `visitedSections` du store
|
||||
- [ ] Le calcul est fait dans le store (completionPercent)
|
||||
|
||||
- [ ] **Task 5: Version mobile** (AC: #7)
|
||||
- [ ] Sur mobile, la barre est masquée du header
|
||||
- [ ] La progression est accessible via l'icône dans la bottom bar
|
||||
- [ ] Un tap affiche un mini-modal ou drawer avec le détail
|
||||
|
||||
- [ ] **Task 6: Effets visuels RPG** (AC: #5)
|
||||
- [ ] Effet de brillance/glow au survol
|
||||
- [ ] Particules optionnelles quand la barre augmente
|
||||
- [ ] Bordure stylisée évoquant un cadre de jeu
|
||||
- [ ] Graduation subtile sur la barre
|
||||
|
||||
- [ ] **Task 7: Tests et validation**
|
||||
- [ ] Tester l'animation de remplissage
|
||||
- [ ] Vérifier le tooltip (desktop)
|
||||
- [ ] Valider la version mobile (bottom bar)
|
||||
- [ ] Tester prefers-reduced-motion
|
||||
- [ ] Vérifier que la barre est masquée en mode Express
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Composant ProgressBar
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/ProgressBar.vue -->
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
percent: number
|
||||
showTooltip?: boolean
|
||||
compact?: boolean // Pour la version mobile
|
||||
}>(), {
|
||||
showTooltip: true,
|
||||
compact: false,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const progressionStore = useProgressionStore()
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
// Sections avec leur statut
|
||||
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'),
|
||||
},
|
||||
])
|
||||
|
||||
const visitedCount = computed(() => sections.value.filter(s => s.visited).length)
|
||||
const remainingCount = computed(() => 4 - visitedCount.value)
|
||||
|
||||
// État du tooltip
|
||||
const showPopover = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="progress-bar-container relative"
|
||||
:class="{ 'compact': compact }"
|
||||
>
|
||||
<!-- Barre principale -->
|
||||
<div
|
||||
class="progress-bar-wrapper group cursor-pointer"
|
||||
@mouseenter="showPopover = true"
|
||||
@mouseleave="showPopover = false"
|
||||
@focus="showPopover = true"
|
||||
@blur="showPopover = false"
|
||||
tabindex="0"
|
||||
role="progressbar"
|
||||
:aria-valuenow="percent"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-label="t('progress.label', { percent })"
|
||||
>
|
||||
<!-- Cadre RPG -->
|
||||
<div class="progress-frame relative h-6 w-40 rounded-full border-2 border-sky-accent/50 bg-sky-dark overflow-hidden">
|
||||
<!-- Fond avec graduation -->
|
||||
<div class="absolute inset-0 flex">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="flex-1 border-r border-sky-dark-100/30 last:border-r-0"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de remplissage -->
|
||||
<div
|
||||
class="progress-fill absolute inset-y-0 left-0 bg-gradient-to-r from-sky-accent to-sky-accent-light"
|
||||
:class="{ 'transition-all duration-500 ease-out': !reducedMotion }"
|
||||
:style="{ width: `${percent}%` }"
|
||||
>
|
||||
<!-- Effet de brillance -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-white/20 to-transparent"></div>
|
||||
|
||||
<!-- Effet glow au survol -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:class="{ 'transition-none': reducedMotion }"
|
||||
style="box-shadow: 0 0 10px var(--sky-accent), 0 0 20px var(--sky-accent)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Pourcentage -->
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-xs font-ui font-bold text-sky-text drop-shadow-md">
|
||||
{{ percent }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showTooltip && showPopover"
|
||||
class="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-50"
|
||||
>
|
||||
<div class="bg-sky-dark-50 border border-sky-dark-100 rounded-lg shadow-xl p-3 min-w-48">
|
||||
<!-- Titre -->
|
||||
<p class="text-sm font-ui font-semibold text-sky-text mb-2">
|
||||
{{ t('progress.title') }}
|
||||
</p>
|
||||
|
||||
<!-- Liste des sections -->
|
||||
<ul class="space-y-1">
|
||||
<li
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span
|
||||
v-if="section.visited"
|
||||
class="text-green-400"
|
||||
>✓</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-sky-text-muted"
|
||||
>○</span>
|
||||
<span
|
||||
:class="section.visited ? 'text-sky-text' : 'text-sky-text-muted'"
|
||||
>
|
||||
{{ section.name }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Résumé -->
|
||||
<p class="text-xs text-sky-text-muted mt-2 pt-2 border-t border-sky-dark-100">
|
||||
{{ t('progress.summary', { visited: visitedCount, remaining: remainingCount }) }}
|
||||
</p>
|
||||
|
||||
<!-- Flèche du tooltip -->
|
||||
<div class="absolute -top-2 left-1/2 -translate-x-1/2 w-4 h-4 bg-sky-dark-50 border-l border-t border-sky-dark-100 transform rotate-45"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress-bar-wrapper:focus {
|
||||
outline: 2px solid var(--sky-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Version compacte pour mobile */
|
||||
.compact .progress-frame {
|
||||
height: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compact .progress-frame span {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.progress-fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Clés i18n
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"progress": {
|
||||
"label": "Progression : {percent}%",
|
||||
"title": "Exploration du portfolio",
|
||||
"sections": {
|
||||
"projects": "Projets",
|
||||
"skills": "Compétences",
|
||||
"testimonials": "Témoignages",
|
||||
"journey": "Parcours"
|
||||
},
|
||||
"summary": "{visited} visité(s), {remaining} à découvrir"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"progress": {
|
||||
"label": "Progress: {percent}%",
|
||||
"title": "Portfolio exploration",
|
||||
"sections": {
|
||||
"projects": "Projects",
|
||||
"skills": "Skills",
|
||||
"testimonials": "Testimonials",
|
||||
"journey": "Journey"
|
||||
},
|
||||
"summary": "{visited} visited, {remaining} to discover"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Intégration dans le Header
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/layout/AppHeader.vue (extrait) -->
|
||||
<script setup>
|
||||
const progressionStore = useProgressionStore()
|
||||
|
||||
// Afficher la barre uniquement en mode Aventure (pas en Express)
|
||||
const showProgressBar = computed(() => {
|
||||
return !progressionStore.expressMode
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<!-- ... autres éléments ... -->
|
||||
|
||||
<!-- Progress Bar (desktop only, mode Aventure) -->
|
||||
<ProgressBar
|
||||
v-if="showProgressBar"
|
||||
:percent="progressionStore.completionPercent"
|
||||
class="hidden md:block"
|
||||
/>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
<!-- ... -->
|
||||
</header>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Composant ProgressIcon pour mobile (Bottom Bar)
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/ProgressIcon.vue -->
|
||||
<script setup lang="ts">
|
||||
const progressionStore = useProgressionStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const showDrawer = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="progress-icon relative p-3"
|
||||
:aria-label="t('progress.label', { percent: progressionStore.completionPercent })"
|
||||
@click="showDrawer = true"
|
||||
>
|
||||
<!-- Icône avec indicateur circulaire -->
|
||||
<div class="relative w-8 h-8">
|
||||
<!-- Cercle de progression -->
|
||||
<svg class="w-full h-full -rotate-90" viewBox="0 0 36 36">
|
||||
<!-- Fond -->
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
class="text-sky-dark-100"
|
||||
/>
|
||||
<!-- Progression -->
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
class="text-sky-accent"
|
||||
:stroke-dasharray="`${progressionStore.completionPercent}, 100`"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Pourcentage au centre -->
|
||||
<span class="absolute inset-0 flex items-center justify-center text-xs font-ui font-bold text-sky-text">
|
||||
{{ progressionStore.completionPercent }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Drawer/Modal avec détail -->
|
||||
<Teleport to="body">
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="showDrawer"
|
||||
class="fixed inset-x-0 bottom-0 z-50 bg-sky-dark-50 rounded-t-2xl shadow-xl p-4 pb-safe"
|
||||
style="bottom: var(--bottom-bar-height, 64px)"
|
||||
>
|
||||
<!-- Handle -->
|
||||
<div class="w-12 h-1 bg-sky-dark-100 rounded-full mx-auto mb-4"></div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<h3 class="text-lg font-ui font-bold text-sky-text mb-4">
|
||||
{{ t('progress.title') }}
|
||||
</h3>
|
||||
|
||||
<!-- Barre compacte -->
|
||||
<ProgressBar
|
||||
:percent="progressionStore.completionPercent"
|
||||
:show-tooltip="false"
|
||||
compact
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- Liste des sections -->
|
||||
<!-- ... même logique que le tooltip desktop ... -->
|
||||
|
||||
<!-- Bouton fermer -->
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 w-full py-2 bg-sky-dark-100 rounded-lg text-sky-text font-ui"
|
||||
@click="showDrawer = false"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Overlay -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showDrawer"
|
||||
class="fixed inset-0 bg-black/50 z-40"
|
||||
@click="showDrawer = false"
|
||||
></div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Calcul du pourcentage dans le store
|
||||
|
||||
```typescript
|
||||
// frontend/app/stores/progression.ts (extrait)
|
||||
|
||||
// Sections disponibles pour la progression
|
||||
const AVAILABLE_SECTIONS = ['projets', 'competences', 'temoignages', 'parcours'] as const
|
||||
type Section = typeof AVAILABLE_SECTIONS[number]
|
||||
|
||||
export const useProgressionStore = defineStore('progression', () => {
|
||||
const visitedSections = ref<Section[]>([])
|
||||
|
||||
const completionPercent = computed(() => {
|
||||
const visitedCount = visitedSections.value.length
|
||||
return Math.round((visitedCount / AVAILABLE_SECTIONS.length) * 100)
|
||||
})
|
||||
|
||||
function visitSection(section: Section) {
|
||||
if (!visitedSections.value.includes(section)) {
|
||||
visitedSections.value.push(section)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
visitedSections,
|
||||
completionPercent,
|
||||
visitSection,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 1.6 : Store Pinia (visitedSections, completionPercent, expressMode)
|
||||
- Story 3.2 : useReducedMotion composable
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 3.5 : Logique de progression (complète le store)
|
||||
- Story 3.7 : Navigation mobile (utilise ProgressIcon)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/components/feature/
|
||||
├── ProgressBar.vue # CRÉER
|
||||
└── ProgressIcon.vue # CRÉER (version mobile)
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/app/components/layout/AppHeader.vue # AJOUTER ProgressBar
|
||||
frontend/i18n/fr.json # AJOUTER progress.*
|
||||
frontend/i18n/en.json # AJOUTER progress.*
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-3.4]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#XP-Bar]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Design-Tokens]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Sections | 4 (25% chacune) | Epics |
|
||||
| Couleur | sky-accent (#fa784f) | UX Spec |
|
||||
| Animation | CSS transition 500ms | Décision technique |
|
||||
| Position desktop | Header, à droite | Epics |
|
||||
| Position mobile | Bottom bar (icône) | 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
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
# Story 3.5: Logique de progression et déblocage contact
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want que ma progression débloque l'accès au contact,
|
||||
so that l'exploration est récompensée sans être frustrante.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le store `useProgressionStore` est actif **When** le visiteur visite une nouvelle zone **Then** la zone est ajoutée à `visitedSections`
|
||||
2. **And** le `completionPercent` est recalculé automatiquement
|
||||
3. **And** la progression est persistée en LocalStorage (si consentement RGPD donné)
|
||||
4. **Given** le visiteur a visité 2 zones ou plus **When** la condition est atteinte **Then** `contactUnlocked` passe à `true`
|
||||
5. **And** le narrateur annonce le déblocage avec un message spécial
|
||||
6. **And** la zone Contact s'illumine sur la carte (si visible)
|
||||
7. **And** le visiteur peut continuer à explorer ou aller au contact
|
||||
8. **Given** le visiteur revient sur le site **When** une progression existe en LocalStorage **Then** le store est réhydraté avec l'état sauvegardé
|
||||
9. **And** le narrateur affiche "Bienvenue à nouveau"
|
||||
10. **And** la carte affiche l'état correct des zones visitées
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Compléter le store useProgressionStore** (AC: #1, #2, #4)
|
||||
- [ ] État : visitedSections, completionPercent, contactUnlocked, heroType, expressMode, narratorStage, choices
|
||||
- [ ] Action : visitSection(section) pour enregistrer une visite
|
||||
- [ ] Getter : contactUnlocked = visitedSections.length >= 2
|
||||
- [ ] Getter : narratorStage basé sur completionPercent
|
||||
|
||||
- [ ] **Task 2: Implémenter la persistance LocalStorage** (AC: #3, #8)
|
||||
- [ ] Créer `frontend/app/composables/useProgressionPersistence.ts`
|
||||
- [ ] Vérifier le consentement RGPD avant de persister
|
||||
- [ ] Clé LocalStorage : `skycel_progression`
|
||||
- [ ] Sérialiser : visitedSections, heroType, choices
|
||||
- [ ] Réhydrater au chargement
|
||||
|
||||
- [ ] **Task 3: Détecter les visites de sections** (AC: #1)
|
||||
- [ ] Créer un plugin ou middleware qui détecte la route actuelle
|
||||
- [ ] Mapper les routes aux sections : /projets → projets, etc.
|
||||
- [ ] Appeler `visitSection()` automatiquement
|
||||
|
||||
- [ ] **Task 4: Implémenter le déblocage du contact** (AC: #4, #5, #6, #7)
|
||||
- [ ] Le contact est débloqué après 2 sections visitées
|
||||
- [ ] Émettre un événement ou watcher pour déclencher le narrateur
|
||||
- [ ] Permettre l'accès au contact même si bloqué (UX non frustrante)
|
||||
- [ ] Marquer visuellement sur la carte
|
||||
|
||||
- [ ] **Task 5: Réhydratation au retour** (AC: #8, #9, #10)
|
||||
- [ ] Au montage de l'app, vérifier LocalStorage
|
||||
- [ ] Si progression existante, réhydrater le store
|
||||
- [ ] Déclencher le message "Bienvenue à nouveau" via useNarrator
|
||||
- [ ] La carte reflète l'état correct
|
||||
|
||||
- [ ] **Task 6: Gestion du consentement RGPD** (AC: #3)
|
||||
- [ ] Lire l'état du consentement depuis le store ou cookie
|
||||
- [ ] Si pas de consentement, ne pas persister
|
||||
- [ ] Si consentement retiré, supprimer les données
|
||||
|
||||
- [ ] **Task 7: Tests et validation**
|
||||
- [ ] Tester l'ajout de sections visitées
|
||||
- [ ] Vérifier le calcul automatique du pourcentage
|
||||
- [ ] Tester le déblocage à 2 sections
|
||||
- [ ] Valider la persistance LocalStorage
|
||||
- [ ] Tester la réhydratation au rechargement
|
||||
- [ ] Vérifier le comportement sans consentement RGPD
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Store useProgressionStore complet
|
||||
|
||||
```typescript
|
||||
// frontend/app/stores/progression.ts
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
// Types
|
||||
export type Section = 'projets' | 'competences' | 'temoignages' | 'parcours'
|
||||
export type HeroType = 'recruteur' | 'client' | 'dev' | null
|
||||
export type Choice = { id: string; value: string; timestamp: number }
|
||||
|
||||
// Constantes
|
||||
const AVAILABLE_SECTIONS: Section[] = ['projets', 'competences', 'temoignages', 'parcours']
|
||||
const CONTACT_UNLOCK_THRESHOLD = 2
|
||||
const NARRATOR_STAGE_THRESHOLDS = [0, 20, 40, 60, 80] // 5 stages
|
||||
|
||||
export const useProgressionStore = defineStore('progression', () => {
|
||||
// === État ===
|
||||
const visitedSections = ref<Section[]>([])
|
||||
const heroType = ref<HeroType>(null)
|
||||
const expressMode = ref(false)
|
||||
const choices = ref<Choice[]>([])
|
||||
const hasReturned = ref(false) // Pour savoir si c'est un retour
|
||||
|
||||
// === Getters ===
|
||||
const completionPercent = computed(() => {
|
||||
return Math.round((visitedSections.value.length / AVAILABLE_SECTIONS.length) * 100)
|
||||
})
|
||||
|
||||
const contactUnlocked = computed(() => {
|
||||
return visitedSections.value.length >= CONTACT_UNLOCK_THRESHOLD
|
||||
})
|
||||
|
||||
const narratorStage = computed(() => {
|
||||
const percent = completionPercent.value
|
||||
for (let i = NARRATOR_STAGE_THRESHOLDS.length - 1; i >= 0; i--) {
|
||||
if (percent >= NARRATOR_STAGE_THRESHOLDS[i]) {
|
||||
return i + 1 // Stages 1-5
|
||||
}
|
||||
}
|
||||
return 1
|
||||
})
|
||||
|
||||
const remainingSections = computed(() => {
|
||||
return AVAILABLE_SECTIONS.filter(s => !visitedSections.value.includes(s))
|
||||
})
|
||||
|
||||
// === Actions ===
|
||||
function visitSection(section: Section) {
|
||||
if (!visitedSections.value.includes(section)) {
|
||||
visitedSections.value.push(section)
|
||||
}
|
||||
}
|
||||
|
||||
function setHeroType(type: HeroType) {
|
||||
heroType.value = type
|
||||
}
|
||||
|
||||
function setExpressMode(enabled: boolean) {
|
||||
expressMode.value = enabled
|
||||
}
|
||||
|
||||
function addChoice(id: string, value: string) {
|
||||
choices.value.push({
|
||||
id,
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
function markAsReturned() {
|
||||
hasReturned.value = true
|
||||
}
|
||||
|
||||
function reset() {
|
||||
visitedSections.value = []
|
||||
heroType.value = null
|
||||
expressMode.value = false
|
||||
choices.value = []
|
||||
hasReturned.value = false
|
||||
}
|
||||
|
||||
// === Sérialisation pour persistance ===
|
||||
function getSerializableState() {
|
||||
return {
|
||||
visitedSections: visitedSections.value,
|
||||
heroType: heroType.value,
|
||||
choices: choices.value,
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateFromState(state: ReturnType<typeof getSerializableState>) {
|
||||
if (state.visitedSections?.length) {
|
||||
visitedSections.value = state.visitedSections
|
||||
markAsReturned()
|
||||
}
|
||||
if (state.heroType) {
|
||||
heroType.value = state.heroType
|
||||
}
|
||||
if (state.choices?.length) {
|
||||
choices.value = state.choices
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// État
|
||||
visitedSections,
|
||||
heroType,
|
||||
expressMode,
|
||||
choices,
|
||||
hasReturned,
|
||||
// Getters
|
||||
completionPercent,
|
||||
contactUnlocked,
|
||||
narratorStage,
|
||||
remainingSections,
|
||||
// Actions
|
||||
visitSection,
|
||||
setHeroType,
|
||||
setExpressMode,
|
||||
addChoice,
|
||||
markAsReturned,
|
||||
reset,
|
||||
// Sérialisation
|
||||
getSerializableState,
|
||||
hydrateFromState,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Composable useProgressionPersistence
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useProgressionPersistence.ts
|
||||
const STORAGE_KEY = 'skycel_progression'
|
||||
|
||||
export function useProgressionPersistence() {
|
||||
const progressionStore = useProgressionStore()
|
||||
const consentStore = useConsentStore() // Supposé existant depuis Story 1.6
|
||||
|
||||
// Sauvegarder dans LocalStorage
|
||||
function persist() {
|
||||
// Vérifier le consentement RGPD
|
||||
if (!consentStore.hasConsent) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const state = progressionStore.getSerializableState()
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist progression:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Charger depuis LocalStorage
|
||||
function hydrate() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const state = JSON.parse(stored)
|
||||
progressionStore.hydrateFromState(state)
|
||||
return true // Retourne true si des données ont été trouvées
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to hydrate progression:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Supprimer les données
|
||||
function clear() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear progression:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Watcher pour persister automatiquement
|
||||
watch(
|
||||
() => progressionStore.getSerializableState(),
|
||||
() => {
|
||||
persist()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Watcher sur le consentement pour supprimer si retiré
|
||||
watch(
|
||||
() => consentStore.hasConsent,
|
||||
(hasConsent) => {
|
||||
if (!hasConsent) {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
persist,
|
||||
hydrate,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin de détection des visites
|
||||
|
||||
```typescript
|
||||
// frontend/app/plugins/progression-tracker.client.ts
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const progressionStore = useProgressionStore()
|
||||
const router = useRouter()
|
||||
|
||||
// Map des routes vers les sections
|
||||
const routeSectionMap: Record<string, Section> = {
|
||||
'/projets': 'projets',
|
||||
'/en/projects': 'projets',
|
||||
'/competences': 'competences',
|
||||
'/en/skills': 'competences',
|
||||
'/temoignages': 'temoignages',
|
||||
'/en/testimonials': 'temoignages',
|
||||
'/parcours': 'parcours',
|
||||
'/en/journey': 'parcours',
|
||||
}
|
||||
|
||||
// Détecter les changements de route
|
||||
router.afterEach((to) => {
|
||||
const section = routeSectionMap[to.path]
|
||||
if (section) {
|
||||
progressionStore.visitSection(section)
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Plugin d'initialisation de la progression
|
||||
|
||||
```typescript
|
||||
// frontend/app/plugins/progression-init.client.ts
|
||||
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
const { hydrate } = useProgressionPersistence()
|
||||
const progressionStore = useProgressionStore()
|
||||
const narrator = useNarrator()
|
||||
|
||||
// Attendre que l'app soit montée
|
||||
nuxtApp.hook('app:mounted', () => {
|
||||
// Réhydrater la progression
|
||||
const hasExistingProgress = hydrate()
|
||||
|
||||
// Si le visiteur revient avec une progression existante
|
||||
if (hasExistingProgress && progressionStore.hasReturned) {
|
||||
// Le message "Bienvenue à nouveau" sera déclenché via useNarrator
|
||||
// dans le layout adventure.vue
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Intégration avec le narrateur
|
||||
|
||||
```typescript
|
||||
// Dans frontend/app/layouts/adventure.vue ou composable useNarrator
|
||||
// Déclencher le message de déblocage du contact
|
||||
|
||||
// Watcher sur contactUnlocked
|
||||
const unwatchContact = watch(
|
||||
() => progressionStore.contactUnlocked,
|
||||
(isUnlocked, wasUnlocked) => {
|
||||
if (isUnlocked && !wasUnlocked) {
|
||||
narrator.showContactUnlocked()
|
||||
// Notification visuelle optionnelle
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Au montage, vérifier si c'est un retour
|
||||
onMounted(async () => {
|
||||
if (progressionStore.hasReturned) {
|
||||
await narrator.showWelcomeBack()
|
||||
} else if (progressionStore.heroType) {
|
||||
await narrator.showIntro()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Interface du consentement (référence)
|
||||
|
||||
```typescript
|
||||
// frontend/app/stores/consent.ts (supposé existant depuis Story 1.6)
|
||||
export const useConsentStore = defineStore('consent', () => {
|
||||
const hasConsent = ref(false)
|
||||
const consentDate = ref<number | null>(null)
|
||||
|
||||
function giveConsent() {
|
||||
hasConsent.value = true
|
||||
consentDate.value = Date.now()
|
||||
}
|
||||
|
||||
function revokeConsent() {
|
||||
hasConsent.value = false
|
||||
consentDate.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
hasConsent,
|
||||
consentDate,
|
||||
giveConsent,
|
||||
revokeConsent,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Schéma du flux de progression
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FLUX DE PROGRESSION │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. PREMIÈRE VISITE │
|
||||
│ └─> Landing page │
|
||||
│ └─> Choix du héros (recruteur/client/dev) │
|
||||
│ └─> heroType = 'recruteur' | 'client' | 'dev' │
|
||||
│ │
|
||||
│ 2. NAVIGATION │
|
||||
│ └─> Visite /projets │
|
||||
│ └─> visitSection('projets') │
|
||||
│ └─> visitedSections = ['projets'] │
|
||||
│ └─> completionPercent = 25% │
|
||||
│ └─> narratorStage = 2 (>= 20%) │
|
||||
│ │
|
||||
│ 3. DÉBLOCAGE CONTACT │
|
||||
│ └─> Visite /competences (2ème section) │
|
||||
│ └─> visitedSections = ['projets', 'competences'] │
|
||||
│ └─> completionPercent = 50% │
|
||||
│ └─> contactUnlocked = true (>= 2 sections) │
|
||||
│ └─> Trigger: narrator.showContactUnlocked() │
|
||||
│ │
|
||||
│ 4. PERSISTANCE │
|
||||
│ └─> Si consentement RGPD │
|
||||
│ └─> localStorage.setItem('skycel_progression', {...}) │
|
||||
│ │
|
||||
│ 5. RETOUR DU VISITEUR │
|
||||
│ └─> Au chargement │
|
||||
│ └─> Lire localStorage │
|
||||
│ └─> hydrateFromState() │
|
||||
│ └─> hasReturned = true │
|
||||
│ └─> Trigger: narrator.showWelcomeBack() │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 1.6 : Store Pinia de base + consentement RGPD
|
||||
- Story 3.3 : useNarrator pour les messages de déblocage et retour
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 3.6 : Carte interactive (affiche l'état des zones)
|
||||
- Story 3.7 : Navigation mobile (même logique)
|
||||
- Story 4.3 : Chemins narratifs (utilise choices)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── stores/
|
||||
│ └── progression.ts # CRÉER (complet)
|
||||
├── composables/
|
||||
│ └── useProgressionPersistence.ts # CRÉER
|
||||
└── plugins/
|
||||
├── progression-tracker.client.ts # CRÉER
|
||||
└── progression-init.client.ts # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/app/layouts/adventure.vue # INTÉGRER la logique de retour
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-3.5]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Progression-System]
|
||||
- [Source: docs/planning-artifacts/architecture.md#State-Management]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Sections pour progression | 4 (projets, competences, temoignages, parcours) | Epics |
|
||||
| Seuil déblocage contact | 2 sections visitées | Epics |
|
||||
| Clé LocalStorage | skycel_progression | Décision technique |
|
||||
| Condition persistance | Consentement RGPD | RGPD |
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
# Story 3.6: Carte interactive desktop (Konva.js)
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur desktop,
|
||||
I want naviguer via une carte interactive visuelle,
|
||||
so that j'explore librement le portfolio comme un monde.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur est sur desktop (>= 1024px) et accède à la carte **When** la carte se charge **Then** un canvas Konva.js affiche une carte stylisée avec les zones (Projets, Compétences, Parcours, Témoignages, Contact)
|
||||
2. **And** le composant est chargé en lazy-loading (`.client.vue`) pour respecter le budget JS
|
||||
3. **And** chaque zone a une apparence distincte (teinte unique, icône)
|
||||
4. **And** les zones visitées ont une apparence différente des zones non visitées
|
||||
5. **And** la zone Contact est verrouillée visuellement si `contactUnlocked` est `false`
|
||||
6. **And** la position actuelle du visiteur est marquée sur la carte
|
||||
7. **And** au hover sur une zone : le nom et le statut s'affichent (tooltip)
|
||||
8. **And** au clic sur une zone : navigation vers la section correspondante avec transition
|
||||
9. **And** un curseur personnalisé indique les zones cliquables
|
||||
10. **And** la navigation au clavier est fonctionnelle (Tab entre zones, Enter pour naviguer)
|
||||
11. **And** les zones ont des labels ARIA descriptifs
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Installer et configurer Konva.js** (AC: #2)
|
||||
- [ ] Installer `konva` et `vue-konva`
|
||||
- [ ] Configurer pour Nuxt (SSR-safe)
|
||||
- [ ] Créer le wrapper `.client.vue` pour lazy-loading
|
||||
|
||||
- [ ] **Task 2: Définir la structure des zones** (AC: #1, #3)
|
||||
- [ ] Créer les données des 5 zones : projets, competences, parcours, temoignages, contact
|
||||
- [ ] Pour chaque zone : position (x, y), couleur, icône, label, route
|
||||
- [ ] Design en forme d'île/territoire stylisé
|
||||
|
||||
- [ ] **Task 3: Créer le composant InteractiveMap** (AC: #1, #2)
|
||||
- [ ] Créer `frontend/app/components/feature/InteractiveMap.client.vue`
|
||||
- [ ] Initialiser le Stage et Layer Konva
|
||||
- [ ] Dessiner le fond de carte (texture, grille, etc.)
|
||||
- [ ] Placer les zones selon les positions définies
|
||||
|
||||
- [ ] **Task 4: Implémenter les états visuels des zones** (AC: #3, #4, #5)
|
||||
- [ ] Zone non visitée : couleur atténuée, opacité réduite
|
||||
- [ ] Zone visitée : couleur vive, checkmark ou brillance
|
||||
- [ ] Zone Contact verrouillée : effet grisé + icône cadenas
|
||||
- [ ] Zone Contact débloquée : brillance, invitation visuelle
|
||||
|
||||
- [ ] **Task 5: Implémenter le marqueur de position** (AC: #6)
|
||||
- [ ] Créer un marqueur animé (pulsation)
|
||||
- [ ] Positionner sur la zone actuelle (basé sur la route)
|
||||
- [ ] Animer le déplacement entre zones
|
||||
|
||||
- [ ] **Task 6: Implémenter les interactions hover** (AC: #7, #9)
|
||||
- [ ] Détecter le hover sur chaque zone
|
||||
- [ ] Afficher un tooltip avec nom + statut
|
||||
- [ ] Changer le curseur en pointer
|
||||
- [ ] Effet de surbrillance sur la zone
|
||||
|
||||
- [ ] **Task 7: Implémenter les interactions clic** (AC: #8)
|
||||
- [ ] Détecter le clic sur une zone
|
||||
- [ ] Si zone accessible : naviguer avec router.push()
|
||||
- [ ] Si zone Contact verrouillée : afficher message ou shake
|
||||
- [ ] Animation de transition (zoom ou fade)
|
||||
|
||||
- [ ] **Task 8: Implémenter l'accessibilité** (AC: #10, #11)
|
||||
- [ ] Rendre les zones focusables (tabindex)
|
||||
- [ ] Gérer Tab pour naviguer entre zones
|
||||
- [ ] Gérer Enter/Space pour cliquer
|
||||
- [ ] Ajouter aria-label descriptif à chaque zone
|
||||
- [ ] Ajouter role="button" aux zones cliquables
|
||||
|
||||
- [ ] **Task 9: Responsive et performance**
|
||||
- [ ] Masquer la carte sous 1024px (afficher alternative mobile)
|
||||
- [ ] Optimiser les redessins (cache les images)
|
||||
- [ ] Lazy-load les images des zones
|
||||
|
||||
- [ ] **Task 10: Tests et validation**
|
||||
- [ ] Tester le chargement lazy
|
||||
- [ ] Vérifier les 5 zones distinctes
|
||||
- [ ] Tester les états (visité/non visité/verrouillé)
|
||||
- [ ] Valider hover et clic
|
||||
- [ ] Tester navigation clavier
|
||||
- [ ] Vérifier accessibilité (screen reader)
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Installation de Konva
|
||||
|
||||
```bash
|
||||
# Dans le dossier frontend
|
||||
pnpm add konva vue-konva
|
||||
```
|
||||
|
||||
### Nuxt Config (Konva SSR-safe)
|
||||
|
||||
```typescript
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
// ...
|
||||
build: {
|
||||
transpile: ['konva', 'vue-konva'],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Définition des zones
|
||||
|
||||
```typescript
|
||||
// frontend/app/data/mapZones.ts
|
||||
import type { Section } from '~/stores/progression'
|
||||
|
||||
export interface MapZone {
|
||||
id: Section | 'contact'
|
||||
label: {
|
||||
fr: string
|
||||
en: string
|
||||
}
|
||||
route: {
|
||||
fr: string
|
||||
en: string
|
||||
}
|
||||
position: { x: number; y: number }
|
||||
color: string
|
||||
icon: string // URL ou emoji
|
||||
size: number // rayon ou taille
|
||||
}
|
||||
|
||||
export const mapZones: MapZone[] = [
|
||||
{
|
||||
id: 'projets',
|
||||
label: { fr: 'Projets', en: 'Projects' },
|
||||
route: { fr: '/projets', en: '/en/projects' },
|
||||
position: { x: 200, y: 150 },
|
||||
color: '#3b82f6', // blue-500
|
||||
icon: '/images/map/icon-projects.svg',
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'competences',
|
||||
label: { fr: 'Compétences', en: 'Skills' },
|
||||
route: { fr: '/competences', en: '/en/skills' },
|
||||
position: { x: 450, y: 120 },
|
||||
color: '#10b981', // emerald-500
|
||||
icon: '/images/map/icon-skills.svg',
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'temoignages',
|
||||
label: { fr: 'Témoignages', en: 'Testimonials' },
|
||||
route: { fr: '/temoignages', en: '/en/testimonials' },
|
||||
position: { x: 350, y: 280 },
|
||||
color: '#f59e0b', // amber-500
|
||||
icon: '/images/map/icon-testimonials.svg',
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'parcours',
|
||||
label: { fr: 'Parcours', en: 'Journey' },
|
||||
route: { fr: '/parcours', en: '/en/journey' },
|
||||
position: { x: 550, y: 300 },
|
||||
color: '#8b5cf6', // violet-500
|
||||
icon: '/images/map/icon-journey.svg',
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
label: { fr: 'Contact', en: 'Contact' },
|
||||
route: { fr: '/contact', en: '/en/contact' },
|
||||
position: { x: 650, y: 180 },
|
||||
color: '#fa784f', // sky-accent
|
||||
icon: '/images/map/icon-contact.svg',
|
||||
size: 80,
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### Composant InteractiveMap
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/InteractiveMap.client.vue -->
|
||||
<script setup lang="ts">
|
||||
import Konva from 'konva'
|
||||
import { mapZones, type MapZone } from '~/data/mapZones'
|
||||
|
||||
const props = defineProps<{
|
||||
currentSection?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [zone: MapZone]
|
||||
}>()
|
||||
|
||||
const { locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const progressionStore = useProgressionStore()
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const stageRef = ref<Konva.Stage | null>(null)
|
||||
|
||||
// Dimensions du canvas
|
||||
const CANVAS_WIDTH = 800
|
||||
const CANVAS_HEIGHT = 500
|
||||
|
||||
// État du hover
|
||||
const hoveredZone = ref<MapZone | null>(null)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
// Zone focusée (pour clavier)
|
||||
const focusedZoneIndex = ref(-1)
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas()
|
||||
})
|
||||
|
||||
function initCanvas() {
|
||||
if (!containerRef.value) return
|
||||
|
||||
const stage = new Konva.Stage({
|
||||
container: containerRef.value,
|
||||
width: CANVAS_WIDTH,
|
||||
height: CANVAS_HEIGHT,
|
||||
})
|
||||
|
||||
stageRef.value = stage
|
||||
|
||||
// Layer de fond
|
||||
const backgroundLayer = new Konva.Layer()
|
||||
drawBackground(backgroundLayer)
|
||||
stage.add(backgroundLayer)
|
||||
|
||||
// Layer des zones
|
||||
const zonesLayer = new Konva.Layer()
|
||||
drawZones(zonesLayer)
|
||||
stage.add(zonesLayer)
|
||||
|
||||
// Layer du marqueur de position
|
||||
const markerLayer = new Konva.Layer()
|
||||
drawPositionMarker(markerLayer)
|
||||
stage.add(markerLayer)
|
||||
}
|
||||
|
||||
function drawBackground(layer: Konva.Layer) {
|
||||
// Fond avec texture (grille ou motif)
|
||||
const background = new Konva.Rect({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: CANVAS_WIDTH,
|
||||
height: CANVAS_HEIGHT,
|
||||
fill: '#0f172a', // sky-dark
|
||||
})
|
||||
layer.add(background)
|
||||
|
||||
// Lignes de connexion entre zones (chemins)
|
||||
const connections = [
|
||||
['projets', 'competences'],
|
||||
['competences', 'temoignages'],
|
||||
['temoignages', 'parcours'],
|
||||
['parcours', 'contact'],
|
||||
['projets', 'temoignages'],
|
||||
]
|
||||
|
||||
connections.forEach(([from, to]) => {
|
||||
const zoneFrom = mapZones.find(z => z.id === from)
|
||||
const zoneTo = mapZones.find(z => z.id === to)
|
||||
if (zoneFrom && zoneTo) {
|
||||
const line = new Konva.Line({
|
||||
points: [zoneFrom.position.x, zoneFrom.position.y, zoneTo.position.x, zoneTo.position.y],
|
||||
stroke: '#334155', // sky-dark-100
|
||||
strokeWidth: 2,
|
||||
dash: [10, 5],
|
||||
opacity: 0.5,
|
||||
})
|
||||
layer.add(line)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function drawZones(layer: Konva.Layer) {
|
||||
mapZones.forEach((zone, index) => {
|
||||
const isVisited = zone.id !== 'contact' && progressionStore.visitedSections.includes(zone.id)
|
||||
const isLocked = zone.id === 'contact' && !progressionStore.contactUnlocked
|
||||
const isCurrent = zone.id === props.currentSection
|
||||
|
||||
// Groupe pour la zone
|
||||
const group = new Konva.Group({
|
||||
x: zone.position.x,
|
||||
y: zone.position.y,
|
||||
})
|
||||
|
||||
// Cercle de la zone
|
||||
const circle = new Konva.Circle({
|
||||
radius: zone.size,
|
||||
fill: isLocked ? '#475569' : zone.color,
|
||||
opacity: isVisited ? 1 : 0.6,
|
||||
shadowColor: zone.color,
|
||||
shadowBlur: isVisited ? 20 : 0,
|
||||
shadowOpacity: 0.5,
|
||||
})
|
||||
group.add(circle)
|
||||
|
||||
// Icône (texte emoji pour simplifier, ou image)
|
||||
const icon = new Konva.Text({
|
||||
text: isLocked ? '🔒' : getZoneEmoji(zone.id),
|
||||
fontSize: 32,
|
||||
x: -16,
|
||||
y: -16,
|
||||
})
|
||||
group.add(icon)
|
||||
|
||||
// Checkmark si visité
|
||||
if (isVisited && zone.id !== 'contact') {
|
||||
const check = new Konva.Text({
|
||||
text: '✓',
|
||||
fontSize: 24,
|
||||
fill: '#22c55e',
|
||||
x: zone.size - 20,
|
||||
y: -zone.size,
|
||||
})
|
||||
group.add(check)
|
||||
}
|
||||
|
||||
// Événements
|
||||
group.on('mouseenter', () => {
|
||||
document.body.style.cursor = 'pointer'
|
||||
hoveredZone.value = zone
|
||||
tooltipPosition.value = {
|
||||
x: zone.position.x,
|
||||
y: zone.position.y - zone.size - 20,
|
||||
}
|
||||
// Effet hover
|
||||
circle.shadowBlur(30)
|
||||
layer.draw()
|
||||
})
|
||||
|
||||
group.on('mouseleave', () => {
|
||||
document.body.style.cursor = 'default'
|
||||
hoveredZone.value = null
|
||||
circle.shadowBlur(isVisited ? 20 : 0)
|
||||
layer.draw()
|
||||
})
|
||||
|
||||
group.on('click tap', () => {
|
||||
handleZoneClick(zone)
|
||||
})
|
||||
|
||||
layer.add(group)
|
||||
})
|
||||
}
|
||||
|
||||
function drawPositionMarker(layer: Konva.Layer) {
|
||||
if (!props.currentSection) return
|
||||
|
||||
const currentZone = mapZones.find(z => z.id === props.currentSection)
|
||||
if (!currentZone) return
|
||||
|
||||
// Marqueur pulsant
|
||||
const marker = new Konva.Circle({
|
||||
x: currentZone.position.x,
|
||||
y: currentZone.position.y,
|
||||
radius: 10,
|
||||
fill: '#fa784f', // sky-accent
|
||||
opacity: 1,
|
||||
})
|
||||
|
||||
// Animation de pulsation
|
||||
const anim = new Konva.Animation((frame) => {
|
||||
if (!frame) return
|
||||
const scale = 1 + 0.3 * Math.sin(frame.time / 200)
|
||||
marker.scale({ x: scale, y: scale })
|
||||
marker.opacity(1 - 0.3 * Math.abs(Math.sin(frame.time / 200)))
|
||||
}, layer)
|
||||
|
||||
anim.start()
|
||||
layer.add(marker)
|
||||
}
|
||||
|
||||
function getZoneEmoji(id: string): string {
|
||||
const emojis: Record<string, string> = {
|
||||
projets: '💻',
|
||||
competences: '⚡',
|
||||
temoignages: '💬',
|
||||
parcours: '📍',
|
||||
contact: '📧',
|
||||
}
|
||||
return emojis[id] || '?'
|
||||
}
|
||||
|
||||
function handleZoneClick(zone: MapZone) {
|
||||
if (zone.id === 'contact' && !progressionStore.contactUnlocked) {
|
||||
// Zone verrouillée - afficher message ou shake
|
||||
// TODO: Animation shake ou notification
|
||||
return
|
||||
}
|
||||
|
||||
const route = locale.value === 'fr' ? zone.route.fr : zone.route.en
|
||||
router.push(route)
|
||||
emit('navigate', zone)
|
||||
}
|
||||
|
||||
// Navigation clavier
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
focusedZoneIndex.value = (focusedZoneIndex.value - 1 + mapZones.length) % mapZones.length
|
||||
} else {
|
||||
focusedZoneIndex.value = (focusedZoneIndex.value + 1) % mapZones.length
|
||||
}
|
||||
highlightFocusedZone()
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (focusedZoneIndex.value >= 0) {
|
||||
handleZoneClick(mapZones[focusedZoneIndex.value])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function highlightFocusedZone() {
|
||||
// Mettre en surbrillance la zone focusée
|
||||
hoveredZone.value = mapZones[focusedZoneIndex.value]
|
||||
const zone = mapZones[focusedZoneIndex.value]
|
||||
tooltipPosition.value = {
|
||||
x: zone.position.x,
|
||||
y: zone.position.y - zone.size - 20,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="interactive-map-container relative"
|
||||
@keydown="handleKeydown"
|
||||
tabindex="0"
|
||||
role="application"
|
||||
:aria-label="$t('map.ariaLabel')"
|
||||
>
|
||||
<!-- Canvas Konva -->
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="konva-container rounded-xl overflow-hidden shadow-2xl"
|
||||
></div>
|
||||
|
||||
<!-- Tooltip HTML (au-dessus du canvas) -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="hoveredZone"
|
||||
class="tooltip absolute pointer-events-none z-10 bg-sky-dark-50 border border-sky-dark-100 rounded-lg px-3 py-2 shadow-lg"
|
||||
:style="{
|
||||
left: `${tooltipPosition.x}px`,
|
||||
top: `${tooltipPosition.y}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}"
|
||||
>
|
||||
<p class="font-ui font-semibold text-sky-text">
|
||||
{{ locale === 'fr' ? hoveredZone.label.fr : hoveredZone.label.en }}
|
||||
</p>
|
||||
<p class="text-xs text-sky-text-muted">
|
||||
<template v-if="hoveredZone.id === 'contact' && !progressionStore.contactUnlocked">
|
||||
{{ $t('map.locked') }}
|
||||
</template>
|
||||
<template v-else-if="hoveredZone.id !== 'contact' && progressionStore.visitedSections.includes(hoveredZone.id)">
|
||||
{{ $t('map.visited') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('map.clickToExplore') }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Légende -->
|
||||
<div class="legend absolute bottom-4 left-4 bg-sky-dark-50/80 backdrop-blur rounded-lg p-3">
|
||||
<div class="flex items-center gap-4 text-xs font-ui">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-full bg-sky-accent opacity-60"></span>
|
||||
<span class="text-sky-text-muted">{{ $t('map.legend.notVisited') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-full bg-sky-accent shadow-lg shadow-sky-accent/50"></span>
|
||||
<span class="text-sky-text-muted">{{ $t('map.legend.visited') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-full bg-gray-500"></span>
|
||||
<span class="text-sky-text-muted">{{ $t('map.legend.locked') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<p class="sr-only">
|
||||
{{ $t('map.instructions') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.interactive-map-container {
|
||||
width: 800px;
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.interactive-map-container:focus {
|
||||
outline: 2px solid var(--sky-accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Clés i18n
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"map": {
|
||||
"ariaLabel": "Carte interactive du portfolio. Utilisez Tab pour naviguer entre les zones et Entrée pour explorer.",
|
||||
"instructions": "Utilisez les touches Tab pour naviguer entre les zones et Entrée ou Espace pour explorer une zone.",
|
||||
"locked": "Zone verrouillée - Explorez davantage pour débloquer",
|
||||
"visited": "Déjà visité",
|
||||
"clickToExplore": "Cliquez pour explorer",
|
||||
"legend": {
|
||||
"notVisited": "Non visité",
|
||||
"visited": "Visité",
|
||||
"locked": "Verrouillé"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"map": {
|
||||
"ariaLabel": "Interactive portfolio map. Use Tab to navigate between zones and Enter to explore.",
|
||||
"instructions": "Use Tab keys to navigate between zones and Enter or Space to explore a zone.",
|
||||
"locked": "Locked zone - Explore more to unlock",
|
||||
"visited": "Already visited",
|
||||
"clickToExplore": "Click to explore",
|
||||
"legend": {
|
||||
"notVisited": "Not visited",
|
||||
"visited": "Visited",
|
||||
"locked": "Locked"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Utilisation dans une page
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/carte.vue ou dans le layout -->
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
|
||||
// Déterminer la section actuelle basée sur la route
|
||||
const currentSection = computed(() => {
|
||||
const path = route.path
|
||||
if (path.includes('projets') || path.includes('projects')) return 'projets'
|
||||
if (path.includes('competences') || path.includes('skills')) return 'competences'
|
||||
if (path.includes('temoignages') || path.includes('testimonials')) return 'temoignages'
|
||||
if (path.includes('parcours') || path.includes('journey')) return 'parcours'
|
||||
if (path.includes('contact')) return 'contact'
|
||||
return undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center py-8">
|
||||
<!-- Carte visible uniquement sur desktop -->
|
||||
<ClientOnly>
|
||||
<InteractiveMap
|
||||
:current-section="currentSection"
|
||||
class="hidden lg:block"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 3.5 : Store de progression (visitedSections, contactUnlocked)
|
||||
- Nuxt/Vue 3 avec support Konva
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 3.7 : Navigation mobile (alternative à la carte)
|
||||
- Story 4.2 : Intro narrative (peut utiliser la carte)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/
|
||||
├── app/
|
||||
│ ├── components/feature/
|
||||
│ │ └── InteractiveMap.client.vue # CRÉER
|
||||
│ └── data/
|
||||
│ └── mapZones.ts # CRÉER
|
||||
└── public/images/map/
|
||||
├── icon-projects.svg # CRÉER (optionnel)
|
||||
├── icon-skills.svg # CRÉER (optionnel)
|
||||
├── icon-testimonials.svg # CRÉER (optionnel)
|
||||
├── icon-journey.svg # CRÉER (optionnel)
|
||||
└── icon-contact.svg # CRÉER (optionnel)
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/package.json # AJOUTER konva, vue-konva
|
||||
frontend/nuxt.config.ts # AJOUTER transpile konva
|
||||
frontend/i18n/fr.json # AJOUTER map.*
|
||||
frontend/i18n/en.json # AJOUTER map.*
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-3.6]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Interactive-Map]
|
||||
- [Source: docs/planning-artifacts/architecture.md#JS-Budget]
|
||||
- [Konva.js Documentation](https://konvajs.org/)
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Breakpoint desktop | >= 1024px | Epics |
|
||||
| Bibliothèque canvas | Konva.js + vue-konva | Architecture |
|
||||
| Chargement | Lazy (.client.vue) | JS Budget |
|
||||
| Zones | 5 (projets, competences, temoignages, parcours, contact) | Epics |
|
||||
| Accessibilité | Tab + Enter/Space, ARIA | 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
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
# Story 3.7: Navigation mobile - Chemin Libre et Bottom Bar
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur mobile,
|
||||
I want naviguer facilement avec une interface adaptée au tactile,
|
||||
so that l'expérience reste immersive sur petit écran.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur est sur mobile (< 768px) **When** il accède à la navigation **Then** le "Chemin Libre" affiche les zones en cards verticales scrollables (`ZoneCard`)
|
||||
2. **And** chaque `ZoneCard` affiche : illustration, nom de la zone, statut (visité/nouveau/verrouillé)
|
||||
3. **And** une ligne décorative relie les cards visuellement (effet chemin)
|
||||
4. **And** un tap sur une zone navigue vers la section correspondante
|
||||
5. **And** la zone Contact affiche un cadenas si `contactUnlocked` est `false`
|
||||
6. **Given** la bottom bar mobile est affichée **When** le visiteur interagit **Then** 3 icônes sont accessibles : Carte (ouvre le Chemin Libre), Progression (affiche le %), Paramètres
|
||||
7. **And** les touch targets font au minimum 48x48px
|
||||
8. **And** la bottom bar est fixe et toujours visible
|
||||
9. **And** le narrateur s'affiche au-dessus de la bottom bar quand actif
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer le composant ZoneCard** (AC: #1, #2, #5)
|
||||
- [ ] Créer `frontend/app/components/feature/ZoneCard.vue`
|
||||
- [ ] Props : zone (MapZone), isVisited, isLocked, isCurrent
|
||||
- [ ] Afficher illustration, nom traduit, statut visuel
|
||||
- [ ] Icône cadenas si verrouillé
|
||||
- [ ] Badge "Nouveau" si non visité
|
||||
- [ ] Checkmark si visité
|
||||
|
||||
- [ ] **Task 2: Créer le composant CheminLibre** (AC: #1, #3, #4)
|
||||
- [ ] Créer `frontend/app/components/feature/CheminLibre.vue`
|
||||
- [ ] Afficher les 5 zones en cards verticales
|
||||
- [ ] Ligne décorative reliant les cards (SVG ou CSS)
|
||||
- [ ] Scroll vertical natif
|
||||
- [ ] Gestion du tap pour navigation
|
||||
|
||||
- [ ] **Task 3: Créer le composant BottomBar** (AC: #6, #7, #8)
|
||||
- [ ] Créer `frontend/app/components/layout/BottomBar.vue`
|
||||
- [ ] 3 boutons : Carte, Progression, Paramètres
|
||||
- [ ] Touch targets minimum 48x48px
|
||||
- [ ] Position fixe en bas
|
||||
- [ ] Variable CSS --bottom-bar-height pour le spacing
|
||||
|
||||
- [ ] **Task 4: Intégrer le drawer Chemin Libre** (AC: #1)
|
||||
- [ ] Au tap sur Carte dans BottomBar, ouvrir le CheminLibre
|
||||
- [ ] Le CheminLibre s'affiche en slide-up depuis le bas
|
||||
- [ ] Overlay pour fermer en tapant à l'extérieur
|
||||
- [ ] Handle de glissement pour fermer
|
||||
|
||||
- [ ] **Task 5: Intégrer le modal Progression** (AC: #6)
|
||||
- [ ] Au tap sur Progression, afficher le détail
|
||||
- [ ] Réutiliser le composant ProgressIcon de Story 3.4
|
||||
- [ ] Afficher la liste des sections visitées/restantes
|
||||
|
||||
- [ ] **Task 6: Intégrer les paramètres** (AC: #6)
|
||||
- [ ] Au tap sur Paramètres, ouvrir un drawer
|
||||
- [ ] Options : langue, mode Express/Aventure, réinitialiser
|
||||
- [ ] Consentement RGPD accessible
|
||||
|
||||
- [ ] **Task 7: Gérer le positionnement du narrateur** (AC: #9)
|
||||
- [ ] Variable CSS --bottom-bar-height définie
|
||||
- [ ] Le NarratorBubble utilise cette variable pour son bottom
|
||||
- [ ] Pas de chevauchement entre narrateur et bottom bar
|
||||
|
||||
- [ ] **Task 8: Responsive design**
|
||||
- [ ] BottomBar visible uniquement < 768px
|
||||
- [ ] CheminLibre adapté aux petits écrans
|
||||
- [ ] Safe-area-inset pour les appareils avec notch
|
||||
|
||||
- [ ] **Task 9: Tests et validation**
|
||||
- [ ] Tester sur mobile réel ou émulateur
|
||||
- [ ] Vérifier les touch targets (48px minimum)
|
||||
- [ ] Tester navigation entre zones
|
||||
- [ ] Valider le drawer Chemin Libre
|
||||
- [ ] Tester le positionnement du narrateur
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Composant ZoneCard
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/ZoneCard.vue -->
|
||||
<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 / Icône -->
|
||||
<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>
|
||||
<img
|
||||
v-if="zone.icon.startsWith('/')"
|
||||
:src="zone.icon"
|
||||
:alt="zoneName"
|
||||
class="w-10 h-10"
|
||||
/>
|
||||
<span v-else>{{ zone.icon }}</span>
|
||||
</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>
|
||||
```
|
||||
|
||||
### Composant CheminLibre
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/CheminLibre.vue -->
|
||||
<script setup lang="ts">
|
||||
import { mapZones } from '~/data/mapZones'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
navigate: [route: string]
|
||||
}>()
|
||||
|
||||
const { locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const progressionStore = useProgressionStore()
|
||||
|
||||
function handleZoneSelect(zone: typeof mapZones[0]) {
|
||||
if (zone.id === 'contact' && !progressionStore.contactUnlocked) {
|
||||
return
|
||||
}
|
||||
|
||||
const route = locale.value === 'fr' ? zone.route.fr : zone.route.en
|
||||
router.push(route)
|
||||
emit('navigate', route)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function isVisited(zoneId: string): boolean {
|
||||
return zoneId !== 'contact' && progressionStore.visitedSections.includes(zoneId as any)
|
||||
}
|
||||
|
||||
function isLocked(zoneId: string): boolean {
|
||||
return zoneId === 'contact' && !progressionStore.contactUnlocked
|
||||
}
|
||||
|
||||
function isCurrent(zoneId: string): boolean {
|
||||
// Détecter basé sur la route actuelle
|
||||
const route = useRoute()
|
||||
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"></div>
|
||||
<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"></div>
|
||||
|
||||
<!-- Zones -->
|
||||
<div class="space-y-4 relative z-10">
|
||||
<div
|
||||
v-for="(zone, index) 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' : '',
|
||||
]"
|
||||
></div>
|
||||
|
||||
<!-- 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>
|
||||
```
|
||||
|
||||
### Composant BottomBar
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/layout/BottomBar.vue -->
|
||||
<script setup lang="ts">
|
||||
const showCheminLibre = ref(false)
|
||||
const showProgress = ref(false)
|
||||
const showSettings = ref(false)
|
||||
|
||||
const progressionStore = useProgressionStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bottom-bar-container md:hidden">
|
||||
<!-- Bottom Bar fixe -->
|
||||
<nav
|
||||
class="bottom-bar fixed bottom-0 inset-x-0 z-40 bg-sky-dark-50 border-t border-sky-dark-100 safe-bottom"
|
||||
style="--bottom-bar-height: 64px"
|
||||
>
|
||||
<div class="flex items-center justify-around h-16">
|
||||
<!-- Bouton Carte -->
|
||||
<button
|
||||
type="button"
|
||||
class="bottom-bar-btn flex flex-col items-center justify-center min-w-12 min-h-12 p-2 text-sky-text-muted hover:text-sky-accent transition-colors"
|
||||
:class="{ 'text-sky-accent': showCheminLibre }"
|
||||
:aria-label="$t('bottomBar.map')"
|
||||
@click="showCheminLibre = true"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
<span class="text-xs font-ui mt-1">{{ $t('bottomBar.map') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Bouton Progression -->
|
||||
<button
|
||||
type="button"
|
||||
class="bottom-bar-btn flex flex-col items-center justify-center min-w-12 min-h-12 p-2 text-sky-text-muted hover:text-sky-accent transition-colors"
|
||||
:class="{ 'text-sky-accent': showProgress }"
|
||||
:aria-label="$t('bottomBar.progress')"
|
||||
@click="showProgress = true"
|
||||
>
|
||||
<!-- Cercle de progression -->
|
||||
<div class="relative w-6 h-6">
|
||||
<svg class="w-full h-full -rotate-90" viewBox="0 0 36 36">
|
||||
<circle
|
||||
cx="18" cy="18" r="14"
|
||||
fill="none" stroke="currentColor" stroke-width="3"
|
||||
class="text-sky-dark-100"
|
||||
/>
|
||||
<circle
|
||||
cx="18" cy="18" r="14"
|
||||
fill="none" stroke="currentColor" stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
class="text-sky-accent"
|
||||
:stroke-dasharray="`${progressionStore.completionPercent}, 100`"
|
||||
/>
|
||||
</svg>
|
||||
<span class="absolute inset-0 flex items-center justify-center text-[8px] font-ui font-bold">
|
||||
{{ progressionStore.completionPercent }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs font-ui mt-1">{{ $t('bottomBar.progress') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Bouton Paramètres -->
|
||||
<button
|
||||
type="button"
|
||||
class="bottom-bar-btn flex flex-col items-center justify-center min-w-12 min-h-12 p-2 text-sky-text-muted hover:text-sky-accent transition-colors"
|
||||
:class="{ 'text-sky-accent': showSettings }"
|
||||
:aria-label="$t('bottomBar.settings')"
|
||||
@click="showSettings = true"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span class="text-xs font-ui mt-1">{{ $t('bottomBar.settings') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Drawer Chemin Libre -->
|
||||
<Teleport to="body">
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="showCheminLibre"
|
||||
class="fixed inset-x-0 bottom-16 top-0 z-50"
|
||||
>
|
||||
<!-- Overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50"
|
||||
@click="showCheminLibre = false"
|
||||
></div>
|
||||
|
||||
<!-- Drawer content -->
|
||||
<div class="absolute inset-x-0 bottom-0 max-h-[80vh] bg-sky-dark-50 rounded-t-2xl">
|
||||
<CheminLibre @close="showCheminLibre = false" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Modal Progression -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showProgress"
|
||||
class="fixed inset-0 z-50 flex items-end justify-center md:items-center"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50"
|
||||
@click="showProgress = false"
|
||||
></div>
|
||||
<div class="relative w-full max-w-sm bg-sky-dark-50 rounded-t-2xl md:rounded-2xl p-6 safe-bottom">
|
||||
<ProgressDetail @close="showProgress = false" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Drawer Paramètres -->
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="showSettings"
|
||||
class="fixed inset-x-0 bottom-16 top-0 z-50"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50"
|
||||
@click="showSettings = false"
|
||||
></div>
|
||||
<div class="absolute inset-x-0 bottom-0 max-h-[60vh] bg-sky-dark-50 rounded-t-2xl p-6 safe-bottom">
|
||||
<SettingsDrawer @close="showSettings = false" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.safe-bottom {
|
||||
padding-bottom: max(env(safe-area-inset-bottom), 1rem);
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
height: var(--bottom-bar-height, 64px);
|
||||
}
|
||||
|
||||
.bottom-bar-btn {
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Composant ProgressDetail (pour le modal)
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/ProgressDetail.vue -->
|
||||
<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"></div>
|
||||
|
||||
<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>
|
||||
```
|
||||
|
||||
### Composant SettingsDrawer
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/SettingsDrawer.vue -->
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { locale, setLocale, t } = useI18n()
|
||||
const progressionStore = useProgressionStore()
|
||||
const consentStore = useConsentStore()
|
||||
|
||||
function toggleLanguage() {
|
||||
setLocale(locale.value === 'fr' ? 'en' : 'fr')
|
||||
}
|
||||
|
||||
function toggleExpressMode() {
|
||||
progressionStore.setExpressMode(!progressionStore.expressMode)
|
||||
}
|
||||
|
||||
function resetProgress() {
|
||||
if (confirm(t('settings.confirmReset'))) {
|
||||
progressionStore.reset()
|
||||
localStorage.removeItem('skycel_progression')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-drawer">
|
||||
<!-- Handle -->
|
||||
<div class="w-12 h-1 bg-sky-dark-100 rounded-full mx-auto mb-4"></div>
|
||||
|
||||
<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="w-12 h-6 rounded-full transition-colors"
|
||||
:class="progressionStore.expressMode ? 'bg-sky-accent' : 'bg-sky-dark-100'"
|
||||
@click="toggleExpressMode"
|
||||
>
|
||||
<span
|
||||
class="block w-5 h-5 rounded-full bg-white shadow transition-transform"
|
||||
:class="progressionStore.expressMode ? 'translate-x-6' : 'translate-x-0.5'"
|
||||
></span>
|
||||
</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="consentStore.hasConsent ? 'bg-sky-accent' : 'bg-sky-dark-100'"
|
||||
@click="consentStore.hasConsent ? consentStore.revokeConsent() : consentStore.giveConsent()"
|
||||
>
|
||||
<span
|
||||
class="block w-5 h-5 rounded-full bg-white shadow transition-transform"
|
||||
:class="consentStore.hasConsent ? 'translate-x-6' : 'translate-x-0.5'"
|
||||
></span>
|
||||
</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>
|
||||
```
|
||||
|
||||
### Clés i18n
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"zone": {
|
||||
"locked": "Verrouillé",
|
||||
"visited": "Visité",
|
||||
"new": "À découvrir",
|
||||
"newBadge": "Nouveau"
|
||||
},
|
||||
"cheminLibre": {
|
||||
"title": "Chemin Libre"
|
||||
},
|
||||
"bottomBar": {
|
||||
"map": "Carte",
|
||||
"progress": "Progression",
|
||||
"settings": "Options"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"language": "Langue",
|
||||
"expressMode": "Mode Express",
|
||||
"expressModeDesc": "Navigation rapide sans aventure",
|
||||
"saveProgress": "Sauvegarder ma progression",
|
||||
"saveProgressDesc": "Permet de reprendre là où vous vous êtes arrêté",
|
||||
"reset": "Réinitialiser ma progression",
|
||||
"confirmReset": "Êtes-vous sûr de vouloir réinitialiser votre progression ?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"zone": {
|
||||
"locked": "Locked",
|
||||
"visited": "Visited",
|
||||
"new": "To discover",
|
||||
"newBadge": "New"
|
||||
},
|
||||
"cheminLibre": {
|
||||
"title": "Free Path"
|
||||
},
|
||||
"bottomBar": {
|
||||
"map": "Map",
|
||||
"progress": "Progress",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"expressMode": "Express Mode",
|
||||
"expressModeDesc": "Quick navigation without adventure",
|
||||
"saveProgress": "Save my progress",
|
||||
"saveProgressDesc": "Allows you to resume where you left off",
|
||||
"reset": "Reset my progress",
|
||||
"confirmReset": "Are you sure you want to reset your progress?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Variable CSS pour la Bottom Bar
|
||||
|
||||
```css
|
||||
/* frontend/app/assets/css/main.css ou variables.css */
|
||||
:root {
|
||||
--bottom-bar-height: 64px;
|
||||
}
|
||||
|
||||
/* Padding bottom pour le contenu principal sur mobile */
|
||||
@media (max-width: 767px) {
|
||||
.main-content {
|
||||
padding-bottom: calc(var(--bottom-bar-height) + 1rem);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 3.4 : ProgressBar composant
|
||||
- Story 3.5 : Store de progression
|
||||
- Story 3.2 : NarratorBubble (pour le positionnement)
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 4.2 : Intro narrative (navigation mobile)
|
||||
- Epic 4 : Chemins narratifs (utilise la navigation)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/components/
|
||||
├── feature/
|
||||
│ ├── ZoneCard.vue # CRÉER
|
||||
│ ├── CheminLibre.vue # CRÉER
|
||||
│ ├── ProgressDetail.vue # CRÉER
|
||||
│ └── SettingsDrawer.vue # CRÉER
|
||||
└── layout/
|
||||
└── BottomBar.vue # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/app/layouts/default.vue # AJOUTER BottomBar
|
||||
frontend/app/assets/css/main.css # AJOUTER variables CSS
|
||||
frontend/i18n/fr.json # AJOUTER traductions
|
||||
frontend/i18n/en.json # AJOUTER traductions
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-3.7]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Mobile-Navigation]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Bottom-Bar]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Breakpoint mobile | < 768px | Epics |
|
||||
| Touch targets | 48x48px minimum | WCAG |
|
||||
| Bottom bar height | 64px | Décision technique |
|
||||
| Safe area | env(safe-area-inset-bottom) | iOS |
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
# Story 4.1: Composant ChoiceCards et choix narratifs
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want faire des choix qui influencent mon parcours,
|
||||
so that mon expérience est unique et personnalisée.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le composant `ChoiceCards` est implémenté **When** le narrateur propose un choix **Then** 2 cards s'affichent côte à côte (desktop) ou empilées (mobile)
|
||||
2. **And** chaque card affiche : icône, texte narratif du choix
|
||||
3. **And** un hover/focus highlight la card sélectionnable
|
||||
4. **And** un clic enregistre le choix dans `choices` du store Pinia
|
||||
5. **And** une transition animée mène vers la destination choisie
|
||||
6. **And** le composant est accessible (`role="radiogroup"`, navigation clavier, focus visible)
|
||||
7. **And** `prefers-reduced-motion` simplifie les animations
|
||||
8. **And** le style est cohérent avec l'univers narratif (police serif, couleurs des zones)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Définir les types de choix** (AC: #2, #4)
|
||||
- [ ] Créer `frontend/app/types/choice.ts`
|
||||
- [ ] Interface Choice : id, textFr, textEn, icon, destination, zoneColor
|
||||
- [ ] Interface ChoicePoint : id, choices (2 options), context
|
||||
|
||||
- [ ] **Task 2: Créer le composant ChoiceCard** (AC: #2, #3, #8)
|
||||
- [ ] Créer `frontend/app/components/feature/ChoiceCard.vue`
|
||||
- [ ] Props : choice (Choice), selected (boolean), disabled (boolean)
|
||||
- [ ] Afficher icône + texte narratif
|
||||
- [ ] Effet hover/focus avec highlight
|
||||
- [ ] Police serif narrative pour le texte
|
||||
|
||||
- [ ] **Task 3: Créer le composant ChoiceCards** (AC: #1, #4, #5, #6)
|
||||
- [ ] Créer `frontend/app/components/feature/ChoiceCards.vue`
|
||||
- [ ] Props : choicePoint (ChoicePoint)
|
||||
- [ ] Emit : select (choice)
|
||||
- [ ] Layout côte à côte desktop, empilé mobile
|
||||
- [ ] Gérer la sélection et enregistrer dans le store
|
||||
- [ ] Animation de transition vers la destination
|
||||
|
||||
- [ ] **Task 4: Implémenter l'accessibilité** (AC: #6)
|
||||
- [ ] role="radiogroup" sur le conteneur
|
||||
- [ ] role="radio" sur chaque card
|
||||
- [ ] aria-checked pour indiquer la sélection
|
||||
- [ ] Navigation clavier (flèches gauche/droite)
|
||||
- [ ] Focus visible conforme WCAG
|
||||
|
||||
- [ ] **Task 5: Gérer les animations** (AC: #5, #7)
|
||||
- [ ] Animation de sélection (scale + glow)
|
||||
- [ ] Transition vers la destination (fade-out)
|
||||
- [ ] Respecter prefers-reduced-motion
|
||||
|
||||
- [ ] **Task 6: Intégrer avec le store** (AC: #4)
|
||||
- [ ] Appeler `progressionStore.addChoice(id, value)` à la sélection
|
||||
- [ ] Les choix sont persistés avec le reste de la progression
|
||||
|
||||
- [ ] **Task 7: Tests et validation**
|
||||
- [ ] Tester le layout desktop et mobile
|
||||
- [ ] Valider hover/focus
|
||||
- [ ] Tester navigation clavier
|
||||
- [ ] Vérifier l'enregistrement du choix
|
||||
- [ ] Tester prefers-reduced-motion
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Types des choix
|
||||
|
||||
```typescript
|
||||
// frontend/app/types/choice.ts
|
||||
export interface Choice {
|
||||
id: string
|
||||
textFr: string
|
||||
textEn: string
|
||||
icon: string // emoji ou URL d'image
|
||||
destination: string // route vers laquelle naviguer
|
||||
zoneColor: string // couleur de la zone associée
|
||||
}
|
||||
|
||||
export interface ChoicePoint {
|
||||
id: string
|
||||
questionFr: string
|
||||
questionEn: string
|
||||
choices: [Choice, Choice] // Toujours 2 choix binaires
|
||||
context: string // contexte narratif (intro, after_projects, etc.)
|
||||
}
|
||||
|
||||
// Exemple de point de choix
|
||||
export const CHOICE_POINTS: Record<string, ChoicePoint> = {
|
||||
intro_first_choice: {
|
||||
id: 'intro_first_choice',
|
||||
questionFr: 'Par où veux-tu commencer ton exploration ?',
|
||||
questionEn: 'Where do you want to start your exploration?',
|
||||
choices: [
|
||||
{
|
||||
id: 'choice_projects_first',
|
||||
textFr: 'Découvrir les créations',
|
||||
textEn: 'Discover the creations',
|
||||
icon: '💻',
|
||||
destination: '/projets',
|
||||
zoneColor: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'choice_skills_first',
|
||||
textFr: 'Explorer les compétences',
|
||||
textEn: 'Explore the skills',
|
||||
icon: '⚡',
|
||||
destination: '/competences',
|
||||
zoneColor: '#10b981',
|
||||
},
|
||||
],
|
||||
context: 'intro',
|
||||
},
|
||||
after_projects: {
|
||||
id: 'after_projects',
|
||||
questionFr: 'Quelle sera ta prochaine étape ?',
|
||||
questionEn: 'What will be your next step?',
|
||||
choices: [
|
||||
{
|
||||
id: 'choice_testimonials',
|
||||
textFr: "Écouter ceux qui l'ont rencontré",
|
||||
textEn: 'Listen to those who met him',
|
||||
icon: '💬',
|
||||
destination: '/temoignages',
|
||||
zoneColor: '#f59e0b',
|
||||
},
|
||||
{
|
||||
id: 'choice_journey',
|
||||
textFr: 'Suivre son parcours',
|
||||
textEn: 'Follow his journey',
|
||||
icon: '📍',
|
||||
destination: '/parcours',
|
||||
zoneColor: '#8b5cf6',
|
||||
},
|
||||
],
|
||||
context: 'after_projects',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Composant ChoiceCard
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/ChoiceCard.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { Choice } from '~/types/choice'
|
||||
|
||||
const props = defineProps<{
|
||||
choice: Choice
|
||||
selected: boolean
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: []
|
||||
}>()
|
||||
|
||||
const reducedMotion = useReducedMotion()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const text = computed(() => {
|
||||
return locale.value === 'fr' ? props.choice.textFr : props.choice.textEn
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="choice-card relative flex flex-col items-center p-6 rounded-xl border-2 transition-all duration-300 focus:outline-none"
|
||||
:class="[
|
||||
selected
|
||||
? 'border-sky-accent bg-sky-accent/10 scale-105 shadow-lg shadow-sky-accent/20'
|
||||
: 'border-sky-dark-100 bg-sky-dark-50 hover:border-sky-accent/50 hover:bg-sky-dark-50/80',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
!reducedMotion && 'transform',
|
||||
]"
|
||||
:style="{ '--zone-color': choice.zoneColor }"
|
||||
:disabled="disabled"
|
||||
:aria-checked="selected"
|
||||
role="radio"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<!-- Glow effect au hover -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-xl opacity-0 transition-opacity pointer-events-none"
|
||||
:class="!selected && 'group-hover:opacity-100'"
|
||||
:style="{ boxShadow: `0 0 30px ${choice.zoneColor}40` }"
|
||||
></div>
|
||||
|
||||
<!-- Icône -->
|
||||
<div
|
||||
class="w-16 h-16 rounded-full flex items-center justify-center text-4xl mb-4"
|
||||
:style="{ backgroundColor: `${choice.zoneColor}20` }"
|
||||
>
|
||||
{{ choice.icon }}
|
||||
</div>
|
||||
|
||||
<!-- Texte narratif -->
|
||||
<p class="font-narrative text-lg text-sky-text text-center leading-relaxed">
|
||||
{{ text }}
|
||||
</p>
|
||||
|
||||
<!-- Indicateur de sélection -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-sky-accent flex items-center justify-center"
|
||||
>
|
||||
<span class="text-white text-sm">✓</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.choice-card:focus-visible {
|
||||
outline: 2px solid var(--sky-accent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.choice-card:not(:disabled):hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.choice-card {
|
||||
transition: none;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.choice-card:not(:disabled):hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Composant ChoiceCards
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/ChoiceCards.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { ChoicePoint, Choice } from '~/types/choice'
|
||||
|
||||
const props = defineProps<{
|
||||
choicePoint: ChoicePoint
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selected: [choice: Choice]
|
||||
}>()
|
||||
|
||||
const { locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const progressionStore = useProgressionStore()
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
const selectedChoice = ref<Choice | null>(null)
|
||||
const isTransitioning = ref(false)
|
||||
|
||||
const question = computed(() => {
|
||||
return locale.value === 'fr' ? props.choicePoint.questionFr : props.choicePoint.questionEn
|
||||
})
|
||||
|
||||
function handleSelect(choice: Choice) {
|
||||
if (isTransitioning.value) return
|
||||
|
||||
selectedChoice.value = choice
|
||||
|
||||
// Enregistrer le choix dans le store
|
||||
progressionStore.addChoice(props.choicePoint.id, choice.id)
|
||||
|
||||
// Émettre l'événement
|
||||
emit('selected', choice)
|
||||
|
||||
// Animation puis navigation
|
||||
isTransitioning.value = true
|
||||
|
||||
const delay = reducedMotion.value ? 100 : 800
|
||||
setTimeout(() => {
|
||||
const route = locale.value === 'fr' ? choice.destination : `/en${choice.destination}`
|
||||
router.push(route)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// Navigation clavier
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const choices = props.choicePoint.choices
|
||||
const currentIndex = selectedChoice.value
|
||||
? choices.findIndex(c => c.id === selectedChoice.value?.id)
|
||||
: -1
|
||||
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const newIndex = currentIndex <= 0 ? choices.length - 1 : currentIndex - 1
|
||||
handleSelect(choices[newIndex])
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const newIndex = currentIndex >= choices.length - 1 ? 0 : currentIndex + 1
|
||||
handleSelect(choices[newIndex])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="choice-cards-container"
|
||||
:class="{ 'transitioning': isTransitioning }"
|
||||
>
|
||||
<!-- Question du narrateur -->
|
||||
<p class="font-narrative text-xl text-sky-text text-center mb-8 italic">
|
||||
{{ question }}
|
||||
</p>
|
||||
|
||||
<!-- Cards de choix -->
|
||||
<div
|
||||
class="choice-cards grid grid-cols-1 md:grid-cols-2 gap-6 max-w-2xl mx-auto"
|
||||
role="radiogroup"
|
||||
:aria-label="question"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<ChoiceCard
|
||||
v-for="choice in choicePoint.choices"
|
||||
:key="choice.id"
|
||||
:choice="choice"
|
||||
:selected="selectedChoice?.id === choice.id"
|
||||
:disabled="isTransitioning"
|
||||
@select="handleSelect(choice)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.choice-cards-container.transitioning {
|
||||
animation: fadeOut 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.choice-cards-container.transitioning {
|
||||
animation: fadeOutSimple 0.1s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeOutSimple {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Utilisation dans une page/composant
|
||||
|
||||
```vue
|
||||
<!-- Exemple d'utilisation -->
|
||||
<script setup>
|
||||
import { CHOICE_POINTS } from '~/types/choice'
|
||||
|
||||
const currentChoicePoint = ref(CHOICE_POINTS.intro_first_choice)
|
||||
|
||||
function handleChoiceSelected(choice) {
|
||||
console.log('Choice selected:', choice.id)
|
||||
// La navigation est gérée automatiquement par ChoiceCards
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-8">
|
||||
<ChoiceCards
|
||||
:choice-point="currentChoicePoint"
|
||||
@selected="handleChoiceSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 3.5 : Store de progression (addChoice)
|
||||
- Story 3.2 : useReducedMotion composable
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 4.2 : Intro narrative (utilise ChoiceCards)
|
||||
- Story 4.3 : Chemins narratifs (points de choix multiples)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── types/
|
||||
│ └── choice.ts # CRÉER
|
||||
└── components/feature/
|
||||
├── ChoiceCard.vue # CRÉER
|
||||
└── ChoiceCards.vue # CRÉER
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-4.1]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Choice-System]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Parcours-Narratifs]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Choix par point | 2 (binaire) | Epics |
|
||||
| Layout desktop | Côte à côte | Epics |
|
||||
| Layout mobile | Empilé | Epics |
|
||||
| Accessibilité | role="radiogroup", clavier | Epics |
|
||||
| Police | font-narrative | UX Spec |
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
# Story 4.2: Intro narrative et premier choix
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur aventurier,
|
||||
I want une introduction narrative captivante suivie d'un premier choix,
|
||||
so that je suis immergé dès le début de l'aventure.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur a sélectionné son héros sur la landing page **When** il commence l'aventure **Then** une séquence d'intro narrative s'affiche avec le narrateur (Le Bug)
|
||||
2. **And** le texte présente le "héros mystérieux" (le développeur) à découvrir
|
||||
3. **And** l'effet typewriter anime le texte (skippable par clic/Espace)
|
||||
4. **And** l'ambiance visuelle est immersive (fond sombre, illustrations)
|
||||
5. **And** un bouton "Continuer" permet d'avancer
|
||||
6. **And** à la fin de l'intro, le premier choix binaire s'affiche via `ChoiceCards`
|
||||
7. **And** le choix propose deux zones à explorer en premier (ex: Projets vs Compétences)
|
||||
8. **And** le contenu est bilingue (FR/EN) et adapté au héros (vouvoiement/tutoiement)
|
||||
9. **And** la durée de l'intro est courte (15-30s max, skippable)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer les textes d'intro dans l'API** (AC: #2, #8)
|
||||
- [ ] Ajouter les contextes `intro_sequence_1`, `intro_sequence_2`, `intro_sequence_3` dans narrator_texts
|
||||
- [ ] Variantes pour chaque type de héros (vouvoiement/tutoiement)
|
||||
- [ ] Textes mystérieux présentant le développeur
|
||||
|
||||
- [ ] **Task 2: Créer la page intro** (AC: #1, #4, #9)
|
||||
- [ ] Créer `frontend/app/pages/intro.vue`
|
||||
- [ ] Rediriger automatiquement depuis landing après choix du héros
|
||||
- [ ] Fond sombre avec ambiance mystérieuse
|
||||
- [ ] Structure en étapes (séquences de texte)
|
||||
|
||||
- [ ] **Task 3: Implémenter la séquence narrative** (AC: #2, #3, #5)
|
||||
- [ ] Créer composant `IntroSequence.vue`
|
||||
- [ ] Afficher le Bug avec le texte en typewriter
|
||||
- [ ] Bouton "Continuer" pour passer à l'étape suivante
|
||||
- [ ] Clic/Espace pour skip le typewriter
|
||||
- [ ] 3-4 séquences de texte courtes
|
||||
|
||||
- [ ] **Task 4: Ajouter les illustrations d'ambiance** (AC: #4)
|
||||
- [ ] Illustrations de fond (toiles d'araignée, ombres, code flottant)
|
||||
- [ ] Animation subtile sur les éléments de fond
|
||||
- [ ] Cohérence avec l'univers de Le Bug
|
||||
|
||||
- [ ] **Task 5: Intégrer le premier choix** (AC: #6, #7)
|
||||
- [ ] Après la dernière séquence, afficher ChoiceCards
|
||||
- [ ] Choix : Projets vs Compétences
|
||||
- [ ] La sélection navigue vers la zone choisie
|
||||
|
||||
- [ ] **Task 6: Gérer le skip global** (AC: #9)
|
||||
- [ ] Bouton discret "Passer l'intro" visible en permanence
|
||||
- [ ] Navigation directe vers le choix si skip
|
||||
- [ ] Enregistrer dans le store que l'intro a été vue/skip
|
||||
|
||||
- [ ] **Task 7: Tests et validation**
|
||||
- [ ] Tester le flow complet
|
||||
- [ ] Vérifier les 3 types de héros (textes adaptés)
|
||||
- [ ] Tester FR et EN
|
||||
- [ ] Valider la durée (< 30s)
|
||||
- [ ] Tester le skip intro
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Textes d'intro (exemples)
|
||||
|
||||
```php
|
||||
// À ajouter dans NarratorTextSeeder.php
|
||||
|
||||
// Intro séquence 1 - Recruteur (vouvoiement)
|
||||
['context' => 'intro_sequence_1', 'text_key' => 'narrator.intro_seq.1.recruteur', 'variant' => 1, 'hero_type' => 'recruteur'],
|
||||
|
||||
// Intro séquence 1 - Client/Dev (tutoiement)
|
||||
['context' => 'intro_sequence_1', 'text_key' => 'narrator.intro_seq.1.casual', 'variant' => 1, 'hero_type' => 'client'],
|
||||
['context' => 'intro_sequence_1', 'text_key' => 'narrator.intro_seq.1.casual', 'variant' => 1, 'hero_type' => 'dev'],
|
||||
|
||||
// Traductions
|
||||
['key' => 'narrator.intro_seq.1.recruteur', 'fr' => "Bienvenue dans mon domaine, voyageur... Je suis Le Bug, et je vais vous guider dans cette aventure.", 'en' => "Welcome to my domain, traveler... I am The Bug, and I will guide you through this adventure."],
|
||||
['key' => 'narrator.intro_seq.1.casual', 'fr' => "Hey ! Bienvenue chez moi. Je suis Le Bug, ton guide pour cette aventure.", 'en' => "Hey! Welcome to my place. I'm The Bug, your guide for this adventure."],
|
||||
|
||||
['key' => 'narrator.intro_seq.2', 'fr' => "Il y a quelqu'un ici que tu cherches... Un développeur mystérieux qui a créé tout ce que tu vois autour de toi.", 'en' => "There's someone here you're looking for... A mysterious developer who created everything you see around you."],
|
||||
|
||||
['key' => 'narrator.intro_seq.3', 'fr' => "Pour le trouver, tu devras explorer ce monde. Chaque zone cache une partie de son histoire. Es-tu prêt ?", 'en' => "To find them, you'll have to explore this world. Each zone hides a part of their story. Are you ready?"],
|
||||
```
|
||||
|
||||
### Page intro.vue
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/intro.vue -->
|
||||
<script setup lang="ts">
|
||||
import { CHOICE_POINTS } from '~/types/choice'
|
||||
|
||||
const progressionStore = useProgressionStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Rediriger si pas de héros sélectionné
|
||||
if (!progressionStore.heroType) {
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
// Étapes de la séquence
|
||||
const steps = ['intro_sequence_1', 'intro_sequence_2', 'intro_sequence_3', 'choice']
|
||||
const currentStepIndex = ref(0)
|
||||
|
||||
const currentStep = computed(() => steps[currentStepIndex.value])
|
||||
const isLastTextStep = computed(() => currentStepIndex.value === steps.length - 2)
|
||||
const isChoiceStep = computed(() => currentStep.value === 'choice')
|
||||
|
||||
// Texte actuel
|
||||
const currentText = ref('')
|
||||
const isTextComplete = ref(false)
|
||||
|
||||
const { fetchText } = useFetchNarratorText()
|
||||
|
||||
async function loadCurrentText() {
|
||||
if (isChoiceStep.value) return
|
||||
|
||||
const response = await fetchText(currentStep.value, progressionStore.heroType || undefined)
|
||||
currentText.value = response.data.text
|
||||
}
|
||||
|
||||
function handleTextComplete() {
|
||||
isTextComplete.value = true
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (currentStepIndex.value < steps.length - 1) {
|
||||
currentStepIndex.value++
|
||||
isTextComplete.value = false
|
||||
loadCurrentText()
|
||||
}
|
||||
}
|
||||
|
||||
function skipIntro() {
|
||||
currentStepIndex.value = steps.length - 1 // Aller directement au choix
|
||||
}
|
||||
|
||||
// Charger le premier texte
|
||||
onMounted(() => {
|
||||
loadCurrentText()
|
||||
})
|
||||
|
||||
// Marquer l'intro comme vue
|
||||
onUnmounted(() => {
|
||||
progressionStore.setIntroSeen(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="intro-page min-h-screen bg-sky-dark relative overflow-hidden">
|
||||
<!-- Fond d'ambiance -->
|
||||
<IntroBackground />
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<div class="relative z-10 flex flex-col items-center justify-center min-h-screen p-8">
|
||||
<!-- Séquence narrative -->
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="!isChoiceStep"
|
||||
:key="currentStep"
|
||||
class="max-w-2xl mx-auto text-center"
|
||||
>
|
||||
<IntroSequence
|
||||
:text="currentText"
|
||||
@complete="handleTextComplete"
|
||||
@skip="handleTextComplete"
|
||||
/>
|
||||
|
||||
<!-- Bouton continuer -->
|
||||
<Transition name="fade">
|
||||
<button
|
||||
v-if="isTextComplete"
|
||||
type="button"
|
||||
class="mt-8 px-8 py-3 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ isLastTextStep ? t('intro.startExploring') : t('intro.continue') }}
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Choix après l'intro -->
|
||||
<div v-else class="w-full max-w-3xl mx-auto">
|
||||
<ChoiceCards :choice-point="CHOICE_POINTS.intro_first_choice" />
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Bouton skip (toujours visible) -->
|
||||
<button
|
||||
v-if="!isChoiceStep"
|
||||
type="button"
|
||||
class="absolute bottom-8 right-8 text-sky-text-muted hover:text-sky-text text-sm font-ui underline transition-colors"
|
||||
@click="skipIntro"
|
||||
>
|
||||
{{ t('intro.skip') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Composant IntroSequence
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/IntroSequence.vue -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const progressionStore = useProgressionStore()
|
||||
const { displayedText, isTyping, isComplete, start, skip } = useTypewriter({
|
||||
speed: 35,
|
||||
onComplete: () => emit('complete'),
|
||||
})
|
||||
|
||||
// Bug image selon le stage (toujours stage 1 au début)
|
||||
const bugImage = '/images/bug/bug-stage-1.svg'
|
||||
|
||||
// Démarrer le typewriter quand le texte change
|
||||
watch(() => props.text, (newText) => {
|
||||
if (newText) {
|
||||
start(newText)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function handleInteraction() {
|
||||
if (isTyping.value) {
|
||||
skip()
|
||||
emit('skip')
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.code === 'Space' || e.code === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleInteraction()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="intro-sequence"
|
||||
@click="handleInteraction"
|
||||
@keydown="handleKeydown"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Avatar du Bug -->
|
||||
<div class="mb-8">
|
||||
<img
|
||||
:src="bugImage"
|
||||
alt="Le Bug"
|
||||
class="w-32 h-32 mx-auto animate-float"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Texte avec typewriter -->
|
||||
<div class="bg-sky-dark-50/80 backdrop-blur rounded-xl p-8 border border-sky-dark-100">
|
||||
<p class="font-narrative text-xl md:text-2xl text-sky-text leading-relaxed">
|
||||
{{ displayedText }}
|
||||
<span
|
||||
v-if="isTyping"
|
||||
class="inline-block w-0.5 h-6 bg-sky-accent animate-blink ml-1"
|
||||
></span>
|
||||
</p>
|
||||
|
||||
<!-- Indication pour skip -->
|
||||
<p
|
||||
v-if="isTyping"
|
||||
class="text-sm text-sky-text-muted mt-4 font-ui"
|
||||
>
|
||||
{{ $t('narrator.clickToSkip') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
.intro-sequence:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-float {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Composant IntroBackground
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/IntroBackground.vue -->
|
||||
<script setup lang="ts">
|
||||
const reducedMotion = useReducedMotion()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="intro-background absolute inset-0 overflow-hidden">
|
||||
<!-- Gradient de fond -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-sky-dark via-sky-dark-50 to-sky-dark"></div>
|
||||
|
||||
<!-- Particules flottantes (code fragments) -->
|
||||
<div
|
||||
v-if="!reducedMotion"
|
||||
class="particles absolute inset-0"
|
||||
>
|
||||
<div
|
||||
v-for="i in 20"
|
||||
:key="i"
|
||||
class="particle absolute text-sky-accent/10 font-mono text-xs"
|
||||
:style="{
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
animationDelay: `${Math.random() * 5}s`,
|
||||
animationDuration: `${10 + Math.random() * 10}s`,
|
||||
}"
|
||||
>
|
||||
{{ ['</', '/>', '{}', '[]', '()', '=>', '&&', '||'][i % 8] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toile d'araignée stylisée (SVG) -->
|
||||
<svg
|
||||
class="absolute top-0 right-0 w-64 h-64 text-sky-dark-100/30"
|
||||
viewBox="0 0 200 200"
|
||||
>
|
||||
<path
|
||||
d="M100,100 L100,0 M100,100 L200,100 M100,100 L100,200 M100,100 L0,100 M100,100 L170,30 M100,100 L170,170 M100,100 L30,170 M100,100 L30,30"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="100" cy="100" r="30" stroke="currentColor" stroke-width="1" fill="none" />
|
||||
<circle cx="100" cy="100" r="60" stroke="currentColor" stroke-width="1" fill="none" />
|
||||
<circle cx="100" cy="100" r="90" stroke="currentColor" stroke-width="1" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes float-up {
|
||||
from {
|
||||
transform: translateY(100vh) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(-100vh) rotate(360deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.particle {
|
||||
animation: float-up linear infinite;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Clés i18n
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"intro": {
|
||||
"continue": "Continuer",
|
||||
"startExploring": "Commencer l'exploration",
|
||||
"skip": "Passer l'intro"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"intro": {
|
||||
"continue": "Continue",
|
||||
"startExploring": "Start exploring",
|
||||
"skip": "Skip intro"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 3.1 : API narrateur (contextes intro_sequence_*)
|
||||
- Story 3.2 : NarratorBubble et useTypewriter
|
||||
- Story 4.1 : ChoiceCards pour le premier choix
|
||||
- Story 1.5 : Landing page (choix du héros)
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 4.3 : Chemins narratifs (suite de l'aventure)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── pages/
|
||||
│ └── intro.vue # CRÉER
|
||||
└── components/feature/
|
||||
├── IntroSequence.vue # CRÉER
|
||||
└── IntroBackground.vue # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
api/database/seeders/NarratorTextSeeder.php # AJOUTER intro_sequence_*
|
||||
frontend/i18n/fr.json # AJOUTER intro.*
|
||||
frontend/i18n/en.json # AJOUTER intro.*
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-4.2]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Intro-Sequence]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Onboarding]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Durée intro | 15-30s max (skippable) | Epics |
|
||||
| Séquences | 3-4 textes courts | Décision technique |
|
||||
| Premier choix | Projets vs Compétences | Epics |
|
||||
| Adaptation héros | Vouvoiement/tutoiement | UX Spec |
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
# Story 4.3: Chemins narratifs différenciés
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want que mes choix aient un impact visible sur mon parcours,
|
||||
so that je sens que mon expérience est vraiment personnalisée.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur fait des choix tout au long de l'aventure **When** il navigue entre les zones **Then** 2-3 points de choix binaires créent 4-8 parcours possibles
|
||||
2. **And** chaque choix est enregistré dans `choices` du store
|
||||
3. **And** l'ordre suggéré des zones varie selon le chemin choisi
|
||||
4. **And** les textes du narrateur s'adaptent au chemin (transitions contextuelles)
|
||||
5. **And** tous les chemins permettent de visiter tout le contenu
|
||||
6. **And** tous les chemins mènent au contact (pas de "mauvais" choix)
|
||||
7. **And** le `currentPath` du store reflète le chemin actuel
|
||||
8. **And** à la fin de chaque zone, le narrateur propose un choix vers la suite
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Définir l'arbre des chemins** (AC: #1, #5, #6)
|
||||
- [ ] Créer `frontend/app/data/narrativePaths.ts`
|
||||
- [ ] Définir 2-3 points de choix créant 4-8 parcours
|
||||
- [ ] S'assurer que tous les chemins visitent toutes les zones
|
||||
- [ ] S'assurer que tous les chemins mènent au contact
|
||||
|
||||
- [ ] **Task 2: Créer le composable useNarrativePath** (AC: #2, #3, #7)
|
||||
- [ ] Créer `frontend/app/composables/useNarrativePath.ts`
|
||||
- [ ] Calculer le chemin actuel basé sur les choix
|
||||
- [ ] Exposer la prochaine zone suggérée
|
||||
- [ ] Exposer les zones restantes dans l'ordre
|
||||
|
||||
- [ ] **Task 3: Ajouter les textes de transition contextuels** (AC: #4)
|
||||
- [ ] Créer des contextes spécifiques : `transition_after_projects_to_skills`, etc.
|
||||
- [ ] Variantes selon le chemin pris
|
||||
- [ ] Commentaires du narrateur sur les choix précédents
|
||||
|
||||
- [ ] **Task 4: Intégrer les choix après chaque zone** (AC: #8)
|
||||
- [ ] Composant `ZoneEndChoice.vue` affiché à la fin de chaque page de zone
|
||||
- [ ] Proposer les options de destination selon le chemin
|
||||
- [ ] Utiliser ChoiceCards pour la présentation
|
||||
|
||||
- [ ] **Task 5: Mettre à jour le store** (AC: #2, #7)
|
||||
- [ ] Ajouter `currentPath` computed au store
|
||||
- [ ] Ajouter `suggestedNextZone` computed
|
||||
- [ ] Méthode pour obtenir le choix à un point donné
|
||||
|
||||
- [ ] **Task 6: Créer l'API pour les transitions contextuelles** (AC: #4)
|
||||
- [ ] Endpoint `/api/narrator/transition-contextual`
|
||||
- [ ] Paramètres : from_zone, to_zone, path_choices
|
||||
- [ ] Retourner un texte adapté au contexte
|
||||
|
||||
- [ ] **Task 7: Tests et validation**
|
||||
- [ ] Tester tous les chemins possibles (4-8)
|
||||
- [ ] Vérifier que tous mènent au contact
|
||||
- [ ] Valider les textes contextuels
|
||||
- [ ] Tester la suggestion de zone suivante
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Arbre des chemins narratifs
|
||||
|
||||
```typescript
|
||||
// frontend/app/data/narrativePaths.ts
|
||||
|
||||
// Points de choix dans l'aventure
|
||||
export const NARRATIVE_CHOICE_POINTS = {
|
||||
// Point 1 : Après l'intro
|
||||
intro: {
|
||||
id: 'intro',
|
||||
options: ['projects', 'skills'],
|
||||
},
|
||||
// Point 2 : Après la première zone
|
||||
after_first_zone: {
|
||||
id: 'after_first_zone',
|
||||
options: ['testimonials', 'journey'],
|
||||
},
|
||||
// Point 3 : Après la deuxième zone
|
||||
after_second_zone: {
|
||||
id: 'after_second_zone',
|
||||
// Les options dépendent de ce qui reste
|
||||
},
|
||||
}
|
||||
|
||||
// Chemins possibles (4-8 combinaisons)
|
||||
// Format : intro_choice -> after_first -> after_second -> contact
|
||||
export const NARRATIVE_PATHS = [
|
||||
// Chemin 1 : Projets → Témoignages → Compétences → Parcours → Contact
|
||||
['projects', 'testimonials', 'skills', 'journey', 'contact'],
|
||||
// Chemin 2 : Projets → Témoignages → Parcours → Compétences → Contact
|
||||
['projects', 'testimonials', 'journey', 'skills', 'contact'],
|
||||
// Chemin 3 : Projets → Parcours → Témoignages → Compétences → Contact
|
||||
['projects', 'journey', 'testimonials', 'skills', 'contact'],
|
||||
// Chemin 4 : Projets → Parcours → Compétences → Témoignages → Contact
|
||||
['projects', 'journey', 'skills', 'testimonials', 'contact'],
|
||||
// Chemin 5 : Compétences → Témoignages → Projets → Parcours → Contact
|
||||
['skills', 'testimonials', 'projects', 'journey', 'contact'],
|
||||
// Chemin 6 : Compétences → Témoignages → Parcours → Projets → Contact
|
||||
['skills', 'testimonials', 'journey', 'projects', 'contact'],
|
||||
// Chemin 7 : Compétences → Parcours → Témoignages → Projets → Contact
|
||||
['skills', 'journey', 'testimonials', 'projects', 'contact'],
|
||||
// Chemin 8 : Compétences → Parcours → Projets → Témoignages → Contact
|
||||
['skills', 'journey', 'projects', 'testimonials', 'contact'],
|
||||
]
|
||||
|
||||
// Mapper zone key -> route
|
||||
export const ZONE_ROUTES: Record<string, { fr: string; en: string }> = {
|
||||
projects: { fr: '/projets', en: '/en/projects' },
|
||||
skills: { fr: '/competences', en: '/en/skills' },
|
||||
testimonials: { fr: '/temoignages', en: '/en/testimonials' },
|
||||
journey: { fr: '/parcours', en: '/en/journey' },
|
||||
contact: { fr: '/contact', en: '/en/contact' },
|
||||
}
|
||||
```
|
||||
|
||||
### Composable useNarrativePath
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useNarrativePath.ts
|
||||
import { NARRATIVE_PATHS, ZONE_ROUTES } from '~/data/narrativePaths'
|
||||
|
||||
export function useNarrativePath() {
|
||||
const progressionStore = useProgressionStore()
|
||||
const { locale } = useI18n()
|
||||
|
||||
// Déterminer le chemin actuel basé sur les choix
|
||||
const currentPath = computed(() => {
|
||||
const choices = progressionStore.choices
|
||||
|
||||
// Trouver le premier choix (intro)
|
||||
const introChoice = choices.find(c => c.id === 'intro_first_choice')
|
||||
if (!introChoice) return null
|
||||
|
||||
const startZone = introChoice.value === 'choice_projects_first' ? 'projects' : 'skills'
|
||||
|
||||
// Filtrer les chemins qui commencent par cette zone
|
||||
let possiblePaths = NARRATIVE_PATHS.filter(path => path[0] === startZone)
|
||||
|
||||
// Affiner avec les choix suivants
|
||||
const afterFirstChoice = choices.find(c => c.id === 'after_first_zone')
|
||||
if (afterFirstChoice && possiblePaths.length > 1) {
|
||||
const secondZone = afterFirstChoice.value.includes('testimonials') ? 'testimonials' : 'journey'
|
||||
possiblePaths = possiblePaths.filter(path => path[1] === secondZone)
|
||||
}
|
||||
|
||||
return possiblePaths[0] || null
|
||||
})
|
||||
|
||||
// Zone actuelle basée sur la route
|
||||
const currentZone = computed(() => {
|
||||
const route = useRoute()
|
||||
const path = route.path.toLowerCase()
|
||||
|
||||
for (const [zone, routes] of Object.entries(ZONE_ROUTES)) {
|
||||
if (path.includes(routes.fr.slice(1)) || path.includes(routes.en.slice(4))) {
|
||||
return zone
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Index de la zone actuelle dans le chemin
|
||||
const currentZoneIndex = computed(() => {
|
||||
if (!currentPath.value || !currentZone.value) return -1
|
||||
return currentPath.value.indexOf(currentZone.value)
|
||||
})
|
||||
|
||||
// Prochaine zone suggérée
|
||||
const suggestedNextZone = computed(() => {
|
||||
if (!currentPath.value || currentZoneIndex.value === -1) return null
|
||||
|
||||
const nextIndex = currentZoneIndex.value + 1
|
||||
if (nextIndex >= currentPath.value.length) return null
|
||||
|
||||
return currentPath.value[nextIndex]
|
||||
})
|
||||
|
||||
// Zones restantes à visiter
|
||||
const remainingZones = computed(() => {
|
||||
if (!currentPath.value) return []
|
||||
|
||||
const visited = progressionStore.visitedSections
|
||||
return currentPath.value.filter(zone =>
|
||||
zone !== 'contact' && !visited.includes(zone as any)
|
||||
)
|
||||
})
|
||||
|
||||
// Obtenir la route pour une zone
|
||||
function getZoneRoute(zone: string): string {
|
||||
const routes = ZONE_ROUTES[zone]
|
||||
if (!routes) return '/'
|
||||
return locale.value === 'fr' ? routes.fr : routes.en
|
||||
}
|
||||
|
||||
// Générer le choix pour après la zone actuelle
|
||||
function getNextChoicePoint() {
|
||||
if (!remainingZones.value.length) {
|
||||
// Plus de zones, aller au contact
|
||||
return {
|
||||
id: 'go_to_contact',
|
||||
choices: [
|
||||
{
|
||||
id: 'contact',
|
||||
textFr: 'Rencontrer le développeur',
|
||||
textEn: 'Meet the developer',
|
||||
icon: '📧',
|
||||
destination: getZoneRoute('contact'),
|
||||
zoneColor: '#fa784f',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Proposer les 2 prochaines zones
|
||||
const nextTwo = remainingZones.value.slice(0, 2)
|
||||
|
||||
return {
|
||||
id: `after_${currentZone.value}`,
|
||||
questionFr: 'Où vas-tu ensuite ?',
|
||||
questionEn: 'Where to next?',
|
||||
choices: nextTwo.map(zone => ({
|
||||
id: `choice_${zone}`,
|
||||
textFr: getZoneLabel(zone, 'fr'),
|
||||
textEn: getZoneLabel(zone, 'en'),
|
||||
icon: getZoneIcon(zone),
|
||||
destination: getZoneRoute(zone),
|
||||
zoneColor: getZoneColor(zone),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
currentZone,
|
||||
suggestedNextZone,
|
||||
remainingZones,
|
||||
getZoneRoute,
|
||||
getNextChoicePoint,
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function getZoneLabel(zone: string, locale: string): string {
|
||||
const labels: Record<string, { fr: string; en: string }> = {
|
||||
projects: { fr: 'Découvrir les créations', en: 'Discover the creations' },
|
||||
skills: { fr: 'Explorer les compétences', en: 'Explore the skills' },
|
||||
testimonials: { fr: 'Écouter les témoignages', en: 'Listen to testimonials' },
|
||||
journey: { fr: 'Suivre le parcours', en: 'Follow the journey' },
|
||||
}
|
||||
return labels[zone]?.[locale] || zone
|
||||
}
|
||||
|
||||
function getZoneIcon(zone: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
projects: '💻',
|
||||
skills: '⚡',
|
||||
testimonials: '💬',
|
||||
journey: '📍',
|
||||
}
|
||||
return icons[zone] || '?'
|
||||
}
|
||||
|
||||
function getZoneColor(zone: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
projects: '#3b82f6',
|
||||
skills: '#10b981',
|
||||
testimonials: '#f59e0b',
|
||||
journey: '#8b5cf6',
|
||||
}
|
||||
return colors[zone] || '#fa784f'
|
||||
}
|
||||
```
|
||||
|
||||
### Composant ZoneEndChoice
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/ZoneEndChoice.vue -->
|
||||
<script setup lang="ts">
|
||||
const { getNextChoicePoint, remainingZones } = useNarrativePath()
|
||||
const narrator = useNarrator()
|
||||
|
||||
const choicePoint = computed(() => getNextChoicePoint())
|
||||
|
||||
// Afficher un message du narrateur avant le choix
|
||||
onMounted(async () => {
|
||||
if (remainingZones.value.length > 0) {
|
||||
await narrator.showTransitionChoice()
|
||||
} else {
|
||||
await narrator.showContactReady()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="zone-end-choice py-16 px-4 border-t border-sky-dark-100 mt-16">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Message narratif -->
|
||||
<p class="font-narrative text-xl text-sky-text text-center mb-8 italic">
|
||||
{{ $t('narrative.whatNext') }}
|
||||
</p>
|
||||
|
||||
<!-- Choix -->
|
||||
<ChoiceCards
|
||||
v-if="choicePoint.choices?.length"
|
||||
:choice-point="choicePoint"
|
||||
/>
|
||||
|
||||
<!-- Si une seule option (contact) -->
|
||||
<div
|
||||
v-else-if="choicePoint.choices?.length === 1"
|
||||
class="text-center"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="choicePoint.choices[0].destination"
|
||||
class="inline-flex items-center gap-3 px-8 py-4 bg-sky-accent text-white font-ui font-semibold rounded-xl hover:bg-sky-accent/90 transition-colors"
|
||||
>
|
||||
<span class="text-2xl">{{ choicePoint.choices[0].icon }}</span>
|
||||
<span>{{ $i18n.locale === 'fr' ? choicePoint.choices[0].textFr : choicePoint.choices[0].textEn }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Schéma des chemins narratifs
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ INTRO │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ PROJETS │ │ COMPÉT. │
|
||||
└────┬─────┘ └────┬─────┘
|
||||
│ │
|
||||
┌───────┴───────┐ ┌───────┴───────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
|
||||
│TÉMOIGN.│ │PARCOURS│ │TÉMOIGN.│ │PARCOURS│
|
||||
└───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
(suite) (suite) (suite) (suite)
|
||||
│ │ │ │
|
||||
└──────┬──────┴───────────┴──────┬──────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ CONTACT │
|
||||
│ (tous les chemins y mènent) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 4.1 : ChoiceCards
|
||||
- Story 4.2 : Intro narrative (premier choix)
|
||||
- Story 3.5 : Store de progression (choices)
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 4.7 : Révélation (fin des chemins)
|
||||
- Story 4.8 : Page contact (destination finale)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── data/
|
||||
│ └── narrativePaths.ts # CRÉER
|
||||
├── composables/
|
||||
│ └── useNarrativePath.ts # CRÉER
|
||||
└── components/feature/
|
||||
└── ZoneEndChoice.vue # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/app/pages/projets.vue # AJOUTER ZoneEndChoice
|
||||
frontend/app/pages/competences.vue # AJOUTER ZoneEndChoice
|
||||
frontend/app/pages/temoignages.vue # AJOUTER ZoneEndChoice
|
||||
frontend/app/pages/parcours.vue # AJOUTER ZoneEndChoice
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-4.3]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Narrative-Paths]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Parcours-Narratifs]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Points de choix | 2-3 | Epics |
|
||||
| Parcours possibles | 4-8 | Epics |
|
||||
| Toutes zones visitables | Oui | Epics |
|
||||
| Tous chemins → contact | Oui | 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
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
# Story 4.4: Table easter_eggs et système de détection
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a développeur,
|
||||
I want une infrastructure pour gérer les easter eggs cachés,
|
||||
so that je peux ajouter des surprises récompensant l'exploration.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** les migrations Laravel sont exécutées **When** `php artisan migrate` est lancé **Then** la table `easter_eggs` est créée (id, slug, location, trigger_type ENUM, reward_type ENUM, reward_key, difficulty, is_active, timestamps)
|
||||
2. **And** les trigger_types incluent : click, hover, konami, scroll, sequence
|
||||
3. **And** les reward_types incluent : snippet, anecdote, image, badge
|
||||
4. **And** les seeders insèrent 5-10 easter eggs avec leurs récompenses traduites
|
||||
5. **Given** l'API `/api/easter-eggs` est appelée **When** la requête est faite **Then** les métadonnées des easter eggs actifs sont retournées (slug, location, trigger_type)
|
||||
6. **And** les réponses/récompenses ne sont PAS incluses (pour éviter la triche)
|
||||
7. **Given** l'API `/api/easter-eggs/{slug}/validate` est appelée **When** un slug valide est fourni **Then** la récompense traduite est retournée
|
||||
8. **And** l'easter egg est marqué comme trouvé côté client (store)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer la migration table easter_eggs** (AC: #1, #2, #3)
|
||||
- [ ] Créer migration `create_easter_eggs_table`
|
||||
- [ ] Colonnes : id, slug (unique), location, trigger_type (ENUM), reward_type (ENUM), reward_key, difficulty (1-5), is_active (boolean), timestamps
|
||||
- [ ] ENUMs pour trigger_type et reward_type
|
||||
|
||||
- [ ] **Task 2: Créer le Model EasterEgg** (AC: #1)
|
||||
- [ ] Créer `app/Models/EasterEgg.php`
|
||||
- [ ] Définir les fillable et casts
|
||||
- [ ] Scope `active()` pour les easter eggs actifs
|
||||
- [ ] Relation avec translations pour reward_key
|
||||
|
||||
- [ ] **Task 3: Créer le Seeder des easter eggs** (AC: #4)
|
||||
- [ ] Créer `database/seeders/EasterEggSeeder.php`
|
||||
- [ ] 5-10 easter eggs avec variété de triggers et récompenses
|
||||
- [ ] Ajouter les traductions FR et EN pour les récompenses
|
||||
|
||||
- [ ] **Task 4: Créer l'endpoint liste des easter eggs** (AC: #5, #6)
|
||||
- [ ] Créer `app/Http/Controllers/Api/EasterEggController.php`
|
||||
- [ ] Méthode `index()` retournant slug, location, trigger_type
|
||||
- [ ] NE PAS inclure reward_key ou détails de la récompense
|
||||
|
||||
- [ ] **Task 5: Créer l'endpoint validation** (AC: #7)
|
||||
- [ ] Méthode `validate($slug)` retournant la récompense
|
||||
- [ ] Traduire selon Accept-Language
|
||||
- [ ] Retourner 404 si slug invalide
|
||||
|
||||
- [ ] **Task 6: Créer le store côté client** (AC: #8)
|
||||
- [ ] Ajouter `easterEggsFound: string[]` dans useProgressionStore
|
||||
- [ ] Méthode `markEasterEggFound(slug)`
|
||||
- [ ] Getter `easterEggsCount` (trouvés/total)
|
||||
|
||||
- [ ] **Task 7: Créer le composable useFetchEasterEggs**
|
||||
- [ ] Créer `frontend/app/composables/useFetchEasterEggs.ts`
|
||||
- [ ] Méthode `fetchList()` pour récupérer les métadonnées
|
||||
- [ ] Méthode `validate(slug)` pour valider un easter egg trouvé
|
||||
|
||||
- [ ] **Task 8: Tests et validation**
|
||||
- [ ] Exécuter les migrations
|
||||
- [ ] Vérifier le seeding
|
||||
- [ ] Tester l'API liste (sans récompenses)
|
||||
- [ ] Tester l'API validation (avec récompenses)
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Migration easter_eggs
|
||||
|
||||
```php
|
||||
<?php
|
||||
// database/migrations/2026_02_04_000003_create_easter_eggs_table.php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('easter_eggs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('slug')->unique();
|
||||
$table->string('location'); // Page ou zone où se trouve l'easter egg
|
||||
$table->enum('trigger_type', ['click', 'hover', 'konami', 'scroll', 'sequence']);
|
||||
$table->enum('reward_type', ['snippet', 'anecdote', 'image', 'badge']);
|
||||
$table->string('reward_key'); // Clé de traduction pour la récompense
|
||||
$table->unsignedTinyInteger('difficulty')->default(1); // 1-5
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('is_active');
|
||||
$table->index('location');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('easter_eggs');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Model EasterEgg
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Models/EasterEgg.php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EasterEgg extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'location',
|
||||
'trigger_type',
|
||||
'reward_type',
|
||||
'reward_key',
|
||||
'difficulty',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'difficulty' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeByLocation($query, string $location)
|
||||
{
|
||||
return $query->where('location', $location);
|
||||
}
|
||||
|
||||
public function getReward(string $lang = 'fr'): ?string
|
||||
{
|
||||
return Translation::getTranslation($this->reward_key, $lang);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Seeder des easter eggs
|
||||
|
||||
```php
|
||||
<?php
|
||||
// database/seeders/EasterEggSeeder.php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\EasterEgg;
|
||||
use App\Models\Translation;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class EasterEggSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$easterEggs = [
|
||||
// 1. Konami code sur la landing
|
||||
[
|
||||
'slug' => 'konami-master',
|
||||
'location' => 'landing',
|
||||
'trigger_type' => 'konami',
|
||||
'reward_type' => 'badge',
|
||||
'reward_key' => 'easter.konami.reward',
|
||||
'difficulty' => 3,
|
||||
],
|
||||
// 2. Clic sur l'araignée cachée (header)
|
||||
[
|
||||
'slug' => 'hidden-spider',
|
||||
'location' => 'header',
|
||||
'trigger_type' => 'click',
|
||||
'reward_type' => 'anecdote',
|
||||
'reward_key' => 'easter.spider.reward',
|
||||
'difficulty' => 2,
|
||||
],
|
||||
// 3. Hover sur un caractère spécial dans le code (page projets)
|
||||
[
|
||||
'slug' => 'secret-comment',
|
||||
'location' => 'projects',
|
||||
'trigger_type' => 'hover',
|
||||
'reward_type' => 'snippet',
|
||||
'reward_key' => 'easter.comment.reward',
|
||||
'difficulty' => 2,
|
||||
],
|
||||
// 4. Scroll jusqu'en bas de la page parcours
|
||||
[
|
||||
'slug' => 'journey-end',
|
||||
'location' => 'journey',
|
||||
'trigger_type' => 'scroll',
|
||||
'reward_type' => 'anecdote',
|
||||
'reward_key' => 'easter.journey_end.reward',
|
||||
'difficulty' => 1,
|
||||
],
|
||||
// 5. Séquence de clics sur les compétences (Vue, Laravel, TypeScript)
|
||||
[
|
||||
'slug' => 'tech-sequence',
|
||||
'location' => 'skills',
|
||||
'trigger_type' => 'sequence',
|
||||
'reward_type' => 'snippet',
|
||||
'reward_key' => 'easter.tech_seq.reward',
|
||||
'difficulty' => 4,
|
||||
],
|
||||
// 6. Clic sur le logo Skycel 5 fois
|
||||
[
|
||||
'slug' => 'logo-clicks',
|
||||
'location' => 'global',
|
||||
'trigger_type' => 'click',
|
||||
'reward_type' => 'image',
|
||||
'reward_key' => 'easter.logo.reward',
|
||||
'difficulty' => 2,
|
||||
],
|
||||
// 7. Hover sur la date "2022" dans le parcours
|
||||
[
|
||||
'slug' => 'founding-date',
|
||||
'location' => 'journey',
|
||||
'trigger_type' => 'hover',
|
||||
'reward_type' => 'anecdote',
|
||||
'reward_key' => 'easter.founding.reward',
|
||||
'difficulty' => 2,
|
||||
],
|
||||
// 8. Console.log dans les devtools
|
||||
[
|
||||
'slug' => 'dev-console',
|
||||
'location' => 'global',
|
||||
'trigger_type' => 'sequence',
|
||||
'reward_type' => 'badge',
|
||||
'reward_key' => 'easter.console.reward',
|
||||
'difficulty' => 3,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($easterEggs as $egg) {
|
||||
EasterEgg::create($egg);
|
||||
}
|
||||
|
||||
// Traductions des récompenses
|
||||
$translations = [
|
||||
// Konami
|
||||
['key' => 'easter.konami.reward', 'fr' => "🎮 Badge 'Gamer' débloqué ! Tu connais les classiques.", 'en' => "🎮 'Gamer' badge unlocked! You know the classics."],
|
||||
|
||||
// Spider
|
||||
['key' => 'easter.spider.reward', 'fr' => "🕷️ Tu m'as trouvé ! Je me cache partout sur ce site... Le Bug te surveille toujours.", 'en' => "🕷️ You found me! I hide everywhere on this site... The Bug is always watching."],
|
||||
|
||||
// Comment
|
||||
['key' => 'easter.comment.reward', 'fr' => "// Premier code écrit : console.log('Hello World'); // Tout a commencé là...", 'en' => "// First code written: console.log('Hello World'); // It all started there..."],
|
||||
|
||||
// Journey end
|
||||
['key' => 'easter.journey_end.reward', 'fr' => "Tu as lu jusqu'au bout ? Respect. Le prochain chapitre s'écrit peut-être avec toi.", 'en' => "You read all the way? Respect. The next chapter might be written with you."],
|
||||
|
||||
// Tech sequence
|
||||
['key' => 'easter.tech_seq.reward', 'fr' => "const stack = ['Vue', 'Laravel', 'TypeScript'];\n// La sainte trinité du dev moderne ⚡", 'en' => "const stack = ['Vue', 'Laravel', 'TypeScript'];\n// The holy trinity of modern dev ⚡"],
|
||||
|
||||
// Logo
|
||||
['key' => 'easter.logo.reward', 'fr' => "🖼️ Image secrète débloquée : La première version du logo Skycel (spoiler: c'était moche)", 'en' => "🖼️ Secret image unlocked: The first version of the Skycel logo (spoiler: it was ugly)"],
|
||||
|
||||
// Founding
|
||||
['key' => 'easter.founding.reward', 'fr' => "2022 : l'année où Le Bug est né. Littéralement un bug dans le code qui m'a donné l'idée de la mascotte.", 'en' => "2022: the year The Bug was born. Literally a bug in the code that gave me the mascot idea."],
|
||||
|
||||
// Console
|
||||
['key' => 'easter.console.reward', 'fr' => "🔧 Badge 'Développeur' débloqué ! Tu as vérifié la console comme un vrai dev.", 'en' => "🔧 'Developer' badge unlocked! You checked the console like a real dev."],
|
||||
];
|
||||
|
||||
foreach ($translations as $t) {
|
||||
Translation::firstOrCreate(
|
||||
['lang' => 'fr', 'key_name' => $t['key']],
|
||||
['value' => $t['fr']]
|
||||
);
|
||||
Translation::firstOrCreate(
|
||||
['lang' => 'en', 'key_name' => $t['key']],
|
||||
['value' => $t['en']]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Controller API
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Controllers/Api/EasterEggController.php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EasterEgg;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EasterEggController extends Controller
|
||||
{
|
||||
/**
|
||||
* Liste les easter eggs actifs (sans révéler les récompenses)
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$easterEggs = EasterEgg::active()
|
||||
->select('slug', 'location', 'trigger_type', 'difficulty')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'data' => $easterEggs,
|
||||
'meta' => [
|
||||
'total' => $easterEggs->count(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide un easter egg et retourne la récompense
|
||||
*/
|
||||
public function validate(Request $request, string $slug)
|
||||
{
|
||||
$easterEgg = EasterEgg::active()->where('slug', $slug)->first();
|
||||
|
||||
if (!$easterEgg) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'EASTER_EGG_NOT_FOUND',
|
||||
'message' => 'Easter egg not found or inactive',
|
||||
],
|
||||
], 404);
|
||||
}
|
||||
|
||||
$lang = $request->header('Accept-Language', 'fr');
|
||||
$reward = $easterEgg->getReward($lang);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'slug' => $easterEgg->slug,
|
||||
'reward_type' => $easterEgg->reward_type,
|
||||
'reward' => $reward,
|
||||
'difficulty' => $easterEgg->difficulty,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Routes API
|
||||
|
||||
```php
|
||||
// api/routes/api.php
|
||||
Route::get('/easter-eggs', [EasterEggController::class, 'index']);
|
||||
Route::post('/easter-eggs/{slug}/validate', [EasterEggController::class, 'validate']);
|
||||
```
|
||||
|
||||
### Extension du store progression
|
||||
|
||||
```typescript
|
||||
// À ajouter dans frontend/app/stores/progression.ts
|
||||
|
||||
// État
|
||||
const easterEggsFound = ref<string[]>([])
|
||||
|
||||
// Actions
|
||||
function markEasterEggFound(slug: string) {
|
||||
if (!easterEggsFound.value.includes(slug)) {
|
||||
easterEggsFound.value.push(slug)
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
const easterEggsFoundCount = computed(() => easterEggsFound.value.length)
|
||||
|
||||
// Export
|
||||
return {
|
||||
// ... existing ...
|
||||
easterEggsFound,
|
||||
easterEggsFoundCount,
|
||||
markEasterEggFound,
|
||||
}
|
||||
```
|
||||
|
||||
### Composable useFetchEasterEggs
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useFetchEasterEggs.ts
|
||||
interface EasterEggMeta {
|
||||
slug: string
|
||||
location: string
|
||||
trigger_type: 'click' | 'hover' | 'konami' | 'scroll' | 'sequence'
|
||||
difficulty: number
|
||||
}
|
||||
|
||||
interface EasterEggReward {
|
||||
slug: string
|
||||
reward_type: 'snippet' | 'anecdote' | 'image' | 'badge'
|
||||
reward: string
|
||||
difficulty: number
|
||||
}
|
||||
|
||||
export function useFetchEasterEggs() {
|
||||
const config = useRuntimeConfig()
|
||||
const { locale } = useI18n()
|
||||
|
||||
// Cache des easter eggs disponibles
|
||||
const availableEasterEggs = ref<EasterEggMeta[]>([])
|
||||
const isLoaded = ref(false)
|
||||
|
||||
async function fetchList(): Promise<EasterEggMeta[]> {
|
||||
if (isLoaded.value) return availableEasterEggs.value
|
||||
|
||||
const response = await $fetch<{ data: EasterEggMeta[] }>('/easter-eggs', {
|
||||
baseURL: config.public.apiUrl,
|
||||
headers: {
|
||||
'X-API-Key': config.public.apiKey,
|
||||
},
|
||||
})
|
||||
|
||||
availableEasterEggs.value = response.data
|
||||
isLoaded.value = true
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function validate(slug: string): Promise<EasterEggReward | null> {
|
||||
try {
|
||||
const response = await $fetch<{ data: EasterEggReward }>(`/easter-eggs/${slug}/validate`, {
|
||||
method: 'POST',
|
||||
baseURL: config.public.apiUrl,
|
||||
headers: {
|
||||
'X-API-Key': config.public.apiKey,
|
||||
'Accept-Language': locale.value,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to validate easter egg:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getByLocation(location: string): EasterEggMeta[] {
|
||||
return availableEasterEggs.value.filter(e => e.location === location || e.location === 'global')
|
||||
}
|
||||
|
||||
return {
|
||||
availableEasterEggs,
|
||||
fetchList,
|
||||
validate,
|
||||
getByLocation,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tableau des easter eggs
|
||||
|
||||
| Slug | Location | Trigger | Type | Difficulté |
|
||||
|------|----------|---------|------|------------|
|
||||
| konami-master | landing | konami | badge | 3/5 |
|
||||
| hidden-spider | header | click | anecdote | 2/5 |
|
||||
| secret-comment | projects | hover | snippet | 2/5 |
|
||||
| journey-end | journey | scroll | anecdote | 1/5 |
|
||||
| tech-sequence | skills | sequence | snippet | 4/5 |
|
||||
| logo-clicks | global | click | image | 2/5 |
|
||||
| founding-date | journey | hover | anecdote | 2/5 |
|
||||
| dev-console | global | sequence | badge | 3/5 |
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 1.2 : Table translations
|
||||
- Story 3.5 : Store de progression
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 4.5 : Implémentation UI des easter eggs
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
api/
|
||||
├── app/Models/
|
||||
│ └── EasterEgg.php # CRÉER
|
||||
├── app/Http/Controllers/Api/
|
||||
│ └── EasterEggController.php # CRÉER
|
||||
└── database/
|
||||
├── migrations/
|
||||
│ └── 2026_02_04_000003_create_easter_eggs_table.php # CRÉER
|
||||
└── seeders/
|
||||
└── EasterEggSeeder.php # CRÉER
|
||||
|
||||
frontend/app/composables/
|
||||
└── useFetchEasterEggs.ts # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
api/routes/api.php # AJOUTER routes easter-eggs
|
||||
api/database/seeders/DatabaseSeeder.php # APPELER EasterEggSeeder
|
||||
frontend/app/stores/progression.ts # AJOUTER easterEggsFound
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-4.4]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Easter-Eggs]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Easter-Eggs]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Nombre d'easter eggs | 5-10 | Epics |
|
||||
| Trigger types | click, hover, konami, scroll, sequence | Epics |
|
||||
| Reward types | snippet, anecdote, image, badge | Epics |
|
||||
| API sans spoil | Liste sans récompenses | 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
# 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
|
||||
|
||||
493
docs/implementation-artifacts/4-7-revelation-monde-de-code.md
Normal file
493
docs/implementation-artifacts/4-7-revelation-monde-de-code.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# Story 4.7: Révélation "Monde de Code"
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur ayant complété le parcours,
|
||||
I want vivre un moment waouh de révélation finale,
|
||||
so that la découverte du développeur est mémorable.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur accède à la zone Contact (après challenge ou skip) **When** la révélation se déclenche **Then** une transition immersive mène vers le "Monde de Code"
|
||||
2. **And** un paysage composé de blocs de code ASCII art s'affiche (montagnes, arbres, ville en code)
|
||||
3. **And** le code scroll/apparaît progressivement (animation)
|
||||
4. **And** l'avatar illustré de Célian est révélé au centre du monde de code
|
||||
5. **And** le narrateur (Le Bug) commente : "Tu l'as trouvé !"
|
||||
6. **And** le message "Tu m'as trouvé !" s'affiche avec effet de célébration
|
||||
7. **And** sur mobile, une version allégée mais émotionnellement équivalente s'affiche
|
||||
8. **And** `prefers-reduced-motion` affiche une version statique
|
||||
9. **And** une description alternative est disponible pour les screen readers
|
||||
10. **And** un bouton permet de continuer vers le formulaire de contact
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer la page révélation** (AC: #1, #10)
|
||||
- [ ] Créer `frontend/app/pages/revelation.vue`
|
||||
- [ ] Vérifier que le contact est débloqué
|
||||
- [ ] Structure en phases : transition → monde de code → avatar → message
|
||||
|
||||
- [ ] **Task 2: Créer le composant CodeWorld** (AC: #2, #3)
|
||||
- [ ] Créer `frontend/app/components/feature/CodeWorld.vue`
|
||||
- [ ] ASCII art représentant un paysage (montagnes, arbres, soleil)
|
||||
- [ ] Animation de révélation ligne par ligne
|
||||
- [ ] Couleurs syntaxiques (comme du code)
|
||||
|
||||
- [ ] **Task 3: Créer l'ASCII art du paysage**
|
||||
- [ ] Montagnes en caractères (`/\`, `^`, etc.)
|
||||
- [ ] Arbres stylisés (`{}`, `[]`)
|
||||
- [ ] Soleil ou étoiles
|
||||
- [ ] Personnage au centre
|
||||
|
||||
- [ ] **Task 4: Révéler l'avatar de Célian** (AC: #4)
|
||||
- [ ] Image illustrée de Célian
|
||||
- [ ] Animation d'apparition (fade + scale)
|
||||
- [ ] Position centrale sur le monde de code
|
||||
|
||||
- [ ] **Task 5: Message du narrateur** (AC: #5)
|
||||
- [ ] Le Bug s'exclame "Tu l'as trouvé !"
|
||||
- [ ] Utiliser NarratorBubble ou message intégré
|
||||
- [ ] Ton enthousiaste et célébratoire
|
||||
|
||||
- [ ] **Task 6: Message de Célian** (AC: #6)
|
||||
- [ ] "Tu m'as trouvé !" avec effet typewriter
|
||||
- [ ] Animation de célébration autour
|
||||
- [ ] Signature de Célian
|
||||
|
||||
- [ ] **Task 7: Version mobile** (AC: #7)
|
||||
- [ ] ASCII art simplifié ou image de remplacement
|
||||
- [ ] Mêmes éléments clés : avatar, message, émotion
|
||||
- [ ] Performance optimisée
|
||||
|
||||
- [ ] **Task 8: Accessibilité** (AC: #8, #9)
|
||||
- [ ] Respecter prefers-reduced-motion (version statique)
|
||||
- [ ] Description alternative pour screen readers
|
||||
- [ ] aria-label descriptif
|
||||
|
||||
- [ ] **Task 9: Tests et validation**
|
||||
- [ ] Tester l'animation complète
|
||||
- [ ] Vérifier la version mobile
|
||||
- [ ] Tester prefers-reduced-motion
|
||||
- [ ] Valider l'accessibilité
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### ASCII Art du Monde de Code
|
||||
|
||||
```
|
||||
* . *
|
||||
* . . *
|
||||
. ___ .
|
||||
* . / \ *
|
||||
. / ^ \ . *
|
||||
* / /^\ \ *
|
||||
. /____/ \____\ .
|
||||
* | | | | *
|
||||
. | | | | .
|
||||
_______| |_____| |_______
|
||||
/ | | | | \
|
||||
{ Vue }| TS |{PHP}| DB |{Nuxt}
|
||||
\_______________________/
|
||||
|| || ||
|
||||
{ } { } { }
|
||||
|| || ||
|
||||
___||_____||_____||___
|
||||
| YOU |
|
||||
| FOUND ME! 🎉 |
|
||||
|_____________________|
|
||||
```
|
||||
|
||||
### Page revelation.vue
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/revelation.vue -->
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const progressionStore = useProgressionStore()
|
||||
const narrator = useNarrator()
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
// Vérifier que le contact est débloqué
|
||||
if (!progressionStore.contactUnlocked) {
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
// Phases de la révélation
|
||||
type Phase = 'transition' | 'codeworld' | 'avatar' | 'message' | 'complete'
|
||||
const currentPhase = ref<Phase>('transition')
|
||||
|
||||
// Progression des phases
|
||||
async function advancePhase() {
|
||||
const phases: Phase[] = ['transition', 'codeworld', 'avatar', 'message', 'complete']
|
||||
const currentIndex = phases.indexOf(currentPhase.value)
|
||||
|
||||
if (currentIndex < phases.length - 1) {
|
||||
currentPhase.value = phases[currentIndex + 1]
|
||||
|
||||
// Actions spécifiques par phase
|
||||
if (currentPhase.value === 'avatar') {
|
||||
await narrator.showMessage('revelation_found')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Démarrer la séquence
|
||||
onMounted(() => {
|
||||
if (reducedMotion.value) {
|
||||
// Version statique : aller directement à complete
|
||||
currentPhase.value = 'complete'
|
||||
} else {
|
||||
// Animation : transition vers codeworld après 1.5s
|
||||
setTimeout(() => {
|
||||
advancePhase()
|
||||
}, 1500)
|
||||
}
|
||||
})
|
||||
|
||||
function goToContact() {
|
||||
router.push('/contact')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="revelation-page min-h-screen bg-sky-dark overflow-hidden">
|
||||
<!-- Screen reader description -->
|
||||
<p class="sr-only">
|
||||
{{ t('revelation.srDescription') }}
|
||||
</p>
|
||||
|
||||
<!-- Phase : Transition -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="currentPhase === 'transition'"
|
||||
class="fixed inset-0 flex items-center justify-center bg-black z-50"
|
||||
>
|
||||
<p class="font-narrative text-2xl text-sky-text animate-pulse">
|
||||
{{ t('revelation.transition') }}
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Phase : Code World -->
|
||||
<div
|
||||
v-show="currentPhase !== 'transition'"
|
||||
class="relative min-h-screen flex flex-col items-center justify-center p-4"
|
||||
>
|
||||
<!-- ASCII Code World -->
|
||||
<CodeWorld
|
||||
:animate="currentPhase === 'codeworld'"
|
||||
@complete="advancePhase"
|
||||
class="mb-8"
|
||||
/>
|
||||
|
||||
<!-- Avatar de Célian -->
|
||||
<Transition name="scale-fade">
|
||||
<div
|
||||
v-if="['avatar', 'message', 'complete'].includes(currentPhase)"
|
||||
class="relative"
|
||||
>
|
||||
<img
|
||||
src="/images/avatar-celian.svg"
|
||||
alt="Célian"
|
||||
class="w-32 h-32 md:w-48 md:h-48 rounded-full border-4 border-sky-accent shadow-2xl shadow-sky-accent/30"
|
||||
/>
|
||||
|
||||
<!-- Sparkles autour -->
|
||||
<div class="absolute inset-0 -m-4">
|
||||
<span
|
||||
v-for="i in 8"
|
||||
:key="i"
|
||||
class="absolute text-xl animate-pulse"
|
||||
:style="{
|
||||
top: `${50 + 45 * Math.sin(i * Math.PI / 4)}%`,
|
||||
left: `${50 + 45 * Math.cos(i * Math.PI / 4)}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
animationDelay: `${i * 100}ms`,
|
||||
}"
|
||||
>
|
||||
✨
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Message "Tu m'as trouvé !" -->
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="['message', 'complete'].includes(currentPhase)"
|
||||
class="mt-8 text-center"
|
||||
>
|
||||
<h1 class="text-4xl md:text-5xl font-ui font-bold text-sky-accent mb-4">
|
||||
{{ t('revelation.foundMe') }}
|
||||
</h1>
|
||||
|
||||
<p class="font-narrative text-xl text-sky-text mb-2">
|
||||
{{ t('revelation.greeting') }}
|
||||
</p>
|
||||
|
||||
<p class="font-ui text-sky-text-muted">
|
||||
— Célian, {{ t('revelation.title') }}
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Bouton continuer -->
|
||||
<Transition name="fade">
|
||||
<button
|
||||
v-if="currentPhase === 'complete'"
|
||||
type="button"
|
||||
class="mt-12 px-8 py-4 bg-sky-accent text-white font-ui font-semibold rounded-xl hover:bg-sky-accent/90 transition-colors shadow-lg shadow-sky-accent/30"
|
||||
@click="goToContact"
|
||||
>
|
||||
{{ t('revelation.contactMe') }}
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Version reduced-motion -->
|
||||
<div
|
||||
v-if="reducedMotion && currentPhase === 'complete'"
|
||||
class="fixed inset-0 flex flex-col items-center justify-center p-8 bg-sky-dark"
|
||||
>
|
||||
<img
|
||||
src="/images/avatar-celian.svg"
|
||||
alt="Célian"
|
||||
class="w-32 h-32 rounded-full border-4 border-sky-accent mb-8"
|
||||
/>
|
||||
|
||||
<h1 class="text-3xl font-ui font-bold text-sky-accent mb-4">
|
||||
{{ t('revelation.foundMe') }}
|
||||
</h1>
|
||||
|
||||
<p class="font-narrative text-lg text-sky-text text-center mb-8">
|
||||
{{ t('revelation.greeting') }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="px-8 py-4 bg-sky-accent text-white font-ui font-semibold rounded-xl"
|
||||
@click="goToContact"
|
||||
>
|
||||
{{ t('revelation.contactMe') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scale-fade-enter-active,
|
||||
.scale-fade-leave-active {
|
||||
transition: all 0.8s ease;
|
||||
}
|
||||
|
||||
.scale-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.6s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Composant CodeWorld
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/CodeWorld.vue -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
animate: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
// ASCII Art du monde de code
|
||||
const asciiArt = `
|
||||
* . * . *
|
||||
* . . * .
|
||||
. ___ .
|
||||
. / \\ *
|
||||
* / ^ \\ . *
|
||||
/ /^\\ \\ *
|
||||
/____/ \\____\\ .
|
||||
* | | | | *
|
||||
| | | | .
|
||||
____| |_____| |_______
|
||||
| | | |
|
||||
{Vue}| TS |{PHP}| DB |{Nuxt}
|
||||
____________________________
|
||||
|| || ||
|
||||
{ } { } { }
|
||||
|| || ||
|
||||
`.trim()
|
||||
|
||||
const lines = asciiArt.split('\n')
|
||||
const visibleLines = ref(reducedMotion.value ? lines.length : 0)
|
||||
|
||||
// Animation ligne par ligne
|
||||
watch(() => props.animate, (shouldAnimate) => {
|
||||
if (shouldAnimate && !reducedMotion.value) {
|
||||
animateLines()
|
||||
}
|
||||
})
|
||||
|
||||
function animateLines() {
|
||||
const interval = setInterval(() => {
|
||||
if (visibleLines.value < lines.length) {
|
||||
visibleLines.value++
|
||||
} else {
|
||||
clearInterval(interval)
|
||||
setTimeout(() => {
|
||||
emit('complete')
|
||||
}, 500)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// Coloration syntaxique simple
|
||||
function colorize(line: string): string {
|
||||
return line
|
||||
.replace(/{(\w+)}/g, '<span class="text-green-400">{$1}</span>')
|
||||
.replace(/\|/g, '<span class="text-sky-accent">|</span>')
|
||||
.replace(/\*/g, '<span class="text-yellow-400">*</span>')
|
||||
.replace(/\./g, '<span class="text-blue-400">.</span>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="code-world font-mono text-xs md:text-sm text-sky-text-muted leading-tight"
|
||||
role="img"
|
||||
:aria-label="$t('revelation.codeWorldAlt')"
|
||||
>
|
||||
<pre class="overflow-hidden"><code><template v-for="(line, index) in lines" :key="index"><span
|
||||
v-if="index < visibleLines"
|
||||
v-html="colorize(line)"
|
||||
class="block"
|
||||
></span></template></code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.code-world {
|
||||
text-shadow: 0 0 10px rgba(250, 120, 79, 0.3);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Clés i18n
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"revelation": {
|
||||
"transition": "Le voilà...",
|
||||
"foundMe": "Tu m'as trouvé !",
|
||||
"greeting": "Bienvenue dans mon monde de code. Je suis Célian, le développeur que tu cherchais depuis le début.",
|
||||
"title": "Développeur Web Fullstack",
|
||||
"contactMe": "Me contacter",
|
||||
"codeWorldAlt": "Un paysage stylisé composé de caractères de code, représentant l'univers du développeur",
|
||||
"srDescription": "Vous avez découvert le développeur ! Célian vous accueille dans son monde de code."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"revelation": {
|
||||
"transition": "There he is...",
|
||||
"foundMe": "You found me!",
|
||||
"greeting": "Welcome to my world of code. I'm Célian, the developer you've been looking for all along.",
|
||||
"title": "Fullstack Web Developer",
|
||||
"contactMe": "Contact me",
|
||||
"codeWorldAlt": "A stylized landscape made of code characters, representing the developer's universe",
|
||||
"srDescription": "You discovered the developer! Célian welcomes you to his world of code."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 3.5 : Store de progression (contactUnlocked)
|
||||
- Story 3.2 : useReducedMotion
|
||||
- Story 3.3 : useNarrator (révélation)
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 4.8 : Page contact (destination finale)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── pages/
|
||||
│ └── revelation.vue # CRÉER
|
||||
├── components/feature/
|
||||
│ └── CodeWorld.vue # CRÉER
|
||||
└── public/images/
|
||||
└── avatar-celian.svg # CRÉER (asset)
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/i18n/fr.json # AJOUTER revelation.*
|
||||
frontend/i18n/en.json # AJOUTER revelation.*
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-4.7]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Revelation]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Revelation]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| ASCII Art | Paysage stylisé | Epics |
|
||||
| Avatar | Image de Célian | Epics |
|
||||
| Message | "Tu m'as trouvé !" | Epics |
|
||||
| Accessibilité | prefers-reduced-motion, aria | 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
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
# Story 4.8: Page Contact - Formulaire et célébration
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur ayant trouvé le développeur,
|
||||
I want le contacter facilement avec une célébration,
|
||||
so that l'envoi du message est une conclusion satisfaisante.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur est sur la page Contact après la révélation **When** la page s'affiche **Then** un message de félicitations avec stats du parcours est visible (zones visitées, easter eggs trouvés, temps passé)
|
||||
2. **And** un formulaire de contact s'affiche : nom (requis), email (requis), message (requis)
|
||||
3. **And** la validation temps réel est effectuée côté frontend (champs requis, format email)
|
||||
4. **And** les erreurs sont communiquées par le narrateur (pas de messages d'erreur classiques)
|
||||
5. **And** un champ honeypot invisible est présent (anti-spam)
|
||||
6. **And** reCAPTCHA v3 est intégré de manière invisible
|
||||
7. **And** le bouton d'envoi utilise la couleur accent (`sky-accent`)
|
||||
8. **Given** le formulaire est soumis **When** les données sont envoyées à l'API **Then** la validation backend Laravel (Form Request) vérifie les données
|
||||
9. **And** le rate limiting (5 req/min par IP) est appliqué
|
||||
10. **And** l'email est envoyé via Laravel Mail
|
||||
11. **And** une animation de célébration s'affiche (confettis ou similaire)
|
||||
12. **And** le narrateur confirme l'envoi avec un message personnalisé
|
||||
13. **And** en cas d'erreur, le narrateur explique le problème avec bienveillance
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer la page contact** (AC: #1, #2)
|
||||
- [ ] Créer `frontend/app/pages/contact.vue`
|
||||
- [ ] Afficher les stats du parcours (zones, easter eggs, temps)
|
||||
- [ ] Formulaire avec nom, email, message
|
||||
|
||||
- [ ] **Task 2: Implémenter la validation frontend** (AC: #3, #4)
|
||||
- [ ] Validation en temps réel avec Vuelidate ou Vee-Validate
|
||||
- [ ] Format email valide
|
||||
- [ ] Champs requis
|
||||
- [ ] Erreurs via le narrateur (pas de messages classiques)
|
||||
|
||||
- [ ] **Task 3: Ajouter les protections anti-spam** (AC: #5, #6)
|
||||
- [ ] Champ honeypot invisible
|
||||
- [ ] Intégrer reCAPTCHA v3 (invisible)
|
||||
- [ ] Obtenir token reCAPTCHA avant envoi
|
||||
|
||||
- [ ] **Task 4: Créer l'API de contact** (AC: #8, #9, #10)
|
||||
- [ ] Créer `app/Http/Controllers/Api/ContactController.php`
|
||||
- [ ] Form Request pour validation backend
|
||||
- [ ] Rate limiting : 5 requêtes/min par IP
|
||||
- [ ] Envoi email via Laravel Mail
|
||||
- [ ] Vérification reCAPTCHA côté serveur
|
||||
|
||||
- [ ] **Task 5: Créer le template d'email**
|
||||
- [ ] Template Blade pour l'email de contact
|
||||
- [ ] Inclure : nom, email, message
|
||||
- [ ] Design sobre et professionnel
|
||||
|
||||
- [ ] **Task 6: Animation de succès** (AC: #11, #12)
|
||||
- [ ] Confettis après envoi réussi
|
||||
- [ ] Message du narrateur confirmant l'envoi
|
||||
- [ ] Transition vers le challenge post-formulaire
|
||||
|
||||
- [ ] **Task 7: Gestion des erreurs** (AC: #13)
|
||||
- [ ] Erreur réseau : narrateur explique
|
||||
- [ ] Rate limit : narrateur demande de patienter
|
||||
- [ ] reCAPTCHA : narrateur suggère de réessayer
|
||||
|
||||
- [ ] **Task 8: Tests et validation**
|
||||
- [ ] Tester la validation frontend
|
||||
- [ ] Tester l'envoi complet (API + email)
|
||||
- [ ] Vérifier le rate limiting
|
||||
- [ ] Tester le honeypot
|
||||
- [ ] Valider reCAPTCHA
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Page contact.vue
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/contact.vue -->
|
||||
<script setup lang="ts">
|
||||
import confetti from 'canvas-confetti'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { required, email as emailValidator } from '@vuelidate/validators'
|
||||
|
||||
const { t } = useI18n()
|
||||
const config = useRuntimeConfig()
|
||||
const router = useRouter()
|
||||
const progressionStore = useProgressionStore()
|
||||
const narrator = useNarrator()
|
||||
|
||||
// Stats du parcours
|
||||
const stats = computed(() => ({
|
||||
zonesVisited: progressionStore.visitedSections.length,
|
||||
zonesTotal: 4,
|
||||
easterEggsFound: progressionStore.easterEggsFoundCount,
|
||||
easterEggsTotal: 8,
|
||||
challengeCompleted: progressionStore.challengeCompleted,
|
||||
}))
|
||||
|
||||
// Formulaire
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
message: '',
|
||||
honeypot: '', // Champ honeypot
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: { required },
|
||||
email: { required, email: emailValidator },
|
||||
message: { required },
|
||||
}
|
||||
|
||||
const v$ = useVuelidate(rules, form)
|
||||
|
||||
// États
|
||||
const isSubmitting = ref(false)
|
||||
const isSuccess = ref(false)
|
||||
|
||||
// Récupérer le token reCAPTCHA
|
||||
async function getRecaptchaToken(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
window.grecaptcha.ready(() => {
|
||||
window.grecaptcha.execute(config.public.recaptchaSiteKey, { action: 'contact' })
|
||||
.then(resolve)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Soumission du formulaire
|
||||
async function handleSubmit() {
|
||||
const isValid = await v$.value.$validate()
|
||||
|
||||
if (!isValid) {
|
||||
// Erreurs communiquées par le narrateur
|
||||
narrator.showMessage('contact_validation_error')
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier le honeypot
|
||||
if (form.honeypot) {
|
||||
// C'est un bot, faire semblant de réussir
|
||||
fakeSuccess()
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
const recaptchaToken = await getRecaptchaToken()
|
||||
|
||||
await $fetch('/contact', {
|
||||
method: 'POST',
|
||||
baseURL: config.public.apiUrl,
|
||||
headers: {
|
||||
'X-API-Key': config.public.apiKey,
|
||||
},
|
||||
body: {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
message: form.message,
|
||||
recaptcha_token: recaptchaToken,
|
||||
},
|
||||
})
|
||||
|
||||
// Succès !
|
||||
isSuccess.value = true
|
||||
launchConfetti()
|
||||
narrator.showMessage('contact_success')
|
||||
|
||||
// Naviguer vers le challenge post-formulaire après délai
|
||||
setTimeout(() => {
|
||||
router.push('/challenge-bonus')
|
||||
}, 5000)
|
||||
|
||||
} catch (error: any) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(error: any) {
|
||||
const status = error.response?.status
|
||||
|
||||
if (status === 429) {
|
||||
narrator.showMessage('contact_rate_limited')
|
||||
} else if (status === 422) {
|
||||
narrator.showMessage('contact_validation_error')
|
||||
} else {
|
||||
narrator.showMessage('contact_error')
|
||||
}
|
||||
}
|
||||
|
||||
function fakeSuccess() {
|
||||
isSuccess.value = true
|
||||
launchConfetti()
|
||||
}
|
||||
|
||||
function launchConfetti() {
|
||||
confetti({
|
||||
particleCount: 150,
|
||||
spread: 100,
|
||||
origin: { y: 0.6 },
|
||||
colors: ['#fa784f', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6'],
|
||||
})
|
||||
}
|
||||
|
||||
// Message du narrateur au montage
|
||||
onMounted(() => {
|
||||
narrator.showMessage('contact_welcome')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contact-page min-h-screen bg-sky-dark py-12 px-4">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Stats du parcours -->
|
||||
<div class="bg-sky-dark-50 rounded-xl p-6 mb-8 border border-sky-dark-100">
|
||||
<h2 class="text-lg font-ui font-bold text-sky-text mb-4">
|
||||
{{ t('contact.yourJourney') }}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<p class="text-2xl font-ui font-bold text-sky-accent">
|
||||
{{ stats.zonesVisited }}/{{ stats.zonesTotal }}
|
||||
</p>
|
||||
<p class="text-sm text-sky-text-muted">{{ t('contact.zones') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-ui font-bold text-sky-accent">
|
||||
{{ stats.easterEggsFound }}/{{ stats.easterEggsTotal }}
|
||||
</p>
|
||||
<p class="text-sm text-sky-text-muted">{{ t('contact.easterEggs') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-ui font-bold" :class="stats.challengeCompleted ? 'text-green-400' : 'text-sky-text-muted'">
|
||||
{{ stats.challengeCompleted ? '✓' : '—' }}
|
||||
</p>
|
||||
<p class="text-sm text-sky-text-muted">{{ t('contact.challenge') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-ui font-bold text-sky-accent">🏆</p>
|
||||
<p class="text-sm text-sky-text-muted">{{ t('contact.explorer') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Titre -->
|
||||
<h1 class="text-3xl font-ui font-bold text-sky-text text-center mb-2">
|
||||
{{ t('contact.title') }}
|
||||
</h1>
|
||||
|
||||
<p class="text-sky-text-muted text-center mb-8 font-narrative">
|
||||
{{ t('contact.subtitle') }}
|
||||
</p>
|
||||
|
||||
<!-- Formulaire ou message de succès -->
|
||||
<Transition name="fade" mode="out-in">
|
||||
<!-- Formulaire -->
|
||||
<form
|
||||
v-if="!isSuccess"
|
||||
class="space-y-6"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- Honeypot (invisible) -->
|
||||
<input
|
||||
v-model="form.honeypot"
|
||||
type="text"
|
||||
name="website"
|
||||
class="hidden"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<!-- Nom -->
|
||||
<div>
|
||||
<label class="block text-sm font-ui font-medium text-sky-text mb-2">
|
||||
{{ t('contact.name') }} *
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="w-full px-4 py-3 bg-sky-dark border rounded-lg text-sky-text placeholder-sky-text-muted focus:outline-none focus:ring-2 focus:ring-sky-accent"
|
||||
:class="v$.name.$error ? 'border-red-500' : 'border-sky-dark-100'"
|
||||
:placeholder="t('contact.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label class="block text-sm font-ui font-medium text-sky-text mb-2">
|
||||
{{ t('contact.email') }} *
|
||||
</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="w-full px-4 py-3 bg-sky-dark border rounded-lg text-sky-text placeholder-sky-text-muted focus:outline-none focus:ring-2 focus:ring-sky-accent"
|
||||
:class="v$.email.$error ? 'border-red-500' : 'border-sky-dark-100'"
|
||||
:placeholder="t('contact.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<div>
|
||||
<label class="block text-sm font-ui font-medium text-sky-text mb-2">
|
||||
{{ t('contact.message') }} *
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.message"
|
||||
rows="5"
|
||||
class="w-full px-4 py-3 bg-sky-dark border rounded-lg text-sky-text placeholder-sky-text-muted focus:outline-none focus:ring-2 focus:ring-sky-accent resize-none"
|
||||
:class="v$.message.$error ? 'border-red-500' : 'border-sky-dark-100'"
|
||||
:placeholder="t('contact.messagePlaceholder')"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Bouton envoi -->
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-4 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
<span v-if="isSubmitting" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('contact.sending') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('contact.send') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Note reCAPTCHA -->
|
||||
<p class="text-xs text-sky-text-muted text-center">
|
||||
{{ t('contact.recaptchaNote') }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- Message de succès -->
|
||||
<div
|
||||
v-else
|
||||
class="text-center py-12"
|
||||
>
|
||||
<div class="text-6xl mb-4">🎉</div>
|
||||
|
||||
<h2 class="text-2xl font-ui font-bold text-sky-accent mb-4">
|
||||
{{ t('contact.successTitle') }}
|
||||
</h2>
|
||||
|
||||
<p class="font-narrative text-lg text-sky-text mb-8">
|
||||
{{ t('contact.successMessage') }}
|
||||
</p>
|
||||
|
||||
<p class="text-sky-text-muted">
|
||||
{{ t('contact.redirecting') }}
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Controller API Laravel
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Controllers/Api/ContactController.php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ContactRequest;
|
||||
use App\Mail\ContactMail;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function store(ContactRequest $request)
|
||||
{
|
||||
// Vérifier reCAPTCHA
|
||||
$recaptchaResponse = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [
|
||||
'secret' => config('services.recaptcha.secret'),
|
||||
'response' => $request->input('recaptcha_token'),
|
||||
'remoteip' => $request->ip(),
|
||||
]);
|
||||
|
||||
$recaptchaData = $recaptchaResponse->json();
|
||||
|
||||
if (!$recaptchaData['success'] || $recaptchaData['score'] < 0.5) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'RECAPTCHA_FAILED',
|
||||
'message' => 'reCAPTCHA verification failed',
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Envoyer l'email
|
||||
Mail::to(config('mail.contact_to'))
|
||||
->send(new ContactMail(
|
||||
$request->input('name'),
|
||||
$request->input('email'),
|
||||
$request->input('message')
|
||||
));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Message sent successfully',
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Form Request
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Http/Requests/ContactRequest.php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ContactRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'message' => ['required', 'string', 'max:5000'],
|
||||
'recaptcha_token' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```php
|
||||
// api/routes/api.php
|
||||
Route::middleware(['throttle:contact'])->group(function () {
|
||||
Route::post('/contact', [ContactController::class, 'store']);
|
||||
});
|
||||
|
||||
// api/app/Providers/RouteServiceProvider.php
|
||||
protected function configureRateLimiting(): void
|
||||
{
|
||||
RateLimiter::for('contact', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->ip());
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Mail Template
|
||||
|
||||
```php
|
||||
<?php
|
||||
// api/app/Mail/ContactMail.php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
class ContactMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string $email,
|
||||
public string $message
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: "Nouveau message de {$this->name} via Skycel",
|
||||
replyTo: [$this->email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.contact',
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```blade
|
||||
<!-- api/resources/views/emails/contact.blade.php -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<h2>Nouveau message via Skycel</h2>
|
||||
|
||||
<p><strong>De :</strong> {{ $name }}</p>
|
||||
<p><strong>Email :</strong> {{ $email }}</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h3>Message :</h3>
|
||||
<p>{!! nl2br(e($message)) !!}</p>
|
||||
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Ce message a été envoyé depuis le portfolio Skycel.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Clés i18n
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"contact": {
|
||||
"yourJourney": "Ton parcours",
|
||||
"zones": "Zones explorées",
|
||||
"easterEggs": "Easter eggs",
|
||||
"challenge": "Challenge",
|
||||
"explorer": "Explorateur",
|
||||
"title": "Contacte-moi",
|
||||
"subtitle": "Tu m'as trouvé ! Maintenant, écris-moi. Je lis chaque message.",
|
||||
"name": "Ton nom",
|
||||
"namePlaceholder": "Comment dois-je t'appeler ?",
|
||||
"email": "Ton email",
|
||||
"emailPlaceholder": "Pour que je puisse te répondre",
|
||||
"message": "Ton message",
|
||||
"messagePlaceholder": "Dis-moi tout...",
|
||||
"send": "Envoyer le message",
|
||||
"sending": "Envoi en cours...",
|
||||
"recaptchaNote": "Ce site est protégé par reCAPTCHA.",
|
||||
"successTitle": "Message envoyé !",
|
||||
"successMessage": "Je l'ai bien reçu et je te réponds dès que possible. En attendant, un petit défi bonus ?",
|
||||
"redirecting": "Redirection vers le challenge bonus..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"contact": {
|
||||
"yourJourney": "Your journey",
|
||||
"zones": "Zones explored",
|
||||
"easterEggs": "Easter eggs",
|
||||
"challenge": "Challenge",
|
||||
"explorer": "Explorer",
|
||||
"title": "Contact me",
|
||||
"subtitle": "You found me! Now, write to me. I read every message.",
|
||||
"name": "Your name",
|
||||
"namePlaceholder": "What should I call you?",
|
||||
"email": "Your email",
|
||||
"emailPlaceholder": "So I can reply to you",
|
||||
"message": "Your message",
|
||||
"messagePlaceholder": "Tell me everything...",
|
||||
"send": "Send message",
|
||||
"sending": "Sending...",
|
||||
"recaptchaNote": "This site is protected by reCAPTCHA.",
|
||||
"successTitle": "Message sent!",
|
||||
"successMessage": "I received it and will reply as soon as possible. In the meantime, a bonus challenge?",
|
||||
"redirecting": "Redirecting to bonus challenge..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 3.5 : Store de progression (stats)
|
||||
- Story 3.3 : useNarrator (messages d'erreur)
|
||||
- Story 4.7 : Révélation (page précédente)
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 4.9 : Challenge post-formulaire
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/pages/
|
||||
└── contact.vue # CRÉER
|
||||
|
||||
api/
|
||||
├── app/Http/Controllers/Api/
|
||||
│ └── ContactController.php # CRÉER
|
||||
├── app/Http/Requests/
|
||||
│ └── ContactRequest.php # CRÉER
|
||||
├── app/Mail/
|
||||
│ └── ContactMail.php # CRÉER
|
||||
└── resources/views/emails/
|
||||
└── contact.blade.php # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
api/routes/api.php # AJOUTER route contact
|
||||
api/config/services.php # AJOUTER recaptcha config
|
||||
frontend/nuxt.config.ts # AJOUTER reCAPTCHA
|
||||
frontend/i18n/fr.json # AJOUTER contact.*
|
||||
frontend/i18n/en.json # AJOUTER contact.*
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-4.8]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Contact-Form]
|
||||
- [Source: docs/planning-artifacts/architecture.md#Security]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Validation | Frontend + Backend | Epics |
|
||||
| Anti-spam | Honeypot + reCAPTCHA v3 | Epics |
|
||||
| Rate limiting | 5 req/min/IP | Epics |
|
||||
| Envoi email | Laravel Mail | Architecture |
|
||||
|
||||
## 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
|
||||
|
||||
558
docs/implementation-artifacts/4-9-challenge-post-formulaire.md
Normal file
558
docs/implementation-artifacts/4-9-challenge-post-formulaire.md
Normal file
@@ -0,0 +1,558 @@
|
||||
# Story 4.9: Challenge post-formulaire
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur ayant envoyé un message,
|
||||
I want m'amuser en attendant la réponse,
|
||||
so that le temps d'attente est transformé en moment de jeu.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le formulaire de contact a été envoyé avec succès **When** la confirmation s'affiche **Then** un message "En attendant que le développeur retrouve le chemin vers sa boîte mail..." est affiché
|
||||
2. **And** un challenge optionnel bonus est proposé
|
||||
3. **And** le challenge est différent du challenge principal (mini-jeu, quiz, exploration)
|
||||
4. **And** le visiteur peut fermer et quitter à tout moment
|
||||
5. **And** la participation est totalement optionnelle
|
||||
6. **And** le résultat n'impacte rien (juste pour le fun)
|
||||
7. **And** le narrateur commente avec humour
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer la page challenge-bonus** (AC: #1, #2, #4)
|
||||
- [ ] Créer `frontend/app/pages/challenge-bonus.vue`
|
||||
- [ ] Message d'attente humoristique
|
||||
- [ ] Présentation du mini-jeu
|
||||
- [ ] Bouton "Quitter" visible en permanence
|
||||
|
||||
- [ ] **Task 2: Concevoir le mini-jeu** (AC: #3, #6)
|
||||
- [ ] Quiz sur le développement web (5 questions)
|
||||
- [ ] OU : Memory avec des technos (Vue, Laravel, TypeScript, etc.)
|
||||
- [ ] OU : Snake simplifié thème code
|
||||
- [ ] Résultat juste pour le fun, pas de récompense
|
||||
|
||||
- [ ] **Task 3: Créer le composant BonusQuiz** (AC: #3)
|
||||
- [ ] 5 questions aléatoires sur le dev
|
||||
- [ ] Choix multiples (4 options)
|
||||
- [ ] Feedback immédiat (correct/incorrect)
|
||||
- [ ] Score à la fin
|
||||
|
||||
- [ ] **Task 4: Commentaires du narrateur** (AC: #7)
|
||||
- [ ] Message d'intro humoristique
|
||||
- [ ] Réactions aux réponses
|
||||
- [ ] Message de fin selon le score
|
||||
|
||||
- [ ] **Task 5: Navigation de sortie** (AC: #4, #5)
|
||||
- [ ] Bouton "Retour à l'accueil" visible
|
||||
- [ ] Confirmation que le message est envoyé
|
||||
- [ ] Remerciement final
|
||||
|
||||
- [ ] **Task 6: Tests et validation**
|
||||
- [ ] Tester le quiz complet
|
||||
- [ ] Vérifier que le résultat n'impacte rien
|
||||
- [ ] Tester la sortie à tout moment
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Page challenge-bonus.vue
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/challenge-bonus.vue -->
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const narrator = useNarrator()
|
||||
|
||||
// États
|
||||
const showIntro = ref(true)
|
||||
const showQuiz = ref(false)
|
||||
const showResult = ref(false)
|
||||
const score = ref(0)
|
||||
|
||||
// Afficher le message d'intro
|
||||
onMounted(() => {
|
||||
narrator.showMessage('bonus_intro')
|
||||
})
|
||||
|
||||
function startQuiz() {
|
||||
showIntro.value = false
|
||||
showQuiz.value = true
|
||||
}
|
||||
|
||||
function handleQuizComplete(finalScore: number) {
|
||||
score.value = finalScore
|
||||
showQuiz.value = false
|
||||
showResult.value = true
|
||||
|
||||
// Message du narrateur selon le score
|
||||
if (finalScore === 5) {
|
||||
narrator.showMessage('bonus_perfect')
|
||||
} else if (finalScore >= 3) {
|
||||
narrator.showMessage('bonus_good')
|
||||
} else {
|
||||
narrator.showMessage('bonus_try_again')
|
||||
}
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bonus-page min-h-screen bg-sky-dark flex flex-col items-center justify-center p-8">
|
||||
<!-- Bouton quitter (toujours visible) -->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-4 right-4 text-sky-text-muted hover:text-sky-text text-sm font-ui flex items-center gap-2"
|
||||
@click="goHome"
|
||||
>
|
||||
<span>{{ t('bonus.exit') }}</span>
|
||||
<span>→</span>
|
||||
</button>
|
||||
|
||||
<!-- Intro -->
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="showIntro"
|
||||
class="max-w-lg text-center"
|
||||
>
|
||||
<img
|
||||
src="/images/bug/bug-stage-5.svg"
|
||||
alt="Le Bug"
|
||||
class="w-24 h-24 mx-auto mb-6"
|
||||
/>
|
||||
|
||||
<h1 class="text-2xl font-ui font-bold text-sky-text mb-4">
|
||||
{{ t('bonus.waitingTitle') }}
|
||||
</h1>
|
||||
|
||||
<p class="font-narrative text-lg text-sky-text-muted mb-8">
|
||||
{{ t('bonus.waitingMessage') }}
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-8 py-4 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors"
|
||||
@click="startQuiz"
|
||||
>
|
||||
{{ t('bonus.playQuiz') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-8 py-4 border border-sky-dark-100 text-sky-text font-ui rounded-lg hover:bg-sky-dark-50 transition-colors"
|
||||
@click="goHome"
|
||||
>
|
||||
{{ t('bonus.noThanks') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quiz -->
|
||||
<div
|
||||
v-else-if="showQuiz"
|
||||
class="w-full max-w-2xl"
|
||||
>
|
||||
<BonusQuiz @complete="handleQuizComplete" />
|
||||
</div>
|
||||
|
||||
<!-- Résultat -->
|
||||
<div
|
||||
v-else-if="showResult"
|
||||
class="max-w-lg text-center"
|
||||
>
|
||||
<div class="text-6xl mb-4">
|
||||
{{ score === 5 ? '🏆' : score >= 3 ? '🎉' : '💪' }}
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-ui font-bold text-sky-text mb-2">
|
||||
{{ t('bonus.resultTitle') }}
|
||||
</h2>
|
||||
|
||||
<p class="text-4xl font-ui font-bold text-sky-accent mb-4">
|
||||
{{ score }} / 5
|
||||
</p>
|
||||
|
||||
<p class="font-narrative text-lg text-sky-text-muted mb-8">
|
||||
{{ score === 5
|
||||
? t('bonus.perfectMessage')
|
||||
: score >= 3
|
||||
? t('bonus.goodMessage')
|
||||
: t('bonus.tryMessage')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-8 py-4 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors"
|
||||
@click="showIntro = true; showResult = false"
|
||||
>
|
||||
{{ t('bonus.playAgain') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-8 py-4 border border-sky-dark-100 text-sky-text font-ui rounded-lg hover:bg-sky-dark-50 transition-colors"
|
||||
@click="goHome"
|
||||
>
|
||||
{{ t('bonus.backHome') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation message envoyé -->
|
||||
<p class="mt-8 text-sm text-sky-text-muted">
|
||||
{{ t('bonus.messageConfirm') }}
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Composant BonusQuiz
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/BonusQuiz.vue -->
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
complete: [score: number]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
interface Question {
|
||||
question: { fr: string; en: string }
|
||||
options: { fr: string; en: string }[]
|
||||
correctIndex: number
|
||||
}
|
||||
|
||||
// Questions du quiz
|
||||
const allQuestions: Question[] = [
|
||||
{
|
||||
question: {
|
||||
fr: "Quel framework JavaScript utilise Célian pour le frontend ?",
|
||||
en: "What JavaScript framework does Célian use for the frontend?"
|
||||
},
|
||||
options: [
|
||||
{ fr: "React", en: "React" },
|
||||
{ fr: "Vue.js", en: "Vue.js" },
|
||||
{ fr: "Angular", en: "Angular" },
|
||||
{ fr: "Svelte", en: "Svelte" }
|
||||
],
|
||||
correctIndex: 1
|
||||
},
|
||||
{
|
||||
question: {
|
||||
fr: "Quel est le nom du framework PHP backend préféré de Célian ?",
|
||||
en: "What is the name of Célian's favorite PHP backend framework?"
|
||||
},
|
||||
options: [
|
||||
{ fr: "Symfony", en: "Symfony" },
|
||||
{ fr: "CodeIgniter", en: "CodeIgniter" },
|
||||
{ fr: "Laravel", en: "Laravel" },
|
||||
{ fr: "CakePHP", en: "CakePHP" }
|
||||
],
|
||||
correctIndex: 2
|
||||
},
|
||||
{
|
||||
question: {
|
||||
fr: "Comment s'appelle la mascotte de Skycel ?",
|
||||
en: "What is the name of Skycel's mascot?"
|
||||
},
|
||||
options: [
|
||||
{ fr: "La Fourmi", en: "The Ant" },
|
||||
{ fr: "Le Bug", en: "The Bug" },
|
||||
{ fr: "Le Pixel", en: "The Pixel" },
|
||||
{ fr: "Le Code", en: "The Code" }
|
||||
],
|
||||
correctIndex: 1
|
||||
},
|
||||
{
|
||||
question: {
|
||||
fr: "Quelle extension de JavaScript ajoute le typage statique ?",
|
||||
en: "Which JavaScript extension adds static typing?"
|
||||
},
|
||||
options: [
|
||||
{ fr: "CoffeeScript", en: "CoffeeScript" },
|
||||
{ fr: "TypeScript", en: "TypeScript" },
|
||||
{ fr: "Babel", en: "Babel" },
|
||||
{ fr: "ESLint", en: "ESLint" }
|
||||
],
|
||||
correctIndex: 1
|
||||
},
|
||||
{
|
||||
question: {
|
||||
fr: "Quel meta-framework Nuxt est utilisé pour ce portfolio ?",
|
||||
en: "Which Nuxt meta-framework is used for this portfolio?"
|
||||
},
|
||||
options: [
|
||||
{ fr: "Nuxt 2", en: "Nuxt 2" },
|
||||
{ fr: "Nuxt 3", en: "Nuxt 3" },
|
||||
{ fr: "Nuxt 4", en: "Nuxt 4" },
|
||||
{ fr: "Next.js", en: "Next.js" }
|
||||
],
|
||||
correctIndex: 2
|
||||
},
|
||||
{
|
||||
question: {
|
||||
fr: "En quelle année Skycel a été créé ?",
|
||||
en: "In what year was Skycel created?"
|
||||
},
|
||||
options: [
|
||||
{ fr: "2020", en: "2020" },
|
||||
{ fr: "2021", en: "2021" },
|
||||
{ fr: "2022", en: "2022" },
|
||||
{ fr: "2023", en: "2023" }
|
||||
],
|
||||
correctIndex: 2
|
||||
},
|
||||
{
|
||||
question: {
|
||||
fr: "Quel est l'acronyme de l'outil CSS utilitaire populaire ?",
|
||||
en: "What is the acronym of the popular utility CSS tool?"
|
||||
},
|
||||
options: [
|
||||
{ fr: "Bootstrap", en: "Bootstrap" },
|
||||
{ fr: "Tailwind CSS", en: "Tailwind CSS" },
|
||||
{ fr: "Bulma", en: "Bulma" },
|
||||
{ fr: "Foundation", en: "Foundation" }
|
||||
],
|
||||
correctIndex: 1
|
||||
},
|
||||
]
|
||||
|
||||
// Sélectionner 5 questions aléatoires
|
||||
const questions = ref<Question[]>([])
|
||||
const currentIndex = ref(0)
|
||||
const score = ref(0)
|
||||
const selectedOption = ref<number | null>(null)
|
||||
const showFeedback = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
// Mélanger et prendre 5 questions
|
||||
questions.value = [...allQuestions]
|
||||
.sort(() => Math.random() - 0.5)
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
const currentQuestion = computed(() => questions.value[currentIndex.value])
|
||||
const progress = computed(() => ((currentIndex.value + 1) / 5) * 100)
|
||||
|
||||
function selectOption(index: number) {
|
||||
if (showFeedback.value) return
|
||||
|
||||
selectedOption.value = index
|
||||
showFeedback.value = true
|
||||
|
||||
if (index === currentQuestion.value.correctIndex) {
|
||||
score.value++
|
||||
}
|
||||
|
||||
// Passer à la question suivante après délai
|
||||
setTimeout(() => {
|
||||
if (currentIndex.value < 4) {
|
||||
currentIndex.value++
|
||||
selectedOption.value = null
|
||||
showFeedback.value = false
|
||||
} else {
|
||||
emit('complete', score.value)
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
function getText(obj: { fr: string; en: string }): string {
|
||||
return locale.value === 'fr' ? obj.fr : obj.en
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bonus-quiz">
|
||||
<!-- Barre de progression -->
|
||||
<div class="h-2 bg-sky-dark-100 rounded-full mb-8 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-sky-accent transition-all duration-300"
|
||||
:style="{ width: `${progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Compteur -->
|
||||
<p class="text-sm text-sky-text-muted text-center mb-4">
|
||||
{{ t('bonus.question') }} {{ currentIndex + 1 }} / 5
|
||||
</p>
|
||||
|
||||
<!-- Question -->
|
||||
<div
|
||||
v-if="currentQuestion"
|
||||
class="bg-sky-dark-50 rounded-xl p-6 border border-sky-dark-100"
|
||||
>
|
||||
<h3 class="text-xl font-ui font-semibold text-sky-text mb-6">
|
||||
{{ getText(currentQuestion.question) }}
|
||||
</h3>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
v-for="(option, index) in currentQuestion.options"
|
||||
:key="index"
|
||||
type="button"
|
||||
class="w-full p-4 rounded-lg border text-left transition-all font-ui"
|
||||
:class="[
|
||||
selectedOption === null
|
||||
? 'border-sky-dark-100 hover:border-sky-accent hover:bg-sky-dark'
|
||||
: selectedOption === index
|
||||
? index === currentQuestion.correctIndex
|
||||
? 'border-green-500 bg-green-500/20'
|
||||
: 'border-red-500 bg-red-500/20'
|
||||
: index === currentQuestion.correctIndex && showFeedback
|
||||
? 'border-green-500 bg-green-500/10'
|
||||
: 'border-sky-dark-100 opacity-50'
|
||||
]"
|
||||
:disabled="showFeedback"
|
||||
@click="selectOption(index)"
|
||||
>
|
||||
<span class="flex items-center gap-3">
|
||||
<span
|
||||
class="shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
|
||||
:class="[
|
||||
selectedOption === index && index === currentQuestion.correctIndex
|
||||
? 'bg-green-500 text-white'
|
||||
: selectedOption === index && index !== currentQuestion.correctIndex
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-sky-dark-100 text-sky-text'
|
||||
]"
|
||||
>
|
||||
{{ ['A', 'B', 'C', 'D'][index] }}
|
||||
</span>
|
||||
<span class="text-sky-text">{{ getText(option) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Feedback -->
|
||||
<Transition name="fade">
|
||||
<p
|
||||
v-if="showFeedback"
|
||||
class="mt-4 text-center font-ui"
|
||||
:class="selectedOption === currentQuestion.correctIndex ? 'text-green-400' : 'text-red-400'"
|
||||
>
|
||||
{{ selectedOption === currentQuestion.correctIndex
|
||||
? t('bonus.correct')
|
||||
: t('bonus.incorrect')
|
||||
}}
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Clés i18n
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"bonus": {
|
||||
"exit": "Quitter",
|
||||
"waitingTitle": "Message envoyé !",
|
||||
"waitingMessage": "En attendant que le développeur retrouve le chemin vers sa boîte mail... un petit quiz pour passer le temps ?",
|
||||
"playQuiz": "Jouer au quiz",
|
||||
"noThanks": "Non merci, j'ai terminé",
|
||||
"question": "Question",
|
||||
"correct": "Bonne réponse ! 🎉",
|
||||
"incorrect": "Pas tout à fait... 😅",
|
||||
"resultTitle": "Quiz terminé !",
|
||||
"perfectMessage": "Score parfait ! Tu connais vraiment bien le développement web... et Célian !",
|
||||
"goodMessage": "Bien joué ! Tu as de bonnes bases en développement web.",
|
||||
"tryMessage": "Continue d'apprendre ! Le développement web est un voyage sans fin.",
|
||||
"playAgain": "Rejouer",
|
||||
"backHome": "Retour à l'accueil",
|
||||
"messageConfirm": "Ton message a bien été envoyé. Célian te répondra bientôt !"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"bonus": {
|
||||
"exit": "Exit",
|
||||
"waitingTitle": "Message sent!",
|
||||
"waitingMessage": "While the developer finds their way to the inbox... a little quiz to pass the time?",
|
||||
"playQuiz": "Play the quiz",
|
||||
"noThanks": "No thanks, I'm done",
|
||||
"question": "Question",
|
||||
"correct": "Correct! 🎉",
|
||||
"incorrect": "Not quite... 😅",
|
||||
"resultTitle": "Quiz completed!",
|
||||
"perfectMessage": "Perfect score! You really know web development... and Célian!",
|
||||
"goodMessage": "Well done! You have solid web development basics.",
|
||||
"tryMessage": "Keep learning! Web development is an endless journey.",
|
||||
"playAgain": "Play again",
|
||||
"backHome": "Back to home",
|
||||
"messageConfirm": "Your message was sent successfully. Célian will reply soon!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 4.8 : Page contact (redirection après envoi)
|
||||
- Story 3.3 : useNarrator
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Aucune (dernière story de l'Epic 4)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── pages/
|
||||
│ └── challenge-bonus.vue # CRÉER
|
||||
└── components/feature/
|
||||
└── BonusQuiz.vue # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/i18n/fr.json # AJOUTER bonus.*
|
||||
frontend/i18n/en.json # AJOUTER bonus.*
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-4.9]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Bonus-Challenge]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Post-Contact]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Type de mini-jeu | Quiz (5 questions) | Décision technique |
|
||||
| Impact sur progression | Aucun | Epics |
|
||||
| Sortie | Toujours possible | Epics |
|
||||
| Ambiance | Humoristique | 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
|
||||
|
||||
96
docs/implementation-artifacts/sprint-status.yaml
Normal file
96
docs/implementation-artifacts/sprint-status.yaml
Normal file
@@ -0,0 +1,96 @@
|
||||
# generated: 2026-02-03
|
||||
# project: skycel
|
||||
# project_key: skycel-portfolio
|
||||
# tracking_system: file-system
|
||||
# story_location: docs/implementation-artifacts
|
||||
|
||||
# STATUS DEFINITIONS:
|
||||
# ==================
|
||||
# Epic Status:
|
||||
# - backlog: Epic not yet started
|
||||
# - in-progress: Epic actively being worked on
|
||||
# - done: All stories in epic completed
|
||||
#
|
||||
# Epic Status Transitions:
|
||||
# - backlog → in-progress: Automatically when first story is created (via create-story)
|
||||
# - in-progress → done: Manually when all stories reach 'done' status
|
||||
#
|
||||
# Story Status:
|
||||
# - backlog: Story only exists in epic file
|
||||
# - ready-for-dev: Story file created in stories folder
|
||||
# - in-progress: Developer actively working on implementation
|
||||
# - review: Ready for code review (via Dev's code-review workflow)
|
||||
# - done: Story completed
|
||||
#
|
||||
# Retrospective Status:
|
||||
# - optional: Can be completed but not required
|
||||
# - done: Retrospective has been completed
|
||||
#
|
||||
# WORKFLOW NOTES:
|
||||
# ===============
|
||||
# - Epic transitions to 'in-progress' automatically when first story is created
|
||||
# - Stories can be worked in parallel if team capacity allows
|
||||
# - SM typically creates next story after previous one is 'done' to incorporate learnings
|
||||
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
||||
|
||||
generated: 2026-02-03
|
||||
project: skycel
|
||||
project_key: skycel-portfolio
|
||||
tracking_system: file-system
|
||||
story_location: docs/implementation-artifacts
|
||||
|
||||
development_status:
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# EPIC 1: Fondations & Double Entrée
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
epic-1: in-progress
|
||||
1-1-initialisation-monorepo-infrastructure: review
|
||||
1-2-base-donnees-migrations-initiales: ready-for-dev
|
||||
1-3-systeme-i18n-frontend-api-bilingue: ready-for-dev
|
||||
1-4-layouts-routing-transitions-page: ready-for-dev
|
||||
1-5-landing-page-choix-heros: ready-for-dev
|
||||
1-6-store-pinia-progression-bandeau-rgpd: ready-for-dev
|
||||
1-7-page-resume-express-mode-presse: ready-for-dev
|
||||
epic-1-retrospective: optional
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# EPIC 2: Contenu & Découverte
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
epic-2: in-progress
|
||||
2-1-composant-projectcard: ready-for-dev
|
||||
2-2-page-projets-galerie: ready-for-dev
|
||||
2-3-page-projet-detail: ready-for-dev
|
||||
2-4-page-competences-affichage-categories: ready-for-dev
|
||||
2-5-competences-cliquables-projets-lies: ready-for-dev
|
||||
2-6-page-temoignages-migrations-bdd: ready-for-dev
|
||||
2-7-composant-dialogue-pnj: ready-for-dev
|
||||
2-8-page-parcours-timeline-narrative: ready-for-dev
|
||||
epic-2-retrospective: optional
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# EPIC 3: Navigation Gamifiée & Progression
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
epic-3: in-progress
|
||||
3-1-table-narrator-texts-api-narrateur: ready-for-dev
|
||||
3-2-composant-narratorbubble-le-bug: ready-for-dev
|
||||
3-3-textes-narrateur-contextuels-arc-revelation: ready-for-dev
|
||||
3-4-barre-progression-globale-xp-bar: ready-for-dev
|
||||
3-5-logique-progression-deblocage-contact: ready-for-dev
|
||||
3-6-carte-interactive-desktop-konvajs: ready-for-dev
|
||||
3-7-navigation-mobile-chemin-libre-bottom-bar: ready-for-dev
|
||||
epic-3-retrospective: optional
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# EPIC 4: Chemins Narratifs, Challenge & Contact
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
epic-4: in-progress
|
||||
4-1-composant-choicecards-choix-narratifs: ready-for-dev
|
||||
4-2-intro-narrative-premier-choix: ready-for-dev
|
||||
4-3-chemins-narratifs-differencies: ready-for-dev
|
||||
4-4-table-easter-eggs-systeme-detection: ready-for-dev
|
||||
4-5-easter-eggs-implementation-ui-collection: ready-for-dev
|
||||
4-6-page-challenge-structure-puzzle: ready-for-dev
|
||||
4-7-revelation-monde-de-code: ready-for-dev
|
||||
4-8-page-contact-formulaire-celebration: ready-for-dev
|
||||
4-9-challenge-post-formulaire: ready-for-dev
|
||||
epic-4-retrospective: optional
|
||||
Reference in New Issue
Block a user