📋 Contexte du projet : Pour comprendre le contexte complet, les objectifs et les requirements du projet, consultez le fichier
PRD.md.
- Install deps:
pnpm install - Start dev server:
pnpm dev - Build:
pnpm build - Start production server:
pnpm start
- INTERDICTION ABSOLUE : Aucun commentaire dans le code produit
- Le code doit être auto-documenté via des noms explicites
- Utiliser des noms de variables, fonctions et composants descriptifs
- Si le code nécessite un commentaire, il doit être refactoré
- Minimaliste : Écrire le minimum de code nécessaire
- Explicite : Préférer la clarté à la concision excessive
- DRY : Don't Repeat Yourself - factoriser systématiquement
- KISS : Keep It Simple, Stupid - éviter la sur-ingénierie
- YAGNI : You Aren't Gonna Need It - ne pas anticiper les besoins futurs
// tsconfig.json doit avoir
{
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}OBLIGATOIRE :
- Typer toutes les fonctions (paramètres et retour)
- Typer toutes les props de composants
- Typer tous les états (useState, useReducer)
- Typer toutes les réponses API
- Utiliser des interfaces pour les objets complexes
- Utiliser des types pour les unions et intersections
INTERDIT :
any(sauf cas exceptionnel justifié)@ts-ignoreou@ts-expect-error- Typage implicite
- Type casting non justifié (
as)
EXEMPLES :
interface ButtonProps {
variant: "primary" | "secondary" | "outline";
size?: "sm" | "md" | "lg";
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}
export function Button({
variant,
size = "md",
onClick,
disabled = false,
children,
}: ButtonProps): JSX.Element {
return (
<button
onClick={onClick}
disabled={disabled}
className={getButtonClasses(variant, size)}
>
{children}
</button>
);
}- Interface : Pour les props de composants et objets extensibles
- Type : Pour les unions, intersections, utilitaires
interface User {
id: string;
name: string;
email: string;
}
type UserRole = "admin" | "user" | "guest";
type UserWithRole = User & { role: UserRole };OBLIGATOIRE :
- Composants fonctionnels uniquement (pas de classes)
- Un composant = un fichier
- Nommage PascalCase pour les composants
- Export nommé pour les composants réutilisables
- Export par défaut pour les pages Next.js
- Utiliser
export default functionpour tous les composants (sauf les pages Next.js qui sont déjà exportées par défaut).
ORDRE DANS LE COMPOSANT :
- Imports
- Types/Interfaces
- Constantes du composant
- Composant principal
- Sous-composants (si petits et locaux)
- Exports
import { useState } from "react";
import { Button } from "@/components/ui/button";
interface ContactFormProps {
onSubmit: (data: FormData) => Promise<void>;
}
interface FormData {
name: string;
email: string;
}
export function ContactForm({ onSubmit }: ContactFormProps): JSX.Element {
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(
e: React.FormEvent<HTMLFormElement>
): Promise<void> {
e.preventDefault();
setIsSubmitting(true);
const formData = new FormData(e.currentTarget);
await onSubmit({
name: formData.get("name") as string,
email: formData.get("email") as string,
});
setIsSubmitting(false);
}
return (
<form onSubmit={handleSubmit}>
<input type="text" name="name" required />
<input type="email" name="email" required />
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Envoi..." : "Envoyer"}
</Button>
</form>
);
}RÈGLES :
- Hooks en début de composant (après les déclarations)
- Hooks conditionnels interdits
- Créer des hooks custom pour logique réutilisable
- Préfixer les hooks custom par
use
function useScrollAnimation() {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => setIsVisible(entry.isIntersecting),
{ threshold: 0.1 }
);
return () => observer.disconnect();
}, []);
return isVisible;
}SERVER COMPONENTS (par défaut) :
- Pas d'interactivité
- Pas de hooks (useState, useEffect, etc.)
- Accès direct à la base de données / API
- Meilleure performance
CLIENT COMPONENTS :
- Directive
'use client'en première ligne - Interactivité requise
- Hooks React
- Event handlers
"use client";
import { useState } from "react";
export function Counter(): JSX.Element {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}OBLIGATOIRE :
- Utiliser uniquement les classes utilitaires Tailwind
- Pas de CSS custom sauf cas exceptionnels
- Responsive-first (mobile d'abord)
- Grouper les classes par catégorie
ORDRE DES CLASSES :
- Layout (flex, grid, display)
- Spacing (p-, m-)
- Sizing (w-, h-)
- Typography (text-, font-)
- Colors (bg-, text-)
- Borders (border-, rounded-)
- Effects (shadow-, opacity-)
- Transitions/Animations
- Responsive variants (md:, lg:)
- States (hover:, focus:)
<button
className="
flex items-center justify-center
px-6 py-3
w-full
text-base font-semibold
bg-blue-600 text-white
rounded-lg
shadow-md
transition-all duration-300
hover:bg-blue-700 hover:shadow-lg
focus:outline-none focus:ring-2 focus:ring-blue-500
disabled:opacity-50 disabled:cursor-not-allowed
md:w-auto
"
>
Click me
</button>BREAKPOINTS :
- Mobile first : styles par défaut = mobile
sm:(640px) : Small tabletsmd:(768px) : Tabletslg:(1024px) : Desktopxl:(1280px) : Large desktop
<div className="
grid grid-cols-1 gap-4
md:grid-cols-2 md:gap-6
lg:grid-cols-3 lg:gap-8
">OBLIGATOIRE : Utiliser la fonction cn (de lib/utils) pour les classes conditionnelles
import { cn } from "@/lib/utils";
interface ButtonProps {
variant: "primary" | "secondary";
isActive?: boolean;
}
function Button({ variant, isActive }: ButtonProps) {
return (
<button
className={cn(
"px-4 py-2 rounded-lg transition-colors",
variant === "primary" && "bg-blue-600 text-white hover:bg-blue-700",
variant === "secondary" &&
"bg-gray-200 text-gray-900 hover:bg-gray-300",
isActive && "ring-2 ring-blue-500"
)}
/>
);
}OBLIGATOIRE :
- Valider toutes les entrées utilisateur
- Valider côté client ET serveur
- Utiliser Zod pour les schémas de validation
- Sanitiser les données avant affichage
import { z } from "zod";
const contactFormSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
phone: z.string().regex(/^[0-9]{10}$/),
message: z.string().min(10).max(1000),
});
type ContactFormData = z.infer<typeof contactFormSchema>;
export async function validateContactForm(
data: unknown
): Promise<ContactFormData> {
return contactFormSchema.parse(data);
}OBLIGATOIRE :
- Ne jamais utiliser
dangerouslySetInnerHTMLsauf cas justifié - Échapper les données utilisateur
- Utiliser des composants React (échappement automatique)
INTERDIT :
<div dangerouslySetInnerHTML={{ __html: userInput }} />AUTORISÉ :
<div>{userInput}</div>OBLIGATOIRE :
- Valider les méthodes HTTP
- Valider et sanitiser les données
- Rate limiting
- CORS approprié
- Gestion des erreurs sécurisée (pas de stack traces en prod)
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const requestSchema = z.object({
email: z.string().email(),
});
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const body = await request.json();
const validatedData = requestSchema.parse(body);
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: "Invalid data" }, { status: 400 });
}
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}OBLIGATOIRE :
- Préfixer les variables publiques par
NEXT_PUBLIC_ - Ne JAMAIS exposer de secrets côté client
- Valider les variables au démarrage
const envSchema = z.object({
NEXT_PUBLIC_SITE_URL: z.string().url(),
DATABASE_URL: z.string(),
API_SECRET: z.string().min(32),
});
const env = envSchema.parse(process.env);
export default env;OBLIGATOIRE :
- Utiliser
next/imagepour toutes les images - Définir
widthetheightoufill - Utiliser
prioritypour images above-the-fold - Format WebP avec fallback
- Lazy loading par défaut
import Image from "next/image";
<Image
src="/team-photo.jpg"
alt="Notre équipe d'électriciens"
width={1200}
height={800}
quality={85}
priority
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>;OBLIGATOIRE :
- Dynamic imports pour composants lourds
- Lazy loading des routes
- Suspense boundaries
import dynamic from "next/dynamic";
import { Suspense } from "react";
const HeavyComponent = dynamic(() => import("./HeavyComponent"), {
loading: () => <LoadingSpinner />,
ssr: false,
});
function Page() {
return (
<Suspense fallback={<LoadingSpinner />}>
<HeavyComponent />
</Suspense>
);
}OBLIGATOIRE :
useMemopour calculs coûteuxuseCallbackpour fonctions passées en propsReact.memopour composants purs et lourds- Éviter les re-renders inutiles
import { useMemo, useCallback, memo } from "react";
interface ListProps {
items: Item[];
onItemClick: (id: string) => void;
}
export const List = memo(function List({ items, onItemClick }: ListProps) {
const sortedItems = useMemo(
() => items.sort((a, b) => a.name.localeCompare(b.name)),
[items]
);
const handleClick = useCallback(
(id: string) => onItemClick(id),
[onItemClick]
);
return (
<ul>
{sortedItems.map((item) => (
<li key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</li>
))}
</ul>
);
});OBLIGATOIRE :
- Utiliser
next/fontpour l'optimisation - Précharger les fonts critiques
- Utiliser
font-display: swap
import { Inter } from "next/font/inter";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="fr" className={inter.variable}>
<body>{children}</body>
</html>
);
}RÈGLES :
- Utiliser pour animations React déclaratives
- Préférer
motioncomponents - Variants pour animations complexes
layoutIdpour shared layout animations
import { motion } from "framer-motion";
const fadeInVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: "easeOut" },
},
};
export function Card() {
return (
<motion.div
variants={fadeInVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.3 }}
className="bg-white p-6 rounded-lg"
>
Content
</motion.div>
);
}RÈGLES :
- Utiliser pour animations complexes et scroll-based
- ScrollTrigger pour animations au scroll
- Cleanup dans useEffect return
- GPU acceleration (
force3D: true)
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
export function AnimatedSection() {
const sectionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const ctx = gsap.context(() => {
gsap.from(".card", {
scrollTrigger: {
trigger: sectionRef.current,
start: "top 80%",
end: "bottom 20%",
toggleActions: "play none none reverse",
},
opacity: 0,
y: 50,
stagger: 0.2,
duration: 0.8,
ease: "power2.out",
});
}, sectionRef);
return () => ctx.revert();
}, []);
return (
<div ref={sectionRef}>
<div className="card">Card 1</div>
<div className="card">Card 2</div>
</div>
);
}RÈGLES :
- Initialiser une seule fois dans layout
- Smooth scroll sur toute la page
- Intégrer avec GSAP ScrollTrigger
"use client";
import { useEffect } from "react";
import Lenis from "@studio-freight/lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
export function SmoothScroll({ children }: { children: React.ReactNode }) {
useEffect(() => {
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
});
function raf(time: number) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
gsap.ticker.lagSmoothing(0);
return () => {
lenis.destroy();
gsap.ticker.remove(raf);
};
}, []);
return <>{children}</>;
}OBLIGATOIRE :
- Animer uniquement
transformetopacity - Éviter animations sur
width,height,top,left will-changeavec parcimonie- Respecter
prefers-reduced-motion
import { motion } from "framer-motion";
export function AccessibleAnimation() {
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.3,
ease: "easeOut",
}}
style={{
"@media (prefers-reduced-motion: reduce)": {
animation: "none",
transition: "none",
},
}}
>
Content
</motion.div>
);
}/src
├── /app
│ ├── layout.tsx
│ ├── page.tsx
│ ├── /prestations
│ │ └── page.tsx
│ └── /api
│ └── /contact
│ └── route.ts
├── /components
│ ├── /ui (ShadcN components)
│ ├── /layout (Header, Footer, etc.)
│ ├── /sections (Hero, Services, etc.)
│ └── /forms
├── /lib
│ ├── utils.ts
│ ├── validations.ts
│ └── constants.ts
├── /hooks
│ ├── useSmoothScroll.ts
│ └── useScrollAnimation.ts
└── /types
└── index.ts
FICHIERS :
- Composants : PascalCase (
ContactForm.tsx) - Utilitaires : camelCase (
formatPhone.ts) - Constantes : camelCase (
constants.ts) - Types : camelCase (
types.ts)
VARIABLES/FONCTIONS :
- camelCase pour tout
- Descriptif et explicite
- Verbe pour les fonctions (
handleClick,fetchUser) - Booléens préfixés (
isLoading,hasError,canSubmit)
const isFormValid = true;
const userList = [];
const maxRetries = 3;
function calculateTotal(items: Item[]): number {}
function handleSubmit(e: FormEvent): void {}OBLIGATOIRE :
- Extraire les valeurs magiques
- Grouper par domaine
- UPPER_SNAKE_CASE pour constantes primitives
- camelCase pour objets de configuration
export const PHONE_NUMBER = "0412345678";
export const EMERGENCY_NUMBER = "0698765432";
export const MAX_MESSAGE_LENGTH = 1000;
export const serviceTypes = [
{ value: "depannage", label: "Dépannage" },
{ value: "installation", label: "Installation" },
{ value: "mise-aux-normes", label: "Mise aux normes" },
] as const;
export const interventionZone = {
center: "Marseille",
radius: 50,
cities: ["Marseille", "Aix-en-Provence", "Aubagne"],
} as const;OBLIGATOIRE :
- Contraste minimum 4.5:1 (texte normal)
- Contraste minimum 3:1 (texte large)
- Navigation au clavier complète
- Focus visible
- Labels sur tous les inputs
- Alt text sur toutes les images
- Attributs ARIA appropriés
<button
type="button"
onClick={handleClick}
aria-label="Ouvrir le menu"
aria-expanded={isOpen}
aria-controls="mobile-menu"
className="focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<MenuIcon aria-hidden="true" />
</button>
<input
type="email"
id="email"
name="email"
aria-label="Adresse email"
aria-required="true"
aria-invalid={hasError}
aria-describedby={hasError ? 'email-error' : undefined}
/>
{hasError && (
<span id="email-error" role="alert">
Email invalide
</span>
)}"use client";
import { useEffect, useState } from "react";
export function useReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
setPrefersReducedMotion(mediaQuery.matches);
const handler = (e: MediaQueryListEvent) =>
setPrefersReducedMotion(e.matches);
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, []);
return prefersReducedMotion;
}OBLIGATOIRE :
- Try-catch pour code asynchrone
- Error boundaries pour erreurs React
- Messages d'erreur utilisateur-friendly
- Logging des erreurs (pas de console.log en prod)
async function fetchData(): Promise<Data> {
try {
const response = await fetch("/api/data");
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return await response.json();
} catch (error) {
if (error instanceof Error) {
console.error("Fetch error:", error.message);
}
throw new Error("Unable to fetch data");
}
}"use client";
import { Component, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error): void {
console.error("Error boundary caught:", error);
}
render(): ReactNode {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className="p-8 text-center">
<h2 className="text-xl font-bold mb-4">Une erreur est survenue</h2>
<button
onClick={() => this.setState({ hasError: false })}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Réessayer
</button>
</div>
)
);
}
return this.props.children;
}
}OBLIGATOIRE :
- React Hook Form pour gestion d'état
- Zod pour validation
- Messages d'erreur français
- Disabled pendant soumission
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(2, "Le nom doit contenir au moins 2 caractères"),
email: z.string().email("Email invalide"),
phone: z.string().regex(/^[0-9]{10}$/, "Numéro de téléphone invalide"),
message: z
.string()
.min(10, "Le message doit contenir au moins 10 caractères"),
});
type FormData = z.infer<typeof formSchema>;
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
});
async function onSubmit(data: FormData): Promise<void> {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("Submission failed");
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<input
{...register("name")}
type="text"
placeholder="Nom"
className="w-full px-4 py-2 border rounded"
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{isSubmitting ? "Envoi..." : "Envoyer"}
</button>
</form>
);
}import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Électricien Marseille | 15 ans d'expérience | Urgence 24/7",
description:
"Entreprise d'électricité à Marseille. Dépannage, installation, mise aux normes. 4 électriciens qualifiés. Intervention rapide.",
keywords: [
"électricien Marseille",
"dépannage électrique",
"urgence électricien",
],
authors: [{ name: "Votre Entreprise" }],
openGraph: {
title: "Électricien Marseille",
description: "Dépannage et installation électrique à Marseille",
url: "https://votresite.fr",
siteName: "Votre Entreprise",
images: [
{
url: "/og-image.jpg",
width: 1200,
height: 630,
},
],
locale: "fr_FR",
type: "website",
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
};export function generateLocalBusinessSchema() {
return {
"@context": "https://schema.org",
"@type": "LocalBusiness",
name: "Votre Entreprise Électricité",
description: "Entreprise d'électricité à Marseille",
telephone: "+33412345678",
address: {
"@type": "PostalAddress",
streetAddress: "Votre adresse",
addressLocality: "Marseille",
postalCode: "13000",
addressCountry: "FR",
},
geo: {
"@type": "GeoCoordinates",
latitude: 43.296482,
longitude: 5.36978,
},
openingHours: "Mo-Fr 08:00-18:00",
priceRange: "€€",
areaServed: {
"@type": "GeoCircle",
geoMidpoint: {
"@type": "GeoCoordinates",
latitude: 43.296482,
longitude: 5.36978,
},
geoRadius: "50000",
},
};
}