Files
Portfolio-Game/docs/implementation-artifacts/1-3-systeme-i18n-frontend-api-bilingue.md
skycel 262242c7df 🌐 Add full i18n system frontend + API (Story 1.3)
Nuxt i18n with lazy-loaded JSON files, localized routes, hreflang SEO tags,
LanguageSwitcher component. Laravel SetLocale middleware, HasTranslations trait,
API Resources and Controllers for projects/skills with Accept-Language support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 18:17:44 +01:00

19 KiB

Story 1.3: Système i18n frontend + API bilingue

Status: review

Story

As a visiteur, I want voir le site dans ma langue (FR ou EN), so that je comprends le contenu.

Acceptance Criteria

  1. Given le module @nuxtjs/i18n configuré avec stratégie prefix_except_default When le visiteur accède à / ou /en Then le contenu statique UI est affiché dans la langue correspondante via fichiers JSON (i18n/fr.json, i18n/en.json)
  2. And les URLs FR sont par défaut (/, /projets, /competences, /contact)
  3. And les URLs EN sont préfixées (/en, /en/projects, /en/skills, /en/contact)
  4. And useI18n(), $t(), localePath(), switchLocalePath() fonctionnent en SSR
  5. And les tags hreflang sont générés automatiquement dans le <head>
  6. And l'attribut lang du <html> est dynamique (fr/en)
  7. And le middleware Laravel extrait Accept-Language et joint la table translations pour le contenu dynamique
  8. And les API Resources Laravel renvoient le contenu traduit selon la langue demandée
  9. And le fallback est FR si langue non supportée

