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>
17 KiB
Story 1.3: Système i18n frontend + API bilingue
Status: ready-for-dev
Story
As a visiteur, I want voir le site dans ma langue (FR ou EN), so that je comprends le contenu.
Acceptance Criteria
- Given le module
@nuxtjs/i18nconfiguré avec stratégieprefix_except_defaultWhen le visiteur accède à/ou/enThen le contenu statique UI est affiché dans la langue correspondante via fichiers JSON (i18n/fr.json,i18n/en.json) - And les URLs FR sont par défaut (
/,/projets,/competences,/contact) - And les URLs EN sont préfixées (
/en,/en/projects,/en/skills,/en/contact) - And
useI18n(),$t(),localePath(),switchLocalePath()fonctionnent en SSR - And les tags
hreflangsont générés automatiquement dans le<head> - And l'attribut
langdu<html>est dynamique (fr/en) - And le middleware Laravel extrait
Accept-Languageet joint la tabletranslationspour le contenu dynamique - And les API Resources Laravel renvoient le contenu traduit selon la langue demandée
- 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/i18nest installé (Story 1.1) - Créer la structure
frontend/i18n/pour les fichiers de traduction - Configurer
nuxt.config.tsavec i18n complet :- locales: ['fr', 'en']
- defaultLocale: 'fr'
- strategy: 'prefix_except_default'
- detectBrowserLanguage: false (on utilise l'URL)
- Activer
vueI18npour le composant<i18n-t>
- Vérifier que
-
Task 2: Fichiers de traduction JSON (AC: #1)
- Créer
frontend/i18n/fr.jsonavec structure de base - Créer
frontend/i18n/en.jsonavec 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
- Créer
-
Task 3: Routes localisées Nuxt (AC: #2, #3)
- Configurer
i18n.pagesdans 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
- Configurer
-
Task 4: Helpers i18n et composables (AC: #4)
- Créer un composable
frontend/app/composables/useLocale.tspour 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
- Créer un composable
-
Task 5: SEO et balises hreflang (AC: #5, #6)
- Configurer
i18n.headdans 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="..." />
- Configurer
-
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)
- Créer
-
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.8→fr) - Valider que la langue est supportée (fr, en)
- Fallback vers
frsi 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.phppour les routes API
- Créer
-
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
translationspour obtenir la valeur - Utilise la langue du request ou le fallback
- Récupère la clé depuis le champ (ex:
- Appliquer le trait aux models : Project, Skill
- Tester :
$project->getTranslated('title_key', 'fr')
- Créer
-
Task 9: API Resources avec traductions (AC: #8)
- Créer
api/app/Http/Resources/ProjectResource.php - Transformer les champs
*_keyen valeurs traduites :'title' => $this->getTranslated('title_key'), 'description' => $this->getTranslated('description_key'), - Créer
api/app/Http/Resources/SkillResource.phpde même - Inclure
meta.langdans les réponses pour debug/vérification
- Créer
-
Task 10: Endpoints API avec traductions (AC: #7, #8)
- Créer
api/app/Http/Controllers/Api/ProjectController.php - Endpoint
GET /api/projectsretournant 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/skillsretournant la liste traduite par catégorie - Enregistrer les routes dans
routes/api.php
- Créer
-
Task 11: Intégration frontend-backend (AC: tous)
- Créer composable
frontend/app/composables/useApi.tsqui :- Utilise
$fetchouuseFetchde Nuxt - Ajoute automatiquement le header
X-API-Key - Ajoute automatiquement le header
Accept-Languageselon la locale courante
- Utilise
- Tester un appel API depuis une page Nuxt
- Vérifier que le contenu retourné est dans la bonne langue
- Créer composable
-
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
- Accéder à
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
translationscréé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
translationsavec 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
{{agent_model_name_version}}
Debug Log References
Completion Notes List
Change Log
| Date | Change | Author |
|---|---|---|
| 2026-02-03 | Story créée avec contexte complet | SM Agent |