🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)

Setup complet de l'infrastructure projet :
- Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS)
- Backend Laravel 12 API-only avec middleware X-API-Key et CORS
- Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter)
- Documentation planning et implementation artifacts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 02:08:56 +01:00
commit ec1ae92799
116 changed files with 55669 additions and 0 deletions

View File

@@ -0,0 +1,660 @@
# Story 2.6: Page Témoignages et migrations BDD
Status: ready-for-dev
## Story
As a visiteur,
I want voir les témoignages des personnes ayant travaillé avec le développeur,
so that j'ai une validation sociale de ses compétences.
## Acceptance Criteria
1. **Given** les migrations Laravel sont exécutées **When** `php artisan migrate` est lancé **Then** la table `testimonials` est créée (id, name, role, company, avatar, text_key, personality ENUM, project_id FK nullable, display_order, is_active, timestamps)
2. **And** les seeders de test sont disponibles avec des témoignages en FR et EN
3. **Given** le visiteur accède à `/temoignages` (FR) ou `/en/testimonials` (EN) **When** la page se charge **Then** la liste des témoignages s'affiche depuis l'API `/api/testimonials`
4. **And** chaque témoignage affiche : nom, rôle, entreprise, avatar, texte traduit
5. **And** la personnalité de chaque PNJ est indiquée visuellement (style différent selon personality)
6. **And** un lien vers le projet associé est présent si pertinent
7. **And** l'ordre d'affichage respecte `display_order`
8. **And** le design est préparé pour accueillir le composant DialoguePNJ (story suivante)
9. **And** les meta tags SEO sont dynamiques pour cette page
## Tasks / Subtasks
- [ ] **Task 1: Créer la migration table testimonials** (AC: #1)
- [ ] Créer migration `create_testimonials_table`
- [ ] Colonnes : id, name, role, company, avatar, text_key, personality (ENUM: sage, sarcastique, enthousiaste, professionnel), project_id (FK nullable), display_order, is_active (boolean), timestamps
- [ ] Foreign key project_id → projects.id (nullable, ON DELETE SET NULL)
- [ ] Index sur display_order pour le tri
- [ ] Index sur is_active pour le filtrage
- [ ] **Task 2: Créer le Model Testimonial** (AC: #1)
- [ ] Créer `app/Models/Testimonial.php`
- [ ] Définir les fillable : name, role, company, avatar, text_key, personality, project_id, display_order, is_active
- [ ] Casts : is_active → boolean
- [ ] Relation `project()` : belongsTo(Project::class)
- [ ] Scope `scopeActive($query)` pour filtrer les actifs
- [ ] Scope `scopeOrdered($query)` pour le tri
- [ ] **Task 3: Créer le Seeder des témoignages** (AC: #2)
- [ ] Créer `database/seeders/TestimonialSeeder.php`
- [ ] Ajouter 4-5 témoignages de test avec différentes personnalités
- [ ] Ajouter les traductions FR et EN dans TranslationSeeder
- [ ] Lier certains témoignages à des projets existants
- [ ] Mettre à jour `DatabaseSeeder.php`
- [ ] **Task 4: Créer l'endpoint API testimonials** (AC: #3, #4, #6, #7)
- [ ] Créer `app/Http/Controllers/Api/TestimonialController.php`
- [ ] Méthode `index()` pour lister les témoignages actifs
- [ ] Créer `app/Http/Resources/TestimonialResource.php`
- [ ] Inclure le projet lié (si existe) avec titre traduit
- [ ] Trier par display_order
- [ ] Ajouter la route `GET /api/testimonials`
- [ ] **Task 5: Créer le composable useFetchTestimonials** (AC: #3)
- [ ] Créer `frontend/app/composables/useFetchTestimonials.ts`
- [ ] Typer la réponse avec interface Testimonial[]
- [ ] **Task 6: Créer la page temoignages.vue** (AC: #3, #4, #5, #8)
- [ ] Créer `frontend/app/pages/temoignages.vue`
- [ ] Charger les données avec le composable
- [ ] Afficher chaque témoignage comme une card
- [ ] Appliquer un style visuel selon la personnalité
- [ ] Préparer l'emplacement pour DialoguePNJ
- [ ] **Task 7: Créer le composant TestimonialCard** (AC: #4, #5, #6)
- [ ] Créer `frontend/app/components/feature/TestimonialCard.vue`
- [ ] Props : testimonial (avec name, role, company, avatar, text, personality, project)
- [ ] Afficher l'avatar, le nom, le rôle, l'entreprise
- [ ] Afficher le texte du témoignage
- [ ] Style de bulle selon la personnalité
- [ ] Lien vers le projet si présent
- [ ] **Task 8: Styles visuels par personnalité** (AC: #5)
- [ ] Définir 4 styles de bulles/cards selon personality :
- sage : style calme, bordure subtile
- sarcastique : style décalé, accent différent
- enthousiaste : style vif, couleurs plus marquées
- professionnel : style sobre, formel
- [ ] Classes CSS ou Tailwind variants
- [ ] **Task 9: Meta tags SEO** (AC: #9)
- [ ] Titre : "Témoignages | Skycel"
- [ ] Description dynamique
- [ ] **Task 10: Tests et validation**
- [ ] Exécuter les migrations
- [ ] Vérifier le seeding des données
- [ ] Tester l'API en FR et EN
- [ ] Valider l'affichage de la page
- [ ] Vérifier les liens vers projets
## Dev Notes
### Migration testimonials
```php
<?php
// database/migrations/2026_02_04_000001_create_testimonials_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('testimonials', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('role');
$table->string('company')->nullable();
$table->string('avatar')->nullable();
$table->string('text_key');
$table->enum('personality', ['sage', 'sarcastique', 'enthousiaste', 'professionnel'])->default('professionnel');
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
$table->integer('display_order')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index('display_order');
$table->index('is_active');
});
}
public function down(): void
{
Schema::dropIfExists('testimonials');
}
};
```
### Model Testimonial
```php
<?php
// api/app/Models/Testimonial.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Testimonial extends Model
{
use HasFactory;
protected $fillable = [
'name',
'role',
'company',
'avatar',
'text_key',
'personality',
'project_id',
'display_order',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeOrdered($query)
{
return $query->orderBy('display_order');
}
}
```
### Seeder des témoignages
```php
<?php
// database/seeders/TestimonialSeeder.php
namespace Database\Seeders;
use App\Models\Testimonial;
use App\Models\Translation;
use Illuminate\Database\Seeder;
class TestimonialSeeder extends Seeder
{
public function run(): void
{
$testimonials = [
[
'name' => 'Marie Dupont',
'role' => 'CTO',
'company' => 'TechStartup',
'avatar' => '/images/testimonials/marie.jpg',
'text_key' => 'testimonial.marie.text',
'personality' => 'enthousiaste',
'project_id' => 1,
'display_order' => 1,
],
[
'name' => 'Pierre Martin',
'role' => 'Lead Developer',
'company' => 'DevAgency',
'avatar' => '/images/testimonials/pierre.jpg',
'text_key' => 'testimonial.pierre.text',
'personality' => 'professionnel',
'project_id' => 2,
'display_order' => 2,
],
[
'name' => 'Sophie Bernard',
'role' => 'Product Manager',
'company' => 'InnovateCorp',
'avatar' => '/images/testimonials/sophie.jpg',
'text_key' => 'testimonial.sophie.text',
'personality' => 'sage',
'project_id' => null,
'display_order' => 3,
],
[
'name' => 'Thomas Leroy',
'role' => 'Freelance Designer',
'company' => null,
'avatar' => '/images/testimonials/thomas.jpg',
'text_key' => 'testimonial.thomas.text',
'personality' => 'sarcastique',
'project_id' => null,
'display_order' => 4,
],
];
foreach ($testimonials as $data) {
Testimonial::create($data);
}
// Traductions
$translations = [
['key' => 'testimonial.marie.text', 'fr' => "Travailler avec Célian a été une révélation ! Son approche créative et sa maîtrise technique ont transformé notre projet. Je recommande sans hésitation !", 'en' => "Working with Célian was a revelation! His creative approach and technical mastery transformed our project. I highly recommend!"],
['key' => 'testimonial.pierre.text', 'fr' => "Code propre, architecture solide, communication claire. Célian sait exactement ce qu'il fait et le fait bien.", 'en' => "Clean code, solid architecture, clear communication. Célian knows exactly what he's doing and does it well."],
['key' => 'testimonial.sophie.text', 'fr' => "Une personne rare qui combine vision produit et excellence technique. Les retours utilisateurs parlent d'eux-mêmes.", 'en' => "A rare person who combines product vision and technical excellence. User feedback speaks for itself."],
['key' => 'testimonial.thomas.text', 'fr' => "Bon, j'avoue, au début je pensais que les devs ne comprenaient rien au design. Célian m'a prouvé le contraire. Presque agaçant.", 'en' => "Okay, I admit, at first I thought devs didn't understand design. Célian proved me wrong. Almost annoying."],
];
foreach ($translations as $t) {
Translation::create(['lang' => 'fr', 'key_name' => $t['key'], 'value' => $t['fr']]);
Translation::create(['lang' => 'en', 'key_name' => $t['key'], 'value' => $t['en']]);
}
}
}
```
### Controller et Resource
```php
<?php
// api/app/Http/Controllers/Api/TestimonialController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\TestimonialResource;
use App\Models\Testimonial;
use Illuminate\Http\Request;
class TestimonialController extends Controller
{
public function index(Request $request)
{
$lang = $request->header('Accept-Language', 'fr');
$testimonials = Testimonial::with('project')
->active()
->ordered()
->get();
return TestimonialResource::collection($testimonials)
->additional(['meta' => ['lang' => $lang]]);
}
}
```
```php
<?php
// api/app/Http/Resources/TestimonialResource.php
namespace App\Http\Resources;
use App\Models\Translation;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TestimonialResource extends JsonResource
{
public function toArray(Request $request): array
{
$lang = $request->header('Accept-Language', 'fr');
return [
'id' => $this->id,
'name' => $this->name,
'role' => $this->role,
'company' => $this->company,
'avatar' => $this->avatar,
'text' => Translation::getTranslation($this->text_key, $lang),
'personality' => $this->personality,
'displayOrder' => $this->display_order,
'project' => $this->whenLoaded('project', function () use ($lang) {
return $this->project ? [
'id' => $this->project->id,
'slug' => $this->project->slug,
'title' => Translation::getTranslation($this->project->title_key, $lang),
] : null;
}),
];
}
}
```
```php
// api/routes/api.php
Route::get('/testimonials', [TestimonialController::class, 'index']);
```
### Types TypeScript
```typescript
// frontend/app/types/testimonial.ts
export interface Testimonial {
id: number
name: string
role: string
company: string | null
avatar: string | null
text: string
personality: 'sage' | 'sarcastique' | 'enthousiaste' | 'professionnel'
displayOrder: number
project?: {
id: number
slug: string
title: string
} | null
}
```
### Composant TestimonialCard
```vue
<!-- frontend/app/components/feature/TestimonialCard.vue -->
<script setup lang="ts">
import type { Testimonial } from '~/types/testimonial'
const props = defineProps<{
testimonial: Testimonial
}>()
const { t } = useI18n()
const localePath = useLocalePath()
// Styles selon la personnalité
const personalityStyles = {
sage: 'border-l-4 border-blue-400 bg-blue-400/5',
sarcastique: 'border-l-4 border-purple-400 bg-purple-400/5 italic',
enthousiaste: 'border-l-4 border-sky-accent bg-sky-accent/5',
professionnel: 'border-l-4 border-gray-400 bg-gray-400/5',
}
const bubbleStyle = computed(() => personalityStyles[props.testimonial.personality])
</script>
<template>
<article class="testimonial-card bg-sky-dark-50 rounded-lg overflow-hidden">
<!-- Header avec avatar et info -->
<div class="flex items-center gap-4 p-4 border-b border-sky-dark-100">
<!-- Avatar -->
<div class="w-16 h-16 rounded-full overflow-hidden bg-sky-dark flex-shrink-0">
<NuxtImg
v-if="testimonial.avatar"
:src="testimonial.avatar"
:alt="testimonial.name"
format="webp"
width="64"
height="64"
class="w-full h-full object-cover"
/>
<div v-else class="w-full h-full flex items-center justify-center text-2xl text-sky-text-muted">
👤
</div>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<h3 class="font-ui font-semibold text-sky-text truncate">
{{ testimonial.name }}
</h3>
<p class="text-sm text-sky-text-muted">
{{ testimonial.role }}
<span v-if="testimonial.company">
@ {{ testimonial.company }}
</span>
</p>
<!-- Badge personnalité -->
<span
class="inline-block mt-1 text-xs px-2 py-0.5 rounded-full"
:class="{
'bg-blue-400/20 text-blue-300': testimonial.personality === 'sage',
'bg-purple-400/20 text-purple-300': testimonial.personality === 'sarcastique',
'bg-sky-accent/20 text-sky-accent': testimonial.personality === 'enthousiaste',
'bg-gray-400/20 text-gray-300': testimonial.personality === 'professionnel',
}"
>
{{ t(`testimonials.personality.${testimonial.personality}`) }}
</span>
</div>
</div>
<!-- Texte du témoignage -->
<div class="p-4" :class="bubbleStyle">
<p class="font-narrative text-sky-text leading-relaxed">
"{{ testimonial.text }}"
</p>
</div>
<!-- Lien vers le projet -->
<div v-if="testimonial.project" class="px-4 pb-4">
<NuxtLink
:to="localePath(`/projets/${testimonial.project.slug}`)"
class="inline-flex items-center text-sm text-sky-accent hover:underline"
>
📁 {{ t('testimonials.relatedProject') }}: {{ testimonial.project.title }}
</NuxtLink>
</div>
</article>
</template>
```
### Page temoignages.vue
```vue
<!-- frontend/app/pages/temoignages.vue -->
<script setup lang="ts">
const { t } = useI18n()
const { data, pending, error, refresh } = useFetchTestimonials()
const testimonials = computed(() => data.value?.data ?? [])
// SEO
useHead({
title: () => t('testimonials.pageTitle'),
})
useSeoMeta({
title: () => t('testimonials.pageTitle'),
description: () => t('testimonials.pageDescription'),
ogTitle: () => t('testimonials.pageTitle'),
ogDescription: () => t('testimonials.pageDescription'),
})
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-ui font-bold text-sky-text mb-8">
{{ t('testimonials.title') }}
</h1>
<!-- Loading -->
<div v-if="pending" class="space-y-6">
<div v-for="i in 4" :key="i" class="bg-sky-dark-50 rounded-lg p-6 animate-pulse">
<div class="flex items-center gap-4 mb-4">
<div class="w-16 h-16 bg-sky-dark-100 rounded-full"></div>
<div class="flex-1">
<div class="h-5 bg-sky-dark-100 rounded w-32 mb-2"></div>
<div class="h-4 bg-sky-dark-100 rounded w-48"></div>
</div>
</div>
<div class="h-4 bg-sky-dark-100 rounded w-full mb-2"></div>
<div class="h-4 bg-sky-dark-100 rounded w-3/4"></div>
</div>
</div>
<!-- Error -->
<div v-else-if="error" class="text-center py-12">
<p class="text-sky-text-muted mb-4">{{ t('testimonials.loadError') }}</p>
<button
@click="refresh()"
class="bg-sky-accent text-white px-6 py-2 rounded-lg hover:bg-sky-accent-hover"
>
{{ t('common.retry') }}
</button>
</div>
<!-- Testimonials list -->
<div v-else class="space-y-6">
<!-- Placeholder pour DialoguePNJ (Story 2.7) -->
<div class="hidden">
<!-- <DialoguePNJ :testimonials="testimonials" /> -->
</div>
<!-- Affichage en cards (sera remplacé par DialoguePNJ) -->
<TestimonialCard
v-for="testimonial in testimonials"
:key="testimonial.id"
:testimonial="testimonial"
class="testimonial-animated"
/>
</div>
</div>
</template>
<style scoped>
.testimonial-animated {
animation: fadeInUp 0.5s ease-out forwards;
opacity: 0;
}
.testimonial-animated:nth-child(1) { animation-delay: 0ms; }
.testimonial-animated:nth-child(2) { animation-delay: 100ms; }
.testimonial-animated:nth-child(3) { animation-delay: 200ms; }
.testimonial-animated:nth-child(4) { animation-delay: 300ms; }
.testimonial-animated:nth-child(5) { animation-delay: 400ms; }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.testimonial-animated {
animation: none;
opacity: 1;
}
}
</style>
```
### Clés i18n
**fr.json :**
```json
{
"testimonials": {
"title": "Témoignages",
"pageTitle": "Témoignages | Skycel",
"pageDescription": "Découvrez ce que disent les personnes qui ont travaillé avec Célian.",
"loadError": "Impossible de charger les témoignages...",
"relatedProject": "Projet associé",
"personality": {
"sage": "Sage",
"sarcastique": "Sarcastique",
"enthousiaste": "Enthousiaste",
"professionnel": "Professionnel"
}
}
}
```
**en.json :**
```json
{
"testimonials": {
"title": "Testimonials",
"pageTitle": "Testimonials | Skycel",
"pageDescription": "Discover what people who worked with Célian have to say.",
"loadError": "Unable to load testimonials...",
"relatedProject": "Related project",
"personality": {
"sage": "Wise",
"sarcastique": "Sarcastic",
"enthousiaste": "Enthusiastic",
"professionnel": "Professional"
}
}
}
```
### Dépendances
**Cette story nécessite :**
- Story 1.2 : Table projects pour la FK
- Story 1.3 : Système i18n configuré
**Cette story prépare pour :**
- Story 2.7 : Composant DialoguePNJ
### Project Structure Notes
**Fichiers à créer :**
```
api/
├── app/Models/
│ └── Testimonial.php # CRÉER
├── app/Http/Controllers/Api/
│ └── TestimonialController.php # CRÉER
├── app/Http/Resources/
│ └── TestimonialResource.php # CRÉER
└── database/
├── migrations/
│ └── 2026_02_04_000001_create_testimonials_table.php # CRÉER
└── seeders/
└── TestimonialSeeder.php # CRÉER
frontend/app/
├── pages/
│ └── temoignages.vue # CRÉER
├── components/feature/
│ └── TestimonialCard.vue # CRÉER
├── composables/
│ └── useFetchTestimonials.ts # CRÉER
└── types/
└── testimonial.ts # CRÉER
```
### References
- [Source: docs/planning-artifacts/epics.md#Story-2.6]
- [Source: docs/planning-artifacts/architecture.md#API-&-Communication-Patterns]
- [Source: docs/planning-artifacts/ux-design-specification.md#DialoguePNJ]
- [Source: docs/brainstorming-gamification-2026-01-26.md#Personnalites-PNJ]
### Technical Requirements
| Requirement | Value | Source |
|-------------|-------|--------|
| Table | testimonials avec personality ENUM | Epics |
| API endpoint | GET /api/testimonials | Architecture |
| Personnalités | sage, sarcastique, enthousiaste, professionnel | Brainstorming |
| FK project_id | Nullable, ON DELETE SET NULL | Architecture |
## Dev Agent Record
### Agent Model Used
{{agent_model_name_version}}
### Debug Log References
### Completion Notes List
### Change Log
| Date | Change | Author |
|------|--------|--------|
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
### File List