Tasks / Subtasks

  • Task 1: Configuration @nuxtjs/i18n (AC: #1, #2, #3, #4)

    • Vérifier que @nuxtjs/i18n est installé (Story 1.1)
    • Créer la structure frontend/i18n/ pour les fichiers de traduction
    • Configurer nuxt.config.ts avec i18n complet :
      • locales: ['fr', 'en']
      • defaultLocale: 'fr'
      • strategy: 'prefix_except_default'
      • detectBrowserLanguage: false (on utilise l'URL)
    • Activer vueI18n pour le composant <i18n-t>
  • Task 2: Fichiers de traduction JSON (AC: #1)

    • Créer frontend/i18n/fr.json avec structure de base
    • Créer frontend/i18n/en.json avec structure de base
    • Inclure les traductions pour :
      • Navigation (Accueil, Projets, Compétences, Témoignages, Parcours, Contact)
      • Boutons communs (Continuer, Retour, Découvrir, Fermer)
      • Messages d'erreur (404, erreur générique)
      • Landing page (accroche, CTA Aventure, CTA Express)
      • Footer et metadata
  • Task 3: Routes localisées Nuxt (AC: #2, #3)

    • Configurer i18n.pages dans nuxt.config.ts pour les routes custom :
      pages: {
        'projets/[slug]': { en: '/projects/[slug]' },
        'competences': { en: '/skills' },
        'temoignages': { en: '/testimonials' },
        'parcours': { en: '/journey' },
        'contact': { en: '/contact' }
      }
      
    • Vérifier que les routes FR fonctionnent sans préfixe
    • Vérifier que les routes EN fonctionnent avec préfixe /en
  • Task 4: Helpers i18n et composables (AC: #4)

    • Créer un composable frontend/app/composables/useLocale.ts pour centraliser la logique i18n
    • Exposer : currentLocale, switchLocale(), localizedPath()
    • Tester useI18n() dans un composant
    • Tester $t('key') dans un template
    • Tester localePath('/projets') pour les liens
    • Tester switchLocalePath('en') pour le switcher de langue
  • Task 5: SEO et balises hreflang (AC: #5, #6)

    • Configurer i18n.head dans nuxt.config.ts pour les balises SEO
    • Vérifier que <html lang="fr"> ou <html lang="en"> est dynamique
    • Vérifier les balises <link rel="alternate" hreflang="fr" href="..." />
    • Vérifier les balises <link rel="alternate" hreflang="en" href="..." />
    • Vérifier <link rel="alternate" hreflang="x-default" href="..." />
  • Task 6: Composant LanguageSwitcher (AC: #4)

    • Créer frontend/app/components/ui/LanguageSwitcher.vue
    • Afficher les langues disponibles (FR / EN)
    • Utiliser switchLocalePath() pour la navigation
    • Highlight de la langue active
    • Accessible au clavier (boutons ou liens)
    • Style cohérent avec le design system (sky-accent pour actif)
  • Task 7: Middleware Laravel SetLocale (AC: #7, #9)

    • Créer api/app/Http/Middleware/SetLocale.php
    • Extraire la langue depuis le header Accept-Language
    • Parser le header (ex: fr-FR,fr;q=0.9,en;q=0.8fr)
    • Valider que la langue est supportée (fr, en)
    • Fallback vers fr si langue non supportée
    • Stocker la langue dans app()->setLocale($lang)
    • Passer la langue via $request->attributes->set('lang', $lang)
    • Enregistrer le middleware dans bootstrap/app.php pour les routes API
  • Task 8: Trait HasTranslations pour les Models (AC: #8)

    • Créer api/app/Traits/HasTranslations.php
    • Méthode getTranslated($keyField, $lang = null) qui :
      • Récupère la clé depuis le champ (ex: $this->title_key)
      • Joint la table translations pour obtenir la valeur
      • Utilise la langue du request ou le fallback
    • Appliquer le trait aux models : Project, Skill
    • Tester : $project->getTranslated('title_key', 'fr')
  • Task 9: API Resources avec traductions (AC: #8)

    • Créer api/app/Http/Resources/ProjectResource.php
    • Transformer les champs *_key en valeurs traduites :
      'title' => $this->getTranslated('title_key'),
      'description' => $this->getTranslated('description_key'),
      
    • Créer api/app/Http/Resources/SkillResource.php de même
    • Inclure meta.lang dans les réponses pour debug/vérification
  • Task 10: Endpoints API avec traductions (AC: #7, #8)

    • Créer api/app/Http/Controllers/Api/ProjectController.php
    • Endpoint GET /api/projects retournant la liste traduite
    • Endpoint GET /api/projects/{slug} retournant le détail traduit
    • Créer api/app/Http/Controllers/Api/SkillController.php
    • Endpoint GET /api/skills retournant la liste traduite par catégorie
    • Enregistrer les routes dans routes/api.php
  • Task 11: Intégration frontend-backend (AC: tous)

    • Créer composable frontend/app/composables/useApi.ts qui :
      • Utilise $fetch ou useFetch de Nuxt
      • Ajoute automatiquement le header X-API-Key
      • Ajoute automatiquement le header Accept-Language selon la locale courante
    • Tester un appel API depuis une page Nuxt
    • Vérifier que le contenu retourné est dans la bonne langue
  • Task 12: Validation finale (AC: tous)

    • Accéder à / → contenu FR
    • Accéder à /en → contenu EN
    • Cliquer sur le switcher FR → EN → URL change vers /en
    • API call avec Accept-Language: en → réponse en anglais
    • API call avec Accept-Language: de → fallback FR
    • Vérifier les balises hreflang dans le code source HTML
    • Vérifier <html lang="..."> dynamique

Dev Notes

Architecture i18n Hybride

┌────────────────────────────────────────────────────────────────────────────┐
│                           FRONTEND (Nuxt 4)                                │
├────────────────────────────────────────────────────────────────────────────┤
│  Contenu statique UI → Fichiers JSON (i18n/fr.json, i18n/en.json)         │
│  - Labels, boutons, navigation, messages d'erreur                          │
│  - Déployé avec le frontend                                                │
│  - Accès via $t('key') ou useI18n()                                        │
└────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    │ API calls avec Accept-Language header
                                    ▼
┌────────────────────────────────────────────────────────────────────────────┐
│                           BACKEND (Laravel 12)                             │
├────────────────────────────────────────────────────────────────────────────┤
│  Contenu dynamique → Table translations (MariaDB)                          │
│  - Titres, descriptions projets/skills                                     │
│  - Textes narrateur, dialogues PNJ                                         │
│  - Géré via API/BDD                                                        │
│  - Accès via HasTranslations trait + API Resources                         │
└────────────────────────────────────────────────────────────────────────────┘

Configuration nuxt.config.ts complète pour i18n

// frontend/nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    '@nuxtjs/i18n',
    // ... autres modules
  ],

  i18n: {
    locales: [
      { code: 'fr', iso: 'fr-FR', file: 'fr.json', name: 'Français' },
      { code: 'en', iso: 'en-US', file: 'en.json', name: 'English' },
    ],
    defaultLocale: 'fr',
    strategy: 'prefix_except_default',
    lazy: true,
    langDir: 'i18n/',
    detectBrowserLanguage: false, // On utilise l'URL uniquement

    // Routes personnalisées
    pages: {
      'projets/index': { en: '/projects' },
      'projets/[slug]': { en: '/projects/[slug]' },
      'competences': { en: '/skills' },
      'temoignages': { en: '/testimonials' },
      'parcours': { en: '/journey' },
      'contact': { en: '/contact' },
      'resume': { en: '/resume' },
    },

    // SEO
    baseUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://skycel.fr',
  },
})

Structure des fichiers de traduction

// frontend/i18n/fr.json
{
  "nav": {
    "home": "Accueil",
    "projects": "Projets",
    "skills": "Compétences",
    "testimonials": "Témoignages",
    "journey": "Parcours",
    "contact": "Contact"
  },
  "common": {
    "continue": "Continuer",
    "back": "Retour",
    "discover": "Découvrir",
    "close": "Fermer",
    "loading": "Chargement..."
  },
  "landing": {
    "title": "Bienvenue dans mon univers",
    "subtitle": "Développeur Full-Stack",
    "cta_adventure": "Partir à l'aventure",
    "cta_express": "Mode express"
  },
  "error": {
    "404": "Page non trouvée",
    "generic": "Une erreur est survenue"
  },
  "meta": {
    "title": "Skycel - Portfolio de Célian",
    "description": "Découvrez mon portfolio interactif et gamifié"
  }
}

Middleware Laravel SetLocale

<?php
// api/app/Http/Middleware/SetLocale.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SetLocale
{
    protected array $supportedLocales = ['fr', 'en'];
    protected string $fallbackLocale = 'fr';

    public function handle(Request $request, Closure $next): Response
    {
        $locale = $this->parseAcceptLanguage($request->header('Accept-Language'));

        app()->setLocale($locale);
        $request->attributes->set('locale', $locale);

        return $next($request);
    }

    protected function parseAcceptLanguage(?string $header): string
    {
        if (!$header) {
            return $this->fallbackLocale;
        }

        // Parse "fr-FR,fr;q=0.9,en;q=0.8" → ['fr', 'en']
        $locales = [];
        foreach (explode(',', $header) as $part) {
            $part = trim($part);
            if (preg_match('/^([a-z]{2})(?:-[A-Z]{2})?(?:;q=([0-9.]+))?$/i', $part, $matches)) {
                $lang = strtolower($matches[1]);
                $quality = isset($matches[2]) ? (float)$matches[2] : 1.0;
                $locales[$lang] = $quality;
            }
        }

        arsort($locales);

        foreach (array_keys($locales) as $lang) {
            if (in_array($lang, $this->supportedLocales)) {
                return $lang;
            }
        }

        return $this->fallbackLocale;
    }
}

Trait HasTranslations

<?php
// api/app/Traits/HasTranslations.php

namespace App\Traits;

use App\Models\Translation;

trait HasTranslations
{
    public function getTranslated(string $keyField, ?string $lang = null): ?string
    {
        $lang = $lang ?? request()->attributes->get('locale', 'fr');
        $key = $this->{$keyField};

        if (!$key) {
            return null;
        }

        return Translation::getTranslation($key, $lang);
    }
}

API Resource avec traductions

<?php
// api/app/Http/Resources/ProjectResource.php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ProjectResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'slug' => $this->slug,
            'title' => $this->getTranslated('title_key'),
            'description' => $this->getTranslated('description_key'),
            'short_description' => $this->getTranslated('short_description_key'),
            'image' => $this->image,
            'url' => $this->url,
            'github_url' => $this->github_url,
            'date_completed' => $this->date_completed?->format('Y-m-d'),
            'is_featured' => $this->is_featured,
            'skills' => SkillResource::collection($this->whenLoaded('skills')),
        ];
    }
}

Composable useApi

// frontend/app/composables/useApi.ts
export const useApi = () => {
  const config = useRuntimeConfig()
  const { locale } = useI18n()

  const apiFetch = async <T>(endpoint: string, options: any = {}) => {
    return await $fetch<T>(`${config.public.apiUrl}${endpoint}`, {
      ...options,
      headers: {
        'X-API-Key': config.public.apiKey,
        'Accept-Language': locale.value,
        'Content-Type': 'application/json',
        ...options.headers,
      },
    })
  }

  return { apiFetch }
}

Dépendances avec Stories précédentes

Cette story DÉPEND de :

  • Story 1.1 : Module @nuxtjs/i18n installé
  • Story 1.2 : Table translations créée, Models Project et Skill avec relations

Cette story PRÉPARE pour :

  • Story 1.4 : Layouts utiliseront $t() pour les labels
  • Story 1.5 : Landing page avec contenu bilingue
  • Story 2.x : Tous les contenus projets/skills seront traduits

Project Structure Notes

Fichiers à créer dans frontend/ :

frontend/
├── i18n/
│   ├── fr.json                    # CRÉER
│   └── en.json                    # CRÉER
├── app/
│   ├── components/
│   │   └── ui/
│   │       └── LanguageSwitcher.vue  # CRÉER
│   └── composables/
│       ├── useLocale.ts           # CRÉER
│       └── useApi.ts              # CRÉER
└── nuxt.config.ts                 # MODIFIER (i18n config)

Fichiers à créer dans api/ :

api/
├── app/
│   ├── Http/
│   │   ├── Controllers/
│   │   │   └── Api/
│   │   │       ├── ProjectController.php   # CRÉER
│   │   │       └── SkillController.php     # CRÉER
│   │   ├── Middleware/
│   │   │   └── SetLocale.php               # CRÉER
│   │   └── Resources/
│   │       ├── ProjectResource.php         # CRÉER
│   │       └── SkillResource.php           # CRÉER
│   └── Traits/
│       └── HasTranslations.php             # CRÉER
├── bootstrap/
│   └── app.php                             # MODIFIER (middleware)
└── routes/
    └── api.php                             # MODIFIER (routes)

References

  • [Source: docs/planning-artifacts/architecture.md#Data-Architecture]
  • [Source: docs/planning-artifacts/architecture.md#Stratégie-i18n]
  • [Source: docs/planning-artifacts/architecture.md#API-Communication-Patterns]
  • [Source: docs/planning-artifacts/epics.md#Story-1.3]
  • [Source: docs/prd-gamification.md#NFR7]

Technical Requirements

Requirement Value Source
i18n module @nuxtjs/i18n 8.x Architecture
Strategy prefix_except_default Architecture
Default locale fr PRD
Supported locales fr, en PRD
SSR Required NFR5, NFR7
SEO hreflang Required NFR5

Previous Story Intelligence (Story 1.2)

Files created in Story 1.2:

  • Table translations avec clés i18n
  • Models Project, Skill avec colonnes *_key
  • Seeders avec données FR et EN

Pattern established:

  • Convention de nommage des clés : {table}.{slug}.{field}
  • Ex: project.skycel.title, skill.vuejs.name

Dev Agent Record

Agent Model Used

Claude Opus 4.5 (claude-opus-4-5-20251101)

Debug Log References

  • hreflang tags non générés au début — résolu en ajoutant useLocaleHead() dans app.vue avec addSeoAttributes: true
  • langDir configuré en '../i18n/' (relatif à app/) car Nuxt 4 utilise app/ comme srcDir

Completion Notes List

  • Frontend: i18n configuré avec lazy loading, fichiers JSON FR/EN, routes localisées, useLocaleHead() pour SEO
  • Frontend: Composable useLocale encapsule la logique i18n, composable useApi ajoute automatiquement les headers
  • Frontend: LanguageSwitcher accessible au clavier avec highlight de la langue active
  • Backend: Middleware SetLocale parse Accept-Language avec support qualité (q=), fallback FR
  • Backend: Trait HasTranslations appliqué à Project et Skill
  • Backend: API Resources transforment les *_key en valeurs traduites, meta.lang inclus
  • Backend: Controllers avec endpoints GET /api/projects, GET /api/projects/{slug}, GET /api/skills
  • Validé: / → FR, /en → EN, hreflang tags, <html lang="fr-FR"> / <html lang="en-US">
  • Validé: API avec Accept-Language: fr → FR, en → EN, de → fallback FR

Change Log

Date Change Author
2026-02-03 Story créée avec contexte complet SM Agent
2026-02-05 Tasks 1-12 implémentées et validées Dev Agent (Claude Opus 4.5)

File List

  • frontend/i18n/fr.json — CRÉÉ
  • frontend/i18n/en.json — CRÉÉ
  • frontend/nuxt.config.ts — MODIFIÉ (config i18n complète)
  • frontend/app/app.vue — MODIFIÉ (useLocaleHead pour SEO)
  • frontend/app/pages/index.vue — MODIFIÉ (utilise $t())
  • frontend/app/composables/useLocale.ts — CRÉÉ
  • frontend/app/composables/useApi.ts — CRÉÉ
  • frontend/app/components/ui/LanguageSwitcher.vue — CRÉÉ
  • api/app/Http/Middleware/SetLocale.php — CRÉÉ
  • api/app/Traits/HasTranslations.php — CRÉÉ
  • api/app/Http/Resources/ProjectResource.php — CRÉÉ
  • api/app/Http/Resources/SkillResource.php — CRÉÉ
  • api/app/Http/Controllers/Api/ProjectController.php — CRÉÉ
  • api/app/Http/Controllers/Api/SkillController.php — CRÉÉ
  • api/bootstrap/app.php — MODIFIÉ (ajout middleware SetLocale)
  • api/routes/api.php — MODIFIÉ (ajout routes projects/skills)
  • api/app/Models/Project.php — MODIFIÉ (ajout HasTranslations trait)
  • api/app/Models/Skill.php — MODIFIÉ (ajout HasTranslations trait)