Story 1.1: initialisation projet
This commit is contained in:
225
docs/stories/1.1.initialisation-projet.md
Normal file
225
docs/stories/1.1.initialisation-projet.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Story 1.1: Initialisation du Projet et Structure de Fichiers
|
||||
|
||||
## Status
|
||||
|
||||
review
|
||||
|
||||
## Story
|
||||
|
||||
**As a** développeur,
|
||||
**I want** initialiser la structure du projet avec les dossiers et fichiers de base,
|
||||
**so that** je dispose d'une organisation claire et maintenable dès le départ.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. La structure de dossiers est créée : `pages/`, `templates/`, `includes/`, `api/`, `assets/css/`, `assets/js/`, `assets/img/`, `assets/img/projects/`, `assets/fonts/`, `data/`, `logs/`
|
||||
2. Un fichier `index.php` existe à la racine avec un contenu minimal ("Hello World")
|
||||
3. Un fichier `.gitignore` est configuré (node_modules, vendor, .env, logs, output.css)
|
||||
4. Le projet est initialisé avec Git et un premier commit est effectué
|
||||
5. Les fichiers `.env.example` et `composer.json` sont créés
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] **Task 1 : Créer la structure de dossiers** (AC: 1)
|
||||
- [x] Créer le dossier `pages/` pour les pages PHP
|
||||
- [x] Créer le dossier `templates/` pour les composants réutilisables
|
||||
- [x] Créer le dossier `includes/` pour router, functions, handlers
|
||||
- [x] Créer le dossier `api/` pour les endpoints (contact)
|
||||
- [x] Créer le dossier `assets/css/` pour les fichiers CSS
|
||||
- [x] Créer le dossier `assets/js/` pour les fichiers JavaScript
|
||||
- [x] Créer le dossier `assets/img/` et `assets/img/projects/`
|
||||
- [x] Créer le dossier `assets/fonts/` pour les polices
|
||||
- [x] Créer le dossier `data/` pour les fichiers JSON
|
||||
- [x] Créer le dossier `logs/` avec un fichier `.gitkeep`
|
||||
|
||||
- [x] **Task 2 : Créer le fichier index.php** (AC: 2)
|
||||
- [x] Créer `index.php` à la racine
|
||||
- [x] Ajouter un contenu minimal HTML5 avec "Hello World"
|
||||
- [x] Inclure les meta tags viewport pour le responsive
|
||||
|
||||
- [x] **Task 3 : Configurer .gitignore** (AC: 3)
|
||||
- [x] Créer le fichier `.gitignore`
|
||||
- [x] Ajouter les exclusions : `.env`, `vendor/`, `node_modules/`, `logs/*.log`, `assets/css/output.css`
|
||||
- [x] Ajouter les exclusions IDE : `.idea/`, `.vscode/`, `.DS_Store`
|
||||
|
||||
- [x] **Task 4 : Créer les fichiers de configuration** (AC: 5)
|
||||
- [x] Créer `.env.example` avec les variables requises
|
||||
- [x] Créer `composer.json` avec la dépendance `vlucas/phpdotenv`
|
||||
|
||||
- [x] **Task 5 : Initialiser Git** (AC: 4)
|
||||
- [x] Vérifier que le repo Git existe (déjà initialisé)
|
||||
- [x] Effectuer un commit initial avec message descriptif
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Structure de Fichiers Cible
|
||||
|
||||
```
|
||||
/portfolio
|
||||
├── index.php # Point d'entrée + router front controller
|
||||
├── config.php # Charge .env et définit les constantes
|
||||
├── composer.json # Dépendances PHP
|
||||
├── .env # Variables sensibles (gitignore)
|
||||
├── .env.example # Template sans valeurs sensibles
|
||||
│
|
||||
├── api/
|
||||
│ └── contact.php # Endpoint formulaire de contact
|
||||
│
|
||||
├── pages/
|
||||
│ ├── home.php # Page d'accueil
|
||||
│ ├── projects.php # Liste projets
|
||||
│ ├── project-single.php # Page projet individuelle
|
||||
│ ├── skills.php # Compétences
|
||||
│ ├── about.php # Me Découvrir
|
||||
│ ├── contact.php # Formulaire de contact
|
||||
│ └── 404.php # Page erreur 404
|
||||
│
|
||||
├── templates/
|
||||
│ ├── header.php # <head>, meta tags, CSS
|
||||
│ ├── footer.php # Scripts JS, copyright
|
||||
│ ├── navbar.php # Navigation sticky + CTA
|
||||
│ └── ... # Autres composants
|
||||
│
|
||||
├── includes/
|
||||
│ ├── router.php # Logique de routage
|
||||
│ ├── functions.php # Helpers PHP
|
||||
│ └── contact-handler.php # Traitement formulaire
|
||||
│
|
||||
├── data/
|
||||
│ ├── projects.json # Données des projets
|
||||
│ └── testimonials.json # Témoignages
|
||||
│
|
||||
├── assets/
|
||||
│ ├── css/
|
||||
│ │ ├── input.css # Source Tailwind
|
||||
│ │ └── output.css # CSS compilé (généré)
|
||||
│ ├── js/
|
||||
│ │ └── main.js # Scripts JS
|
||||
│ ├── img/
|
||||
│ │ └── projects/ # Images des projets
|
||||
│ └── fonts/ # Polices (Inter, JetBrains Mono)
|
||||
│
|
||||
├── logs/ # Logs d'erreurs (gitignore)
|
||||
│ └── .gitkeep
|
||||
│
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
### Contenu .env.example
|
||||
|
||||
```env
|
||||
# Application
|
||||
APP_ENV=development
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
# reCAPTCHA v3
|
||||
RECAPTCHA_SITE_KEY=your_site_key_here
|
||||
RECAPTCHA_SECRET_KEY=your_secret_key_here
|
||||
|
||||
# Contact Email
|
||||
CONTACT_EMAIL=contact@example.com
|
||||
|
||||
# Sécurité
|
||||
APP_SECRET=your_random_secret_key_here
|
||||
```
|
||||
|
||||
### Contenu composer.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "portfolio/website",
|
||||
"description": "Portfolio développeur web",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=8.0",
|
||||
"vlucas/phpdotenv": "^5.6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Conventions de Nommage
|
||||
|
||||
| Élément | Convention | Exemple |
|
||||
|---------|------------|---------|
|
||||
| Fichiers PHP pages | kebab-case | `project-single.php` |
|
||||
| Fichiers PHP templates | kebab-case | `project-card.php` |
|
||||
| Fichiers JS | kebab-case | `contact-form.js` |
|
||||
| Dossiers | kebab-case | `assets/img/projects/` |
|
||||
|
||||
## Testing
|
||||
|
||||
### Validation Manuelle
|
||||
|
||||
- [ ] Tous les dossiers existent et sont accessibles
|
||||
- [ ] Le fichier `index.php` s'affiche dans le navigateur (Hello World)
|
||||
- [ ] Le `.gitignore` fonctionne (les fichiers exclus ne sont pas trackés)
|
||||
- [ ] `composer install` s'exécute sans erreur
|
||||
|
||||
### Commande de Test
|
||||
|
||||
```bash
|
||||
# Vérifier la structure
|
||||
ls -la
|
||||
ls -la pages/ templates/ includes/ api/ assets/ data/ logs/
|
||||
|
||||
# Tester le serveur PHP
|
||||
php -S localhost:8000
|
||||
|
||||
# Installer les dépendances
|
||||
composer install
|
||||
```
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-02-04 | 0.1 | Implementation story 1.1 | Amelia |
|
||||
| 2026-01-22 | 0.1 | Création initiale de la story | Sarah (PO) |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
GPT-5 Codex
|
||||
|
||||
### Debug Log References
|
||||
|
||||
- tests/structure.test.ps1: Assert-True boolean cast fix
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- Structure de dossiers créée
|
||||
- index.php avec Hello World et meta viewport
|
||||
- .gitignore configuré (.env, vendor/, node_modules/, logs/*.log, assets/css/output.css, IDE)
|
||||
- composer.json avec dépendance vlucas/phpdotenv
|
||||
- .env.example avec toutes les variables requises
|
||||
- Tests: `powershell -ExecutionPolicy Bypass -File tests/run.ps1`
|
||||
- Commit initial effectué
|
||||
|
||||
### File List
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `index.php` | Créé |
|
||||
| `.gitignore` | Créé |
|
||||
| `.env.example` | Créé |
|
||||
| `composer.json` | Créé |
|
||||
| `logs/.gitkeep` | Créé |
|
||||
| `tests/run.ps1` | Créé |
|
||||
| `tests/structure.test.ps1` | Créé |
|
||||
| `pages/` | Dossier créé |
|
||||
| `templates/` | Dossier créé |
|
||||
| `includes/` | Dossier créé |
|
||||
| `api/` | Dossier créé |
|
||||
| `assets/css/` | Dossier créé |
|
||||
| `assets/js/` | Dossier créé |
|
||||
| `assets/img/` | Dossier créé |
|
||||
| `assets/img/projects/` | Dossier créé |
|
||||
| `assets/fonts/` | Dossier créé |
|
||||
| `data/` | Dossier créé |
|
||||
| `logs/` | Dossier créé |
|
||||
|
||||
## QA Results
|
||||
|
||||
_À compléter par le QA agent_
|
||||
445
docs/stories/1.2.configuration-tailwind.md
Normal file
445
docs/stories/1.2.configuration-tailwind.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# Story 1.2: Configuration de Tailwind CSS avec CLI
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** développeur,
|
||||
**I want** configurer Tailwind CSS avec le CLI et le build optimisé,
|
||||
**so that** je bénéficie d'un CSS performant avec purge automatique.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Node.js et npm sont utilisés uniquement pour le build (pas en production)
|
||||
2. `tailwind.config.js` est créé avec les chemins des fichiers PHP à scanner
|
||||
3. Le fichier `assets/css/input.css` contient les directives Tailwind (@tailwind base, components, utilities)
|
||||
4. La commande `npm run build` génère `assets/css/output.css` minifié
|
||||
5. Une commande `npm run dev` permet le développement en temps réel (watch)
|
||||
6. Le fichier CSS généré fait moins de 50kb après purge
|
||||
7. La palette de couleurs personnalisée est configurée (thème sombre)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Initialiser npm et installer les dépendances** (AC: 1)
|
||||
- [] Créer `package.json` avec `npm init -y`
|
||||
- [] Installer Tailwind CSS : `npm install -D tailwindcss postcss autoprefixer`
|
||||
- [] Vérifier que `node_modules/` est dans `.gitignore`
|
||||
|
||||
- [] **Task 2 : Créer la configuration Tailwind** (AC: 2, 7)
|
||||
- [] Créer `tailwind.config.js` avec les chemins PHP à scanner
|
||||
- [] Configurer la palette de couleurs personnalisée (primary, background, surface, text, etc.)
|
||||
- [] Configurer les polices (Inter, JetBrains Mono)
|
||||
- [] Configurer les tailles de texte personnalisées
|
||||
- [] Configurer les ombres personnalisées
|
||||
|
||||
- [] **Task 3 : Créer le fichier CSS source** (AC: 3)
|
||||
- [] Créer `assets/css/input.css`
|
||||
- [] Ajouter les directives `@tailwind base`, `@tailwind components`, `@tailwind utilities`
|
||||
- [] Ajouter les styles de base dans `@layer base`
|
||||
- [] Ajouter les composants réutilisables dans `@layer components` (btn, card, input, badge, etc.)
|
||||
- [] Ajouter les animations dans `@layer utilities`
|
||||
|
||||
- [] **Task 4 : Configurer PostCSS** (AC: 1)
|
||||
- [] Créer `postcss.config.js` avec tailwindcss et autoprefixer
|
||||
|
||||
- [] **Task 5 : Configurer les scripts npm** (AC: 4, 5)
|
||||
- [] Ajouter le script `build` dans package.json
|
||||
- [] Ajouter le script `dev` (watch) dans package.json
|
||||
- [] Tester les deux scripts
|
||||
|
||||
- [] **Task 6 : Valider la taille du CSS** (AC: 6)
|
||||
- [] Exécuter `npm run build`
|
||||
- [] Vérifier que `output.css` < 50kb (6,4 Ko)
|
||||
- [] Vérifier que le purge fonctionne correctement
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Dépendances npm (devDependencies uniquement)
|
||||
|
||||
```json
|
||||
{
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration tailwind.config.js Complète
|
||||
|
||||
```javascript
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./*.php',
|
||||
'./pages/**/*.php',
|
||||
'./templates/**/*.php',
|
||||
'./assets/js/**/*.js'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#FA784F',
|
||||
light: '#FB9570',
|
||||
dark: '#E5623A',
|
||||
},
|
||||
background: '#17171F',
|
||||
surface: {
|
||||
DEFAULT: '#1E1E28',
|
||||
light: '#2A2A36',
|
||||
},
|
||||
border: '#3A3A48',
|
||||
text: {
|
||||
primary: '#F5F5F7',
|
||||
secondary: '#A1A1AA',
|
||||
muted: '#71717A',
|
||||
},
|
||||
success: '#34D399',
|
||||
warning: '#FBBF24',
|
||||
error: '#F87171',
|
||||
info: '#60A5FA',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
fontSize: {
|
||||
'display': ['2.5rem', { lineHeight: '1.2', fontWeight: '700' }],
|
||||
'heading': ['2rem', { lineHeight: '1.3', fontWeight: '600' }],
|
||||
'subheading': ['1.5rem', { lineHeight: '1.4', fontWeight: '600' }],
|
||||
'body': ['1rem', { lineHeight: '1.6', fontWeight: '400' }],
|
||||
'small': ['0.875rem', { lineHeight: '1.5', fontWeight: '400' }],
|
||||
},
|
||||
maxWidth: {
|
||||
'content': '1280px',
|
||||
},
|
||||
boxShadow: {
|
||||
'card': '0 4px 20px rgba(0, 0, 0, 0.25)',
|
||||
'card-hover': '0 10px 40px rgba(0, 0, 0, 0.3)',
|
||||
'input-focus': '0 0 0 3px rgba(250, 120, 79, 0.2)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration postcss.config.js
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Contenu assets/css/input.css
|
||||
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply scroll-smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-text-primary font-sans antialiased;
|
||||
}
|
||||
|
||||
h1 { @apply text-display text-text-primary; }
|
||||
h2 { @apply text-heading text-text-primary; }
|
||||
h3 { @apply text-subheading text-text-primary; }
|
||||
p { @apply text-body text-text-secondary; }
|
||||
|
||||
a {
|
||||
@apply text-primary hover:text-primary-light transition-colors duration-150;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@apply outline-none ring-2 ring-primary ring-offset-2 ring-offset-background;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-primary/30 text-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Container */
|
||||
.container-content {
|
||||
@apply max-w-content mx-auto px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
/* Boutons */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2
|
||||
px-6 py-3 font-medium rounded-lg
|
||||
transition-all duration-150
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
focus:ring-offset-background
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary text-background
|
||||
hover:bg-primary-light active:bg-primary-dark
|
||||
focus:ring-primary;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn border-2 border-primary text-primary bg-transparent
|
||||
hover:bg-primary hover:text-background
|
||||
focus:ring-primary;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn text-primary bg-transparent
|
||||
hover:text-primary-light hover:bg-surface-light
|
||||
focus:ring-primary;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-1
|
||||
text-xs font-medium rounded
|
||||
bg-surface-light text-text-secondary;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
@apply bg-primary/20 text-primary;
|
||||
}
|
||||
|
||||
.badge-muted {
|
||||
@apply bg-border text-text-muted;
|
||||
}
|
||||
|
||||
/* Cartes */
|
||||
.card {
|
||||
@apply bg-surface rounded-lg overflow-hidden
|
||||
border border-border/50
|
||||
transition-all duration-200;
|
||||
}
|
||||
|
||||
.card-interactive {
|
||||
@apply card cursor-pointer
|
||||
hover:-translate-y-1 hover:shadow-card-hover
|
||||
hover:border-border;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
@apply p-4 sm:p-6;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.input {
|
||||
@apply w-full px-4 py-3
|
||||
bg-surface border border-border rounded-lg
|
||||
text-text-primary placeholder-text-muted
|
||||
transition-all duration-150
|
||||
focus:outline-none focus:border-primary focus:shadow-input-focus;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@apply border-error focus:border-error
|
||||
focus:shadow-[0_0_0_3px_rgba(248,113,113,0.2)];
|
||||
}
|
||||
|
||||
.textarea {
|
||||
@apply input min-h-[150px] resize-y;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-text-secondary mb-2;
|
||||
}
|
||||
|
||||
.label-required::after {
|
||||
content: '*';
|
||||
@apply text-error ml-1;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@apply text-sm text-error mt-1.5 flex items-center gap-1;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
@apply py-16 sm:py-24;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@apply text-center mb-12;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-heading mb-4;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
@apply text-body text-text-secondary max-w-2xl mx-auto;
|
||||
}
|
||||
|
||||
/* Témoignage */
|
||||
.testimonial {
|
||||
@apply bg-surface-light rounded-lg p-6 border-l-4 border-primary;
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
.breadcrumb {
|
||||
@apply flex items-center gap-2 text-sm text-text-muted;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
@apply text-text-secondary hover:text-primary transition-colors;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
@apply text-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animation-delay-100 { animation-delay: 100ms; }
|
||||
.animation-delay-200 { animation-delay: 200ms; }
|
||||
.animation-delay-300 { animation-delay: 300ms; }
|
||||
|
||||
.aspect-thumbnail {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Accessibilité - Réduction de mouvement */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scripts package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "tailwindcss -i ./assets/css/input.css -o ./assets/css/output.css --watch",
|
||||
"build": "tailwindcss -i ./assets/css/input.css -o ./assets/css/output.css --minify"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Palette de Couleurs (Thème Sombre)
|
||||
|
||||
| Couleur | Hex | Usage |
|
||||
|---------|-----|-------|
|
||||
| Primary | `#FA784F` | CTA, liens, accents |
|
||||
| Primary Light | `#FB9570` | Hover |
|
||||
| Primary Dark | `#E5623A` | Active/pressed |
|
||||
| Background | `#17171F` | Fond de page |
|
||||
| Surface | `#1E1E28` | Cartes, navbar |
|
||||
| Surface Light | `#2A2A36` | Hover cartes |
|
||||
| Border | `#3A3A48` | Bordures |
|
||||
| Text Primary | `#F5F5F7` | Titres |
|
||||
| Text Secondary | `#A1A1AA` | Texte secondaire |
|
||||
| Text Muted | `#71717A` | Placeholders |
|
||||
|
||||
## Testing
|
||||
|
||||
### Validation Manuelle
|
||||
|
||||
- [ ] `npm install` s'exécute sans erreur
|
||||
- [ ] `npm run build` génère `output.css`
|
||||
- [ ] `npm run dev` lance le watch mode
|
||||
- [ ] Le fichier `output.css` < 50kb
|
||||
- [ ] Les classes Tailwind fonctionnent dans le navigateur
|
||||
|
||||
### Commandes de Test
|
||||
|
||||
```bash
|
||||
# Installer les dépendances
|
||||
npm install
|
||||
|
||||
# Build production
|
||||
npm run build
|
||||
|
||||
# Vérifier la taille du CSS
|
||||
ls -lh assets/css/output.css
|
||||
|
||||
# Lancer le watch pour le dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Test Visuel
|
||||
|
||||
Créer un fichier HTML temporaire pour tester les classes :
|
||||
- `.btn-primary` → bouton orange
|
||||
- `.bg-background` → fond sombre #17171F
|
||||
- `.text-primary` → texte orange
|
||||
- `.card` → carte avec fond surface
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale de la story | Sarah (PO) |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
_À compléter par le dev agent_
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- Package.json mis à jour (v2.0.0) avec scripts dev/build Tailwind
|
||||
- Dépendances installées : tailwindcss ^3.4.0, postcss ^8.4.0, autoprefixer ^10.4.0
|
||||
- tailwind.config.js créé avec palette couleurs sombre, polices Inter/JetBrains Mono
|
||||
- input.css complet avec @layer base, components (btn, card, input, badge, etc.), utilities (animations)
|
||||
- postcss.config.js configuré
|
||||
- Build validé : output.css = 6,4 Ko (< 50 Ko requis)
|
||||
- Warning normal : pas encore de fichiers PHP utilisant les classes
|
||||
|
||||
### File List
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `package.json` | Modifié |
|
||||
| `package-lock.json` | Modifié |
|
||||
| `tailwind.config.js` | Créé |
|
||||
| `postcss.config.js` | Créé |
|
||||
| `assets/css/input.css` | Créé |
|
||||
| `assets/css/output.css` | Généré |
|
||||
|
||||
## QA Results
|
||||
|
||||
_À compléter par le QA agent_
|
||||
272
docs/stories/1.3.templates-php-base.md
Normal file
272
docs/stories/1.3.templates-php-base.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# Story 1.3: Templates PHP de Base (Header/Footer)
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** développeur,
|
||||
**I want** créer les templates PHP réutilisables pour le header et le footer,
|
||||
**so that** je ne duplique pas le code HTML commun sur chaque page.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. `templates/header.php` contient le doctype, head, meta tags SEO de base, et lien vers le CSS
|
||||
2. `templates/footer.php` contient la fermeture body, les scripts JS, et le copyright
|
||||
3. Le header inclut les balises meta viewport pour le responsive
|
||||
4. Le header permet de passer un titre de page dynamique via une variable PHP
|
||||
5. Les templates sont inclus dans `index.php` et la page s'affiche correctement
|
||||
6. Une fonction helper `include_template()` est créée pour inclure les templates avec des données
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer la fonction helper include_template()** (AC: 6)
|
||||
- [] Créer le fichier `includes/functions.php`
|
||||
- [] Implémenter la fonction `include_template($name, $data = [])`
|
||||
- [] La fonction doit utiliser `extract()` pour passer les variables au template
|
||||
- [] Gérer le chemin vers le dossier templates/
|
||||
|
||||
- [] **Task 2 : Créer le template header.php** (AC: 1, 3, 4)
|
||||
- [] Créer `templates/header.php`
|
||||
- [] Ajouter le doctype HTML5
|
||||
- [] Ajouter les meta tags essentiels (charset, viewport, description)
|
||||
- [] Ajouter les meta tags Open Graph de base
|
||||
- [] Ajouter le lien vers `output.css`
|
||||
- [] Ajouter le preload des polices
|
||||
- [] Permettre un titre dynamique via `$pageTitle`
|
||||
- [] Permettre une description dynamique via `$pageDescription`
|
||||
|
||||
- [] **Task 3 : Créer le template footer.php** (AC: 2)
|
||||
- [] Créer `templates/footer.php`
|
||||
- [] Ajouter le copyright avec l'année dynamique
|
||||
- [] Ajouter le lien vers `main.js` avec attribut `defer`
|
||||
- [] Fermer les balises body et html
|
||||
|
||||
- [] **Task 4 : Mettre à jour index.php** (AC: 5)
|
||||
- [] Inclure `includes/functions.php`
|
||||
- [] Utiliser `include_template('header', ['pageTitle' => '...'])`
|
||||
- [] Ajouter un contenu de test minimal
|
||||
- [] Utiliser `include_template('footer')`
|
||||
|
||||
- [] **Task 5 : Tester l'affichage**
|
||||
- [] Lancer le serveur PHP local
|
||||
- [] Vérifier que la page s'affiche correctement
|
||||
- [] Vérifier le titre dans l'onglet du navigateur
|
||||
- [] Vérifier que le CSS est chargé
|
||||
- [] Valider le HTML avec W3C Validator
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Fonction include_template()
|
||||
|
||||
```php
|
||||
// includes/functions.php
|
||||
|
||||
/**
|
||||
* Inclut un template avec des données
|
||||
* @param string $name Nom du template (sans .php)
|
||||
* @param array $data Variables à passer au template
|
||||
*/
|
||||
function include_template(string $name, array $data = []): void
|
||||
{
|
||||
extract($data);
|
||||
include __DIR__ . "/../templates/{$name}.php";
|
||||
}
|
||||
```
|
||||
|
||||
### Contenu templates/header.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Template Header
|
||||
* Variables disponibles :
|
||||
* - $pageTitle (string) : Titre de la page
|
||||
* - $pageDescription (string, optionnel) : Meta description
|
||||
*/
|
||||
|
||||
$pageTitle = $pageTitle ?? 'Portfolio - Développeur Web';
|
||||
$pageDescription = $pageDescription ?? 'Portfolio de développeur web full-stack. Découvrez mes projets, compétences et parcours.';
|
||||
$siteName = 'Portfolio';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="<?= htmlspecialchars($pageDescription, ENT_QUOTES, 'UTF-8') ?>">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="<?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta property="og:description" content="<?= htmlspecialchars($pageDescription, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:locale" content="fr_FR">
|
||||
|
||||
<!-- Preload fonts -->
|
||||
<link rel="preload" href="/assets/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/jetbrains-mono-var.woff2" as="font" type="font/woff2" crossorigin>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="/assets/img/favicon.ico" type="image/x-icon">
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/assets/css/output.css">
|
||||
|
||||
<title><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?> | <?= $siteName ?></title>
|
||||
</head>
|
||||
<body class="bg-background text-text-primary font-sans antialiased">
|
||||
```
|
||||
|
||||
### Contenu templates/footer.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Template Footer
|
||||
*/
|
||||
$currentYear = date('Y');
|
||||
?>
|
||||
<!-- Footer -->
|
||||
<footer class="bg-surface border-t border-border py-8 mt-auto">
|
||||
<div class="container-content text-center">
|
||||
<p class="text-text-muted text-sm">
|
||||
© <?= $currentYear ?> Portfolio. Tous droits réservés.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/assets/js/main.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Structure index.php mise à jour
|
||||
|
||||
```php
|
||||
<?php
|
||||
// index.php - Point d'entrée
|
||||
|
||||
require_once __DIR__ . '/includes/functions.php';
|
||||
|
||||
// Inclure le header avec le titre
|
||||
include_template('header', [
|
||||
'pageTitle' => 'Accueil',
|
||||
'pageDescription' => 'Portfolio de développeur web. Découvrez mes projets et compétences.'
|
||||
]);
|
||||
?>
|
||||
|
||||
<main class="min-h-screen">
|
||||
<div class="container-content py-20">
|
||||
<h1 class="text-display text-center">Hello World</h1>
|
||||
<p class="text-center text-text-secondary mt-4">
|
||||
Le portfolio est en construction.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php include_template('footer'); ?>
|
||||
```
|
||||
|
||||
### Conventions pour les Variables de Template
|
||||
|
||||
| Variable | Type | Obligatoire | Description |
|
||||
|----------|------|-------------|-------------|
|
||||
| `$pageTitle` | string | Oui | Titre affiché dans l'onglet |
|
||||
| `$pageDescription` | string | Non | Meta description SEO |
|
||||
| `$bodyClass` | string | Non | Classes additionnelles sur body |
|
||||
|
||||
### Meta Tags SEO Inclus
|
||||
|
||||
- `charset` : UTF-8
|
||||
- `viewport` : responsive mobile-first
|
||||
- `description` : description de la page
|
||||
- `og:title` : titre Open Graph
|
||||
- `og:description` : description Open Graph
|
||||
- `og:type` : website
|
||||
- `og:locale` : fr_FR
|
||||
|
||||
### Fichier main.js Minimal
|
||||
|
||||
Créer `assets/js/main.js` avec un contenu minimal pour éviter l'erreur 404 :
|
||||
|
||||
```javascript
|
||||
// assets/js/main.js
|
||||
// Script principal du portfolio
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('Portfolio chargé');
|
||||
});
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Validation Manuelle
|
||||
|
||||
- [ ] La page index.php s'affiche sans erreur PHP
|
||||
- [ ] Le titre de l'onglet affiche "Accueil | Portfolio"
|
||||
- [ ] Le fond est sombre (#17171F)
|
||||
- [ ] Le texte "Hello World" est visible et stylé
|
||||
- [ ] Pas d'erreur 404 dans la console (CSS, JS)
|
||||
- [ ] Le HTML est valide (W3C Validator)
|
||||
|
||||
### Commandes de Test
|
||||
|
||||
```bash
|
||||
# Lancer le serveur PHP
|
||||
php -S localhost:8000
|
||||
|
||||
# Ouvrir dans le navigateur
|
||||
# http://localhost:8000
|
||||
|
||||
# Vérifier les erreurs dans la console DevTools
|
||||
```
|
||||
|
||||
### Checklist Accessibilité
|
||||
|
||||
- [ ] `lang="fr"` sur la balise html
|
||||
- [ ] Meta viewport présent
|
||||
- [ ] Titre de page descriptif
|
||||
- [ ] Structure sémantique (main, footer)
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale de la story | Sarah (PO) |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
_À compléter par le dev agent_
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- Fonction include_template() créée avec extract() pour les variables
|
||||
- header.php avec doctype, meta SEO, Open Graph, preload fonts, CSS link
|
||||
- footer.php avec copyright dynamique et script main.js defer
|
||||
- index.php mis à jour pour utiliser les templates
|
||||
- main.js créé (minimal) pour éviter erreur 404
|
||||
- Syntaxe PHP validée sans erreurs
|
||||
- CSS regénéré avec nouvelles classes (7,7 Ko)
|
||||
|
||||
### File List
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `includes/functions.php` | Créé |
|
||||
| `templates/header.php` | Créé |
|
||||
| `templates/footer.php` | Créé |
|
||||
| `assets/js/main.js` | Créé |
|
||||
| `index.php` | Modifié |
|
||||
| `assets/css/output.css` | Regénéré |
|
||||
|
||||
## QA Results
|
||||
|
||||
_À compléter par le QA agent_
|
||||
333
docs/stories/1.4.page-canary-deploiement.md
Normal file
333
docs/stories/1.4.page-canary-deploiement.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Story 1.4: Page Canary et Validation du Déploiement
|
||||
|
||||
## Status
|
||||
|
||||
In Progress
|
||||
|
||||
## Story
|
||||
|
||||
**As a** développeur,
|
||||
**I want** déployer une page "canary" minimale sur le serveur,
|
||||
**so that** je valide que l'infrastructure PHP et le CSS fonctionnent en production.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. La page index.php affiche un message de test stylé avec Tailwind (titre centré, couleur d'accent)
|
||||
2. La page est responsive (vérifiable sur mobile)
|
||||
3. Le déploiement sur le serveur personnel fonctionne
|
||||
4. Le site est accessible via HTTPS
|
||||
5. Le temps de chargement est inférieur à 2 secondes (Lighthouse)
|
||||
6. Les fichiers sensibles ne sont pas accessibles (.env, vendor/, data/)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Finaliser la page canary** (AC: 1, 2)
|
||||
- [] Mettre à jour `index.php` avec un contenu de test attractif
|
||||
- [] Ajouter un titre centré avec la classe `text-primary`
|
||||
- [] Ajouter un sous-titre et une description
|
||||
- [] Vérifier le responsive sur mobile (DevTools)
|
||||
- [] Tester les classes Tailwind (btn-primary, card, etc.)
|
||||
|
||||
- [] **Task 2 : Préparer les fichiers pour le déploiement** (AC: 3)
|
||||
- [] Exécuter `npm run build` pour générer le CSS minifié
|
||||
- [] Exécuter `composer install --no-dev` pour les dépendances
|
||||
- [ ] Créer le fichier `.env` de production (à faire sur le serveur)
|
||||
- [] Vérifier que `.gitignore` exclut les fichiers sensibles
|
||||
|
||||
- [ ] **Task 3 : Configurer le serveur nginx** (AC: 3, 4, 6)
|
||||
- [ ] Créer/adapter la configuration nginx
|
||||
- [ ] Configurer les redirections vers index.php (front controller)
|
||||
- [ ] Bloquer l'accès aux fichiers sensibles (.env, vendor/, data/, logs/)
|
||||
- [ ] Configurer les headers de sécurité
|
||||
- [ ] Activer la compression gzip
|
||||
|
||||
- [ ] **Task 4 : Configurer HTTPS** (AC: 4)
|
||||
- [ ] Vérifier le certificat SSL (Let's Encrypt)
|
||||
- [ ] Configurer la redirection HTTP → HTTPS
|
||||
- [ ] Tester l'accès HTTPS
|
||||
|
||||
- [ ] **Task 5 : Déployer sur le serveur** (AC: 3)
|
||||
- [ ] Transférer les fichiers (FTP/SFTP ou git pull)
|
||||
- [ ] Exclure : `node_modules/`, `package.json`, `package-lock.json`, `tailwind.config.js`, `postcss.config.js`
|
||||
- [ ] Vérifier les permissions des dossiers
|
||||
- [ ] Tester l'accès à la page
|
||||
|
||||
- [ ] **Task 6 : Valider les performances** (AC: 5)
|
||||
- [ ] Lancer un audit Lighthouse
|
||||
- [ ] Vérifier que le temps de chargement < 2s
|
||||
- [ ] Vérifier le score Performance > 90
|
||||
- [ ] Corriger les éventuels problèmes
|
||||
|
||||
- [ ] **Task 7 : Tests de sécurité** (AC: 6)
|
||||
- [ ] Tester l'accès à `/.env` → doit retourner 404
|
||||
- [ ] Tester l'accès à `/vendor/` → doit retourner 404
|
||||
- [ ] Tester l'accès à `/data/` → doit retourner 404
|
||||
- [ ] Tester l'accès à `/logs/` → doit retourner 404
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Contenu Page Canary (index.php)
|
||||
|
||||
```php
|
||||
<?php
|
||||
// index.php - Page Canary
|
||||
|
||||
require_once __DIR__ . '/includes/functions.php';
|
||||
|
||||
include_template('header', [
|
||||
'pageTitle' => 'Portfolio en construction',
|
||||
'pageDescription' => 'Mon portfolio de développeur web arrive bientôt. Restez connectés !'
|
||||
]);
|
||||
?>
|
||||
|
||||
<main class="min-h-screen flex items-center justify-center">
|
||||
<div class="container-content text-center py-20">
|
||||
<!-- Titre principal -->
|
||||
<h1 class="text-display text-text-primary mb-4 animate-fade-in">
|
||||
Portfolio <span class="text-primary">en construction</span>
|
||||
</h1>
|
||||
|
||||
<!-- Sous-titre -->
|
||||
<p class="text-xl text-text-secondary mb-8 max-w-2xl mx-auto animate-fade-in animation-delay-100">
|
||||
Je prépare quelque chose de génial pour vous.
|
||||
<br>Revenez bientôt pour découvrir mes projets !
|
||||
</p>
|
||||
|
||||
<!-- Badge de test -->
|
||||
<div class="flex justify-center gap-4 mb-12 animate-fade-in animation-delay-200">
|
||||
<span class="badge">PHP</span>
|
||||
<span class="badge">Tailwind CSS</span>
|
||||
<span class="badge badge-primary">En cours</span>
|
||||
</div>
|
||||
|
||||
<!-- Card de test -->
|
||||
<div class="card max-w-md mx-auto animate-fade-in animation-delay-300">
|
||||
<div class="card-body">
|
||||
<h3 class="text-subheading mb-2">Infrastructure validée</h3>
|
||||
<p class="text-text-secondary mb-4">
|
||||
PHP, Tailwind CSS et le serveur fonctionnent correctement.
|
||||
</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<span class="btn-primary">Bouton Primary</span>
|
||||
<span class="btn-secondary">Bouton Secondary</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test responsive -->
|
||||
<p class="text-text-muted text-sm mt-12">
|
||||
Testé sur mobile, tablette et desktop.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php include_template('footer'); ?>
|
||||
```
|
||||
|
||||
### Configuration Nginx (Production)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name monportfolio.fr www.monportfolio.fr;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name monportfolio.fr www.monportfolio.fr;
|
||||
|
||||
# SSL
|
||||
ssl_certificate /etc/letsencrypt/live/monportfolio.fr/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/monportfolio.fr/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
root /var/www/portfolio;
|
||||
index index.php;
|
||||
charset utf-8;
|
||||
|
||||
# Headers de sécurité
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Bloquer fichiers sensibles
|
||||
location ~ /\.(env|git|htaccess) { deny all; return 404; }
|
||||
location ^~ /vendor/ { deny all; return 404; }
|
||||
location ^~ /node_modules/ { deny all; return 404; }
|
||||
location ^~ /logs/ { deny all; return 404; }
|
||||
location ^~ /data/ { deny all; return 404; }
|
||||
location ^~ /includes/ { deny all; return 404; }
|
||||
|
||||
# Assets statiques avec cache long
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
gzip_static on;
|
||||
}
|
||||
|
||||
# Router PHP (front controller)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
# PHP-FPM
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
# Compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/javascript application/javascript application/json image/svg+xml;
|
||||
}
|
||||
```
|
||||
|
||||
### Fichiers à Déployer
|
||||
|
||||
**Inclure :**
|
||||
- `index.php`
|
||||
- `config.php`
|
||||
- `composer.json`, `composer.lock`
|
||||
- `vendor/` (après composer install --no-dev)
|
||||
- `pages/`
|
||||
- `templates/`
|
||||
- `includes/`
|
||||
- `api/`
|
||||
- `assets/` (avec output.css généré)
|
||||
- `data/`
|
||||
- `logs/` (dossier vide avec .gitkeep)
|
||||
- `.env` (créé sur le serveur, pas commité)
|
||||
|
||||
**Exclure :**
|
||||
- `node_modules/`
|
||||
- `package.json`, `package-lock.json`
|
||||
- `tailwind.config.js`, `postcss.config.js`
|
||||
- `.env` (local)
|
||||
- `.git/`
|
||||
- `docs/`
|
||||
|
||||
### Variables .env Production
|
||||
|
||||
```env
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://monportfolio.fr
|
||||
|
||||
RECAPTCHA_SITE_KEY=votre_cle_site
|
||||
RECAPTCHA_SECRET_KEY=votre_cle_secrete
|
||||
|
||||
CONTACT_EMAIL=contact@monportfolio.fr
|
||||
|
||||
APP_SECRET=une_cle_secrete_aleatoire_de_32_caracteres
|
||||
```
|
||||
|
||||
### Checklist Pré-Déploiement
|
||||
|
||||
- [ ] `npm run build` exécuté (CSS minifié)
|
||||
- [ ] `composer install --no-dev` exécuté
|
||||
- [ ] Fichier `.env` de production prêt
|
||||
- [ ] Configuration nginx testée localement
|
||||
- [ ] Certificat SSL valide
|
||||
|
||||
## Testing
|
||||
|
||||
### Tests Fonctionnels
|
||||
|
||||
- [ ] La page s'affiche correctement en production
|
||||
- [ ] Le titre "Portfolio en construction" est visible
|
||||
- [ ] La couleur d'accent (#FA784F) s'affiche
|
||||
- [ ] Les boutons sont stylés correctement
|
||||
- [ ] Pas d'erreur dans la console
|
||||
|
||||
### Tests Responsive
|
||||
|
||||
- [ ] Mobile (375px) : contenu lisible, pas de scroll horizontal
|
||||
- [ ] Tablette (768px) : mise en page correcte
|
||||
- [ ] Desktop (1280px) : centré avec max-width
|
||||
|
||||
### Tests Performance (Lighthouse)
|
||||
|
||||
| Métrique | Objectif | Maximum |
|
||||
|----------|----------|---------|
|
||||
| Performance | > 90 | > 80 |
|
||||
| Accessibility | > 90 | > 85 |
|
||||
| Best Practices | > 90 | > 85 |
|
||||
| SEO | > 90 | > 85 |
|
||||
| FCP | < 1.5s | < 2.5s |
|
||||
| LCP | < 2.5s | < 4s |
|
||||
|
||||
### Tests Sécurité
|
||||
|
||||
```bash
|
||||
# Tester les accès bloqués
|
||||
curl -I https://monportfolio.fr/.env # Doit retourner 404
|
||||
curl -I https://monportfolio.fr/vendor/ # Doit retourner 404
|
||||
curl -I https://monportfolio.fr/data/ # Doit retourner 404
|
||||
curl -I https://monportfolio.fr/includes/ # Doit retourner 404
|
||||
```
|
||||
|
||||
### Commandes de Déploiement
|
||||
|
||||
```bash
|
||||
# Sur le poste local
|
||||
npm run build
|
||||
composer install --no-dev
|
||||
|
||||
# Transfert (exemple rsync)
|
||||
rsync -avz --exclude='node_modules' --exclude='.git' --exclude='docs' \
|
||||
./ user@serveur:/var/www/portfolio/
|
||||
|
||||
# Sur le serveur
|
||||
sudo nginx -t # Tester la config nginx
|
||||
sudo systemctl reload nginx # Recharger nginx
|
||||
```
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale de la story | Sarah (PO) |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
_À compléter par le dev agent_
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- Page canary créée avec titre animé, badges, card de test, boutons
|
||||
- CSS regénéré (12 Ko minifié)
|
||||
- Dépendances PHP installées (vlucas/phpdotenv)
|
||||
- Configuration nginx exemple créée (nginx.conf.example)
|
||||
- Syntaxe PHP validée
|
||||
|
||||
**Tâches restantes (manuelles) :**
|
||||
- Créer .env de production sur le serveur
|
||||
- Copier nginx.conf.example et adapter pour votre serveur
|
||||
- Déployer les fichiers (rsync/FTP)
|
||||
- Configurer SSL/HTTPS
|
||||
- Tests de sécurité et performance
|
||||
|
||||
### File List
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `index.php` | Modifié |
|
||||
| `nginx.conf.example` | Créé |
|
||||
| `vendor/` | Installé |
|
||||
| `composer.lock` | Créé |
|
||||
| `assets/css/output.css` | Regénéré |
|
||||
|
||||
## QA Results
|
||||
|
||||
_À compléter par le QA agent_
|
||||
388
docs/stories/2.1.navbar-responsive.md
Normal file
388
docs/stories/2.1.navbar-responsive.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# Story 2.1: Navbar Responsive avec Menu Mobile
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** disposer d'une navigation claire et accessible sur tous les appareils,
|
||||
**so that** je trouve facilement les sections du portfolio.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. `templates/navbar.php` contient le menu de navigation avec les liens : Accueil, Projets, Compétences, Me Découvrir, Contact
|
||||
2. La navbar est fixe en haut de page (sticky) et reste visible au scroll
|
||||
3. Sur mobile, un menu "hamburger" affiche/masque les liens en JavaScript vanilla
|
||||
4. Le lien de la page active est visuellement distingué (couleur/soulignement)
|
||||
5. La navbar est responsive et s'adapte aux 3 breakpoints (mobile, tablette, desktop)
|
||||
6. La navbar a un effet d'ombre au scroll (optionnel mais recommandé)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer le template navbar.php** (AC: 1, 2)
|
||||
- [] Créer `templates/navbar.php`
|
||||
- [] Ajouter la structure HTML sémantique (`<header>`, `<nav>`)
|
||||
- [] Ajouter le logo/nom du site (lien vers accueil)
|
||||
- [] Ajouter les liens de navigation desktop
|
||||
- [] Appliquer les classes Tailwind pour sticky + fond
|
||||
|
||||
- [] **Task 2 : Implémenter le menu mobile hamburger** (AC: 3)
|
||||
- [] Ajouter le bouton hamburger (icône 3 barres)
|
||||
- [] Ajouter le menu mobile (overlay ou slide)
|
||||
- [] Masquer le bouton sur desktop, afficher sur mobile
|
||||
- [] Ajouter l'attribut `aria-expanded` pour l'accessibilité
|
||||
|
||||
- [] **Task 3 : Créer le JavaScript pour le menu mobile** (AC: 3)
|
||||
- [] Créer ou mettre à jour `assets/js/main.js`
|
||||
- [] Implémenter le toggle du menu (ouvert/fermé)
|
||||
- [] Fermer le menu au clic sur un lien
|
||||
- [] Fermer le menu avec la touche Escape
|
||||
- [] Gérer l'état `aria-expanded`
|
||||
|
||||
- [] **Task 4 : Implémenter l'état actif des liens** (AC: 4)
|
||||
- [] Passer la page courante via une variable PHP
|
||||
- [] Appliquer une classe visuelle sur le lien actif
|
||||
- [] Utiliser la couleur primary ou un soulignement
|
||||
|
||||
- [] **Task 5 : Rendre la navbar responsive** (AC: 5)
|
||||
- [] Mobile : logo + hamburger uniquement
|
||||
- [] Tablette/Desktop : tous les liens visibles
|
||||
- [] Vérifier les 3 breakpoints
|
||||
|
||||
- [] **Task 6 : Ajouter l'effet au scroll** (AC: 6)
|
||||
- [] Ajouter une ombre quand la page est scrollée
|
||||
- [] Utiliser JavaScript pour détecter le scroll
|
||||
- [] Transition smooth pour l'effet
|
||||
|
||||
- [] **Task 7 : Intégrer la navbar dans les pages**
|
||||
- [] Modifier `header.php` ou les pages pour inclure la navbar
|
||||
- [] Passer la variable `$currentPage` à la navbar
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Structure HTML de la Navbar
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Template Navbar
|
||||
* Variables disponibles :
|
||||
* - $currentPage (string) : identifiant de la page active ('home', 'projects', 'skills', 'about', 'contact')
|
||||
*/
|
||||
|
||||
$currentPage = $currentPage ?? 'home';
|
||||
|
||||
$navLinks = [
|
||||
['id' => 'home', 'label' => 'Accueil', 'url' => '/'],
|
||||
['id' => 'projects', 'label' => 'Projets', 'url' => '/projets'],
|
||||
['id' => 'skills', 'label' => 'Compétences', 'url' => '/competences'],
|
||||
['id' => 'about', 'label' => 'Me Découvrir', 'url' => '/a-propos'],
|
||||
['id' => 'contact', 'label' => 'Contact', 'url' => '/contact', 'isCta' => true],
|
||||
];
|
||||
?>
|
||||
|
||||
<header id="navbar" class="fixed top-0 left-0 right-0 z-50 bg-background/95 backdrop-blur-sm border-b border-border/50 transition-shadow duration-300">
|
||||
<nav class="container-content" aria-label="Navigation principale">
|
||||
<div class="flex items-center justify-between h-16 lg:h-20">
|
||||
|
||||
<!-- Logo -->
|
||||
<a href="/" class="text-xl font-bold text-text-primary hover:text-primary transition-colors">
|
||||
Portfolio
|
||||
</a>
|
||||
|
||||
<!-- Navigation Desktop -->
|
||||
<ul class="hidden lg:flex items-center gap-1">
|
||||
<?php foreach ($navLinks as $link): ?>
|
||||
<?php if (!empty($link['isCta'])): ?>
|
||||
<li class="ml-4">
|
||||
<a href="<?= $link['url'] ?>" class="btn-primary text-sm">
|
||||
<?= $link['label'] ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<li>
|
||||
<a href="<?= $link['url'] ?>"
|
||||
class="nav-link <?= $currentPage === $link['id'] ? 'nav-link-active' : '' ?>">
|
||||
<?= $link['label'] ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
|
||||
<!-- Bouton Hamburger Mobile -->
|
||||
<button
|
||||
id="mobile-menu-toggle"
|
||||
class="lg:hidden p-2 text-text-primary hover:text-primary transition-colors"
|
||||
aria-label="Ouvrir le menu"
|
||||
aria-expanded="false"
|
||||
aria-controls="mobile-menu"
|
||||
>
|
||||
<!-- Icône Hamburger -->
|
||||
<svg class="w-6 h-6 hamburger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
<!-- Icône Close (hidden par défaut) -->
|
||||
<svg class="w-6 h-6 close-icon hidden" 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>
|
||||
|
||||
<!-- Menu Mobile -->
|
||||
<div id="mobile-menu" class="lg:hidden hidden" aria-hidden="true">
|
||||
<ul class="py-4 space-y-2 border-t border-border">
|
||||
<?php foreach ($navLinks as $link): ?>
|
||||
<li>
|
||||
<a href="<?= $link['url'] ?>"
|
||||
class="block py-3 px-4 rounded-lg <?= $currentPage === $link['id'] ? 'bg-surface text-primary' : 'text-text-secondary hover:bg-surface hover:text-text-primary' ?> transition-colors">
|
||||
<?= $link['label'] ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Spacer pour compenser la navbar fixed -->
|
||||
<div class="h-16 lg:h-20"></div>
|
||||
```
|
||||
|
||||
### Classes CSS Additionnelles (input.css)
|
||||
|
||||
Ajouter dans `@layer components` :
|
||||
|
||||
```css
|
||||
/* Navigation links */
|
||||
.nav-link {
|
||||
@apply px-4 py-2 text-sm font-medium text-text-secondary
|
||||
hover:text-text-primary transition-colors rounded-lg
|
||||
hover:bg-surface-light;
|
||||
}
|
||||
|
||||
.nav-link-active {
|
||||
@apply text-primary bg-primary/10;
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript pour le Menu Mobile
|
||||
|
||||
```javascript
|
||||
// assets/js/main.js
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initMobileMenu();
|
||||
initNavbarScroll();
|
||||
});
|
||||
|
||||
/**
|
||||
* Gestion du menu mobile
|
||||
*/
|
||||
function initMobileMenu() {
|
||||
const toggle = document.getElementById('mobile-menu-toggle');
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
|
||||
if (!toggle || !menu) return;
|
||||
|
||||
const hamburgerIcon = toggle.querySelector('.hamburger-icon');
|
||||
const closeIcon = toggle.querySelector('.close-icon');
|
||||
|
||||
function openMenu() {
|
||||
menu.classList.remove('hidden');
|
||||
menu.setAttribute('aria-hidden', 'false');
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
toggle.setAttribute('aria-label', 'Fermer le menu');
|
||||
hamburgerIcon?.classList.add('hidden');
|
||||
closeIcon?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menu.classList.add('hidden');
|
||||
menu.setAttribute('aria-hidden', 'true');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
toggle.setAttribute('aria-label', 'Ouvrir le menu');
|
||||
hamburgerIcon?.classList.remove('hidden');
|
||||
closeIcon?.classList.add('hidden');
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
const isOpen = toggle.getAttribute('aria-expanded') === 'true';
|
||||
if (isOpen) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle au clic
|
||||
toggle.addEventListener('click', toggleMenu);
|
||||
|
||||
// Fermer au clic sur un lien
|
||||
menu.querySelectorAll('a').forEach(link => {
|
||||
link.addEventListener('click', closeMenu);
|
||||
});
|
||||
|
||||
// Fermer avec Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && toggle.getAttribute('aria-expanded') === 'true') {
|
||||
closeMenu();
|
||||
toggle.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Fermer si on redimensionne vers desktop
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Effet d'ombre au scroll
|
||||
*/
|
||||
function initNavbarScroll() {
|
||||
const navbar = document.getElementById('navbar');
|
||||
if (!navbar) return;
|
||||
|
||||
let lastScroll = 0;
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
const currentScroll = window.scrollY;
|
||||
|
||||
if (currentScroll > 10) {
|
||||
navbar.classList.add('shadow-lg');
|
||||
} else {
|
||||
navbar.classList.remove('shadow-lg');
|
||||
}
|
||||
|
||||
lastScroll = currentScroll;
|
||||
}, { passive: true });
|
||||
}
|
||||
```
|
||||
|
||||
### Intégration dans les Pages
|
||||
|
||||
Modifier `index.php` et les futures pages :
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/functions.php';
|
||||
|
||||
include_template('header', [
|
||||
'pageTitle' => 'Accueil',
|
||||
]);
|
||||
|
||||
include_template('navbar', [
|
||||
'currentPage' => 'home'
|
||||
]);
|
||||
?>
|
||||
|
||||
<!-- Contenu de la page -->
|
||||
|
||||
<?php include_template('footer'); ?>
|
||||
```
|
||||
|
||||
### Structure de Navigation
|
||||
|
||||
| Lien | URL | ID |
|
||||
|------|-----|----|
|
||||
| Accueil | `/` | home |
|
||||
| Projets | `/projets` | projects |
|
||||
| Compétences | `/competences` | skills |
|
||||
| Me Découvrir | `/a-propos` | about |
|
||||
| Contact (CTA) | `/contact` | contact |
|
||||
|
||||
### Breakpoints Responsive
|
||||
|
||||
| Breakpoint | Comportement |
|
||||
|------------|--------------|
|
||||
| Mobile (< 1024px) | Logo + hamburger, menu caché |
|
||||
| Desktop (≥ 1024px) | Tous les liens visibles |
|
||||
|
||||
### Accessibilité
|
||||
|
||||
- `aria-label` sur le bouton hamburger
|
||||
- `aria-expanded` pour indiquer l'état du menu
|
||||
- `aria-controls` pour lier le bouton au menu
|
||||
- `aria-hidden` sur le menu mobile
|
||||
- Navigation au clavier (Tab, Escape)
|
||||
- Focus visible sur tous les éléments interactifs
|
||||
|
||||
## Testing
|
||||
|
||||
### Tests Fonctionnels
|
||||
|
||||
- [ ] Tous les liens sont présents et cliquables
|
||||
- [ ] La navbar reste visible au scroll (sticky)
|
||||
- [ ] Le lien actif est visuellement distinct
|
||||
- [ ] L'ombre apparaît au scroll
|
||||
|
||||
### Tests Mobile
|
||||
|
||||
- [ ] Le bouton hamburger est visible sur mobile
|
||||
- [ ] Le menu s'ouvre au clic
|
||||
- [ ] Le menu se ferme au clic sur un lien
|
||||
- [ ] Le menu se ferme avec Escape
|
||||
- [ ] L'icône change (hamburger ↔ X)
|
||||
|
||||
### Tests Accessibilité
|
||||
|
||||
- [ ] Navigation complète au clavier
|
||||
- [ ] `aria-expanded` se met à jour
|
||||
- [ ] Focus visible sur tous les éléments
|
||||
- [ ] Lecteur d'écran annonce l'état du menu
|
||||
|
||||
### Tests Responsive
|
||||
|
||||
```
|
||||
Mobile (375px) → hamburger visible, liens cachés
|
||||
Tablet (768px) → hamburger visible, liens cachés
|
||||
Desktop (1024px) → liens visibles, hamburger caché
|
||||
Wide (1440px) → liens visibles, centré
|
||||
```
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale de la story | Sarah (PO) |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
_À compléter par le dev agent_
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- templates/navbar.php créé avec structure sémantique complète
|
||||
- Menu hamburger avec icônes SVG (hamburger/close)
|
||||
- JavaScript vanilla pour toggle menu, fermeture Escape, resize
|
||||
- Effet d'ombre au scroll (shadow-lg)
|
||||
- Classes nav-link et nav-link-active ajoutées à input.css
|
||||
- État actif via variable $currentPage
|
||||
- Accessibilité complète (aria-expanded, aria-controls, aria-hidden)
|
||||
- Spacer pour compenser navbar fixed
|
||||
- CSS regénéré (15 Ko)
|
||||
|
||||
### File List
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `templates/navbar.php` | Créé |
|
||||
| `assets/css/input.css` | Modifié (classes nav-link) |
|
||||
| `assets/js/main.js` | Modifié (menu mobile + scroll) |
|
||||
| `index.php` | Modifié (intégration navbar) |
|
||||
| `assets/css/output.css` | Regénéré |
|
||||
|
||||
## QA Results
|
||||
|
||||
_À compléter par le QA agent_
|
||||
120
docs/stories/2.2.bouton-cta-navbar.md
Normal file
120
docs/stories/2.2.bouton-cta-navbar.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Story 2.2: Bouton CTA "Me Contacter" dans la Navbar
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir un bouton "Me contacter" visible en permanence,
|
||||
**so that** je puisse initier un contact à tout moment sans chercher.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Le bouton "Me contacter" est stylé différemment des autres liens (bouton rempli, couleur d'accent)
|
||||
2. Le bouton est visible sur desktop ET sur mobile (même dans le menu hamburger)
|
||||
3. Le bouton redirige vers la page contact.php
|
||||
4. Le bouton a un état hover/focus visible
|
||||
5. Le bouton respecte l'accessibilité (focusable, contraste suffisant)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Styler le bouton CTA dans la navbar** (AC: 1)
|
||||
- [] Appliquer la classe `btn-primary` au lien Contact
|
||||
- [] Ajouter un espacement distinct (margin-left)
|
||||
- [] Vérifier le contraste des couleurs
|
||||
|
||||
- [] **Task 2 : Assurer la visibilité mobile** (AC: 2)
|
||||
- [] Vérifier que le CTA est présent dans le menu mobile
|
||||
- [] Styler le CTA différemment dans le menu mobile (full-width ou mis en avant)
|
||||
|
||||
- [] **Task 3 : Configurer le lien** (AC: 3)
|
||||
- [] Le href pointe vers `/contact`
|
||||
- [] Tester la navigation
|
||||
|
||||
- [] **Task 4 : Implémenter les états interactifs** (AC: 4, 5)
|
||||
- [] État hover (couleur plus claire)
|
||||
- [] État focus visible (ring)
|
||||
- [] État active (couleur plus foncée)
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Modification navbar.php
|
||||
|
||||
Le bouton CTA est déjà prévu dans la Story 2.1. Vérifier que :
|
||||
|
||||
```php
|
||||
<?php if (!empty($link['isCta'])): ?>
|
||||
<li class="ml-4">
|
||||
<a href="<?= $link['url'] ?>" class="btn-primary text-sm">
|
||||
<?= $link['label'] ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
```
|
||||
|
||||
### Style du CTA dans le menu mobile
|
||||
|
||||
```php
|
||||
<!-- Dans le menu mobile -->
|
||||
<?php if (!empty($link['isCta'])): ?>
|
||||
<li class="pt-2 border-t border-border mt-2">
|
||||
<a href="<?= $link['url'] ?>" class="btn-primary w-full justify-center">
|
||||
<?= $link['label'] ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<!-- ... liens normaux ... -->
|
||||
<?php endif; ?>
|
||||
```
|
||||
|
||||
### Vérification Contraste
|
||||
|
||||
| Élément | Couleurs | Ratio | Conformité |
|
||||
|---------|----------|-------|------------|
|
||||
| Texte bouton | `#17171F` sur `#FA784F` | 5.2:1 | AA |
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Le bouton est orange (#FA784F) et se distingue des autres liens
|
||||
- [ ] Le texte est lisible (fond sombre sur orange)
|
||||
- [ ] Le hover change la couleur vers #FB9570
|
||||
- [ ] Le focus affiche un ring visible
|
||||
- [ ] Le bouton est présent et stylé dans le menu mobile
|
||||
- [ ] La navigation vers /contact fonctionne
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
_Aucun_
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- CTA desktop déjà implémenté en story 2.1 (btn-primary text-sm, ml-4)
|
||||
- Menu mobile amélioré : CTA full-width avec séparateur visuel
|
||||
- États interactifs via classe btn-primary (hover, focus, active)
|
||||
- Lien pointe vers /contact
|
||||
- Contraste vérifié (5.2:1 AA)
|
||||
|
||||
### File List
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `templates/navbar.php` | Modifié (CTA mobile) |
|
||||
| `assets/css/output.css` | Regénéré |
|
||||
|
||||
## QA Results
|
||||
|
||||
_À compléter par le QA agent_
|
||||
174
docs/stories/2.3.page-accueil-accroche.md
Normal file
174
docs/stories/2.3.page-accueil-accroche.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Story 2.3: Page d'Accueil avec Accroche
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir une page d'accueil avec une accroche claire et engageante,
|
||||
**so that** je comprends immédiatement qui est le développeur et ce qu'il propose.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. La page d'accueil affiche une section "hero" avec : nom/prénom, titre (développeur web), et une phrase d'accroche
|
||||
2. Un bouton secondaire CTA invite à découvrir les projets
|
||||
3. Le design est aéré et la typographie met en valeur l'accroche
|
||||
4. La page inclut la navbar et le footer
|
||||
5. Le contenu est centré et responsive
|
||||
6. Les animations sont subtiles (fade-in au chargement)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer la page home.php** (AC: 4)
|
||||
- [] Créer `pages/home.php` (implémenté dans index.php, migration avec routeur)
|
||||
- [] Inclure header, navbar et footer
|
||||
- [ ] Configurer le routeur pour servir cette page sur `/` (story 3.2)
|
||||
|
||||
- [] **Task 2 : Créer la section Hero** (AC: 1, 3)
|
||||
- [] Ajouter le nom/prénom du développeur
|
||||
- [] Ajouter le titre "Développeur Web Full-Stack"
|
||||
- [] Ajouter une phrase d'accroche percutante
|
||||
- [] Centrer verticalement et horizontalement
|
||||
- [] Appliquer la typographie (text-display)
|
||||
|
||||
- [] **Task 3 : Ajouter les CTA** (AC: 2)
|
||||
- [] Bouton principal "Découvrir mes projets" (btn-primary)
|
||||
- [] Bouton secondaire "En savoir plus" (btn-secondary) optionnel
|
||||
- [] Liens vers /projets et /a-propos
|
||||
|
||||
- [] **Task 4 : Rendre responsive** (AC: 5)
|
||||
- [] Mobile : texte plus petit, padding réduit
|
||||
- [] Desktop : taille maximale, centré
|
||||
|
||||
- [] **Task 5 : Ajouter les animations** (AC: 6)
|
||||
- [] Fade-in sur le titre (animate-fade-in)
|
||||
- [] Fade-in décalé sur le sous-titre (animation-delay-100)
|
||||
- [] Fade-in décalé sur les boutons (animation-delay-200)
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Structure pages/home.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Page d'accueil
|
||||
*/
|
||||
|
||||
$pageTitle = 'Accueil';
|
||||
$pageDescription = 'Portfolio de développeur web full-stack. Découvrez mes projets, compétences et parcours.';
|
||||
$currentPage = 'home';
|
||||
|
||||
include_template('header', compact('pageTitle', 'pageDescription'));
|
||||
include_template('navbar', compact('currentPage'));
|
||||
?>
|
||||
|
||||
<main>
|
||||
<!-- Hero Section -->
|
||||
<section class="min-h-[calc(100vh-5rem)] flex items-center justify-center">
|
||||
<div class="container-content text-center py-20">
|
||||
<!-- Nom -->
|
||||
<p class="text-primary font-medium mb-4 animate-fade-in">
|
||||
Bonjour, je suis
|
||||
</p>
|
||||
|
||||
<!-- Titre principal -->
|
||||
<h1 class="text-display text-text-primary mb-6 animate-fade-in animation-delay-100">
|
||||
Prénom <span class="text-primary">NOM</span>
|
||||
</h1>
|
||||
|
||||
<!-- Sous-titre -->
|
||||
<p class="text-heading text-text-secondary mb-6 animate-fade-in animation-delay-200">
|
||||
Développeur Web Full-Stack
|
||||
</p>
|
||||
|
||||
<!-- Accroche -->
|
||||
<p class="text-xl text-text-secondary max-w-2xl mx-auto mb-10 animate-fade-in animation-delay-300">
|
||||
Je crée des expériences web modernes, performantes et accessibles.
|
||||
<br>Chaque projet est une opportunité de montrer plutôt que de dire.
|
||||
</p>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center animate-fade-in animation-delay-300">
|
||||
<a href="/projets" class="btn-primary">
|
||||
Découvrir mes projets
|
||||
</a>
|
||||
<a href="/a-propos" class="btn-secondary">
|
||||
En savoir plus
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<?php include_template('footer'); ?>
|
||||
```
|
||||
|
||||
### Mise à jour du Router (index.php)
|
||||
|
||||
```php
|
||||
$router
|
||||
->add('/', 'pages/home.php')
|
||||
// ... autres routes
|
||||
```
|
||||
|
||||
### Responsive
|
||||
|
||||
| Breakpoint | Adaptations |
|
||||
|------------|-------------|
|
||||
| Mobile | text-3xl pour H1, padding réduit |
|
||||
| Desktop | text-display (2.5rem), max-w-2xl pour l'accroche |
|
||||
|
||||
### Animations
|
||||
|
||||
Les classes sont déjà définies dans input.css :
|
||||
- `.animate-fade-in` : opacity 0 → 1
|
||||
- `.animate-fade-in-up` : opacity + translateY
|
||||
- `.animation-delay-100/200/300` : délais
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Le nom et titre sont visibles et centrés
|
||||
- [ ] L'accroche est lisible et bien espacée
|
||||
- [ ] Les boutons CTA sont cliquables
|
||||
- [ ] Les animations se jouent au chargement
|
||||
- [ ] La page est responsive (mobile/desktop)
|
||||
- [ ] La navbar et le footer sont présents
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
_Aucun_
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- Hero section créée dans index.php (migration vers pages/home.php avec routeur story 3.2)
|
||||
- Typographie responsive : text-4xl → text-5xl → text-display
|
||||
- Animations fade-in avec délais progressifs (100, 200, 300ms)
|
||||
- CTA : btn-primary (projets) + btn-secondary (à propos)
|
||||
- Centrage vertical avec min-h-[calc(100vh-5rem)] et flex
|
||||
- Header, navbar, footer inclus via compact()
|
||||
|
||||
### File List
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `index.php` | Modifié (Hero section) |
|
||||
| `assets/css/output.css` | Regénéré |
|
||||
|
||||
## QA Results
|
||||
|
||||
_À compléter par le QA agent_
|
||||
172
docs/stories/2.4.sections-navigation-rapide.md
Normal file
172
docs/stories/2.4.sections-navigation-rapide.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Story 2.4: Sections de Navigation Rapide sur l'Accueil
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir un aperçu des sections principales sur la page d'accueil,
|
||||
**so that** je navigue rapidement vers ce qui m'intéresse.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Sous le hero, des cartes/blocs présentent les sections : Projets, Compétences, Me Découvrir
|
||||
2. Chaque bloc a un titre, une courte description, et un lien vers la page correspondante
|
||||
3. Les blocs sont disposés en grille responsive (1 colonne mobile, 3 colonnes desktop)
|
||||
4. Les blocs ont un effet hover subtil
|
||||
5. L'ensemble reste cohérent avec le design global
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Ajouter la section sous le hero** (AC: 1)
|
||||
- [] Créer une section avec titre "Explorez mon portfolio"
|
||||
- [] Ajouter les 3 cartes de navigation
|
||||
|
||||
- [] **Task 2 : Créer les cartes de navigation** (AC: 2)
|
||||
- [] Carte Projets : icône, titre, description, lien
|
||||
- [] Carte Compétences : icône, titre, description, lien
|
||||
- [] Carte Me Découvrir : icône, titre, description, lien
|
||||
|
||||
- [] **Task 3 : Implémenter la grille responsive** (AC: 3)
|
||||
- [] 1 colonne sur mobile (grid-cols-1)
|
||||
- [] 3 colonnes sur desktop (md:grid-cols-3)
|
||||
- [] Gap approprié entre les cartes (gap-6 lg:gap-8)
|
||||
|
||||
- [] **Task 4 : Ajouter les effets hover** (AC: 4)
|
||||
- [] Utiliser la classe card-interactive
|
||||
- [] Élévation + ombre au hover
|
||||
|
||||
- [] **Task 5 : Intégrer les icônes** (AC: 5)
|
||||
- [] Utiliser Heroicons (SVG inline)
|
||||
- [] Taille cohérente (w-8 h-8 dans conteneur w-16 h-16)
|
||||
- [] Couleur primary
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Code à ajouter dans pages/home.php
|
||||
|
||||
```php
|
||||
<!-- Après la section Hero -->
|
||||
|
||||
<!-- Section Navigation Rapide -->
|
||||
<section class="section bg-surface">
|
||||
<div class="container-content">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Explorez mon portfolio</h2>
|
||||
<p class="section-subtitle">
|
||||
Découvrez mes réalisations, compétences et parcours
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||
<!-- Carte Projets -->
|
||||
<a href="/projets" class="card-interactive group">
|
||||
<div class="card-body text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-subheading mb-2 group-hover:text-primary transition-colors">Projets</h3>
|
||||
<p class="text-text-secondary">
|
||||
Découvrez mes réalisations web avec démonstrations et explications techniques.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Carte Compétences -->
|
||||
<a href="/competences" class="card-interactive group">
|
||||
<div class="card-body text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-subheading mb-2 group-hover:text-primary transition-colors">Compétences</h3>
|
||||
<p class="text-text-secondary">
|
||||
Technologies maîtrisées et outils utilisés, avec preuves à l'appui.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Carte Me Découvrir -->
|
||||
<a href="/a-propos" class="card-interactive group">
|
||||
<div class="card-body text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-subheading mb-2 group-hover:text-primary transition-colors">Me Découvrir</h3>
|
||||
<p class="text-text-secondary">
|
||||
Mon parcours, mes motivations et ce qui me passionne au-delà du code.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### Icônes Heroicons Utilisées
|
||||
|
||||
| Carte | Icône | Description |
|
||||
|-------|-------|-------------|
|
||||
| Projets | squares-2x2 | Grille de carrés |
|
||||
| Compétences | code-bracket | Chevrons de code |
|
||||
| Me Découvrir | user | Silhouette utilisateur |
|
||||
|
||||
### Responsive
|
||||
|
||||
| Breakpoint | Colonnes |
|
||||
|------------|----------|
|
||||
| Mobile (< 768px) | 1 colonne |
|
||||
| Tablet (≥ 768px) | 3 colonnes |
|
||||
| Desktop | 3 colonnes avec gap plus large |
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Les 3 cartes sont visibles sous le hero
|
||||
- [ ] Chaque carte a une icône, titre et description
|
||||
- [ ] Les liens redirigent vers les bonnes pages
|
||||
- [ ] L'effet hover fonctionne (élévation + ombre)
|
||||
- [ ] Mobile : cartes empilées verticalement
|
||||
- [ ] Desktop : 3 cartes côte à côte
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
_Aucun_
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- Section navigation rapide ajoutée sous le hero dans index.php
|
||||
- 3 cartes : Projets, Compétences, Me Découvrir
|
||||
- Grille responsive : grid-cols-1 mobile, md:grid-cols-3 tablet+
|
||||
- Icônes Heroicons SVG inline (squares-2x2, code-bracket, user)
|
||||
- Effets hover via card-interactive + group-hover sur titres
|
||||
- Conteneurs d'icônes avec bg-primary/10 → bg-primary/20 au hover
|
||||
|
||||
### File List
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `index.php` | Modifié (section navigation) |
|
||||
| `assets/css/output.css` | Regénéré |
|
||||
|
||||
## QA Results
|
||||
|
||||
_À compléter par le QA agent_
|
||||
235
docs/stories/3.1.structure-donnees-json.md
Normal file
235
docs/stories/3.1.structure-donnees-json.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Story 3.1: Structure de Données JSON pour les Projets
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** développeur,
|
||||
**I want** définir et créer le fichier JSON contenant les données des projets,
|
||||
**so that** je centralise les informations et facilite la maintenance.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Le fichier `data/projects.json` est créé avec la structure définie
|
||||
2. La structure supporte : id, title, slug, category (vedette/secondaire), thumbnail, url, technologies[], context, solution, teamwork, duration, testimonial, screenshots[]
|
||||
3. Au moins 2 projets de test sont ajoutés pour valider la structure
|
||||
4. Une fonction PHP `getProjects()` lit et décode le JSON
|
||||
5. La fonction gère les erreurs (fichier manquant, JSON invalide)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Définir la structure JSON** (AC: 2)
|
||||
- [] Documenter tous les champs requis et optionnels
|
||||
- [] Définir les types de données pour chaque champ
|
||||
- [] Définir les valeurs possibles pour category
|
||||
|
||||
- [] **Task 2 : Créer le fichier projects.json** (AC: 1, 3)
|
||||
- [] Créer `data/projects.json`
|
||||
- [] Ajouter 2-3 projets de test
|
||||
- [] Valider la syntaxe JSON
|
||||
|
||||
- [] **Task 3 : Créer les fonctions PHP d'accès** (AC: 4, 5)
|
||||
- [] Créer `loadJsonData()` générique
|
||||
- [] Créer `getProjects()`
|
||||
- [] Créer `getProjectsByCategory()`
|
||||
- [] Créer `getProjectBySlug()`
|
||||
- [] Gérer les erreurs (fichier manquant, JSON invalide)
|
||||
|
||||
- [] **Task 4 : Tester les fonctions**
|
||||
- [] Tester avec fichier valide
|
||||
- [] Tester avec fichier manquant
|
||||
- [] Tester avec JSON invalide
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Structure data/projects.json
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Site E-commerce XYZ",
|
||||
"slug": "ecommerce-xyz",
|
||||
"category": "vedette",
|
||||
"thumbnail": "ecommerce-xyz-thumb.webp",
|
||||
"url": "https://example.com",
|
||||
"github": "https://github.com/user/project",
|
||||
"technologies": ["PHP", "JavaScript", "Tailwind CSS", "MySQL"],
|
||||
"context": "Client souhaitant moderniser sa boutique en ligne pour améliorer l'expérience utilisateur et augmenter les conversions.",
|
||||
"solution": "Développement d'une solution e-commerce sur mesure avec panier persistant, paiement sécurisé Stripe, et interface d'administration.",
|
||||
"teamwork": "Projet réalisé en collaboration avec un designer UI/UX. J'ai pris en charge l'intégration et le développement backend.",
|
||||
"duration": "3 mois",
|
||||
"screenshots": [
|
||||
"ecommerce-xyz-screen-1.webp",
|
||||
"ecommerce-xyz-screen-2.webp",
|
||||
"ecommerce-xyz-screen-3.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Application de Gestion",
|
||||
"slug": "app-gestion",
|
||||
"category": "vedette",
|
||||
"thumbnail": "app-gestion-thumb.webp",
|
||||
"url": null,
|
||||
"github": "https://github.com/user/app-gestion",
|
||||
"technologies": ["React", "Node.js", "PostgreSQL", "Docker"],
|
||||
"context": "Startup ayant besoin d'un outil interne pour gérer ses ressources et planifier ses projets.",
|
||||
"solution": "Application web full-stack avec authentification, gestion des rôles, tableaux de bord et exports PDF.",
|
||||
"teamwork": null,
|
||||
"duration": "4 mois",
|
||||
"screenshots": [
|
||||
"app-gestion-screen-1.webp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Site Vitrine Restaurant",
|
||||
"slug": "restaurant-vitrine",
|
||||
"category": "secondaire",
|
||||
"thumbnail": "restaurant-thumb.webp",
|
||||
"url": "https://restaurant-example.com",
|
||||
"github": null,
|
||||
"technologies": ["HTML", "CSS", "JavaScript"],
|
||||
"context": "Restaurant local souhaitant une présence en ligne simple.",
|
||||
"solution": null,
|
||||
"teamwork": null,
|
||||
"duration": "2 semaines",
|
||||
"screenshots": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Champs du Projet
|
||||
|
||||
| Champ | Type | Requis | Description |
|
||||
|-------|------|--------|-------------|
|
||||
| id | number | Oui | Identifiant unique |
|
||||
| title | string | Oui | Titre du projet |
|
||||
| slug | string | Oui | URL-friendly (unique) |
|
||||
| category | string | Oui | "vedette" ou "secondaire" |
|
||||
| thumbnail | string | Oui | Nom du fichier image |
|
||||
| url | string/null | Non | URL du projet en ligne |
|
||||
| github | string/null | Non | URL du repo GitHub |
|
||||
| technologies | array | Oui | Liste des technos |
|
||||
| context | string | Oui | Description du contexte |
|
||||
| solution | string/null | Non | Description technique |
|
||||
| teamwork | string/null | Non | Travail d'équipe |
|
||||
| duration | string | Oui | Durée du projet |
|
||||
| screenshots | array | Non | Liste des captures |
|
||||
|
||||
### Fonctions PHP (includes/functions.php)
|
||||
|
||||
```php
|
||||
/**
|
||||
* Charge et parse un fichier JSON
|
||||
*/
|
||||
function loadJsonData(string $filename): array
|
||||
{
|
||||
$path = __DIR__ . "/../data/{$filename}";
|
||||
|
||||
if (!file_exists($path)) {
|
||||
error_log("JSON file not found: {$filename}");
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
error_log("JSON parse error in {$filename}: " . json_last_error_msg());
|
||||
return [];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère tous les projets
|
||||
*/
|
||||
function getProjects(): array
|
||||
{
|
||||
$data = loadJsonData('projects.json');
|
||||
return $data['projects'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les projets par catégorie
|
||||
*/
|
||||
function getProjectsByCategory(string $category): array
|
||||
{
|
||||
return array_filter(getProjects(), fn($p) => $p['category'] === $category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un projet par son slug
|
||||
*/
|
||||
function getProjectBySlug(string $slug): ?array
|
||||
{
|
||||
$projects = getProjects();
|
||||
foreach ($projects as $project) {
|
||||
if ($project['slug'] === $slug) {
|
||||
return $project;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les technologies uniques de tous les projets
|
||||
*/
|
||||
function getAllTechnologies(): array
|
||||
{
|
||||
$technologies = [];
|
||||
foreach (getProjects() as $project) {
|
||||
foreach ($project['technologies'] ?? [] as $tech) {
|
||||
if (!in_array($tech, $technologies)) {
|
||||
$technologies[] = $tech;
|
||||
}
|
||||
}
|
||||
}
|
||||
sort($technologies);
|
||||
return $technologies;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- [] Le fichier JSON est valide (pas d'erreur de syntaxe)
|
||||
- [] `getProjects()` retourne un tableau de projets
|
||||
- [] `getProjectsByCategory('vedette')` retourne les projets vedettes
|
||||
- [] `getProjectBySlug('ecommerce-xyz')` retourne le bon projet
|
||||
- [] `getProjectBySlug('inexistant')` retourne null
|
||||
- [] Fichier manquant → tableau vide, pas d'exception
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `data/projects.json` | Created | Fichier JSON avec 3 projets de test |
|
||||
| `includes/functions.php` | Modified | Ajout des fonctions d'accès aux données JSON |
|
||||
|
||||
### Completion Notes
|
||||
- Structure JSON complète avec tous les champs requis et optionnels
|
||||
- 3 projets de test ajoutés (2 vedettes, 1 secondaire)
|
||||
- Fonctions PHP: `loadJsonData()`, `getProjects()`, `getProjectsByCategory()`, `getProjectBySlug()`, `getAllTechnologies()`
|
||||
- Gestion des erreurs: fichier manquant et JSON invalide retournent tableau vide avec log
|
||||
- Tous les tests passent (8/8)
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||
250
docs/stories/3.2.router-php-urls.md
Normal file
250
docs/stories/3.2.router-php-urls.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Story 3.2: Router PHP et URLs Propres
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** des URLs lisibles et propres pour accéder aux projets,
|
||||
**so that** je comprends le contenu de la page avant même de cliquer et améliore le SEO.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Un fichier `.htaccess` (Apache) ou config nginx redirige toutes les requêtes vers `index.php` (front controller)
|
||||
2. Un router PHP simple parse l'URL et route vers le bon fichier/action
|
||||
3. Les URLs des projets sont au format `/projet/{slug}` (ex: `/projet/site-ecommerce-xyz`)
|
||||
4. Les autres pages gardent des URLs simples : `/projets`, `/competences`, `/a-propos`, `/contact`
|
||||
5. Une route 404 personnalisée gère les URLs inconnues
|
||||
6. Le router est léger (<50 lignes de code) et sans dépendance externe
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer le router PHP** (AC: 2, 6)
|
||||
- [] Créer `includes/router.php`
|
||||
- [] Implémenter la classe Router
|
||||
- [] Méthode add() pour ajouter des routes
|
||||
- [] Méthode resolve() pour matcher une URL
|
||||
- [] Méthode dispatch() pour exécuter la route
|
||||
|
||||
- [] **Task 2 : Configurer les routes** (AC: 3, 4)
|
||||
- [] Route `/` → pages/home.php
|
||||
- [] Route `/projets` → pages/projects.php
|
||||
- [] Route `/projet/{slug}` → pages/project-single.php
|
||||
- [] Route `/competences` → pages/skills.php
|
||||
- [] Route `/a-propos` → pages/about.php
|
||||
- [] Route `/contact` → pages/contact.php
|
||||
|
||||
- [] **Task 3 : Créer la page 404** (AC: 5)
|
||||
- [] Créer `pages/404.php`
|
||||
- [] Design cohérent avec le site
|
||||
- [] Lien retour vers l'accueil
|
||||
|
||||
- [] **Task 4 : Configurer le serveur** (AC: 1)
|
||||
- [] Créer `.htaccess` pour Apache
|
||||
- [] Documenter la config nginx équivalente
|
||||
|
||||
- [] **Task 5 : Mettre à jour index.php**
|
||||
- [] Inclure le router
|
||||
- [] Définir toutes les routes
|
||||
- [] Appeler dispatch()
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Router PHP (includes/router.php)
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Router simple pour URLs propres
|
||||
* < 50 lignes de code
|
||||
*/
|
||||
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
|
||||
public function add(string $pattern, string $handler): self
|
||||
{
|
||||
// Convertit {param} en regex (?P<param>[^/]+)
|
||||
$regex = preg_replace('/\{(\w+)\}/', '([^/]+)', $pattern);
|
||||
$regex = '#^' . $regex . '$#';
|
||||
$this->routes[$regex] = $handler;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function resolve(string $uri): array
|
||||
{
|
||||
$uri = parse_url($uri, PHP_URL_PATH);
|
||||
$uri = rtrim($uri, '/') ?: '/';
|
||||
|
||||
foreach ($this->routes as $regex => $handler) {
|
||||
if (preg_match($regex, $uri, $matches)) {
|
||||
array_shift($matches); // Enlève le match complet
|
||||
return [$handler, $matches];
|
||||
}
|
||||
}
|
||||
|
||||
return ['pages/404.php', []];
|
||||
}
|
||||
|
||||
public function dispatch(): void
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
[$handler, $params] = $this->resolve($uri);
|
||||
|
||||
// Rend les paramètres accessibles
|
||||
$GLOBALS['routeParams'] = $params;
|
||||
|
||||
require __DIR__ . '/../' . $handler;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Point d'entrée (index.php)
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/includes/functions.php';
|
||||
require_once __DIR__ . '/includes/router.php';
|
||||
|
||||
session_start();
|
||||
|
||||
$router = new Router();
|
||||
|
||||
$router
|
||||
->add('/', 'pages/home.php')
|
||||
->add('/projets', 'pages/projects.php')
|
||||
->add('/projet/{slug}', 'pages/project-single.php')
|
||||
->add('/competences', 'pages/skills.php')
|
||||
->add('/a-propos', 'pages/about.php')
|
||||
->add('/contact', 'pages/contact.php');
|
||||
|
||||
$router->dispatch();
|
||||
```
|
||||
|
||||
### Configuration .htaccess (Apache)
|
||||
|
||||
```apache
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
# Ne pas réécrire les fichiers et dossiers existants
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
|
||||
# Rediriger tout vers index.php
|
||||
RewriteRule ^(.*)$ index.php [QSA,L]
|
||||
```
|
||||
|
||||
### Configuration Nginx
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
```
|
||||
|
||||
### Page 404 (pages/404.php)
|
||||
|
||||
```php
|
||||
<?php
|
||||
http_response_code(404);
|
||||
|
||||
$pageTitle = 'Page non trouvée';
|
||||
$currentPage = '';
|
||||
|
||||
include_template('header', compact('pageTitle'));
|
||||
include_template('navbar', compact('currentPage'));
|
||||
?>
|
||||
|
||||
<main class="min-h-screen flex items-center justify-center">
|
||||
<div class="container-content text-center py-20">
|
||||
<h1 class="text-display text-primary mb-4">404</h1>
|
||||
<p class="text-xl text-text-secondary mb-8">
|
||||
Oups ! Cette page n'existe pas.
|
||||
</p>
|
||||
<a href="/" class="btn-primary">
|
||||
Retour à l'accueil
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php include_template('footer'); ?>
|
||||
```
|
||||
|
||||
### Récupérer les paramètres de route
|
||||
|
||||
```php
|
||||
// Dans pages/project-single.php
|
||||
$slug = $GLOBALS['routeParams'][0] ?? null;
|
||||
$project = getProjectBySlug($slug);
|
||||
|
||||
if (!$project) {
|
||||
http_response_code(404);
|
||||
include __DIR__ . '/404.php';
|
||||
exit;
|
||||
}
|
||||
```
|
||||
|
||||
### Structure des URLs
|
||||
|
||||
| URL | Page | Paramètres |
|
||||
|-----|------|------------|
|
||||
| `/` | home.php | - |
|
||||
| `/projets` | projects.php | - |
|
||||
| `/projet/ecommerce-xyz` | project-single.php | slug=ecommerce-xyz |
|
||||
| `/competences` | skills.php | - |
|
||||
| `/a-propos` | about.php | - |
|
||||
| `/contact` | contact.php | - |
|
||||
| `/nimporte-quoi` | 404.php | - |
|
||||
|
||||
## Testing
|
||||
|
||||
- [] `/` affiche la page d'accueil
|
||||
- [] `/projets` affiche la liste des projets
|
||||
- [] `/projet/ecommerce-xyz` affiche le projet correspondant
|
||||
- [] `/projet/inexistant` affiche la page 404
|
||||
- [] `/page-inexistante` affiche la page 404
|
||||
- [] Les assets (/assets/css/...) sont toujours accessibles
|
||||
- [] Pas de boucle de redirection
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `includes/router.php` | Created | Router PHP simple (43 lignes) |
|
||||
| `index.php` | Modified | Converti en front controller |
|
||||
| `.htaccess` | Created | Réécriture URLs Apache |
|
||||
| `pages/home.php` | Created | Page d'accueil |
|
||||
| `pages/projects.php` | Created | Page liste projets (placeholder) |
|
||||
| `pages/project-single.php` | Created | Page projet individuel |
|
||||
| `pages/skills.php` | Created | Page compétences (placeholder) |
|
||||
| `pages/about.php` | Created | Page à propos (placeholder) |
|
||||
| `pages/contact.php` | Created | Page contact (placeholder) |
|
||||
| `pages/404.php` | Created | Page erreur 404 |
|
||||
|
||||
### Completion Notes
|
||||
- Router PHP léger (43 lignes < 50 requis)
|
||||
- Support des paramètres dynamiques {slug}
|
||||
- Trailing slash normalisé automatiquement
|
||||
- 404 pour routes inconnues
|
||||
- Pages placeholder créées pour futures stories
|
||||
- Tous les tests du router passent (8/8)
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||
212
docs/stories/3.3.page-liste-projets.md
Normal file
212
docs/stories/3.3.page-liste-projets.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Story 3.3: Page Liste des Projets Vedettes
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir une liste visuelle de tous les projets vedettes,
|
||||
**so that** je parcours rapidement le portfolio et choisis ce qui m'intéresse.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. `/projets` affiche tous les projets où category = "vedette"
|
||||
2. Chaque projet est affiché sous forme de carte avec : thumbnail, titre, technologies (badges)
|
||||
3. Les cartes sont cliquables et redirigent vers `/projet/{slug}`
|
||||
4. La grille est responsive (1 col mobile, 2 cols tablette, 3 cols desktop)
|
||||
5. Les cartes ont un effet hover (légère élévation/ombre)
|
||||
6. Le template `templates/project-card.php` est réutilisable
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer la page projects.php** (AC: 1)
|
||||
- [] Créer `pages/projects.php`
|
||||
- [] Récupérer les projets vedettes avec `getProjectsByCategory('vedette')`
|
||||
- [] Inclure header, navbar, footer
|
||||
|
||||
- [] **Task 2 : Créer le template project-card.php** (AC: 2, 6)
|
||||
- [] Créer `templates/project-card.php`
|
||||
- [] Afficher le thumbnail avec lazy loading
|
||||
- [] Afficher le titre
|
||||
- [] Afficher les badges technologies (max 4)
|
||||
- [] Rendre le composant réutilisable
|
||||
|
||||
- [] **Task 3 : Implémenter la grille responsive** (AC: 4)
|
||||
- [] 1 colonne sur mobile
|
||||
- [] 2 colonnes sur tablette (sm:)
|
||||
- [] 3 colonnes sur desktop (lg:)
|
||||
|
||||
- [] **Task 4 : Ajouter les interactions** (AC: 3, 5)
|
||||
- [] Carte entière cliquable (lien vers /projet/{slug})
|
||||
- [] Effet hover avec card-interactive
|
||||
- [] Transition smooth
|
||||
|
||||
- [] **Task 5 : Gérer les cas limites**
|
||||
- [] Aucun projet → message "Projets à venir"
|
||||
- [] Image manquante → placeholder (onerror fallback)
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Page pages/projects.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Page liste des projets
|
||||
*/
|
||||
|
||||
$pageTitle = 'Mes Projets';
|
||||
$pageDescription = 'Découvrez mes réalisations web : sites vitrines, e-commerce, applications et plus encore.';
|
||||
$currentPage = 'projects';
|
||||
|
||||
$featuredProjects = getProjectsByCategory('vedette');
|
||||
$secondaryProjects = getProjectsByCategory('secondaire');
|
||||
|
||||
include_template('header', compact('pageTitle', 'pageDescription'));
|
||||
include_template('navbar', compact('currentPage'));
|
||||
?>
|
||||
|
||||
<main>
|
||||
<!-- Header de page -->
|
||||
<section class="section">
|
||||
<div class="container-content">
|
||||
<div class="section-header">
|
||||
<h1 class="section-title">Mes Projets</h1>
|
||||
<p class="section-subtitle">
|
||||
Découvrez les réalisations qui illustrent mon travail et mes compétences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Grille des projets vedettes -->
|
||||
<?php if (!empty($featuredProjects)): ?>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
<?php foreach ($featuredProjects as $project): ?>
|
||||
<?php include_template('project-card', ['project' => $project]); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="text-center text-text-muted py-12">
|
||||
Projets à venir...
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section projets secondaires (Story 3.5) -->
|
||||
</main>
|
||||
|
||||
<?php include_template('footer'); ?>
|
||||
```
|
||||
|
||||
### Template templates/project-card.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Carte projet réutilisable
|
||||
* @param array $project Données du projet
|
||||
*/
|
||||
|
||||
$title = $project['title'] ?? 'Sans titre';
|
||||
$slug = $project['slug'] ?? '#';
|
||||
$thumbnail = $project['thumbnail'] ?? 'default-project.webp';
|
||||
$technologies = $project['technologies'] ?? [];
|
||||
$maxTechs = 4;
|
||||
?>
|
||||
|
||||
<article class="card-interactive group">
|
||||
<a href="/projet/<?= htmlspecialchars($slug, ENT_QUOTES, 'UTF-8') ?>" class="block">
|
||||
<!-- Thumbnail -->
|
||||
<div class="aspect-thumbnail overflow-hidden">
|
||||
<picture>
|
||||
<source
|
||||
srcset="/assets/img/projects/<?= htmlspecialchars($thumbnail, ENT_QUOTES, 'UTF-8') ?>"
|
||||
type="image/webp"
|
||||
>
|
||||
<img
|
||||
src="/assets/img/projects/<?= htmlspecialchars(str_replace('.webp', '.jpg', $thumbnail), ENT_QUOTES, 'UTF-8') ?>"
|
||||
alt="Aperçu du projet <?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?>"
|
||||
width="400"
|
||||
height="225"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
>
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="card-body">
|
||||
<h3 class="text-lg font-semibold text-text-primary mb-3 group-hover:text-primary transition-colors">
|
||||
<?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?>
|
||||
</h3>
|
||||
|
||||
<!-- Technologies (badges) -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<?php foreach (array_slice($technologies, 0, $maxTechs) as $tech): ?>
|
||||
<span class="badge"><?= htmlspecialchars($tech, ENT_QUOTES, 'UTF-8') ?></span>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (count($technologies) > $maxTechs): ?>
|
||||
<span class="badge badge-muted">+<?= count($technologies) - $maxTechs ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
```
|
||||
|
||||
### Grille Responsive
|
||||
|
||||
| Breakpoint | Colonnes | Gap |
|
||||
|------------|----------|-----|
|
||||
| Mobile (< 640px) | 1 | 1.5rem |
|
||||
| Tablet (≥ 640px) | 2 | 1.5rem |
|
||||
| Desktop (≥ 1024px) | 3 | 2rem |
|
||||
|
||||
### Classes Tailwind
|
||||
|
||||
```html
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- [] La page `/projets` s'affiche correctement
|
||||
- [] Seuls les projets "vedette" sont affichés (2 projets)
|
||||
- [] Chaque carte affiche : thumbnail, titre, badges
|
||||
- [] Cliquer sur une carte redirige vers `/projet/{slug}`
|
||||
- [] L'effet hover fonctionne (élévation, zoom image)
|
||||
- [] Responsive : 1 → 2 → 3 colonnes selon la taille
|
||||
- [] Images en lazy loading
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `pages/projects.php` | Modified | Page liste projets vedettes |
|
||||
| `templates/project-card.php` | Created | Template carte projet réutilisable |
|
||||
| `assets/img/projects/default-project.svg` | Created | Placeholder image par défaut |
|
||||
|
||||
### Completion Notes
|
||||
- Grille responsive: 1 col (mobile) → 2 cols (sm) → 3 cols (lg)
|
||||
- Template project-card réutilisable avec badges (max 4 + compteur)
|
||||
- Lazy loading natif sur les images
|
||||
- Fallback onerror pour images manquantes
|
||||
- Message "Projets à venir" si aucun projet
|
||||
- 2 projets vedettes affichés correctement
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||
317
docs/stories/3.4.page-projet-individuelle.md
Normal file
317
docs/stories/3.4.page-projet-individuelle.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Story 3.4: Page Projet Individuelle
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** consulter une page dédiée pour chaque projet vedette,
|
||||
**so that** je comprends le contexte, la solution technique et vois le résultat.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. L'URL `/projet/{slug}` affiche le projet correspondant au slug
|
||||
2. Le slug est récupéré depuis le router (pas depuis $_GET)
|
||||
3. La page affiche les sections : Contexte, Solution technique, Travail d'équipe (si applicable), Durée, Témoignage (si disponible)
|
||||
4. Un bouton/lien permet de visiter le projet en ligne (ou affiche "Non disponible")
|
||||
5. Les technologies sont affichées sous forme de badges
|
||||
6. Des captures d'écran sont affichées si disponibles (galerie simple)
|
||||
7. Un lien "Retour aux projets" permet de revenir à la liste
|
||||
8. Si le slug n'existe pas, la page 404 est affichée
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer la page project-single.php** (AC: 1, 2, 8)
|
||||
- [] Créer `pages/project-single.php`
|
||||
- [] Récupérer le slug depuis `$GLOBALS['routeParams']`
|
||||
- [] Charger le projet avec `getProjectBySlug()`
|
||||
- [] Rediriger vers 404 si projet non trouvé
|
||||
|
||||
- [] **Task 2 : Afficher les informations principales** (AC: 3, 5)
|
||||
- [] Titre du projet
|
||||
- [] Badges technologies
|
||||
- [] Section Contexte
|
||||
- [] Section Solution technique
|
||||
- [] Section Travail d'équipe (si non null)
|
||||
- [] Durée du projet
|
||||
|
||||
- [] **Task 3 : Ajouter le lien vers le projet** (AC: 4)
|
||||
- [] Bouton "Voir le projet en ligne" si URL disponible
|
||||
- [] Bouton "Voir sur GitHub" si URL GitHub disponible
|
||||
- [] Message "Projet non disponible en ligne" si aucun lien
|
||||
|
||||
- [] **Task 4 : Afficher la galerie de captures** (AC: 6)
|
||||
- [] Grille de screenshots
|
||||
- [] Lazy loading sur les images
|
||||
- [ ] Lightbox optionnel (amélioration future)
|
||||
|
||||
- [] **Task 5 : Ajouter le témoignage** (AC: 3)
|
||||
- [] Placeholder préparé pour Story 4.5
|
||||
- [ ] Récupérer le témoignage lié au projet (Story 4.5)
|
||||
|
||||
- [] **Task 6 : Ajouter la navigation** (AC: 7)
|
||||
- [] Breadcrumb en haut de page
|
||||
- [] Lien "Retour aux projets"
|
||||
- [] CTA "Me contacter" en bas
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Page pages/project-single.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Page projet individuelle
|
||||
*/
|
||||
|
||||
// Récupérer le slug depuis le router
|
||||
$slug = $GLOBALS['routeParams'][0] ?? null;
|
||||
|
||||
if (!$slug) {
|
||||
http_response_code(404);
|
||||
include __DIR__ . '/404.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$project = getProjectBySlug($slug);
|
||||
|
||||
if (!$project) {
|
||||
http_response_code(404);
|
||||
include __DIR__ . '/404.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Récupérer le témoignage lié (si existe)
|
||||
$testimonial = getTestimonialByProject($slug);
|
||||
|
||||
$pageTitle = $project['title'];
|
||||
$pageDescription = $project['context'] ?? "Découvrez le projet {$project['title']}";
|
||||
$currentPage = 'projects';
|
||||
|
||||
include_template('header', compact('pageTitle', 'pageDescription'));
|
||||
include_template('navbar', compact('currentPage'));
|
||||
?>
|
||||
|
||||
<main>
|
||||
<article class="section">
|
||||
<div class="container-content">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb mb-8">
|
||||
<a href="/" class="breadcrumb-link">Accueil</a>
|
||||
<span class="text-text-muted">/</span>
|
||||
<a href="/projets" class="breadcrumb-link">Projets</a>
|
||||
<span class="text-text-muted">/</span>
|
||||
<span class="breadcrumb-current"><?= htmlspecialchars($project['title']) ?></span>
|
||||
</nav>
|
||||
|
||||
<!-- Header du projet -->
|
||||
<header class="mb-12">
|
||||
<h1 class="text-display mb-4"><?= htmlspecialchars($project['title']) ?></h1>
|
||||
|
||||
<!-- Technologies -->
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
<?php foreach ($project['technologies'] ?? [] as $tech): ?>
|
||||
<span class="badge badge-primary"><?= htmlspecialchars($tech) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Boutons d'action -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<?php if (!empty($project['url'])): ?>
|
||||
<a href="<?= htmlspecialchars($project['url']) ?>" target="_blank" rel="noopener" class="btn-primary">
|
||||
Voir le projet en ligne
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($project['github'])): ?>
|
||||
<a href="<?= htmlspecialchars($project['github']) ?>" target="_blank" rel="noopener" class="btn-secondary">
|
||||
Voir sur GitHub
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($project['url']) && empty($project['github'])): ?>
|
||||
<span class="text-text-muted italic">Projet non disponible en ligne</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Image principale -->
|
||||
<?php if (!empty($project['thumbnail'])): ?>
|
||||
<div class="mb-12 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src="/assets/img/projects/<?= htmlspecialchars($project['thumbnail']) ?>"
|
||||
alt="<?= htmlspecialchars($project['title']) ?>"
|
||||
class="w-full"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
<!-- Colonne principale -->
|
||||
<div class="lg:col-span-2 space-y-10">
|
||||
<!-- Contexte -->
|
||||
<?php if (!empty($project['context'])): ?>
|
||||
<section>
|
||||
<h2 class="text-heading mb-4">Contexte</h2>
|
||||
<p class="text-text-secondary leading-relaxed">
|
||||
<?= nl2br(htmlspecialchars($project['context'])) ?>
|
||||
</p>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Solution technique -->
|
||||
<?php if (!empty($project['solution'])): ?>
|
||||
<section>
|
||||
<h2 class="text-heading mb-4">Solution Technique</h2>
|
||||
<p class="text-text-secondary leading-relaxed">
|
||||
<?= nl2br(htmlspecialchars($project['solution'])) ?>
|
||||
</p>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Travail d'équipe -->
|
||||
<?php if (!empty($project['teamwork'])): ?>
|
||||
<section>
|
||||
<h2 class="text-heading mb-4">Travail d'Équipe</h2>
|
||||
<p class="text-text-secondary leading-relaxed">
|
||||
<?= nl2br(htmlspecialchars($project['teamwork'])) ?>
|
||||
</p>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Galerie -->
|
||||
<?php if (!empty($project['screenshots'])): ?>
|
||||
<section>
|
||||
<h2 class="text-heading mb-4">Captures d'écran</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<?php foreach ($project['screenshots'] as $screenshot): ?>
|
||||
<img
|
||||
src="/assets/img/projects/<?= htmlspecialchars($screenshot) ?>"
|
||||
alt="Capture d'écran - <?= htmlspecialchars($project['title']) ?>"
|
||||
class="rounded-lg"
|
||||
loading="lazy"
|
||||
>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="space-y-6">
|
||||
<!-- Durée -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h3 class="text-sm font-medium text-text-muted mb-1">Durée du projet</h3>
|
||||
<p class="text-lg font-semibold"><?= htmlspecialchars($project['duration'] ?? 'Non spécifiée') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Témoignage -->
|
||||
<?php if ($testimonial): ?>
|
||||
<div class="testimonial">
|
||||
<blockquote class="text-text-secondary italic mb-4">
|
||||
"<?= htmlspecialchars($testimonial['quote']) ?>"
|
||||
</blockquote>
|
||||
<footer>
|
||||
<p class="font-medium text-text-primary"><?= htmlspecialchars($testimonial['author_name']) ?></p>
|
||||
<p class="text-sm text-text-muted">
|
||||
<?= htmlspecialchars($testimonial['author_role']) ?>
|
||||
<?php if (!empty($testimonial['author_company'])): ?>
|
||||
- <?= htmlspecialchars($testimonial['author_company']) ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Navigation bas de page -->
|
||||
<footer class="mt-16 pt-8 border-t border-border flex flex-wrap justify-between items-center gap-4">
|
||||
<a href="/projets" class="btn-ghost">
|
||||
← Retour aux projets
|
||||
</a>
|
||||
<a href="/contact" class="btn-primary">
|
||||
Me contacter
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<?php include_template('footer'); ?>
|
||||
```
|
||||
|
||||
### Structure de la Page
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Breadcrumb │
|
||||
├─────────────────────────────────────────┤
|
||||
│ TITRE DU PROJET │
|
||||
│ [Badge] [Badge] [Badge] │
|
||||
│ [Voir en ligne] [GitHub] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [Image principale] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ CONTENU │ SIDEBAR │
|
||||
│ ───────────── │ │
|
||||
│ Contexte │ Durée │
|
||||
│ Solution technique │ │
|
||||
│ Travail d'équipe │ Témoignage │
|
||||
│ Captures d'écran │ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [← Retour] [Me contacter] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- [] `/projet/ecommerce-xyz` affiche le bon projet
|
||||
- [] `/projet/inexistant` affiche la page 404
|
||||
- [] Toutes les sections s'affichent correctement
|
||||
- [] Le bouton "Voir en ligne" fonctionne (si URL)
|
||||
- [] Le bouton "Voir sur GitHub" fonctionne (si GitHub)
|
||||
- [] Le breadcrumb est navigable
|
||||
- [] Les images sont en lazy loading
|
||||
- [] La page est responsive
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `pages/project-single.php` | Modified | Page projet individuelle complète |
|
||||
|
||||
### Completion Notes
|
||||
- Récupération slug via router ($GLOBALS['routeParams'])
|
||||
- Redirection 404 si projet non trouvé
|
||||
- Sections: Contexte, Solution technique, Travail d'équipe (conditionnel)
|
||||
- Boutons: Voir en ligne + GitHub avec icônes SVG
|
||||
- Galerie screenshots en grille 2 colonnes
|
||||
- Sidebar avec durée du projet
|
||||
- Breadcrumb accessible avec aria-label
|
||||
- Navigation: retour + CTA contact
|
||||
- Lazy loading + fallback onerror sur images
|
||||
- Témoignage: placeholder préparé pour Story 4.5
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||
169
docs/stories/3.5.projets-secondaires.md
Normal file
169
docs/stories/3.5.projets-secondaires.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Story 3.5: Liste des Projets Secondaires
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir une liste simplifiée des projets secondaires,
|
||||
**so that** je découvre l'étendue du travail sans page dédiée pour chaque.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Sur `/projets`, une section "Autres projets" liste les projets où category = "secondaire"
|
||||
2. Chaque projet secondaire affiche : titre, courte description (1 ligne), technologies
|
||||
3. Le format est compact (liste ou petites cartes)
|
||||
4. Les projets secondaires peuvent avoir un lien externe direct (optionnel)
|
||||
5. La section est visuellement distincte des projets vedettes (séparateur, titre de section)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Ajouter la section dans projects.php** (AC: 1, 5)
|
||||
- [] Récupérer les projets secondaires
|
||||
- [] Ajouter un titre de section "Autres projets"
|
||||
- [] Ajouter un séparateur visuel
|
||||
|
||||
- [] **Task 2 : Créer le template project-card-compact.php** (AC: 2, 3)
|
||||
- [] Format liste horizontale
|
||||
- [] Titre cliquable (si URL)
|
||||
- [] Description courte (truncate si nécessaire)
|
||||
- [] Badges technologies (3 max)
|
||||
|
||||
- [] **Task 3 : Gérer les liens** (AC: 4)
|
||||
- [] Si URL → lien externe (nouvel onglet)
|
||||
- [] Si pas d'URL → texte simple
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Section à ajouter dans pages/projects.php
|
||||
|
||||
```php
|
||||
<!-- Après la grille des projets vedettes -->
|
||||
|
||||
<?php if (!empty($secondaryProjects)): ?>
|
||||
<!-- Séparateur -->
|
||||
<hr class="border-border my-16">
|
||||
|
||||
<!-- Section projets secondaires -->
|
||||
<section>
|
||||
<h2 class="text-heading mb-8">Autres projets</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<?php foreach ($secondaryProjects as $project): ?>
|
||||
<?php include_template('project-card-compact', ['project' => $project]); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
```
|
||||
|
||||
### Template templates/project-card-compact.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Carte projet compacte (projets secondaires)
|
||||
* @param array $project Données du projet
|
||||
*/
|
||||
|
||||
$title = $project['title'] ?? 'Sans titre';
|
||||
$context = $project['context'] ?? '';
|
||||
$url = $project['url'] ?? null;
|
||||
$technologies = $project['technologies'] ?? [];
|
||||
$maxTechs = 3;
|
||||
|
||||
// Tronquer la description à ~100 caractères
|
||||
$shortContext = strlen($context) > 100
|
||||
? substr($context, 0, 100) . '...'
|
||||
: $context;
|
||||
?>
|
||||
|
||||
<article class="card hover:border-border transition-colors">
|
||||
<div class="card-body flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<!-- Titre et description -->
|
||||
<div class="flex-grow">
|
||||
<?php if ($url): ?>
|
||||
<a href="<?= htmlspecialchars($url) ?>"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-lg font-semibold text-text-primary hover:text-primary transition-colors inline-flex items-center gap-2">
|
||||
<?= htmlspecialchars($title) ?>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<h3 class="text-lg font-semibold text-text-primary">
|
||||
<?= htmlspecialchars($title) ?>
|
||||
</h3>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($shortContext): ?>
|
||||
<p class="text-text-secondary text-sm mt-1">
|
||||
<?= htmlspecialchars($shortContext) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Technologies -->
|
||||
<div class="flex flex-wrap gap-2 sm:flex-shrink-0">
|
||||
<?php foreach (array_slice($technologies, 0, $maxTechs) as $tech): ?>
|
||||
<span class="badge text-xs"><?= htmlspecialchars($tech) ?></span>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (count($technologies) > $maxTechs): ?>
|
||||
<span class="badge badge-muted text-xs">+<?= count($technologies) - $maxTechs ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
```
|
||||
|
||||
### Différences Vedettes vs Secondaires
|
||||
|
||||
| Aspect | Projets Vedettes | Projets Secondaires |
|
||||
|--------|------------------|---------------------|
|
||||
| Format | Carte avec image | Ligne compacte |
|
||||
| Image | Thumbnail | Aucune |
|
||||
| Lien | Page dédiée | Lien externe direct |
|
||||
| Description | Non affichée dans la liste | 1 ligne |
|
||||
| Technologies | 4 max | 3 max |
|
||||
|
||||
## Testing
|
||||
|
||||
- [] La section "Autres projets" apparaît sous les projets vedettes
|
||||
- [] Seuls les projets "secondaire" sont listés (1 projet)
|
||||
- [] Chaque projet affiche : titre, description courte, badges
|
||||
- [] Le titre est cliquable si URL disponible
|
||||
- [] Le lien s'ouvre dans un nouvel onglet (target="_blank")
|
||||
- [] Le design est distinct des projets vedettes (séparateur hr)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `pages/projects.php` | Modified | Ajout section projets secondaires |
|
||||
| `templates/project-card-compact.php` | Created | Template carte compacte |
|
||||
|
||||
### Completion Notes
|
||||
- Section "Autres projets" avec séparateur visuel (hr)
|
||||
- Template compact: titre + description tronquée (100 chars) + badges (3 max)
|
||||
- Lien externe avec icône SVG si URL disponible
|
||||
- rel="noopener" pour sécurité
|
||||
- 1 projet secondaire affiché: "Site Vitrine Restaurant"
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||
196
docs/stories/3.6.optimisation-images.md
Normal file
196
docs/stories/3.6.optimisation-images.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Story 3.6: Optimisation des Images Projets
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** que les images des projets se chargent rapidement,
|
||||
**so that** je n'attends pas et j'ai une expérience fluide.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Les images sont stockées dans `assets/img/projects/`
|
||||
2. Le lazy loading est activé sur toutes les images (attribut `loading="lazy"`)
|
||||
3. Les images ont des dimensions explicites (width/height) pour éviter le layout shift
|
||||
4. Le format WebP est utilisé avec fallback JPG/PNG via `<picture>`
|
||||
5. Les thumbnails sont redimensionnés (max 400px de large)
|
||||
6. Le score Lighthouse "Images" est vert (>90)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Organiser le dossier images** (AC: 1)
|
||||
- [] Créer `assets/img/projects/`
|
||||
- [] Définir la convention de nommage : `{slug}-{type}.{ext}`
|
||||
- [] Exemple : `ecommerce-xyz-thumb.webp`, `ecommerce-xyz-screen-1.webp`
|
||||
|
||||
- [] **Task 2 : Implémenter le lazy loading** (AC: 2)
|
||||
- [] Ajouter `loading="lazy"` sur toutes les images projets
|
||||
- [] Image principale above-the-fold sans lazy loading (false)
|
||||
|
||||
- [] **Task 3 : Ajouter les dimensions explicites** (AC: 3)
|
||||
- [] Définir les tailles standards : thumbnail (400x225), screenshot (800x450), hero (1200x675)
|
||||
- [] Ajouter `width` et `height` sur toutes les `<img>`
|
||||
|
||||
- [] **Task 4 : Implémenter WebP avec fallback** (AC: 4)
|
||||
- [] Utiliser `<picture>` avec `<source type="image/webp">`
|
||||
- [] Fallback vers JPG
|
||||
|
||||
- [] **Task 5 : Documenter les tailles recommandées** (AC: 5)
|
||||
- [] Thumbnails : 400x225, qualité 80%
|
||||
- [] Screenshots : 800x450, qualité 85%
|
||||
- [] Hero : 1200x675, qualité 85%
|
||||
- [] Documentation dans Dev Notes
|
||||
|
||||
- [ ] **Task 6 : Tester les performances** (AC: 6)
|
||||
- [ ] Audit Lighthouse sur la page projets (requiert images réelles)
|
||||
- [ ] Vérifier le score images > 90
|
||||
- [ ] Vérifier le CLS < 0.1
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Convention de Nommage des Images
|
||||
|
||||
```
|
||||
assets/img/projects/
|
||||
├── ecommerce-xyz-thumb.webp # Thumbnail (400x225)
|
||||
├── ecommerce-xyz-thumb.jpg # Fallback JPG
|
||||
├── ecommerce-xyz-screen-1.webp # Screenshot 1 (800x450)
|
||||
├── ecommerce-xyz-screen-1.jpg # Fallback
|
||||
├── ecommerce-xyz-screen-2.webp # Screenshot 2
|
||||
├── app-gestion-thumb.webp
|
||||
├── app-gestion-screen-1.webp
|
||||
└── default-project.webp # Image par défaut
|
||||
```
|
||||
|
||||
### Format : {slug}-{type}.{extension}
|
||||
|
||||
| Type | Usage | Dimensions | Qualité |
|
||||
|------|-------|------------|---------|
|
||||
| thumb | Carte projet | 400x225 (16:9) | 80% |
|
||||
| screen-N | Captures d'écran | 800x450 (16:9) | 85% |
|
||||
| hero | Image principale | 1200x675 (16:9) | 85% |
|
||||
|
||||
### Template Image avec Picture
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Helper pour afficher une image optimisée
|
||||
*/
|
||||
function projectImage(string $filename, string $alt, int $width, int $height, bool $lazy = true): string
|
||||
{
|
||||
$webp = $filename;
|
||||
$fallback = str_replace('.webp', '.jpg', $filename);
|
||||
$lazyAttr = $lazy ? 'loading="lazy"' : '';
|
||||
|
||||
return <<<HTML
|
||||
<picture>
|
||||
<source srcset="/assets/img/projects/{$webp}" type="image/webp">
|
||||
<img
|
||||
src="/assets/img/projects/{$fallback}"
|
||||
alt="{$alt}"
|
||||
width="{$width}"
|
||||
height="{$height}"
|
||||
{$lazyAttr}
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
</picture>
|
||||
HTML;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage dans les Templates
|
||||
|
||||
```php
|
||||
<!-- Dans project-card.php -->
|
||||
<div class="aspect-thumbnail overflow-hidden">
|
||||
<?= projectImage(
|
||||
$project['thumbnail'],
|
||||
"Aperçu du projet " . $project['title'],
|
||||
400,
|
||||
225,
|
||||
true // lazy loading
|
||||
) ?>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Checklist Optimisation Image
|
||||
|
||||
Pour chaque image :
|
||||
- [ ] Format WebP créé
|
||||
- [ ] Fallback JPG créé
|
||||
- [ ] Redimensionnée à la taille cible
|
||||
- [ ] Compressée (qualité 80-85%)
|
||||
- [ ] Nommée selon la convention
|
||||
|
||||
### Outils Recommandés
|
||||
|
||||
| Outil | Usage |
|
||||
|-------|-------|
|
||||
| Squoosh.app | Compression web (gratuit) |
|
||||
| ImageMagick | CLI batch conversion |
|
||||
| Sharp (Node) | Automatisation build |
|
||||
|
||||
### Commandes ImageMagick
|
||||
|
||||
```bash
|
||||
# Convertir en WebP
|
||||
convert input.jpg -quality 80 -resize 400x225 output.webp
|
||||
|
||||
# Batch conversion
|
||||
for f in *.jpg; do
|
||||
convert "$f" -quality 80 "${f%.jpg}.webp"
|
||||
done
|
||||
```
|
||||
|
||||
### Métriques de Performance Cibles
|
||||
|
||||
| Métrique | Objectif |
|
||||
|----------|----------|
|
||||
| Lighthouse Images | > 90 |
|
||||
| CLS | < 0.1 |
|
||||
| LCP (si image above-fold) | < 2.5s |
|
||||
| Taille thumbnail | < 30kb |
|
||||
| Taille screenshot | < 80kb |
|
||||
|
||||
## Testing
|
||||
|
||||
- [] Toutes les images sont dans `assets/img/projects/`
|
||||
- [] Le lazy loading fonctionne (attribut loading="lazy")
|
||||
- [] Dimensions explicites pour éviter CLS (width/height)
|
||||
- [] Les images WebP sont servies via `<picture>`
|
||||
- [] Le fallback JPG est présent dans `<img src>`
|
||||
- [ ] Score Lighthouse images > 90 (requiert images réelles)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `includes/functions.php` | Modified | Ajout fonction projectImage() |
|
||||
| `templates/project-card.php` | Modified | Utilise projectImage() |
|
||||
| `pages/project-single.php` | Modified | Utilise projectImage() pour hero et galerie |
|
||||
|
||||
### Completion Notes
|
||||
- Fonction `projectImage()` créée avec support `<picture>` WebP + fallback JPG
|
||||
- Tailles standards définies: thumbnail (400x225), screenshot (800x450), hero (1200x675)
|
||||
- Lazy loading activé par défaut, désactivé pour images above-the-fold
|
||||
- Fallback onerror vers default-project.svg
|
||||
- Templates mis à jour: project-card.php, project-single.php
|
||||
- Task 6 (Lighthouse) non testable sans images réelles
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||
211
docs/stories/4.1.page-competences-technologies.md
Normal file
211
docs/stories/4.1.page-competences-technologies.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Story 4.1: Page Compétences - Technologies Liées aux Projets
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir les compétences techniques du développeur liées aux projets réalisés,
|
||||
**so that** je vérifie qu'il maîtrise les technologies dont j'ai besoin.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. `/competences` affiche une liste des technologies de développement (HTML, CSS, JS, PHP, etc.)
|
||||
2. Chaque technologie est liée aux projets qui l'utilisent (liens cliquables)
|
||||
3. Les technologies sont groupées par catégorie (Frontend, Backend, Outils, etc.)
|
||||
4. Un indicateur visuel montre le nombre de projets utilisant chaque technologie
|
||||
5. Le design utilise des badges/tags cohérents avec les pages projets
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer la page skills.php** (AC: 1)
|
||||
- [] Mettre à jour `pages/skills.php`
|
||||
- [] Inclure header, navbar, footer
|
||||
- [] Route `/competences` déjà configurée
|
||||
|
||||
- [] **Task 2 : Créer la structure de données des technologies**
|
||||
- [] Définir les catégories : Frontend, Backend, Base de données, DevOps
|
||||
- [] Lister les technologies par catégorie
|
||||
- [] Comptage automatique via getProjectCountByTech()
|
||||
|
||||
- [] **Task 3 : Afficher les technologies groupées** (AC: 3)
|
||||
- [] Section par catégorie avec icône
|
||||
- [] Titre de catégorie
|
||||
- [] Liste des technologies
|
||||
|
||||
- [] **Task 4 : Lier aux projets** (AC: 2, 4)
|
||||
- [] Compter les projets par technologie
|
||||
- [] Afficher le compteur en badge
|
||||
- [] Tooltip avec nombre de projets
|
||||
|
||||
- [] **Task 5 : Styler avec les badges** (AC: 5)
|
||||
- [] Technologies avec projets: fond coloré + compteur
|
||||
- [] Technologies sans projet: grisées
|
||||
- [] Effet hover
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Page pages/skills.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Page Compétences
|
||||
*/
|
||||
|
||||
$pageTitle = 'Compétences';
|
||||
$pageDescription = 'Mes compétences techniques en développement web : langages, frameworks et outils.';
|
||||
$currentPage = 'skills';
|
||||
|
||||
// Récupérer toutes les technologies depuis les projets
|
||||
$projects = getProjects();
|
||||
$techCount = [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
foreach ($project['technologies'] ?? [] as $tech) {
|
||||
$techCount[$tech] = ($techCount[$tech] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Catégoriser les technologies
|
||||
$categories = [
|
||||
'Frontend' => ['HTML', 'CSS', 'JavaScript', 'TypeScript', 'React', 'Vue.js', 'Tailwind CSS', 'Bootstrap', 'SASS'],
|
||||
'Backend' => ['PHP', 'Node.js', 'Python', 'Laravel', 'Express', 'Symfony'],
|
||||
'Base de données' => ['MySQL', 'PostgreSQL', 'MongoDB', 'SQLite', 'Redis'],
|
||||
'DevOps & Outils' => ['Git', 'Docker', 'Linux', 'Nginx', 'Apache', 'CI/CD'],
|
||||
];
|
||||
|
||||
include_template('header', compact('pageTitle', 'pageDescription'));
|
||||
include_template('navbar', compact('currentPage'));
|
||||
?>
|
||||
|
||||
<main>
|
||||
<section class="section">
|
||||
<div class="container-content">
|
||||
<div class="section-header">
|
||||
<h1 class="section-title">Compétences</h1>
|
||||
<p class="section-subtitle">
|
||||
Technologies que j'utilise au quotidien, liées à mes projets réels.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Technologies par catégorie -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
<?php foreach ($categories as $category => $techs): ?>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="text-subheading mb-6"><?= htmlspecialchars($category) ?></h2>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<?php foreach ($techs as $tech): ?>
|
||||
<?php $count = $techCount[$tech] ?? 0; ?>
|
||||
<?php if ($count > 0): ?>
|
||||
<a href="/projets?tech=<?= urlencode($tech) ?>"
|
||||
class="group flex items-center gap-2 px-4 py-2 bg-surface-light rounded-lg hover:bg-primary/20 transition-colors">
|
||||
<span class="font-medium text-text-primary group-hover:text-primary">
|
||||
<?= htmlspecialchars($tech) ?>
|
||||
</span>
|
||||
<span class="text-xs px-2 py-0.5 bg-primary/20 text-primary rounded-full">
|
||||
<?= $count ?>
|
||||
</span>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="flex items-center gap-2 px-4 py-2 bg-surface-light/50 rounded-lg text-text-muted">
|
||||
<?= htmlspecialchars($tech) ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section outils (Story 4.2) sera ajoutée ici -->
|
||||
</main>
|
||||
|
||||
<?php include_template('footer'); ?>
|
||||
```
|
||||
|
||||
### Logique de Comptage
|
||||
|
||||
```php
|
||||
/**
|
||||
* Compte les projets par technologie
|
||||
*/
|
||||
function getProjectCountByTech(): array
|
||||
{
|
||||
$projects = getProjects();
|
||||
$count = [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
foreach ($project['technologies'] ?? [] as $tech) {
|
||||
$count[$tech] = ($count[$tech] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les projets utilisant une technologie
|
||||
*/
|
||||
function getProjectsByTech(string $tech): array
|
||||
{
|
||||
return array_filter(getProjects(), function($project) use ($tech) {
|
||||
return in_array($tech, $project['technologies'] ?? []);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Catégories de Technologies
|
||||
|
||||
| Catégorie | Technologies |
|
||||
|-----------|--------------|
|
||||
| Frontend | HTML, CSS, JS, React, Vue, Tailwind, etc. |
|
||||
| Backend | PHP, Node.js, Python, Laravel, etc. |
|
||||
| Base de données | MySQL, PostgreSQL, MongoDB, etc. |
|
||||
| DevOps & Outils | Git, Docker, Linux, CI/CD, etc. |
|
||||
|
||||
## Testing
|
||||
|
||||
- [] La page `/competences` s'affiche correctement
|
||||
- [] Les technologies sont groupées par catégorie
|
||||
- [] Le compteur de projets est affiché
|
||||
- [] Les technologies avec projets ont un tooltip
|
||||
- [] Les technologies sans projet sont grisées
|
||||
- [] Le design est cohérent avec le reste du site
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `includes/functions.php` | Modified | Ajout getProjectCountByTech() et getProjectsByTech() |
|
||||
| `pages/skills.php` | Modified | Implémentation complète de la page compétences |
|
||||
|
||||
### Completion Notes
|
||||
- Page `/competences` avec 4 catégories de technologies (Frontend, Backend, Base de données, DevOps & Outils)
|
||||
- Icône SVG pour chaque catégorie
|
||||
- Compteur de projets affiché en badge pour chaque technologie
|
||||
- Tooltip avec nombre de projets au survol
|
||||
- Technologies sans projet associé affichées en grisé
|
||||
- Design cohérent avec les cartes du reste du site
|
||||
- Note: Les liens vers `/projets?tech=X` ont été retirés (filtrage à implémenter dans une future story)
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||
214
docs/stories/4.2.page-competences-outils.md
Normal file
214
docs/stories/4.2.page-competences-outils.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Story 4.2: Page Compétences - Outils Démontrables
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** voir les outils maîtrisés avec des preuves concrètes,
|
||||
**so that** je vérifie les compétences au-delà des simples affirmations.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Une section "Outils démontrables" liste les outils avec liens de preuve (Git → GitHub, Notion → page publique, etc.)
|
||||
2. Chaque outil a : nom, icône/logo, lien vers la preuve externe
|
||||
3. Une section "Autres outils" liste les outils non démontrables avec contexte d'utilisation
|
||||
4. Le design distingue clairement les deux types d'outils
|
||||
5. Les liens externes s'ouvrent dans un nouvel onglet
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Définir la structure des outils**
|
||||
- [] Créer un tableau d'outils démontrables avec liens
|
||||
- [] Créer un tableau d'autres outils avec contexte
|
||||
|
||||
- [] **Task 2 : Ajouter la section "Outils démontrables"** (AC: 1, 2)
|
||||
- [] Titre de section
|
||||
- [] Grille d'outils avec icône et lien
|
||||
- [] Effet hover
|
||||
|
||||
- [] **Task 3 : Ajouter la section "Autres outils"** (AC: 3)
|
||||
- [] Titre de section
|
||||
- [] Liste avec description du contexte
|
||||
- [] Style différent (moins mis en avant)
|
||||
|
||||
- [] **Task 4 : Implémenter les liens externes** (AC: 4, 5)
|
||||
- [] `target="_blank"` et `rel="noopener"`
|
||||
- [] Icône "lien externe" visuelle
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Données des Outils
|
||||
|
||||
```php
|
||||
// Outils démontrables (avec preuves)
|
||||
$demonstrableTools = [
|
||||
[
|
||||
'name' => 'Git / GitHub',
|
||||
'icon' => 'github',
|
||||
'url' => 'https://github.com/votre-username',
|
||||
'description' => 'Historique de commits et projets publics'
|
||||
],
|
||||
[
|
||||
'name' => 'VS Code',
|
||||
'icon' => 'vscode',
|
||||
'url' => null, // pas de lien mais démontrable via code
|
||||
'description' => 'Éditeur principal, configuration partagée'
|
||||
],
|
||||
[
|
||||
'name' => 'Figma',
|
||||
'icon' => 'figma',
|
||||
'url' => 'https://figma.com/@votre-username',
|
||||
'description' => 'Maquettes et prototypes'
|
||||
],
|
||||
[
|
||||
'name' => 'Notion',
|
||||
'icon' => 'notion',
|
||||
'url' => 'https://notion.so/votre-page-publique',
|
||||
'description' => 'Organisation et documentation'
|
||||
],
|
||||
[
|
||||
'name' => 'Docker',
|
||||
'icon' => 'docker',
|
||||
'url' => 'https://hub.docker.com/u/votre-username',
|
||||
'description' => 'Images et configurations'
|
||||
],
|
||||
];
|
||||
|
||||
// Autres outils (sans preuve directe)
|
||||
$otherTools = [
|
||||
['name' => 'Photoshop', 'context' => 'Retouche d\'images et création graphique'],
|
||||
['name' => 'Insomnia', 'context' => 'Test d\'APIs REST'],
|
||||
['name' => 'DBeaver', 'context' => 'Administration de bases de données'],
|
||||
['name' => 'FileZilla', 'context' => 'Transfert FTP/SFTP'],
|
||||
['name' => 'Trello', 'context' => 'Gestion de projet Kanban'],
|
||||
];
|
||||
```
|
||||
|
||||
### Section à ajouter dans pages/skills.php
|
||||
|
||||
```php
|
||||
<!-- Outils démontrables -->
|
||||
<section class="section bg-surface">
|
||||
<div class="container-content">
|
||||
<h2 class="text-heading mb-8">Outils Démontrables</h2>
|
||||
<p class="text-text-secondary mb-8">
|
||||
Ces outils sont accompagnés de preuves vérifiables.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<?php foreach ($demonstrableTools as $tool): ?>
|
||||
<a href="<?= htmlspecialchars($tool['url']) ?>"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="card-interactive group">
|
||||
<div class="card-body flex items-start gap-4">
|
||||
<!-- Icône -->
|
||||
<div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-2xl text-primary">
|
||||
<?= getToolIcon($tool['icon']) ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-semibold text-text-primary group-hover:text-primary transition-colors flex items-center gap-2">
|
||||
<?= htmlspecialchars($tool['name']) ?>
|
||||
<svg class="w-4 h-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
</h3>
|
||||
<p class="text-sm text-text-muted mt-1">
|
||||
<?= htmlspecialchars($tool['description']) ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Autres outils -->
|
||||
<section class="section">
|
||||
<div class="container-content">
|
||||
<h2 class="text-heading mb-8">Autres Outils</h2>
|
||||
<p class="text-text-secondary mb-8">
|
||||
Outils utilisés régulièrement dans mes projets.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<?php foreach ($otherTools as $tool): ?>
|
||||
<div class="group relative">
|
||||
<span class="badge text-sm cursor-help">
|
||||
<?= htmlspecialchars($tool['name']) ?>
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-surface-light text-text-secondary text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap">
|
||||
<?= htmlspecialchars($tool['context']) ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### Icônes des Outils
|
||||
|
||||
Utiliser des SVG inline ou une sprite d'icônes pour les outils :
|
||||
|
||||
```php
|
||||
function getToolIcon(string $icon): string
|
||||
{
|
||||
$icons = [
|
||||
'github' => '<svg>...</svg>',
|
||||
'vscode' => '<svg>...</svg>',
|
||||
'figma' => '<svg>...</svg>',
|
||||
'notion' => '<svg>...</svg>',
|
||||
'docker' => '<svg>...</svg>',
|
||||
];
|
||||
|
||||
return $icons[$icon] ?? '🔧';
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- [] La section "Outils démontrables" affiche les outils avec liens
|
||||
- [] Les liens s'ouvrent dans un nouvel onglet
|
||||
- [] L'icône "lien externe" est visible
|
||||
- [] La section "Autres outils" est visuellement distincte
|
||||
- [] Les tooltips fonctionnent au hover
|
||||
- [] Le design est responsive
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `includes/functions.php` | Modified | Ajout fonction getToolIcon() avec SVG |
|
||||
| `pages/skills.php` | Modified | Ajout sections outils démontrables et autres outils |
|
||||
|
||||
### Completion Notes
|
||||
- Fonction `getToolIcon()` avec icônes SVG pour GitHub, VS Code, Figma, Docker, Linux
|
||||
- Section "Outils Démontrables" avec grille responsive (1→2→3 colonnes)
|
||||
- Liens externes avec `target="_blank"` et `rel="noopener"`
|
||||
- Icône lien externe SVG sur les outils avec URL
|
||||
- Section "Autres Outils" avec badges et tooltips au hover
|
||||
- Design distinct entre les deux sections (cartes vs badges)
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||
254
docs/stories/4.3.page-decouvrir-parcours.md
Normal file
254
docs/stories/4.3.page-decouvrir-parcours.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Story 4.3: Page Me Découvrir - Parcours et Motivations
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** en savoir plus sur le développeur en tant que personne,
|
||||
**so that** je crée une connexion humaine et évalue la compatibilité.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. `/a-propos` affiche les sections : Qui je suis, Mon parcours, Pourquoi ce métier
|
||||
2. Le ton est sympathique et authentique (pas trop formel, pas trop familier)
|
||||
3. Le texte est aéré avec des paragraphes courts
|
||||
4. Une photo professionnelle ou illustration est présente (optionnel mais recommandé)
|
||||
5. La localisation est mentionnée de façon générale (grande ville, pas adresse précise)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer la page about.php** (AC: 1)
|
||||
- [] Créer `pages/about.php`
|
||||
- [] Inclure header, navbar, footer
|
||||
- [] Configurer la route `/a-propos` (déjà fait en 3.2)
|
||||
|
||||
- [] **Task 2 : Créer la section "Qui je suis"** (AC: 2, 4, 5)
|
||||
- [] Photo ou illustration (placeholder SVG)
|
||||
- [] Texte d'introduction personnel
|
||||
- [] Localisation générale (Grand Est, France)
|
||||
|
||||
- [] **Task 3 : Créer la section "Mon parcours"** (AC: 2, 3)
|
||||
- [] Timeline ou liste des étapes clés (4 étapes)
|
||||
- [] Formation, expériences, projets marquants
|
||||
- [] Paragraphes courts et aérés
|
||||
|
||||
- [] **Task 4 : Créer la section "Pourquoi ce métier"** (AC: 2)
|
||||
- [] Motivations personnelles
|
||||
- [] Ce qui passionne dans le développement
|
||||
- [] Vision et valeurs
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Page pages/about.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Page Me Découvrir
|
||||
*/
|
||||
|
||||
$pageTitle = 'Me Découvrir';
|
||||
$pageDescription = 'Découvrez mon parcours, mes motivations et ce qui me passionne en tant que développeur web.';
|
||||
$currentPage = 'about';
|
||||
|
||||
include_template('header', compact('pageTitle', 'pageDescription'));
|
||||
include_template('navbar', compact('currentPage'));
|
||||
?>
|
||||
|
||||
<main>
|
||||
<!-- Section Hero / Qui je suis -->
|
||||
<section class="section">
|
||||
<div class="container-content">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<!-- Photo -->
|
||||
<div class="order-2 lg:order-1">
|
||||
<div class="aspect-square max-w-md mx-auto lg:mx-0 rounded-2xl overflow-hidden bg-surface">
|
||||
<img
|
||||
src="/assets/img/profile.webp"
|
||||
alt="Photo de profil"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Texte -->
|
||||
<div class="order-1 lg:order-2">
|
||||
<h1 class="text-display mb-6">
|
||||
Bonjour, je suis <span class="text-primary">Prénom</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-text-secondary mb-6 leading-relaxed">
|
||||
Développeur web passionné basé à <strong>Ville, France</strong>.
|
||||
Je crée des expériences numériques qui allient performance,
|
||||
accessibilité et design soigné.
|
||||
</p>
|
||||
|
||||
<p class="text-text-secondary leading-relaxed">
|
||||
Depuis X ans, je transforme des idées en solutions web concrètes.
|
||||
Mon approche : comprendre les besoins, proposer des solutions pragmatiques,
|
||||
et livrer un travail dont je suis fier.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mon Parcours -->
|
||||
<section class="section bg-surface">
|
||||
<div class="container-content">
|
||||
<h2 class="text-heading mb-12 text-center">Mon Parcours</h2>
|
||||
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Timeline -->
|
||||
<div class="space-y-8">
|
||||
<!-- Étape 1 -->
|
||||
<div class="flex gap-6">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<span class="text-primary font-bold">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-2">Formation</h3>
|
||||
<p class="text-text-secondary">
|
||||
[Votre formation - école, diplôme, année]
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 2 -->
|
||||
<div class="flex gap-6">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<span class="text-primary font-bold">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-2">Premières Expériences</h3>
|
||||
<p class="text-text-secondary">
|
||||
[Stages, premiers emplois, projets personnels]
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 3 -->
|
||||
<div class="flex gap-6">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<span class="text-primary font-bold">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-2">Aujourd'hui</h3>
|
||||
<p class="text-text-secondary">
|
||||
[Situation actuelle, spécialisation, objectifs]
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pourquoi ce métier -->
|
||||
<section class="section">
|
||||
<div class="container-content">
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
<h2 class="text-heading mb-8">Pourquoi le Développement Web ?</h2>
|
||||
|
||||
<div class="space-y-6 text-text-secondary text-lg leading-relaxed">
|
||||
<p>
|
||||
Ce qui me passionne dans le développement, c'est la possibilité de
|
||||
<strong class="text-text-primary">créer quelque chose à partir de rien</strong>.
|
||||
Une idée, du code, et soudain un site web existe et aide des gens.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
J'aime particulièrement le challenge de rendre les choses
|
||||
<strong class="text-text-primary">simples pour l'utilisateur</strong>,
|
||||
même quand elles sont complexes sous le capot.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Mon objectif : livrer un travail dont je suis fier, avec des solutions
|
||||
qui durent dans le temps et qui sont agréables à utiliser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="section bg-surface">
|
||||
<div class="container-content text-center">
|
||||
<h2 class="text-heading mb-4">Envie d'en savoir plus ?</h2>
|
||||
<p class="text-text-secondary mb-8">
|
||||
Découvrez mes réalisations ou contactez-moi directement.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<a href="/projets" class="btn-primary">Voir mes projets</a>
|
||||
<a href="/contact" class="btn-secondary">Me contacter</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<?php include_template('footer'); ?>
|
||||
```
|
||||
|
||||
### Ton Rédactionnel
|
||||
|
||||
| À faire | À éviter |
|
||||
|---------|----------|
|
||||
| Phrases courtes et directes | Jargon technique excessif |
|
||||
| Ton conversationnel | Ton trop corporate |
|
||||
| Anecdotes personnelles | Informations trop personnelles |
|
||||
| Montrer la passion | Arrogance ou fausse modestie |
|
||||
|
||||
### Contenu à Personnaliser
|
||||
|
||||
- [ ] Photo de profil professionnelle
|
||||
- [ ] Ville (pas d'adresse précise)
|
||||
- [ ] Années d'expérience
|
||||
- [ ] Formation et diplômes
|
||||
- [ ] Étapes clés du parcours
|
||||
- [ ] Motivations personnelles
|
||||
|
||||
## Testing
|
||||
|
||||
- [] La page `/a-propos` s'affiche correctement
|
||||
- [] Les 3 sections sont présentes (Qui je suis, Parcours, Pourquoi)
|
||||
- [] Le ton est approprié (sympathique, authentique)
|
||||
- [] Les paragraphes sont courts et aérés
|
||||
- [] Le placeholder photo s'affiche correctement
|
||||
- [] Les CTA en bas de page fonctionnent
|
||||
- [] La page est responsive
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `pages/about.php` | Modified | Implémentation complète de la page Me Découvrir |
|
||||
|
||||
### Completion Notes
|
||||
- Section "Qui je suis" avec placeholder photo SVG (gradient + icône)
|
||||
- Prénom personnalisé : "Célian"
|
||||
- Localisation générale : "Grand Est, France"
|
||||
- Timeline de 4 étapes pour le parcours
|
||||
- L'étape "Aujourd'hui" est mise en avant (badge plein)
|
||||
- Section "Pourquoi le développement" centrée avec emphases
|
||||
- CTA vers /projets et /contact
|
||||
- Ton authentique et sympathique
|
||||
- Note: Le placeholder peut être remplacé par une vraie photo dans `/assets/img/profile.webp`
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||
187
docs/stories/4.4.page-decouvrir-passions.md
Normal file
187
docs/stories/4.4.page-decouvrir-passions.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Story 4.4: Page Me Découvrir - Passions et Hobbies
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** découvrir les passions du développeur en dehors du code,
|
||||
**so that** je vois la personne au-delà du professionnel.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Une section "En dehors du code" présente les hobbies et passions
|
||||
2. Des preuves visuelles sont incluses si possible (photos d'événements, créations, etc.)
|
||||
3. Le contenu reste professionnel (pas d'informations trop personnelles)
|
||||
4. Les projets personnels sont mentionnés comme preuve de passion implicite
|
||||
5. Le design intègre harmonieusement texte et visuels
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Ajouter la section dans about.php** (AC: 1)
|
||||
- [] Titre "En dehors du code" ou "Mes passions"
|
||||
- [] Sous-titre engageant
|
||||
|
||||
- [] **Task 2 : Lister les hobbies** (AC: 1, 3)
|
||||
- [] 3-4 passions maximum (3 passions)
|
||||
- [] Description courte pour chaque
|
||||
- [] Garder un ton professionnel
|
||||
|
||||
- [] **Task 3 : Ajouter des visuels** (AC: 2, 5)
|
||||
- [] Placeholders SVG avec gradients pour chaque passion
|
||||
- [] Grille responsive (1→2→3 colonnes)
|
||||
- [] Effet hover sur les cartes
|
||||
|
||||
- [] **Task 4 : Mentionner les projets personnels** (AC: 4)
|
||||
- [] Lien vers GitHub (https://github.com/skycel)
|
||||
- [] Carte dédiée aux projets open source
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Section à ajouter dans pages/about.php
|
||||
|
||||
```php
|
||||
<!-- En dehors du code -->
|
||||
<section class="section">
|
||||
<div class="container-content">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">En Dehors du Code</h2>
|
||||
<p class="section-subtitle">
|
||||
Parce qu'un développeur a aussi une vie en dehors de l'écran.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<!-- Passion 1 -->
|
||||
<div class="card group">
|
||||
<div class="aspect-video overflow-hidden">
|
||||
<img
|
||||
src="/assets/img/hobbies/passion-1.webp"
|
||||
alt="[Description de la passion]"
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="text-lg font-semibold mb-2">[Passion 1]</h3>
|
||||
<p class="text-text-secondary text-sm">
|
||||
[Description courte de cette passion et pourquoi elle compte pour vous]
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Passion 2 -->
|
||||
<div class="card group">
|
||||
<div class="aspect-video overflow-hidden">
|
||||
<img
|
||||
src="/assets/img/hobbies/passion-2.webp"
|
||||
alt="[Description de la passion]"
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="text-lg font-semibold mb-2">[Passion 2]</h3>
|
||||
<p class="text-text-secondary text-sm">
|
||||
[Description courte]
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Passion 3 / Projets personnels -->
|
||||
<div class="card group">
|
||||
<div class="aspect-video overflow-hidden bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="text-lg font-semibold mb-2">Projets Open Source</h3>
|
||||
<p class="text-text-secondary text-sm mb-3">
|
||||
Je contribue à des projets open source et développe mes propres outils sur mon temps libre.
|
||||
</p>
|
||||
<a href="https://github.com/votre-username" target="_blank" rel="noopener" class="text-primary text-sm hover:underline inline-flex items-center gap-1">
|
||||
Voir sur GitHub
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### Exemples de Passions Appropriées
|
||||
|
||||
| Passion | Pourquoi c'est bien | Ce qu'il faut éviter |
|
||||
|---------|---------------------|----------------------|
|
||||
| Sport (course, escalade) | Montre discipline et dépassement | Détails trop personnels |
|
||||
| Musique/instrument | Créativité, pratique régulière | Photos de soirées |
|
||||
| Voyages | Ouverture d'esprit, curiosité | Lieux trop identifiables |
|
||||
| Lecture/apprentissage | Curiosité intellectuelle | Opinions politiques |
|
||||
| Projets DIY/maker | Créativité technique | - |
|
||||
| Photographie | Sens esthétique | Photos personnelles |
|
||||
|
||||
### Images des Hobbies
|
||||
|
||||
```
|
||||
assets/img/hobbies/
|
||||
├── passion-1.webp # 400x225 (16:9)
|
||||
├── passion-2.webp
|
||||
├── passion-3.webp
|
||||
└── open-source.webp # Ou utiliser une icône
|
||||
```
|
||||
|
||||
### Équilibre Pro/Perso
|
||||
|
||||
Le contenu doit :
|
||||
- ✅ Humaniser le développeur
|
||||
- ✅ Montrer des qualités transférables (discipline, créativité)
|
||||
- ✅ Rester professionnel
|
||||
- ❌ Ne pas inclure de vie privée
|
||||
- ❌ Ne pas inclure d'opinions controversées
|
||||
- ❌ Ne pas en dire trop (3-4 passions max)
|
||||
|
||||
## Testing
|
||||
|
||||
- [] La section "En dehors du code" est présente
|
||||
- [] 3 passions sont affichées avec visuels (placeholders SVG)
|
||||
- [] Le contenu reste professionnel
|
||||
- [] Les placeholders sont légers (pas de lazy loading nécessaire)
|
||||
- [] Le lien GitHub fonctionne (target="_blank", rel="noopener")
|
||||
- [] La section est responsive (1→2→3 colonnes)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `pages/about.php` | Modified | Ajout section "En dehors du code" |
|
||||
|
||||
### Completion Notes
|
||||
- 3 cartes passion avec placeholders SVG et gradients colorés
|
||||
- Passion 1 : Musique (icône note, gradient purple/pink)
|
||||
- Passion 2 : Jeux vidéo (icône ticket, gradient green/cyan)
|
||||
- Passion 3 : Projets Open Source (icône code, gradient primary)
|
||||
- Lien GitHub vers https://github.com/skycel
|
||||
- Chaque carte a un effet hover sur le groupe
|
||||
- Design cohérent avec les cartes du reste du site
|
||||
- Ton professionnel : chaque passion est reliée à des compétences transférables
|
||||
- Note: Les placeholders peuvent être remplacés par des vraies photos dans `/assets/img/hobbies/`
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||
281
docs/stories/4.5.section-temoignages.md
Normal file
281
docs/stories/4.5.section-temoignages.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Story 4.5: Section Témoignages (JSON Dynamique)
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** lire des témoignages de clients ou employeurs,
|
||||
**so that** j'ai une preuve sociale de la qualité du travail.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Le fichier `data/testimonials.json` stocke tous les témoignages
|
||||
2. La structure JSON supporte : id, quote, author_name, author_role, author_company, author_photo, project_slug (optionnel), date, featured (booléen)
|
||||
3. Une fonction PHP `getTestimonials()` lit et décode le JSON
|
||||
4. La section témoignages affiche dynamiquement les entrées du JSON
|
||||
5. Chaque témoignage affiche : citation, nom, rôle/entreprise, photo (si disponible)
|
||||
6. Si un témoignage est lié à un projet (`project_slug`), un lien vers le projet est affiché
|
||||
7. Les témoignages `featured: true` peuvent être affichés sur la page d'accueil
|
||||
8. Si le JSON est vide ou le fichier absent, la section affiche "Témoignages à venir" ou est masquée
|
||||
9. Le design utilise des guillemets ou un style "citation" reconnaissable
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer le fichier testimonials.json** (AC: 1, 2)
|
||||
- [] Créer `data/testimonials.json`
|
||||
- [] Définir la structure complète
|
||||
- [] Ajouter 3 témoignages de test
|
||||
|
||||
- [] **Task 2 : Créer les fonctions PHP** (AC: 3)
|
||||
- [] `getTestimonials()` - tous les témoignages
|
||||
- [] `getFeaturedTestimonials()` - témoignages mis en avant
|
||||
- [] `getTestimonialByProject($slug)` - témoignage lié à un projet
|
||||
|
||||
- [] **Task 3 : Créer le template testimonial.php** (AC: 5, 9)
|
||||
- [] Style citation avec guillemets SVG
|
||||
- [] Photo de l'auteur (optionnelle, sinon initiale)
|
||||
- [] Nom, rôle, entreprise
|
||||
|
||||
- [] **Task 4 : Ajouter la section dans about.php** (AC: 4, 8)
|
||||
- [] Grille de témoignages (1→2→3 colonnes)
|
||||
- [] Gestion du cas vide (section masquée)
|
||||
|
||||
- [] **Task 5 : Lien vers le projet** (AC: 6)
|
||||
- [] Si project_slug existe, afficher le lien
|
||||
- [] "Voir le projet →" avec icône
|
||||
|
||||
- [] **Task 6 : Témoignages sur l'accueil** (AC: 7)
|
||||
- [] Afficher 2 témoignages featured sur home.php
|
||||
- [] Lien "Voir tous les témoignages"
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Structure data/testimonials.json
|
||||
|
||||
```json
|
||||
{
|
||||
"testimonials": [
|
||||
{
|
||||
"id": 1,
|
||||
"quote": "Excellent travail ! Le site a été livré dans les délais avec une qualité irréprochable. Communication fluide tout au long du projet.",
|
||||
"author_name": "Marie Dupont",
|
||||
"author_role": "Directrice Marketing",
|
||||
"author_company": "Entreprise XYZ",
|
||||
"author_photo": "marie-dupont.webp",
|
||||
"project_slug": "ecommerce-xyz",
|
||||
"date": "2025-06-15",
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"quote": "Un développeur rigoureux et créatif. Il a su comprendre nos besoins et proposer des solutions adaptées.",
|
||||
"author_name": "Jean Martin",
|
||||
"author_role": "CEO",
|
||||
"author_company": "Startup ABC",
|
||||
"author_photo": null,
|
||||
"project_slug": "app-gestion",
|
||||
"date": "2025-03-20",
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"quote": "Travail soigné et professionnel. Je recommande vivement.",
|
||||
"author_name": "Sophie Leroy",
|
||||
"author_role": "Gérante",
|
||||
"author_company": "Restaurant Le Bon Goût",
|
||||
"author_photo": null,
|
||||
"project_slug": null,
|
||||
"date": "2024-11-10",
|
||||
"featured": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Fonctions PHP (includes/functions.php)
|
||||
|
||||
```php
|
||||
/**
|
||||
* Récupère tous les témoignages
|
||||
*/
|
||||
function getTestimonials(): array
|
||||
{
|
||||
$data = loadJsonData('testimonials.json');
|
||||
return $data['testimonials'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les témoignages mis en avant
|
||||
*/
|
||||
function getFeaturedTestimonials(): array
|
||||
{
|
||||
return array_filter(getTestimonials(), fn($t) => $t['featured'] === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le témoignage lié à un projet
|
||||
*/
|
||||
function getTestimonialByProject(string $projectSlug): ?array
|
||||
{
|
||||
$testimonials = getTestimonials();
|
||||
foreach ($testimonials as $testimonial) {
|
||||
if (($testimonial['project_slug'] ?? '') === $projectSlug) {
|
||||
return $testimonial;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Template templates/testimonial.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Composant témoignage
|
||||
* @param array $testimonial Données du témoignage
|
||||
* @param bool $showProjectLink Afficher le lien vers le projet
|
||||
*/
|
||||
|
||||
$quote = $testimonial['quote'] ?? '';
|
||||
$authorName = $testimonial['author_name'] ?? 'Anonyme';
|
||||
$authorRole = $testimonial['author_role'] ?? '';
|
||||
$authorCompany = $testimonial['author_company'] ?? '';
|
||||
$authorPhoto = $testimonial['author_photo'] ?? null;
|
||||
$projectSlug = $testimonial['project_slug'] ?? null;
|
||||
$showProjectLink = $showProjectLink ?? true;
|
||||
?>
|
||||
|
||||
<blockquote class="testimonial">
|
||||
<!-- Guillemets décoratifs -->
|
||||
<svg class="w-8 h-8 text-primary/30 mb-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Citation -->
|
||||
<p class="text-text-primary text-lg leading-relaxed mb-6 italic">
|
||||
"<?= htmlspecialchars($quote) ?>"
|
||||
</p>
|
||||
|
||||
<!-- Auteur -->
|
||||
<footer class="flex items-center gap-4">
|
||||
<?php if ($authorPhoto): ?>
|
||||
<img
|
||||
src="/assets/img/testimonials/<?= htmlspecialchars($authorPhoto) ?>"
|
||||
alt="<?= htmlspecialchars($authorName) ?>"
|
||||
class="w-12 h-12 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
<?php else: ?>
|
||||
<div class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<span class="text-primary font-semibold text-lg">
|
||||
<?= strtoupper(substr($authorName, 0, 1)) ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div>
|
||||
<p class="font-semibold text-text-primary"><?= htmlspecialchars($authorName) ?></p>
|
||||
<p class="text-sm text-text-muted">
|
||||
<?= htmlspecialchars($authorRole) ?>
|
||||
<?php if ($authorCompany): ?>
|
||||
<span class="text-text-muted">—</span> <?= htmlspecialchars($authorCompany) ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Lien vers le projet -->
|
||||
<?php if ($showProjectLink && $projectSlug): ?>
|
||||
<a href="/projet/<?= htmlspecialchars($projectSlug) ?>" class="inline-flex items-center gap-1 text-primary text-sm mt-4 hover:underline">
|
||||
Voir le projet
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</blockquote>
|
||||
```
|
||||
|
||||
### Section dans pages/about.php
|
||||
|
||||
```php
|
||||
<!-- Témoignages -->
|
||||
<?php $testimonials = getTestimonials(); ?>
|
||||
<?php if (!empty($testimonials)): ?>
|
||||
<section class="section bg-surface">
|
||||
<div class="container-content">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Ce Qu'ils Disent</h2>
|
||||
<p class="section-subtitle">
|
||||
Retours de clients et collaborateurs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<?php foreach ($testimonials as $testimonial): ?>
|
||||
<?php include_template('testimonial', ['testimonial' => $testimonial]); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
```
|
||||
|
||||
### Dossier des Photos
|
||||
|
||||
```
|
||||
assets/img/testimonials/
|
||||
├── marie-dupont.webp # 96x96 carré
|
||||
├── jean-martin.webp
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- [] Le fichier JSON est valide (3 témoignages)
|
||||
- [] `getTestimonials()` retourne les témoignages (3)
|
||||
- [] `getFeaturedTestimonials()` filtre correctement (2 featured)
|
||||
- [] La section s'affiche sur la page À propos
|
||||
- [] Les guillemets SVG et style citation sont visibles
|
||||
- [] Les initiales s'affichent si pas de photo
|
||||
- [] Le lien vers le projet fonctionne
|
||||
- [] Cas vide : section masquée (if !empty)
|
||||
- [] 2 témoignages featured affichés sur la home
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `data/testimonials.json` | Created | 3 témoignages de test |
|
||||
| `includes/functions.php` | Modified | Fonctions getTestimonials(), getFeaturedTestimonials(), getTestimonialByProject() |
|
||||
| `templates/testimonial.php` | Created | Template avec guillemets, auteur, lien projet |
|
||||
| `pages/about.php` | Modified | Section "Ce Qu'ils Disent" |
|
||||
| `pages/home.php` | Modified | 2 témoignages featured |
|
||||
|
||||
### Completion Notes
|
||||
- Structure JSON complète : id, quote, author_name, author_role, author_company, author_photo, project_slug, date, featured
|
||||
- 3 fonctions PHP pour accéder aux témoignages
|
||||
- Template réutilisable avec guillemets SVG décoratifs
|
||||
- Photo optionnelle : si absente, affiche l'initiale sur fond coloré
|
||||
- Lien vers projet optionnel (paramètre showProjectLink)
|
||||
- Section masquée si JSON vide
|
||||
- 2 témoignages featured sur la home avec lien "Voir tous"
|
||||
- Note: Les photos peuvent être ajoutées dans `/assets/img/testimonials/`
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||
326
docs/stories/5.1.formulaire-structure-html5.md
Normal file
326
docs/stories/5.1.formulaire-structure-html5.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# Story 5.1: Structure du Formulaire et Validation HTML5
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** un formulaire de contact clair avec des champs bien identifiés,
|
||||
**so that** je sais exactement quelles informations fournir.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. `/contact` affiche le formulaire avec les champs : Nom (requis), Prénom (requis), Email (requis), Entreprise (optionnel), Catégorie (dropdown requis), Objet (requis), Message (textarea requis)
|
||||
2. Le champ email utilise `type="email"` pour validation native
|
||||
3. Le dropdown Catégorie propose : "Je souhaite parler de mon projet", "Je souhaite vous proposer un poste", "Autre"
|
||||
4. Les champs requis sont marqués visuellement (astérisque ou indication)
|
||||
5. La validation HTML5 native est activée (required, type="email", maxlength)
|
||||
6. Les labels sont explicites et associés aux champs (accessibilité)
|
||||
7. Le formulaire est responsive et utilisable sur mobile
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer la page contact.php** (AC: 1)
|
||||
- [] Mettre à jour `pages/contact.php`
|
||||
- [] Inclure header, navbar, footer
|
||||
- [] Route `/contact` déjà configurée (Story 3.2)
|
||||
|
||||
- [] **Task 2 : Créer la structure du formulaire** (AC: 1, 6)
|
||||
- [] Balise `<form>` avec method POST et action
|
||||
- [] Champ Nom avec label associé (for/id)
|
||||
- [] Champ Prénom avec label associé
|
||||
- [] Champ Email avec label associé
|
||||
- [] Champ Entreprise (optionnel)
|
||||
- [] Dropdown Catégorie
|
||||
- [] Champ Objet
|
||||
- [] Textarea Message
|
||||
|
||||
- [] **Task 3 : Configurer les attributs HTML5** (AC: 2, 5)
|
||||
- [] `type="email"` sur le champ email
|
||||
- [] `required` sur les champs obligatoires
|
||||
- [] `maxlength` appropriés (100, 255, 200, 5000)
|
||||
- [] `placeholder` pour guider la saisie
|
||||
- [] `autocomplete` pour les champs standards
|
||||
|
||||
- [] **Task 4 : Marquer les champs requis** (AC: 4)
|
||||
- [] Astérisque visuel sur les labels (span.text-primary)
|
||||
- [] Indication "(optionnel)" sur entreprise
|
||||
|
||||
- [] **Task 5 : Configurer le dropdown** (AC: 3)
|
||||
- [] Option par défaut "Sélectionnez une catégorie..."
|
||||
- [] 3 options : projet, poste, autre
|
||||
- [] Attribut `required`
|
||||
|
||||
- [] **Task 6 : Rendre responsive** (AC: 7)
|
||||
- [] Grille sm:grid-cols-2 pour Nom/Prénom et Email/Entreprise
|
||||
- [] Champs empilés sur mobile (grid-cols-1)
|
||||
- [] Boutons flex-col sur mobile, flex-row sur desktop
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Page pages/contact.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Page Contact
|
||||
*/
|
||||
|
||||
$pageTitle = 'Contact';
|
||||
$pageDescription = 'Contactez-moi pour discuter de votre projet web ou d\'une opportunité professionnelle.';
|
||||
$currentPage = 'contact';
|
||||
|
||||
// Générer le token CSRF
|
||||
$csrfToken = generateCsrfToken();
|
||||
|
||||
include_template('header', compact('pageTitle', 'pageDescription'));
|
||||
include_template('navbar', compact('currentPage'));
|
||||
?>
|
||||
|
||||
<main>
|
||||
<section class="section">
|
||||
<div class="container-content">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-display mb-4">Me Contacter</h1>
|
||||
<p class="text-xl text-text-secondary">
|
||||
Une question, un projet ? Parlons-en !
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire -->
|
||||
<form
|
||||
id="contact-form"
|
||||
method="POST"
|
||||
action="/api/contact.php"
|
||||
class="space-y-6"
|
||||
novalidate
|
||||
>
|
||||
<!-- Token CSRF -->
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
|
||||
|
||||
<!-- Nom & Prénom (côte à côte sur desktop) -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<!-- Nom -->
|
||||
<div>
|
||||
<label for="nom" class="label label-required">Nom</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nom"
|
||||
name="nom"
|
||||
class="input"
|
||||
required
|
||||
maxlength="100"
|
||||
autocomplete="family-name"
|
||||
placeholder="Dupont"
|
||||
>
|
||||
<p class="error-message hidden" data-error="nom"></p>
|
||||
</div>
|
||||
|
||||
<!-- Prénom -->
|
||||
<div>
|
||||
<label for="prenom" class="label label-required">Prénom</label>
|
||||
<input
|
||||
type="text"
|
||||
id="prenom"
|
||||
name="prenom"
|
||||
class="input"
|
||||
required
|
||||
maxlength="100"
|
||||
autocomplete="given-name"
|
||||
placeholder="Marie"
|
||||
>
|
||||
<p class="error-message hidden" data-error="prenom"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email & Entreprise -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label for="email" class="label label-required">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
class="input"
|
||||
required
|
||||
maxlength="255"
|
||||
autocomplete="email"
|
||||
placeholder="marie.dupont@example.com"
|
||||
>
|
||||
<p class="error-message hidden" data-error="email"></p>
|
||||
</div>
|
||||
|
||||
<!-- Entreprise (optionnel) -->
|
||||
<div>
|
||||
<label for="entreprise" class="label">Entreprise <span class="text-text-muted">(optionnel)</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="entreprise"
|
||||
name="entreprise"
|
||||
class="input"
|
||||
maxlength="200"
|
||||
autocomplete="organization"
|
||||
placeholder="Nom de votre entreprise"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Catégorie -->
|
||||
<div>
|
||||
<label for="categorie" class="label label-required">Catégorie</label>
|
||||
<select
|
||||
id="categorie"
|
||||
name="categorie"
|
||||
class="input"
|
||||
required
|
||||
>
|
||||
<option value="" disabled selected>Sélectionnez une catégorie...</option>
|
||||
<option value="projet">Je souhaite parler de mon projet</option>
|
||||
<option value="poste">Je souhaite vous proposer un poste</option>
|
||||
<option value="autre">Autre</option>
|
||||
</select>
|
||||
<p class="error-message hidden" data-error="categorie"></p>
|
||||
</div>
|
||||
|
||||
<!-- Objet -->
|
||||
<div>
|
||||
<label for="objet" class="label label-required">Objet</label>
|
||||
<input
|
||||
type="text"
|
||||
id="objet"
|
||||
name="objet"
|
||||
class="input"
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="Résumez votre demande en quelques mots"
|
||||
>
|
||||
<p class="error-message hidden" data-error="objet"></p>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<div>
|
||||
<label for="message" class="label label-required">Message</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
class="textarea"
|
||||
required
|
||||
maxlength="5000"
|
||||
rows="6"
|
||||
placeholder="Décrivez votre projet ou votre demande..."
|
||||
></textarea>
|
||||
<p class="error-message hidden" data-error="message"></p>
|
||||
<p class="text-xs text-text-muted mt-1">
|
||||
<span id="message-count">0</span> / 5000 caractères
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
<button type="submit" id="submit-btn" class="btn-primary flex-1 justify-center">
|
||||
<span id="submit-text">Envoyer le message</span>
|
||||
<span id="submit-loading" class="hidden">
|
||||
<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 12h4z"></path>
|
||||
</svg>
|
||||
Envoi en cours...
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" id="clear-form-btn" class="btn-ghost">
|
||||
Effacer le formulaire
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Message de succès (caché par défaut) -->
|
||||
<div id="success-message" class="hidden mt-8 p-6 bg-success/10 border border-success/30 rounded-lg text-center">
|
||||
<svg class="w-12 h-12 text-success mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-text-primary mb-2">Message envoyé !</h3>
|
||||
<p class="text-text-secondary">
|
||||
Merci pour votre message. Je vous répondrai dans les meilleurs délais.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Message d'erreur global (caché par défaut) -->
|
||||
<div id="error-message" class="hidden mt-8 p-6 bg-error/10 border border-error/30 rounded-lg">
|
||||
<p class="text-error" id="error-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<?php include_template('footer'); ?>
|
||||
|
||||
<!-- Script du formulaire -->
|
||||
<script src="/assets/js/contact-form.js" defer></script>
|
||||
```
|
||||
|
||||
### Attributs des Champs
|
||||
|
||||
| Champ | Type | Required | Maxlength | Autocomplete |
|
||||
|-------|------|----------|-----------|--------------|
|
||||
| nom | text | Oui | 100 | family-name |
|
||||
| prenom | text | Oui | 100 | given-name |
|
||||
| email | email | Oui | 255 | email |
|
||||
| entreprise | text | Non | 200 | organization |
|
||||
| categorie | select | Oui | - | - |
|
||||
| objet | text | Oui | 200 | - |
|
||||
| message | textarea | Oui | 5000 | - |
|
||||
|
||||
### Responsive
|
||||
|
||||
| Breakpoint | Layout |
|
||||
|------------|--------|
|
||||
| Mobile | Tous les champs empilés (1 colonne) |
|
||||
| Desktop (sm:) | Nom/Prénom côte à côte, Email/Entreprise côte à côte |
|
||||
|
||||
## Testing
|
||||
|
||||
- [] Tous les champs sont présents (7 champs)
|
||||
- [] Les labels sont associés aux inputs (for/id)
|
||||
- [] Les champs requis ont l'astérisque rouge
|
||||
- [] La validation HTML5 fonctionne (required)
|
||||
- [] Le type="email" valide le format
|
||||
- [] Le dropdown a les 3 options + placeholder
|
||||
- [] Le formulaire est responsive (grilles adaptatives)
|
||||
- [] Le compteur de caractères fonctionne (JS inline)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `includes/functions.php` | Modified | Ajout generateCsrfToken() et verifyCsrfToken() |
|
||||
| `pages/contact.php` | Modified | Formulaire complet avec 7 champs |
|
||||
|
||||
### Completion Notes
|
||||
- Formulaire avec 7 champs : nom, prénom, email, entreprise, catégorie, objet, message
|
||||
- Token CSRF généré et stocké en session
|
||||
- Validation HTML5 : required, type="email", maxlength
|
||||
- Autocomplete sur les champs standards (family-name, given-name, email, organization)
|
||||
- Layout responsive : 2 colonnes sur desktop, 1 sur mobile
|
||||
- Compteur de caractères en temps réel pour le message
|
||||
- Placeholders de messages succès/erreur (pour Story 5.6)
|
||||
- Spinner de chargement préparé (pour Story 5.2/5.5)
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||
351
docs/stories/5.2.validation-javascript.md
Normal file
351
docs/stories/5.2.validation-javascript.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# Story 5.2: Validation JavaScript Côté Client
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** être informé immédiatement si je fais une erreur de saisie,
|
||||
**so that** je corrige avant d'envoyer et j'évite les allers-retours.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. La validation JavaScript s'exécute à la soumission ET à la perte de focus (blur)
|
||||
2. Les messages d'erreur sont affichés sous chaque champ concerné
|
||||
3. Les champs en erreur sont visuellement distingués (bordure rouge, icône)
|
||||
4. Le message d'erreur est clair et indique comment corriger
|
||||
5. Le bouton d'envoi est désactivé tant que le formulaire contient des erreurs
|
||||
6. La validation est en JavaScript vanilla (pas de bibliothèque)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer le validateur de formulaire** (AC: 6)
|
||||
- [] Créer `assets/js/contact-form.js`
|
||||
- [] Classe ou objet `FormValidator`
|
||||
- [] Méthodes de validation par type de champ
|
||||
|
||||
- [] **Task 2 : Implémenter la validation au blur** (AC: 1)
|
||||
- [] Écouter l'événement `blur` sur chaque champ
|
||||
- [] Valider le champ concerné
|
||||
- [] Afficher/masquer l'erreur
|
||||
|
||||
- [] **Task 3 : Implémenter la validation à la soumission** (AC: 1)
|
||||
- [] Écouter l'événement `submit`
|
||||
- [] Valider tous les champs
|
||||
- [] Empêcher l'envoi si erreurs
|
||||
|
||||
- [] **Task 4 : Afficher les erreurs** (AC: 2, 3, 4)
|
||||
- [] Message sous le champ (data-error)
|
||||
- [] Bordure rouge sur le champ (classes Tailwind)
|
||||
- [] Messages clairs et actionnables
|
||||
|
||||
- [] **Task 5 : Gérer l'état du bouton** (AC: 5)
|
||||
- [] Désactiver si erreurs
|
||||
- [] Réactiver quand tout est valide
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Structure assets/js/contact-form.js
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Validation du formulaire de contact
|
||||
* JavaScript vanilla - pas de dépendances
|
||||
*/
|
||||
|
||||
class FormValidator {
|
||||
constructor(formId) {
|
||||
this.form = document.getElementById(formId);
|
||||
if (!this.form) return;
|
||||
|
||||
this.submitBtn = document.getElementById('submit-btn');
|
||||
this.fields = {};
|
||||
this.errors = {};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Définir les règles de validation
|
||||
this.rules = {
|
||||
nom: {
|
||||
required: true,
|
||||
minLength: 2,
|
||||
maxLength: 100,
|
||||
message: 'Veuillez entrer votre nom (2 caractères minimum)'
|
||||
},
|
||||
prenom: {
|
||||
required: true,
|
||||
minLength: 2,
|
||||
maxLength: 100,
|
||||
message: 'Veuillez entrer votre prénom (2 caractères minimum)'
|
||||
},
|
||||
email: {
|
||||
required: true,
|
||||
email: true,
|
||||
message: 'Veuillez entrer une adresse email valide'
|
||||
},
|
||||
categorie: {
|
||||
required: true,
|
||||
message: 'Veuillez sélectionner une catégorie'
|
||||
},
|
||||
objet: {
|
||||
required: true,
|
||||
minLength: 5,
|
||||
maxLength: 200,
|
||||
message: 'Veuillez entrer un objet (5 caractères minimum)'
|
||||
},
|
||||
message: {
|
||||
required: true,
|
||||
minLength: 20,
|
||||
maxLength: 5000,
|
||||
message: 'Veuillez entrer votre message (20 caractères minimum)'
|
||||
}
|
||||
};
|
||||
|
||||
// Récupérer les champs
|
||||
Object.keys(this.rules).forEach(fieldName => {
|
||||
this.fields[fieldName] = this.form.querySelector(`[name="${fieldName}"]`);
|
||||
});
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Validation au blur
|
||||
Object.keys(this.fields).forEach(fieldName => {
|
||||
const field = this.fields[fieldName];
|
||||
if (field) {
|
||||
field.addEventListener('blur', () => this.validateField(fieldName));
|
||||
field.addEventListener('input', () => this.clearError(fieldName));
|
||||
}
|
||||
});
|
||||
|
||||
// Validation à la soumission
|
||||
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||
|
||||
// Compteur de caractères pour le message
|
||||
const messageField = this.fields.message;
|
||||
if (messageField) {
|
||||
messageField.addEventListener('input', () => this.updateCharCount());
|
||||
}
|
||||
}
|
||||
|
||||
validateField(fieldName) {
|
||||
const field = this.fields[fieldName];
|
||||
const rule = this.rules[fieldName];
|
||||
|
||||
if (!field || !rule) return true;
|
||||
|
||||
const value = field.value.trim();
|
||||
let isValid = true;
|
||||
let errorMessage = '';
|
||||
|
||||
// Required
|
||||
if (rule.required && !value) {
|
||||
isValid = false;
|
||||
errorMessage = rule.message;
|
||||
}
|
||||
|
||||
// Min length
|
||||
if (isValid && rule.minLength && value.length < rule.minLength) {
|
||||
isValid = false;
|
||||
errorMessage = rule.message;
|
||||
}
|
||||
|
||||
// Max length
|
||||
if (isValid && rule.maxLength && value.length > rule.maxLength) {
|
||||
isValid = false;
|
||||
errorMessage = `Maximum ${rule.maxLength} caractères`;
|
||||
}
|
||||
|
||||
// Email format
|
||||
if (isValid && rule.email && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
isValid = false;
|
||||
errorMessage = rule.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Afficher ou masquer l'erreur
|
||||
if (isValid) {
|
||||
this.clearError(fieldName);
|
||||
} else {
|
||||
this.showError(fieldName, errorMessage);
|
||||
}
|
||||
|
||||
this.errors[fieldName] = !isValid;
|
||||
this.updateSubmitButton();
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
validateAll() {
|
||||
let allValid = true;
|
||||
|
||||
Object.keys(this.rules).forEach(fieldName => {
|
||||
if (!this.validateField(fieldName)) {
|
||||
allValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return allValid;
|
||||
}
|
||||
|
||||
showError(fieldName, message) {
|
||||
const field = this.fields[fieldName];
|
||||
const errorEl = this.form.querySelector(`[data-error="${fieldName}"]`);
|
||||
|
||||
if (field) {
|
||||
field.classList.add('input-error');
|
||||
field.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
clearError(fieldName) {
|
||||
const field = this.fields[fieldName];
|
||||
const errorEl = this.form.querySelector(`[data-error="${fieldName}"]`);
|
||||
|
||||
if (field) {
|
||||
field.classList.remove('input-error');
|
||||
field.removeAttribute('aria-invalid');
|
||||
}
|
||||
|
||||
if (errorEl) {
|
||||
errorEl.textContent = '';
|
||||
errorEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
this.errors[fieldName] = false;
|
||||
this.updateSubmitButton();
|
||||
}
|
||||
|
||||
updateSubmitButton() {
|
||||
const hasErrors = Object.values(this.errors).some(err => err);
|
||||
|
||||
if (this.submitBtn) {
|
||||
this.submitBtn.disabled = hasErrors;
|
||||
}
|
||||
}
|
||||
|
||||
updateCharCount() {
|
||||
const messageField = this.fields.message;
|
||||
const countEl = document.getElementById('message-count');
|
||||
|
||||
if (messageField && countEl) {
|
||||
countEl.textContent = messageField.value.length;
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.validateAll()) {
|
||||
// Focus sur le premier champ en erreur
|
||||
const firstError = Object.keys(this.errors).find(key => this.errors[key]);
|
||||
if (firstError && this.fields[firstError]) {
|
||||
this.fields[firstError].focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Si valide, déclencher l'envoi (géré par une autre partie du code)
|
||||
this.form.dispatchEvent(new CustomEvent('validSubmit'));
|
||||
}
|
||||
|
||||
getFormData() {
|
||||
const formData = {};
|
||||
Object.keys(this.fields).forEach(fieldName => {
|
||||
if (this.fields[fieldName]) {
|
||||
formData[fieldName] = this.fields[fieldName].value.trim();
|
||||
}
|
||||
});
|
||||
// Ajouter le champ entreprise (optionnel)
|
||||
const entreprise = this.form.querySelector('[name="entreprise"]');
|
||||
if (entreprise) {
|
||||
formData.entreprise = entreprise.value.trim();
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialisation
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.contactFormValidator = new FormValidator('contact-form');
|
||||
});
|
||||
```
|
||||
|
||||
### Messages d'Erreur
|
||||
|
||||
| Champ | Message |
|
||||
|-------|---------|
|
||||
| nom | Veuillez entrer votre nom (2 caractères minimum) |
|
||||
| prenom | Veuillez entrer votre prénom (2 caractères minimum) |
|
||||
| email | Veuillez entrer une adresse email valide |
|
||||
| categorie | Veuillez sélectionner une catégorie |
|
||||
| objet | Veuillez entrer un objet (5 caractères minimum) |
|
||||
| message | Veuillez entrer votre message (20 caractères minimum) |
|
||||
|
||||
### Règles de Validation
|
||||
|
||||
| Champ | Required | Min | Max | Format |
|
||||
|-------|----------|-----|-----|--------|
|
||||
| nom | Oui | 2 | 100 | - |
|
||||
| prenom | Oui | 2 | 100 | - |
|
||||
| email | Oui | - | 255 | email |
|
||||
| entreprise | Non | - | 200 | - |
|
||||
| categorie | Oui | - | - | - |
|
||||
| objet | Oui | 5 | 200 | - |
|
||||
| message | Oui | 20 | 5000 | - |
|
||||
|
||||
## Testing
|
||||
|
||||
- [] La validation se déclenche au blur
|
||||
- [] La validation se déclenche à la soumission
|
||||
- [] Les messages d'erreur s'affichent sous les champs
|
||||
- [] Les champs en erreur ont une bordure rouge
|
||||
- [] Le bouton est désactivé si erreurs
|
||||
- [] Le compteur de caractères fonctionne
|
||||
- [] Le focus va au premier champ en erreur
|
||||
- [] Email invalide est détecté
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `assets/js/contact-form.js` | Created | Classe FormValidator avec validation complète |
|
||||
| `pages/contact.php` | Modified | Lien vers le script JS, classes Tailwind pour erreurs |
|
||||
|
||||
### Completion Notes
|
||||
- Classe FormValidator en JavaScript vanilla (pas de dépendances)
|
||||
- Validation au blur et à la soumission
|
||||
- Messages d'erreur sous chaque champ avec data-error
|
||||
- Bordure rouge sur les champs invalides (classes Tailwind)
|
||||
- Bouton submit désactivé si erreurs (updateSubmitButton)
|
||||
- Compteur de caractères en temps réel
|
||||
- Focus automatique sur le premier champ en erreur
|
||||
- Validation email avec regex
|
||||
- Événement 'validSubmit' dispatché quand tout est valide
|
||||
- Gestion du reset du formulaire
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-24 | 1.0 | Implémentation complète | James (Dev) |
|
||||
300
docs/stories/5.3.persistance-localstorage.md
Normal file
300
docs/stories/5.3.persistance-localstorage.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# Story 5.3: Persistance des Données avec localStorage
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** que mes données soient sauvegardées si je quitte la page,
|
||||
**so that** je ne ressaisisse pas tout si je reviens plus tard.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Chaque modification d'un champ sauvegarde automatiquement dans localStorage
|
||||
2. Au chargement de la page, les champs sont pré-remplis avec les données sauvegardées
|
||||
3. Le localStorage est vidé après un envoi réussi du formulaire
|
||||
4. Un bouton "Effacer le formulaire" permet de réinitialiser manuellement
|
||||
5. Les données sensibles (si ajoutées plus tard) ne sont PAS stockées
|
||||
6. Le stockage utilise une clé unique (ex: `portfolio_contact_form`)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer le gestionnaire de stockage** (AC: 6)
|
||||
- [] Clé unique `portfolio_contact_form`
|
||||
- [] Méthodes save, load, clear
|
||||
- [] Gestion des erreurs (localStorage indisponible)
|
||||
|
||||
- [] **Task 2 : Sauvegarder automatiquement** (AC: 1)
|
||||
- [] Écouter l'événement `input` sur chaque champ
|
||||
- [] Debounce pour éviter trop d'écritures (500ms)
|
||||
- [] Sauvegarder l'état complet
|
||||
|
||||
- [] **Task 3 : Restaurer au chargement** (AC: 2)
|
||||
- [] Charger les données au DOMContentLoaded
|
||||
- [] Pré-remplir chaque champ
|
||||
- [] Mettre à jour le compteur de caractères
|
||||
|
||||
- [] **Task 4 : Vider après envoi réussi** (AC: 3)
|
||||
- [] Appeler clear() après succès (événement formSuccess)
|
||||
- [] Réinitialiser le formulaire
|
||||
|
||||
- [] **Task 5 : Bouton "Effacer"** (AC: 4)
|
||||
- [] Écouter le clic sur le bouton (id="clear-form-btn")
|
||||
- [] Vider le localStorage
|
||||
- [] Réinitialiser le formulaire
|
||||
- [] Confirmation avec confirm()
|
||||
|
||||
- [] **Task 6 : Exclure les données sensibles** (AC: 5)
|
||||
- [] Ne pas stocker csrf_token, password, recaptcha_token
|
||||
- [] Documenté dans EXCLUDED_FIELDS
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Gestionnaire de Stockage (state.js)
|
||||
|
||||
```javascript
|
||||
// assets/js/state.js
|
||||
|
||||
/**
|
||||
* Gestionnaire d'état pour le localStorage
|
||||
*/
|
||||
const AppState = {
|
||||
STORAGE_KEY: 'portfolio_contact_form',
|
||||
|
||||
// Champs à ne jamais stocker
|
||||
EXCLUDED_FIELDS: ['csrf_token', 'password', 'recaptcha_token'],
|
||||
|
||||
/**
|
||||
* Vérifie si localStorage est disponible
|
||||
*/
|
||||
isStorageAvailable() {
|
||||
try {
|
||||
const test = '__storage_test__';
|
||||
localStorage.setItem(test, test);
|
||||
localStorage.removeItem(test);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sauvegarde les données du formulaire
|
||||
*/
|
||||
saveFormData(data) {
|
||||
if (!this.isStorageAvailable()) return;
|
||||
|
||||
try {
|
||||
// Filtrer les champs exclus
|
||||
const filteredData = {};
|
||||
Object.keys(data).forEach(key => {
|
||||
if (!this.EXCLUDED_FIELDS.includes(key)) {
|
||||
filteredData[key] = data[key];
|
||||
}
|
||||
});
|
||||
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filteredData));
|
||||
} catch (e) {
|
||||
console.warn('Impossible de sauvegarder dans localStorage:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Charge les données sauvegardées
|
||||
*/
|
||||
getFormData() {
|
||||
if (!this.isStorageAvailable()) return null;
|
||||
|
||||
try {
|
||||
const data = localStorage.getItem(this.STORAGE_KEY);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (e) {
|
||||
console.warn('Impossible de charger depuis localStorage:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Efface les données sauvegardées
|
||||
*/
|
||||
clearFormData() {
|
||||
if (!this.isStorageAvailable()) return;
|
||||
|
||||
try {
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
} catch (e) {
|
||||
// Silencieux
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Intégration dans contact-form.js
|
||||
|
||||
```javascript
|
||||
// Ajouter dans la classe FormValidator ou en complément
|
||||
|
||||
class ContactFormPersistence {
|
||||
constructor(formId) {
|
||||
this.form = document.getElementById(formId);
|
||||
if (!this.form) return;
|
||||
|
||||
this.debounceTimer = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loadSavedData();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Sauvegarder à chaque modification (avec debounce)
|
||||
this.form.addEventListener('input', () => {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => this.saveData(), 500);
|
||||
});
|
||||
|
||||
// Bouton effacer
|
||||
const clearBtn = document.getElementById('clear-form-btn');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => this.clearForm());
|
||||
}
|
||||
|
||||
// Écouter l'envoi réussi
|
||||
this.form.addEventListener('formSuccess', () => {
|
||||
AppState.clearFormData();
|
||||
});
|
||||
}
|
||||
|
||||
saveData() {
|
||||
const formData = new FormData(this.form);
|
||||
const data = {};
|
||||
|
||||
formData.forEach((value, key) => {
|
||||
data[key] = value;
|
||||
});
|
||||
|
||||
AppState.saveFormData(data);
|
||||
}
|
||||
|
||||
loadSavedData() {
|
||||
const savedData = AppState.getFormData();
|
||||
if (!savedData) return;
|
||||
|
||||
Object.keys(savedData).forEach(key => {
|
||||
const field = this.form.querySelector(`[name="${key}"]`);
|
||||
if (field && savedData[key]) {
|
||||
field.value = savedData[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Mettre à jour le compteur de caractères
|
||||
const messageField = this.form.querySelector('[name="message"]');
|
||||
const countEl = document.getElementById('message-count');
|
||||
if (messageField && countEl) {
|
||||
countEl.textContent = messageField.value.length;
|
||||
}
|
||||
}
|
||||
|
||||
clearForm() {
|
||||
// Confirmation optionnelle
|
||||
if (!confirm('Êtes-vous sûr de vouloir effacer le formulaire ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Vider le localStorage
|
||||
AppState.clearFormData();
|
||||
|
||||
// Réinitialiser le formulaire
|
||||
this.form.reset();
|
||||
|
||||
// Réinitialiser le compteur
|
||||
const countEl = document.getElementById('message-count');
|
||||
if (countEl) {
|
||||
countEl.textContent = '0';
|
||||
}
|
||||
|
||||
// Effacer les erreurs visuelles
|
||||
this.form.querySelectorAll('.input-error').forEach(el => {
|
||||
el.classList.remove('input-error');
|
||||
});
|
||||
this.form.querySelectorAll('[data-error]').forEach(el => {
|
||||
el.classList.add('hidden');
|
||||
el.textContent = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialisation
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.contactFormPersistence = new ContactFormPersistence('contact-form');
|
||||
});
|
||||
```
|
||||
|
||||
### Clé de Stockage
|
||||
|
||||
```
|
||||
localStorage key: "portfolio_contact_form"
|
||||
|
||||
Exemple de données stockées:
|
||||
{
|
||||
"nom": "Dupont",
|
||||
"prenom": "Marie",
|
||||
"email": "marie@example.com",
|
||||
"entreprise": "Acme Corp",
|
||||
"categorie": "projet",
|
||||
"objet": "Nouveau site web",
|
||||
"message": "Bonjour, je souhaite..."
|
||||
}
|
||||
```
|
||||
|
||||
### Champs Exclus du Stockage
|
||||
|
||||
- `csrf_token` (sécurité)
|
||||
- `password` (si ajouté)
|
||||
- `recaptcha_token` (généré dynamiquement)
|
||||
|
||||
## Testing
|
||||
|
||||
- [] Les données sont sauvegardées à la saisie
|
||||
- [] Les données sont restaurées au rechargement
|
||||
- [] Le bouton "Effacer" vide le formulaire
|
||||
- [] Le localStorage est vidé après envoi réussi (événement formSuccess)
|
||||
- [] Les champs exclus ne sont pas stockés (EXCLUDED_FIELDS)
|
||||
- [] Pas d'erreur si localStorage indisponible (isStorageAvailable)
|
||||
- [] Le compteur de caractères est mis à jour au chargement
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `assets/js/state.js` | Created | Objet AppState pour gestion localStorage |
|
||||
| `assets/js/contact-form.js` | Modified | Ajout classe ContactFormPersistence |
|
||||
| `pages/contact.php` | Modified | Bouton Effacer + script state.js |
|
||||
|
||||
### Completion Notes
|
||||
- AppState avec clé unique `portfolio_contact_form`
|
||||
- Vérification disponibilité localStorage (try/catch)
|
||||
- Filtrage des champs sensibles (csrf_token, password, recaptcha_token)
|
||||
- Debounce de 500ms sur la sauvegarde
|
||||
- Restauration automatique au chargement de la page
|
||||
- Bouton "Effacer" avec confirmation et reset complet
|
||||
- Événement `formSuccess` pour vider après envoi réussi
|
||||
- Scripts chargés avec defer pour ne pas bloquer le rendu
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-24 | 1.0 | Implémentation complète | James (Dev) |
|
||||
254
docs/stories/5.4.integration-recaptcha.md
Normal file
254
docs/stories/5.4.integration-recaptcha.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Story 5.4: Intégration reCAPTCHA v3
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** propriétaire du site,
|
||||
**I want** une protection anti-spam invisible,
|
||||
**so that** je ne reçois pas de spam sans pénaliser l'expérience utilisateur.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. reCAPTCHA v3 est intégré (invisible, pas de case à cocher)
|
||||
2. Le script reCAPTCHA est chargé depuis Google
|
||||
3. Un token est généré à la soumission du formulaire
|
||||
4. Le token est envoyé avec les données du formulaire au backend PHP
|
||||
5. Les clés API (site key) sont configurables (fichier de config ou .env)
|
||||
6. Si reCAPTCHA échoue à charger, le formulaire reste utilisable (dégradation gracieuse)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Configurer les clés reCAPTCHA** (AC: 5)
|
||||
- [] Ajouter RECAPTCHA_SITE_KEY dans .env
|
||||
- [] Ajouter RECAPTCHA_SECRET_KEY dans .env
|
||||
- [] Créer includes/config.php pour charger .env et définir les constantes
|
||||
|
||||
- [] **Task 2 : Charger le script Google** (AC: 2)
|
||||
- [] Ajouter le script dans templates/footer.php
|
||||
- [] Charger de manière asynchrone (async defer)
|
||||
- [] Exposer la site key via window.RECAPTCHA_SITE_KEY
|
||||
|
||||
- [] **Task 3 : Générer le token** (AC: 3)
|
||||
- [] Créer RecaptchaService dans contact-form.js
|
||||
- [] Méthode getToken() avec grecaptcha.execute()
|
||||
- [] Retourne une Promise avec le token
|
||||
|
||||
- [] **Task 4 : Envoyer le token au backend** (AC: 4)
|
||||
- [] RecaptchaService.getToken() prêt à être utilisé
|
||||
- [] Intégration avec AJAX dans Story 5.5/5.6
|
||||
|
||||
- [] **Task 5 : Dégradation gracieuse** (AC: 6)
|
||||
- [] isAvailable() vérifie si grecaptcha est défini
|
||||
- [] Retourne chaîne vide si indisponible
|
||||
- [] console.warn si non disponible
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Configuration .env
|
||||
|
||||
```env
|
||||
# reCAPTCHA v3
|
||||
RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
|
||||
RECAPTCHA_SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
|
||||
```
|
||||
|
||||
Note : Les clés ci-dessus sont les clés de test de Google (fonctionnent partout mais retournent toujours un score de 0.9).
|
||||
|
||||
### Exposer la Site Key (config.php ou header.php)
|
||||
|
||||
```php
|
||||
<!-- Dans templates/footer.php ou avant </body> -->
|
||||
|
||||
<?php if (defined('RECAPTCHA_SITE_KEY') && RECAPTCHA_SITE_KEY): ?>
|
||||
<script>
|
||||
window.RECAPTCHA_SITE_KEY = '<?= RECAPTCHA_SITE_KEY ?>';
|
||||
</script>
|
||||
<script src="https://www.google.com/recaptcha/api.js?render=<?= RECAPTCHA_SITE_KEY ?>" async defer></script>
|
||||
<?php endif; ?>
|
||||
```
|
||||
|
||||
### Service JavaScript
|
||||
|
||||
```javascript
|
||||
// assets/js/contact-form.js (ajouter)
|
||||
|
||||
/**
|
||||
* Service reCAPTCHA v3
|
||||
*/
|
||||
const RecaptchaService = {
|
||||
siteKey: null,
|
||||
|
||||
init() {
|
||||
this.siteKey = window.RECAPTCHA_SITE_KEY || null;
|
||||
},
|
||||
|
||||
isAvailable() {
|
||||
return this.siteKey && typeof grecaptcha !== 'undefined';
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtient un token reCAPTCHA
|
||||
* @param {string} action - Action à valider (ex: 'contact')
|
||||
* @returns {Promise<string>} - Token ou chaîne vide si indisponible
|
||||
*/
|
||||
async getToken(action = 'contact') {
|
||||
// Dégradation gracieuse si reCAPTCHA non disponible
|
||||
if (!this.isAvailable()) {
|
||||
console.warn('reCAPTCHA non disponible, envoi sans protection');
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
grecaptcha.ready(() => {
|
||||
grecaptcha.execute(this.siteKey, { action })
|
||||
.then(token => resolve(token))
|
||||
.catch(error => {
|
||||
console.error('Erreur reCAPTCHA:', error);
|
||||
resolve(''); // Permettre l'envoi quand même
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initialiser au chargement
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
RecaptchaService.init();
|
||||
});
|
||||
```
|
||||
|
||||
### Intégration dans l'Envoi du Formulaire
|
||||
|
||||
```javascript
|
||||
// Dans contact-form.js
|
||||
|
||||
async function submitForm(formData) {
|
||||
// Obtenir le token reCAPTCHA
|
||||
const recaptchaToken = await RecaptchaService.getToken('contact');
|
||||
|
||||
// Ajouter aux données
|
||||
const payload = {
|
||||
...formData,
|
||||
recaptcha_token: recaptchaToken
|
||||
};
|
||||
|
||||
// Envoyer au backend
|
||||
const response = await fetch('/api/contact.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
### Vérification Côté Serveur (api/contact.php)
|
||||
|
||||
```php
|
||||
/**
|
||||
* Vérifie le token reCAPTCHA v3 auprès de Google
|
||||
* @param string $token Token reçu du client
|
||||
* @return float Score (0.0 à 1.0), 0.0 si échec
|
||||
*/
|
||||
function verifyRecaptcha(string $token): float
|
||||
{
|
||||
// Si pas de token, retourner un score bas mais pas bloquant
|
||||
if (empty($token)) {
|
||||
error_log('reCAPTCHA: token vide');
|
||||
return 0.3; // Score bas mais pas 0
|
||||
}
|
||||
|
||||
$response = file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => 'Content-Type: application/x-www-form-urlencoded',
|
||||
'content' => http_build_query([
|
||||
'secret' => RECAPTCHA_SECRET_KEY,
|
||||
'response' => $token,
|
||||
'remoteip' => $_SERVER['REMOTE_ADDR'] ?? ''
|
||||
])
|
||||
]
|
||||
]));
|
||||
|
||||
if ($response === false) {
|
||||
error_log('reCAPTCHA: impossible de contacter Google');
|
||||
return 0.3; // Dégradation gracieuse
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
if (!($result['success'] ?? false)) {
|
||||
error_log('reCAPTCHA: échec - ' . json_encode($result['error-codes'] ?? []));
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (float) ($result['score'] ?? 0.0);
|
||||
}
|
||||
```
|
||||
|
||||
### Seuil de Score
|
||||
|
||||
| Score | Interprétation | Action |
|
||||
|-------|----------------|--------|
|
||||
| 0.9 - 1.0 | Très probablement humain | Accepter |
|
||||
| 0.5 - 0.9 | Probablement humain | Accepter |
|
||||
| 0.3 - 0.5 | Douteux | Accepter avec vigilance |
|
||||
| 0.0 - 0.3 | Probablement bot | Rejeter |
|
||||
|
||||
Dans notre implémentation : seuil à 0.5
|
||||
|
||||
### Dégradation Gracieuse
|
||||
|
||||
Si reCAPTCHA échoue :
|
||||
1. Le formulaire reste fonctionnel
|
||||
2. Un avertissement est loggé
|
||||
3. Le backend peut décider d'accepter ou non
|
||||
4. Pas de message d'erreur visible pour l'utilisateur
|
||||
|
||||
## Testing
|
||||
|
||||
- [] Le script reCAPTCHA se charge (vérifier Network)
|
||||
- [] Un token est généré à la soumission (RecaptchaService.getToken)
|
||||
- [] Le token est prêt à être envoyé au backend
|
||||
- [ ] Le backend vérifie le token avec Google (Story 5.5)
|
||||
- [] Si reCAPTCHA indisponible, le formulaire fonctionne quand même
|
||||
- [] Pas d'erreur visible si reCAPTCHA échoue (console.warn seulement)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `includes/config.php` | Created | Chargement .env et définition des constantes |
|
||||
| `.env` | Created | Variables d'environnement (clés test Google) |
|
||||
| `index.php` | Modified | Ajout require config.php |
|
||||
| `templates/footer.php` | Modified | Script reCAPTCHA + window.RECAPTCHA_SITE_KEY |
|
||||
| `assets/js/contact-form.js` | Modified | Ajout RecaptchaService |
|
||||
|
||||
### Completion Notes
|
||||
- Système de chargement .env avec loadEnv() dans config.php
|
||||
- Constantes PHP : RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY, APP_ENV, etc.
|
||||
- Script Google chargé en async/defer dans footer.php
|
||||
- RecaptchaService avec méthodes init(), isAvailable(), getToken()
|
||||
- Dégradation gracieuse : retourne '' si reCAPTCHA indisponible
|
||||
- Clés de test Google utilisées en développement (score toujours 0.9)
|
||||
- La vérification côté serveur sera implémentée dans Story 5.5
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-24 | 1.0 | Implémentation complète | James (Dev) |
|
||||
320
docs/stories/5.5.traitement-php-email.md
Normal file
320
docs/stories/5.5.traitement-php-email.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# Story 5.5: Traitement PHP et Envoi d'Email
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** propriétaire du site,
|
||||
**I want** recevoir les messages par email de manière sécurisée,
|
||||
**so that** je puisse répondre aux visiteurs.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Le backend PHP valide à nouveau tous les champs (ne jamais faire confiance au client)
|
||||
2. Le token reCAPTCHA est vérifié via l'API Google (score > 0.5)
|
||||
3. Les données sont nettoyées (htmlspecialchars, trim) contre XSS
|
||||
4. Un token CSRF est vérifié pour éviter les attaques cross-site
|
||||
5. L'email est envoyé via `mail()` PHP ou SMTP configuré
|
||||
6. L'email contient : tous les champs du formulaire, catégorie, date/heure, IP (optionnel)
|
||||
7. En cas de succès, une réponse JSON `{"success": true}` est renvoyée
|
||||
8. En cas d'erreur, une réponse JSON avec le message d'erreur est renvoyée
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Créer l'endpoint api/contact.php** (AC: 7, 8)
|
||||
- [] Créer le fichier api/contact.php
|
||||
- [] Configurer les headers JSON (Content-Type, X-Content-Type-Options)
|
||||
- [] Gérer uniquement les requêtes POST (405 sinon)
|
||||
|
||||
- [] **Task 2 : Valider le token CSRF** (AC: 4)
|
||||
- [] Récupérer le token de la requête JSON
|
||||
- [] Utiliser verifyCsrfToken() existante
|
||||
- [] Exception si invalide
|
||||
|
||||
- [] **Task 3 : Vérifier reCAPTCHA** (AC: 2)
|
||||
- [] Créer verifyRecaptcha() dans functions.php
|
||||
- [] Appeler l'API Google siteverify
|
||||
- [] Rejeter si score < RECAPTCHA_THRESHOLD (0.5)
|
||||
|
||||
- [] **Task 4 : Valider les données** (AC: 1, 3)
|
||||
- [] Créer validateContactData() dans functions.php
|
||||
- [] Valider required, format email, longueurs min/max
|
||||
- [] Nettoyer avec htmlspecialchars et trim
|
||||
- [] Exception avec messages détaillés
|
||||
|
||||
- [] **Task 5 : Envoyer l'email** (AC: 5, 6)
|
||||
- [] Créer sendContactEmail() dans functions.php
|
||||
- [] Corps formaté avec tous les champs + IP + date
|
||||
- [] Headers avec Reply-To vers l'expéditeur
|
||||
|
||||
- [] **Task 6 : Retourner la réponse** (AC: 7, 8)
|
||||
- [] JSON {"success": true, "message": "..."} si OK
|
||||
- [] JSON {"success": false, "error": "..."} si erreur
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Endpoint api/contact.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Endpoint de traitement du formulaire de contact
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../config.php';
|
||||
require_once __DIR__ . '/../includes/functions.php';
|
||||
|
||||
// Headers
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
// Uniquement POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Méthode non autorisée']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Récupérer les données JSON
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$input) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Données invalides']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Valider le token CSRF
|
||||
if (!validateCsrfToken($input['csrf_token'] ?? '')) {
|
||||
throw new Exception('Token de sécurité invalide. Veuillez rafraîchir la page.');
|
||||
}
|
||||
|
||||
// 2. Vérifier reCAPTCHA
|
||||
$recaptchaScore = verifyRecaptcha($input['recaptcha_token'] ?? '');
|
||||
if ($recaptchaScore < RECAPTCHA_THRESHOLD) {
|
||||
error_log("reCAPTCHA score trop bas: {$recaptchaScore}");
|
||||
throw new Exception('Vérification anti-spam échouée. Veuillez réessayer.');
|
||||
}
|
||||
|
||||
// 3. Valider et nettoyer les données
|
||||
$data = validateContactData($input);
|
||||
|
||||
// 4. Envoyer l'email
|
||||
$sent = sendContactEmail($data);
|
||||
|
||||
if (!$sent) {
|
||||
throw new Exception('Erreur lors de l\'envoi du message. Veuillez réessayer plus tard.');
|
||||
}
|
||||
|
||||
// 5. Succès
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Votre message a bien été envoyé ! Je vous répondrai dans les meilleurs délais.'
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
### Fonction de Validation (includes/functions.php)
|
||||
|
||||
```php
|
||||
/**
|
||||
* Valide et nettoie les données du formulaire de contact
|
||||
* @throws Exception si validation échoue
|
||||
*/
|
||||
function validateContactData(array $input): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Champs requis
|
||||
$required = ['nom', 'prenom', 'email', 'categorie', 'objet', 'message'];
|
||||
foreach ($required as $field) {
|
||||
if (empty(trim($input[$field] ?? ''))) {
|
||||
$errors[] = "Le champ {$field} est requis";
|
||||
}
|
||||
}
|
||||
|
||||
// Validation email
|
||||
$email = trim($input['email'] ?? '');
|
||||
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = "L'adresse email n'est pas valide";
|
||||
}
|
||||
|
||||
// Validation catégorie
|
||||
$validCategories = ['projet', 'poste', 'autre'];
|
||||
$categorie = $input['categorie'] ?? '';
|
||||
if ($categorie && !in_array($categorie, $validCategories)) {
|
||||
$errors[] = "Catégorie invalide";
|
||||
}
|
||||
|
||||
// Validation longueurs
|
||||
if (strlen($input['nom'] ?? '') > 100) {
|
||||
$errors[] = "Le nom est trop long (max 100 caractères)";
|
||||
}
|
||||
if (strlen($input['prenom'] ?? '') > 100) {
|
||||
$errors[] = "Le prénom est trop long (max 100 caractères)";
|
||||
}
|
||||
if (strlen($input['objet'] ?? '') > 200) {
|
||||
$errors[] = "L'objet est trop long (max 200 caractères)";
|
||||
}
|
||||
if (strlen($input['message'] ?? '') > 5000) {
|
||||
$errors[] = "Le message est trop long (max 5000 caractères)";
|
||||
}
|
||||
|
||||
// Si erreurs, les lancer
|
||||
if (!empty($errors)) {
|
||||
throw new Exception(implode('. ', $errors));
|
||||
}
|
||||
|
||||
// Nettoyer et retourner
|
||||
return [
|
||||
'nom' => htmlspecialchars(trim($input['nom']), ENT_QUOTES, 'UTF-8'),
|
||||
'prenom' => htmlspecialchars(trim($input['prenom']), ENT_QUOTES, 'UTF-8'),
|
||||
'email' => filter_var(trim($input['email']), FILTER_SANITIZE_EMAIL),
|
||||
'entreprise' => htmlspecialchars(trim($input['entreprise'] ?? ''), ENT_QUOTES, 'UTF-8'),
|
||||
'categorie' => $input['categorie'],
|
||||
'objet' => htmlspecialchars(trim($input['objet']), ENT_QUOTES, 'UTF-8'),
|
||||
'message' => htmlspecialchars(trim($input['message']), ENT_QUOTES, 'UTF-8'),
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'inconnue',
|
||||
'date' => date('d/m/Y à H:i:s'),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Fonction d'Envoi d'Email
|
||||
|
||||
```php
|
||||
/**
|
||||
* Envoie l'email de contact
|
||||
*/
|
||||
function sendContactEmail(array $data): bool
|
||||
{
|
||||
$categorieLabels = [
|
||||
'projet' => 'Projet freelance',
|
||||
'poste' => 'Proposition de poste',
|
||||
'autre' => 'Autre demande'
|
||||
];
|
||||
|
||||
$subject = "[Portfolio] {$categorieLabels[$data['categorie']]} - {$data['objet']}";
|
||||
|
||||
$body = <<<EMAIL
|
||||
═══════════════════════════════════════════
|
||||
NOUVEAU MESSAGE - PORTFOLIO
|
||||
═══════════════════════════════════════════
|
||||
|
||||
DE: {$data['prenom']} {$data['nom']}
|
||||
EMAIL: {$data['email']}
|
||||
ENTREPRISE: {$data['entreprise']}
|
||||
CATÉGORIE: {$categorieLabels[$data['categorie']]}
|
||||
|
||||
───────────────────────────────────────────
|
||||
OBJET: {$data['objet']}
|
||||
───────────────────────────────────────────
|
||||
|
||||
MESSAGE:
|
||||
|
||||
{$data['message']}
|
||||
|
||||
═══════════════════════════════════════════
|
||||
Envoyé le {$data['date']}
|
||||
IP: {$data['ip']}
|
||||
═══════════════════════════════════════════
|
||||
EMAIL;
|
||||
|
||||
$headers = implode("\r\n", [
|
||||
'From: ' . CONTACT_EMAIL,
|
||||
'Reply-To: ' . $data['email'],
|
||||
'Content-Type: text/plain; charset=UTF-8',
|
||||
'X-Mailer: PHP/' . phpversion(),
|
||||
'X-Priority: 1'
|
||||
]);
|
||||
|
||||
$result = mail(CONTACT_EMAIL, $subject, $body, $headers);
|
||||
|
||||
if (!$result) {
|
||||
error_log("Échec envoi email contact: " . print_r($data, true));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
```
|
||||
|
||||
### Sécurité Implémentée
|
||||
|
||||
| Menace | Protection |
|
||||
|--------|------------|
|
||||
| XSS | htmlspecialchars() sur toutes les entrées |
|
||||
| CSRF | Token vérifié en session |
|
||||
| Spam | reCAPTCHA v3 avec seuil 0.5 |
|
||||
| Injection | filter_var() pour l'email |
|
||||
| Email header injection | Pas de \r\n dans les champs utilisateur |
|
||||
|
||||
### Structure de la Réponse JSON
|
||||
|
||||
**Succès :**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Votre message a bien été envoyé !"
|
||||
}
|
||||
```
|
||||
|
||||
**Erreur :**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Message d'erreur explicite"
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- [] Les données sont validées côté serveur (validateContactData)
|
||||
- [] Le token CSRF est vérifié (verifyCsrfToken)
|
||||
- [] Le score reCAPTCHA est vérifié (verifyRecaptcha)
|
||||
- [] Les données sont nettoyées (htmlspecialchars, filter_var)
|
||||
- [] L'email est envoyé avec tous les champs (sendContactEmail)
|
||||
- [] La réponse JSON est correcte (succès)
|
||||
- [] La réponse JSON est correcte (erreur avec message)
|
||||
- [] Les erreurs sont loggées (error_log)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `api/contact.php` | Created | Endpoint de traitement du formulaire |
|
||||
| `includes/functions.php` | Modified | Ajout verifyRecaptcha, validateContactData, sendContactEmail |
|
||||
| `includes/config.php` | Modified | Ajout RECAPTCHA_THRESHOLD |
|
||||
|
||||
### Completion Notes
|
||||
- Endpoint api/contact.php avec gestion JSON complète
|
||||
- verifyRecaptcha() : appel API Google avec dégradation gracieuse (0.3 si échec)
|
||||
- validateContactData() : validation complète (required, email, longueurs min/max, catégorie)
|
||||
- sendContactEmail() : email formaté avec tous les champs, Reply-To, IP, date
|
||||
- Sécurité : CSRF, reCAPTCHA, htmlspecialchars, filter_var
|
||||
- Réponses JSON standardisées {success, message/error}
|
||||
- Logging des erreurs via error_log()
|
||||
|
||||
### Debug Log References
|
||||
- Correction syntaxe heredoc (EMAIL: interprété comme label)
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-24 | 1.0 | Implémentation complète | James (Dev) |
|
||||
294
docs/stories/5.6.feedback-utilisateur.md
Normal file
294
docs/stories/5.6.feedback-utilisateur.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# Story 5.6: Feedback Utilisateur (Succès/Erreur)
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** savoir clairement si mon message a été envoyé,
|
||||
**so that** je ne doute pas et j'évite les envois multiples.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Pendant l'envoi, un indicateur de chargement est affiché (spinner ou texte)
|
||||
2. Le bouton d'envoi est désactivé pendant le traitement
|
||||
3. En cas de succès : message de confirmation visible, formulaire réinitialisé, localStorage vidé
|
||||
4. En cas d'erreur : message d'erreur explicite, données conservées pour réessayer
|
||||
5. L'envoi est fait en AJAX (pas de rechargement de page)
|
||||
6. Le message de succès invite à vérifier les spams si pas de réponse
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Afficher l'état de chargement** (AC: 1, 2)
|
||||
- [] Masquer le texte du bouton (submitText.classList.add('hidden'))
|
||||
- [] Afficher le spinner (submitLoading.classList.remove('hidden'))
|
||||
- [] Désactiver le bouton (submitBtn.disabled = true)
|
||||
|
||||
- [] **Task 2 : Envoyer en AJAX** (AC: 5)
|
||||
- [] Utiliser fetch() avec POST
|
||||
- [] Envoyer les données en JSON (Content-Type: application/json)
|
||||
- [] Inclure les tokens (CSRF + reCAPTCHA)
|
||||
|
||||
- [] **Task 3 : Gérer le succès** (AC: 3, 6)
|
||||
- [] Masquer le formulaire (form.classList.add('hidden'))
|
||||
- [] Afficher le message de succès
|
||||
- [] Mention des spams (vérifier sous 48h)
|
||||
- [] Vider le localStorage (AppState.clearFormData())
|
||||
- [] Réinitialiser le formulaire (form.reset())
|
||||
|
||||
- [] **Task 4 : Gérer les erreurs** (AC: 4)
|
||||
- [] Afficher le message d'erreur avec icône
|
||||
- [] Garder les données dans le formulaire
|
||||
- [] Message "Vos données ont été conservées"
|
||||
- [] Permettre de réessayer
|
||||
|
||||
- [] **Task 5 : Réinitialiser l'état après feedback**
|
||||
- [] Masquer le spinner (finally block)
|
||||
- [] Réactiver le bouton
|
||||
- [] Scroll vers le message (scrollIntoView)
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Code JavaScript Complet
|
||||
|
||||
```javascript
|
||||
// assets/js/contact-form.js (compléter)
|
||||
|
||||
class ContactFormSubmit {
|
||||
constructor(formId) {
|
||||
this.form = document.getElementById(formId);
|
||||
if (!this.form) return;
|
||||
|
||||
this.submitBtn = document.getElementById('submit-btn');
|
||||
this.submitText = document.getElementById('submit-text');
|
||||
this.submitLoading = document.getElementById('submit-loading');
|
||||
this.successMessage = document.getElementById('success-message');
|
||||
this.errorMessage = document.getElementById('error-message');
|
||||
this.errorText = document.getElementById('error-text');
|
||||
|
||||
this.isSubmitting = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Écouter l'événement de validation réussie
|
||||
this.form.addEventListener('validSubmit', () => this.handleSubmit());
|
||||
}
|
||||
|
||||
async handleSubmit() {
|
||||
if (this.isSubmitting) return;
|
||||
|
||||
this.setLoadingState(true);
|
||||
this.hideMessages();
|
||||
|
||||
try {
|
||||
// Récupérer les données
|
||||
const formData = window.contactFormValidator.getFormData();
|
||||
|
||||
// Ajouter le token CSRF
|
||||
const csrfInput = this.form.querySelector('[name="csrf_token"]');
|
||||
if (csrfInput) {
|
||||
formData.csrf_token = csrfInput.value;
|
||||
}
|
||||
|
||||
// Obtenir le token reCAPTCHA
|
||||
formData.recaptcha_token = await RecaptchaService.getToken('contact');
|
||||
|
||||
// Envoyer la requête
|
||||
const response = await fetch('/api/contact.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.handleSuccess(result.message);
|
||||
} else {
|
||||
this.handleError(result.error || 'Une erreur est survenue');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur envoi formulaire:', error);
|
||||
this.handleError('Impossible de contacter le serveur. Vérifiez votre connexion.');
|
||||
} finally {
|
||||
this.setLoadingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
setLoadingState(loading) {
|
||||
this.isSubmitting = loading;
|
||||
|
||||
if (this.submitBtn) {
|
||||
this.submitBtn.disabled = loading;
|
||||
}
|
||||
|
||||
if (this.submitText && this.submitLoading) {
|
||||
if (loading) {
|
||||
this.submitText.classList.add('hidden');
|
||||
this.submitLoading.classList.remove('hidden');
|
||||
} else {
|
||||
this.submitText.classList.remove('hidden');
|
||||
this.submitLoading.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSuccess(message) {
|
||||
// Masquer le formulaire
|
||||
this.form.classList.add('hidden');
|
||||
|
||||
// Afficher le message de succès
|
||||
if (this.successMessage) {
|
||||
this.successMessage.classList.remove('hidden');
|
||||
|
||||
// Scroll vers le message
|
||||
this.successMessage.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// Vider le localStorage
|
||||
AppState.clearFormData();
|
||||
|
||||
// Réinitialiser le formulaire (pour un éventuel nouvel envoi)
|
||||
this.form.reset();
|
||||
|
||||
// Déclencher l'événement de succès
|
||||
this.form.dispatchEvent(new CustomEvent('formSuccess'));
|
||||
}
|
||||
|
||||
handleError(message) {
|
||||
// Afficher le message d'erreur
|
||||
if (this.errorMessage && this.errorText) {
|
||||
this.errorText.textContent = message;
|
||||
this.errorMessage.classList.remove('hidden');
|
||||
|
||||
// Scroll vers le message
|
||||
this.errorMessage.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// Les données sont conservées dans le formulaire pour réessayer
|
||||
}
|
||||
|
||||
hideMessages() {
|
||||
if (this.successMessage) {
|
||||
this.successMessage.classList.add('hidden');
|
||||
}
|
||||
if (this.errorMessage) {
|
||||
this.errorMessage.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialisation
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.contactFormSubmit = new ContactFormSubmit('contact-form');
|
||||
});
|
||||
```
|
||||
|
||||
### HTML des Messages (dans contact.php)
|
||||
|
||||
```html
|
||||
<!-- Message de succès -->
|
||||
<div id="success-message" class="hidden mt-8 p-6 bg-success/10 border border-success/30 rounded-lg text-center">
|
||||
<svg class="w-12 h-12 text-success mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-text-primary mb-2">Message envoyé avec succès !</h3>
|
||||
<p class="text-text-secondary mb-4">
|
||||
Merci pour votre message. Je vous répondrai dans les meilleurs délais.
|
||||
</p>
|
||||
<p class="text-sm text-text-muted">
|
||||
Si vous ne recevez pas de réponse sous 48h, pensez à vérifier vos spams.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<div id="error-message" class="hidden mt-8 p-6 bg-error/10 border border-error/30 rounded-lg">
|
||||
<div class="flex items-start gap-4">
|
||||
<svg class="w-6 h-6 text-error flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-semibold text-error mb-1">Erreur</h3>
|
||||
<p class="text-text-secondary" id="error-text"></p>
|
||||
<p class="text-sm text-text-muted mt-2">
|
||||
Vos données ont été conservées. Vous pouvez réessayer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### État du Bouton
|
||||
|
||||
| État | Texte | Icône | Disabled |
|
||||
|------|-------|-------|----------|
|
||||
| Normal | "Envoyer le message" | Aucune | Non |
|
||||
| Loading | "Envoi en cours..." | Spinner | Oui |
|
||||
| Succès | (caché) | - | - |
|
||||
| Erreur | "Envoyer le message" | Aucune | Non |
|
||||
|
||||
### Spinner CSS
|
||||
|
||||
```css
|
||||
/* Déjà inclus dans Tailwind avec animate-spin */
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- [] Le spinner s'affiche pendant l'envoi
|
||||
- [] Le bouton est désactivé pendant l'envoi
|
||||
- [] Le message de succès s'affiche après envoi réussi
|
||||
- [] Le formulaire est masqué après succès
|
||||
- [] La mention des spams est présente (48h)
|
||||
- [] Le localStorage est vidé après succès
|
||||
- [] Le message d'erreur s'affiche si échec
|
||||
- [] Les données sont conservées après erreur
|
||||
- [] On peut réessayer après une erreur
|
||||
- [] Pas de rechargement de page (AJAX)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `assets/js/contact-form.js` | Modified | Ajout classe ContactFormSubmit |
|
||||
| `pages/contact.php` | Modified | Messages succès/erreur améliorés |
|
||||
|
||||
### Completion Notes
|
||||
- Classe ContactFormSubmit avec gestion complète du cycle de vie
|
||||
- État loading : spinner + bouton désactivé
|
||||
- Envoi AJAX avec fetch() et JSON
|
||||
- Tokens CSRF et reCAPTCHA inclus automatiquement
|
||||
- Succès : formulaire masqué, message avec mention spams, localStorage vidé
|
||||
- Erreur : message explicite, données conservées, possibilité de réessayer
|
||||
- Scroll automatique vers les messages (scrollIntoView smooth)
|
||||
- Gestion des erreurs réseau (catch)
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-24 | 1.0 | Implémentation complète | James (Dev) |
|
||||
209
docs/stories/5.7.liens-contact-secondaires.md
Normal file
209
docs/stories/5.7.liens-contact-secondaires.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Story 5.7: Liens de Contact Secondaires
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** visiteur,
|
||||
**I want** avoir des alternatives au formulaire pour contacter le développeur,
|
||||
**so that** je choisis le canal qui me convient.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Sous le formulaire, une section affiche les liens secondaires : LinkedIn, GitHub, email direct (mailto)
|
||||
2. Les liens sont affichés avec leurs icônes respectives
|
||||
3. Les liens s'ouvrent dans un nouvel onglet (sauf mailto)
|
||||
4. La section est visuellement distincte mais cohérente avec le formulaire
|
||||
5. L'adresse email est protégée contre le scraping (encodage ou JS)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Ajouter la section dans contact.php** (AC: 1, 4)
|
||||
- [] Titre "Retrouvez-moi aussi sur"
|
||||
- [] Positionnement sous le formulaire (mt-16, pt-8, border-t)
|
||||
- [] Style distinct mais cohérent (bg-surface-alt, border)
|
||||
|
||||
- [] **Task 2 : Ajouter les liens avec icônes** (AC: 2)
|
||||
- [] LinkedIn avec icône SVG (#0A66C2)
|
||||
- [] GitHub avec icône SVG
|
||||
- [] Email avec icône SVG (primary)
|
||||
|
||||
- [] **Task 3 : Configurer les liens** (AC: 3)
|
||||
- [] `target="_blank"` + `rel="noopener noreferrer"` pour LinkedIn/GitHub
|
||||
- [] `mailto:` généré par JS pour l'email
|
||||
|
||||
- [] **Task 4 : Protéger l'email** (AC: 5)
|
||||
- [] data-user et data-domain dans le HTML
|
||||
- [] initEmailProtection() dans main.js
|
||||
- [] Reconstruction du mailto au chargement
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Section à ajouter dans contact.php
|
||||
|
||||
```php
|
||||
<!-- Sous le formulaire et les messages de feedback -->
|
||||
|
||||
<!-- Liens secondaires -->
|
||||
<section class="mt-16 pt-8 border-t border-border">
|
||||
<h2 class="text-lg font-semibold text-center mb-6">Retrouvez-moi aussi sur</h2>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-6">
|
||||
<!-- LinkedIn -->
|
||||
<a
|
||||
href="https://linkedin.com/in/votre-profil"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center gap-3 px-6 py-3 bg-surface rounded-lg hover:bg-surface-light transition-colors group"
|
||||
aria-label="Profil LinkedIn"
|
||||
>
|
||||
<svg class="w-6 h-6 text-[#0A66C2]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
<span class="font-medium text-text-primary group-hover:text-primary transition-colors">LinkedIn</span>
|
||||
</a>
|
||||
|
||||
<!-- GitHub -->
|
||||
<a
|
||||
href="https://github.com/votre-username"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center gap-3 px-6 py-3 bg-surface rounded-lg hover:bg-surface-light transition-colors group"
|
||||
aria-label="Profil GitHub"
|
||||
>
|
||||
<svg class="w-6 h-6 text-text-primary" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
<span class="font-medium text-text-primary group-hover:text-primary transition-colors">GitHub</span>
|
||||
</a>
|
||||
|
||||
<!-- Email (protégé) -->
|
||||
<a
|
||||
href="#"
|
||||
id="email-link"
|
||||
class="flex items-center gap-3 px-6 py-3 bg-surface rounded-lg hover:bg-surface-light transition-colors group"
|
||||
aria-label="Envoyer un email"
|
||||
data-user="contact"
|
||||
data-domain="monportfolio.fr"
|
||||
>
|
||||
<svg class="w-6 h-6 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="font-medium text-text-primary group-hover:text-primary transition-colors">Email</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### Protection de l'Email (JavaScript)
|
||||
|
||||
```javascript
|
||||
// assets/js/main.js (ajouter)
|
||||
|
||||
/**
|
||||
* Protection de l'email contre le scraping
|
||||
* Reconstruit l'adresse email à partir de data-attributes
|
||||
*/
|
||||
function initEmailProtection() {
|
||||
const emailLink = document.getElementById('email-link');
|
||||
if (!emailLink) return;
|
||||
|
||||
const user = emailLink.dataset.user;
|
||||
const domain = emailLink.dataset.domain;
|
||||
|
||||
if (user && domain) {
|
||||
const email = `${user}@${domain}`;
|
||||
emailLink.href = `mailto:${email}`;
|
||||
|
||||
// Optionnel : afficher l'email au hover
|
||||
emailLink.title = email;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initEmailProtection();
|
||||
});
|
||||
```
|
||||
|
||||
### Alternative : Encodage HTML
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Encode une adresse email en entités HTML
|
||||
*/
|
||||
function encodeEmail(string $email): string
|
||||
{
|
||||
$encoded = '';
|
||||
for ($i = 0; $i < strlen($email); $i++) {
|
||||
$encoded .= '&#' . ord($email[$i]) . ';';
|
||||
}
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
$email = 'contact@monportfolio.fr';
|
||||
$encodedEmail = encodeEmail($email);
|
||||
?>
|
||||
|
||||
<a href="mailto:<?= $encodedEmail ?>">
|
||||
<?= $encodedEmail ?>
|
||||
</a>
|
||||
```
|
||||
|
||||
### Liens à Configurer
|
||||
|
||||
| Plateforme | URL | Notes |
|
||||
|------------|-----|-------|
|
||||
| LinkedIn | https://linkedin.com/in/votre-profil | Profil public |
|
||||
| GitHub | https://github.com/votre-username | Profil public |
|
||||
| Email | contact@monportfolio.fr | Via .env ou config |
|
||||
|
||||
### Couleurs des Icônes
|
||||
|
||||
| Plateforme | Couleur | Tailwind |
|
||||
|------------|---------|----------|
|
||||
| LinkedIn | #0A66C2 | `text-[#0A66C2]` |
|
||||
| GitHub | Inherit (blanc) | `text-text-primary` |
|
||||
| Email | Primary | `text-primary` |
|
||||
|
||||
## Testing
|
||||
|
||||
- [] Les 3 liens sont affichés avec leurs icônes
|
||||
- [] LinkedIn et GitHub s'ouvrent dans un nouvel onglet
|
||||
- [] Le lien mailto déclenche le client email
|
||||
- [] L'email n'apparaît pas en clair dans le HTML source (data-attributes)
|
||||
- [] Le JS reconstruit correctement le lien mailto
|
||||
- [] Les hover states fonctionnent (border-primary/50)
|
||||
- [] La section est visuellement distincte (border-t, mt-16)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `pages/contact.php` | Modified | Section liens secondaires (LinkedIn, GitHub, Email) |
|
||||
| `assets/js/main.js` | Modified | Ajout initEmailProtection() |
|
||||
|
||||
### Completion Notes
|
||||
- Section "Retrouvez-moi aussi sur" avec 3 liens
|
||||
- LinkedIn : https://linkedin.com/in/celian-music (à personnaliser)
|
||||
- GitHub : https://github.com/skycel
|
||||
- Email protégé : data-user/data-domain + reconstruction JS
|
||||
- Icônes SVG avec couleurs appropriées (LinkedIn bleu, GitHub inherit, Email primary)
|
||||
- Hover state : border-primary/50
|
||||
- target="_blank" + rel="noopener noreferrer" pour les liens externes
|
||||
|
||||
### Debug Log References
|
||||
Aucun problème rencontré.
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------|---------|-------------|--------|
|
||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||
| 2026-01-24 | 1.0 | Implémentation complète | James (Dev) |
|
||||
67
docs/stories/6.1.correctif-contact-form-production.md
Normal file
67
docs/stories/6.1.correctif-contact-form-production.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Story 6.1 - Correctif contact form production
|
||||
|
||||
## Status
|
||||
|
||||
Ready for Dev
|
||||
|
||||
## Story
|
||||
|
||||
**As a** developer
|
||||
**I want** corriger le formulaire de contact non fonctionnel en production
|
||||
**So that** le site fonctionne correctement
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Le formulaire de contact fonctionne correctement en production
|
||||
2. Usage de PHPMailer pour ne pas dépendre d'une installation lourde sur le serveur
|
||||
3. Ajout des variables d'environnement dans le fichier .env
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [] **Task 1 : Ajout de PHPMailer**
|
||||
- [] Installation de PHPMailer
|
||||
- [] Utilisation de PHPMailer pour envoyer un mail
|
||||
|
||||
- [] **Task 2 : Ajout des variables d'environnement pour PHPMailer**
|
||||
- [] Ajout des variables d'environnement dans le fichier .env
|
||||
- [] Ajout des variables d'environnement dans le fichier .env.example
|
||||
- [] Configuration des constantes basées sur les variables d'environnement
|
||||
|
||||
- [] **Task 3 : Intégrer PHPMailer dans le formulaire de contact**
|
||||
- [] Modification de la fonction sendContactMail() pour utiliser PHPMailer
|
||||
- [] Modification de l'endpoint /api/contact pour utiliser PHPMailer
|
||||
- [] Test de l'envoi d'un mail avec PHPMailer
|
||||
|
||||
- [] **Task 4 : Tester le formulaire de contact en production**
|
||||
- [] Tester le formulaire de contact en production
|
||||
|
||||
## Dev Notes
|
||||
|
||||
## Testing
|
||||
|
||||
- [] Tester l'envoi d'un mail avec PHPMailer
|
||||
- [] Tester le formulaire de contact en local
|
||||
- [] Tester le formulaire de contact en production
|
||||
- [] Vérifier la réception du mail
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
### File list
|
||||
| File | Action | Description |
|
||||
|--------------------------|--------|-------------|
|
||||
| `includes/functions.php` | Modified | Modification de la fonction sendContactMail() pour utiliser PHPMailer |
|
||||
| `api/contact.php` | Modified | Modification de l'endpoint /api/contact pour utiliser PHPMailer |
|
||||
|
||||
### Completion Notes
|
||||
- Utilisation de PHPMailer pour envoyer un mail
|
||||
|
||||
### Debug Log References
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
|------------|---------|-------------------------|---------------------|
|
||||
| 2026-01-24 | 0.1 | Creation du story | Skycel (developper) |
|
||||
| 2026-01-24 | 1.0 | Implémentation complète | Skycel (developper) |
|
||||
Reference in New Issue
Block a user