Files
Portfolio-Game/docs/implementation-artifacts/2-6-page-temoignages-migrations-bdd.md
skycel ec1ae92799 🎉 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>
2026-02-05 02:08:56 +01:00

21 KiB

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
// 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
// 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
// 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
// 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
// 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;
            }),
        ];
    }
}
// api/routes/api.php
Route::get('/testimonials', [TestimonialController::class, 'index']);

Types 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

<!-- 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

<!-- 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 :

{
  "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 :

{
  "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