🎉 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:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
vendor/
|
||||
.claude/
|
||||
_bmad/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build outputs
|
||||
.output/
|
||||
.nuxt/
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Laravel
|
||||
storage/*.key
|
||||
storage/framework/cache/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
bootstrap/cache/*
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.phpunit.result.cache
|
||||
40
README.md
Normal file
40
README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Skycel
|
||||
|
||||
Portfolio interactif gamifie - Monorepo Nuxt 4 + Laravel 12.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
skycel/
|
||||
├── frontend/ # Application Nuxt 4 (SSR, TypeScript)
|
||||
├── api/ # Backend Laravel 12 (API-only)
|
||||
└── docs/ # Documentation projet
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- PHP 8.2+
|
||||
- Composer
|
||||
- MySQL / MariaDB
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd api
|
||||
cp .env.example .env
|
||||
composer install
|
||||
php artisan key:generate
|
||||
php artisan serve
|
||||
```
|
||||
18
api/.editorconfig
Normal file
18
api/.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
71
api/.env.example
Normal file
71
api/.env.example
Normal file
@@ -0,0 +1,71 @@
|
||||
APP_NAME=Skycel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=skycel
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# API Security
|
||||
API_KEY=your-api-key-here
|
||||
|
||||
# CORS
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||
11
api/.gitattributes
vendored
Normal file
11
api/.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
24
api/.gitignore
vendored
Normal file
24
api/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
59
api/README.md
Normal file
59
api/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[Vehikl](https://vehikl.com)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Redberry](https://redberry.international/laravel-development)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
8
api/app/Http/Controllers/Controller.php
Normal file
8
api/app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
26
api/app/Http/Middleware/VerifyApiKey.php
Normal file
26
api/app/Http/Middleware/VerifyApiKey.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
48
api/app/Models/User.php
Normal file
48
api/app/Models/User.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
24
api/app/Providers/AppServiceProvider.php
Normal file
24
api/app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
18
api/artisan
Normal file
18
api/artisan
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
20
api/bootstrap/app.php
Normal file
20
api/bootstrap/app.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->api(append: [
|
||||
\App\Http\Middleware\VerifyApiKey::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
2
api/bootstrap/cache/.gitignore
vendored
Normal file
2
api/bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
5
api/bootstrap/providers.php
Normal file
5
api/bootstrap/providers.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
86
api/composer.json
Normal file
86
api/composer.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
8395
api/composer.lock
generated
Normal file
8395
api/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
137
api/config/app.php
Normal file
137
api/config/app.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is used to authenticate API requests via the X-API-Key header.
|
||||
|
|
||||
*/
|
||||
|
||||
'api_key' => env('API_KEY'),
|
||||
|
||||
];
|
||||
115
api/config/auth.php
Normal file
115
api/config/auth.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
117
api/config/cache.php
Normal file
117
api/config/cache.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "octane",
|
||||
| "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
];
|
||||
21
api/config/cors.php
Normal file
21
api/config/cors.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?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,
|
||||
|
||||
];
|
||||
183
api/config/database.php
Normal file
183
api/config/database.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
80
api/config/filesystems.php
Normal file
80
api/config/filesystems.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
132
api/config/logging.php
Normal file
132
api/config/logging.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
118
api/config/mail.php
Normal file
118
api/config/mail.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||
],
|
||||
|
||||
];
|
||||
129
api/config/queue.php
Normal file
129
api/config/queue.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
38
api/config/services.php
Normal file
38
api/config/services.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
217
api/config/session.php
Normal file
217
api/config/session.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
];
|
||||
1
api/database/.gitignore
vendored
Normal file
1
api/database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
44
api/database/factories/UserFactory.php
Normal file
44
api/database/factories/UserFactory.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->integer('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->integer('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
25
api/database/seeders/DatabaseSeeder.php
Normal file
25
api/database/seeders/DatabaseSeeder.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
}
|
||||
}
|
||||
17
api/package.json
Normal file
17
api/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^7.0.7"
|
||||
}
|
||||
}
|
||||
35
api/phpunit.xml
Normal file
35
api/phpunit.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
25
api/public/.htaccess
Normal file
25
api/public/.htaccess
Normal file
@@ -0,0 +1,25 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Handle X-XSRF-Token Header
|
||||
RewriteCond %{HTTP:x-xsrf-token} .
|
||||
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
0
api/public/favicon.ico
Normal file
0
api/public/favicon.ico
Normal file
20
api/public/index.php
Normal file
20
api/public/index.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->handleRequest(Request::capture());
|
||||
2
api/public/robots.txt
Normal file
2
api/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
11
api/resources/css/app.css
Normal file
11
api/resources/css/app.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
@source '../**/*.js';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
1
api/resources/js/app.js
Normal file
1
api/resources/js/app.js
Normal file
@@ -0,0 +1 @@
|
||||
import './bootstrap';
|
||||
4
api/resources/js/bootstrap.js
vendored
Normal file
4
api/resources/js/bootstrap.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
7
api/routes/api.php
Normal file
7
api/routes/api.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/health', function () {
|
||||
return response()->json(['status' => 'ok']);
|
||||
});
|
||||
8
api/routes/console.php
Normal file
8
api/routes/console.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
3
api/routes/web.php
Normal file
3
api/routes/web.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
// API-only mode: no web routes
|
||||
4
api/storage/app/.gitignore
vendored
Normal file
4
api/storage/app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!private/
|
||||
!public/
|
||||
!.gitignore
|
||||
2
api/storage/app/private/.gitignore
vendored
Normal file
2
api/storage/app/private/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
api/storage/app/public/.gitignore
vendored
Normal file
2
api/storage/app/public/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
9
api/storage/framework/.gitignore
vendored
Normal file
9
api/storage/framework/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
compiled.php
|
||||
config.php
|
||||
down
|
||||
events.scanned.php
|
||||
maintenance.php
|
||||
routes.php
|
||||
routes.scanned.php
|
||||
schedule-*
|
||||
services.json
|
||||
3
api/storage/framework/cache/.gitignore
vendored
Normal file
3
api/storage/framework/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!data/
|
||||
!.gitignore
|
||||
2
api/storage/framework/cache/data/.gitignore
vendored
Normal file
2
api/storage/framework/cache/data/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
api/storage/framework/sessions/.gitignore
vendored
Normal file
2
api/storage/framework/sessions/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
api/storage/framework/testing/.gitignore
vendored
Normal file
2
api/storage/framework/testing/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
api/storage/framework/views/.gitignore
vendored
Normal file
2
api/storage/framework/views/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
api/storage/logs/.gitignore
vendored
Normal file
2
api/storage/logs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
19
api/tests/Feature/ExampleTest.php
Normal file
19
api/tests/Feature/ExampleTest.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
10
api/tests/TestCase.php
Normal file
10
api/tests/TestCase.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
}
|
||||
16
api/tests/Unit/ExampleTest.php
Normal file
16
api/tests/Unit/ExampleTest.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
18
api/vite.config.js
Normal file
18
api/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
refresh: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ['**/storage/framework/views/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
653
docs/brainstorming-gamification-2026-01-26.md
Normal file
653
docs/brainstorming-gamification-2026-01-26.md
Normal file
@@ -0,0 +1,653 @@
|
||||
# Brainstorming Session - Portfolio Gamifié
|
||||
|
||||
**Date** : 26 janvier 2026
|
||||
**Facilitatrice** : Mary (Business Analyst)
|
||||
**Techniques utilisées** : Role Playing, SCAMPER, What If Scenarios, Yes And Building
|
||||
**Stack technique** : PHP 8+ / MariaDB / TailwindCSS / Swup.js / Konva.js / vis.js
|
||||
**Langues** : Français (défaut) + Anglais
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
| Élément | Détail |
|
||||
|---------|--------|
|
||||
| **Sujet** | Amélioration portfolio - Fonctionnalités techniques et visuelles |
|
||||
| **Objectif** | Exploration large |
|
||||
| **Idées initiales** | Carousel témoignages, Compétences cliquables → projets |
|
||||
| **Total idées générées** | 40+ |
|
||||
|
||||
### Concept Central Retenu
|
||||
|
||||
> **Portfolio = Aventure narrative immersive** avec quête principale "Trouver le développeur", chemins multiples à choix, progression gamifiée, et expérience unique par visiteur.
|
||||
|
||||
---
|
||||
|
||||
## Insights - Role Playing (3 Perspectives)
|
||||
|
||||
### Perspective Recruteur Pressé
|
||||
| Besoin | Insight |
|
||||
|--------|---------|
|
||||
| Se démarquer | Design non-conventionnel, éléments surprenants |
|
||||
| Infos rapides | Compétences visibles immédiatement |
|
||||
| Preuves concrètes | Lien direct compétences → projets |
|
||||
| Teasing efficace | Previews projets sans tout révéler |
|
||||
|
||||
### Perspective Client Potentiel
|
||||
| Besoin | Insight |
|
||||
|--------|---------|
|
||||
| Confiance technique | Détails techniques par projet |
|
||||
| Process de travail | Section "Comment je travaille" |
|
||||
| Confiance humaine | Ton authentique, touche personnelle |
|
||||
| Personnalité | À propos qui va au-delà du CV |
|
||||
|
||||
### Perspective Développeur Pair
|
||||
| Besoin | Insight |
|
||||
|--------|---------|
|
||||
| Impressionner | Animations/interactions avancées |
|
||||
| Valeur ajoutée | Opinions sur les technos, contenu régulier |
|
||||
| Mémorable | Élément signature unique et challengeant |
|
||||
| Dialogue | Interaction qui donne envie de discuter |
|
||||
|
||||
---
|
||||
|
||||
## SCAMPER - Idées Générées
|
||||
|
||||
### S - Substitute (Substituer)
|
||||
|
||||
| Élément actuel | Substitution |
|
||||
|----------------|--------------|
|
||||
| Textes classiques | Narrateur (toi) qui guide la visite comme une aventure |
|
||||
| Images statiques | Animations SVG + entrées animées pour les projets |
|
||||
| Navigation standard | Navigation gamifiée : carte interactive accessible via icône |
|
||||
| Formulaire contact | "Objectif final" avec célébration à la complétion |
|
||||
| Contenu bonus | Easter eggs cachés avec récompense globale |
|
||||
|
||||
### C - Combine (Combiner)
|
||||
|
||||
| Combinaison | Résultat |
|
||||
|-------------|----------|
|
||||
| À propos + Timeline | Section "Mon parcours" narrative |
|
||||
| Gamification + Navigation | Système de progression global sur tout le site |
|
||||
| Compétences + Projets | Compétences cliquables → projets liés |
|
||||
|
||||
### A - Adapt (Adapter)
|
||||
|
||||
| Inspiration | Adaptation portfolio |
|
||||
|-------------|---------------------|
|
||||
| Zelda BOTW - Quêtes | Objectifs de découverte à accomplir |
|
||||
| Zelda BOTW - Dialogues PNJ | Témoignages sous forme de dialogues interactifs |
|
||||
| Portfolios fluides | Transitions de page animées seamless |
|
||||
|
||||
### M - Modify/Magnify (Amplifier)
|
||||
|
||||
| Élément | Amplification |
|
||||
|---------|---------------|
|
||||
| Compétences | Arbre de compétences RPG évoluant avec chaque projet |
|
||||
| Progression skill | Avant/après projet = niveau qui monte + description |
|
||||
| Transitions | Animation "changement de zone" immersive (élément signature) |
|
||||
| Storytelling | Intrigue parallèle au métier de développeur |
|
||||
| Micro-interactions | Éléments cliquables cachés + easter eggs |
|
||||
|
||||
### P - Put to other uses (Autres usages)
|
||||
|
||||
| Usage | Implémentation |
|
||||
|-------|----------------|
|
||||
| Ressource code | Section snippets/templates réutilisables |
|
||||
| Networking | Multi-points de contact facilités |
|
||||
|
||||
### E - Eliminate (Éliminer)
|
||||
|
||||
| À éliminer | Alternative |
|
||||
|------------|-------------|
|
||||
| Navigation classique (menu burger) | Carte interactive + narrateur guide |
|
||||
| Informations redondantes | Chaque info à un seul endroit stratégique |
|
||||
| Footer classique | Intégrer les liens dans l'expérience |
|
||||
|
||||
### R - Reverse/Rearrange (Inverser)
|
||||
|
||||
| Inversion | Effet |
|
||||
|-----------|-------|
|
||||
| Héros mystérieux au départ | Intrigue dès l'arrivée |
|
||||
| Révélation progressive | Chaque section dévoile une facette |
|
||||
| Pas de page "À propos" classique | L'histoire se construit au fil de l'exploration |
|
||||
|
||||
---
|
||||
|
||||
## What If Scenarios - Concepts Avancés
|
||||
|
||||
### Quête Principale
|
||||
| Concept | Implémentation |
|
||||
|---------|----------------|
|
||||
| Quête principale | "Trouver le développeur" = fil rouge |
|
||||
| Climax narratif | Le visiteur te "trouve" enfin |
|
||||
| Récompense | Dialogue avec toi = formulaire de contact |
|
||||
|
||||
### Double Entrée (Visiteurs Pressés)
|
||||
| Chemin | Expérience |
|
||||
|--------|------------|
|
||||
| **Aventurier** | "Partir à l'aventure" → Expérience complète |
|
||||
| **Pressé** | "Je cherche..." + éléments loufoques → Roadmap complétée |
|
||||
|
||||
### Expérience Unique & Viralité
|
||||
| Concept | Implémentation |
|
||||
|---------|----------------|
|
||||
| Parcours multiples | 2-3 choix binaires = 4-8 parcours différents |
|
||||
| Même destination | Toutes les fins → Contact développeur |
|
||||
| Rejouabilité | Chemins différents pour chaque visiteur |
|
||||
|
||||
---
|
||||
|
||||
## Yes And Building - Affinements
|
||||
|
||||
### Système de Challenges
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| Challenge obligatoire | 1 puzzle facile pour accéder au contact |
|
||||
| Easter eggs | Challenges cachés plus complexes |
|
||||
| Récompenses | Snippets de code, anecdotes cachées |
|
||||
| Aide | Système d'indices → réponse si bloqué |
|
||||
|
||||
### Dialogues PNJ (Témoignages)
|
||||
| Élément | Implémentation |
|
||||
|---------|----------------|
|
||||
| Format | Avatar + bulle de dialogue style Zelda |
|
||||
| Interaction | Clic pour "parler" → typewriter effect |
|
||||
| Personnalités | Mentor sage, collègue sarcastique, client enthousiaste |
|
||||
| Variété | 3-4 textes aléatoires par PNJ, même sens |
|
||||
|
||||
### Sauvegarde & Réengagement
|
||||
| Fonctionnalité | Implémentation |
|
||||
|----------------|----------------|
|
||||
| Sauvegarde locale | LocalStorage JS (transparent) |
|
||||
| Sauvegarde cloud | Email optionnel (multi-device) |
|
||||
| Rappel narratif | Email après X jours : "Ta quête t'attend..." |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Narrative Proposée
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ARRIVÉE SUR LE SITE │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ "Partir à │ │ "Je n'ai pas │
|
||||
│ l'aventure" │ │ le temps..." │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Intro narrative │ │ Roadmap │
|
||||
│ Héros mystérieux│ │ "Partie sauvée" │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ CHOIX 1 │ │
|
||||
│ Chemin A ou B │ │
|
||||
└─────────────────┘ │
|
||||
│ │ │
|
||||
▼ ▼ │
|
||||
┌───────┐ ┌───────┐ │
|
||||
│Projets│ │Skills │ │
|
||||
│ │ │Tree │ │
|
||||
└───────┘ └───────┘ │
|
||||
│ │ │
|
||||
└───┬───┘ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ Parcours/ │ │
|
||||
│ Timeline │◄──────────────────────┘
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Témoignages │
|
||||
│ (Dialogues PNJ) │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ CHALLENGE │
|
||||
│ (Puzzle facile) │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ "TU M'AS TROUVÉ !" │
|
||||
│ Dialogue avec le dev = Contact │
|
||||
│ + Célébration finale │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Catégorisation des Idées
|
||||
|
||||
### Opportunités Immédiates (Quick Wins)
|
||||
*Implémentables rapidement avec impact fort*
|
||||
|
||||
| # | Idée | Effort |
|
||||
|---|------|--------|
|
||||
| 1 | Compétences cliquables → projets | Faible |
|
||||
| 2 | Carousel témoignages style dialogue | Faible |
|
||||
| 3 | Transitions de page animées | Moyen |
|
||||
| 4 | Fusion À propos + Timeline | Faible |
|
||||
| 5 | Multi-points de contact | Faible |
|
||||
|
||||
### Innovations Futures (Phase 2)
|
||||
*Demandent plus de développement mais réalisables*
|
||||
|
||||
| # | Idée | Effort |
|
||||
|---|------|--------|
|
||||
| 1 | Narrateur-guide avec textes d'accompagnement | Moyen |
|
||||
| 2 | Double entrée (Aventure vs Mode pressé) | Moyen |
|
||||
| 3 | Carte interactive comme navigation | Élevé |
|
||||
| 4 | Arbre de compétences visuel | Élevé |
|
||||
| 5 | Barre de progression exploration | Moyen |
|
||||
| 6 | Sauvegarde LocalStorage | Faible |
|
||||
|
||||
### Moonshots (Phase 3)
|
||||
*Ambitieux, différenciants*
|
||||
|
||||
| # | Idée | Effort |
|
||||
|---|------|--------|
|
||||
| 1 | Chemins multiples à choix (4-8 parcours) | Élevé |
|
||||
| 2 | Quête principale "Trouver le dev" | Élevé |
|
||||
| 3 | Challenge/puzzle obligatoire | Moyen |
|
||||
| 4 | Easter eggs avec récompenses | Moyen |
|
||||
| 5 | Système de rappel email narratif | Moyen |
|
||||
| 6 | Textes PNJ aléatoires + personnalités | Moyen |
|
||||
|
||||
---
|
||||
|
||||
## Plan d'Action Recommandé
|
||||
|
||||
### Phase 1 : Fondations
|
||||
|
||||
| # | Action | Priorité |
|
||||
|---|--------|----------|
|
||||
| 1 | Implémenter transitions de page seamless | Haute |
|
||||
| 2 | Créer composant compétences cliquables → projets | Haute |
|
||||
| 3 | Refondre carousel témoignages en style dialogue | Haute |
|
||||
| 4 | Fusionner À propos et Timeline | Haute |
|
||||
| 5 | Concevoir la carte interactive (maquette) | Moyenne |
|
||||
|
||||
### Phase 2 : Gamification Light
|
||||
|
||||
| # | Action | Priorité |
|
||||
|---|--------|----------|
|
||||
| 1 | Ajouter narrateur-guide | Haute |
|
||||
| 2 | Créer double entrée (Aventure / Pressé) | Haute |
|
||||
| 3 | Implémenter barre de progression | Moyenne |
|
||||
| 4 | Système de sauvegarde LocalStorage | Moyenne |
|
||||
| 5 | Arbre de compétences visuel | Moyenne |
|
||||
|
||||
### Phase 3 : Expérience Complète
|
||||
|
||||
| # | Action | Priorité |
|
||||
|---|--------|----------|
|
||||
| 1 | Système de choix narratifs | Haute |
|
||||
| 2 | Challenge/puzzle principal | Moyenne |
|
||||
| 3 | Easter eggs et récompenses | Basse |
|
||||
| 4 | Personnalités PNJ + textes aléatoires | Basse |
|
||||
| 5 | Système de rappel email | Basse |
|
||||
|
||||
---
|
||||
|
||||
## Stack Technique Validée
|
||||
|
||||
### Architecture Globale
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ BACKEND │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ PHP 8+ Routing, templates, logique │
|
||||
│ MariaDB Données, i18n, progression │
|
||||
│ PDO Connexion sécurisée BDD │
|
||||
│ Custom i18n Helper __('key') + cache │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ TailwindCSS Styling + animations CSS │
|
||||
│ Swup.js ~5kb Transitions pages seamless │
|
||||
│ Konva.js ~150kb Carte interactive / minimap │
|
||||
│ vis.js Network ~150kb Skill tree interactif │
|
||||
│ GSAP ~60kb Animations avancées (optionnel) │
|
||||
│ JS Vanilla Logique, localStorage, events │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ INTERNATIONALISATION │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Langues FR (défaut) + EN │
|
||||
│ Stockage Table MariaDB `translations` │
|
||||
│ Détection URL (/en/...) ou cookie/session │
|
||||
│ Fallback FR si clé manquante en EN │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ASSETS │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Images WebP optimisées │
|
||||
│ Icônes SVG inline ou sprite │
|
||||
│ Fonts Variable fonts (perf) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Librairies JS - Détail
|
||||
|
||||
| Librairie | Version | Poids (gzip) | Usage |
|
||||
|-----------|---------|--------------|-------|
|
||||
| [Swup.js](https://swup.js.org/) | 4.x | ~2kb | Transitions de page seamless |
|
||||
| [Konva.js](https://konvajs.org/) | 9.x | ~50kb | Carte interactive, minimap |
|
||||
| [vis.js Network](https://visjs.github.io/vis-network/) | 9.x | ~50kb | Skill tree interactif |
|
||||
| [GSAP](https://greensock.com/gsap/) | 3.x | ~25kb | Animations complexes (optionnel) |
|
||||
| **Total estimé** | | **~100-125kb** | |
|
||||
|
||||
### Routing i18n
|
||||
|
||||
| URL | Langue | Page |
|
||||
|-----|--------|------|
|
||||
| `/` | FR (défaut) | Accueil |
|
||||
| `/en` | EN | Accueil |
|
||||
| `/projets` | FR | Projets |
|
||||
| `/en/projects` | EN | Projets |
|
||||
| `/competences` | FR | Skills |
|
||||
| `/en/skills` | EN | Skills |
|
||||
| `/a-propos` | FR | À propos |
|
||||
| `/en/about` | EN | About |
|
||||
| `/contact` | FR | Contact |
|
||||
| `/en/contact` | EN | Contact |
|
||||
|
||||
---
|
||||
|
||||
## Schéma Base de Données MariaDB
|
||||
|
||||
### Diagramme Relationnel
|
||||
|
||||
```
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ projects │ │ skills │
|
||||
├──────────────────┤ ├──────────────────┤
|
||||
│ id │ │ id │
|
||||
│ slug │ │ slug │
|
||||
│ title_key (i18n) │ │ name_key (i18n) │
|
||||
│ description_key │ │ icon │
|
||||
│ image │ │ max_level │
|
||||
│ url │ │ category │
|
||||
│ github_url │ └────────┬─────────┘
|
||||
│ date_completed │ │
|
||||
│ is_featured │ │
|
||||
└────────┬─────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────┴───────────────────┐
|
||||
│ │ skill_project │
|
||||
│ ├───────────────────────────────────────┤
|
||||
└────┤ skill_id │
|
||||
│ project_id │
|
||||
│ level_before │
|
||||
│ level_after │
|
||||
└───────────────────────────────────────┘
|
||||
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ testimonials │ │ narrator_texts │
|
||||
├──────────────────┤ ├──────────────────┤
|
||||
│ id │ │ id │
|
||||
│ name │ │ context │
|
||||
│ role │ │ text_key (i18n) │
|
||||
│ company │ │ variant │
|
||||
│ avatar │ └──────────────────┘
|
||||
│ text_key (i18n) │
|
||||
│ personality │ ┌──────────────────┐
|
||||
│ project_id (FK) │ │ easter_eggs │
|
||||
└──────────────────┘ ├──────────────────┤
|
||||
│ id │
|
||||
┌──────────────────┐ │ location │
|
||||
│ translations │ │ trigger_type │
|
||||
├──────────────────┤ │ reward_type │
|
||||
│ id │ │ reward_key (i18n)│
|
||||
│ lang (fr/en) │ └──────────────────┘
|
||||
│ key_name │
|
||||
│ value │ ┌──────────────────┐
|
||||
└──────────────────┘ │ user_progress │
|
||||
├──────────────────┤
|
||||
│ id │
|
||||
│ session_id │
|
||||
│ email (nullable) │
|
||||
│ progress_json │
|
||||
│ last_visited │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### Tables Détaillées
|
||||
|
||||
#### Table `translations` (i18n)
|
||||
|
||||
```sql
|
||||
CREATE TABLE translations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
lang VARCHAR(5) NOT NULL, -- 'fr', 'en'
|
||||
key_name VARCHAR(255) NOT NULL, -- 'hero.title', 'nav.projects'
|
||||
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)
|
||||
);
|
||||
```
|
||||
|
||||
#### Table `projects`
|
||||
|
||||
```sql
|
||||
CREATE TABLE projects (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
title_key VARCHAR(255) NOT NULL, -- Clé i18n
|
||||
description_key VARCHAR(255) NOT NULL, -- Clé i18n
|
||||
short_description_key VARCHAR(255), -- Clé i18n (teaser)
|
||||
image VARCHAR(255),
|
||||
url VARCHAR(255),
|
||||
github_url VARCHAR(255),
|
||||
date_completed DATE,
|
||||
is_featured BOOLEAN DEFAULT FALSE,
|
||||
display_order INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
#### Table `skills`
|
||||
|
||||
```sql
|
||||
CREATE TABLE skills (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
name_key VARCHAR(255) NOT NULL, -- Clé i18n
|
||||
description_key VARCHAR(255), -- Clé i18n
|
||||
icon VARCHAR(100), -- Nom icône ou chemin SVG
|
||||
category ENUM('frontend', 'backend', 'tools', 'soft') NOT NULL,
|
||||
max_level INT DEFAULT 5,
|
||||
display_order INT DEFAULT 0
|
||||
);
|
||||
```
|
||||
|
||||
#### Table `skill_project` (liaison + progression)
|
||||
|
||||
```sql
|
||||
CREATE TABLE skill_project (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
skill_id INT NOT NULL,
|
||||
project_id INT NOT NULL,
|
||||
level_before INT DEFAULT 0,
|
||||
level_after INT NOT NULL,
|
||||
level_description_key VARCHAR(255), -- Clé i18n
|
||||
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_skill_project (skill_id, project_id)
|
||||
);
|
||||
```
|
||||
|
||||
#### Table `testimonials`
|
||||
|
||||
```sql
|
||||
CREATE TABLE testimonials (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
role VARCHAR(100),
|
||||
company VARCHAR(100),
|
||||
avatar VARCHAR(255),
|
||||
text_key VARCHAR(255) NOT NULL, -- Clé i18n
|
||||
personality ENUM('sage', 'sarcastique', 'enthousiaste', 'professionnel') DEFAULT 'professionnel',
|
||||
project_id INT,
|
||||
display_order INT DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
|
||||
);
|
||||
```
|
||||
|
||||
#### Table `narrator_texts`
|
||||
|
||||
```sql
|
||||
CREATE TABLE narrator_texts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
context VARCHAR(100) NOT NULL, -- 'intro', 'transition_projects', 'hint'
|
||||
text_key VARCHAR(255) NOT NULL, -- Clé i18n
|
||||
variant INT DEFAULT 1, -- Pour textes aléatoires
|
||||
INDEX idx_context (context)
|
||||
);
|
||||
```
|
||||
|
||||
#### Table `easter_eggs`
|
||||
|
||||
```sql
|
||||
CREATE TABLE easter_eggs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
location VARCHAR(100) NOT NULL, -- 'header-logo', 'footer-secret'
|
||||
trigger_type ENUM('click', 'hover', 'konami', 'scroll') NOT NULL,
|
||||
reward_type ENUM('snippet', 'anecdote', 'image', 'badge') NOT NULL,
|
||||
reward_key VARCHAR(255) NOT NULL, -- Clé i18n ou chemin fichier
|
||||
difficulty ENUM('easy', 'medium', 'hard') DEFAULT 'medium',
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
```
|
||||
|
||||
#### Table `user_progress`
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_progress (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(100) NOT NULL UNIQUE,
|
||||
email VARCHAR(255), -- Optionnel pour sauvegarde cloud
|
||||
progress_json JSON NOT NULL, -- État complet de la progression
|
||||
current_path VARCHAR(50), -- Chemin narratif choisi
|
||||
completion_percent INT DEFAULT 0,
|
||||
easter_eggs_found JSON, -- Liste des IDs trouvés
|
||||
last_visited TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
reminder_sent BOOLEAN DEFAULT FALSE,
|
||||
INDEX idx_email (email)
|
||||
);
|
||||
```
|
||||
|
||||
### Helper PHP i18n
|
||||
|
||||
```php
|
||||
<?php
|
||||
// includes/i18n.php
|
||||
|
||||
function __($key, $lang = null) {
|
||||
global $translations_cache;
|
||||
|
||||
$lang = $lang ?? $_SESSION['lang'] ?? 'fr';
|
||||
|
||||
// Cache check
|
||||
if (!isset($translations_cache[$lang])) {
|
||||
// Load from DB or file cache
|
||||
$translations_cache[$lang] = loadTranslations($lang);
|
||||
}
|
||||
|
||||
return $translations_cache[$lang][$key] ?? $key;
|
||||
}
|
||||
|
||||
function loadTranslations($lang) {
|
||||
// Priorité : cache fichier > BDD
|
||||
$cacheFile = __DIR__ . "/../cache/translations_{$lang}.php";
|
||||
|
||||
if (file_exists($cacheFile) && filemtime($cacheFile) > time() - 3600) {
|
||||
return include $cacheFile;
|
||||
}
|
||||
|
||||
// Charger depuis MariaDB
|
||||
$pdo = getDbConnection();
|
||||
$stmt = $pdo->prepare("SELECT key_name, value FROM translations WHERE lang = ?");
|
||||
$stmt->execute([$lang]);
|
||||
|
||||
$translations = [];
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$translations[$row['key_name']] = $row['value'];
|
||||
}
|
||||
|
||||
// Sauvegarder en cache
|
||||
file_put_contents($cacheFile, '<?php return ' . var_export($translations, true) . ';');
|
||||
|
||||
return $translations;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Questions Ouvertes pour la Suite
|
||||
|
||||
1. **Design carte interactive** : Quel style visuel ? (minimaliste, illustré, isométrique...)
|
||||
2. **Contenu narrateur** : Quel ton exact ? Tutoiement confirmé ?
|
||||
3. **Challenge principal** : Quel type de puzzle ? (code, logique, exploration...)
|
||||
4. **Easter eggs** : Combien ? Quelles récompenses concrètes ?
|
||||
5. **Métriques** : Comment mesurer le succès de la gamification ?
|
||||
|
||||
---
|
||||
|
||||
## Prochaines Étapes Recommandées
|
||||
|
||||
| # | Action | Agent suggéré | Priorité |
|
||||
|---|--------|---------------|----------|
|
||||
| 1 | Créer le schéma BDD MariaDB | Dev | Haute |
|
||||
| 2 | Configurer i18n (helper + table translations) | Dev | Haute |
|
||||
| 3 | Intégrer Swup.js pour transitions seamless | Dev | Haute |
|
||||
| 4 | Maquettes/Wireframes de l'expérience | UX Expert | Haute |
|
||||
| 5 | Prototype carte interactive avec Konva.js | Dev | Moyenne |
|
||||
| 6 | Prototype skill tree avec vis.js | Dev | Moyenne |
|
||||
| 7 | Rédaction des textes du narrateur (FR + EN) | Analyst/PO | Moyenne |
|
||||
| 8 | Créer les stories techniques par phase | PM | Moyenne |
|
||||
|
||||
---
|
||||
|
||||
## Réflexion Session
|
||||
|
||||
### Ce qui a bien fonctionné
|
||||
- Role Playing pour identifier les vrais besoins des visiteurs
|
||||
- SCAMPER pour structurer l'exploration systématique
|
||||
- What If pour pousser les concepts audacieux
|
||||
- Yes And pour affiner et rester pragmatique
|
||||
- Discussion stack technique pour garder le projet réalisable
|
||||
|
||||
### Principes clés dégagés
|
||||
1. **Immersion avant tout** - Pas de navigation classique
|
||||
2. **Respect du temps** - Double entrée Aventure/Pressé
|
||||
3. **Expérience unique** - Chemins multiples, textes aléatoires
|
||||
4. **Gamification subtile** - Progression discrète, pas un jeu vidéo
|
||||
5. **Contact = récompense narrative** - Pas une corvée
|
||||
6. **Stack évolutive** - MariaDB pour faciliter les évolutions futures
|
||||
7. **International dès le départ** - FR + EN intégrés dans l'architecture
|
||||
|
||||
---
|
||||
|
||||
*Document généré lors de la session de brainstorming du 2026-01-26*
|
||||
*Facilitatrice : Mary (Business Analyst) - BMAD Method*
|
||||
@@ -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
|
||||
439
docs/planning-artifacts/architecture.md
Normal file
439
docs/planning-artifacts/architecture.md
Normal file
@@ -0,0 +1,439 @@
|
||||
---
|
||||
stepsCompleted: [1, 2, 3, 4]
|
||||
inputDocuments:
|
||||
- docs/prd-gamification.md
|
||||
- docs/planning-artifacts/ux-design-specification.md
|
||||
- docs/brainstorming-gamification-2026-01-26.md
|
||||
workflowType: 'architecture'
|
||||
project_name: 'skycel'
|
||||
user_name: 'Célian'
|
||||
date: '2026-02-01'
|
||||
---
|
||||
|
||||
# Architecture Decision Document
|
||||
|
||||
_This document builds collaboratively through step-by-step discovery. Sections are appended as we work through each architectural decision together._
|
||||
|
||||
## Project Context Analysis
|
||||
|
||||
### Requirements Overview
|
||||
|
||||
**Functional Requirements:**
|
||||
14 FRs couvrant : double entrée visiteur (FR1), transitions animées seamless (FR2), narrateur-guide contextuel (FR3), carte interactive Konva.js (FR4), arbre de compétences vis.js (FR5), compétences cliquables → projets (FR6), dialogues PNJ typewriter (FR7), barre de progression globale (FR8), chemins narratifs multiples 4-8 parcours (FR9), challenge/puzzle avant contact (FR10), easter eggs cachés (FR11), sauvegarde LocalStorage (FR12), bilingue FR/EN avec détection URL (FR13), contact comme récompense narrative (FR14).
|
||||
|
||||
Architecturalement, ces FRs dessinent un système à **forte interactivité côté client** avec un **backend API relativement simple** (CRUD + contact + progression). La complexité réside dans l'orchestration frontend : état de progression, navigation narrative adaptative, et composants lourds en lazy-loading.
|
||||
|
||||
**Non-Functional Requirements:**
|
||||
- **NFR1** : Bundle JS ≤ 170kb gzip (Nuxt + Konva + vis.js) avec lazy-loading
|
||||
- **NFR2** : LCP < 2.5s sur 3G
|
||||
- **NFR3** : Responsive avec expérience mobile adaptée (carte simplifiée)
|
||||
- **NFR4** : Navigateurs modernes (Chrome, Firefox, Safari, Edge — 2 dernières versions)
|
||||
- **NFR5** : URLs SEO-friendly, contenu accessible aux crawlers (SSR)
|
||||
- **NFR6** : Respect `prefers-reduced-motion` (accessibilité animations)
|
||||
- **NFR7** : i18n SSR via @nuxtjs/i18n avec fichiers JSON
|
||||
- **NFR8** : Images WebP avec lazy loading
|
||||
|
||||
Les NFRs les plus structurants pour l'architecture sont le budget JS (NFR1), le SSR pour SEO (NFR5/NFR7), et le responsive avec deux paradigmes de navigation (NFR3).
|
||||
|
||||
**Scale & Complexity:**
|
||||
|
||||
- Domaine principal : Full-stack web (Nuxt 4 SSR + Laravel 12 API REST)
|
||||
- Niveau de complexité : **Moyenne-haute** — richesse des interactions frontend, faible volume de données
|
||||
- Composants architecturaux estimés : ~15-20 (pages, composants custom, stores, composables, API endpoints, modèles)
|
||||
|
||||
### Technical Constraints & Dependencies
|
||||
|
||||
- **Nuxt 4 SSR** : Impose une architecture hybride serveur/client avec nouvelle structure `app/`. Les composants Konva.js et vis.js doivent être exclusivement client-side (`.client.vue`)
|
||||
- **Laravel 12 API-only** : Backend découplé, communication via API REST JSON. CORS requis. Upgrade vers Laravel 13 prévu dès sa sortie stable (Q1 2026)
|
||||
- **MariaDB** : Schéma relationnel défini dans le brainstorming (7 tables). Migration vers Eloquent ORM
|
||||
- **Budget JS 170kb** : Konva (~50kb) + vis-network (~50kb) + Nuxt (~50kb) = marge très faible. Stratégie de lazy-loading critique
|
||||
- **Monorepo** : `/frontend` (Nuxt) + `/api` (Laravel) dans le même repo — décision validée pour un projet solo avec frontend/backend fortement couplés
|
||||
- **Hébergement dual** : Node.js pour Nuxt SSR + PHP 8.2+ pour Laravel — deux runtimes distincts
|
||||
|
||||
### Cross-Cutting Concerns Identified
|
||||
|
||||
1. **Gestion d'état & progression** : Le store Pinia `useProgressionStore` irrigue toute l'application — carte, narrateur, barre XP, déblocage contact, easter eggs. Doit être persisté (LocalStorage) et compatible SSR
|
||||
2. **Internationalisation (i18n)** : Bilingue FR/EN à travers toutes les couches — SSR, API responses, textes narrateur, dialogues PNJ, challenges. Stratégie `prefix_except_default` pour URLs
|
||||
3. **Système de héros** : Le choix du personnage (Recruteur/Client/Dev) impacte le vouvoiement, le ton du narrateur, le contenu des challenges, et potentiellement l'ordre des suggestions. Transversal à toute la couche de présentation
|
||||
4. **Accessibilité (WCAG AA)** : Contraste, navigation clavier, `prefers-reduced-motion`, screen readers, skip links. Impacte chaque composant custom (carte, PNJ, narrateur, skill tree)
|
||||
5. **Performance & lazy-loading** : Composants lourds (Konva, vis.js) chargés à la demande. Images WebP, fonts variables, SSR pour le premier rendu. Budget strict
|
||||
|
||||
## Starter Template Evaluation
|
||||
|
||||
### Primary Technology Domain
|
||||
|
||||
Full-stack web (Nuxt 4 SSR + Laravel API REST) basé sur l'analyse des exigences projet.
|
||||
|
||||
### Starter Options Considered
|
||||
|
||||
| Option | Version | Statut | Notes |
|
||||
|--------|---------|--------|-------|
|
||||
| **Nuxt 4** | 4.3+ | Stable (juillet 2025) | Nouvelle structure `app/`, TypeScript strict, data fetching amélioré |
|
||||
| ~~Nuxt 3~~ | 3.21 | EOL juillet 2026 | Écarté — fin de vie trop proche pour un nouveau projet |
|
||||
| **Laravel 12** | 12.x | Stable (février 2025) | Release de maintenance, support bugs jusqu'en août 2026 |
|
||||
| Laravel 13 | 13.x | Imminent (Q1 2026) | PHP 8.3+, support jusqu'en 2028. Upgrade depuis 12 attendu facile |
|
||||
|
||||
### Selected Starters
|
||||
|
||||
**Frontend : Nuxt 4**
|
||||
|
||||
**Rationale :** Version stable et actuelle. Structure `app/` plus propre, meilleur TypeScript, Nuxt 3 en fin de vie. Tous les modules clés (@nuxtjs/i18n, @pinia/nuxt, @nuxtjs/tailwindcss, nuxt/image, @nuxtjs/sitemap) sont compatibles Nuxt 4.
|
||||
|
||||
**Initialization Command :**
|
||||
|
||||
```bash
|
||||
npx nuxi@latest init frontend
|
||||
```
|
||||
|
||||
**Backend : Laravel 12 (upgrade vers 13 dès sa sortie)**
|
||||
|
||||
**Rationale :** Stable et maintenu. Laravel 13 est imminent mais pas encore sorti. Démarrer sur 12 avec PHP 8.2+ permet de commencer immédiatement. L'upgrade vers 13 sera minimal.
|
||||
|
||||
```bash
|
||||
composer create-project laravel/laravel api
|
||||
```
|
||||
|
||||
### Architectural Decisions Provided by Starters
|
||||
|
||||
**Nuxt 4 fournit :**
|
||||
- Structure `app/` avec auto-imports (components, composables, utils)
|
||||
- SSR natif avec hydration client
|
||||
- Routing fichier-based (`app/pages/`)
|
||||
- Nitro comme serveur (build optimisé)
|
||||
- TypeScript par défaut
|
||||
- DevTools intégrés
|
||||
|
||||
**Laravel 12 fournit :**
|
||||
- Structure MVC avec Eloquent ORM
|
||||
- Routing API (`routes/api.php`)
|
||||
- Migration system pour le schéma BDD
|
||||
- Form Requests pour la validation
|
||||
- API Resources pour les transformations JSON
|
||||
- Rate limiting, CORS, middleware stack
|
||||
- Pest/PHPUnit pour les tests
|
||||
|
||||
### Structure Monorepo (Nuxt 4)
|
||||
|
||||
```
|
||||
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
|
||||
│ └── package.json
|
||||
├── api/ # Backend Laravel 12
|
||||
│ ├── app/
|
||||
│ ├── database/
|
||||
│ ├── routes/
|
||||
│ ├── config/
|
||||
│ ├── tests/
|
||||
│ └── composer.json
|
||||
├── docs/ # Documentation projet
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Third-Party Services
|
||||
|
||||
| Service | Solution | Intégration |
|
||||
|---------|----------|-------------|
|
||||
| **Email** | PHPMailer via Laravel Mail | Backend |
|
||||
| **Anti-spam** | Google reCAPTCHA v3 | Frontend + Backend validation |
|
||||
| **Images** | `nuxt/image` + Sharp | Frontend (local) |
|
||||
| **Sitemap** | `@nuxtjs/sitemap` | Frontend |
|
||||
| **Analytics** | Matomo (self-hosted) | Frontend script |
|
||||
| **Error tracking** | Sentry | Frontend + Backend |
|
||||
| **Monitoring** | Uptime Kuma | Externe (existant) |
|
||||
| **Backups BDD** | Script cron mysqldump | Serveur |
|
||||
|
||||
## Core Architectural Decisions
|
||||
|
||||
### Decision Priority Analysis
|
||||
|
||||
**Critical Decisions (Block Implementation) :**
|
||||
- Stratégie i18n hybride (JSON statique + table translations centralisée)
|
||||
- Architecture API REST avec API Key + CORS strict
|
||||
- Structure composants frontend (ui / feature / layout)
|
||||
- Stratégie lazy-loading pour respecter le budget JS ≤ 170kb
|
||||
- Store Pinia de progression avec persistance LocalStorage
|
||||
|
||||
**Important Decisions (Shape Architecture) :**
|
||||
- Abandon Swup.js → transitions Nuxt natives + GSAP
|
||||
- Double validation frontend + backend
|
||||
- Format de réponse API Resources avec enveloppe standard
|
||||
- Bandeau RGPD intégré à l'immersion narrative
|
||||
- Environnement staging avec sous-domaine
|
||||
|
||||
**Deferred Decisions (Post-MVP) :**
|
||||
- Endpoints CRUD admin protégés par tokens exclusifs (après MVP)
|
||||
- Upgrade Laravel 12 → 13 (dès sortie stable)
|
||||
- Sauvegarde cloud de progression via email (Phase 2)
|
||||
|
||||
### Data Architecture
|
||||
|
||||
**Stratégie i18n : Hybride**
|
||||
- **Contenu statique UI** : Fichiers JSON via @nuxtjs/i18n (`i18n/fr.json`, `i18n/en.json`). Labels, boutons, messages d'interface, textes de navigation
|
||||
- **Contenu dynamique** : Table `translations` centralisée en MariaDB. Les tables métier (projects, skills, testimonials, narrator_texts, easter_eggs) stockent des clés i18n (`title_key`, `text_key`). La table `translations` contient les valeurs par langue
|
||||
- **Rationale** : Flexibilité pour ajouter une langue sans modifier le schéma. Séparation claire entre UI (déployée avec le frontend) et contenu (géré via API/BDD)
|
||||
|
||||
**Schéma table translations :**
|
||||
|
||||
```sql
|
||||
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)
|
||||
);
|
||||
```
|
||||
|
||||
**Cache : File cache Laravel**
|
||||
- Driver : `file` (config `CACHE_DRIVER=file`)
|
||||
- Suffisant pour la volumétrie d'un portfolio (faible nombre de requêtes, peu de données)
|
||||
- Pas de dépendance externe (Redis non requis)
|
||||
|
||||
**Validation : Double validation**
|
||||
- **Frontend (Nuxt)** : Validation légère en temps réel pour l'UX (champs requis, format email, longueur). Via les composables Vue ou VeeValidate
|
||||
- **Backend (Laravel)** : Validation complète via Form Requests. Source de vérité pour la sécurité. Rejette toute donnée invalide avec réponse 422
|
||||
|
||||
### Authentication & Security
|
||||
|
||||
**Protection formulaire de contact :**
|
||||
- Google reCAPTCHA v3 (invisible, score-based) côté frontend
|
||||
- Honeypot field (champ caché) comme seconde couche
|
||||
- Rate limiting Laravel : 5 requêtes/minute par IP sur `POST /api/contact`
|
||||
|
||||
**Sécurité API :**
|
||||
- **API Key** : Token partagé entre Nuxt et Laravel via header `X-API-Key`. Stocké dans les `.env` des deux applications. Middleware Laravel vérifie la présence et validité du token sur chaque requête
|
||||
- **CORS strict** : N'accepte que le domaine du frontend (`Access-Control-Allow-Origin: https://skycel.fr`)
|
||||
- **Endpoints CRUD admin** (post-MVP) : Protégés par tokens exclusifs différents de l'API Key publique. Middleware dédié avec permissions granulaires
|
||||
|
||||
**Protection des données visiteur :**
|
||||
- Session ID : UUID v4 généré côté client, stocké en LocalStorage
|
||||
- Email : Optionnel, uniquement pour la sauvegarde cloud de progression
|
||||
- Pas de tracking sans consentement
|
||||
|
||||
**RGPD :**
|
||||
- Bandeau de consentement intégré à l'immersion narrative (dialogue PNJ ou narrateur araignée, style "pacte d'aventurier")
|
||||
- Consentement requis avant activation de Matomo et stockage LocalStorage de progression
|
||||
- État du consentement stocké dans le store Pinia (`consentGiven`) et persisté en LocalStorage
|
||||
|
||||
### API & Communication Patterns
|
||||
|
||||
**Design pattern : REST classique**
|
||||
|
||||
Endpoints publics (lecture) :
|
||||
|
||||
| Méthode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| `GET` | `/api/projects` | Liste des projets |
|
||||
| `GET` | `/api/projects/{slug}` | Détail d'un projet |
|
||||
| `GET` | `/api/skills` | Arbre de compétences |
|
||||
| `GET` | `/api/testimonials` | Témoignages PNJ |
|
||||
| `GET` | `/api/narrator/{context}` | Textes narrateur par contexte |
|
||||
| `GET` | `/api/easter-eggs` | Métadonnées easter eggs (pas les réponses) |
|
||||
| `GET` | `/api/progress/{session_id}` | Récupérer progression |
|
||||
| `POST` | `/api/progress` | Sauvegarder progression |
|
||||
| `POST` | `/api/contact` | Formulaire contact (rate limited + reCAPTCHA) |
|
||||
|
||||
Endpoints admin CRUD (post-MVP, tokens exclusifs) :
|
||||
|
||||
| Méthode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| `POST` | `/api/admin/projects` | Créer un projet |
|
||||
| `PUT` | `/api/admin/projects/{id}` | Modifier un projet |
|
||||
| `DELETE` | `/api/admin/projects/{id}` | Supprimer un projet |
|
||||
| _idem_ | _pour skills, testimonials, narrator, easter-eggs_ | _CRUD complet_ |
|
||||
|
||||
**Gestion de la langue : Header `Accept-Language`**
|
||||
- Le frontend Nuxt envoie `Accept-Language: fr` ou `Accept-Language: en` dans chaque requête API
|
||||
- Middleware Laravel extrait la langue et la passe au query builder pour joindre la table `translations`
|
||||
- Fallback : `fr` si header absent ou langue non supportée
|
||||
|
||||
**Format de réponse : Laravel API Resources**
|
||||
|
||||
Réponse standard :
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{ "id": 1, "slug": "skycel", "title": "Skycel Portfolio", "..." : "..." }
|
||||
],
|
||||
"meta": {
|
||||
"total": 5,
|
||||
"lang": "fr"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Gestion d'erreurs : Format standard**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Le champ email est requis",
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Codes HTTP : 400 (bad request), 401 (API key invalide), 404 (not found), 422 (validation), 429 (rate limit), 500 (erreur serveur)
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
**Architecture des composants :**
|
||||
|
||||
```
|
||||
app/components/
|
||||
├── ui/ # Composants atomiques réutilisables
|
||||
│ ├── BaseButton.vue
|
||||
│ ├── BaseBadge.vue
|
||||
│ ├── BaseModal.vue
|
||||
│ └── ...
|
||||
├── feature/ # Composants métier
|
||||
│ ├── NarratorDialogue.vue
|
||||
│ ├── PnjCard.vue
|
||||
│ ├── SkillTree.client.vue # Client-only (vis-network)
|
||||
│ ├── InteractiveMap.client.vue # Client-only (Konva.js)
|
||||
│ ├── ProgressBar.vue
|
||||
│ ├── HeroSelector.vue
|
||||
│ ├── ChallengePanel.vue
|
||||
│ └── EasterEgg.vue
|
||||
├── layout/ # Structure de page
|
||||
│ ├── AppHeader.vue
|
||||
│ ├── AppFooter.vue
|
||||
│ ├── ConsentBanner.vue # RGPD immersif
|
||||
│ └── NarratorOverlay.vue
|
||||
```
|
||||
|
||||
**Stratégie lazy-loading :**
|
||||
|
||||
| Couche | Chargement | Poids estimé (gzip) |
|
||||
|--------|------------|---------------------|
|
||||
| Nuxt core + Vue + Pinia | Immédiat | ~50kb |
|
||||
| TailwindCSS (purgé) | Immédiat | ~10kb |
|
||||
| Pages | Lazy (navigation) | ~5-10kb/page |
|
||||
| Konva.js | Lazy (page carte desktop uniquement) | ~50kb |
|
||||
| vis-network | Lazy (page skills uniquement) | ~50kb |
|
||||
| GSAP | Lazy (première animation complexe) | ~25kb |
|
||||
| reCAPTCHA v3 | Lazy (page contact uniquement) | Externe |
|
||||
|
||||
Budget initial (premier chargement) : **~60-70kb gzip** — bien sous le budget de 170kb. Les librairies lourdes ne se chargent qu'à la demande.
|
||||
|
||||
**Store Pinia `useProgressionStore` :**
|
||||
|
||||
```typescript
|
||||
interface ProgressionState {
|
||||
sessionId: string // UUID v4
|
||||
hero: 'recruteur' | 'client' | 'dev' | null
|
||||
currentPath: string // Chemin narratif actuel
|
||||
visitedSections: string[] // Sections visitées
|
||||
completionPercent: number // 0-100
|
||||
easterEggsFound: string[] // Slugs des easter eggs trouvés
|
||||
challengeCompleted: boolean
|
||||
contactUnlocked: boolean
|
||||
narratorStage: number // 1-5 (évolution de l'araignée)
|
||||
choices: Record<string, string> // Choix narratifs
|
||||
consentGiven: boolean // RGPD
|
||||
}
|
||||
```
|
||||
|
||||
- Persistance : `pinia-plugin-persistedstate` → LocalStorage
|
||||
- Synchronisation API : `POST /api/progress` déclenché quand le visiteur fournit son email (sauvegarde cloud optionnelle)
|
||||
- Compatibilité SSR : Le store s'initialise vide côté serveur, se réhydrate côté client depuis LocalStorage
|
||||
|
||||
**Transitions et animations :**
|
||||
- **Transitions de page** : Système natif Nuxt/Vue (`<NuxtPage>` + `<Transition>`) avec CSS animations
|
||||
- **Animations complexes** : GSAP (narrateur araignée, révélation progressive, transitions de zone immersives)
|
||||
- **Swup.js** : Abandonné — redondant avec les transitions Nuxt natives et potentiellement conflictuel
|
||||
- **`prefers-reduced-motion`** : Respecté via media query, animations réduites ou désactivées
|
||||
|
||||
### Infrastructure & Deployment
|
||||
|
||||
**Architecture serveur :**
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ Nginx (port 80/443) │
|
||||
│ SSL + gzip + cache │
|
||||
└─────────┬───────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ Node.js :3000 │ │ PHP-FPM :9000 │
|
||||
│ Nuxt 4 SSR │ │ Laravel 12 API │
|
||||
└───────────────────┘ └───────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ MariaDB :3306 │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
- Nginx dispatch : `/api/*` → PHP-FPM, tout le reste → Node.js (Nuxt SSR)
|
||||
- SSL via Let's Encrypt (certbot)
|
||||
- Compression gzip activée
|
||||
- Headers de cache pour les assets statiques
|
||||
|
||||
**Gestion des environnements :**
|
||||
- **Production** : `skycel.fr` — branche `prod`
|
||||
- **Staging** : `staging.skycel.fr` — branche `staging` ou `main`
|
||||
- Fichiers `.env` distincts par environnement et par application (`frontend/.env.production`, `frontend/.env.staging`, `api/.env.production`, `api/.env.staging`)
|
||||
|
||||
**CI/CD : Script `deploy.sh` manuel**
|
||||
|
||||
```bash
|
||||
# Déploiement déclenché manuellement
|
||||
# Se base sur la branche 'prod'
|
||||
./deploy.sh [production|staging]
|
||||
```
|
||||
|
||||
Le script automatise :
|
||||
1. `git pull origin prod` (ou staging)
|
||||
2. `cd frontend && npm install && npm run build`
|
||||
3. `cd api && composer install --no-dev && php artisan migrate --force`
|
||||
4. `php artisan config:cache && php artisan route:cache`
|
||||
5. Restart du process Node.js (PM2 ou systemd)
|
||||
6. Notification de succès/échec
|
||||
|
||||
**Backups BDD :**
|
||||
- Cron quotidien à 3h00 : `mysqldump` complet de la base skycel
|
||||
- Rétention locale : 7 jours (rotation automatique, suppression des dumps > 7j)
|
||||
- Réplication : Copie automatique vers un serveur distant via `rsync` ou `scp` après chaque dump
|
||||
- Nommage : `skycel_backup_YYYY-MM-DD_HH-MM.sql.gz` (compressé)
|
||||
|
||||
### Decision Impact Analysis
|
||||
|
||||
**Séquence d'implémentation recommandée :**
|
||||
1. Initialisation monorepo (Nuxt 4 + Laravel 12)
|
||||
2. Configuration Nginx + environnements (.env, staging)
|
||||
3. Schéma BDD + migrations + table translations
|
||||
4. API endpoints publics (lecture) + middleware API Key + CORS
|
||||
5. Store Pinia progression + persistance LocalStorage
|
||||
6. Composants layout + transitions Nuxt natives
|
||||
7. Pages et composants feature (par epic)
|
||||
8. Intégrations tierces (reCAPTCHA, Matomo, Sentry)
|
||||
9. Script deploy.sh + cron backup
|
||||
10. Endpoints CRUD admin (post-MVP)
|
||||
|
||||
**Dépendances inter-composants :**
|
||||
- Le store Pinia dépend du schéma de progression (BDD + API)
|
||||
- Les composants feature dépendent de l'API (endpoints + format de réponse)
|
||||
- L'i18n frontend dépend de la table translations (contenu dynamique)
|
||||
- Le bandeau RGPD doit être en place avant l'activation de Matomo
|
||||
- Le lazy-loading des composants lourds dépend de la structure de routing Nuxt
|
||||
801
docs/planning-artifacts/epics.md
Normal file
801
docs/planning-artifacts/epics.md
Normal file
@@ -0,0 +1,801 @@
|
||||
---
|
||||
stepsCompleted: [1, 2, 3, 4]
|
||||
status: complete
|
||||
validatedAt: '2026-02-03'
|
||||
inputDocuments:
|
||||
- docs/prd-gamification.md
|
||||
- docs/planning-artifacts/architecture.md
|
||||
- docs/planning-artifacts/ux-design-specification.md
|
||||
- docs/brainstorming-gamification-2026-01-26.md
|
||||
---
|
||||
|
||||
# skycel - Epic Breakdown
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides the complete epic and story breakdown for skycel, decomposing the requirements from the PRD, UX Design if it exists, and Architecture requirements into implementable stories.
|
||||
|
||||
## Requirements Inventory
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR1** : Le système offre une double entrée au visiteur : "Partir à l'aventure" (expérience complète) ou "Je n'ai pas le temps" (mode express avec roadmap)
|
||||
- **FR2** : Les transitions entre pages sont animées de manière seamless via Nuxt transitions, créant une impression de "changement de zone" immersive
|
||||
- **FR3** : Un narrateur-guide accompagne le visiteur avec des textes contextuels tout au long de l'expérience
|
||||
- **FR4** : Une carte interactive (Konva.js) permet la navigation non-linéaire et affiche la progression du visiteur
|
||||
- **FR5** : Un arbre de compétences interactif (vis.js) visualise les skills avec niveaux évoluant selon les projets
|
||||
- **FR6** : Les compétences sont cliquables et mènent directement aux projets qui les utilisent
|
||||
- **FR7** : Les témoignages s'affichent sous forme de dialogues PNJ style Zelda avec avatar, bulles, effet typewriter et personnalités variées
|
||||
- **FR8** : Une barre de progression globale indique l'avancement dans l'exploration du site
|
||||
- **FR9** : Le système propose 2-3 choix binaires créant 4-8 parcours narratifs différents, tous menant au contact
|
||||
- **FR10** : Un challenge/puzzle accessible doit être résolu pour accéder au formulaire de contact (avec système d'indices)
|
||||
- **FR11** : Des easter eggs cachés récompensent l'exploration avec des snippets de code ou anecdotes
|
||||
- **FR12** : La progression est sauvegardée automatiquement en LocalStorage pour permettre la reprise
|
||||
- **FR13** : Le site supporte deux langues (FR par défaut + EN) avec détection par URL (/en/...)
|
||||
- **FR14** : Le formulaire de contact est présenté comme la récompense finale "Tu m'as trouvé !" avec célébration
|
||||
|
||||
### NonFunctional Requirements
|
||||
|
||||
- **NFR1** : Le bundle JS total (Nuxt + Konva + vis.js) ne doit pas dépasser 170kb gzippé, avec lazy-loading des composants lourds
|
||||
- **NFR2** : Le temps de chargement initial (LCP) doit rester sous 2.5 secondes sur connexion 3G
|
||||
- **NFR3** : Le site doit être responsive et offrir une expérience adaptée mobile (carte simplifiée en Chemin Libre vertical)
|
||||
- **NFR4** : Le site doit fonctionner sur les navigateurs modernes (Chrome, Firefox, Safari, Edge — 2 dernières versions)
|
||||
- **NFR5** : Les URLs doivent être SEO-friendly et le contenu principal accessible aux crawlers (SSR Nuxt 4)
|
||||
- **NFR6** : Les animations doivent respecter `prefers-reduced-motion` pour l'accessibilité
|
||||
- **NFR7** : Le système i18n utilise @nuxtjs/i18n avec fichiers JSON, rendu SSR pour SEO optimal
|
||||
- **NFR8** : Les images sont optimisées en WebP avec lazy loading
|
||||
|
||||
### Additional Requirements
|
||||
|
||||
**Architecture :**
|
||||
- Starter template : Nuxt 4 (`npx nuxi@latest init`) + Laravel 12 (`composer create-project`) — impacte Epic 1 Story 1
|
||||
- Structure monorepo `frontend/` (Nuxt 4 avec structure `app/`) + `api/` (Laravel 12)
|
||||
- Table `translations` centralisée en MariaDB pour i18n du contenu dynamique (clés i18n dans les tables métier)
|
||||
- API REST avec API Key (`X-API-Key`) + CORS strict (domaine frontend uniquement)
|
||||
- Store Pinia `useProgressionStore` avec persistance LocalStorage via `pinia-plugin-persistedstate` + compatibilité SSR
|
||||
- Architecture composants frontend : `ui/` (atomiques réutilisables), `feature/` (métier), `layout/` (structure page)
|
||||
- GSAP pour animations complexes (Swup.js abandonné — redondant avec transitions Nuxt natives)
|
||||
- Transitions Nuxt natives (`pageTransition` + `<Transition>`) + CSS animations
|
||||
- Sécurité contact : reCAPTCHA v3 (invisible) + honeypot + rate limiting Laravel (5 req/min par IP)
|
||||
- Bandeau RGPD intégré à l'immersion narrative (dialogue PNJ/narrateur, style "pacte d'aventurier")
|
||||
- Déploiement : Nginx → Node.js :3000 (Nuxt SSR) + PHP-FPM :9000 (Laravel API) + MariaDB :3306
|
||||
- CI/CD : Script `deploy.sh` manuel (git pull, build, migrate, restart)
|
||||
- Backups BDD : cron quotidien mysqldump + réplication distante rsync/scp, rétention 7 jours
|
||||
- Environnements : production (`skycel.fr`, branche `prod`) + staging (`staging.skycel.fr`)
|
||||
- Cache : File cache Laravel (driver `file`, pas de Redis)
|
||||
- Double validation : frontend (UX temps réel) + backend (Form Requests Laravel, source de vérité)
|
||||
- Format réponse API : Laravel API Resources avec enveloppe `{ data, meta }`
|
||||
- Gestion langue API : header `Accept-Language` → middleware Laravel → jointure table translations
|
||||
|
||||
**UX :**
|
||||
- Système de héros : 3 personnages (Recruteur/Client/Dev) impactant vouvoiement, ton narrateur, type de challenges
|
||||
- Narrateur = "Le Bug" (araignée, mascotte micro-entreprise) avec arc de révélation progressive (silhouette sombre → araignée complète en 5 étapes liées à la progression)
|
||||
- Page résumé 30s (`/resume`) : URL directe pour candidatures, accès sans passer par la landing
|
||||
- Déblocage contact après 2 zones visitées (pas de blocage excessif)
|
||||
- Challenge final optionnel (jamais bloquant l'accès au contact)
|
||||
- Challenge post-formulaire ("En attendant que le dev retrouve sa boîte mail...")
|
||||
- "Monde de Code" : révélation finale — paysage en blocs de code ASCII art, avatar Célian au centre
|
||||
- Navigation mobile : "Chemin Libre" vertical (ZoneCards scrollables) au lieu de Konva.js
|
||||
- Bottom bar mobile fixe : Carte, Progression, Paramètres (thumb zone)
|
||||
- Tous les feedbacks système passent par le narrateur (pas de toasts/notifications classiques)
|
||||
- Headless UI / Radix UI pour composants standards accessibles (modals, tooltips, toggles, menus)
|
||||
- Design tokens Tailwind : `sky-dark` (noir→bleu), `sky-accent` (#fa784f orange), `sky-text` (blanc cassé→jaune)
|
||||
- Polices : serif élégante (narrateur/PNJ/narration) + sans-serif moderne (interface/UI)
|
||||
- Approche Mobile First CSS
|
||||
- WCAG AA : contraste ≥ 4.5:1, touch targets 44x44px min, skip links, `aria-live="polite"` narrateur, navigation clavier complète
|
||||
- Sortie de zone par choix narratif du narrateur (pas de bouton "retour" froid)
|
||||
- Couleurs par zone sur la carte (teintes uniques par section)
|
||||
- Espacement aéré : spacing scale de 4px à 128px
|
||||
|
||||
**Brainstorming :**
|
||||
- Schéma BDD détaillé : 8 tables (projects, skills, skill_project, testimonials, narrator_texts, easter_eggs, user_progress, translations)
|
||||
- Personnalités PNJ : sage, sarcastique, enthousiaste, professionnel
|
||||
- Easter eggs : triggers variés (click, hover, konami, scroll, sequence)
|
||||
- Rewards easter eggs : snippet, anecdote, image, badge
|
||||
- Sauvegarde cloud progression par email (optionnel, phase 2)
|
||||
- Rappel email narratif après X jours (hors scope MVP)
|
||||
|
||||
### FR Coverage Map
|
||||
|
||||
| FR | Epic | Description |
|
||||
|---|---|---|
|
||||
| FR1 | Epic 1 | Double entrée Aventure / Mode Express |
|
||||
| FR2 | Epic 1 | Transitions de page animées seamless |
|
||||
| FR3 | Epic 3 | Narrateur-guide contextuel |
|
||||
| FR4 | Epic 3 | Carte interactive navigation non-linéaire |
|
||||
| FR5 | Epic 2 | Arbre de compétences interactif |
|
||||
| FR6 | Epic 2 | Compétences cliquables → projets liés |
|
||||
| FR7 | Epic 2 | Témoignages dialogues PNJ |
|
||||
| FR8 | Epic 3 | Barre de progression globale |
|
||||
| FR9 | Epic 4 | Choix binaires créant parcours multiples |
|
||||
| FR10 | Epic 4 | Challenge/puzzle avant contact |
|
||||
| FR11 | Epic 4 | Easter eggs cachés |
|
||||
| FR12 | Epic 3 | Sauvegarde progression LocalStorage |
|
||||
| FR13 | Epic 1 | Bilingue FR/EN |
|
||||
| FR14 | Epic 4 | Contact comme récompense finale |
|
||||
|
||||
## Epic List
|
||||
|
||||
### Epic 1 : Fondations & Double Entrée
|
||||
Le visiteur arrive sur le site, choisit son héros et son mode (Aventure ou Express), et peut naviguer entre les pages avec des transitions immersives. Le site est bilingue et fonctionnel en SSR.
|
||||
**FRs couverts :** FR1, FR2, FR13
|
||||
|
||||
### Epic 2 : Contenu & Découverte
|
||||
Le visiteur explore les zones de contenu : projets (galerie + détail), compétences organisées par catégories avec liens vers les projets associés, témoignages en dialogues PNJ, et parcours en timeline narrative.
|
||||
**FRs couverts :** FR5, FR6, FR7
|
||||
|
||||
### Epic 3 : Navigation Gamifiée & Progression
|
||||
Le visiteur navigue via la carte interactive (Konva.js desktop / Chemin Libre mobile), est accompagné par le narrateur-guide (Le Bug), et voit sa progression sauvegardée automatiquement avec une barre XP.
|
||||
**FRs couverts :** FR3, FR4, FR8, FR12
|
||||
|
||||
### Epic 4 : Chemins Narratifs, Challenge & Contact
|
||||
Le visiteur fait des choix qui créent son parcours unique, relève un challenge optionnel, et accède à la révélation finale "Monde de Code" + formulaire de contact comme récompense narrative. Les easter eggs récompensent l'exploration.
|
||||
**FRs couverts :** FR9, FR10, FR11, FR14
|
||||
|
||||
---
|
||||
|
||||
## Epic 1 : Fondations & Double Entrée
|
||||
|
||||
Le visiteur arrive sur le site, choisit son héros et son mode (Aventure ou Express), et peut naviguer entre les pages avec des transitions immersives. Le site est bilingue et fonctionnel en SSR.
|
||||
|
||||
### Story 1.1 : Initialisation du monorepo et infrastructure
|
||||
|
||||
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:**
|
||||
|
||||
**Given** un nouveau repository Git
|
||||
**When** le projet est initialisé
|
||||
**Then** la structure monorepo `frontend/` (Nuxt 4) + `api/` (Laravel 12) est en place
|
||||
**And** Nuxt 4 est configuré avec SSR activé, TypeScript, et les modules `@nuxtjs/i18n`, `@nuxtjs/tailwindcss`, `@pinia/nuxt`, `nuxt/image`, `@nuxtjs/sitemap`
|
||||
**And** Laravel 12 est configuré en mode API-only avec CORS autorisant le domaine frontend
|
||||
**And** le middleware API Key (`X-API-Key`) est en place sur les routes API
|
||||
**And** les fichiers `.env.example` existent pour frontend et backend
|
||||
**And** TailwindCSS est configuré avec les design tokens (`sky-dark`, `sky-accent` #fa784f, `sky-text`)
|
||||
**And** les polices sont définies (serif narrateur + sans-serif UI)
|
||||
**And** le `.gitignore` est approprié pour les deux applications
|
||||
|
||||
### Story 1.2 : Base de données et migrations initiales
|
||||
|
||||
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:**
|
||||
|
||||
**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)
|
||||
**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)
|
||||
**And** la table `skills` est créée (id, slug, name_key, description_key, icon, category, max_level, display_order)
|
||||
**And** la table `skill_project` est créée (id, skill_id, project_id, level_before, level_after, level_description_key) avec foreign keys
|
||||
**And** les Models Eloquent sont définis avec leurs relations (Project belongsToMany Skill, etc.)
|
||||
**And** des Seeders de base sont disponibles avec données de test en FR et EN
|
||||
**And** `php artisan db:seed` fonctionne correctement
|
||||
|
||||
### Story 1.3 : Système i18n frontend + API bilingue
|
||||
|
||||
As a visiteur,
|
||||
I want voir le site dans ma langue (FR ou EN),
|
||||
So that je comprends le contenu.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**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`)
|
||||
**And** les URLs FR sont par défaut (`/`, `/projets`, `/competences`, `/contact`)
|
||||
**And** les URLs EN sont préfixées (`/en`, `/en/projects`, `/en/skills`, `/en/contact`)
|
||||
**And** `useI18n()`, `$t()`, `localePath()`, `switchLocalePath()` fonctionnent en SSR
|
||||
**And** les tags `hreflang` sont générés automatiquement dans le `<head>`
|
||||
**And** l'attribut `lang` du `<html>` est dynamique (fr/en)
|
||||
**And** le middleware Laravel extrait `Accept-Language` et joint la table `translations` pour le contenu dynamique
|
||||
**And** les API Resources Laravel renvoient le contenu traduit selon la langue demandée
|
||||
**And** le fallback est FR si langue non supportée
|
||||
|
||||
### Story 1.4 : Layouts, routing et transitions de page
|
||||
|
||||
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:**
|
||||
|
||||
**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`
|
||||
**And** la navigation utilise `<NuxtLink>` pour l'hydration SPA (pas de rechargement)
|
||||
**And** le layout par défaut (`default.vue`) inclut le header avec barre de progression (placeholder) et sélecteur de langue
|
||||
**And** un layout `minimal.vue` existe pour le mode express
|
||||
**And** le `scrollBehavior` est personnalisé (smooth scroll, retour position sauvegardée)
|
||||
**And** `prefers-reduced-motion` désactive les animations de transition via media query CSS
|
||||
**And** une page 404 (`error.vue`) bilingue est en place
|
||||
**And** les meta tags SEO dynamiques fonctionnent via `useHead()` et `useSeoMeta()`
|
||||
**And** le favicon est configuré
|
||||
|
||||
### Story 1.5 : Landing page et choix du héros
|
||||
|
||||
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:**
|
||||
|
||||
**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"
|
||||
**And** un texte d'accroche intrigant bilingue est affiché
|
||||
**And** une animation d'entrée subtile est présente (respectant `prefers-reduced-motion`)
|
||||
**And** le design est responsive (mobile + desktop)
|
||||
**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
|
||||
**And** le héros sélectionné est stocké dans le store Pinia `useProgressionStore` (champ `hero`)
|
||||
**And** au clic sur "Mode express", le visiteur est redirigé vers la page résumé
|
||||
**And** le `HeroSelector` est accessible au clavier (`role="radiogroup"`, flèches pour naviguer, Enter pour sélectionner)
|
||||
|
||||
### Story 1.6 : Store Pinia progression et bandeau RGPD
|
||||
|
||||
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:**
|
||||
|
||||
**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)
|
||||
**And** le store Pinia `useProgressionStore` est initialisé avec : sessionId (UUID v4), hero, currentPath, visitedSections, completionPercent, easterEggsFound, challengeCompleted, contactUnlocked, narratorStage, choices, consentGiven
|
||||
**And** la persistance LocalStorage est activée via `pinia-plugin-persistedstate` (uniquement après consentement)
|
||||
**And** le store est compatible SSR (initialisation vide côté serveur, réhydratation client)
|
||||
**And** si une progression existante est détectée, un message "Bienvenue à nouveau" est affiché
|
||||
**And** l'action `$reset()` permet de réinitialiser la progression
|
||||
|
||||
### Story 1.7 : Page résumé express et mode pressé
|
||||
|
||||
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:**
|
||||
|
||||
**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)
|
||||
**And** les compétences clés avec stack technique sont visibles (10s)
|
||||
**And** 3-4 projets highlights avec liens sont affichés (10s)
|
||||
**And** un CTA de contact direct est visible (5s)
|
||||
**And** un bouton discret "Voir l'aventure" invite à l'expérience complète
|
||||
**And** la page est fonctionnelle en FR et EN
|
||||
**And** les données sont chargées depuis l'API (projets, skills)
|
||||
**And** les meta tags SEO sont optimisés pour cette page
|
||||
**And** le layout `minimal.vue` est utilisé
|
||||
|
||||
---
|
||||
|
||||
## Epic 2 : Contenu & Découverte
|
||||
|
||||
Le visiteur explore les zones de contenu : projets (galerie + détail), compétences organisées par catégories avec liens vers les projets associés, témoignages en dialogues PNJ, et parcours en timeline narrative.
|
||||
|
||||
### Story 2.1 : Composant ProjectCard
|
||||
|
||||
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:**
|
||||
|
||||
**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)
|
||||
**And** il affiche le titre traduit selon la langue courante
|
||||
**And** il affiche la description courte traduite
|
||||
**And** un hover effect révèle un CTA "Découvrir" avec animation subtile
|
||||
**And** le composant est cliquable et navigue vers `/projets/{slug}` (ou `/en/projects/{slug}`)
|
||||
**And** le composant respecte `prefers-reduced-motion` pour les animations
|
||||
**And** le composant est responsive (adaptation mobile/desktop)
|
||||
**And** le composant est accessible (focus visible, `role` approprié)
|
||||
|
||||
### Story 2.2 : Page Projets - Galerie
|
||||
|
||||
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:**
|
||||
|
||||
**Given** le visiteur accède à `/projets` (FR) ou `/en/projects` (EN)
|
||||
**When** la page se charge
|
||||
**Then** une grille responsive de `ProjectCard` s'affiche
|
||||
**And** les projets sont triés par date avec les "featured" en tête
|
||||
**And** une animation d'entrée progressive des cards est présente (respectant `prefers-reduced-motion`)
|
||||
**And** les données sont chargées depuis l'API `/api/projects` avec le contenu traduit
|
||||
**And** les meta tags SEO sont dynamiques pour cette page
|
||||
**And** le layout s'adapte : grille sur desktop, cards empilées sur mobile
|
||||
|
||||
### Story 2.3 : Page Projet - Détail
|
||||
|
||||
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:**
|
||||
|
||||
**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
|
||||
**And** la date de réalisation est visible
|
||||
**And** la liste des compétences utilisées s'affiche avec leurs niveaux (avant/après le projet)
|
||||
**And** les liens externes sont présents : URL du projet live (si existe), repository GitHub (si existe)
|
||||
**And** une navigation "Projet précédent / Projet suivant" permet de parcourir les projets
|
||||
**And** un bouton retour vers la galerie est visible
|
||||
**And** les meta tags SEO sont dynamiques (titre, description, image Open Graph)
|
||||
**And** si le slug n'existe pas, une page 404 appropriée s'affiche
|
||||
**And** le design est responsive (adaptation mobile/desktop)
|
||||
|
||||
### Story 2.4 : Page Compétences - Affichage par catégories
|
||||
|
||||
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:**
|
||||
|
||||
**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)
|
||||
**And** chaque compétence affiche : icône, nom traduit, niveau actuel (représentation visuelle)
|
||||
**And** les données sont chargées depuis l'API `/api/skills` avec le contenu traduit
|
||||
**And** une animation d'entrée des éléments est présente (respectant `prefers-reduced-motion`)
|
||||
**And** sur desktop : préparé pour accueillir le skill tree vis.js (Epic 3)
|
||||
**And** sur mobile : liste groupée par catégorie avec design adapté
|
||||
**And** les meta tags SEO sont dynamiques pour cette page
|
||||
**And** chaque compétence est visuellement cliquable (affordance)
|
||||
|
||||
### Story 2.5 : Compétences cliquables → Projets liés
|
||||
|
||||
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:**
|
||||
|
||||
**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
|
||||
**And** pour chaque projet lié : titre, description courte, lien vers le détail
|
||||
**And** l'indication du niveau avant/après chaque projet est visible (progression)
|
||||
**And** une animation d'ouverture/fermeture fluide est présente (respectant `prefers-reduced-motion`)
|
||||
**And** la fermeture est possible par clic extérieur, bouton close, ou touche Escape
|
||||
**And** le panneau/modal utilise Headless UI pour l'accessibilité
|
||||
**And** la navigation au clavier est fonctionnelle (Tab, Escape, Enter)
|
||||
**And** le focus est piégé dans le modal quand ouvert (`focus trap`)
|
||||
**And** les données viennent de la relation `skill_project` via l'API
|
||||
|
||||
### Story 2.6 : Page Témoignages et migrations BDD
|
||||
|
||||
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:**
|
||||
|
||||
**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)
|
||||
**And** les seeders de test sont disponibles avec des témoignages en FR et EN
|
||||
|
||||
**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`
|
||||
**And** chaque témoignage affiche : nom, rôle, entreprise, avatar, texte traduit
|
||||
**And** la personnalité de chaque PNJ est indiquée visuellement (style différent selon personality)
|
||||
**And** un lien vers le projet associé est présent si pertinent
|
||||
**And** l'ordre d'affichage respecte `display_order`
|
||||
**And** le design est préparé pour accueillir le composant DialoguePNJ (story suivante)
|
||||
**And** les meta tags SEO sont dynamiques pour cette page
|
||||
|
||||
### Story 2.7 : Composant Dialogue PNJ
|
||||
|
||||
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:**
|
||||
|
||||
**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
|
||||
**And** une bulle de dialogue s'affiche à droite avec le texte
|
||||
**And** l'effet typewriter fait apparaître le texte lettre par lettre
|
||||
**And** un clic ou appui sur Espace accélère l'animation typewriter (x3-x5)
|
||||
**And** la personnalité du PNJ influence le style visuel de la bulle (sage, sarcastique, enthousiaste, professionnel)
|
||||
**And** la police serif narrative est utilisée pour le texte du dialogue
|
||||
**And** `prefers-reduced-motion` affiche le texte complet instantanément
|
||||
**And** le texte complet est accessible via `aria-label` pour les screen readers
|
||||
**And** une navigation entre témoignages est disponible (précédent/suivant)
|
||||
**And** une transition animée s'effectue entre les PNJ
|
||||
**And** un indicateur du témoignage actuel est visible (ex: 2/5)
|
||||
**And** la navigation au clavier est fonctionnelle (flèches gauche/droite)
|
||||
|
||||
### Story 2.8 : Page Parcours - Timeline narrative
|
||||
|
||||
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:**
|
||||
|
||||
**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
|
||||
**And** chaque étape affiche : date, titre, description narrative traduite
|
||||
**And** sur desktop : les étapes alternent gauche/droite pour un effet visuel dynamique
|
||||
**And** sur mobile : les étapes sont linéaires (toutes du même côté)
|
||||
**And** une animation d'apparition au scroll est présente (respectant `prefers-reduced-motion`)
|
||||
**And** des icônes ou images illustrent les étapes clés
|
||||
**And** le contenu est bilingue (FR/EN) et chargé depuis l'API ou fichiers i18n
|
||||
**And** les meta tags SEO sont dynamiques pour cette page
|
||||
**And** la police serif narrative est utilisée pour les descriptions
|
||||
|
||||
---
|
||||
|
||||
## Epic 3 : Navigation Gamifiée & Progression
|
||||
|
||||
Le visiteur navigue via la carte interactive (Konva.js desktop / Chemin Libre mobile), est accompagné par le narrateur-guide (Le Bug), et voit sa progression sauvegardée automatiquement avec une barre XP.
|
||||
|
||||
### Story 3.1 : Table narrator_texts et API narrateur
|
||||
|
||||
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:**
|
||||
|
||||
**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)
|
||||
**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
|
||||
**And** plusieurs variantes par contexte permettent une sélection aléatoire
|
||||
**And** les seeders insèrent les textes de base en FR et EN dans la table `translations`
|
||||
|
||||
**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é
|
||||
**And** le texte est traduit selon le header `Accept-Language`
|
||||
**And** le ton est adapté au héros (vouvoiement pour Recruteur, tutoiement pour Client/Dev)
|
||||
|
||||
### Story 3.2 : Composant NarratorBubble (Le Bug)
|
||||
|
||||
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:**
|
||||
|
||||
**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)
|
||||
**And** l'avatar du Bug (araignée) s'affiche avec son apparence selon le `narratorStage` du store
|
||||
**And** le texte apparaît avec effet typewriter (lettre par lettre)
|
||||
**And** un clic ou Espace accélère l'animation typewriter
|
||||
**And** la bulle peut être fermée/minimisée sans bloquer la navigation
|
||||
**And** le composant utilise `aria-live="polite"` et `role="status"` pour l'accessibilité
|
||||
**And** `prefers-reduced-motion` affiche le texte instantanément
|
||||
**And** la police serif narrative est utilisée pour le texte
|
||||
**And** l'animation d'apparition/disparition est fluide et non-bloquante
|
||||
|
||||
### Story 3.3 : Textes narrateur contextuels et arc de révélation
|
||||
|
||||
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:**
|
||||
|
||||
**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)
|
||||
**And** des messages de transition s'affichent entre les zones
|
||||
**And** des encouragements apparaissent à 25%, 50%, 75% de progression
|
||||
**And** des indices s'affichent si le visiteur semble inactif (> 30s sans action)
|
||||
**And** un message spécial "Bienvenue à nouveau" s'affiche si progression existante détectée
|
||||
**And** le message de déblocage du contact s'affiche après 2 zones visitées
|
||||
|
||||
**Given** le visiteur progresse dans l'exploration
|
||||
**When** le `completionPercent` atteint certains seuils
|
||||
**Then** le `narratorStage` du store est mis à jour (1→5)
|
||||
**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)
|
||||
**And** le ton du narrateur évolue de mystérieux à complice
|
||||
|
||||
### Story 3.4 : Barre de progression globale (XP bar)
|
||||
|
||||
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:**
|
||||
|
||||
**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
|
||||
**And** le pourcentage est calculé selon les sections visitées (Projets, Compétences, Témoignages, Parcours)
|
||||
**And** l'animation de la barre est fluide lors des mises à jour
|
||||
**And** un tooltip au hover indique les sections visitées et restantes
|
||||
**And** le design évoque une barre XP style RPG (cohérent avec `sky-accent`)
|
||||
**And** la barre respecte `prefers-reduced-motion` (pas d'animation si activé)
|
||||
**And** sur mobile, la progression est accessible via la bottom bar
|
||||
**And** la barre n'est pas visible en mode Express/Résumé
|
||||
|
||||
### Story 3.5 : Logique de progression et déblocage contact
|
||||
|
||||
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:**
|
||||
|
||||
**Given** le store `useProgressionStore` est actif
|
||||
**When** le visiteur visite une nouvelle zone
|
||||
**Then** la zone est ajoutée à `visitedSections`
|
||||
**And** le `completionPercent` est recalculé automatiquement
|
||||
**And** la progression est persistée en LocalStorage (si consentement RGPD donné)
|
||||
|
||||
**Given** le visiteur a visité 2 zones ou plus
|
||||
**When** la condition est atteinte
|
||||
**Then** `contactUnlocked` passe à `true`
|
||||
**And** le narrateur annonce le déblocage avec un message spécial
|
||||
**And** la zone Contact s'illumine sur la carte (si visible)
|
||||
**And** le visiteur peut continuer à explorer ou aller au contact
|
||||
|
||||
**Given** le visiteur revient sur le site
|
||||
**When** une progression existe en LocalStorage
|
||||
**Then** le store est réhydraté avec l'état sauvegardé
|
||||
**And** le narrateur affiche "Bienvenue à nouveau"
|
||||
**And** la carte affiche l'état correct des zones visitées
|
||||
|
||||
### Story 3.6 : Carte interactive desktop (Konva.js)
|
||||
|
||||
As a visiteur desktop,
|
||||
I want naviguer via une carte interactive visuelle,
|
||||
So that j'explore librement le portfolio comme un monde.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**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)
|
||||
**And** le composant est chargé en lazy-loading (`.client.vue`) pour respecter le budget JS
|
||||
**And** chaque zone a une apparence distincte (teinte unique, icône)
|
||||
**And** les zones visitées ont une apparence différente des zones non visitées
|
||||
**And** la zone Contact est verrouillée visuellement si `contactUnlocked` est `false`
|
||||
**And** la position actuelle du visiteur est marquée sur la carte
|
||||
**And** au hover sur une zone : le nom et le statut s'affichent (tooltip)
|
||||
**And** au clic sur une zone : navigation vers la section correspondante avec transition
|
||||
**And** un curseur personnalisé indique les zones cliquables
|
||||
**And** la navigation au clavier est fonctionnelle (Tab entre zones, Enter pour naviguer)
|
||||
**And** les zones ont des labels ARIA descriptifs
|
||||
|
||||
### Story 3.7 : Navigation mobile - Chemin Libre et Bottom Bar
|
||||
|
||||
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:**
|
||||
|
||||
**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`)
|
||||
**And** chaque `ZoneCard` affiche : illustration, nom de la zone, statut (visité/nouveau/verrouillé)
|
||||
**And** une ligne décorative relie les cards visuellement (effet chemin)
|
||||
**And** un tap sur une zone navigue vers la section correspondante
|
||||
**And** la zone Contact affiche un cadenas si `contactUnlocked` est `false`
|
||||
|
||||
**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
|
||||
**And** les touch targets font au minimum 48x48px
|
||||
**And** la bottom bar est fixe et toujours visible
|
||||
**And** le narrateur s'affiche au-dessus de la bottom bar quand actif
|
||||
|
||||
---
|
||||
|
||||
## Epic 4 : Chemins Narratifs, Challenge & Contact
|
||||
|
||||
Le visiteur fait des choix qui créent son parcours unique, relève un challenge optionnel, et accède à la révélation finale "Monde de Code" + formulaire de contact comme récompense narrative. Les easter eggs récompensent l'exploration.
|
||||
|
||||
### Story 4.1 : Composant ChoiceCards et choix narratifs
|
||||
|
||||
As a visiteur,
|
||||
I want faire des choix qui influencent mon parcours,
|
||||
So that mon expérience est unique et personnalisée.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**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)
|
||||
**And** chaque card affiche : icône, texte narratif du choix
|
||||
**And** un hover/focus highlight la card sélectionnable
|
||||
**And** un clic enregistre le choix dans `choices` du store Pinia
|
||||
**And** une transition animée mène vers la destination choisie
|
||||
**And** le composant est accessible (`role="radiogroup"`, navigation clavier, focus visible)
|
||||
**And** `prefers-reduced-motion` simplifie les animations
|
||||
**And** le style est cohérent avec l'univers narratif (police serif, couleurs des zones)
|
||||
|
||||
### Story 4.2 : Intro narrative et premier choix
|
||||
|
||||
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:**
|
||||
|
||||
**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)
|
||||
**And** le texte présente le "héros mystérieux" (le développeur) à découvrir
|
||||
**And** l'effet typewriter anime le texte (skippable par clic/Espace)
|
||||
**And** l'ambiance visuelle est immersive (fond sombre, illustrations)
|
||||
**And** un bouton "Continuer" permet d'avancer
|
||||
**And** à la fin de l'intro, le premier choix binaire s'affiche via `ChoiceCards`
|
||||
**And** le choix propose deux zones à explorer en premier (ex: Projets vs Compétences)
|
||||
**And** le contenu est bilingue (FR/EN) et adapté au héros (vouvoiement/tutoiement)
|
||||
**And** la durée de l'intro est courte (15-30s max, skippable)
|
||||
|
||||
### Story 4.3 : Chemins narratifs différenciés
|
||||
|
||||
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:**
|
||||
|
||||
**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
|
||||
**And** chaque choix est enregistré dans `choices` du store
|
||||
**And** l'ordre suggéré des zones varie selon le chemin choisi
|
||||
**And** les textes du narrateur s'adaptent au chemin (transitions contextuelles)
|
||||
**And** tous les chemins permettent de visiter tout le contenu
|
||||
**And** tous les chemins mènent au contact (pas de "mauvais" choix)
|
||||
**And** le `currentPath` du store reflète le chemin actuel
|
||||
**And** à la fin de chaque zone, le narrateur propose un choix vers la suite
|
||||
|
||||
### Story 4.4 : Table easter_eggs et système de détection
|
||||
|
||||
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:**
|
||||
|
||||
**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)
|
||||
**And** les trigger_types incluent : click, hover, konami, scroll, sequence
|
||||
**And** les reward_types incluent : snippet, anecdote, image, badge
|
||||
**And** les seeders insèrent 5-10 easter eggs avec leurs récompenses traduites
|
||||
|
||||
**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)
|
||||
**And** les réponses/récompenses ne sont PAS incluses (pour éviter la triche)
|
||||
|
||||
**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
|
||||
**And** l'easter egg est marqué comme trouvé côté client (store)
|
||||
|
||||
### Story 4.5 : Easter eggs - Implémentation UI et collection
|
||||
|
||||
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:**
|
||||
|
||||
**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)
|
||||
**And** la récompense est affichée (snippet de code, anecdote, image, badge)
|
||||
**And** le narrateur réagit avec enthousiasme
|
||||
**And** une notification "Easter egg trouvé ! (X/Y)" s'affiche
|
||||
**And** le slug est ajouté à `easterEggsFound` dans le store
|
||||
**And** un bouton permet de fermer et continuer
|
||||
|
||||
**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
|
||||
**And** les détails sont visibles pour les découverts
|
||||
**And** un compteur X/Y indique la progression
|
||||
**And** un badge spécial s'affiche si 100% trouvés
|
||||
|
||||
### Story 4.6 : Page Challenge - Structure et puzzle
|
||||
|
||||
As a visiteur,
|
||||
I want relever un défi optionnel avant d'accéder au contact,
|
||||
So that l'accès au développeur est une récompense méritée (mais pas bloquante).
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** le visiteur accède à `/challenge` (après avoir débloqué le contact)
|
||||
**When** la page se charge
|
||||
**Then** une introduction narrative "Une dernière épreuve..." s'affiche
|
||||
**And** un puzzle logique/code simple est présenté (réordonner, compléter, décoder)
|
||||
**And** la difficulté est calibrée : 1-3 minutes pour résoudre
|
||||
**And** le thème est lié au développement/code
|
||||
**And** un système d'indices est disponible (bouton "Besoin d'aide ?")
|
||||
**And** 3 niveaux d'indices progressifs sont proposés
|
||||
**And** après 3 indices, une option "Passer" apparaît
|
||||
**And** le challenge est TOUJOURS skippable (bouton discret "Passer directement au contact")
|
||||
**And** une validation avec feedback clair indique succès/échec
|
||||
**And** une animation de succès célèbre la réussite
|
||||
**And** `challengeCompleted` est mis à `true` dans le store si réussi
|
||||
|
||||
### Story 4.7 : Révélation "Monde de Code"
|
||||
|
||||
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:**
|
||||
|
||||
**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"
|
||||
**And** un paysage composé de blocs de code ASCII art s'affiche (montagnes, arbres, ville en code)
|
||||
**And** le code scroll/apparaît progressivement (animation)
|
||||
**And** l'avatar illustré de Célian est révélé au centre du monde de code
|
||||
**And** le narrateur (Le Bug) commente : "Tu l'as trouvé !"
|
||||
**And** le message "Tu m'as trouvé !" s'affiche avec effet de célébration
|
||||
**And** sur mobile, une version allégée mais émotionnellement équivalente s'affiche
|
||||
**And** `prefers-reduced-motion` affiche une version statique
|
||||
**And** une description alternative est disponible pour les screen readers
|
||||
**And** un bouton permet de continuer vers le formulaire de contact
|
||||
|
||||
### Story 4.8 : Page Contact - Formulaire et célébration
|
||||
|
||||
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:**
|
||||
|
||||
**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é)
|
||||
**And** un formulaire de contact s'affiche : nom (requis), email (requis), message (requis)
|
||||
**And** la validation temps réel est effectuée côté frontend (champs requis, format email)
|
||||
**And** les erreurs sont communiquées par le narrateur (pas de messages d'erreur classiques)
|
||||
**And** un champ honeypot invisible est présent (anti-spam)
|
||||
**And** reCAPTCHA v3 est intégré de manière invisible
|
||||
**And** le bouton d'envoi utilise la couleur accent (`sky-accent`)
|
||||
|
||||
**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
|
||||
**And** le rate limiting (5 req/min par IP) est appliqué
|
||||
**And** l'email est envoyé via Laravel Mail
|
||||
**And** une animation de célébration s'affiche (confettis ou similaire)
|
||||
**And** le narrateur confirme l'envoi avec un message personnalisé
|
||||
**And** en cas d'erreur, le narrateur explique le problème avec bienveillance
|
||||
|
||||
### Story 4.9 : Challenge post-formulaire
|
||||
|
||||
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:**
|
||||
|
||||
**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é
|
||||
**And** un challenge optionnel bonus est proposé
|
||||
**And** le challenge est différent du challenge principal (mini-jeu, quiz, exploration)
|
||||
**And** le visiteur peut fermer et quitter à tout moment
|
||||
**And** la participation est totalement optionnelle
|
||||
**And** le résultat n'impacte rien (juste pour le fun)
|
||||
**And** le narrateur commente avec humour
|
||||
1466
docs/planning-artifacts/ux-design-specification.md
Normal file
1466
docs/planning-artifacts/ux-design-specification.md
Normal file
File diff suppressed because it is too large
Load Diff
941
docs/prd-gamification.md
Normal file
941
docs/prd-gamification.md
Normal file
@@ -0,0 +1,941 @@
|
||||
# Portfolio Gamifié - Product Requirements Document (PRD)
|
||||
|
||||
## 1. Goals and Background Context
|
||||
|
||||
### 1.1 Goals
|
||||
|
||||
- **Différenciation** : Créer un portfolio qui se démarque par une expérience narrative immersive et gamifiée
|
||||
- **Engagement visiteur** : Transformer la visite en aventure avec quête principale "Trouver le développeur"
|
||||
- **Respect du temps** : Offrir une double entrée (Mode Aventure vs Mode Pressé) pour s'adapter à chaque visiteur
|
||||
- **Rejouabilité** : Proposer des chemins multiples (4-8 parcours différents) pour une expérience unique
|
||||
- **Contact optimisé** : Transformer le formulaire de contact en récompense narrative plutôt qu'en corvée
|
||||
- **Démonstration technique** : Utiliser des animations avancées et interactions comme preuve de compétences
|
||||
- **International** : Support FR + EN dès le lancement
|
||||
|
||||
### 1.2 Background Context
|
||||
|
||||
Le portfolio actuel utilise une navigation et présentation classiques. L'analyse des besoins de trois personas (recruteur pressé, client potentiel, développeur pair) révèle une opportunité de différenciation majeure : un portfolio qui raconte une histoire plutôt que de lister des informations.
|
||||
|
||||
Le concept central retenu est de traiter le portfolio comme une aventure narrative inspirée de Zelda BOTW, avec des PNJ (témoignages sous forme de dialogues), un arbre de compétences RPG, une carte interactive, et des easter eggs. La stack technique validée (Nuxt 3 SSR / Laravel API / MariaDB / TailwindCSS / Konva.js / vis.js) permet de réaliser cette vision avec une architecture moderne offrant SEO optimal, navigation fluide et API maintenable.
|
||||
|
||||
### 1.3 Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-26 | 0.1 | Création initiale basée sur le brainstorming | John (PM) |
|
||||
| 2026-01-26 | 1.0 | PRD complet avec 4 epics, 37 stories, checklist validée | John (PM) |
|
||||
| 2026-01-27 | 1.1 | Migration stack frontend vers Vue 3 + Composition API + Pinia | John (PM) |
|
||||
| 2026-01-27 | 1.2 | Adoption Nuxt 3 pour SSR + navigation SPA fluide | John (PM) |
|
||||
| 2026-01-27 | 1.3 | Backend API avec Laravel au lieu de PHP natif | John (PM) |
|
||||
|
||||
## 1.4 Out of Scope (MVP)
|
||||
|
||||
Les éléments suivants sont explicitement **hors du scope MVP** :
|
||||
|
||||
- Application mobile native (iOS/Android)
|
||||
- Authentification utilisateur / comptes persistants
|
||||
- CMS / back-office d'administration du contenu
|
||||
- Analytics avancées (au-delà de Lighthouse et logs basiques)
|
||||
- Système de commentaires ou blog
|
||||
- Intégration réseaux sociaux (partage, login social)
|
||||
- Mode multijoueur ou classements
|
||||
- Notifications push
|
||||
- PWA complète (service worker, offline mode)
|
||||
|
||||
### 1.5 Success Metrics (MVP)
|
||||
|
||||
| Métrique | Objectif | Méthode de mesure |
|
||||
|----------|----------|-------------------|
|
||||
| Taux de complétion parcours aventure | > 60% | LocalStorage progression + logs serveur |
|
||||
| Temps moyen sur site | > 3 minutes | Analytics basiques ou logs |
|
||||
| Taux d'accès au contact | > 40% des visiteurs | Logs page contact |
|
||||
| Score Lighthouse Performance | > 90 | Audit Lighthouse |
|
||||
| Easter eggs découverts (moyenne) | > 2/visiteur | LocalStorage stats |
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
### 2.1 Functional Requirements
|
||||
|
||||
- **FR1**: Le système offre une double entrée au visiteur : "Partir à l'aventure" (expérience complète) ou "Je n'ai pas le temps" (mode express avec roadmap)
|
||||
- **FR2**: Les transitions entre pages sont animées de manière seamless via Vue/Nuxt transitions, créant une impression de "changement de zone" immersive
|
||||
- **FR3**: Un narrateur-guide accompagne le visiteur avec des textes contextuels tout au long de l'expérience
|
||||
- **FR4**: Une carte interactive (Konva.js) permet la navigation non-linéaire et affiche la progression du visiteur
|
||||
- **FR5**: Un arbre de compétences interactif (vis.js) visualise les skills avec niveaux évoluant selon les projets
|
||||
- **FR6**: Les compétences sont cliquables et mènent directement aux projets qui les utilisent
|
||||
- **FR7**: Les témoignages s'affichent sous forme de dialogues PNJ style Zelda avec avatar, bulles, effet typewriter et personnalités variées
|
||||
- **FR8**: Une barre de progression globale indique l'avancement dans l'exploration du site
|
||||
- **FR9**: Le système propose 2-3 choix binaires créant 4-8 parcours narratifs différents, tous menant au contact
|
||||
- **FR10**: Un challenge/puzzle accessible doit être résolu pour accéder au formulaire de contact (avec système d'indices)
|
||||
- **FR11**: Des easter eggs cachés récompensent l'exploration avec des snippets de code ou anecdotes
|
||||
- **FR12**: La progression est sauvegardée automatiquement en LocalStorage pour permettre la reprise
|
||||
- **FR13**: Le site supporte deux langues (FR par défaut + EN) avec détection par URL (/en/...)
|
||||
- **FR14**: Le formulaire de contact est présenté comme la récompense finale "Tu m'as trouvé !" avec célébration
|
||||
|
||||
### 2.2 Non-Functional Requirements
|
||||
|
||||
- **NFR1**: Le bundle JS total (Nuxt + Konva + vis.js) ne doit pas dépasser 170kb gzippé, avec lazy-loading des composants lourds
|
||||
- **NFR2**: Le temps de chargement initial (LCP) doit rester sous 2.5 secondes sur connexion 3G
|
||||
- **NFR3**: Le site doit être responsive et offrir une expérience adaptée mobile (carte simplifiée)
|
||||
- **NFR4**: Le site doit fonctionner sur les navigateurs modernes (Chrome, Firefox, Safari, Edge - 2 dernières versions)
|
||||
- **NFR5**: Les URLs doivent être SEO-friendly et le contenu principal accessible aux crawlers
|
||||
- **NFR6**: Les animations doivent respecter `prefers-reduced-motion` pour l'accessibilité
|
||||
- **NFR7**: Le système i18n utilise @nuxtjs/i18n avec fichiers JSON, rendu SSR pour SEO optimal
|
||||
- **NFR8**: Les images sont optimisées en WebP avec lazy loading
|
||||
|
||||
## 3. User Interface Design Goals
|
||||
|
||||
### 3.1 Overall UX Vision
|
||||
|
||||
L'expérience utilisateur vise à transformer un portfolio classique en **aventure narrative immersive**. Le visiteur ne consulte pas un CV interactif, il **part à la découverte d'un développeur**. Chaque interaction est pensée pour créer de la surprise, de l'engagement et de la mémorabilité. L'ambiance s'inspire des jeux d'aventure/RPG (notamment Zelda BOTW) tout en restant professionnelle et accessible.
|
||||
|
||||
**Mots-clés UX** : Immersion, Découverte, Surprise, Fluidité, Personnalisation du parcours.
|
||||
|
||||
### 3.2 Key Interaction Paradigms
|
||||
|
||||
| Paradigme | Description |
|
||||
|-----------|-------------|
|
||||
| **Navigation narrative** | Pas de menu burger classique - la carte interactive et le narrateur guident le visiteur |
|
||||
| **Choix binaires** | À plusieurs moments clés, le visiteur choisit son chemin |
|
||||
| **Dialogues interactifs** | Clic pour "parler" aux PNJ (témoignages), avec effet typewriter progressif |
|
||||
| **Progression visible** | Barre/indicateur montrant l'avancement dans l'exploration globale |
|
||||
| **Récompenses cachées** | Easter eggs déclenchés par exploration (clics, hover, scroll, Konami code) |
|
||||
| **Transitions cinématiques** | Changements de "zone" animés entre chaque section majeure |
|
||||
|
||||
### 3.3 Core Screens and Views
|
||||
|
||||
| Écran | Description | Rôle narratif |
|
||||
|-------|-------------|---------------|
|
||||
| **Landing / Choix initial** | Double entrée : Aventure vs Mode Pressé | Point de départ de la quête |
|
||||
| **Intro narrative** | Présentation du héros mystérieux | Accroche et intrigue |
|
||||
| **Carte interactive** | Vue globale des zones à explorer, progression visible | Hub de navigation |
|
||||
| **Zone Projets** | Galerie de projets avec previews et détails | Preuves concrètes |
|
||||
| **Zone Compétences** | Arbre de skills interactif avec niveaux | Démonstration technique |
|
||||
| **Zone Parcours/Timeline** | Histoire professionnelle narrative | Contexte et crédibilité |
|
||||
| **Zone Témoignages** | Dialogues PNJ avec personnalités | Validation sociale |
|
||||
| **Challenge final** | Puzzle à résoudre avec système d'indices | Climax de l'aventure |
|
||||
| **Contact / Récompense** | "Tu m'as trouvé !" + formulaire + célébration | Conclusion satisfaisante |
|
||||
| **Roadmap (Mode Pressé)** | Vue condensée avec accès direct aux sections | Alternative rapide |
|
||||
|
||||
### 3.4 Accessibility: WCAG AA
|
||||
|
||||
- Respect de `prefers-reduced-motion` : animations désactivées ou réduites
|
||||
- Contraste suffisant sur tous les textes narratifs
|
||||
- Navigation au clavier possible (focus visible, skip links)
|
||||
- Alternative textuelle pour la carte interactive
|
||||
- Le mode pressé sert aussi de fallback accessible
|
||||
|
||||
### 3.5 Branding
|
||||
|
||||
| Élément | Direction |
|
||||
|---------|-----------|
|
||||
| **Ton visuel** | Moderne, légèrement fantaisiste, inspiré RPG mais pas cartoon |
|
||||
| **Palette** | Tons sombres avec accents colorés pour les interactions |
|
||||
| **Typographie** | Sans-serif moderne pour le corps, display font pour titres narratifs |
|
||||
| **Iconographie** | SVG custom ou sprite, style cohérent avec l'univers RPG light |
|
||||
| **Avatars PNJ** | Illustrations stylisées pour les témoignages |
|
||||
|
||||
### 3.6 Target Devices and Platforms: Web Responsive
|
||||
|
||||
| Device | Expérience |
|
||||
|--------|------------|
|
||||
| **Desktop (>1024px)** | Expérience complète : carte interactive, animations, skill tree complet |
|
||||
| **Tablet (768-1024px)** | Expérience adaptée : carte simplifiée, interactions touch |
|
||||
| **Mobile (<768px)** | Expérience repensée : navigation linéaire, carte en minimap, skill tree scrollable |
|
||||
|
||||
## 4. Technical Assumptions
|
||||
|
||||
### 4.1 Repository Structure: Nouveau Repo Dédié
|
||||
|
||||
Projet standalone en monorepo (Nuxt 3 frontend SSR + API PHP) :
|
||||
|
||||
```
|
||||
portfolio-gamifie/
|
||||
├── frontend/ # Application Nuxt 3
|
||||
│ ├── pages/ # Routes automatiques (fichier-based)
|
||||
│ │ ├── index.vue # / (landing double entrée)
|
||||
│ │ ├── aventure.vue # /aventure (intro narrative)
|
||||
│ │ ├── roadmap.vue # /roadmap (mode pressé)
|
||||
│ │ ├── projets/
|
||||
│ │ │ ├── index.vue # /projets (galerie)
|
||||
│ │ │ └── [slug].vue # /projets/:slug (détail)
|
||||
│ │ ├── competences.vue # /competences (skill tree)
|
||||
│ │ ├── temoignages.vue # /temoignages (dialogues PNJ)
|
||||
│ │ ├── parcours.vue # /parcours (timeline)
|
||||
│ │ ├── challenge.vue # /challenge (puzzle)
|
||||
│ │ └── contact.vue # /contact (récompense finale)
|
||||
│ ├── components/ # Auto-importés
|
||||
│ │ ├── DialoguePNJ.vue
|
||||
│ │ ├── Narrator.vue
|
||||
│ │ ├── ProgressBar.vue
|
||||
│ │ ├── InteractiveMap.client.vue # Client-only (Konva)
|
||||
│ │ └── SkillTree.client.vue # Client-only (vis.js)
|
||||
│ ├── composables/ # Auto-importés
|
||||
│ │ ├── useTypewriter.ts
|
||||
│ │ ├── useNarrator.ts
|
||||
│ │ └── useEasterEggs.ts
|
||||
│ ├── stores/ # Pinia stores
|
||||
│ │ └── progression.ts
|
||||
│ ├── layouts/
|
||||
│ │ ├── default.vue # Layout avec narrateur + progress bar
|
||||
│ │ └── minimal.vue # Layout mode pressé
|
||||
│ ├── i18n/
|
||||
│ │ ├── fr.json
|
||||
│ │ └── en.json
|
||||
│ ├── assets/
|
||||
│ ├── public/
|
||||
│ ├── nuxt.config.ts
|
||||
│ ├── tailwind.config.js
|
||||
│ └── package.json
|
||||
├── api/ # Backend Laravel API
|
||||
│ ├── app/
|
||||
│ │ ├── Http/
|
||||
│ │ │ ├── Controllers/Api/
|
||||
│ │ │ │ ├── ProjectController.php
|
||||
│ │ │ │ ├── SkillController.php
|
||||
│ │ │ │ ├── TestimonialController.php
|
||||
│ │ │ │ ├── NarratorController.php
|
||||
│ │ │ │ └── ContactController.php
|
||||
│ │ │ ├── Requests/ # Form Request validation
|
||||
│ │ │ └── Resources/ # API Resources (transformers)
|
||||
│ │ └── Models/
|
||||
│ │ ├── Project.php
|
||||
│ │ ├── Skill.php
|
||||
│ │ ├── Testimonial.php
|
||||
│ │ └── NarratorText.php
|
||||
│ ├── database/
|
||||
│ │ ├── migrations/
|
||||
│ │ ├── seeders/
|
||||
│ │ └── factories/
|
||||
│ ├── routes/
|
||||
│ │ └── api.php # Routes API
|
||||
│ ├── config/
|
||||
│ ├── tests/
|
||||
│ ├── .env.example
|
||||
│ └── composer.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### 4.2 Service Architecture: Nuxt 3 SSR + Laravel API
|
||||
|
||||
| Composant | Technologie | Justification |
|
||||
|-----------|-------------|---------------|
|
||||
| **Frontend SSR** | Nuxt 3 | Rendu serveur SEO + hydration SPA |
|
||||
| **Backend API** | Laravel 11 | Framework PHP robuste, conventions claires, Eloquent ORM |
|
||||
| **Base de données** | MariaDB | Relationnel adapté aux données structurées (skills, projets) |
|
||||
| **ORM** | Eloquent | Relations fluides (belongsToMany, hasMany), query builder |
|
||||
| **Routing API** | Laravel Router | RESTful, middleware, rate limiting |
|
||||
| **Validation** | Form Requests | Validation déclarative, messages localisés |
|
||||
| **Mail** | Laravel Mail | PHPMailer intégré, templates Blade |
|
||||
| **Cache** | Laravel Cache | Redis ou file driver selon hébergement |
|
||||
|
||||
### 4.3 Frontend Stack
|
||||
|
||||
| Librairie | Version | Poids gzip | Rôle |
|
||||
|-----------|---------|------------|------|
|
||||
| **Nuxt 3** | 3.x | ~50kb | Meta-framework Vue avec SSR natif |
|
||||
| **Vue 3** | 3.5+ | (inclus) | Framework réactif, Composition API |
|
||||
| **Pinia** | 2.x | ~1kb | Gestion d'état (progression, choix, easter eggs) |
|
||||
| **@nuxtjs/i18n** | 8.x | ~5kb | Internationalisation avec SSR |
|
||||
| **TailwindCSS** | 3.x | ~10kb | Styling, animations CSS |
|
||||
| **Konva.js** | 9.x | ~50kb | Carte interactive (chargé côté client) |
|
||||
| **vue-konva** | 3.x | ~2kb | Binding Vue pour Konva |
|
||||
| **vis-network** | 9.x | ~50kb | Arbre de compétences (chargé côté client) |
|
||||
|
||||
**Total estimé** : ~160-170kb gzippé (avec lazy-loading des librairies lourdes)
|
||||
|
||||
#### Justification Nuxt 3
|
||||
|
||||
- **SSR natif** : HTML complet pour les crawlers, SEO optimal sans configuration
|
||||
- **Hydration intelligente** : Premier rendu serveur, puis navigation SPA fluide
|
||||
- **Routing fichier-based** : `pages/projets/[slug].vue` → URL `/projets/mon-projet` automatique
|
||||
- **Transitions de page** : `<NuxtPage>` avec `pageTransition` configuration native
|
||||
- **Auto-imports** : Composables et composants importés automatiquement
|
||||
- **i18n SSR** : URLs localisées (`/en/projects`) avec contenu traduit côté serveur
|
||||
- **Lazy components** : `<LazyKonvaMap>` charge Konva uniquement quand nécessaire
|
||||
|
||||
### 4.4 Testing Requirements: Unit + Integration
|
||||
|
||||
| Type | Scope | Outils |
|
||||
|------|-------|--------|
|
||||
| **Unit Tests Laravel** | Models, Services, Form Requests | Pest PHP ou PHPUnit |
|
||||
| **Feature Tests Laravel** | API endpoints, middleware | Laravel HTTP Tests |
|
||||
| **Unit Tests Nuxt** | Composants, composables, stores | Vitest + @nuxt/test-utils |
|
||||
| **E2E Tests** | Parcours utilisateur critiques, SSR | Playwright |
|
||||
| **Tests manuels** | UX gamification, animations, transitions | Checklist QA |
|
||||
|
||||
### 4.5 Additional Technical Assumptions
|
||||
|
||||
- **Hébergement** :
|
||||
- Frontend Nuxt : Node.js (Vercel, Netlify, ou serveur Node)
|
||||
- API Laravel : Serveur PHP 8.2+ avec MariaDB (Laravel Forge, shared hosting, VPS)
|
||||
- **Mode SSR** : Nuxt en mode `ssr: true` (défaut), rendu serveur + hydration client
|
||||
- **Versioning** : Git, branches feature, main = production
|
||||
- **Build Frontend** : Nitro (bundler Nuxt), optimisations automatiques
|
||||
- **Build API** : Composer, `php artisan optimize` en production
|
||||
- **Composants client-only** : Konva et vis.js avec suffix `.client.vue` (pas de SSR)
|
||||
- **Images** : `<NuxtImg>` avec optimisation automatique (nuxt/image)
|
||||
- **État global** : Pinia avec `persistedState` plugin, compatible SSR
|
||||
- **Sécurité Laravel** : CORS middleware, Sanctum (si auth future), rate limiting, validation
|
||||
- **Sécurité Nuxt** : Headers sécurité via nuxt.config.ts
|
||||
- **SEO** : `useHead()` et `useSeoMeta()` pour meta tags dynamiques SSR
|
||||
- **Transitions** : `pageTransition` dans `nuxt.config.ts` + CSS personnalisé
|
||||
- **API Resources** : Laravel Resources pour transformer les réponses JSON
|
||||
|
||||
## 5. Epic List
|
||||
|
||||
| Epic | Titre | Objectif |
|
||||
|------|-------|----------|
|
||||
| **Epic 1** | Setup Projet & Infrastructure i18n | Créer le nouveau repo Nuxt 3, structure projet, schéma BDD MariaDB, système i18n, routing SSR, transitions de page et double entrée (Aventure/Pressé) |
|
||||
| **Epic 2** | Contenu Interactif | Implémenter les compétences cliquables → projets, les témoignages style dialogue PNJ, et la timeline narrative |
|
||||
| **Epic 3** | Gamification & Navigation | Créer la carte interactive (Konva.js), l'arbre de compétences (vis.js), la barre de progression et le narrateur-guide |
|
||||
| **Epic 4** | Expérience Complète & Finalisation | Implémenter les chemins narratifs multiples, le challenge/puzzle final, les easter eggs et la célébration contact |
|
||||
|
||||
## 6. Epic Details
|
||||
|
||||
### Epic 1 : Setup Projet & Infrastructure i18n
|
||||
|
||||
**Objectif** : Créer le nouveau projet Nuxt 3 from scratch avec une infrastructure solide : structure de fichiers, base de données MariaDB, système d'internationalisation SSR (FR/EN), routing avec transitions de pages seamless, et la première fonctionnalité utilisateur visible (double entrée Aventure/Pressé).
|
||||
|
||||
#### Story 1.1 : Initialisation du projet
|
||||
|
||||
**As a** développeur,
|
||||
**I want** une structure de projet moderne initialisée (Nuxt 3 SSR + Laravel API),
|
||||
**so that** je peux commencer le développement sur des bases solides.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Repository Git initialisé avec `.gitignore` approprié
|
||||
2. Structure monorepo : `/frontend` (Nuxt 3) + `/api` (Laravel)
|
||||
3. Frontend : `npx nuxi@latest init` avec TypeScript activé
|
||||
4. Modules Nuxt installés : `@nuxtjs/i18n`, `@nuxtjs/tailwindcss`, `@pinia/nuxt`
|
||||
5. Backend : `laravel new api` ou `composer create-project laravel/laravel api`
|
||||
6. Laravel configuré en mode API-only (sans Blade views inutiles)
|
||||
7. `nuxt.config.ts` configuré avec SSR activé et pageTransition
|
||||
8. Fichiers `.env.example` pour frontend et backend
|
||||
9. CORS configuré dans Laravel pour autoriser le frontend Nuxt
|
||||
10. README.md avec instructions d'installation et déploiement
|
||||
|
||||
#### Story 1.2 : Configuration base de données Laravel
|
||||
|
||||
**As a** développeur,
|
||||
**I want** une connexion MariaDB fonctionnelle avec Eloquent et migrations Laravel,
|
||||
**so that** je peux créer et versionner le schéma de données.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Configuration DB dans `.env` (DB_CONNECTION=mysql, DB_HOST, etc.)
|
||||
2. `config/database.php` configuré pour MariaDB
|
||||
3. Commande `php artisan migrate` fonctionnelle
|
||||
4. Commande `php artisan db:seed` pour données initiales
|
||||
5. Factories définies pour les tests (`ProjectFactory`, `SkillFactory`, etc.)
|
||||
6. Connexion testée avec `php artisan tinker`
|
||||
|
||||
#### Story 1.3 : Schéma de données initial (Migrations Laravel)
|
||||
|
||||
**As a** développeur,
|
||||
**I want** les tables principales créées via migrations Laravel,
|
||||
**so that** l'application peut stocker projets, compétences et témoignages.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Migration `create_projects_table` (id, slug, title, description, image, url, github_url, date_completed, is_featured, timestamps)
|
||||
2. Migration `create_skills_table` (id, slug, name, icon, category, max_level, timestamps)
|
||||
3. Migration `create_project_skill_table` (pivot avec level_before, level_after)
|
||||
4. Migration `create_testimonials_table` (id, name, role, company, avatar, content, personality, project_id, timestamps)
|
||||
5. Migration `create_narrator_texts_table` (id, context, content, variant, timestamps)
|
||||
6. Models Eloquent avec relations : `Project->belongsToMany(Skill)`, `Skill->belongsToMany(Project)`, `Testimonial->belongsTo(Project)`
|
||||
7. Seeders : `ProjectSeeder`, `SkillSeeder`, `TestimonialSeeder` avec données de test
|
||||
8. `DatabaseSeeder` appelant tous les seeders dans le bon ordre
|
||||
|
||||
#### Story 1.4 : Système d'internationalisation (i18n) frontend
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir le site dans ma langue (FR ou EN),
|
||||
**so that** je comprends le contenu.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Fichiers JSON de traduction (`i18n/fr.json`, `i18n/en.json`)
|
||||
2. Composable `useI18n()` avec `t()` pour accéder aux traductions
|
||||
3. Traductions chargées côté serveur (SSR) pour SEO
|
||||
4. Fallback vers FR si clé manquante en EN
|
||||
5. Détection de la langue via URL (`/en/...`) avec `prefix_except_default`
|
||||
6. Sélecteur de langue avec `switchLocalePath()`
|
||||
7. Traductions de base définies (navigation, boutons, messages)
|
||||
|
||||
#### Story 1.5 : Configuration @nuxtjs/i18n et URLs localisées
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** des URLs propres et localisées,
|
||||
**so that** je peux naviguer et partager des liens.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Module `@nuxtjs/i18n` configuré avec stratégie `prefix_except_default`
|
||||
2. URLs FR par défaut : `/`, `/projets`, `/competences`, `/parcours`, `/contact`
|
||||
3. URLs EN préfixées : `/en`, `/en/projects`, `/en/skills`, `/en/journey`, `/en/contact`
|
||||
4. Fichiers de traduction JSON dans `i18n/fr.json` et `i18n/en.json`
|
||||
5. Composable `useI18n()` et `$t()` pour les traductions SSR
|
||||
6. `<NuxtLink>` avec `localePath()` pour liens localisés
|
||||
7. Page 404 (`error.vue`) personnalisée bilingue
|
||||
8. SEO : `hreflang` automatiques dans le `<head>`
|
||||
|
||||
#### Story 1.6 : Layout de base et intégration TailwindCSS
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** une interface visuelle cohérente,
|
||||
**so that** l'expérience est agréable.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Layout principal dans `src/Views/layouts/main.php`
|
||||
2. TailwindCSS configuré avec thème custom
|
||||
3. Build CSS via PostCSS avec minification production
|
||||
4. Police(s) variable font chargée(s)
|
||||
5. Meta tags SEO dynamiques par page
|
||||
6. Favicon configuré
|
||||
7. Classes utilitaires thème sombre avec accents
|
||||
|
||||
#### Story 1.7 : Configuration Nuxt Routing et Transitions de page
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** des transitions fluides entre les pages sans rechargement visible,
|
||||
**so that** l'expérience est immersive comme une application native.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Routing fichier-based Nuxt avec structure `pages/` définie
|
||||
2. `pageTransition` configuré dans `nuxt.config.ts` (fade + slide)
|
||||
3. CSS transitions avec classes `page-enter-active`, `page-leave-active`
|
||||
4. Navigation via `<NuxtLink>` pour hydration SPA (pas de rechargement)
|
||||
5. Middleware de navigation pour tracking progression visiteur
|
||||
6. `scrollBehavior` personnalisé (smooth, retour position sauvegardée)
|
||||
7. Respect de `prefers-reduced-motion` via media query CSS
|
||||
8. Fallback gracieux si JS désactivé (SSR HTML complet)
|
||||
|
||||
#### Story 1.8 : Landing page - Double entrée
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** choisir entre explorer l'aventure ou aller à l'essentiel,
|
||||
**so that** mon temps est respecté.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Landing page avec deux CTA distincts
|
||||
2. Bouton "Partir à l'aventure" → intro narrative (placeholder)
|
||||
3. Bouton "Je n'ai pas le temps..." → roadmap express
|
||||
4. Animation d'entrée subtile
|
||||
5. Texte d'accroche intrigant bilingue
|
||||
6. Design responsive
|
||||
|
||||
#### Story 1.9 : Mode Pressé - Roadmap express
|
||||
|
||||
**As a** visiteur pressé,
|
||||
**I want** une vue condensée de toutes les sections,
|
||||
**so that** je trouve rapidement ce que je cherche.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Page roadmap avec liens directs vers chaque section
|
||||
2. Affichage en grille avec icônes et descriptions
|
||||
3. Indication visuelle "Partie sauvée" si progression existante
|
||||
4. Message humoristique sympathique
|
||||
5. Lien pour "changer d'avis" et partir à l'aventure
|
||||
6. Fonctionnel en FR et EN
|
||||
|
||||
### Epic 2 : Contenu Interactif
|
||||
|
||||
**Objectif** : Enrichir le site avec les contenus principaux de manière interactive : pages projets avec détails, compétences cliquables menant aux projets associés, témoignages présentés comme des dialogues de PNJ style Zelda, et une timeline narrative du parcours professionnel.
|
||||
|
||||
#### Story 2.1 : Page Projets - Galerie
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir la liste des projets réalisés,
|
||||
**so that** je peux évaluer les compétences du développeur.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Page `/projets` (FR) et `/en/projects` (EN) fonctionnelle
|
||||
2. Affichage en grille responsive des projets (cards)
|
||||
3. Chaque card affiche : image, titre, description courte
|
||||
4. Projets triés par date avec option "featured" en tête
|
||||
5. Animation d'entrée progressive des cards
|
||||
6. Hover effect révélant un CTA "Découvrir"
|
||||
7. Données chargées depuis la table `projects`
|
||||
|
||||
#### Story 2.2 : Page Projet - Détail
|
||||
|
||||
**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. Page `/projets/{slug}` dynamique avec routing
|
||||
2. Affichage : titre, description complète, image(s), date
|
||||
3. Liste des compétences utilisées avec niveaux
|
||||
4. Liens externes : URL du projet, repository GitHub
|
||||
5. Navigation "Projet précédent / suivant"
|
||||
6. Bouton retour vers la galerie
|
||||
7. Meta tags SEO dynamiques
|
||||
|
||||
#### Story 2.3 : Page Compétences - Affichage par catégories
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir les compétences du développeur organisées par catégorie,
|
||||
**so that** je comprends son profil technique.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Page `/competences` (FR) et `/en/skills` (EN) fonctionnelle
|
||||
2. Compétences groupées par catégorie (Frontend, Backend, Tools, Soft skills)
|
||||
3. Chaque compétence affiche : icône, nom, niveau actuel
|
||||
4. Design en grille ou liste selon la catégorie
|
||||
5. Animation d'entrée des éléments
|
||||
6. Données chargées depuis la table `skills`
|
||||
|
||||
#### Story 2.4 : Compétences cliquables → Projets liés
|
||||
|
||||
**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.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Chaque compétence est cliquable
|
||||
2. Au clic, affichage d'un panneau/modal avec les projets liés
|
||||
3. Pour chaque projet lié : titre, description courte, lien vers le détail
|
||||
4. Indication du niveau avant/après chaque projet
|
||||
5. Animation d'ouverture/fermeture fluide
|
||||
6. Fermeture par clic extérieur ou bouton close
|
||||
7. Fonctionne au clavier (accessibilité)
|
||||
|
||||
#### Story 2.5 : Page Témoignages - Structure de base
|
||||
|
||||
**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. Page `/temoignages` (FR) et `/en/testimonials` (EN) fonctionnelle
|
||||
2. Liste des témoignages depuis la table `testimonials`
|
||||
3. Chaque témoignage : nom, rôle, entreprise, avatar, texte
|
||||
4. Design préparé pour le composant dialogue PNJ
|
||||
5. Lien vers le projet associé si pertinent
|
||||
6. Ordre d'affichage configurable
|
||||
|
||||
#### Story 2.6 : Composant Dialogue PNJ - Style Zelda
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** lire les témoignages comme des dialogues de personnages,
|
||||
**so that** l'expérience est immersive et mémorable.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Composant réutilisable "DialoguePNJ" avec avatar et bulle
|
||||
2. Avatar stylisé à gauche, bulle de texte à droite
|
||||
3. Effet typewriter sur le texte (lettre par lettre)
|
||||
4. Clic pour accélérer/passer le texte complet
|
||||
5. Personnalités visuelles selon le champ `personality`
|
||||
6. Son optionnel "blip" désactivable
|
||||
7. Respect de `prefers-reduced-motion`
|
||||
|
||||
#### Story 2.7 : Carousel de témoignages avec dialogues
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** naviguer entre les différents témoignages,
|
||||
**so that** je peux tous les consulter.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Navigation entre témoignages (précédent/suivant)
|
||||
2. Transition animée entre les PNJ
|
||||
3. Indicateur du témoignage actuel
|
||||
4. Auto-play optionnel avec pause au hover
|
||||
5. Navigation au clavier (flèches)
|
||||
6. Mémoire du témoignage consulté
|
||||
|
||||
#### Story 2.8 : Page Parcours - Timeline narrative
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** découvrir le parcours professionnel du développeur,
|
||||
**so that** je comprends son évolution et son expérience.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Page `/parcours` (FR) et `/en/journey` (EN) fonctionnelle
|
||||
2. Timeline verticale avec étapes chronologiques
|
||||
3. Chaque étape : date, titre, description narrative
|
||||
4. Alternance gauche/droite sur desktop, linéaire sur mobile
|
||||
5. Animation d'apparition au scroll
|
||||
6. Icônes ou images pour les étapes clés
|
||||
7. Contenu bilingue
|
||||
|
||||
### Epic 3 : Gamification & Navigation
|
||||
|
||||
**Objectif** : Implémenter les éléments de gamification et la navigation alternative : narrateur-guide accompagnant le visiteur, barre de progression globale, sauvegarde de la progression, arbre de compétences interactif (vis.js), et carte interactive pour la navigation (Konva.js).
|
||||
|
||||
#### Story 3.1 : Table narrator_texts et système narrateur
|
||||
|
||||
**As a** développeur,
|
||||
**I want** un système de gestion des textes du narrateur,
|
||||
**so that** le narrateur peut afficher des messages contextuels.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Table `narrator_texts` créée (id, context, text_key, variant)
|
||||
2. Contextes définis : intro, transition_projects, transition_skills, hint, encouragement
|
||||
3. Support de variantes (sélection aléatoire)
|
||||
4. Helper PHP `getNarratorText($context, $lang)`
|
||||
5. Textes de base insérés en FR et EN
|
||||
6. Migration SQL ajoutée
|
||||
|
||||
#### Story 3.2 : Composant Narrateur UI
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir un narrateur qui me guide à travers le site,
|
||||
**so that** je me sens accompagné dans mon exploration.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Composant UI narrateur fixe (coin bas)
|
||||
2. Avatar/icône du narrateur identifiable
|
||||
3. Bulle de texte avec message contextuel
|
||||
4. Effet typewriter similaire aux dialogues PNJ
|
||||
5. Apparition/disparition animée non intrusive
|
||||
6. Bouton pour fermer/minimiser
|
||||
7. Ne bloque pas la navigation
|
||||
|
||||
#### Story 3.3 : Textes narrateur contextuels
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** que le narrateur réagisse à mes actions,
|
||||
**so that** l'expérience est personnalisée.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Message d'accueil à l'arrivée (mode aventure)
|
||||
2. Messages de transition entre sections
|
||||
3. Encouragements lors de la progression (25%, 50%, 75%)
|
||||
4. Indices si visiteur semble perdu
|
||||
5. Message spécial première visite vs retour
|
||||
6. Intégration avec hooks de navigation Nuxt (middleware)
|
||||
|
||||
#### Story 3.4 : Barre de progression globale
|
||||
|
||||
**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. Barre de progression discrète dans le header
|
||||
2. Pourcentage calculé selon sections visitées
|
||||
3. Sections trackées : Projets, Compétences, Témoignages, Parcours
|
||||
4. Animation fluide lors de la mise à jour
|
||||
5. Tooltip indiquant sections visitées/restantes
|
||||
6. Design XP bar style RPG
|
||||
|
||||
#### Story 3.5 : Store Pinia Progression avec persistance LocalStorage
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** que ma progression soit sauvegardée,
|
||||
**so that** je peux reprendre mon exploration plus tard.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Store Pinia `useProgressionStore` avec état réactif
|
||||
2. Données : sections visitées (Set), choix narratifs (Array), easter eggs (Array)
|
||||
3. Getters : pourcentage completion, sections restantes
|
||||
4. Plugin `pinia-plugin-persistedstate` pour sync LocalStorage automatique
|
||||
5. Session ID unique généré au premier accès
|
||||
6. Détection progression existante → message "Bienvenue à nouveau"
|
||||
7. Action `$reset()` pour réinitialiser la progression
|
||||
|
||||
#### Story 3.6 : Table user_progress (sauvegarde cloud optionnelle)
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** optionnellement sauvegarder ma progression en ligne,
|
||||
**so that** je peux la retrouver sur un autre appareil.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Table `user_progress` créée
|
||||
2. Formulaire "Sauvegarder ma progression" (email)
|
||||
3. Endpoint API POST pour sauvegarder
|
||||
4. Endpoint API GET pour récupérer (lien magique)
|
||||
5. Validation email basique
|
||||
6. RGPD : consentement, possibilité suppression
|
||||
|
||||
#### Story 3.7 : Arbre de compétences interactif (vis.js)
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir mes compétences sous forme d'arbre interactif,
|
||||
**so that** je visualise les connexions et la progression.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Intégration vis.js Network sur page compétences
|
||||
2. Nœuds représentant compétences avec icônes
|
||||
3. Liens entre compétences liées
|
||||
4. Taille/couleur des nœuds selon le niveau
|
||||
5. Clic sur nœud → projets liés
|
||||
6. Zoom et pan pour naviguer
|
||||
7. Layout esthétique
|
||||
|
||||
#### Story 3.8 : Carte interactive - Design Konva.js
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir une carte du "monde" du portfolio,
|
||||
**so that** je comprends la structure et ma position.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Canvas Konva.js dans page/modal dédiée
|
||||
2. Design de carte stylisé (îles, régions, chemins)
|
||||
3. Zones : Projets, Compétences, Parcours, Témoignages, Contact
|
||||
4. Indicateurs visuels zones visitées
|
||||
5. Position actuelle marquée
|
||||
6. Style visuel RPG cohérent
|
||||
7. Assets graphiques SVG/PNG
|
||||
|
||||
#### Story 3.9 : Carte interactive - Navigation
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** naviguer vers les sections en cliquant sur la carte,
|
||||
**so that** j'explore librement.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Zones cliquables sur la carte
|
||||
2. Clic → navigation vers section
|
||||
3. Animation de "voyage" optionnelle
|
||||
4. Hover → nom et statut de la zone
|
||||
5. Bouton ouvrir/fermer carte depuis toute page
|
||||
6. Navigation via `<NuxtLink>` avec transition intégrée
|
||||
|
||||
#### Story 3.10 : Minimap mobile
|
||||
|
||||
**As a** visiteur mobile,
|
||||
**I want** une version simplifiée de la carte,
|
||||
**so that** je peux naviguer sur petit écran.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Minimap compacte sur mobile
|
||||
2. Mode "liste visuelle" ou carte schématique
|
||||
3. Indicateurs de progression visibles
|
||||
4. Touch-friendly
|
||||
5. Accessible via bouton flottant
|
||||
6. Performance optimisée (pas Konva lourd)
|
||||
|
||||
### Epic 4 : Expérience Complète & Finalisation
|
||||
|
||||
**Objectif** : Finaliser l'expérience gamifiée avec les éléments avancés : chemins narratifs multiples, challenge/puzzle final, easter eggs, et célébration de fin avec le formulaire de contact comme récompense narrative.
|
||||
|
||||
#### Story 4.1 : Intro narrative - Mode Aventure
|
||||
|
||||
**As a** visiteur aventurier,
|
||||
**I want** une introduction narrative captivante,
|
||||
**so that** je suis immergé dans l'expérience dès le début.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Page/séquence d'intro après choix "Partir à l'aventure"
|
||||
2. Présentation du "héros mystérieux" (le développeur)
|
||||
3. Texte narratif avec effet typewriter
|
||||
4. Ambiance visuelle immersive
|
||||
5. Bouton "Continuer" pour avancer
|
||||
6. Transition fluide vers le premier choix
|
||||
7. Contenu bilingue FR/EN
|
||||
|
||||
#### Story 4.2 : Système de choix narratifs
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** faire des choix qui influencent mon parcours,
|
||||
**so that** mon expérience est unique.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Structure de données pour les choix (table ou JSON)
|
||||
2. 2-3 points de choix binaires dans le parcours
|
||||
3. Chaque choix mène à un chemin différent
|
||||
4. Choix stockés dans la progression LocalStorage
|
||||
5. Interface de choix claire (2 options distinctes)
|
||||
6. Pas de "mauvais" choix - tous mènent au contact
|
||||
|
||||
#### Story 4.3 : Chemins narratifs différenciés
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** que mes choix aient un impact visible,
|
||||
**so that** je sens que mon parcours est personnalisé.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. 4-8 parcours possibles selon combinaisons
|
||||
2. Ordre des sections varie selon le chemin
|
||||
3. Textes du narrateur adaptés au chemin
|
||||
4. Transitions contextuelles entre sections
|
||||
5. Tous les chemins couvrent tout le contenu
|
||||
|
||||
#### Story 4.4 : Page Challenge - Structure
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** relever un défi avant d'accéder au contact,
|
||||
**so that** l'accès au développeur est une récompense méritée.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Page `/challenge` accessible après exploration minimum
|
||||
2. Introduction narrative "Une dernière épreuve..."
|
||||
3. Zone de puzzle/challenge visible
|
||||
4. Système d'indices (bouton "Besoin d'aide ?")
|
||||
5. Blocage accès direct contact sans challenge complété
|
||||
|
||||
#### Story 4.5 : Puzzle principal
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** résoudre un puzzle intéressant mais accessible,
|
||||
**so that** je me sens intelligent sans être frustré.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Puzzle logique/code simple (réordonner, compléter, décoder)
|
||||
2. Difficulté calibrée : 1-3 minutes
|
||||
3. Validation avec feedback clair
|
||||
4. Animation de succès
|
||||
5. 3 niveaux d'indices progressifs
|
||||
6. Option "Passer" après 3 indices
|
||||
7. Thématique développement/code
|
||||
|
||||
#### Story 4.6 : Page Contact - Célébration finale
|
||||
|
||||
**As a** visiteur ayant complété le parcours,
|
||||
**I want** une conclusion mémorable et satisfaisante,
|
||||
**so that** je me souviens de cette expérience.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Page `/contact` accessible après le challenge
|
||||
2. Animation célébration "Tu m'as trouvé !" (confettis)
|
||||
3. Message de félicitations avec stats du parcours
|
||||
4. Formulaire : nom, email, message
|
||||
5. Validation côté client et serveur
|
||||
6. Envoi email via PHPMailer
|
||||
|
||||
#### Story 4.7 : Table easter_eggs et système
|
||||
|
||||
**As a** développeur,
|
||||
**I want** un système pour gérer les easter eggs,
|
||||
**so that** je peux ajouter des surprises cachées.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Table `easter_eggs` (id, slug, location, trigger_type, reward_type, reward_key)
|
||||
2. Trigger types : click, hover, konami, scroll, sequence
|
||||
3. Reward types : snippet, anecdote, image, badge
|
||||
4. Helper `checkEasterEgg($slug)`
|
||||
5. Stockage easter eggs trouvés dans progression
|
||||
6. 5-10 easter eggs définis
|
||||
|
||||
#### Story 4.8 : Easter eggs - Implémentation UI
|
||||
|
||||
**As a** visiteur curieux,
|
||||
**I want** découvrir des surprises cachées dans le site,
|
||||
**so that** l'exploration est récompensée.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Easter eggs placés sur différentes pages
|
||||
2. Déclencheurs variés selon le type
|
||||
3. Animation de découverte (popup, effet visuel)
|
||||
4. Affichage de la récompense
|
||||
5. Notification "Easter egg trouvé ! (X/Y)"
|
||||
6. Bouton fermer pour continuer
|
||||
|
||||
#### Story 4.9 : Collection d'easter eggs
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir ma collection d'easter eggs trouvés,
|
||||
**so that** je sais ce qu'il me reste à découvrir.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Section "Collection" accessible
|
||||
2. Grille avec easter eggs trouvés et silhouettes mystère
|
||||
3. Détails visibles pour les découverts
|
||||
4. Compteur X/Y trouvés
|
||||
5. Badge spéciale si 100% trouvés
|
||||
|
||||
#### Story 4.10 : Polissage et optimisations finales
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** une expérience fluide et sans bugs,
|
||||
**so that** je profite pleinement du site.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
1. Audit performance (Lighthouse > 90)
|
||||
2. Test de tous les parcours narratifs
|
||||
3. Vérification accessibilité WCAG AA
|
||||
4. Test responsive sur devices réels
|
||||
5. Corrections bugs identifiés
|
||||
6. Optimisation assets
|
||||
7. Génération sitemap.xml et robots.txt
|
||||
|
||||
## 7. Checklist Results Report
|
||||
|
||||
### Executive Summary
|
||||
|
||||
| Critère | Évaluation |
|
||||
|---------|------------|
|
||||
| **Complétude PRD** | 87% |
|
||||
| **Scope MVP** | Approprié |
|
||||
| **Prêt pour Architecture** | ✅ READY |
|
||||
|
||||
### Category Analysis
|
||||
|
||||
| Category | Status |
|
||||
|----------|--------|
|
||||
| 1. Problem Definition & Context | ✅ PASS |
|
||||
| 2. MVP Scope Definition | ✅ PASS |
|
||||
| 3. User Experience Requirements | ✅ PASS |
|
||||
| 4. Functional Requirements | ✅ PASS |
|
||||
| 5. Non-Functional Requirements | ✅ PASS |
|
||||
| 6. Epic & Story Structure | ✅ PASS |
|
||||
| 7. Technical Guidance | ✅ PASS |
|
||||
| 8. Cross-Functional Requirements | ✅ PASS |
|
||||
| 9. Clarity & Communication | ✅ PASS |
|
||||
|
||||
### Notes
|
||||
|
||||
- Story 3.6 (sauvegarde cloud) marquée optionnelle - à confirmer post-MVP
|
||||
- Assets graphiques pour la carte interactive (Story 3.8) nécessitent design
|
||||
- Puzzle principal (Story 4.5) : type à finaliser avec l'équipe
|
||||
|
||||
## 8. Next Steps
|
||||
|
||||
### 8.1 UX Expert Prompt
|
||||
|
||||
```
|
||||
Je suis l'UX Expert. J'ai besoin de créer les wireframes et le design system pour le projet "Portfolio Gamifié".
|
||||
|
||||
Contexte : Portfolio web transformé en aventure narrative immersive avec :
|
||||
- Double entrée (Aventure vs Mode Pressé)
|
||||
- Navigation par carte interactive
|
||||
- Dialogues PNJ style Zelda pour les témoignages
|
||||
- Arbre de compétences interactif
|
||||
- Système de progression gamifié
|
||||
- Challenge/puzzle avant accès au contact
|
||||
|
||||
Documents à consulter :
|
||||
- PRD : docs/prd-gamification.md
|
||||
- Brainstorming : docs/brainstorming-gamification-2026-01-26.md
|
||||
|
||||
Livrables attendus :
|
||||
1. Wireframes des écrans principaux (Landing, Carte, Projets, Skills, Témoignages, Challenge, Contact)
|
||||
2. Design system (couleurs RPG, typographie, composants)
|
||||
3. Spécifications du composant Dialogue PNJ
|
||||
4. Maquettes de la carte interactive
|
||||
5. Guidelines responsive (desktop → mobile)
|
||||
|
||||
Contraintes : WCAG AA, prefers-reduced-motion, thème sombre avec accents colorés.
|
||||
```
|
||||
|
||||
### 8.2 Architect Prompt
|
||||
|
||||
```
|
||||
Je suis l'Architecte. J'ai besoin de créer l'architecture technique pour le projet "Portfolio Gamifié".
|
||||
|
||||
Contexte : Nouveau projet avec Nuxt 3 SSR et backend Laravel API.
|
||||
|
||||
Stack validée :
|
||||
- Frontend : Nuxt 3 (SSR) / Vue 3 / Pinia / @nuxtjs/i18n / TailwindCSS
|
||||
- Backend : Laravel 11 / Eloquent ORM / MariaDB
|
||||
- Librairies : Konva.js (vue-konva, client-only) / vis-network (client-only)
|
||||
- Build : Nitro (Nuxt) + Composer (Laravel)
|
||||
- Tests : Pest/PHPUnit (Laravel) + Vitest + @nuxt/test-utils + Playwright (E2E)
|
||||
|
||||
Documents à consulter :
|
||||
- PRD : docs/prd-gamification.md
|
||||
- Brainstorming (schéma DB détaillé) : docs/brainstorming-gamification-2026-01-26.md
|
||||
|
||||
Livrables attendus :
|
||||
1. Architecture document avec structure monorepo (frontend Nuxt + api Laravel)
|
||||
2. Schéma de base de données final (migrations Laravel)
|
||||
3. Design API REST endpoints avec Laravel Resources
|
||||
4. Configuration nuxt.config.ts (SSR, i18n, transitions, modules)
|
||||
5. Structure des stores Pinia avec persistedstate (SSR-compatible)
|
||||
6. Composables réutilisables (useTypewriter, useNarrator, useEasterEggs)
|
||||
7. Stratégie composants client-only (*.client.vue) pour Konva/vis.js
|
||||
8. Configuration @nuxtjs/i18n (stratégie prefix_except_default, SEO hreflang)
|
||||
9. Configuration Laravel : CORS, Form Requests, API Resources, rate limiting
|
||||
10. Stratégie déploiement (Node.js pour Nuxt SSR + PHP pour Laravel API)
|
||||
11. Coding standards et conventions (PSR-12 PHP, ESLint/Prettier Vue)
|
||||
|
||||
Contraintes : Bundle JS < 170kb gzip, LCP < 2.5s, SSR pour SEO, navigation SPA fluide.
|
||||
```
|
||||
BIN
docs/resources/interface/Accueil Agence CTA - PC.pdf
Normal file
BIN
docs/resources/interface/Accueil Agence CTA - PC.pdf
Normal file
Binary file not shown.
9563
docs/resources/interface/Accueil Hero - PC.pdf
Normal file
9563
docs/resources/interface/Accueil Hero - PC.pdf
Normal file
File diff suppressed because one or more lines are too long
BIN
docs/resources/interface/Accueil Prestations CTA - PC.pdf
Normal file
BIN
docs/resources/interface/Accueil Prestations CTA - PC.pdf
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user