# 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

