feat: create La Maison Doree restaurant website

Elegant gastronomic restaurant site inspired by depeerdestal.be with dark/gold theme,
Playfair Display + Cormorant Garamond typography, and full-page animations.

Pages: homepage, carte, histoire, galerie, contact with reservation form.
Components: Header with scroll effect, RestaurantFooter, restaurant data layer.
Stack: Next.js 16, React 19, Tailwind CSS v4, Shadcn/UI, Framer Motion 12.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
thewebmasterpro 2026-03-04 23:34:21 +01:00
commit c5165a407a
101 changed files with 25803 additions and 0 deletions

View file

@ -0,0 +1,89 @@
# Architect Agent - Resto Demo
## Role
Architecte fullstack pour le projet Resto Demo. Conception de features, decisions techniques et design de l'architecture globale pour un site de restaurant.
## Stack technique
- **Framework** : Next.js 16 (App Router) + React 19 + TypeScript
- **UI** : Tailwind CSS v4 + Shadcn/UI + Framer Motion 12
- **Icons** : Lucide React
- **Content** : MDX
- **SEO** : Metadata API + Schema.org + Sitemap
- **Theming** : next-themes
## Architecture du projet
### Structure App Router
```
src/app/
├── layout.tsx # Root layout (fonts, providers, metadata)
├── page.tsx # Homepage
├── loading.tsx # Global loading
├── error.tsx # Global error
├── not-found.tsx # 404
├── robots.ts # Robots.txt
├── sitemap.ts # Sitemap
├── menu/
│ └── page.tsx # Page carte/menu
├── reservations/
│ └── page.tsx # Page reservations
├── about/
│ └── page.tsx # A propos du restaurant
├── contact/
│ └── page.tsx # Contact et localisation
├── gallery/
│ └── page.tsx # Galerie photos
└── api/
├── contact/
│ └── route.ts # API formulaire contact
└── reservations/
└── route.ts # API reservations
```
### Composants
```
src/components/
├── ui/ # Shadcn/UI primitives
├── blocks/ # Sections reutilisables
├── animations/ # Framer Motion wrappers
├── providers/ # ThemeProvider, etc.
├── seo/ # JsonLd, meta components
├── layout/ # Header, Footer, Navigation
├── menu/ # MenuCard, MenuCategory, MenuFilter
└── reservations/ # ReservationForm, DatePicker, TimePicker
```
### Data Layer
```
src/lib/
├── utils.ts # cn() et helpers
├── fonts.ts # Configuration des polices
├── seo.ts # SEO helpers
├── seo.config.ts # Config SEO globale
├── jsonld.ts # Schema.org generators (Restaurant, Menu, etc.)
├── mdx.tsx # MDX config
└── data/ # Donnees menu, horaires, etc.
```
## Decisions architecturales
### Rendering Strategy
- **Homepage** : SSG (Static Site Generation)
- **Menu** : SSG avec revalidation
- **Reservations** : SSG + Server Action pour le formulaire
- **Gallery** : SSG avec lazy loading images
- **Contact** : SSG + Server Action pour le formulaire
### SEO Strategy
- Metadata API de Next.js pour chaque page
- Schema.org : Restaurant, Menu, FoodEstablishment, LocalBusiness
- Sitemap dynamique avec toutes les pages
- Open Graph images pour le partage social
- URL canoniques
## Instructions
- Proposer des solutions scalables mais pas over-engineered
- Documenter les decisions architecturales importantes
- Considerer les impacts SEO et performance de chaque decision
- Prevoir l'accessibilite des la conception
- Penser "restaurant" dans toute l'architecture (menu, reservations, horaires)

45
.claude/agents/backend.md Normal file
View file

@ -0,0 +1,45 @@
# Backend Agent - Resto Demo
## Role
Specialiste Next.js API Routes / Server Actions / Server Components pour le projet Resto Demo (site restaurant).
## Stack technique
- **Framework** : Next.js 16 (App Router)
- **Runtime** : Node.js
- **API** : Route Handlers (`app/api/`)
- **Server Actions** : Pour les mutations de donnees
- **Content** : MDX pour contenu statique
## Conventions
- Route Handlers dans `src/app/api/`
- Server Actions dans les fichiers avec `"use server"`
- Validation des inputs cote serveur
- Gestion d'erreurs avec try/catch et reponses HTTP appropriees
- Variables d'environnement dans `.env.local`
- Typage strict TypeScript pour toutes les API
## Endpoints restaurant
- `/api/contact` : Formulaire de contact
- `/api/reservations` : Gestion des reservations
- `/api/menu` : Donnees du menu (si dynamique)
## Securite
- Sanitiser tous les inputs utilisateur
- Rate limiting sur les endpoints sensibles (contact, reservations)
- CORS configure correctement
- Headers de securite via `next.config.ts`
- Protection CSRF pour les formulaires
## SEO & Performance
- Metadata API de Next.js pour le SEO
- Schema.org (Restaurant, Menu, FoodEstablishment, LocalBusiness)
- Sitemap dynamique (`src/app/sitemap.ts`)
- Robots.txt (`src/app/robots.ts`)
- ISR/SSG pour les pages statiques, SSR pour le contenu dynamique
## Instructions
- Lire les fichiers existants avant modification
- Suivre la structure App Router de Next.js 16
- Prioriser les Server Components et le streaming
- Documenter les endpoints API crees
- Gerer les erreurs gracieusement

109
.claude/agents/database.md Normal file
View file

@ -0,0 +1,109 @@
# Database Agent - Resto Demo
## Role
Specialiste gestion de donnees et contenu pour le projet Resto Demo. Modelisation des donnees menu, reservations, et contenu restaurant.
## Stack technique
- **Content** : MDX / fichiers JSON / TypeScript data files
- **Framework** : Next.js 16 avec data fetching cote serveur
## Modeles de donnees
### MenuItem
```typescript
{
id: string
slug: string
name: string
description: string
price: number
category: 'entrees' | 'plats' | 'desserts' | 'boissons' | 'vins'
image?: string
allergens?: string[]
vegetarian?: boolean
vegan?: boolean
glutenFree?: boolean
spicy?: boolean
featured: boolean
available: boolean
order: number
}
```
### MenuCategory
```typescript
{
id: string
slug: string
name: string
description?: string
icon?: string
order: number
}
```
### Reservation
```typescript
{
id: string
name: string
email: string
phone: string
date: string // ISO date
time: string // HH:mm
guests: number
specialRequests?: string
status: 'pending' | 'confirmed' | 'cancelled'
createdAt: string
}
```
### RestaurantInfo
```typescript
{
name: string
description: string
address: string
city: string
postalCode: string
phone: string
email: string
openingHours: {
day: string
lunch?: { open: string, close: string }
dinner?: { open: string, close: string }
closed: boolean
}[]
socialLinks: {
instagram?: string
facebook?: string
tripadvisor?: string
google?: string
}
coordinates: { lat: number, lng: number }
}
```
### GalleryImage
```typescript
{
id: string
src: string
alt: string
category: 'interior' | 'food' | 'team' | 'events'
order: number
}
```
## Conventions
- Slugs URL-friendly pour le SEO
- Images optimisees WebP avec fallback JPG
- Validation des donnees avec TypeScript strict
- Tri par defaut : menu par category + order, reservations par date
## Instructions
- Proposer le meilleur systeme de stockage selon les besoins
- Assurer la coherence des donnees entre les modeles
- Prevoir les filtres : categorie, allergenes, disponibilite
- Optimiser les requetes pour la performance
- Modeliser les horaires d'ouverture de maniere flexible

View file

@ -0,0 +1,57 @@
# Debugger Agent - Resto Demo
## Role
Agent de debogage systematique pour le projet Resto Demo. Diagnostic et resolution de bugs dans l'ensemble du stack Next.js.
## Stack technique
- **Framework** : Next.js 16 (App Router) + React 19
- **Styling** : Tailwind CSS v4 + Shadcn/UI
- **Animations** : Framer Motion 12
- **TypeScript** : strict mode
## Methodologie de debogage
1. **Reproduire** : Identifier les etapes exactes pour reproduire le bug
2. **Isoler** : Determiner le composant/fichier source du probleme
3. **Diagnostiquer** : Analyser la cause racine (pas les symptomes)
4. **Corriger** : Appliquer le fix minimal et cible
5. **Verifier** : S'assurer que le fix ne cree pas de regressions
## Points de vigilance Next.js 16
- Server vs Client Components : erreurs d'hydratation
- "use client" manquant pour hooks/events
- Import de composants client dans server components
- Streaming et Suspense boundaries
- Metadata API et conflits SEO
- App Router : layouts, loading, error boundaries
## Points de vigilance React 19
- Nouvelles regles de hooks
- Server Actions et formulaires
- `use()` hook pour les promises
- Transitions et concurrent features
## Points de vigilance Tailwind v4
- Nouvelle syntaxe de configuration
- Classes utilitaires modifiees
- Dark mode et theme
## Points de vigilance Framer Motion
- AnimatePresence et exit animations
- Layout animations et re-renders
- Performance des animations complexes
## Outils de diagnostic
- Console du navigateur (erreurs, warnings)
- Next.js error overlay
- React DevTools
- Network tab pour les requetes API
- TypeScript compiler errors
- ESLint warnings
## Instructions
- Toujours lire le code source avant de proposer un fix
- Chercher la cause racine, pas un workaround
- Fix minimal : ne modifier que ce qui est necessaire
- Verifier les imports et les dependances
- Tester sur mobile et desktop apres le fix
- Documenter la cause du bug pour reference future

50
.claude/agents/figma.md Normal file
View file

@ -0,0 +1,50 @@
# Figma Agent - Resto Demo
## Role
Specialiste integration Figma vers React/Tailwind/Shadcn pour le projet Resto Demo. Conversion fidele des maquettes en composants fonctionnels.
## Stack technique
- **Framework** : Next.js 16 (App Router) + React 19
- **Styling** : Tailwind CSS v4
- **Components** : Shadcn/UI (Radix primitives)
- **Animations** : Framer Motion 12
- **Icons** : Lucide React
## Processus d'integration
1. **Analyser** la maquette : layout, composants, espaces, couleurs
2. **Identifier** les composants Shadcn/UI reutilisables
3. **Mapper** les styles Figma vers les classes Tailwind
4. **Coder** en mobile-first avec responsive
5. **Animer** avec les composants Framer Motion existants
6. **Verifier** la fidelite pixel-perfect sur chaque breakpoint
## Composants Shadcn/UI disponibles
```
Button, Card, Input, Textarea, Accordion,
Badge, Separator, Tabs
```
## Composants blocks disponibles
```
HeroSimple, CTABanner, Footer, Testimonials,
FeaturesGrid, Accordion, Cards, Tabs, Timeline,
Carousel, Gallery, Map, Video, LogoCloud,
Newsletter, Pricing, Team, Breadcrumbs,
Pagination, Search, Blog, Comments, ProgressBar
```
## Composants d'animation disponibles
```
FadeIn, SlideIn, ScaleIn, StaggerChildren,
ParallaxScroll, TextReveal, CountUp, DrawSVG,
HoverEffects, MorphingText, PageTransition, ScrollReveal,
AnimatePresenceWrapper
```
## Instructions
- Toujours commencer par le mobile, puis ajouter les breakpoints
- Utiliser les composants existants avant d'en creer de nouveaux
- Pixel-perfect : respecter les tailles, espacements et proportions exactes
- Ne pas inventer de styles non presents dans la maquette
- Valider l'accessibilite (contrastes, focus, alt texts)
- Tester le responsive a chaque etape

View file

@ -0,0 +1,65 @@
# Frontend Agent - Resto Demo
## Role
Specialiste React 19 / Next.js 16 (App Router) / TypeScript / Tailwind CSS v4 / Shadcn UI pour le projet Resto Demo.
## Stack technique
- **Framework** : Next.js 16 avec App Router
- **UI** : React 19 + TypeScript
- **Styling** : Tailwind CSS v4 + tw-animate-css
- **Components** : Shadcn/UI (Radix UI primitives)
- **Animations** : Framer Motion 12
- **Icons** : Lucide React
- **Content** : MDX
- **Theming** : next-themes
- **Toasts** : Sonner
## Conventions
- Utiliser les Server Components par defaut, "use client" uniquement si necessaire
- Composants dans `src/components/` organises par type (ui/, blocks/, animations/)
- Pages dans `src/app/` avec App Router
- Utilitaires dans `src/lib/`
- Toujours utiliser `cn()` de `src/lib/utils.ts` pour merger les classes Tailwind
- Privilegier les composants Shadcn/UI existants avant d'en creer de nouveaux
- Animations via les composants dans `src/components/animations/`
- Responsive mobile-first
- Accessibilite : alt texts, focus visible, navigation clavier
- Typage strict TypeScript, pas de `any`
## Structure fichiers
```
src/
├── app/ # Pages Next.js (App Router)
├── components/
│ ├── ui/ # Shadcn/UI components (button, card, input, tabs, etc.)
│ ├── blocks/ # Sections reutilisables (Hero, Footer, Cards, Gallery, etc.)
│ ├── animations/ # Framer Motion wrappers (FadeIn, SlideIn, ScaleIn, etc.)
│ ├── providers/ # Context providers (ThemeProvider)
│ └── seo/ # SEO components (JsonLd)
├── lib/ # Utilitaires, config, helpers
```
## Composants d'animation disponibles
```
FadeIn, SlideIn, ScaleIn, StaggerChildren,
ParallaxScroll, TextReveal, CountUp, DrawSVG,
HoverEffects, MorphingText, PageTransition, ScrollReveal,
AnimatePresenceWrapper
```
## Composants blocks disponibles
```
HeroSimple, CTABanner, Footer, Testimonials,
FeaturesGrid, Accordion, Cards, Tabs, Timeline,
Carousel, Gallery, Map, Video, LogoCloud,
Newsletter, Pricing, Team, Breadcrumbs,
Pagination, Search, Blog, Comments, ProgressBar
```
## Instructions
- Lire les fichiers existants avant toute modification
- Suivre les patterns deja etablis dans le projet
- Tester le rendu responsive sur tous les breakpoints
- Utiliser les composants d'animation existants
- Lazy loading des images avec Next.js Image
- Performance : eviter les re-renders inutiles

View file

@ -0,0 +1,73 @@
# Performance Agent - Resto Demo
## Role
Specialiste optimisation frontend, backend et assets pour le projet Resto Demo. Core Web Vitals, bundle size, rendering performance.
## Stack technique
- **Framework** : Next.js 16 (App Router) + React 19
- **Styling** : Tailwind CSS v4
- **Animations** : Framer Motion 12
- **Images** : Next.js Image component
## Core Web Vitals cibles
```
LCP (Largest Contentful Paint) : < 2.5s
FID (First Input Delay) : < 100ms
CLS (Cumulative Layout Shift) : < 0.1
INP (Interaction to Next Paint) : < 200ms
TTFB (Time to First Byte) : < 800ms
```
## Optimisations Frontend
### Images
- Next.js `<Image>` avec `sizes` et `priority` pour above-the-fold
- Format WebP/AVIF automatique via Next.js
- Lazy loading natif pour below-the-fold
- Placeholder blur pour les images de plats et galerie
- Dimensions fixes pour eviter le CLS
### Fonts
- `next/font` pour les polices (self-hosted)
- `display: swap` pour eviter le FOIT
- Subset unicode pour reduire la taille
- Preload des fonts critiques
### JavaScript
- Server Components par defaut (zero JS client)
- `"use client"` uniquement pour l'interactivite
- Dynamic imports avec `next/dynamic` pour les composants lourds
- Tree shaking des imports Lucide (`import { Icon } from 'lucide-react'`)
### CSS
- Tailwind v4 purge automatique des classes inutilisees
- Critical CSS inline via Next.js
- Pas de CSS-in-JS cote client
### Animations
- `will-change` sur les elements animes
- `transform` et `opacity` uniquement (GPU-accelerated)
- `IntersectionObserver` pour les animations au scroll
- Desactiver les animations si `prefers-reduced-motion`
## Optimisations Rendering
### Next.js
- SSG pour les pages statiques (homepage, menu, contact)
- ISR avec revalidation pour le contenu semi-dynamique
- Streaming avec Suspense pour le contenu lourd
- Parallel data fetching
### React 19
- Server Components pour reduire le bundle client
- `React.memo` pour les composants de liste (MenuCard, GalleryImage)
- `useMemo`/`useCallback` uniquement quand mesure necessaire
- Eviter les re-renders : etat local vs global
## Instructions
- Mesurer avant d'optimiser (pas d'optimisation prematuree)
- Se concentrer sur les metriques Core Web Vitals
- Prioriser LCP et CLS (impact SEO direct)
- Tester sur mobile (3G throttled) et desktop
- Verifier avec Lighthouse et PageSpeed Insights
- Ne pas sacrifier l'UX pour la performance

View file

@ -0,0 +1,62 @@
# Refactoring Agent - Resto Demo
## Role
Specialiste refactoring et amelioration de la qualite du code pour le projet Resto Demo. Restructuration sans changer le comportement.
## Stack technique
- **Framework** : Next.js 16 (App Router) + React 19
- **Language** : TypeScript strict
- **Styling** : Tailwind CSS v4 + Shadcn/UI
- **Animations** : Framer Motion 12
## Principes de refactoring
1. **Ne jamais changer le comportement** : le resultat visible doit etre identique
2. **Petits pas** : une modification a la fois, testable independamment
3. **DRY** : extraire les duplications en composants/utilitaires reutilisables
4. **Single Responsibility** : un composant = une responsabilite
5. **Composition over inheritance** : preferer la composition de composants
## Patterns a appliquer
### Extraction de composants
- Sections trop longues -> composants dedies
- Logique repetee -> custom hooks
- Styles repetes -> variantes Tailwind ou composants
### Organisation
```
src/components/
├── ui/ # Primitives Shadcn/UI (Button, Card, Input...)
├── blocks/ # Sections page (Hero, Footer, Grid...)
├── animations/ # Wrappers Framer Motion
├── providers/ # Context providers
├── seo/ # Composants SEO
├── menu/ # Composants specifiques menu
├── reservations/ # Composants specifiques reservations
└── layout/ # Header, Navigation, etc.
```
### TypeScript
- Remplacer `any` par des types stricts
- Extraire les types partages dans des fichiers dedies
- Utiliser les generics quand pertinent
### Performance
- Memoization (`React.memo`, `useMemo`, `useCallback`) quand justifie
- Code splitting avec `dynamic()` de Next.js
- Lazy loading des composants lourds
## Anti-patterns a corriger
- Props drilling excessif -> Context ou composition
- Composants > 200 lignes -> extraire des sous-composants
- Logique metier dans les composants UI -> extraire dans des hooks/utils
- Styles inline -> classes Tailwind
- Imports circulaires -> restructurer les modules
## Instructions
- Toujours lire le code existant en entier avant de refactorer
- Expliquer le pourquoi de chaque refactoring
- Faire des changements incrementaux
- Verifier que le comportement reste identique apres chaque etape
- Ne pas ajouter de fonctionnalites pendant un refactoring
- Respecter les conventions du projet existant

View file

@ -0,0 +1,79 @@
# Code Reviewer Agent - Resto Demo
## Role
Agent de revue de code pour le projet Resto Demo. Analyse qualite, patterns, coherence et bonnes pratiques.
## Stack technique
- **Framework** : Next.js 16 (App Router) + React 19
- **Language** : TypeScript strict
- **Styling** : Tailwind CSS v4 + Shadcn/UI
- **Animations** : Framer Motion 12
## Criteres de revue
### Architecture
- Respect de la structure App Router (layouts, pages, loading, error)
- Separation Server/Client Components correcte
- Composants dans les bons dossiers (ui/, blocks/, animations/)
- Pas de logique metier dans les composants UI
### TypeScript
- Typage strict, pas de `any`
- Interfaces/types bien definis et exportes
- Props typees pour tous les composants
- Pas de `as` cast inutiles
### React / Next.js
- "use client" uniquement quand necessaire
- Hooks utilises correctement (regles des hooks)
- Keys stables dans les listes
- Pas de re-renders inutiles
- Metadata API pour le SEO
- Image component pour les images
### Tailwind / Shadcn
- Utilisation de `cn()` pour merger les classes
- Classes Tailwind ordonnees logiquement
- Composants Shadcn/UI reutilises plutot que reinventes
- Responsive mobile-first
### Performance
- Pas d'imports inutiles
- Lazy loading des images
- Composants d'animation performants
- Bundle size raisonnable
### Accessibilite
- Alt texts sur les images
- Semantic HTML (header, main, nav, footer, section)
- Focus management
- ARIA labels quand necessaire
## Format de revue
```
## Resume
[Vue d'ensemble du code revu]
## Points positifs
- [Ce qui est bien fait]
## Problemes
### Critique
- [Bugs, vulnerabilites]
### Important
- [Non-conformites, mauvais patterns]
### Mineur
- [Suggestions d'amelioration]
## Recommandations
- [Actions concretes a prendre]
```
## Instructions
- Lire tout le code concerne avant de commenter
- Etre constructif : proposer des alternatives, pas juste critiquer
- Se concentrer sur les problemes reels, pas le style personnel
- Verifier la coherence avec le reste du projet
- Prioriser : critique > important > mineur

View file

@ -0,0 +1,63 @@
# Security Agent - Resto Demo
## Role
Agent d'audit de securite et hardening OWASP pour le projet Resto Demo. Analyse des vulnerabilites et recommandations de securisation.
## Stack technique
- **Framework** : Next.js 16 (App Router)
- **Frontend** : React 19 + TypeScript
- **Formulaires** : Contact + Reservations (input utilisateur)
- **Contenu** : MDX (contenu statique)
## Checklist OWASP Top 10
### 1. Injection
- [ ] Sanitisation des inputs formulaire contact et reservations
- [ ] Protection contre l'injection dans les parametres URL
- [ ] Validation cote serveur de toutes les donnees
### 2. Broken Authentication
- [ ] Pas d'auth utilisateur prevue (site vitrine)
- [ ] Proteger l'eventuel admin/CMS
### 3. Sensitive Data Exposure
- [ ] Variables d'environnement dans `.env.local` (jamais commitees)
- [ ] Pas de donnees sensibles dans le bundle client
- [ ] HTTPS obligatoire
### 4. XSS (Cross-Site Scripting)
- [ ] React echappe par defaut (pas de `dangerouslySetInnerHTML`)
- [ ] Sanitiser le contenu MDX
- [ ] CSP headers configures
### 5. Security Headers
```
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'; ...
Permissions-Policy: camera=(), microphone=(), geolocation=()
```
### 6. Rate Limiting
- [ ] Limiter les soumissions du formulaire contact
- [ ] Limiter les soumissions de reservations
- [ ] Protection anti-spam (honeypot ou reCAPTCHA)
### 7. CSRF
- [ ] Tokens CSRF pour les formulaires
- [ ] SameSite cookies
### 8. Dependency Security
- [ ] Audit `npm audit` regulier
- [ ] Pas de dependances avec vulnerabilites connues
- [ ] Lock file a jour
## Instructions
- Scanner le code pour les vulnerabilites courantes
- Verifier les headers de securite dans `next.config.ts`
- Auditer les dependances npm
- Verifier que `.env*` est dans `.gitignore`
- Proposer des fixes concrets, pas juste des recommandations
- Prioriser les risques : critique > haute > moyenne > basse

View file

@ -0,0 +1,46 @@
# UI Designer Agent - Resto Demo
## Role
Specialiste UI/UX, animations et design system pour le projet Resto Demo. Expert Shadcn/UI + Framer Motion + Tailwind CSS v4 pour un site de restaurant.
## Design System Restaurant
### Composants cles restaurant
- **Menu Card** : photo du plat, nom, description, prix, badges (vegan, sans gluten)
- **Reservation Form** : date picker, time picker, nombre de convives, champs contact
- **Opening Hours** : tableau horaires avec jours/services
- **Gallery** : grille photos avec lightbox (interieur, plats, equipe)
- **Hero** : image plein ecran avec overlay, titre et CTA reservation
- **Testimonials** : avis clients avec etoiles
- **Map** : localisation du restaurant
### Animations (Framer Motion)
- Fade in + Slide up au scroll pour les sections
- Parallax leger sur les hero sections
- Hover : scale subtil sur les cards menu
- Page transitions : fade 300ms
- Stagger children pour les grids de plats
- CountUp pour les statistiques (annees, plats, clients)
## Composants d'animation disponibles
```
FadeIn, SlideIn, ScaleIn, StaggerChildren,
ParallaxScroll, TextReveal, CountUp, DrawSVG,
HoverEffects, MorphingText, PageTransition, ScrollReveal,
AnimatePresenceWrapper
```
## Accessibilite
- Focus visible sur tous les interactifs
- Skip to content link
- Alt texts descriptifs sur les photos de plats
- Navigation clavier complete
- Formulaire de reservation accessible
## Instructions
- Utiliser les composants d'animation existants dans `src/components/animations/`
- Mobile-first : designer d'abord pour < 640px
- Tester les hover states et transitions
- Penser a l'experience restaurant : appetissant, elegant, accueillant
- Les photos de plats doivent etre mises en valeur
- Le CTA reservation doit etre toujours visible/accessible

41
.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

125
README.md Normal file
View file

@ -0,0 +1,125 @@
# Hagen Boilerplate
Boilerplate Next.js moderne pour créer des sites web professionnels rapidement.
## 🚀 Stack
- **Next.js 14** — App Router, Server Components
- **TypeScript** — Type-safe
- **Tailwind CSS v4** — Styling utility-first
- **shadcn/ui** — Composants UI accessibles
- **Framer Motion** — Animations fluides
- **Lucide Icons** — Icônes
## 📦 Structure
```
src/
├── app/ # Routes Next.js
│ ├── page.tsx # Page d'accueil
│ ├── layout.tsx # Layout principal
│ └── globals.css # Styles globaux
├── components/
│ ├── ui/ # Composants shadcn/ui
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ └── ...
│ ├── animations/ # Wrappers Framer Motion
│ │ ├── FadeIn.tsx
│ │ ├── SlideIn.tsx
│ │ ├── ScaleIn.tsx
│ │ └── StaggerChildren.tsx
│ └── blocks/ # Sections de page
│ ├── HeroSimple.tsx
│ ├── FeaturesGrid.tsx
│ ├── CTABanner.tsx
│ ├── Testimonials.tsx
│ └── Footer.tsx
└── lib/
└── utils.ts # Utilitaires (cn, etc.)
```
## 🎯 Quick Start
```bash
# Cloner
git clone <repo> mon-projet
cd mon-projet
# Installer
npm install
# Lancer
npm run dev
```
Ouvrir [http://localhost:3000](http://localhost:3000)
## 🎨 Composants d'animation
```tsx
import { FadeIn, SlideIn, StaggerChildren } from "@/components/animations";
// Fade in au scroll
<FadeIn direction="up" delay={0.2}>
<Card>...</Card>
</FadeIn>
// Slide depuis la gauche
<SlideIn direction="left">
<Image />
</SlideIn>
// Enfants apparaissent en cascade
<StaggerChildren staggerDelay={0.1}>
<Item />
<Item />
<Item />
</StaggerChildren>
```
## 🧱 Blocs disponibles
| Bloc | Description |
|------|-------------|
| `HeroSimple` | Hero avec titre, sous-titre, CTA |
| `FeaturesGrid` | Grille de fonctionnalités avec icônes |
| `CTABanner` | Bannière d'appel à l'action |
| `Testimonials` | Témoignages clients |
| `Footer` | Pied de page multi-colonnes |
## 🎨 Personnalisation
### Couleurs
Modifier `src/app/globals.css` :
```css
:root {
--primary: 220 90% 56%;
--primary-foreground: 0 0% 100%;
/* ... */
}
```
### Ajouter des composants shadcn/ui
```bash
npx shadcn@latest add dialog
npx shadcn@latest add dropdown-menu
npx shadcn@latest add form
```
## 📝 TODO
- [ ] Ajouter Payload CMS
- [ ] Module Auth (NextAuth)
- [ ] Module Billing (Stripe)
- [ ] PWA config
- [ ] SEO metadata helpers
- [ ] i18n (FR/NL/EN)
- [ ] Plus de blocs (Pricing, FAQ, Contact...)
## 📄 License
MIT — Hagen Digital

23
components.json Normal file
View file

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

18
eslint.config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

10
mdx-components.tsx Normal file
View file

@ -0,0 +1,10 @@
import type { MDXComponents } from "mdx/types";
import { mdxComponents } from "@/lib/mdx";
// This file is required for @next/mdx to work with App Router
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...mdxComponents,
...components,
};
}

101
next.config.ts Normal file
View file

@ -0,0 +1,101 @@
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
// =============================================================================
// SECURITY HEADERS
// https://securityheaders.com for testing
// =============================================================================
const securityHeaders = [
// Prevents clickjacking attacks
{
key: "X-Frame-Options",
value: "DENY",
},
// Prevents MIME type sniffing
{
key: "X-Content-Type-Options",
value: "nosniff",
},
// Controls DNS prefetching
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
// Controls referrer information
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
// Permissions Policy (formerly Feature-Policy)
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
},
// HSTS - Force HTTPS (uncomment in production with HTTPS)
// {
// key: "Strict-Transport-Security",
// value: "max-age=31536000; includeSubDomains; preload",
// },
// Content Security Policy (customize based on your needs)
// {
// key: "Content-Security-Policy",
// value: `
// default-src 'self';
// script-src 'self' 'unsafe-eval' 'unsafe-inline';
// style-src 'self' 'unsafe-inline';
// img-src 'self' data: https:;
// font-src 'self';
// connect-src 'self' https:;
// frame-ancestors 'none';
// `.replace(/\n/g, ""),
// },
];
const nextConfig: NextConfig = {
// Security headers
async headers() {
return [
{
// Apply to all routes
source: "/:path*",
headers: securityHeaders,
},
];
},
// Image optimization
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
formats: ["image/avif", "image/webp"],
},
// Recommended: strict mode for better error catching
reactStrictMode: true,
// Experimental features
experimental: {
// Enable React compiler when stable
// reactCompiler: true,
},
};
// MDX configuration
const withMDX = createMDX({
// Add MDX plugins here if needed
options: {
remarkPlugins: [],
rehypePlugins: [],
},
});
// Merge MDX config with Next.js config
export default withMDX({
...nextConfig,
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
});

13593
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "hagen-boilerplate",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^16.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.34.3",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

140
src/app/carte/page.tsx Normal file
View file

@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { FadeIn, StaggerChildren } from "@/components/animations";
import { menuCategories, menuItems } from "@/lib/data/restaurant";
import { cn } from "@/lib/utils";
function GoldDivider({ className = "" }: { className?: string }) {
return (
<div className={`flex items-center justify-center gap-4 ${className}`}>
<div className="w-12 h-px bg-linear-to-r from-transparent to-gold/50" />
<div className="w-1.5 h-1.5 rotate-45 border border-gold/50" />
<div className="w-12 h-px bg-linear-to-l from-transparent to-gold/50" />
</div>
);
}
export default function CartePage() {
const [activeCategory, setActiveCategory] = useState("entrees");
const filteredItems = menuItems.filter(
(item) => item.category === activeCategory
);
const currentCategory = menuCategories.find((c) => c.id === activeCategory);
return (
<>
{/* Hero */}
<section className="pt-32 pb-16 px-6 text-center">
<FadeIn>
<p className="text-gold text-xs tracking-[0.3em] uppercase mb-4">
Saveurs & Raffinement
</p>
<h1 className="font-display text-4xl md:text-6xl lg:text-7xl font-bold mb-6">
La Carte
</h1>
<GoldDivider className="mb-6" />
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Une cuisine qui celebre les produits d&apos;exception,
sublimes par le savoir-faire de notre chef.
</p>
</FadeIn>
</section>
{/* Category Tabs */}
<section className="px-6 mb-16">
<div className="max-w-5xl mx-auto">
<div className="flex flex-wrap justify-center gap-2 md:gap-4">
{menuCategories.map((cat) => (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={cn(
"px-6 py-3 text-xs tracking-[0.2em] uppercase transition-all duration-300 border",
activeCategory === cat.id
? "border-gold bg-gold text-background"
: "border-gold/20 text-foreground/60 hover:border-gold/50 hover:text-gold"
)}
>
{cat.name}
</button>
))}
</div>
</div>
</section>
{/* Menu Items */}
<section className="px-6 pb-24">
<div className="max-w-4xl mx-auto">
{currentCategory && (
<FadeIn className="text-center mb-12">
<h2 className="font-display text-2xl md:text-3xl font-semibold mb-2">
{currentCategory.name}
</h2>
<p className="text-muted-foreground text-sm">
{currentCategory.description}
</p>
</FadeIn>
)}
<AnimatePresence mode="wait">
<motion.div
key={activeCategory}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<StaggerChildren staggerDelay={0.08} className="space-y-0">
{filteredItems.map((item, index) => (
<div key={item.id}>
<div className="flex items-start justify-between gap-4 py-8 group">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-display text-xl md:text-2xl font-semibold group-hover:text-gold transition-colors">
{item.name}
</h3>
{"vegetarian" in item && item.vegetarian && (
<span className="text-[10px] tracking-wider uppercase text-green-400 border border-green-400/30 px-2 py-0.5">
Vegetarien
</span>
)}
</div>
<p className="text-muted-foreground leading-relaxed">
{item.description}
</p>
</div>
<div className="flex items-center gap-4 shrink-0 pt-1">
<div className="hidden md:block w-20 h-px bg-gold/20" />
<span className="font-display text-2xl text-gold font-semibold">
{item.price}&euro;
</span>
</div>
</div>
{index < filteredItems.length - 1 && (
<div className="h-px bg-linear-to-r from-transparent via-gold/10 to-transparent" />
)}
</div>
))}
</StaggerChildren>
</motion.div>
</AnimatePresence>
</div>
</section>
{/* Menu note */}
<section className="px-6 pb-24">
<div className="max-w-3xl mx-auto text-center border-t border-b border-gold/10 py-12">
<p className="text-sm text-muted-foreground italic leading-relaxed">
Nos plats sont elabores a partir de produits frais et de saison.
N&apos;hesitez pas a informer notre equipe de vos allergies ou regimes alimentaires.
<br />
Prix nets, TVA et service compris.
</p>
</div>
</section>
</>
);
}

265
src/app/contact/page.tsx Normal file
View file

@ -0,0 +1,265 @@
"use client";
import { useState } from "react";
import { MapPin, Phone, Mail, Clock, Send } from "lucide-react";
import { FadeIn, SlideIn } from "@/components/animations";
import { restaurant } from "@/lib/data/restaurant";
import { cn } from "@/lib/utils";
function GoldDivider({ className = "" }: { className?: string }) {
return (
<div className={`flex items-center justify-center gap-4 ${className}`}>
<div className="w-12 h-px bg-linear-to-r from-transparent to-gold/50" />
<div className="w-1.5 h-1.5 rotate-45 border border-gold/50" />
<div className="w-12 h-px bg-linear-to-l from-transparent to-gold/50" />
</div>
);
}
export default function ContactPage() {
const [formState, setFormState] = useState({
name: "",
email: "",
phone: "",
date: "",
time: "",
guests: "2",
message: "",
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// TODO: Server action
alert("Merci ! Votre demande de reservation a ete envoyee.");
};
return (
<>
{/* Hero */}
<section className="pt-32 pb-16 px-6 text-center">
<FadeIn>
<p className="text-gold text-xs tracking-[0.3em] uppercase mb-4">
Bienvenue
</p>
<h1 className="font-display text-4xl md:text-6xl lg:text-7xl font-bold mb-6">
Contact & Reservation
</h1>
<GoldDivider className="mb-6" />
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Reservez votre table ou contactez-nous pour toute demande particuliere.
</p>
</FadeIn>
</section>
{/* Contact Info + Reservation Form */}
<section className="px-6 pb-24">
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-5 gap-16">
{/* Contact Info */}
<div className="lg:col-span-2">
<SlideIn direction="left">
<div className="space-y-10">
<div>
<h2 className="font-display text-2xl font-semibold mb-6">
Nous trouver
</h2>
<ul className="space-y-6">
<li className="flex items-start gap-4">
<div className="w-10 h-10 border border-gold/20 flex items-center justify-center shrink-0">
<MapPin className="w-4 h-4 text-gold" />
</div>
<div>
<p className="text-sm text-muted-foreground/60 uppercase tracking-wider mb-1">Adresse</p>
<p className="text-foreground/80">{restaurant.address}</p>
</div>
</li>
<li className="flex items-start gap-4">
<div className="w-10 h-10 border border-gold/20 flex items-center justify-center shrink-0">
<Phone className="w-4 h-4 text-gold" />
</div>
<div>
<p className="text-sm text-muted-foreground/60 uppercase tracking-wider mb-1">Telephone</p>
<a href={`tel:${restaurant.phone}`} className="text-foreground/80 hover:text-gold transition-colors">
{restaurant.phone}
</a>
</div>
</li>
<li className="flex items-start gap-4">
<div className="w-10 h-10 border border-gold/20 flex items-center justify-center shrink-0">
<Mail className="w-4 h-4 text-gold" />
</div>
<div>
<p className="text-sm text-muted-foreground/60 uppercase tracking-wider mb-1">Email</p>
<a href={`mailto:${restaurant.email}`} className="text-foreground/80 hover:text-gold transition-colors">
{restaurant.email}
</a>
</div>
</li>
</ul>
</div>
{/* Opening Hours */}
<div>
<h3 className="font-display text-xl font-semibold mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-gold" />
Horaires
</h3>
<div className="border border-gold/10 divide-y divide-gold/10">
{restaurant.openingHours.map((h) => (
<div
key={h.day}
className={cn(
"flex justify-between px-4 py-3 text-sm",
h.closed && "opacity-50"
)}
>
<span className="font-medium">{h.day}</span>
<span className="text-muted-foreground">
{h.closed
? "Ferme"
: [h.lunch, h.dinner].filter(Boolean).join(" | ")}
</span>
</div>
))}
</div>
</div>
</div>
</SlideIn>
</div>
{/* Reservation Form */}
<div className="lg:col-span-3" id="reservation">
<FadeIn>
<div className="border border-gold/15 p-8 md:p-12">
<h2 className="font-display text-2xl font-semibold mb-2">
Reserver une table
</h2>
<p className="text-sm text-muted-foreground mb-8">
Remplissez le formulaire ci-dessous et nous vous confirmerons votre reservation.
</p>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-xs tracking-wider uppercase text-muted-foreground mb-2">
Nom complet *
</label>
<input
type="text"
required
value={formState.name}
onChange={(e) => setFormState({ ...formState, name: e.target.value })}
className="w-full bg-transparent border border-gold/20 px-4 py-3 text-foreground placeholder:text-muted-foreground/40 focus:border-gold focus:outline-none transition-colors"
placeholder="Votre nom"
/>
</div>
<div>
<label className="block text-xs tracking-wider uppercase text-muted-foreground mb-2">
Email *
</label>
<input
type="email"
required
value={formState.email}
onChange={(e) => setFormState({ ...formState, email: e.target.value })}
className="w-full bg-transparent border border-gold/20 px-4 py-3 text-foreground placeholder:text-muted-foreground/40 focus:border-gold focus:outline-none transition-colors"
placeholder="votre@email.com"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-xs tracking-wider uppercase text-muted-foreground mb-2">
Date *
</label>
<input
type="date"
required
value={formState.date}
onChange={(e) => setFormState({ ...formState, date: e.target.value })}
className="w-full bg-transparent border border-gold/20 px-4 py-3 text-foreground focus:border-gold focus:outline-none transition-colors"
/>
</div>
<div>
<label className="block text-xs tracking-wider uppercase text-muted-foreground mb-2">
Heure *
</label>
<select
required
value={formState.time}
onChange={(e) => setFormState({ ...formState, time: e.target.value })}
className="w-full bg-transparent border border-gold/20 px-4 py-3 text-foreground focus:border-gold focus:outline-none transition-colors"
>
<option value="">Choisir</option>
<option value="12:00">12:00</option>
<option value="12:30">12:30</option>
<option value="13:00">13:00</option>
<option value="13:30">13:30</option>
<option value="19:00">19:00</option>
<option value="19:30">19:30</option>
<option value="20:00">20:00</option>
<option value="20:30">20:30</option>
<option value="21:00">21:00</option>
<option value="21:30">21:30</option>
</select>
</div>
<div>
<label className="block text-xs tracking-wider uppercase text-muted-foreground mb-2">
Convives *
</label>
<select
required
value={formState.guests}
onChange={(e) => setFormState({ ...formState, guests: e.target.value })}
className="w-full bg-transparent border border-gold/20 px-4 py-3 text-foreground focus:border-gold focus:outline-none transition-colors"
>
{[1, 2, 3, 4, 5, 6, 7, 8].map((n) => (
<option key={n} value={n}>
{n} {n === 1 ? "personne" : "personnes"}
</option>
))}
<option value="9+">9+ (nous contacter)</option>
</select>
</div>
</div>
<div>
<label className="block text-xs tracking-wider uppercase text-muted-foreground mb-2">
Message / Demandes speciales
</label>
<textarea
rows={4}
value={formState.message}
onChange={(e) => setFormState({ ...formState, message: e.target.value })}
className="w-full bg-transparent border border-gold/20 px-4 py-3 text-foreground placeholder:text-muted-foreground/40 focus:border-gold focus:outline-none transition-colors resize-none"
placeholder="Allergies, occasion speciale, preferences..."
/>
</div>
<button
type="submit"
className="w-full flex items-center justify-center gap-2 px-8 py-4 bg-gold text-background text-xs tracking-[0.2em] uppercase font-semibold hover:bg-gold-light transition-all duration-300"
>
<Send className="w-4 h-4" />
Envoyer la demande
</button>
</form>
</div>
</FadeIn>
</div>
</div>
</section>
{/* Map placeholder */}
<section className="h-80 bg-card border-t border-gold/10 flex items-center justify-center">
<div className="text-center">
<MapPin className="w-8 h-8 text-gold mx-auto mb-3" />
<p className="text-sm text-muted-foreground">{restaurant.address}</p>
<p className="text-xs text-muted-foreground/50 mt-2">
Integrez Google Maps avec votre cle API
</p>
</div>
</section>
</>
);
}

46
src/app/error.tsx Normal file
View file

@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function Error({ error, reset }: ErrorProps) {
useEffect(() => {
// Log error to error reporting service
console.error(error);
}, [error]);
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="text-center">
<h1 className="text-9xl font-bold text-muted-foreground/20">500</h1>
<h2 className="text-2xl font-semibold mt-4">Une erreur est survenue</h2>
<p className="text-muted-foreground mt-2 max-w-md">
Nous sommes désolés, quelque chose s&apos;est mal passé. Veuillez réessayer.
</p>
<div className="flex gap-4 justify-center mt-8">
<button
onClick={reset}
className="px-6 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
Réessayer
</button>
<a
href="/"
className="px-6 py-3 border rounded-lg hover:bg-muted transition-colors"
>
Accueil
</a>
</div>
{error.digest && (
<p className="text-xs text-muted-foreground mt-8">
Code erreur: {error.digest}
</p>
)}
</div>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

196
src/app/galerie/page.tsx Normal file
View file

@ -0,0 +1,196 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { motion, AnimatePresence } from "framer-motion";
import { X, ChevronLeft, ChevronRight } from "lucide-react";
import { FadeIn } from "@/components/animations";
import { galleryImages } from "@/lib/data/restaurant";
import { cn } from "@/lib/utils";
function GoldDivider({ className = "" }: { className?: string }) {
return (
<div className={`flex items-center justify-center gap-4 ${className}`}>
<div className="w-12 h-px bg-linear-to-r from-transparent to-gold/50" />
<div className="w-1.5 h-1.5 rotate-45 border border-gold/50" />
<div className="w-12 h-px bg-linear-to-l from-transparent to-gold/50" />
</div>
);
}
export default function GaleriePage() {
const [activeFilter, setActiveFilter] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const categories = Array.from(new Set(galleryImages.map((img) => img.category)));
const filtered = activeFilter
? galleryImages.filter((img) => img.category === activeFilter)
: galleryImages;
const openLightbox = (index: number) => {
setLightboxIndex(index);
document.body.style.overflow = "hidden";
};
const closeLightbox = () => {
setLightboxIndex(null);
document.body.style.overflow = "";
};
return (
<>
{/* Hero */}
<section className="pt-32 pb-16 px-6 text-center">
<FadeIn>
<p className="text-gold text-xs tracking-[0.3em] uppercase mb-4">
Immersion
</p>
<h1 className="font-display text-4xl md:text-6xl lg:text-7xl font-bold mb-6">
Galerie
</h1>
<GoldDivider className="mb-6" />
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Plongez dans l&apos;univers de La Maison Doree a travers nos images.
</p>
</FadeIn>
</section>
{/* Filters */}
<section className="px-6 mb-12">
<div className="max-w-7xl mx-auto flex flex-wrap justify-center gap-2 md:gap-4">
<button
onClick={() => setActiveFilter(null)}
className={cn(
"px-6 py-3 text-xs tracking-[0.2em] uppercase transition-all duration-300 border",
activeFilter === null
? "border-gold bg-gold text-background"
: "border-gold/20 text-foreground/60 hover:border-gold/50 hover:text-gold"
)}
>
Tout
</button>
{categories.map((cat) => (
<button
key={cat}
onClick={() => setActiveFilter(cat)}
className={cn(
"px-6 py-3 text-xs tracking-[0.2em] uppercase transition-all duration-300 border",
activeFilter === cat
? "border-gold bg-gold text-background"
: "border-gold/20 text-foreground/60 hover:border-gold/50 hover:text-gold"
)}
>
{cat}
</button>
))}
</div>
</section>
{/* Gallery Grid */}
<section className="px-6 pb-24">
<div className="max-w-7xl mx-auto">
<AnimatePresence mode="wait">
<motion.div
key={activeFilter || "all"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
{filtered.map((image, index) => (
<motion.div
key={image.src}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
className="relative aspect-4/3 overflow-hidden group cursor-pointer"
onClick={() => openLightbox(index)}
>
<Image
src={image.src}
alt={image.alt}
fill
className="object-cover transition-transform duration-700 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-500 flex items-end">
<div className="p-6 translate-y-full group-hover:translate-y-0 transition-transform duration-500">
<p className="text-xs tracking-[0.2em] uppercase text-gold">
{image.category}
</p>
<p className="text-sm text-white/80 mt-1">{image.alt}</p>
</div>
</div>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
</section>
{/* Lightbox */}
<AnimatePresence>
{lightboxIndex !== null && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
onClick={closeLightbox}
>
<button
onClick={closeLightbox}
className="absolute top-6 right-6 w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
{filtered.length > 1 && (
<>
<button
onClick={(e) => {
e.stopPropagation();
setLightboxIndex(
(lightboxIndex - 1 + filtered.length) % filtered.length
);
}}
className="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 flex items-center justify-center text-white/40 hover:text-white transition-colors"
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setLightboxIndex((lightboxIndex + 1) % filtered.length);
}}
className="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 flex items-center justify-center text-white/40 hover:text-white transition-colors"
>
<ChevronRight className="w-8 h-8" />
</button>
</>
)}
<motion.div
key={lightboxIndex}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="relative max-w-[90vw] max-h-[85vh]"
onClick={(e) => e.stopPropagation()}
>
<Image
src={filtered[lightboxIndex].src}
alt={filtered[lightboxIndex].alt}
width={1200}
height={800}
className="max-w-full max-h-[85vh] object-contain"
/>
</motion.div>
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 text-white/40 text-sm tracking-wider">
{lightboxIndex + 1} / {filtered.length}
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

178
src/app/globals.css Normal file
View file

@ -0,0 +1,178 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-display: var(--font-display);
--color-gold: var(--gold);
--color-gold-light: var(--gold-light);
--color-gold-dark: var(--gold-dark);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.375rem;
/* Elegant dark restaurant theme */
--gold: #a88f59;
--gold-light: #c4a96a;
--gold-dark: #846d46;
--background: #0f0f0e;
--foreground: #f5f0e8;
--card: #1a1917;
--card-foreground: #f5f0e8;
--popover: #1a1917;
--popover-foreground: #f5f0e8;
--primary: #a88f59;
--primary-foreground: #0f0f0e;
--secondary: #1e1d1b;
--secondary-foreground: #f5f0e8;
--muted: #1e1d1b;
--muted-foreground: #8a8578;
--accent: #242320;
--accent-foreground: #f5f0e8;
--destructive: #b33a3a;
--border: rgba(168, 143, 89, 0.15);
--input: rgba(168, 143, 89, 0.2);
--ring: #a88f59;
--chart-1: #a88f59;
--chart-2: #c4a96a;
--chart-3: #846d46;
--chart-4: #d4c4a0;
--chart-5: #6b5a3a;
--sidebar: #1a1917;
--sidebar-foreground: #f5f0e8;
--sidebar-primary: #a88f59;
--sidebar-primary-foreground: #0f0f0e;
--sidebar-accent: #242320;
--sidebar-accent-foreground: #f5f0e8;
--sidebar-border: rgba(168, 143, 89, 0.15);
--sidebar-ring: #a88f59;
}
.dark {
--background: #0f0f0e;
--foreground: #f5f0e8;
--card: #1a1917;
--card-foreground: #f5f0e8;
--popover: #1a1917;
--popover-foreground: #f5f0e8;
--primary: #a88f59;
--primary-foreground: #0f0f0e;
--secondary: #1e1d1b;
--secondary-foreground: #f5f0e8;
--muted: #1e1d1b;
--muted-foreground: #8a8578;
--accent: #242320;
--accent-foreground: #f5f0e8;
--destructive: #b33a3a;
--border: rgba(168, 143, 89, 0.15);
--input: rgba(168, 143, 89, 0.2);
--ring: #a88f59;
--chart-1: #a88f59;
--chart-2: #c4a96a;
--chart-3: #846d46;
--chart-4: #d4c4a0;
--chart-5: #6b5a3a;
--sidebar: #1a1917;
--sidebar-foreground: #f5f0e8;
--sidebar-primary: #a88f59;
--sidebar-primary-foreground: #0f0f0e;
--sidebar-accent: #242320;
--sidebar-accent-foreground: #f5f0e8;
--sidebar-border: rgba(168, 143, 89, 0.15);
--sidebar-ring: #a88f59;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Elegant scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #0f0f0e;
}
::-webkit-scrollbar-thumb {
background: #a88f59;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #c4a96a;
}
/* Gold text gradient */
.text-gold-gradient {
background: linear-gradient(135deg, #c4a96a, #a88f59, #846d46);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Decorative lines */
.gold-line {
width: 60px;
height: 1px;
background: linear-gradient(90deg, transparent, #a88f59, transparent);
}
.gold-line-wide {
width: 120px;
height: 1px;
background: linear-gradient(90deg, transparent, #a88f59, transparent);
}
/* Selection */
::selection {
background: rgba(168, 143, 89, 0.3);
color: #f5f0e8;
}

185
src/app/histoire/page.tsx Normal file
View file

@ -0,0 +1,185 @@
"use client";
import Image from "next/image";
import { FadeIn, SlideIn, CountUp } from "@/components/animations";
import { chef, stats } from "@/lib/data/restaurant";
function GoldDivider({ className = "" }: { className?: string }) {
return (
<div className={`flex items-center justify-center gap-4 ${className}`}>
<div className="w-12 h-px bg-linear-to-r from-transparent to-gold/50" />
<div className="w-1.5 h-1.5 rotate-45 border border-gold/50" />
<div className="w-12 h-px bg-linear-to-l from-transparent to-gold/50" />
</div>
);
}
export default function HistoirePage() {
return (
<>
{/* Hero */}
<section className="relative pt-32 pb-24 px-6 text-center">
<FadeIn>
<p className="text-gold text-xs tracking-[0.3em] uppercase mb-4">
Depuis 2010
</p>
<h1 className="font-display text-4xl md:text-6xl lg:text-7xl font-bold mb-6">
Notre Histoire
</h1>
<GoldDivider className="mb-6" />
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
L&apos;histoire d&apos;une passion pour la gastronomie,
nee de la rencontre entre tradition et innovation.
</p>
</FadeIn>
</section>
{/* Chef Section */}
<section className="py-16 md:py-24 px-6">
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
<SlideIn direction="left">
<div className="relative">
<div className="relative aspect-4/5 overflow-hidden">
<Image
src={chef.image}
alt={chef.name}
fill
className="object-cover"
/>
</div>
<div className="absolute -bottom-4 -right-4 w-full h-full border border-gold/20 -z-10" />
</div>
</SlideIn>
<div>
<FadeIn>
<p className="text-gold text-xs tracking-[0.3em] uppercase mb-4">
Le Chef
</p>
<h2 className="font-display text-3xl md:text-5xl font-bold mb-2">
{chef.name}
</h2>
<p className="text-gold tracking-wider text-sm mb-8">{chef.title}</p>
<GoldDivider className="justify-start mb-8" />
</FadeIn>
<FadeIn delay={0.2}>
<p className="text-lg text-muted-foreground leading-relaxed mb-6">
{chef.bio}
</p>
<p className="text-lg text-muted-foreground leading-relaxed">
Sa philosophie : respecter le produit, sublimer les saveurs et offrir
a chaque convive une experience sensorielle unique. Chaque assiette
raconte une histoire, chaque menu est une invitation au voyage.
</p>
</FadeIn>
</div>
</div>
</section>
{/* Philosophy */}
<section className="py-24 px-6 bg-card">
<div className="max-w-4xl mx-auto text-center">
<FadeIn>
<p className="text-gold text-xs tracking-[0.3em] uppercase mb-4">
Notre Philosophie
</p>
<h2 className="font-display text-3xl md:text-5xl font-bold mb-8">
L&apos;art de recevoir
</h2>
<GoldDivider className="mb-12" />
</FadeIn>
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
{[
{
title: "Les Produits",
text: "Nous selectionnons nos ingredients aupres de producteurs locaux et artisans passionnes. Chaque produit est choisi pour sa qualite exceptionnelle et sa fraicheur.",
},
{
title: "Le Savoir-Faire",
text: "Notre equipe de cuisine allie techniques classiques et creativite contemporaine pour creer des plats qui surprennent et emerveillent.",
},
{
title: "L'Experience",
text: "Du premier regard au dernier dessert, chaque detail est pense pour faire de votre visite un moment inoubliable dans un cadre elegant.",
},
].map((item, index) => (
<FadeIn key={index} delay={index * 0.15}>
<div className="border border-gold/10 p-8">
<h3 className="font-display text-xl font-semibold text-gold mb-4">
{item.title}
</h3>
<p className="text-muted-foreground leading-relaxed text-sm">
{item.text}
</p>
</div>
</FadeIn>
))}
</div>
</div>
</section>
{/* Timeline */}
<section className="py-24 px-6">
<div className="max-w-3xl mx-auto">
<FadeIn className="text-center mb-16">
<p className="text-gold text-xs tracking-[0.3em] uppercase mb-4">
Moments Cles
</p>
<h2 className="font-display text-3xl md:text-4xl font-bold mb-6">
Notre Parcours
</h2>
<GoldDivider />
</FadeIn>
<div className="space-y-12">
{[
{ year: "2010", title: "La Naissance", text: "Le Chef Alexandre Dubois ouvre les portes de La Maison Doree avec l'ambition de creer un lieu d'exception." },
{ year: "2013", title: "Premiere Etoile", text: "Le Guide Michelin recompense notre cuisine d'une premiere etoile, saluant la creativite et la precision du chef." },
{ year: "2018", title: "Renovation", text: "Le restaurant se reinvente avec un nouvel interieur signe par un architecte renomme, mariant elegance contemporaine et chaleur." },
{ year: "2024", title: "15 Ans d'Excellence", text: "La Maison Doree celebre 15 annees de gastronomie et plus de 50 000 convives accueillis." },
].map((item, index) => (
<FadeIn key={index} delay={index * 0.1}>
<div className="flex gap-8 items-start">
<div className="shrink-0 w-20 text-right">
<span className="font-display text-2xl font-bold text-gold">
{item.year}
</span>
</div>
<div className="flex-1 border-l border-gold/20 pl-8 pb-4">
<h3 className="font-display text-lg font-semibold mb-2">
{item.title}
</h3>
<p className="text-muted-foreground leading-relaxed text-sm">
{item.text}
</p>
</div>
</div>
</FadeIn>
))}
</div>
</div>
</section>
{/* Stats */}
<section className="py-20 px-6 border-y border-gold/10 bg-card">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{stats.map((stat, index) => (
<FadeIn key={index} delay={index * 0.1} className="text-center">
<div className="font-display text-4xl md:text-5xl font-bold text-gold mb-2">
<CountUp to={stat.value} duration={2.5} />
<span className="text-2xl">{stat.suffix}</span>
</div>
<p className="text-sm text-muted-foreground tracking-wider uppercase">
{stat.label}
</p>
</FadeIn>
))}
</div>
</div>
</section>
</>
);
}

32
src/app/layout.tsx Normal file
View file

@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { fontVariables } from "@/lib/fonts";
import { generateMetadata } from "@/lib/seo";
import { organizationJsonLd, websiteJsonLd } from "@/lib/jsonld";
import { JsonLd } from "@/components/seo/JsonLd";
import { Providers } from "@/components/providers";
import { Header } from "@/components/layout/Header";
import { RestaurantFooter } from "@/components/layout/RestaurantFooter";
import "./globals.css";
export const metadata: Metadata = generateMetadata();
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" suppressHydrationWarning>
<head>
<JsonLd data={[organizationJsonLd(), websiteJsonLd()]} />
</head>
<body className={`${fontVariables} antialiased`}>
<Providers>
<Header />
<main>{children}</main>
<RestaurantFooter />
</Providers>
</body>
</html>
);
}

10
src/app/loading.tsx Normal file
View file

@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground text-sm">Chargement...</p>
</div>
</div>
);
}

21
src/app/not-found.tsx Normal file
View file

@ -0,0 +1,21 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="text-center">
<h1 className="text-9xl font-bold text-muted-foreground/20">404</h1>
<h2 className="text-2xl font-semibold mt-4">Page non trouvée</h2>
<p className="text-muted-foreground mt-2 max-w-md">
La page que vous recherchez n&apos;existe pas ou a é déplacée.
</p>
<Link
href="/"
className="inline-flex items-center justify-center mt-8 px-6 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
Retour à l&apos;accueil
</Link>
</div>
</div>
);
}

448
src/app/page.tsx Normal file
View file

@ -0,0 +1,448 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { motion } from "framer-motion";
import { ArrowRight, Star, ChevronDown } from "lucide-react";
import { FadeIn, SlideIn, StaggerChildren, CountUp } from "@/components/animations";
import { restaurant, menuItems, testimonials, stats, chef, galleryImages } from "@/lib/data/restaurant";
// ─── Decorative separator ───
function GoldDivider({ className = "" }: { className?: string }) {
return (
<div className={`flex items-center justify-center gap-4 ${className}`}>
<div className="w-12 h-px bg-linear-to-r from-transparent to-gold/50" />
<div className="w-1.5 h-1.5 rotate-45 border border-gold/50" />
<div className="w-12 h-px bg-linear-to-l from-transparent to-gold/50" />
</div>
);
}
// ─── Hero Section ───
function HeroSection() {
return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
{/* Background */}
<Image
src="https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=1920&h=1080&fit=crop"
alt="Restaurant La Maison Doree"
fill
className="object-cover"
priority
/>
<div className="absolute inset-0 bg-linear-to-b from-black/70 via-black/50 to-background" />
{/* Content */}
<div className="relative z-10 text-center px-6 max-w-4xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.3 }}
>
<p className="text-gold text-xs md:text-sm tracking-[0.4em] uppercase mb-6">
Restaurant Gastronomique
</p>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.5 }}
className="font-display text-5xl md:text-7xl lg:text-8xl font-bold text-foreground mb-2 tracking-wide"
>
La Maison
</motion.h1>
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.7 }}
className="font-display text-3xl md:text-5xl lg:text-6xl font-light text-gold italic mb-8"
>
Doree
</motion.h1>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1, delay: 1 }}
>
<GoldDivider className="mb-8" />
</motion.div>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 1.2 }}
className="text-lg md:text-xl text-foreground/70 max-w-2xl mx-auto mb-10 leading-relaxed"
>
{restaurant.tagline}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 1.4 }}
className="flex flex-col sm:flex-row gap-4 justify-center"
>
<Link
href="/carte"
className="px-8 py-3.5 bg-gold text-background text-xs tracking-[0.2em] uppercase font-semibold hover:bg-gold-light transition-all duration-300"
>
Decouvrir la Carte
</Link>
<Link
href="/contact#reservation"
className="px-8 py-3.5 border border-foreground/30 text-foreground text-xs tracking-[0.2em] uppercase hover:border-gold hover:text-gold transition-all duration-300"
>
Reserver une Table
</Link>
</motion.div>
</div>
{/* Scroll indicator */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 2 }}
className="absolute bottom-8 left-1/2 -translate-x-1/2"
>
<motion.div
animate={{ y: [0, 8, 0] }}
transition={{ repeat: Infinity, duration: 2 }}
>
<ChevronDown className="w-6 h-6 text-gold/50" />
</motion.div>
</motion.div>
</section>
);
}
// ─── About Preview ───
function AboutSection() {
return (
<section className="py-24 md:py-32 px-6">
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
{/* Image */}
<SlideIn direction="left">
<div className="relative">
<div className="relative aspect-4/5 overflow-hidden">
<Image
src={chef.image}
alt={chef.name}
fill
className="object-cover"
/>
</div>
<div className="absolute -bottom-4 -right-4 w-full h-full border border-gold/20 -z-10" />
</div>
</SlideIn>
{/* Content */}
<div>
<FadeIn>
<p className="text-gold text-xs tracking-[0.3em] uppercase mb-4">
Notre Histoire
</p>
<h2 className="font-display text-3xl md:text-5xl font-bold mb-6 leading-tight">
Une passion pour<br />
<span className="text-gold italic font-light">l&apos;excellence</span>
</h2>
<GoldDivider className="justify-start mb-8" />
</FadeIn>
<FadeIn delay={0.2}>
<p className="text-lg text-muted-foreground leading-relaxed mb-6">
{restaurant.description}
</p>
<p className="text-lg text-muted-foreground leading-relaxed mb-8">
{chef.bio.substring(0, 200)}...
</p>
</FadeIn>
<FadeIn delay={0.3}>
<div className="flex items-center gap-6 mb-8">
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-gold/30">
<Image
src={chef.image}
alt={chef.name}
width={64}
height={64}
className="object-cover w-full h-full"
/>
</div>
<div>
<p className="font-display text-lg font-semibold">
{chef.name}
</p>
<p className="text-sm text-gold tracking-wider">{chef.title}</p>
</div>
</div>
<Link
href="/histoire"
className="inline-flex items-center gap-2 text-gold text-sm tracking-[0.15em] uppercase hover:gap-4 transition-all duration-300 group"
>
Decouvrir notre histoire
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Link>
</FadeIn>
</div>
</div>
</section>
);
}
// ─── Featured Menu ───
function MenuSection() {
const featured = menuItems.filter((item) => item.featured);
return (
<section className="py-24 md:py-32 px-6 bg-card">
<div className="max-w-5xl mx-auto">
<FadeIn className="text-center mb-16">
<p className="text-gold text-xs tracking-[0.3em] uppercase mb-4">
Nos Creations
</p>
<h2 className="font-display text-3xl md:text-5xl font-bold mb-6">
La Carte
</h2>
<GoldDivider />
</FadeIn>
<StaggerChildren staggerDelay={0.1} className="space-y-0">
{featured.map((item, index) => (
<div key={item.id}>
<div className="flex items-start justify-between gap-4 py-6 group">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-display text-xl md:text-2xl font-semibold group-hover:text-gold transition-colors">
{item.name}
</h3>
{"vegetarian" in item && item.vegetarian && (
<span className="text-[10px] tracking-wider uppercase text-green-400 border border-green-400/30 px-2 py-0.5">
V
</span>
)}
</div>
<p className="text-muted-foreground text-sm md:text-base leading-relaxed">
{item.description}
</p>
</div>
<div className="flex items-center gap-4 shrink-0">
<div className="hidden md:block w-24 h-px bg-gold/20" />
<span className="font-display text-xl text-gold font-semibold">
{item.price}&euro;
</span>
</div>
</div>
{index < featured.length - 1 && (
<div className="h-px bg-linear-to-r from-transparent via-gold/10 to-transparent" />
)}
</div>
))}
</StaggerChildren>
<FadeIn delay={0.4} className="text-center mt-12">
<Link
href="/carte"
className="inline-flex items-center gap-2 px-8 py-3.5 border border-gold text-gold text-xs tracking-[0.2em] uppercase hover:bg-gold hover:text-background transition-all duration-300 group"
>
Voir toute la carte
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Link>
</FadeIn>
</div>
</section>
);
}
// ─── Stats ───
function StatsSection() {
return (
<section className="py-20 px-6 border-y border-gold/10">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{stats.map((stat, index) => (
<FadeIn key={index} delay={index * 0.1} className="text-center">
<div className="font-display text-4xl md:text-5xl font-bold text-gold mb-2">
<CountUp to={stat.value} duration={2.5} />
<span className="text-2xl">{stat.suffix}</span>
</div>
<p className="text-sm text-muted-foreground tracking-wider uppercase">
{stat.label}
</p>
</FadeIn>
))}
</div>
</div>
</section>
);
}
// ─── Gallery Preview ───
function GallerySection() {
const previewImages = galleryImages.slice(0, 6);
return (
<section className="py-24 md:py-32 px-6">
<div className="max-w-7xl mx-auto">
<FadeIn className="text-center mb-16">
<p className="text-gold text-xs tracking-[0.3em] uppercase mb-4">
Ambiance
</p>
<h2 className="font-display text-3xl md:text-5xl font-bold mb-6">
Notre Univers
</h2>
<GoldDivider />
</FadeIn>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{previewImages.map((image, index) => (
<motion.div
key={index}
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className="relative aspect-4/3 overflow-hidden group cursor-pointer"
>
<Image
src={image.src}
alt={image.alt}
fill
className="object-cover transition-transform duration-700 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-500 flex items-end">
<div className="p-4 translate-y-full group-hover:translate-y-0 transition-transform duration-500">
<p className="text-xs tracking-[0.2em] uppercase text-gold">{image.category}</p>
<p className="text-sm text-white/80 mt-1">{image.alt}</p>
</div>
</div>
</motion.div>
))}
</div>
<FadeIn delay={0.3} className="text-center mt-12">
<Link
href="/galerie"
className="inline-flex items-center gap-2 text-gold text-sm tracking-[0.15em] uppercase hover:gap-4 transition-all duration-300 group"
>
Voir toute la galerie
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Link>
</FadeIn>
</div>
</section>
);
}
// ─── Testimonials ───
function TestimonialsSection() {
return (
<section className="py-24 md:py-32 px-6 bg-card">
<div className="max-w-6xl mx-auto">
<FadeIn className="text-center mb-16">
<p className="text-gold text-xs tracking-[0.3em] uppercase mb-4">
Temoignages
</p>
<h2 className="font-display text-3xl md:text-5xl font-bold mb-6">
Ce qu&apos;ils en disent
</h2>
<GoldDivider />
</FadeIn>
<StaggerChildren staggerDelay={0.15} className="grid grid-cols-1 md:grid-cols-3 gap-8">
{testimonials.map((t, index) => (
<div
key={index}
className="border border-gold/10 p-8 hover:border-gold/30 transition-colors duration-500"
>
<div className="flex gap-1 mb-6">
{Array.from({ length: t.rating }).map((_, i) => (
<Star key={i} className="w-4 h-4 fill-gold text-gold" />
))}
</div>
<blockquote className="text-foreground/80 leading-relaxed mb-8 italic">
&ldquo;{t.quote}&rdquo;
</blockquote>
<div>
<p className="font-display font-semibold text-lg">
{t.author}
</p>
{t.role && (
<p className="text-xs tracking-wider uppercase text-gold mt-1">
{t.role}
</p>
)}
</div>
</div>
))}
</StaggerChildren>
</div>
</section>
);
}
// ─── CTA / Reservation ───
function ReservationCTA() {
return (
<section className="relative py-32 px-6 overflow-hidden">
<Image
src="https://images.unsplash.com/photo-1559339352-11d035aa65de?w=1920&h=800&fit=crop"
alt="Table elegante"
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-black/70" />
<div className="relative z-10 max-w-3xl mx-auto text-center">
<FadeIn>
<p className="text-gold text-xs tracking-[0.3em] uppercase mb-6">
Experience Unique
</p>
<h2 className="font-display text-3xl md:text-5xl lg:text-6xl font-bold mb-6 leading-tight">
Reservez votre<br />
<span className="text-gold italic font-light">moment d&apos;exception</span>
</h2>
<GoldDivider className="mb-8" />
<p className="text-lg text-foreground/60 mb-10 max-w-xl mx-auto">
Pour une soiree romantique, un diner d&apos;affaires ou une celebration,
notre equipe vous accueille dans un cadre d&apos;exception.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/contact#reservation"
className="px-10 py-4 bg-gold text-background text-xs tracking-[0.2em] uppercase font-semibold hover:bg-gold-light transition-all duration-300"
>
Reserver maintenant
</Link>
<a
href={`tel:${restaurant.phone}`}
className="px-10 py-4 border border-foreground/20 text-foreground text-xs tracking-[0.2em] uppercase hover:border-gold hover:text-gold transition-all duration-300"
>
{restaurant.phone}
</a>
</div>
</FadeIn>
</div>
</section>
);
}
// ─── Homepage ───
export default function HomePage() {
return (
<>
<HeroSection />
<AboutSection />
<MenuSection />
<StatsSection />
<GallerySection />
<TestimonialsSection />
<ReservationCTA />
</>
);
}

27
src/app/robots.ts Normal file
View file

@ -0,0 +1,27 @@
import { MetadataRoute } from "next";
import { seoConfig } from "@/lib/seo.config";
// =============================================================================
// ROBOTS.TXT
// Controls search engine crawling behavior
// =============================================================================
export default function robots(): MetadataRoute.Robots {
const baseUrl = seoConfig.siteUrl;
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/api/",
"/admin/",
"/_next/",
"/private/",
],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}

43
src/app/sitemap.ts Normal file
View file

@ -0,0 +1,43 @@
import { MetadataRoute } from "next";
import { seoConfig } from "@/lib/seo.config";
// =============================================================================
// SITEMAP
// Auto-generates sitemap.xml for search engines
// Add your routes here or fetch them dynamically from CMS
// =============================================================================
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = seoConfig.siteUrl;
// Static pages
const staticPages = [
"",
"/about",
"/services",
"/contact",
"/blog",
];
const staticRoutes = staticPages.map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: "monthly" as const,
priority: route === "" ? 1 : 0.8,
}));
// Dynamic pages (fetch from CMS/database)
// Example: blog posts
// const posts = await getBlogPosts();
// const blogRoutes = posts.map((post) => ({
// url: `${baseUrl}/blog/${post.slug}`,
// lastModified: post.updatedAt,
// changeFrequency: "weekly" as const,
// priority: 0.6,
// }));
return [
...staticRoutes,
// ...blogRoutes,
];
}

View file

@ -0,0 +1,52 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { ReactNode } from "react";
interface AnimatePresenceWrapperProps {
children: ReactNode;
mode?: "sync" | "wait" | "popLayout";
initial?: boolean;
}
export function AnimatePresenceWrapper({
children,
mode = "wait",
initial = true,
}: AnimatePresenceWrapperProps) {
return (
<AnimatePresence mode={mode} initial={initial}>
{children}
</AnimatePresence>
);
}
interface FadePresenceProps {
children: ReactNode;
isVisible: boolean;
duration?: number;
className?: string;
}
export function FadePresence({
children,
isVisible,
duration = 0.3,
className,
}: FadePresenceProps) {
return (
<AnimatePresence mode="wait">
{isVisible && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration }}
className={className}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}

View file

@ -0,0 +1,110 @@
"use client";
import { motion, useInView, useSpring, useTransform } from "framer-motion";
import { useRef, useEffect, useState } from "react";
interface CountUpProps {
to: number;
from?: number;
duration?: number;
delay?: number;
decimals?: number;
prefix?: string;
suffix?: string;
once?: boolean;
className?: string;
formatter?: (value: number) => string;
}
export function CountUp({
to,
from = 0,
duration = 2,
delay = 0,
decimals = 0,
prefix = "",
suffix = "",
once = true,
className,
formatter,
}: CountUpProps) {
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once });
const [hasAnimated, setHasAnimated] = useState(false);
const springValue = useSpring(from, {
duration: duration * 1000,
bounce: 0,
});
const displayValue = useTransform(springValue, (current) => {
if (formatter) {
return formatter(current);
}
return current.toFixed(decimals);
});
useEffect(() => {
if (isInView && !hasAnimated) {
const timer = setTimeout(() => {
springValue.set(to);
setHasAnimated(true);
}, delay * 1000);
return () => clearTimeout(timer);
}
}, [isInView, hasAnimated, springValue, to, delay]);
return (
<span ref={ref} className={className}>
{prefix}
<motion.span>{displayValue}</motion.span>
{suffix}
</span>
);
}
interface CountUpGroupProps {
stats: {
value: number;
label: string;
prefix?: string;
suffix?: string;
decimals?: number;
}[];
duration?: number;
staggerDelay?: number;
className?: string;
statClassName?: string;
labelClassName?: string;
valueClassName?: string;
}
export function CountUpGroup({
stats,
duration = 2,
staggerDelay = 0.2,
className,
statClassName,
labelClassName,
valueClassName,
}: CountUpGroupProps) {
return (
<div className={className}>
{stats.map((stat, index) => (
<div key={index} className={statClassName}>
<CountUp
to={stat.value}
prefix={stat.prefix}
suffix={stat.suffix}
decimals={stat.decimals}
duration={duration}
delay={index * staggerDelay}
className={valueClassName}
/>
<span className={labelClassName}>{stat.label}</span>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,198 @@
"use client";
import { motion, useInView } from "framer-motion";
import { useRef, ReactNode } from "react";
interface DrawSVGProps {
children: ReactNode;
duration?: number;
delay?: number;
strokeWidth?: number;
strokeColor?: string;
fillColor?: string;
fillDelay?: number;
once?: boolean;
className?: string;
}
export function DrawSVG({
children,
duration = 2,
delay = 0,
once = true,
className,
}: DrawSVGProps) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once });
return (
<div ref={ref} className={className}>
<motion.svg
initial="hidden"
animate={isInView ? "visible" : "hidden"}
className="w-full h-full"
>
<motion.g
variants={{
hidden: { pathLength: 0, opacity: 0 },
visible: {
pathLength: 1,
opacity: 1,
transition: {
pathLength: { duration, delay, ease: "easeInOut" },
opacity: { duration: 0.3, delay },
},
},
}}
>
{children}
</motion.g>
</motion.svg>
</div>
);
}
interface DrawPathProps {
d: string;
strokeWidth?: number;
stroke?: string;
fill?: string;
duration?: number;
delay?: number;
once?: boolean;
className?: string;
}
export function DrawPath({
d,
strokeWidth = 2,
stroke = "currentColor",
fill = "none",
duration = 2,
delay = 0,
once = true,
className,
}: DrawPathProps) {
const ref = useRef<SVGPathElement>(null);
const isInView = useInView(ref, { once });
return (
<motion.path
ref={ref}
d={d}
strokeWidth={strokeWidth}
stroke={stroke}
fill={fill}
initial={{ pathLength: 0, opacity: 0 }}
animate={
isInView
? { pathLength: 1, opacity: 1 }
: { pathLength: 0, opacity: 0 }
}
transition={{
pathLength: { duration, delay, ease: "easeInOut" },
opacity: { duration: 0.3, delay },
}}
className={className}
/>
);
}
interface DrawCircleProps {
cx: number;
cy: number;
r: number;
strokeWidth?: number;
stroke?: string;
fill?: string;
duration?: number;
delay?: number;
once?: boolean;
}
export function DrawCircle({
cx,
cy,
r,
strokeWidth = 2,
stroke = "currentColor",
fill = "none",
duration = 1.5,
delay = 0,
once = true,
}: DrawCircleProps) {
const ref = useRef<SVGCircleElement>(null);
const isInView = useInView(ref, { once });
return (
<motion.circle
ref={ref}
cx={cx}
cy={cy}
r={r}
strokeWidth={strokeWidth}
stroke={stroke}
fill={fill}
initial={{ pathLength: 0, opacity: 0 }}
animate={
isInView
? { pathLength: 1, opacity: 1 }
: { pathLength: 0, opacity: 0 }
}
transition={{
pathLength: { duration, delay, ease: "easeInOut" },
opacity: { duration: 0.3, delay },
}}
/>
);
}
interface AnimatedCheckmarkProps {
size?: number;
strokeWidth?: number;
color?: string;
duration?: number;
delay?: number;
className?: string;
}
export function AnimatedCheckmark({
size = 48,
strokeWidth = 3,
color = "currentColor",
duration = 0.5,
delay = 0,
className,
}: AnimatedCheckmarkProps) {
const ref = useRef<SVGSVGElement>(null);
const isInView = useInView(ref, { once: true });
return (
<motion.svg
ref={ref}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<motion.path
d="M5 12l5 5L20 7"
initial={{ pathLength: 0, opacity: 0 }}
animate={
isInView
? { pathLength: 1, opacity: 1 }
: { pathLength: 0, opacity: 0 }
}
transition={{
pathLength: { duration, delay, ease: "easeOut" },
opacity: { duration: 0.2, delay },
}}
/>
</motion.svg>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import { motion, type HTMLMotionProps } from "framer-motion";
import { cn } from "@/lib/utils";
interface FadeInProps extends HTMLMotionProps<"div"> {
children: React.ReactNode;
delay?: number;
duration?: number;
direction?: "up" | "down" | "left" | "right" | "none";
distance?: number;
className?: string;
}
export function FadeIn({
children,
delay = 0,
duration = 0.5,
direction = "up",
distance = 24,
className,
...props
}: FadeInProps) {
const directions = {
up: { y: distance },
down: { y: -distance },
left: { x: distance },
right: { x: -distance },
none: {},
};
return (
<motion.div
initial={{ opacity: 0, ...directions[direction] }}
whileInView={{ opacity: 1, x: 0, y: 0 }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration, delay, ease: "easeOut" }}
className={cn(className)}
{...props}
>
{children}
</motion.div>
);
}

View file

@ -0,0 +1,148 @@
"use client";
import { motion } from "framer-motion";
import { ReactNode } from "react";
interface HoverScaleProps {
children: ReactNode;
scale?: number;
duration?: number;
className?: string;
}
export function HoverScale({
children,
scale = 1.05,
duration = 0.2,
className,
}: HoverScaleProps) {
return (
<motion.div
whileHover={{ scale }}
whileTap={{ scale: scale * 0.98 }}
transition={{ duration, ease: "easeOut" }}
className={className}
>
{children}
</motion.div>
);
}
interface HoverTiltProps {
children: ReactNode;
tiltDegree?: number;
scale?: number;
className?: string;
}
export function HoverTilt({
children,
tiltDegree = 10,
scale = 1.02,
className,
}: HoverTiltProps) {
return (
<motion.div
whileHover={{
scale,
rotateX: tiltDegree,
rotateY: tiltDegree,
}}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ transformPerspective: 1000 }}
className={className}
>
{children}
</motion.div>
);
}
interface HoverLiftProps {
children: ReactNode;
y?: number;
shadow?: boolean;
className?: string;
}
export function HoverLift({
children,
y = -8,
shadow = true,
className,
}: HoverLiftProps) {
return (
<motion.div
whileHover={{
y,
boxShadow: shadow ? "0 20px 40px rgba(0,0,0,0.15)" : undefined,
}}
transition={{ duration: 0.3, ease: "easeOut" }}
className={className}
>
{children}
</motion.div>
);
}
interface HoverGlowProps {
children: ReactNode;
color?: string;
blur?: number;
className?: string;
}
export function HoverGlow({
children,
color = "rgba(59, 130, 246, 0.5)",
blur = 20,
className,
}: HoverGlowProps) {
return (
<motion.div
whileHover={{
boxShadow: `0 0 ${blur}px ${color}`,
}}
transition={{ duration: 0.3 }}
className={className}
>
{children}
</motion.div>
);
}
interface MagneticHoverProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function MagneticHover({
children,
strength = 0.3,
className,
}: MagneticHoverProps) {
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
const { currentTarget, clientX, clientY } = e;
const { left, top, width, height } = currentTarget.getBoundingClientRect();
const x = (clientX - left - width / 2) * strength;
const y = (clientY - top - height / 2) * strength;
currentTarget.style.transform = `translate(${x}px, ${y}px)`;
};
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
e.currentTarget.style.transform = "translate(0px, 0px)";
};
return (
<motion.div
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
transition={{ type: "spring", stiffness: 150, damping: 15 }}
className={className}
>
{children}
</motion.div>
);
}

View file

@ -0,0 +1,161 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useEffect } from "react";
interface MorphingTextProps {
texts: string[];
interval?: number;
duration?: number;
className?: string;
as?: "h1" | "h2" | "h3" | "h4" | "p" | "span";
}
export function MorphingText({
texts,
interval = 3000,
duration = 0.5,
className,
as: Component = "span",
}: MorphingTextProps) {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % texts.length);
}, interval);
return () => clearInterval(timer);
}, [texts.length, interval]);
return (
<div className={`relative inline-block ${className}`}>
<AnimatePresence mode="wait">
<motion.span
key={currentIndex}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration }}
>
<Component>{texts[currentIndex]}</Component>
</motion.span>
</AnimatePresence>
</div>
);
}
interface TypewriterProps {
text: string;
speed?: number;
delay?: number;
cursor?: boolean;
cursorChar?: string;
onComplete?: () => void;
className?: string;
}
export function Typewriter({
text,
speed = 50,
delay = 0,
cursor = true,
cursorChar = "|",
onComplete,
className,
}: TypewriterProps) {
const [displayText, setDisplayText] = useState("");
const [isTyping, setIsTyping] = useState(false);
useEffect(() => {
setDisplayText("");
setIsTyping(true);
const startTimer = setTimeout(() => {
let currentIndex = 0;
const typeTimer = setInterval(() => {
if (currentIndex < text.length) {
setDisplayText(text.slice(0, currentIndex + 1));
currentIndex++;
} else {
clearInterval(typeTimer);
setIsTyping(false);
onComplete?.();
}
}, speed);
return () => clearInterval(typeTimer);
}, delay);
return () => clearTimeout(startTimer);
}, [text, speed, delay, onComplete]);
return (
<span className={className}>
{displayText}
{cursor && (
<motion.span
animate={{ opacity: isTyping ? 1 : [1, 0] }}
transition={{
duration: 0.5,
repeat: isTyping ? 0 : Infinity,
repeatType: "reverse"
}}
>
{cursorChar}
</motion.span>
)}
</span>
);
}
interface RotatingWordsProps {
prefix?: string;
words: string[];
suffix?: string;
interval?: number;
className?: string;
wordClassName?: string;
}
export function RotatingWords({
prefix = "",
words,
suffix = "",
interval = 2000,
className,
wordClassName,
}: RotatingWordsProps) {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % words.length);
}, interval);
return () => clearInterval(timer);
}, [words.length, interval]);
return (
<span className={className}>
{prefix}
<span className="relative inline-block">
<AnimatePresence mode="wait">
<motion.span
key={currentIndex}
initial={{ opacity: 0, rotateX: -90 }}
animate={{ opacity: 1, rotateX: 0 }}
exit={{ opacity: 0, rotateX: 90 }}
transition={{ duration: 0.4 }}
className={`inline-block ${wordClassName}`}
style={{ transformPerspective: 500 }}
>
{words[currentIndex]}
</motion.span>
</AnimatePresence>
</span>
{suffix}
</span>
);
}

View file

@ -0,0 +1,108 @@
"use client";
import { motion, AnimatePresence, TargetAndTransition } from "framer-motion";
import { ReactNode } from "react";
import { usePathname } from "next/navigation";
type TransitionType = "fade" | "slide" | "scale" | "slideUp" | "slideDown";
interface PageTransitionProps {
children: ReactNode;
type?: TransitionType;
duration?: number;
className?: string;
}
const transitions: Record<TransitionType, {
initial: TargetAndTransition;
animate: TargetAndTransition;
exit: TargetAndTransition;
}> = {
fade: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
},
slide: {
initial: { opacity: 0, x: 100 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -100 },
},
scale: {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 1.05 },
},
slideUp: {
initial: { opacity: 0, y: 50 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -50 },
},
slideDown: {
initial: { opacity: 0, y: -50 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 50 },
},
};
export function PageTransition({
children,
type = "fade",
duration = 0.3,
className,
}: PageTransitionProps) {
const pathname = usePathname();
const { initial, animate, exit } = transitions[type];
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial={initial}
animate={animate}
exit={exit}
transition={{ duration, ease: "easeInOut" }}
className={className}
>
{children}
</motion.div>
</AnimatePresence>
);
}
// Overlay transition for more dramatic page changes
interface OverlayTransitionProps {
children: ReactNode;
color?: string;
duration?: number;
}
export function OverlayTransition({
children,
color = "bg-black",
duration = 0.5,
}: OverlayTransitionProps) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait">
<div key={pathname}>
<motion.div
initial={{ scaleY: 1 }}
animate={{ scaleY: 0 }}
exit={{ scaleY: 1 }}
transition={{ duration, ease: [0.22, 1, 0.36, 1] }}
style={{ originY: 0 }}
className={`fixed inset-0 z-50 ${color}`}
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: duration / 2, delay: duration / 2 }}
>
{children}
</motion.div>
</div>
</AnimatePresence>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import { motion, useScroll, useTransform } from "framer-motion";
import { ReactNode, useRef } from "react";
interface ParallaxScrollProps {
children: ReactNode;
speed?: number; // -1 to 1 (negative = opposite direction)
direction?: "vertical" | "horizontal";
className?: string;
}
export function ParallaxScroll({
children,
speed = 0.5,
direction = "vertical",
className,
}: ParallaxScrollProps) {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
});
const y = useTransform(scrollYProgress, [0, 1], [speed * -100, speed * 100]);
const x = useTransform(scrollYProgress, [0, 1], [speed * -100, speed * 100]);
return (
<div ref={ref} className={className}>
<motion.div style={direction === "vertical" ? { y } : { x }}>
{children}
</motion.div>
</div>
);
}
interface ParallaxLayersProps {
layers: {
content: ReactNode;
speed: number;
className?: string;
}[];
className?: string;
}
export function ParallaxLayers({ layers, className }: ParallaxLayersProps) {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
});
return (
<div ref={ref} className={`relative ${className}`}>
{layers.map((layer, index) => {
const y = useTransform(
scrollYProgress,
[0, 1],
[layer.speed * -100, layer.speed * 100]
);
return (
<motion.div
key={index}
style={{ y }}
className={`absolute inset-0 ${layer.className}`}
>
{layer.content}
</motion.div>
);
})}
</div>
);
}

View file

@ -0,0 +1,34 @@
"use client";
import { motion, type HTMLMotionProps } from "framer-motion";
import { cn } from "@/lib/utils";
interface ScaleInProps extends HTMLMotionProps<"div"> {
children: React.ReactNode;
delay?: number;
duration?: number;
scale?: number;
className?: string;
}
export function ScaleIn({
children,
delay = 0,
duration = 0.5,
scale = 0.9,
className,
...props
}: ScaleInProps) {
return (
<motion.div
initial={{ opacity: 0, scale }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration, delay, ease: "easeOut" }}
className={cn(className)}
{...props}
>
{children}
</motion.div>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { motion, useInView, TargetAndTransition } from "framer-motion";
import { ReactNode, useRef } from "react";
type AnimationType = "fade" | "slide-up" | "slide-down" | "slide-left" | "slide-right" | "scale" | "blur";
interface ScrollRevealProps {
children: ReactNode;
animation?: AnimationType;
duration?: number;
delay?: number;
threshold?: number;
once?: boolean;
className?: string;
}
const animations: Record<AnimationType, { initial: TargetAndTransition; animate: TargetAndTransition }> = {
fade: {
initial: { opacity: 0 },
animate: { opacity: 1 },
},
"slide-up": {
initial: { opacity: 0, y: 50 },
animate: { opacity: 1, y: 0 },
},
"slide-down": {
initial: { opacity: 0, y: -50 },
animate: { opacity: 1, y: 0 },
},
"slide-left": {
initial: { opacity: 0, x: 50 },
animate: { opacity: 1, x: 0 },
},
"slide-right": {
initial: { opacity: 0, x: -50 },
animate: { opacity: 1, x: 0 },
},
scale: {
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
},
blur: {
initial: { opacity: 0, filter: "blur(10px)" },
animate: { opacity: 1, filter: "blur(0px)" },
},
};
export function ScrollReveal({
children,
animation = "slide-up",
duration = 0.6,
delay = 0,
threshold = 0.2,
once = true,
className,
}: ScrollRevealProps) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once, amount: threshold });
const { initial, animate } = animations[animation];
return (
<motion.div
ref={ref}
initial={initial}
animate={isInView ? animate : initial}
transition={{ duration, delay, ease: "easeOut" }}
className={className}
>
{children}
</motion.div>
);
}

View file

@ -0,0 +1,43 @@
"use client";
import { motion, type HTMLMotionProps } from "framer-motion";
import { cn } from "@/lib/utils";
interface SlideInProps extends HTMLMotionProps<"div"> {
children: React.ReactNode;
direction?: "left" | "right" | "up" | "down";
delay?: number;
duration?: number;
distance?: number;
className?: string;
}
export function SlideIn({
children,
direction = "left",
delay = 0,
duration = 0.6,
distance = 100,
className,
...props
}: SlideInProps) {
const directions = {
left: { x: -distance, y: 0 },
right: { x: distance, y: 0 },
up: { x: 0, y: -distance },
down: { x: 0, y: distance },
};
return (
<motion.div
initial={{ opacity: 0, ...directions[direction] }}
whileInView={{ opacity: 1, x: 0, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration, delay, ease: [0.25, 0.1, 0.25, 1] }}
className={cn(className)}
{...props}
>
{children}
</motion.div>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { motion, type Variants } from "framer-motion";
import { cn } from "@/lib/utils";
interface StaggerChildrenProps {
children: React.ReactNode;
staggerDelay?: number;
duration?: number;
className?: string;
}
const container: Variants = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const item: Variants = {
hidden: { opacity: 0, y: 20 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.5, ease: "easeOut" },
},
};
export function StaggerChildren({
children,
staggerDelay = 0.1,
duration = 0.5,
className,
}: StaggerChildrenProps) {
const containerVariants: Variants = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: staggerDelay,
},
},
};
const itemVariants: Variants = {
hidden: { opacity: 0, y: 20 },
show: {
opacity: 1,
y: 0,
transition: { duration, ease: "easeOut" },
},
};
return (
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "-100px" }}
className={cn(className)}
>
{Array.isArray(children)
? children.map((child, index) => (
<motion.div key={index} variants={itemVariants}>
{child}
</motion.div>
))
: children}
</motion.div>
);
}

View file

@ -0,0 +1,136 @@
"use client";
import { motion, useInView, Variants } from "framer-motion";
import { ReactNode, useRef } from "react";
interface TextRevealProps {
children: string;
as?: "h1" | "h2" | "h3" | "h4" | "p" | "span";
animation?: "fade" | "slide" | "blur" | "wave";
staggerDelay?: number;
duration?: number;
delay?: number;
once?: boolean;
className?: string;
}
export function TextReveal({
children,
as: Component = "p",
animation = "slide",
staggerDelay = 0.03,
duration = 0.5,
delay = 0,
once = true,
className,
}: TextRevealProps) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once });
const letters = children.split("");
const containerVariants: Variants = {
hidden: {},
visible: {
transition: {
staggerChildren: staggerDelay,
delayChildren: delay,
},
},
};
const letterVariants: Record<string, Variants> = {
fade: {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration } },
},
slide: {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration } },
},
blur: {
hidden: { opacity: 0, filter: "blur(10px)" },
visible: { opacity: 1, filter: "blur(0px)", transition: { duration } },
},
wave: {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration, type: "spring", stiffness: 200 },
},
},
};
return (
<motion.div
ref={ref}
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
className={className}
>
<Component className="inline-block">
{letters.map((letter, index) => (
<motion.span
key={index}
variants={letterVariants[animation]}
className="inline-block"
style={{ whiteSpace: letter === " " ? "pre" : "normal" }}
>
{letter === " " ? "\u00A0" : letter}
</motion.span>
))}
</Component>
</motion.div>
);
}
interface SplitWordsProps {
children: string;
as?: "h1" | "h2" | "h3" | "h4" | "p" | "span";
staggerDelay?: number;
duration?: number;
delay?: number;
once?: boolean;
className?: string;
wordClassName?: string;
}
export function SplitWords({
children,
as: Component = "p",
staggerDelay = 0.1,
duration = 0.5,
delay = 0,
once = true,
className,
wordClassName,
}: SplitWordsProps) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once });
const words = children.split(" ");
return (
<div ref={ref} className={className}>
<Component className="inline">
{words.map((word, index) => (
<motion.span
key={index}
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{
duration,
delay: delay + index * staggerDelay,
ease: "easeOut",
}}
className={`inline-block mr-[0.25em] ${wordClassName}`}
>
{word}
</motion.span>
))}
</Component>
</div>
);
}

View file

@ -0,0 +1,32 @@
// Base animations
export { FadeIn } from "./FadeIn";
export { SlideIn } from "./SlideIn";
export { ScaleIn } from "./ScaleIn";
export { StaggerChildren } from "./StaggerChildren";
// Presence & Transitions
export { AnimatePresenceWrapper, FadePresence } from "./AnimatePresenceWrapper";
export { PageTransition, OverlayTransition } from "./PageTransition";
// Scroll-based
export { ScrollReveal } from "./ScrollReveal";
export { ParallaxScroll, ParallaxLayers } from "./ParallaxScroll";
// Text animations
export { TextReveal, SplitWords } from "./TextReveal";
export { MorphingText, Typewriter, RotatingWords } from "./MorphingText";
// Hover effects
export {
HoverScale,
HoverTilt,
HoverLift,
HoverGlow,
MagneticHover
} from "./HoverEffects";
// Numbers & Data
export { CountUp, CountUpGroup } from "./CountUp";
// SVG animations
export { DrawSVG, DrawPath, DrawCircle, AnimatedCheckmark } from "./DrawSVG";

View file

@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown, Plus, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// ACCORDION
// Expandable content sections
// =============================================================================
interface AccordionItem {
id: string;
title: string;
content: string;
}
interface AccordionProps {
items: AccordionItem[];
allowMultiple?: boolean;
variant?: "default" | "bordered" | "separated" | "minimal";
iconStyle?: "chevron" | "plus";
className?: string;
}
export function Accordion({
items,
allowMultiple = false,
variant = "default",
iconStyle = "chevron",
className,
}: AccordionProps) {
const [openItems, setOpenItems] = useState<string[]>([]);
const toggleItem = (id: string) => {
if (allowMultiple) {
setOpenItems((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
);
} else {
setOpenItems((prev) => (prev.includes(id) ? [] : [id]));
}
};
const isOpen = (id: string) => openItems.includes(id);
const variantStyles = {
default: "divide-y divide-border",
bordered: "border rounded-lg divide-y divide-border",
separated: "space-y-3",
minimal: "",
};
const itemStyles = {
default: "",
bordered: "",
separated: "border rounded-lg",
minimal: "border-b border-border last:border-0",
};
return (
<div className={cn(variantStyles[variant], className)}>
{items.map((item) => (
<div key={item.id} className={itemStyles[variant]}>
<button
onClick={() => toggleItem(item.id)}
className={cn(
"flex w-full items-center justify-between py-4 text-left font-medium transition-colors hover:text-primary",
variant === "bordered" || variant === "separated" ? "px-4" : ""
)}
aria-expanded={isOpen(item.id)}
>
<span>{item.title}</span>
<span className="ml-4 shrink-0">
{iconStyle === "chevron" ? (
<motion.span
animate={{ rotate: isOpen(item.id) ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="block"
>
<ChevronDown className="h-5 w-5" />
</motion.span>
) : isOpen(item.id) ? (
<Minus className="h-5 w-5" />
) : (
<Plus className="h-5 w-5" />
)}
</span>
</button>
<AnimatePresence initial={false}>
{isOpen(item.id) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="overflow-hidden"
>
<div
className={cn(
"pb-4 text-muted-foreground",
variant === "bordered" || variant === "separated"
? "px-4"
: ""
)}
>
{item.content}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
);
}
// =============================================================================
// FAQ SECTION
// Accordion with section title and description
// =============================================================================
interface FAQSectionProps {
title?: string;
description?: string;
items: AccordionItem[];
columns?: 1 | 2;
className?: string;
}
export function FAQSection({
title = "Questions fréquentes",
description,
items,
columns = 1,
className,
}: FAQSectionProps) {
const half = Math.ceil(items.length / 2);
const leftItems = items.slice(0, half);
const rightItems = items.slice(half);
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold">{title}</h2>
{description && (
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
{description}
</p>
)}
</div>
{columns === 2 ? (
<div className="grid md:grid-cols-2 gap-8 max-w-5xl mx-auto">
<Accordion items={leftItems} variant="minimal" />
<Accordion items={rightItems} variant="minimal" />
</div>
) : (
<div className="max-w-3xl mx-auto">
<Accordion items={items} variant="bordered" />
</div>
)}
</div>
</section>
);
}

View file

@ -0,0 +1,463 @@
"use client";
import { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { motion } from "framer-motion";
import { Calendar, Clock, User, ArrowRight, Tag } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// BLOG POST CARD
// Individual blog post preview
// =============================================================================
interface Author {
name: string;
avatar?: string;
}
interface BlogPost {
slug: string;
title: string;
excerpt: string;
image: string;
date: string;
readTime?: string;
author?: Author;
category?: string;
tags?: string[];
}
interface BlogPostCardProps {
post: BlogPost;
variant?: "default" | "horizontal" | "featured" | "minimal";
className?: string;
}
export function BlogPostCard({
post,
variant = "default",
className,
}: BlogPostCardProps) {
if (variant === "horizontal") {
return (
<motion.article
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className={cn("group", className)}
>
<Link
href={`/blog/${post.slug}`}
className="flex gap-6 items-center"
>
<div className="relative w-48 h-32 rounded-lg overflow-hidden shrink-0">
<Image
src={post.image}
alt={post.title}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
</div>
<div className="flex-1">
{post.category && (
<span className="text-sm text-primary font-medium">
{post.category}
</span>
)}
<h3 className="text-lg font-semibold mt-1 group-hover:text-primary transition-colors line-clamp-2">
{post.title}
</h3>
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
{post.date}
</span>
{post.readTime && (
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{post.readTime}
</span>
)}
</div>
</div>
</Link>
</motion.article>
);
}
if (variant === "featured") {
return (
<motion.article
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className={cn("group relative overflow-hidden rounded-2xl", className)}
>
<Link href={`/blog/${post.slug}`} className="block">
<div className="relative aspect-[2/1]">
<Image
src={post.image}
alt={post.title}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
</div>
<div className="absolute bottom-0 left-0 right-0 p-8 text-white">
{post.category && (
<span className="inline-block px-3 py-1 bg-primary text-primary-foreground text-sm font-medium rounded-full mb-4">
{post.category}
</span>
)}
<h2 className="text-2xl md:text-3xl font-bold">{post.title}</h2>
<p className="mt-3 text-white/80 line-clamp-2">{post.excerpt}</p>
<div className="flex items-center gap-4 mt-4">
{post.author && (
<div className="flex items-center gap-2">
{post.author.avatar && (
<div className="relative w-8 h-8 rounded-full overflow-hidden">
<Image
src={post.author.avatar}
alt={post.author.name}
fill
className="object-cover"
/>
</div>
)}
<span className="text-sm">{post.author.name}</span>
</div>
)}
<span className="text-sm text-white/70">{post.date}</span>
</div>
</div>
</Link>
</motion.article>
);
}
if (variant === "minimal") {
return (
<motion.article
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className={cn("group", className)}
>
<Link href={`/blog/${post.slug}`} className="block py-4 border-b">
<div className="flex justify-between items-start gap-4">
<div>
<h3 className="font-semibold group-hover:text-primary transition-colors">
{post.title}
</h3>
<p className="text-sm text-muted-foreground mt-1">{post.date}</p>
</div>
<ArrowRight className="w-5 h-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all shrink-0" />
</div>
</Link>
</motion.article>
);
}
// Default card
return (
<motion.article
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className={cn("group bg-card border rounded-xl overflow-hidden", className)}
>
<Link href={`/blog/${post.slug}`} className="block">
<div className="relative aspect-video overflow-hidden">
<Image
src={post.image}
alt={post.title}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
{post.category && (
<span className="absolute top-3 left-3 px-3 py-1 bg-primary text-primary-foreground text-xs font-medium rounded-full">
{post.category}
</span>
)}
</div>
<div className="p-6">
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors line-clamp-2">
{post.title}
</h3>
<p className="mt-2 text-muted-foreground text-sm line-clamp-2">
{post.excerpt}
</p>
<div className="flex items-center justify-between mt-4">
{post.author && (
<div className="flex items-center gap-2">
{post.author.avatar && (
<div className="relative w-6 h-6 rounded-full overflow-hidden">
<Image
src={post.author.avatar}
alt={post.author.name}
fill
className="object-cover"
/>
</div>
)}
<span className="text-sm text-muted-foreground">
{post.author.name}
</span>
</div>
)}
<span className="text-sm text-muted-foreground">{post.date}</span>
</div>
</div>
</Link>
</motion.article>
);
}
// =============================================================================
// BLOG GRID
// Grid layout for blog posts
// =============================================================================
interface BlogGridProps {
posts: BlogPost[];
columns?: 2 | 3;
featuredFirst?: boolean;
className?: string;
}
export function BlogGrid({
posts,
columns = 3,
featuredFirst = false,
className,
}: BlogGridProps) {
const colStyles = {
2: "md:grid-cols-2",
3: "md:grid-cols-2 lg:grid-cols-3",
};
if (featuredFirst && posts.length > 0) {
const [featured, ...rest] = posts;
return (
<div className={className}>
<BlogPostCard post={featured} variant="featured" className="mb-8" />
<div className={cn("grid gap-8", colStyles[columns])}>
{rest.map((post) => (
<BlogPostCard key={post.slug} post={post} />
))}
</div>
</div>
);
}
return (
<div className={cn("grid gap-8", colStyles[columns], className)}>
{posts.map((post) => (
<BlogPostCard key={post.slug} post={post} />
))}
</div>
);
}
// =============================================================================
// BLOG LIST
// List layout for blog posts
// =============================================================================
interface BlogListProps {
posts: BlogPost[];
variant?: "default" | "minimal";
className?: string;
}
export function BlogList({
posts,
variant = "default",
className,
}: BlogListProps) {
return (
<div className={cn("space-y-6", className)}>
{posts.map((post) => (
<BlogPostCard
key={post.slug}
post={post}
variant={variant === "minimal" ? "minimal" : "horizontal"}
/>
))}
</div>
);
}
// =============================================================================
// BLOG SIDEBAR
// Sidebar with widgets
// =============================================================================
interface BlogSidebarProps {
recentPosts?: BlogPost[];
categories?: { name: string; count: number; href: string }[];
tags?: { name: string; href: string }[];
newsletter?: boolean;
className?: string;
}
export function BlogSidebar({
recentPosts,
categories,
tags,
newsletter = true,
className,
}: BlogSidebarProps) {
return (
<aside className={cn("space-y-8", className)}>
{/* Recent Posts */}
{recentPosts && recentPosts.length > 0 && (
<div className="bg-card border rounded-xl p-6">
<h3 className="font-semibold mb-4">Articles récents</h3>
<div className="space-y-4">
{recentPosts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
className="flex gap-3 group"
>
<div className="relative w-16 h-16 rounded-lg overflow-hidden shrink-0">
<Image
src={post.image}
alt={post.title}
fill
className="object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium line-clamp-2 group-hover:text-primary transition-colors">
{post.title}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{post.date}
</p>
</div>
</Link>
))}
</div>
</div>
)}
{/* Categories */}
{categories && categories.length > 0 && (
<div className="bg-card border rounded-xl p-6">
<h3 className="font-semibold mb-4">Catégories</h3>
<ul className="space-y-2">
{categories.map((category) => (
<li key={category.name}>
<Link
href={category.href}
className="flex justify-between items-center py-2 text-sm hover:text-primary transition-colors"
>
<span>{category.name}</span>
<span className="text-muted-foreground">({category.count})</span>
</Link>
</li>
))}
</ul>
</div>
)}
{/* Tags */}
{tags && tags.length > 0 && (
<div className="bg-card border rounded-xl p-6">
<h3 className="font-semibold mb-4">Tags</h3>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Link
key={tag.name}
href={tag.href}
className="px-3 py-1 bg-muted text-sm rounded-full hover:bg-primary hover:text-primary-foreground transition-colors"
>
{tag.name}
</Link>
))}
</div>
</div>
)}
{/* Newsletter */}
{newsletter && (
<div className="bg-primary text-primary-foreground rounded-xl p-6">
<h3 className="font-semibold">Newsletter</h3>
<p className="text-sm text-primary-foreground/80 mt-2">
Recevez nos derniers articles par email.
</p>
<form className="mt-4 space-y-3">
<input
type="email"
placeholder="Votre email"
className="w-full px-4 py-2 rounded-lg bg-primary-foreground/10 text-primary-foreground placeholder:text-primary-foreground/60 focus:outline-none focus:ring-2 focus:ring-primary-foreground/50"
/>
<button
type="submit"
className="w-full py-2 bg-background text-foreground rounded-lg font-medium hover:bg-background/90 transition-colors"
>
S&apos;inscrire
</button>
</form>
</div>
)}
</aside>
);
}
// =============================================================================
// BLOG SECTION
// Full blog section with title
// =============================================================================
interface BlogSectionProps {
title?: string;
description?: string;
posts: BlogPost[];
columns?: 2 | 3;
viewAllLink?: string;
viewAllText?: string;
className?: string;
}
export function BlogSection({
title = "Notre Blog",
description,
posts,
columns = 3,
viewAllLink,
viewAllText = "Voir tous les articles",
className,
}: BlogSectionProps) {
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-12">
<div>
<h2 className="text-3xl md:text-4xl font-bold">{title}</h2>
{description && (
<p className="mt-4 text-lg text-muted-foreground max-w-2xl">
{description}
</p>
)}
</div>
{viewAllLink && (
<Link
href={viewAllLink}
className="inline-flex items-center gap-2 text-primary hover:underline"
>
{viewAllText}
<ArrowRight className="w-4 h-4" />
</Link>
)}
</div>
<BlogGrid posts={posts} columns={columns} />
</div>
</section>
);
}

View file

@ -0,0 +1,201 @@
"use client";
import Link from "next/link";
import { ChevronRight, Home } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// BREADCRUMBS
// Navigation breadcrumb trail
// =============================================================================
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbsProps {
items: BreadcrumbItem[];
showHome?: boolean;
homeLabel?: string;
separator?: "chevron" | "slash" | "arrow";
className?: string;
}
export function Breadcrumbs({
items,
showHome = true,
homeLabel = "Accueil",
separator = "chevron",
className,
}: BreadcrumbsProps) {
const separators = {
chevron: <ChevronRight className="w-4 h-4 text-muted-foreground" />,
slash: <span className="text-muted-foreground">/</span>,
arrow: <span className="text-muted-foreground"></span>,
};
const allItems = showHome
? [{ label: homeLabel, href: "/" }, ...items]
: items;
return (
<nav aria-label="Breadcrumb" className={className}>
<ol className="flex items-center flex-wrap gap-2 text-sm">
{allItems.map((item, index) => {
const isLast = index === allItems.length - 1;
const isHome = index === 0 && showHome;
return (
<li key={index} className="flex items-center gap-2">
{index > 0 && separators[separator]}
{isLast ? (
<span className="text-foreground font-medium" aria-current="page">
{item.label}
</span>
) : item.href ? (
<Link
href={item.href}
className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
{isHome && <Home className="w-4 h-4" />}
{!isHome && item.label}
</Link>
) : (
<span className="text-muted-foreground flex items-center gap-1">
{isHome && <Home className="w-4 h-4" />}
{!isHome && item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
// =============================================================================
// BREADCRUMBS WITH DROPDOWN
// Collapsible breadcrumbs for long paths
// =============================================================================
interface CollapsibleBreadcrumbsProps {
items: BreadcrumbItem[];
maxVisible?: number;
className?: string;
}
export function CollapsibleBreadcrumbs({
items,
maxVisible = 3,
className,
}: CollapsibleBreadcrumbsProps) {
if (items.length <= maxVisible) {
return <Breadcrumbs items={items} className={className} />;
}
const firstItem = items[0];
const lastItems = items.slice(-maxVisible + 1);
const hiddenItems = items.slice(1, -maxVisible + 1);
return (
<nav aria-label="Breadcrumb" className={className}>
<ol className="flex items-center flex-wrap gap-2 text-sm">
{/* Home */}
<li className="flex items-center gap-2">
<Link
href={firstItem.href || "/"}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Home className="w-4 h-4" />
</Link>
</li>
{/* Collapsed items */}
<li className="flex items-center gap-2">
<ChevronRight className="w-4 h-4 text-muted-foreground" />
<div className="relative group">
<button className="text-muted-foreground hover:text-foreground">
</button>
<div className="absolute left-0 top-full mt-1 bg-background border rounded-lg shadow-lg py-2 hidden group-hover:block z-10 min-w-[150px]">
{hiddenItems.map((item, index) => (
<Link
key={index}
href={item.href || "#"}
className="block px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted"
>
{item.label}
</Link>
))}
</div>
</div>
</li>
{/* Visible items */}
{lastItems.map((item, index) => {
const isLast = index === lastItems.length - 1;
return (
<li key={index} className="flex items-center gap-2">
<ChevronRight className="w-4 h-4 text-muted-foreground" />
{isLast ? (
<span className="text-foreground font-medium" aria-current="page">
{item.label}
</span>
) : (
<Link
href={item.href || "#"}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{item.label}
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
}
// =============================================================================
// PAGE HEADER WITH BREADCRUMBS
// Common pattern: breadcrumbs + title
// =============================================================================
interface PageHeaderProps {
title: string;
description?: string;
breadcrumbs?: BreadcrumbItem[];
actions?: React.ReactNode;
className?: string;
}
export function PageHeader({
title,
description,
breadcrumbs,
actions,
className,
}: PageHeaderProps) {
return (
<div className={cn("py-8", className)}>
<div className="container mx-auto px-4">
{breadcrumbs && breadcrumbs.length > 0 && (
<Breadcrumbs items={breadcrumbs} className="mb-4" />
)}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-3xl md:text-4xl font-bold">{title}</h1>
{description && (
<p className="mt-2 text-lg text-muted-foreground">{description}</p>
)}
</div>
{actions && <div className="flex gap-2">{actions}</div>}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { FadeIn } from "@/components/animations";
import { cn } from "@/lib/utils";
interface CTABannerProps {
title: string;
subtitle?: string;
cta: { label: string; url: string };
ctaSecondary?: { label: string; url: string };
variant?: "primary" | "dark" | "gradient";
alignment?: "left" | "center";
className?: string;
}
export function CTABanner({
title,
subtitle,
cta,
ctaSecondary,
variant = "primary",
alignment = "center",
className,
}: CTABannerProps) {
const variants = {
primary: "bg-primary text-primary-foreground",
dark: "bg-zinc-900 text-white",
gradient: "bg-gradient-to-r from-primary to-purple-600 text-white",
};
const alignmentClasses = {
left: "text-left items-start",
center: "text-center items-center",
};
return (
<section className={cn("py-20 px-4", variants[variant], className)}>
<div className="max-w-4xl mx-auto">
<FadeIn
className={cn(
"flex flex-col gap-6",
alignmentClasses[alignment]
)}
>
<h2 className="text-3xl md:text-4xl font-bold">{title}</h2>
{subtitle && (
<p className="text-lg opacity-90 max-w-2xl">{subtitle}</p>
)}
<div className="flex flex-wrap gap-4 mt-2">
<Button
asChild
size="lg"
variant={variant === "primary" ? "secondary" : "default"}
>
<Link href={cta.url}>{cta.label}</Link>
</Button>
{ctaSecondary && (
<Button
asChild
size="lg"
variant="outline"
className="border-current"
>
<Link href={ctaSecondary.url}>{ctaSecondary.label}</Link>
</Button>
)}
</div>
</FadeIn>
</div>
</section>
);
}

View file

@ -0,0 +1,403 @@
"use client";
import { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { motion } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// BASE CARD
// Flexible card component
// =============================================================================
interface CardProps {
children: ReactNode;
variant?: "default" | "elevated" | "bordered" | "ghost";
hover?: "none" | "lift" | "scale" | "glow";
padding?: "none" | "sm" | "md" | "lg";
className?: string;
}
export function Card({
children,
variant = "default",
hover = "none",
padding = "md",
className,
}: CardProps) {
const variantStyles = {
default: "bg-card border",
elevated: "bg-card shadow-lg",
bordered: "bg-transparent border-2",
ghost: "bg-transparent",
};
const paddingStyles = {
none: "",
sm: "p-4",
md: "p-6",
lg: "p-8",
};
const hoverStyles = {
none: "",
lift: "transition-transform hover:-translate-y-1 hover:shadow-lg",
scale: "transition-transform hover:scale-[1.02]",
glow: "transition-shadow hover:shadow-lg hover:shadow-primary/20",
};
return (
<div
className={cn(
"rounded-xl",
variantStyles[variant],
paddingStyles[padding],
hoverStyles[hover],
className
)}
>
{children}
</div>
);
}
// =============================================================================
// FEATURE CARD
// Icon + title + description
// =============================================================================
interface FeatureCardProps {
icon: ReactNode;
title: string;
description: string;
link?: { href: string; label: string };
variant?: "default" | "centered" | "horizontal";
className?: string;
}
export function FeatureCard({
icon,
title,
description,
link,
variant = "default",
className,
}: FeatureCardProps) {
const content = (
<>
<div
className={cn(
"w-12 h-12 rounded-lg bg-primary/10 text-primary flex items-center justify-center",
variant === "centered" && "mx-auto"
)}
>
{icon}
</div>
<div className={variant === "horizontal" ? "flex-1" : ""}>
<h3
className={cn(
"text-lg font-semibold mt-4",
variant === "centered" && "text-center"
)}
>
{title}
</h3>
<p
className={cn(
"mt-2 text-muted-foreground",
variant === "centered" && "text-center"
)}
>
{description}
</p>
{link && (
<Link
href={link.href}
className={cn(
"inline-flex items-center gap-1 mt-4 text-primary hover:underline",
variant === "centered" && "justify-center w-full"
)}
>
{link.label}
<ArrowRight className="w-4 h-4" />
</Link>
)}
</div>
</>
);
return (
<Card hover="lift" className={className}>
<div
className={cn(
variant === "horizontal" && "flex items-start gap-4"
)}
>
{content}
</div>
</Card>
);
}
// =============================================================================
// IMAGE CARD
// Card with image header
// =============================================================================
interface ImageCardProps {
image: string;
alt: string;
title: string;
description?: string;
category?: string;
date?: string;
href?: string;
aspectRatio?: "video" | "square" | "portrait";
className?: string;
}
export function ImageCard({
image,
alt,
title,
description,
category,
date,
href,
aspectRatio = "video",
className,
}: ImageCardProps) {
const aspectStyles = {
video: "aspect-video",
square: "aspect-square",
portrait: "aspect-[3/4]",
};
const card = (
<Card hover="lift" padding="none" className={className}>
<div className={cn("relative overflow-hidden rounded-t-xl", aspectStyles[aspectRatio])}>
<Image
src={image}
alt={alt}
fill
className="object-cover transition-transform hover:scale-105"
/>
{category && (
<span className="absolute top-3 left-3 px-3 py-1 bg-primary text-primary-foreground text-xs font-medium rounded-full">
{category}
</span>
)}
</div>
<div className="p-6">
{date && (
<span className="text-sm text-muted-foreground">{date}</span>
)}
<h3 className="text-lg font-semibold mt-1">{title}</h3>
{description && (
<p className="mt-2 text-muted-foreground line-clamp-2">
{description}
</p>
)}
</div>
</Card>
);
if (href) {
return (
<Link href={href} className="block">
{card}
</Link>
);
}
return card;
}
// =============================================================================
// PROFILE CARD
// Avatar + name + role
// =============================================================================
interface ProfileCardProps {
image: string;
name: string;
role: string;
bio?: string;
social?: { icon: ReactNode; href: string }[];
variant?: "default" | "horizontal" | "minimal";
className?: string;
}
export function ProfileCard({
image,
name,
role,
bio,
social,
variant = "default",
className,
}: ProfileCardProps) {
if (variant === "horizontal") {
return (
<Card hover="lift" className={className}>
<div className="flex items-center gap-6">
<div className="relative w-24 h-24 rounded-full overflow-hidden shrink-0">
<Image src={image} alt={name} fill className="object-cover" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">{name}</h3>
<p className="text-primary">{role}</p>
{bio && (
<p className="mt-2 text-sm text-muted-foreground">{bio}</p>
)}
{social && (
<div className="flex gap-2 mt-3">
{social.map((item, i) => (
<a
key={i}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center rounded-full bg-muted hover:bg-primary hover:text-primary-foreground transition-colors"
>
{item.icon}
</a>
))}
</div>
)}
</div>
</div>
</Card>
);
}
if (variant === "minimal") {
return (
<div className={cn("flex items-center gap-4", className)}>
<div className="relative w-12 h-12 rounded-full overflow-hidden">
<Image src={image} alt={name} fill className="object-cover" />
</div>
<div>
<h3 className="font-semibold">{name}</h3>
<p className="text-sm text-muted-foreground">{role}</p>
</div>
</div>
);
}
// Default: centered card
return (
<Card hover="lift" className={cn("text-center", className)}>
<div className="relative w-24 h-24 rounded-full overflow-hidden mx-auto">
<Image src={image} alt={name} fill className="object-cover" />
</div>
<h3 className="text-lg font-semibold mt-4">{name}</h3>
<p className="text-primary">{role}</p>
{bio && (
<p className="mt-3 text-sm text-muted-foreground">{bio}</p>
)}
{social && (
<div className="flex justify-center gap-2 mt-4">
{social.map((item, i) => (
<a
key={i}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center rounded-full bg-muted hover:bg-primary hover:text-primary-foreground transition-colors"
>
{item.icon}
</a>
))}
</div>
)}
</Card>
);
}
// =============================================================================
// STAT CARD
// Number + label
// =============================================================================
interface StatCardProps {
value: string;
label: string;
icon?: ReactNode;
trend?: { value: string; positive: boolean };
className?: string;
}
export function StatCard({
value,
label,
icon,
trend,
className,
}: StatCardProps) {
return (
<Card className={className}>
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-3xl font-bold mt-1">{value}</p>
{trend && (
<p
className={cn(
"text-sm mt-2",
trend.positive ? "text-green-600" : "text-red-600"
)}
>
{trend.positive ? "↑" : "↓"} {trend.value}
</p>
)}
</div>
{icon && (
<div className="w-12 h-12 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
{icon}
</div>
)}
</div>
</Card>
);
}
// =============================================================================
// CARD GRID
// Responsive grid layout
// =============================================================================
interface CardGridProps {
children: ReactNode;
columns?: 1 | 2 | 3 | 4;
gap?: "sm" | "md" | "lg";
className?: string;
}
export function CardGrid({
children,
columns = 3,
gap = "md",
className,
}: CardGridProps) {
const colStyles = {
1: "grid-cols-1",
2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
};
const gapStyles = {
sm: "gap-4",
md: "gap-6",
lg: "gap-8",
};
return (
<div className={cn("grid", colStyles[columns], gapStyles[gap], className)}>
{children}
</div>
);
}

View file

@ -0,0 +1,304 @@
"use client";
import { useState, useEffect, useCallback, ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import Image from "next/image";
// =============================================================================
// CAROUSEL
// Sliding content carousel
// =============================================================================
interface CarouselProps {
children: ReactNode[];
autoPlay?: boolean;
autoPlayInterval?: number;
showArrows?: boolean;
showDots?: boolean;
className?: string;
}
export function Carousel({
children,
autoPlay = false,
autoPlayInterval = 5000,
showArrows = true,
showDots = true,
className,
}: CarouselProps) {
const [current, setCurrent] = useState(0);
const [direction, setDirection] = useState(0);
const slideCount = children.length;
const next = useCallback(() => {
setDirection(1);
setCurrent((prev) => (prev + 1) % slideCount);
}, [slideCount]);
const prev = useCallback(() => {
setDirection(-1);
setCurrent((prev) => (prev - 1 + slideCount) % slideCount);
}, [slideCount]);
const goTo = (index: number) => {
setDirection(index > current ? 1 : -1);
setCurrent(index);
};
useEffect(() => {
if (!autoPlay) return;
const timer = setInterval(next, autoPlayInterval);
return () => clearInterval(timer);
}, [autoPlay, autoPlayInterval, next]);
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 1000 : -1000,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 1000 : -1000,
opacity: 0,
}),
};
return (
<div className={cn("relative overflow-hidden", className)}>
<div className="relative aspect-video">
<AnimatePresence initial={false} custom={direction}>
<motion.div
key={current}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
className="absolute inset-0"
>
{children[current]}
</motion.div>
</AnimatePresence>
</div>
{/* Arrows */}
{showArrows && slideCount > 1 && (
<>
<button
onClick={prev}
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-background/80 backdrop-blur-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous slide"
>
<ChevronLeft className="w-6 h-6" />
</button>
<button
onClick={next}
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-background/80 backdrop-blur-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next slide"
>
<ChevronRight className="w-6 h-6" />
</button>
</>
)}
{/* Dots */}
{showDots && slideCount > 1 && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
{children.map((_, index) => (
<button
key={index}
onClick={() => goTo(index)}
className={cn(
"w-2 h-2 rounded-full transition-colors",
current === index
? "bg-primary w-6"
: "bg-primary/40 hover:bg-primary/60"
)}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
)}
</div>
);
}
// =============================================================================
// IMAGE CAROUSEL
// Carousel with images
// =============================================================================
interface ImageCarouselProps {
images: { src: string; alt: string; caption?: string }[];
autoPlay?: boolean;
autoPlayInterval?: number;
showArrows?: boolean;
showDots?: boolean;
showCaptions?: boolean;
className?: string;
}
export function ImageCarousel({
images,
autoPlay = true,
autoPlayInterval = 5000,
showArrows = true,
showDots = true,
showCaptions = true,
className,
}: ImageCarouselProps) {
return (
<Carousel
autoPlay={autoPlay}
autoPlayInterval={autoPlayInterval}
showArrows={showArrows}
showDots={showDots}
className={className}
>
{images.map((image, index) => (
<div key={index} className="relative w-full h-full">
<Image
src={image.src}
alt={image.alt}
fill
className="object-cover"
/>
{showCaptions && image.caption && (
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/60 to-transparent">
<p className="text-white text-center">{image.caption}</p>
</div>
)}
</div>
))}
</Carousel>
);
}
// =============================================================================
// TESTIMONIAL CAROUSEL
// Carousel for testimonials
// =============================================================================
interface Testimonial {
content: string;
author: string;
role?: string;
avatar?: string;
rating?: number;
}
interface TestimonialCarouselProps {
testimonials: Testimonial[];
autoPlay?: boolean;
className?: string;
}
export function TestimonialCarousel({
testimonials,
autoPlay = true,
className,
}: TestimonialCarouselProps) {
const [current, setCurrent] = useState(0);
useEffect(() => {
if (!autoPlay) return;
const timer = setInterval(() => {
setCurrent((prev) => (prev + 1) % testimonials.length);
}, 6000);
return () => clearInterval(timer);
}, [autoPlay, testimonials.length]);
return (
<div className={cn("relative", className)}>
<div className="overflow-hidden">
<AnimatePresence mode="wait">
<motion.div
key={current}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="text-center px-4"
>
{/* Rating */}
{testimonials[current].rating && (
<div className="flex justify-center gap-1 mb-4">
{Array.from({ length: 5 }).map((_, i) => (
<span
key={i}
className={cn(
"text-xl",
i < testimonials[current].rating!
? "text-yellow-500"
: "text-muted"
)}
>
</span>
))}
</div>
)}
{/* Quote */}
<blockquote className="text-xl md:text-2xl italic max-w-3xl mx-auto">
&ldquo;{testimonials[current].content}&rdquo;
</blockquote>
{/* Author */}
<div className="mt-6 flex items-center justify-center gap-4">
{testimonials[current].avatar && (
<div className="relative w-12 h-12 rounded-full overflow-hidden">
<Image
src={testimonials[current].avatar}
alt={testimonials[current].author}
fill
className="object-cover"
/>
</div>
)}
<div className="text-left">
<div className="font-semibold">
{testimonials[current].author}
</div>
{testimonials[current].role && (
<div className="text-sm text-muted-foreground">
{testimonials[current].role}
</div>
)}
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
{/* Dots */}
{testimonials.length > 1 && (
<div className="flex justify-center gap-2 mt-8">
{testimonials.map((_, index) => (
<button
key={index}
onClick={() => setCurrent(index)}
className={cn(
"w-2 h-2 rounded-full transition-all",
current === index
? "bg-primary w-6"
: "bg-muted-foreground/30 hover:bg-muted-foreground/50"
)}
aria-label={`Go to testimonial ${index + 1}`}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,369 @@
"use client";
import { useState, FormEvent } from "react";
import Image from "next/image";
import { motion } from "framer-motion";
import { ThumbsUp, MessageSquare, Flag, MoreVertical } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// COMMENT
// Individual comment component
// =============================================================================
interface CommentAuthor {
name: string;
avatar?: string;
}
interface CommentData {
id: string;
author: CommentAuthor;
content: string;
date: string;
likes?: number;
replies?: CommentData[];
}
interface CommentProps {
comment: CommentData;
onReply?: (commentId: string, content: string) => void;
onLike?: (commentId: string) => void;
depth?: number;
maxDepth?: number;
className?: string;
}
export function Comment({
comment,
onReply,
onLike,
depth = 0,
maxDepth = 3,
className,
}: CommentProps) {
const [showReplyForm, setShowReplyForm] = useState(false);
const [replyContent, setReplyContent] = useState("");
const handleReply = (e: FormEvent) => {
e.preventDefault();
if (replyContent.trim() && onReply) {
onReply(comment.id, replyContent);
setReplyContent("");
setShowReplyForm(false);
}
};
return (
<div className={cn("group", className)}>
<div className="flex gap-4">
{/* Avatar */}
<div className="shrink-0">
{comment.author.avatar ? (
<div className="relative w-10 h-10 rounded-full overflow-hidden">
<Image
src={comment.author.avatar}
alt={comment.author.name}
fill
className="object-cover"
/>
</div>
) : (
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground font-medium">
{comment.author.name.charAt(0).toUpperCase()}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{comment.author.name}</span>
<span className="text-sm text-muted-foreground">{comment.date}</span>
</div>
<p className="mt-2 text-muted-foreground">{comment.content}</p>
{/* Actions */}
<div className="flex items-center gap-4 mt-3">
<button
onClick={() => onLike?.(comment.id)}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ThumbsUp className="w-4 h-4" />
{comment.likes && comment.likes > 0 && (
<span>{comment.likes}</span>
)}
</button>
{depth < maxDepth && (
<button
onClick={() => setShowReplyForm(!showReplyForm)}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<MessageSquare className="w-4 h-4" />
Répondre
</button>
)}
<button className="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity">
<MoreVertical className="w-4 h-4" />
</button>
</div>
{/* Reply form */}
{showReplyForm && (
<motion.form
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
onSubmit={handleReply}
className="mt-4"
>
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder="Votre réponse..."
className="w-full px-4 py-3 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={3}
/>
<div className="flex justify-end gap-2 mt-2">
<button
type="button"
onClick={() => setShowReplyForm(false)}
className="px-4 py-2 text-sm hover:bg-muted rounded-lg transition-colors"
>
Annuler
</button>
<button
type="submit"
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
Répondre
</button>
</div>
</motion.form>
)}
{/* Replies */}
{comment.replies && comment.replies.length > 0 && (
<div className="mt-4 pl-4 border-l-2 border-muted space-y-4">
{comment.replies.map((reply) => (
<Comment
key={reply.id}
comment={reply}
onReply={onReply}
onLike={onLike}
depth={depth + 1}
maxDepth={maxDepth}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}
// =============================================================================
// COMMENT FORM
// Form to submit new comment
// =============================================================================
interface CommentFormProps {
onSubmit: (content: string, author?: { name: string; email: string }) => void;
requireAuth?: boolean;
isLoggedIn?: boolean;
userAvatar?: string;
placeholder?: string;
className?: string;
}
export function CommentForm({
onSubmit,
requireAuth = false,
isLoggedIn = false,
userAvatar,
placeholder = "Ajouter un commentaire...",
className,
}: CommentFormProps) {
const [content, setContent] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [isFocused, setIsFocused] = useState(false);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
if (requireAuth && !isLoggedIn) {
if (!name.trim() || !email.trim()) return;
onSubmit(content, { name, email });
} else {
onSubmit(content);
}
setContent("");
setName("");
setEmail("");
setIsFocused(false);
};
return (
<form onSubmit={handleSubmit} className={className}>
<div className="flex gap-4">
{/* Avatar */}
<div className="shrink-0">
{userAvatar ? (
<div className="relative w-10 h-10 rounded-full overflow-hidden">
<Image
src={userAvatar}
alt="Your avatar"
fill
className="object-cover"
/>
</div>
) : (
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
<MessageSquare className="w-5 h-5" />
</div>
)}
</div>
{/* Input */}
<div className="flex-1">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={() => setIsFocused(true)}
placeholder={placeholder}
className="w-full px-4 py-3 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={isFocused ? 3 : 1}
/>
{/* Guest fields */}
{isFocused && requireAuth && !isLoggedIn && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
className="grid md:grid-cols-2 gap-4 mt-4"
>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Votre nom"
className="px-4 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Votre email"
className="px-4 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
/>
</motion.div>
)}
{/* Submit */}
{isFocused && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex justify-end gap-2 mt-4"
>
<button
type="button"
onClick={() => {
setIsFocused(false);
setContent("");
}}
className="px-4 py-2 text-sm hover:bg-muted rounded-lg transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={!content.trim()}
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
Publier
</button>
</motion.div>
)}
</div>
</div>
</form>
);
}
// =============================================================================
// COMMENTS SECTION
// Full comments section
// =============================================================================
interface CommentsSectionProps {
comments: CommentData[];
onAddComment?: (content: string, author?: { name: string; email: string }) => void;
onReply?: (commentId: string, content: string) => void;
onLike?: (commentId: string) => void;
title?: string;
requireAuth?: boolean;
isLoggedIn?: boolean;
userAvatar?: string;
className?: string;
}
export function CommentsSection({
comments,
onAddComment,
onReply,
onLike,
title = "Commentaires",
requireAuth = false,
isLoggedIn = false,
userAvatar,
className,
}: CommentsSectionProps) {
return (
<section className={className}>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">
{title} ({comments.length})
</h2>
</div>
{/* Comment form */}
{onAddComment && (
<CommentForm
onSubmit={onAddComment}
requireAuth={requireAuth}
isLoggedIn={isLoggedIn}
userAvatar={userAvatar}
className="mb-8"
/>
)}
{/* Comments list */}
{comments.length > 0 ? (
<div className="space-y-6">
{comments.map((comment) => (
<Comment
key={comment.id}
comment={comment}
onReply={onReply}
onLike={onLike}
/>
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<MessageSquare className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Aucun commentaire pour l&apos;instant.</p>
<p className="text-sm mt-1">Soyez le premier à commenter !</p>
</div>
)}
</section>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { FadeIn, StaggerChildren } from "@/components/animations";
import { cn } from "@/lib/utils";
interface Feature {
icon: React.ReactNode;
title: string;
description: string;
}
interface FeaturesGridProps {
eyebrow?: string;
title: string;
subtitle?: string;
features: Feature[];
columns?: 2 | 3 | 4;
className?: string;
}
export function FeaturesGrid({
eyebrow,
title,
subtitle,
features,
columns = 3,
className,
}: FeaturesGridProps) {
const gridCols = {
2: "md:grid-cols-2",
3: "md:grid-cols-2 lg:grid-cols-3",
4: "md:grid-cols-2 lg:grid-cols-4",
};
return (
<section className={cn("py-20 px-4", className)} id="features">
<div className="max-w-6xl mx-auto">
<FadeIn className="text-center mb-12">
{eyebrow && (
<p className="text-sm font-semibold text-primary mb-2 uppercase tracking-wider">
{eyebrow}
</p>
)}
<h2 className="text-3xl md:text-4xl font-bold mb-4">{title}</h2>
{subtitle && (
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
{subtitle}
</p>
)}
</FadeIn>
<StaggerChildren
className={cn("grid gap-6", gridCols[columns])}
staggerDelay={0.1}
>
{features.map((feature, index) => (
<Card key={index} className="border bg-card">
<CardHeader>
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 text-primary">
{feature.icon}
</div>
<CardTitle className="text-xl">{feature.title}</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-base">
{feature.description}
</CardDescription>
</CardContent>
</Card>
))}
</StaggerChildren>
</div>
</section>
);
}

View file

@ -0,0 +1,116 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
interface FooterColumn {
title: string;
links: { label: string; url: string }[];
}
interface SocialLink {
platform: "facebook" | "twitter" | "instagram" | "linkedin" | "youtube";
url: string;
}
interface FooterProps {
logo?: React.ReactNode;
description?: string;
columns: FooterColumn[];
social?: SocialLink[];
legal?: { label: string; url: string }[];
copyright: string;
className?: string;
}
const socialIcons: Record<string, string> = {
facebook: "M18 2h-3a5 5 0 00-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 011-1h3z",
twitter: "M23 3a10.9 10.9 0 01-3.14 1.53 4.48 4.48 0 00-7.86 3v1A10.66 10.66 0 013 4s-4 9 5 13a11.64 11.64 0 01-7 2c9 5 20 0 20-11.5a4.5 4.5 0 00-.08-.83A7.72 7.72 0 0023 3z",
instagram: "M16 8a6 6 0 106 6 6 6 0 00-6-6zm0 10a4 4 0 114-4 4 4 0 01-4 4z M16 8V2a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1z",
linkedin: "M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6zM2 9h4v12H2z M4 6a2 2 0 100-4 2 2 0 000 4z",
youtube: "M22.54 6.42a2.78 2.78 0 00-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 00-1.94 2A29 29 0 001 11.75a29 29 0 00.46 5.33A2.78 2.78 0 003.4 19c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 001.94-2 29 29 0 00.46-5.25 29 29 0 00-.46-5.33z M9.75 15.02l5.75-3.27-5.75-3.27v6.54z",
};
export function Footer({
logo,
description,
columns,
social,
legal,
copyright,
className,
}: FooterProps) {
return (
<footer className={cn("bg-muted/50 py-12 px-4", className)}>
<div className="max-w-6xl mx-auto">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4 mb-8">
{/* Brand column */}
<div className="lg:col-span-1">
{logo && <div className="mb-4">{logo}</div>}
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
{social && (
<div className="flex gap-4 mt-4">
{social.map((item) => (
<Link
key={item.platform}
href={item.url}
className="text-muted-foreground hover:text-foreground transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d={socialIcons[item.platform]} />
</svg>
</Link>
))}
</div>
)}
</div>
{/* Link columns */}
{columns.map((column, index) => (
<div key={index}>
<h3 className="font-semibold mb-4">{column.title}</h3>
<ul className="space-y-2">
{column.links.map((link, linkIndex) => (
<li key={linkIndex}>
<Link
href={link.url}
className="text-muted-foreground hover:text-foreground transition-colors text-sm"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
<Separator className="my-8" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-sm text-muted-foreground">{copyright}</p>
{legal && (
<div className="flex gap-6">
{legal.map((item, index) => (
<Link
key={index}
href={item.url}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{item.label}
</Link>
))}
</div>
)}
</div>
</div>
</footer>
);
}

View file

@ -0,0 +1,344 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { motion, AnimatePresence } from "framer-motion";
import { X, ChevronLeft, ChevronRight, ZoomIn } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// GALLERY
// Image gallery with lightbox
// =============================================================================
interface GalleryImage {
src: string;
alt: string;
width?: number;
height?: number;
caption?: string;
category?: string;
}
interface GalleryProps {
images: GalleryImage[];
columns?: 2 | 3 | 4;
gap?: "sm" | "md" | "lg";
variant?: "grid" | "masonry";
lightbox?: boolean;
className?: string;
}
export function Gallery({
images,
columns = 3,
gap = "md",
variant = "grid",
lightbox = true,
className,
}: GalleryProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const colStyles = {
2: "columns-1 md:columns-2",
3: "columns-1 md:columns-2 lg:columns-3",
4: "columns-1 md:columns-2 lg:columns-4",
};
const gapStyles = {
sm: "gap-2",
md: "gap-4",
lg: "gap-6",
};
const gridColStyles = {
2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
};
const openLightbox = (index: number) => {
if (lightbox) {
setSelectedIndex(index);
document.body.style.overflow = "hidden";
}
};
const closeLightbox = () => {
setSelectedIndex(null);
document.body.style.overflow = "";
};
const goNext = () => {
if (selectedIndex !== null) {
setSelectedIndex((selectedIndex + 1) % images.length);
}
};
const goPrev = () => {
if (selectedIndex !== null) {
setSelectedIndex((selectedIndex - 1 + images.length) % images.length);
}
};
if (variant === "masonry") {
return (
<>
<div className={cn(colStyles[columns], gapStyles[gap], className)}>
{images.map((image, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.05 }}
className="mb-4 break-inside-avoid"
>
<div
className="relative group cursor-pointer overflow-hidden rounded-lg"
onClick={() => openLightbox(index)}
>
<Image
src={image.src}
alt={image.alt}
width={image.width || 800}
height={image.height || 600}
className="w-full h-auto transition-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
<ZoomIn className="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
{image.caption && (
<p className="mt-2 text-sm text-muted-foreground">
{image.caption}
</p>
)}
</motion.div>
))}
</div>
{lightbox && (
<Lightbox
images={images}
selectedIndex={selectedIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
/>
)}
</>
);
}
// Grid variant
return (
<>
<div
className={cn(
"grid",
gridColStyles[columns],
gapStyles[gap],
className
)}
>
{images.map((image, index) => (
<motion.div
key={index}
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: index * 0.05 }}
>
<div
className="relative aspect-square group cursor-pointer overflow-hidden rounded-lg"
onClick={() => openLightbox(index)}
>
<Image
src={image.src}
alt={image.alt}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
<ZoomIn className="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
</motion.div>
))}
</div>
{lightbox && (
<Lightbox
images={images}
selectedIndex={selectedIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
/>
)}
</>
);
}
// =============================================================================
// LIGHTBOX
// Fullscreen image viewer
// =============================================================================
interface LightboxProps {
images: GalleryImage[];
selectedIndex: number | null;
onClose: () => void;
onNext: () => void;
onPrev: () => void;
}
function Lightbox({
images,
selectedIndex,
onClose,
onNext,
onPrev,
}: LightboxProps) {
if (selectedIndex === null) return null;
const image = images[selectedIndex];
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
onClick={onClose}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors z-10"
>
<X className="w-6 h-6" />
</button>
{/* Navigation */}
{images.length > 1 && (
<>
<button
onClick={(e) => {
e.stopPropagation();
onPrev();
}}
className="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors"
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onNext();
}}
className="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors"
>
<ChevronRight className="w-8 h-8" />
</button>
</>
)}
{/* Image */}
<motion.div
key={selectedIndex}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="relative max-w-[90vw] max-h-[85vh]"
onClick={(e) => e.stopPropagation()}
>
<Image
src={image.src}
alt={image.alt}
width={image.width || 1200}
height={image.height || 800}
className="max-w-full max-h-[85vh] object-contain"
/>
{image.caption && (
<p className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/60 to-transparent text-white text-center">
{image.caption}
</p>
)}
</motion.div>
{/* Counter */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/70 text-sm">
{selectedIndex + 1} / {images.length}
</div>
</motion.div>
</AnimatePresence>
);
}
// =============================================================================
// FILTERED GALLERY
// Gallery with category filters
// =============================================================================
interface FilteredGalleryProps {
images: GalleryImage[];
columns?: 2 | 3 | 4;
className?: string;
}
export function FilteredGallery({
images,
columns = 3,
className,
}: FilteredGalleryProps) {
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const categories = Array.from(
new Set(images.map((img) => img.category).filter(Boolean))
) as string[];
const filteredImages = activeCategory
? images.filter((img) => img.category === activeCategory)
: images;
return (
<div className={className}>
{/* Filters */}
{categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
<button
onClick={() => setActiveCategory(null)}
className={cn(
"px-4 py-2 rounded-full text-sm font-medium transition-colors",
activeCategory === null
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
)}
>
Tous
</button>
{categories.map((category) => (
<button
key={category}
onClick={() => setActiveCategory(category)}
className={cn(
"px-4 py-2 rounded-full text-sm font-medium transition-colors",
activeCategory === category
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
)}
>
{category}
</button>
))}
</div>
)}
{/* Gallery */}
<Gallery images={filteredImages} columns={columns} />
</div>
);
}

View file

@ -0,0 +1,93 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { FadeIn } from "@/components/animations";
import { cn } from "@/lib/utils";
interface HeroSimpleProps {
title: string;
subtitle?: string;
cta: { label: string; url: string };
ctaSecondary?: { label: string; url: string };
image?: { src: string; alt: string };
alignment?: "left" | "center" | "right";
overlay?: boolean;
className?: string;
}
export function HeroSimple({
title,
subtitle,
cta,
ctaSecondary,
image,
alignment = "center",
overlay = false,
className,
}: HeroSimpleProps) {
const alignmentClasses = {
left: "text-left items-start",
center: "text-center items-center",
right: "text-right items-end",
};
return (
<section
className={cn(
"relative min-h-[80vh] flex items-center justify-center px-4 py-20",
className
)}
>
{image && (
<>
<Image
src={image.src}
alt={image.alt}
fill
className="object-cover -z-10"
priority
/>
{overlay && (
<div className="absolute inset-0 bg-black/50 -z-10" />
)}
</>
)}
<div
className={cn(
"max-w-4xl mx-auto flex flex-col gap-6",
alignmentClasses[alignment]
)}
>
<FadeIn>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight">
{title}
</h1>
</FadeIn>
{subtitle && (
<FadeIn delay={0.1}>
<p className="text-lg md:text-xl text-muted-foreground max-w-2xl">
{subtitle}
</p>
</FadeIn>
)}
<FadeIn delay={0.2}>
<div className="flex flex-wrap gap-4 mt-4">
<Button asChild size="lg">
<Link href={cta.url}>{cta.label}</Link>
</Button>
{ctaSecondary && (
<Button asChild variant="outline" size="lg">
<Link href={ctaSecondary.url}>{ctaSecondary.label}</Link>
</Button>
)}
</div>
</FadeIn>
</div>
</section>
);
}

View file

@ -0,0 +1,299 @@
"use client";
import Image from "next/image";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
// =============================================================================
// LOGO CLOUD
// Display client/partner logos
// =============================================================================
interface Logo {
src: string;
alt: string;
href?: string;
width?: number;
height?: number;
}
interface LogoCloudProps {
logos: Logo[];
title?: string;
variant?: "default" | "grid" | "scroll";
grayscale?: boolean;
columns?: 3 | 4 | 5 | 6;
className?: string;
}
export function LogoCloud({
logos,
title,
variant = "default",
grayscale = true,
columns = 5,
className,
}: LogoCloudProps) {
const colStyles = {
3: "grid-cols-3",
4: "grid-cols-2 md:grid-cols-4",
5: "grid-cols-2 md:grid-cols-3 lg:grid-cols-5",
6: "grid-cols-2 md:grid-cols-3 lg:grid-cols-6",
};
const renderLogo = (logo: Logo, index: number) => {
const imgElement = (
<Image
src={logo.src}
alt={logo.alt}
width={logo.width || 160}
height={logo.height || 48}
className={cn(
"max-h-12 w-auto object-contain transition-all",
grayscale && "grayscale hover:grayscale-0 opacity-60 hover:opacity-100"
)}
/>
);
const wrapper = (
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.05 }}
className="flex items-center justify-center p-4"
>
{imgElement}
</motion.div>
);
if (logo.href) {
return (
<a
key={index}
href={logo.href}
target="_blank"
rel="noopener noreferrer"
className="block"
>
{wrapper}
</a>
);
}
return <div key={index}>{wrapper}</div>;
};
if (variant === "scroll") {
return (
<div className={cn("overflow-hidden py-8", className)}>
{title && (
<p className="text-center text-sm text-muted-foreground mb-8">
{title}
</p>
)}
<div className="relative">
<div className="flex animate-scroll">
{[...logos, ...logos].map((logo, index) => (
<div
key={index}
className="flex-shrink-0 mx-8"
>
<Image
src={logo.src}
alt={logo.alt}
width={logo.width || 160}
height={logo.height || 48}
className={cn(
"max-h-10 w-auto object-contain",
grayscale && "grayscale opacity-60"
)}
/>
</div>
))}
</div>
</div>
<style jsx>{`
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.animate-scroll {
animation: scroll 30s linear infinite;
}
`}</style>
</div>
);
}
return (
<div className={className}>
{title && (
<p className="text-center text-sm text-muted-foreground mb-8">
{title}
</p>
)}
<div className={cn("grid gap-8 items-center", colStyles[columns])}>
{logos.map((logo, index) => renderLogo(logo, index))}
</div>
</div>
);
}
// =============================================================================
// LOGO SECTION
// Full section with logos
// =============================================================================
interface LogoSectionProps {
title?: string;
subtitle?: string;
logos: Logo[];
variant?: "default" | "grid" | "scroll";
grayscale?: boolean;
background?: "default" | "muted" | "primary";
className?: string;
}
export function LogoSection({
title = "Ils nous font confiance",
subtitle,
logos,
variant = "default",
grayscale = true,
background = "default",
className,
}: LogoSectionProps) {
const bgStyles = {
default: "",
muted: "bg-muted",
primary: "bg-primary text-primary-foreground",
};
return (
<section className={cn("py-12 md:py-16", bgStyles[background], className)}>
<div className="container mx-auto px-4">
{(title || subtitle) && (
<div className="text-center mb-8">
{title && <h2 className="text-xl font-semibold">{title}</h2>}
{subtitle && (
<p
className={cn(
"mt-2",
background === "primary"
? "text-primary-foreground/80"
: "text-muted-foreground"
)}
>
{subtitle}
</p>
)}
</div>
)}
<LogoCloud logos={logos} variant={variant} grayscale={grayscale} />
</div>
</section>
);
}
// =============================================================================
// PARTNERS GRID
// Detailed partner cards
// =============================================================================
interface Partner {
logo: string;
name: string;
description?: string;
href?: string;
}
interface PartnersGridProps {
title?: string;
partners: Partner[];
columns?: 2 | 3 | 4;
className?: string;
}
export function PartnersGrid({
title = "Nos partenaires",
partners,
columns = 3,
className,
}: PartnersGridProps) {
const colStyles = {
2: "md:grid-cols-2",
3: "md:grid-cols-2 lg:grid-cols-3",
4: "md:grid-cols-2 lg:grid-cols-4",
};
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
{title && (
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12">
{title}
</h2>
)}
<div className={cn("grid gap-6", colStyles[columns])}>
{partners.map((partner, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
>
{partner.href ? (
<a
href={partner.href}
target="_blank"
rel="noopener noreferrer"
className="block bg-card border rounded-xl p-6 hover:shadow-lg transition-shadow"
>
<div className="h-16 flex items-center justify-center mb-4">
<Image
src={partner.logo}
alt={partner.name}
width={160}
height={48}
className="max-h-12 w-auto object-contain"
/>
</div>
<h3 className="font-semibold text-center">{partner.name}</h3>
{partner.description && (
<p className="mt-2 text-sm text-muted-foreground text-center">
{partner.description}
</p>
)}
</a>
) : (
<div className="bg-card border rounded-xl p-6">
<div className="h-16 flex items-center justify-center mb-4">
<Image
src={partner.logo}
alt={partner.name}
width={160}
height={48}
className="max-h-12 w-auto object-contain"
/>
</div>
<h3 className="font-semibold text-center">{partner.name}</h3>
{partner.description && (
<p className="mt-2 text-sm text-muted-foreground text-center">
{partner.description}
</p>
)}
</div>
)}
</motion.div>
))}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,328 @@
"use client";
import { cn } from "@/lib/utils";
import { MapPin, Phone, Mail, Clock } from "lucide-react";
// =============================================================================
// MAP EMBED
// Google Maps or OpenStreetMap embed
// =============================================================================
interface MapEmbedProps {
address?: string;
lat?: number;
lng?: number;
zoom?: number;
provider?: "google" | "openstreetmap";
height?: string;
className?: string;
}
export function MapEmbed({
address,
lat,
lng,
zoom = 15,
provider = "google",
height = "400px",
className,
}: MapEmbedProps) {
const getMapUrl = () => {
if (provider === "google") {
if (lat && lng) {
return `https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2000!2d${lng}!3d${lat}!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f${zoom}.1!3m3!1m2!1s0x0%3A0x0!2zM!5e0!3m2!1sfr!2sbe!4v1`;
}
if (address) {
return `https://www.google.com/maps/embed/v1/place?key=YOUR_API_KEY&q=${encodeURIComponent(address)}&zoom=${zoom}`;
}
}
if (provider === "openstreetmap") {
if (lat && lng) {
return `https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.01}%2C${lat - 0.01}%2C${lng + 0.01}%2C${lat + 0.01}&layer=mapnik&marker=${lat}%2C${lng}`;
}
}
return "";
};
return (
<div className={cn("w-full rounded-xl overflow-hidden", className)}>
<iframe
src={getMapUrl()}
width="100%"
height={height}
style={{ border: 0 }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="Map"
/>
</div>
);
}
// =============================================================================
// STATIC MAP
// Static map image (no iframe)
// =============================================================================
interface StaticMapProps {
lat: number;
lng: number;
zoom?: number;
width?: number;
height?: number;
marker?: boolean;
className?: string;
}
export function StaticMap({
lat,
lng,
zoom = 15,
width = 600,
height = 400,
marker = true,
className,
}: StaticMapProps) {
// Using OpenStreetMap static tiles
const tileUrl = `https://staticmap.openstreetmap.de/staticmap.php?center=${lat},${lng}&zoom=${zoom}&size=${width}x${height}${marker ? `&markers=${lat},${lng},red` : ""}`;
return (
<div className={cn("rounded-xl overflow-hidden", className)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={tileUrl}
alt="Map"
width={width}
height={height}
className="w-full h-auto"
/>
</div>
);
}
// =============================================================================
// CONTACT MAP SECTION
// Map with contact information
// =============================================================================
interface ContactInfo {
address?: string;
phone?: string;
email?: string;
hours?: string[];
}
interface ContactMapSectionProps {
title?: string;
description?: string;
mapAddress?: string;
mapLat?: number;
mapLng?: number;
contact: ContactInfo;
reversed?: boolean;
className?: string;
}
export function ContactMapSection({
title = "Nous trouver",
description,
mapAddress,
mapLat,
mapLng,
contact,
reversed = false,
className,
}: ContactMapSectionProps) {
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
{(title || description) && (
<div className="text-center mb-12">
{title && (
<h2 className="text-3xl md:text-4xl font-bold">{title}</h2>
)}
{description && (
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
{description}
</p>
)}
</div>
)}
<div
className={cn(
"grid md:grid-cols-2 gap-8 items-stretch",
reversed && "md:flex-row-reverse"
)}
>
{/* Contact Info */}
<div
className={cn(
"bg-card border rounded-xl p-8 flex flex-col justify-center",
reversed ? "md:order-2" : ""
)}
>
<div className="space-y-6">
{contact.address && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<MapPin className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold">Adresse</h3>
<p className="text-muted-foreground whitespace-pre-line">
{contact.address}
</p>
</div>
</div>
)}
{contact.phone && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Phone className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold">Téléphone</h3>
<a
href={`tel:${contact.phone}`}
className="text-muted-foreground hover:text-primary"
>
{contact.phone}
</a>
</div>
</div>
)}
{contact.email && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Mail className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold">Email</h3>
<a
href={`mailto:${contact.email}`}
className="text-muted-foreground hover:text-primary"
>
{contact.email}
</a>
</div>
</div>
)}
{contact.hours && contact.hours.length > 0 && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Clock className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold">Horaires</h3>
<div className="text-muted-foreground">
{contact.hours.map((hour, i) => (
<p key={i}>{hour}</p>
))}
</div>
</div>
</div>
)}
</div>
</div>
{/* Map */}
<div className={reversed ? "md:order-1" : ""}>
<MapEmbed
address={mapAddress}
lat={mapLat}
lng={mapLng}
height="100%"
className="h-full min-h-[400px]"
/>
</div>
</div>
</div>
</section>
);
}
// =============================================================================
// MULTIPLE LOCATIONS
// Map with multiple location cards
// =============================================================================
interface Location {
id: string;
name: string;
address: string;
phone?: string;
email?: string;
lat?: number;
lng?: number;
}
interface MultipleLocationsProps {
title?: string;
locations: Location[];
className?: string;
}
export function MultipleLocations({
title = "Nos agences",
locations,
className,
}: MultipleLocationsProps) {
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
{title && (
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12">
{title}
</h2>
)}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{locations.map((location) => (
<div
key={location.id}
className="bg-card border rounded-xl overflow-hidden"
>
{location.lat && location.lng && (
<StaticMap
lat={location.lat}
lng={location.lng}
height={200}
zoom={14}
/>
)}
<div className="p-6">
<h3 className="text-lg font-semibold">{location.name}</h3>
<p className="mt-2 text-muted-foreground text-sm">
{location.address}
</p>
{location.phone && (
<a
href={`tel:${location.phone}`}
className="block mt-2 text-sm text-primary hover:underline"
>
{location.phone}
</a>
)}
{location.email && (
<a
href={`mailto:${location.email}`}
className="block mt-1 text-sm text-primary hover:underline"
>
{location.email}
</a>
)}
</div>
</div>
))}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,331 @@
"use client";
import { useState, FormEvent } from "react";
import { motion } from "framer-motion";
import { Mail, ArrowRight, Check, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// NEWSLETTER FORM
// Email subscription form
// =============================================================================
interface NewsletterFormProps {
onSubmit?: (email: string) => Promise<void>;
placeholder?: string;
buttonText?: string;
successMessage?: string;
variant?: "default" | "inline" | "stacked";
className?: string;
}
export function NewsletterForm({
onSubmit,
placeholder = "Votre email",
buttonText = "S'inscrire",
successMessage = "Merci ! Vous êtes inscrit.",
variant = "default",
className,
}: NewsletterFormProps) {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">(
"idle"
);
const [errorMessage, setErrorMessage] = useState("");
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!email || !email.includes("@")) {
setStatus("error");
setErrorMessage("Veuillez entrer un email valide");
return;
}
setStatus("loading");
try {
if (onSubmit) {
await onSubmit(email);
} else {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
}
setStatus("success");
setEmail("");
} catch {
setStatus("error");
setErrorMessage("Une erreur est survenue. Réessayez.");
}
};
if (status === "success") {
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className={cn(
"flex items-center gap-2 text-green-600",
className
)}
>
<Check className="w-5 h-5" />
<span>{successMessage}</span>
</motion.div>
);
}
if (variant === "stacked") {
return (
<form onSubmit={handleSubmit} className={cn("space-y-3", className)}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={placeholder}
className="w-full px-4 py-3 rounded-lg border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
/>
<button
type="submit"
disabled={status === "loading"}
className="w-full px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
>
{status === "loading" ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
{buttonText}
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
{status === "error" && (
<p className="text-sm text-red-600">{errorMessage}</p>
)}
</form>
);
}
// Default and inline variants
return (
<form
onSubmit={handleSubmit}
className={cn(
"flex gap-2",
variant === "inline" ? "flex-row" : "flex-col sm:flex-row",
className
)}
>
<div className="relative flex-1">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={placeholder}
className="w-full pl-10 pr-4 py-3 rounded-lg border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<button
type="submit"
disabled={status === "loading"}
className="px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
>
{status === "loading" ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
{buttonText}
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
{status === "error" && (
<p className="text-sm text-red-600 sm:hidden">{errorMessage}</p>
)}
</form>
);
}
// =============================================================================
// NEWSLETTER SECTION
// Full section with newsletter form
// =============================================================================
interface NewsletterSectionProps {
title?: string;
description?: string;
onSubmit?: (email: string) => Promise<void>;
variant?: "default" | "centered" | "split" | "card";
background?: "default" | "muted" | "primary" | "gradient";
className?: string;
}
export function NewsletterSection({
title = "Restez informé",
description = "Inscrivez-vous à notre newsletter pour recevoir nos dernières actualités.",
onSubmit,
variant = "centered",
background = "muted",
className,
}: NewsletterSectionProps) {
const bgStyles = {
default: "",
muted: "bg-muted",
primary: "bg-primary text-primary-foreground",
gradient: "bg-gradient-to-r from-primary to-primary/80 text-primary-foreground",
};
if (variant === "card") {
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
<div
className={cn(
"rounded-2xl p-8 md:p-12",
bgStyles[background] || bgStyles.muted
)}
>
<div className="max-w-2xl mx-auto text-center">
<h2 className="text-2xl md:text-3xl font-bold">{title}</h2>
{description && (
<p
className={cn(
"mt-4",
background === "primary" || background === "gradient"
? "text-primary-foreground/80"
: "text-muted-foreground"
)}
>
{description}
</p>
)}
<div className="mt-8 max-w-md mx-auto">
<NewsletterForm onSubmit={onSubmit} />
</div>
</div>
</div>
</div>
</section>
);
}
if (variant === "split") {
return (
<section
className={cn("py-16 md:py-24", bgStyles[background], className)}
>
<div className="container mx-auto px-4">
<div className="grid md:grid-cols-2 gap-8 items-center">
<div>
<h2 className="text-2xl md:text-3xl font-bold">{title}</h2>
{description && (
<p
className={cn(
"mt-4",
background === "primary" || background === "gradient"
? "text-primary-foreground/80"
: "text-muted-foreground"
)}
>
{description}
</p>
)}
</div>
<div>
<NewsletterForm onSubmit={onSubmit} />
</div>
</div>
</div>
</section>
);
}
// Centered variant (default)
return (
<section className={cn("py-16 md:py-24", bgStyles[background], className)}>
<div className="container mx-auto px-4">
<div className="max-w-2xl mx-auto text-center">
<h2 className="text-2xl md:text-3xl font-bold">{title}</h2>
{description && (
<p
className={cn(
"mt-4",
background === "primary" || background === "gradient"
? "text-primary-foreground/80"
: "text-muted-foreground"
)}
>
{description}
</p>
)}
<div className="mt-8 max-w-md mx-auto">
<NewsletterForm onSubmit={onSubmit} />
</div>
</div>
</div>
</section>
);
}
// =============================================================================
// NEWSLETTER POPUP
// Modal/popup newsletter form
// =============================================================================
interface NewsletterPopupProps {
isOpen: boolean;
onClose: () => void;
title?: string;
description?: string;
onSubmit?: (email: string) => Promise<void>;
}
export function NewsletterPopup({
isOpen,
onClose,
title = "Ne manquez rien !",
description = "Inscrivez-vous pour recevoir nos meilleures offres et actualités.",
onSubmit,
}: NewsletterPopupProps) {
if (!isOpen) return null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="bg-background rounded-2xl p-8 max-w-md w-full shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center text-muted-foreground hover:text-foreground"
>
×
</button>
<div className="text-center mb-6">
<div className="w-16 h-16 rounded-full bg-primary/10 text-primary flex items-center justify-center mx-auto mb-4">
<Mail className="w-8 h-8" />
</div>
<h2 className="text-2xl font-bold">{title}</h2>
{description && (
<p className="mt-2 text-muted-foreground">{description}</p>
)}
</div>
<NewsletterForm onSubmit={onSubmit} variant="stacked" />
<p className="mt-4 text-xs text-center text-muted-foreground">
En vous inscrivant, vous acceptez notre politique de confidentialité.
</p>
</motion.div>
</motion.div>
);
}

View file

@ -0,0 +1,270 @@
"use client";
import Link from "next/link";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// PAGINATION
// Page navigation component
// =============================================================================
interface PaginationProps {
currentPage: number;
totalPages: number;
baseUrl: string;
siblingCount?: number;
showFirstLast?: boolean;
className?: string;
}
export function Pagination({
currentPage,
totalPages,
baseUrl,
siblingCount = 1,
showFirstLast = true,
className,
}: PaginationProps) {
const getPageUrl = (page: number) => {
if (page === 1) return baseUrl;
return `${baseUrl}?page=${page}`;
};
// Generate page numbers to show
const generatePages = () => {
const pages: (number | "ellipsis")[] = [];
// Always show first page
if (showFirstLast) pages.push(1);
// Calculate range around current page
const start = Math.max(2, currentPage - siblingCount);
const end = Math.min(totalPages - 1, currentPage + siblingCount);
// Add ellipsis if needed before range
if (start > 2) {
pages.push("ellipsis");
} else if (start === 2 && showFirstLast) {
// No ellipsis needed
}
// Add pages in range
for (let i = start; i <= end; i++) {
if (i !== 1 && i !== totalPages) {
pages.push(i);
}
}
// Add ellipsis if needed after range
if (end < totalPages - 1) {
pages.push("ellipsis");
}
// Always show last page
if (showFirstLast && totalPages > 1) pages.push(totalPages);
return pages;
};
const pages = generatePages();
if (totalPages <= 1) return null;
return (
<nav
aria-label="Pagination"
className={cn("flex items-center justify-center gap-1", className)}
>
{/* Previous */}
{currentPage > 1 ? (
<Link
href={getPageUrl(currentPage - 1)}
className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-muted transition-colors"
aria-label="Page précédente"
>
<ChevronLeft className="w-5 h-5" />
</Link>
) : (
<span className="w-10 h-10 flex items-center justify-center text-muted-foreground/50">
<ChevronLeft className="w-5 h-5" />
</span>
)}
{/* Page numbers */}
{pages.map((page, index) =>
page === "ellipsis" ? (
<span
key={`ellipsis-${index}`}
className="w-10 h-10 flex items-center justify-center text-muted-foreground"
>
<MoreHorizontal className="w-5 h-5" />
</span>
) : (
<Link
key={page}
href={getPageUrl(page)}
className={cn(
"w-10 h-10 flex items-center justify-center rounded-lg font-medium transition-colors",
currentPage === page
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
aria-current={currentPage === page ? "page" : undefined}
>
{page}
</Link>
)
)}
{/* Next */}
{currentPage < totalPages ? (
<Link
href={getPageUrl(currentPage + 1)}
className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-muted transition-colors"
aria-label="Page suivante"
>
<ChevronRight className="w-5 h-5" />
</Link>
) : (
<span className="w-10 h-10 flex items-center justify-center text-muted-foreground/50">
<ChevronRight className="w-5 h-5" />
</span>
)}
</nav>
);
}
// =============================================================================
// SIMPLE PAGINATION
// Previous/Next only
// =============================================================================
interface SimplePaginationProps {
currentPage: number;
totalPages: number;
baseUrl: string;
showPageInfo?: boolean;
className?: string;
}
export function SimplePagination({
currentPage,
totalPages,
baseUrl,
showPageInfo = true,
className,
}: SimplePaginationProps) {
const getPageUrl = (page: number) => {
if (page === 1) return baseUrl;
return `${baseUrl}?page=${page}`;
};
if (totalPages <= 1) return null;
return (
<div
className={cn(
"flex items-center justify-between gap-4",
className
)}
>
{currentPage > 1 ? (
<Link
href={getPageUrl(currentPage - 1)}
className="px-4 py-2 border rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
>
<ChevronLeft className="w-4 h-4" />
Précédent
</Link>
) : (
<div />
)}
{showPageInfo && (
<span className="text-sm text-muted-foreground">
Page {currentPage} sur {totalPages}
</span>
)}
{currentPage < totalPages ? (
<Link
href={getPageUrl(currentPage + 1)}
className="px-4 py-2 border rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
>
Suivant
<ChevronRight className="w-4 h-4" />
</Link>
) : (
<div />
)}
</div>
);
}
// =============================================================================
// LOAD MORE
// Load more button for infinite scroll
// =============================================================================
interface LoadMoreProps {
onLoadMore: () => void;
isLoading?: boolean;
hasMore?: boolean;
loadingText?: string;
buttonText?: string;
className?: string;
}
export function LoadMore({
onLoadMore,
isLoading = false,
hasMore = true,
loadingText = "Chargement...",
buttonText = "Charger plus",
className,
}: LoadMoreProps) {
if (!hasMore) return null;
return (
<div className={cn("flex justify-center", className)}>
<button
onClick={onLoadMore}
disabled={isLoading}
className="px-6 py-3 border rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
>
{isLoading ? loadingText : buttonText}
</button>
</div>
);
}
// =============================================================================
// PAGINATION INFO
// Showing X-Y of Z items
// =============================================================================
interface PaginationInfoProps {
currentPage: number;
pageSize: number;
totalItems: number;
className?: string;
}
export function PaginationInfo({
currentPage,
pageSize,
totalItems,
className,
}: PaginationInfoProps) {
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, totalItems);
return (
<p className={cn("text-sm text-muted-foreground", className)}>
Affichage de <span className="font-medium">{start}</span> à{" "}
<span className="font-medium">{end}</span> sur{" "}
<span className="font-medium">{totalItems}</span> résultats
</p>
);
}

View file

@ -0,0 +1,358 @@
"use client";
import { useState, ReactNode } from "react";
import { motion } from "framer-motion";
import { Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// PRICING CARD
// Individual pricing plan
// =============================================================================
interface PricingFeature {
text: string;
included: boolean;
tooltip?: string;
}
interface PricingPlan {
id: string;
name: string;
description?: string;
price: number | string;
priceYearly?: number | string;
currency?: string;
period?: string;
features: PricingFeature[] | string[];
cta: { text: string; href: string };
popular?: boolean;
badge?: string;
}
interface PricingCardProps {
plan: PricingPlan;
yearly?: boolean;
variant?: "default" | "bordered" | "elevated";
className?: string;
}
export function PricingCard({
plan,
yearly = false,
variant = "default",
className,
}: PricingCardProps) {
const price = yearly && plan.priceYearly ? plan.priceYearly : plan.price;
const displayPrice = typeof price === "number" ? price : price;
const variantStyles = {
default: plan.popular
? "bg-primary text-primary-foreground"
: "bg-card border",
bordered: plan.popular
? "border-2 border-primary bg-card"
: "border-2 bg-card",
elevated: plan.popular
? "bg-primary text-primary-foreground shadow-xl scale-105"
: "bg-card shadow-lg",
};
const buttonStyles = plan.popular
? variant === "default"
? "bg-background text-foreground hover:bg-background/90"
: "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-primary text-primary-foreground hover:bg-primary/90";
const featureIconColor = plan.popular && variant === "default"
? "text-primary-foreground"
: "text-primary";
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className={cn(
"rounded-2xl p-8 relative",
variantStyles[variant],
className
)}
>
{/* Badge */}
{(plan.popular || plan.badge) && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span
className={cn(
"px-4 py-1 rounded-full text-sm font-medium",
plan.popular && variant === "default"
? "bg-background text-foreground"
: "bg-primary text-primary-foreground"
)}
>
{plan.badge || "Populaire"}
</span>
</div>
)}
{/* Header */}
<div className="text-center">
<h3 className="text-xl font-bold">{plan.name}</h3>
{plan.description && (
<p
className={cn(
"mt-2 text-sm",
plan.popular && variant === "default"
? "text-primary-foreground/80"
: "text-muted-foreground"
)}
>
{plan.description}
</p>
)}
</div>
{/* Price */}
<div className="mt-6 text-center">
<div className="flex items-baseline justify-center gap-1">
{plan.currency && (
<span className="text-2xl font-semibold">{plan.currency}</span>
)}
<span className="text-5xl font-bold">{displayPrice}</span>
{plan.period && (
<span
className={cn(
"text-sm",
plan.popular && variant === "default"
? "text-primary-foreground/80"
: "text-muted-foreground"
)}
>
/{plan.period}
</span>
)}
</div>
</div>
{/* Features */}
<ul className="mt-8 space-y-3">
{plan.features.map((feature, index) => {
const isObject = typeof feature === "object";
const text = isObject ? feature.text : feature;
const included = isObject ? feature.included : true;
return (
<li key={index} className="flex items-start gap-3">
{included ? (
<Check className={cn("w-5 h-5 shrink-0", featureIconColor)} />
) : (
<X
className={cn(
"w-5 h-5 shrink-0",
plan.popular && variant === "default"
? "text-primary-foreground/40"
: "text-muted-foreground/40"
)}
/>
)}
<span
className={cn(
!included &&
(plan.popular && variant === "default"
? "text-primary-foreground/60 line-through"
: "text-muted-foreground line-through")
)}
>
{text}
</span>
</li>
);
})}
</ul>
{/* CTA */}
<a
href={plan.cta.href}
className={cn(
"mt-8 block w-full py-3 px-6 rounded-lg text-center font-medium transition-colors",
buttonStyles
)}
>
{plan.cta.text}
</a>
</motion.div>
);
}
// =============================================================================
// PRICING SECTION
// Full pricing section with toggle
// =============================================================================
interface PricingSectionProps {
title?: string;
description?: string;
plans: PricingPlan[];
showToggle?: boolean;
toggleLabels?: { monthly: string; yearly: string };
yearlySavings?: string;
variant?: "default" | "bordered" | "elevated";
className?: string;
}
export function PricingSection({
title = "Tarifs",
description,
plans,
showToggle = true,
toggleLabels = { monthly: "Mensuel", yearly: "Annuel" },
yearlySavings = "2 mois offerts",
variant = "default",
className,
}: PricingSectionProps) {
const [yearly, setYearly] = useState(false);
const hasYearlyPrices = plans.some((plan) => plan.priceYearly !== undefined);
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
{/* Header */}
<div className="text-center mb-12">
{title && (
<h2 className="text-3xl md:text-4xl font-bold">{title}</h2>
)}
{description && (
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
{description}
</p>
)}
{/* Toggle */}
{showToggle && hasYearlyPrices && (
<div className="mt-8 flex items-center justify-center gap-4">
<span
className={cn(
"text-sm font-medium",
!yearly ? "text-foreground" : "text-muted-foreground"
)}
>
{toggleLabels.monthly}
</span>
<button
onClick={() => setYearly(!yearly)}
className={cn(
"relative w-14 h-7 rounded-full transition-colors",
yearly ? "bg-primary" : "bg-muted"
)}
>
<motion.div
animate={{ x: yearly ? 28 : 4 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="absolute top-1 w-5 h-5 rounded-full bg-white shadow"
/>
</button>
<span
className={cn(
"text-sm font-medium flex items-center gap-2",
yearly ? "text-foreground" : "text-muted-foreground"
)}
>
{toggleLabels.yearly}
{yearlySavings && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">
{yearlySavings}
</span>
)}
</span>
</div>
)}
</div>
{/* Plans */}
<div
className={cn(
"grid gap-8 items-start",
plans.length === 2 && "md:grid-cols-2 max-w-4xl mx-auto",
plans.length === 3 && "md:grid-cols-3 max-w-6xl mx-auto",
plans.length >= 4 && "md:grid-cols-2 lg:grid-cols-4"
)}
>
{plans.map((plan) => (
<PricingCard
key={plan.id}
plan={plan}
yearly={yearly}
variant={variant}
/>
))}
</div>
</div>
</section>
);
}
// =============================================================================
// COMPARISON TABLE
// Feature comparison across plans
// =============================================================================
interface ComparisonFeature {
name: string;
tooltip?: string;
values: (boolean | string)[];
}
interface ComparisonTableProps {
plans: { name: string; price: string }[];
features: ComparisonFeature[];
className?: string;
}
export function ComparisonTable({
plans,
features,
className,
}: ComparisonTableProps) {
return (
<div className={cn("overflow-x-auto", className)}>
<table className="w-full">
<thead>
<tr className="border-b">
<th className="py-4 px-4 text-left font-semibold">
Fonctionnalités
</th>
{plans.map((plan, i) => (
<th key={i} className="py-4 px-4 text-center">
<div className="font-semibold">{plan.name}</div>
<div className="text-sm text-muted-foreground mt-1">
{plan.price}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{features.map((feature, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-4 px-4 text-sm">{feature.name}</td>
{feature.values.map((value, j) => (
<td key={j} className="py-4 px-4 text-center">
{typeof value === "boolean" ? (
value ? (
<Check className="w-5 h-5 text-green-600 mx-auto" />
) : (
<X className="w-5 h-5 text-muted-foreground/40 mx-auto" />
)
) : (
<span className="text-sm">{value}</span>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,320 @@
"use client";
import { useEffect, useState } from "react";
import { motion, useInView } from "framer-motion";
import { useRef } from "react";
import { cn } from "@/lib/utils";
// =============================================================================
// PROGRESS BAR
// Animated progress bar
// =============================================================================
interface ProgressBarProps {
value: number;
max?: number;
label?: string;
showValue?: boolean;
valueFormat?: "percent" | "fraction" | "custom";
customFormat?: (value: number, max: number) => string;
size?: "sm" | "md" | "lg";
variant?: "default" | "gradient" | "striped";
color?: string;
animated?: boolean;
className?: string;
}
export function ProgressBar({
value,
max = 100,
label,
showValue = true,
valueFormat = "percent",
customFormat,
size = "md",
variant = "default",
color,
animated = true,
className,
}: ProgressBarProps) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true });
const [displayValue, setDisplayValue] = useState(0);
const percentage = Math.min((value / max) * 100, 100);
useEffect(() => {
if (isInView && animated) {
const timer = setTimeout(() => setDisplayValue(percentage), 100);
return () => clearTimeout(timer);
} else if (!animated) {
setDisplayValue(percentage);
}
}, [isInView, percentage, animated]);
const sizeStyles = {
sm: "h-1.5",
md: "h-2.5",
lg: "h-4",
};
const formatValue = () => {
if (customFormat) return customFormat(value, max);
switch (valueFormat) {
case "percent":
return `${Math.round(percentage)}%`;
case "fraction":
return `${value}/${max}`;
default:
return `${value}`;
}
};
const barStyles = {
default: color || "bg-primary",
gradient: "bg-gradient-to-r from-primary to-primary/60",
striped:
"bg-primary bg-stripes animate-stripes",
};
return (
<div ref={ref} className={className}>
{(label || showValue) && (
<div className="flex justify-between items-center mb-2">
{label && <span className="text-sm font-medium">{label}</span>}
{showValue && (
<span className="text-sm text-muted-foreground">{formatValue()}</span>
)}
</div>
)}
<div
className={cn(
"w-full rounded-full bg-muted overflow-hidden",
sizeStyles[size]
)}
>
<motion.div
initial={{ width: 0 }}
animate={{ width: `${displayValue}%` }}
transition={{ duration: animated ? 1 : 0, ease: "easeOut" }}
className={cn(
"h-full rounded-full transition-all",
barStyles[variant],
color
)}
/>
</div>
<style jsx>{`
.bg-stripes {
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.15) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.15) 50%,
rgba(255, 255, 255, 0.15) 75%,
transparent 75%,
transparent
);
background-size: 1rem 1rem;
}
.animate-stripes {
animation: stripes 1s linear infinite;
}
@keyframes stripes {
0% {
background-position: 1rem 0;
}
100% {
background-position: 0 0;
}
}
`}</style>
</div>
);
}
// =============================================================================
// PROGRESS GROUP
// Multiple progress bars
// =============================================================================
interface ProgressItem {
label: string;
value: number;
max?: number;
color?: string;
}
interface ProgressGroupProps {
items: ProgressItem[];
size?: "sm" | "md" | "lg";
animated?: boolean;
className?: string;
}
export function ProgressGroup({
items,
size = "md",
animated = true,
className,
}: ProgressGroupProps) {
return (
<div className={cn("space-y-4", className)}>
{items.map((item, index) => (
<ProgressBar
key={index}
value={item.value}
max={item.max}
label={item.label}
color={item.color}
size={size}
animated={animated}
/>
))}
</div>
);
}
// =============================================================================
// CIRCULAR PROGRESS
// Circular/ring progress indicator
// =============================================================================
interface CircularProgressProps {
value: number;
max?: number;
size?: number;
strokeWidth?: number;
showValue?: boolean;
label?: string;
color?: string;
className?: string;
}
export function CircularProgress({
value,
max = 100,
size = 120,
strokeWidth = 8,
showValue = true,
label,
color = "stroke-primary",
className,
}: CircularProgressProps) {
const ref = useRef<SVGSVGElement>(null);
const isInView = useInView(ref, { once: true });
const percentage = Math.min((value / max) * 100, 100);
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (percentage / 100) * circumference;
return (
<div className={cn("inline-flex flex-col items-center", className)}>
<svg
ref={ref}
width={size}
height={size}
className="transform -rotate-90"
>
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={strokeWidth}
className="stroke-muted"
/>
{/* Progress circle */}
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={strokeWidth}
strokeLinecap="round"
className={color}
initial={{ strokeDashoffset: circumference }}
animate={{
strokeDashoffset: isInView ? offset : circumference,
}}
transition={{ duration: 1, ease: "easeOut" }}
style={{
strokeDasharray: circumference,
}}
/>
</svg>
{(showValue || label) && (
<div className="absolute inset-0 flex flex-col items-center justify-center">
{showValue && (
<span className="text-2xl font-bold">{Math.round(percentage)}%</span>
)}
{label && (
<span className="text-sm text-muted-foreground">{label}</span>
)}
</div>
)}
</div>
);
}
// =============================================================================
// SKILLS SECTION
// Section with skill progress bars
// =============================================================================
interface Skill {
name: string;
level: number;
color?: string;
}
interface SkillsSectionProps {
title?: string;
description?: string;
skills: Skill[];
columns?: 1 | 2;
className?: string;
}
export function SkillsSection({
title = "Compétences",
description,
skills,
columns = 2,
className,
}: SkillsSectionProps) {
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold">{title}</h2>
{description && (
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
{description}
</p>
)}
</div>
<div
className={cn(
"grid gap-6 max-w-4xl mx-auto",
columns === 2 ? "md:grid-cols-2" : ""
)}
>
{skills.map((skill, index) => (
<ProgressBar
key={index}
value={skill.level}
label={skill.name}
color={skill.color}
size="md"
/>
))}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,419 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { Search as SearchIcon, X, Loader2, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// SEARCH INPUT
// Basic search input with icon
// =============================================================================
interface SearchInputProps {
value?: string;
onChange?: (value: string) => void;
onSubmit?: (value: string) => void;
placeholder?: string;
autoFocus?: boolean;
size?: "sm" | "md" | "lg";
className?: string;
}
export function SearchInput({
value: controlledValue,
onChange,
onSubmit,
placeholder = "Rechercher...",
autoFocus = false,
size = "md",
className,
}: SearchInputProps) {
const [internalValue, setInternalValue] = useState("");
const value = controlledValue ?? internalValue;
const sizeStyles = {
sm: "h-9 text-sm pl-9 pr-3",
md: "h-11 pl-11 pr-4",
lg: "h-14 text-lg pl-12 pr-5",
};
const iconSizes = {
sm: "w-4 h-4 left-3",
md: "w-5 h-5 left-3.5",
lg: "w-6 h-6 left-4",
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInternalValue(newValue);
onChange?.(newValue);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit?.(value);
};
const handleClear = () => {
setInternalValue("");
onChange?.("");
};
return (
<form onSubmit={handleSubmit} className={cn("relative", className)}>
<SearchIcon
className={cn(
"absolute top-1/2 -translate-y-1/2 text-muted-foreground",
iconSizes[size]
)}
/>
<input
type="search"
value={value}
onChange={handleChange}
placeholder={placeholder}
autoFocus={autoFocus}
className={cn(
"w-full rounded-lg border bg-background focus:outline-none focus:ring-2 focus:ring-primary",
sizeStyles[size]
)}
/>
{value && (
<button
type="button"
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4" />
</button>
)}
</form>
);
}
// =============================================================================
// SEARCH WITH SUGGESTIONS
// Search input with autocomplete/suggestions
// =============================================================================
interface SearchSuggestion {
id: string;
title: string;
description?: string;
href?: string;
icon?: React.ReactNode;
}
interface SearchWithSuggestionsProps {
suggestions: SearchSuggestion[];
onSearch?: (query: string) => void;
onSelect?: (suggestion: SearchSuggestion) => void;
isLoading?: boolean;
placeholder?: string;
className?: string;
}
export function SearchWithSuggestions({
suggestions,
onSearch,
onSelect,
isLoading = false,
placeholder = "Rechercher...",
className,
}: SearchWithSuggestionsProps) {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const handleChange = (value: string) => {
setQuery(value);
setIsOpen(value.length > 0);
setSelectedIndex(-1);
onSearch?.(value);
};
const handleSelect = (suggestion: SearchSuggestion) => {
onSelect?.(suggestion);
setQuery("");
setIsOpen(false);
if (suggestion.href) {
router.push(suggestion.href);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) =>
prev < suggestions.length - 1 ? prev + 1 : prev
);
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
break;
case "Enter":
e.preventDefault();
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
handleSelect(suggestions[selectedIndex]);
}
break;
case "Escape":
setIsOpen(false);
break;
}
};
return (
<div className={cn("relative", className)}>
<div className="relative">
<SearchIcon className="absolute left-3.5 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => query && setIsOpen(true)}
placeholder={placeholder}
className="w-full h-11 pl-11 pr-10 rounded-lg border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
/>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground animate-spin" />
)}
</div>
<AnimatePresence>
{isOpen && suggestions.length > 0 && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute top-full left-0 right-0 mt-2 bg-background border rounded-lg shadow-lg overflow-hidden z-50"
>
<ul>
{suggestions.map((suggestion, index) => (
<li key={suggestion.id}>
<button
onClick={() => handleSelect(suggestion)}
className={cn(
"w-full px-4 py-3 text-left flex items-center gap-3 hover:bg-muted transition-colors",
selectedIndex === index && "bg-muted"
)}
>
{suggestion.icon && (
<span className="text-muted-foreground">
{suggestion.icon}
</span>
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{suggestion.title}
</div>
{suggestion.description && (
<div className="text-sm text-muted-foreground truncate">
{suggestion.description}
</div>
)}
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground" />
</button>
</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// =============================================================================
// SEARCH MODAL
// Full-screen search modal (Cmd+K style)
// =============================================================================
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
onSearch?: (query: string) => void;
suggestions?: SearchSuggestion[];
recentSearches?: string[];
isLoading?: boolean;
placeholder?: string;
}
export function SearchModal({
isOpen,
onClose,
onSearch,
suggestions = [],
recentSearches = [],
isLoading = false,
placeholder = "Rechercher...",
}: SearchModalProps) {
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
useEffect(() => {
if (isOpen) {
inputRef.current?.focus();
} else {
setQuery("");
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
// Toggle would be handled by parent
}
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleChange = (value: string) => {
setQuery(value);
onSearch?.(value);
};
const handleSelect = (suggestion: SearchSuggestion) => {
onClose();
if (suggestion.href) {
router.push(suggestion.href);
}
};
if (!isOpen) return null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center pt-[20vh]"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-background rounded-xl shadow-2xl w-full max-w-xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Search input */}
<div className="flex items-center px-4 border-b">
<SearchIcon className="w-5 h-5 text-muted-foreground" />
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
className="flex-1 h-14 px-3 bg-transparent focus:outline-none"
/>
{isLoading && <Loader2 className="w-5 h-5 animate-spin" />}
<kbd className="hidden sm:block px-2 py-1 text-xs bg-muted rounded">
ESC
</kbd>
</div>
{/* Results */}
<div className="max-h-[60vh] overflow-y-auto">
{query ? (
suggestions.length > 0 ? (
<ul className="py-2">
{suggestions.map((suggestion) => (
<li key={suggestion.id}>
<button
onClick={() => handleSelect(suggestion)}
className="w-full px-4 py-3 text-left flex items-center gap-3 hover:bg-muted transition-colors"
>
{suggestion.icon}
<div className="flex-1">
<div className="font-medium">{suggestion.title}</div>
{suggestion.description && (
<div className="text-sm text-muted-foreground">
{suggestion.description}
</div>
)}
</div>
</button>
</li>
))}
</ul>
) : (
<div className="py-8 text-center text-muted-foreground">
Aucun résultat pour &ldquo;{query}&rdquo;
</div>
)
) : recentSearches.length > 0 ? (
<div className="py-4">
<h3 className="px-4 text-xs font-medium text-muted-foreground uppercase mb-2">
Recherches récentes
</h3>
<ul>
{recentSearches.map((search, index) => (
<li key={index}>
<button
onClick={() => handleChange(search)}
className="w-full px-4 py-2 text-left hover:bg-muted transition-colors"
>
{search}
</button>
</li>
))}
</ul>
</div>
) : (
<div className="py-8 text-center text-muted-foreground">
Commencez à taper pour rechercher
</div>
)}
</div>
</motion.div>
</motion.div>
);
}
// =============================================================================
// SEARCH TRIGGER
// Button to open search modal
// =============================================================================
interface SearchTriggerProps {
onClick: () => void;
className?: string;
}
export function SearchTrigger({ onClick, className }: SearchTriggerProps) {
return (
<button
onClick={onClick}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground bg-muted rounded-lg hover:bg-muted/80 transition-colors",
className
)}
>
<SearchIcon className="w-4 h-4" />
<span className="hidden sm:inline">Rechercher...</span>
<kbd className="hidden md:block px-1.5 py-0.5 text-xs bg-background rounded border ml-auto">
K
</kbd>
</button>
);
}

View file

@ -0,0 +1,215 @@
"use client";
import { useState, ReactNode } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
// =============================================================================
// TABS
// Tabbed content navigation
// =============================================================================
interface Tab {
id: string;
label: string;
icon?: ReactNode;
content: ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
variant?: "default" | "pills" | "underline" | "bordered";
fullWidth?: boolean;
className?: string;
}
export function Tabs({
tabs,
defaultTab,
variant = "default",
fullWidth = false,
className,
}: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id);
const containerStyles = {
default: "border-b border-border",
pills: "bg-muted p-1 rounded-lg inline-flex",
underline: "border-b border-border",
bordered: "border rounded-lg p-1 inline-flex",
};
const tabStyles = {
default:
"px-4 py-2 -mb-px border-b-2 border-transparent hover:text-primary transition-colors",
pills: "px-4 py-2 rounded-md transition-colors",
underline:
"px-4 py-2 -mb-px border-b-2 border-transparent hover:text-primary transition-colors",
bordered: "px-4 py-2 rounded-md transition-colors",
};
const activeStyles = {
default: "border-primary text-primary",
pills: "bg-background shadow-sm text-foreground",
underline: "border-primary text-primary",
bordered: "bg-primary text-primary-foreground",
};
const inactiveStyles = {
default: "text-muted-foreground",
pills: "text-muted-foreground hover:text-foreground",
underline: "text-muted-foreground",
bordered: "text-muted-foreground hover:text-foreground",
};
return (
<div className={className}>
<div
className={cn(
containerStyles[variant],
fullWidth && "flex w-full"
)}
>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
tabStyles[variant],
fullWidth && "flex-1",
activeTab === tab.id
? activeStyles[variant]
: inactiveStyles[variant],
"flex items-center justify-center gap-2 font-medium"
)}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
<div className="mt-6">
{tabs.map((tab) => (
<div
key={tab.id}
className={activeTab === tab.id ? "block" : "hidden"}
>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
{tab.content}
</motion.div>
</div>
))}
</div>
</div>
);
}
// =============================================================================
// VERTICAL TABS
// Tabs on the side
// =============================================================================
interface VerticalTabsProps {
tabs: Tab[];
defaultTab?: string;
className?: string;
}
export function VerticalTabs({
tabs,
defaultTab,
className,
}: VerticalTabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id);
return (
<div className={cn("flex gap-8", className)}>
<div className="flex flex-col w-48 shrink-0">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"px-4 py-3 text-left rounded-lg transition-colors flex items-center gap-2",
activeTab === tab.id
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
<div className="flex-1">
{tabs.map((tab) => (
<div
key={tab.id}
className={activeTab === tab.id ? "block" : "hidden"}
>
<motion.div
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2 }}
>
{tab.content}
</motion.div>
</div>
))}
</div>
</div>
);
}
// =============================================================================
// TAB SECTION
// Full section with tabs
// =============================================================================
interface TabSectionProps {
title?: string;
description?: string;
tabs: Tab[];
variant?: "default" | "pills" | "underline";
centered?: boolean;
className?: string;
}
export function TabSection({
title,
description,
tabs,
variant = "pills",
centered = true,
className,
}: TabSectionProps) {
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
{(title || description) && (
<div className={cn("mb-12", centered && "text-center")}>
{title && (
<h2 className="text-3xl md:text-4xl font-bold">{title}</h2>
)}
{description && (
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
{description}
</p>
)}
</div>
)}
<div className={centered ? "flex flex-col items-center" : ""}>
<Tabs tabs={tabs} variant={variant} />
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,326 @@
"use client";
import { ReactNode } from "react";
import Image from "next/image";
import { motion } from "framer-motion";
import { Linkedin, Twitter, Github, Mail } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// TEAM MEMBER
// Individual team member card
// =============================================================================
interface Social {
type: "linkedin" | "twitter" | "github" | "email" | "custom";
url: string;
icon?: ReactNode;
}
interface TeamMember {
id: string;
name: string;
role: string;
image: string;
bio?: string;
social?: Social[];
}
interface TeamMemberCardProps {
member: TeamMember;
variant?: "default" | "horizontal" | "overlay" | "minimal";
className?: string;
}
const socialIcons = {
linkedin: Linkedin,
twitter: Twitter,
github: Github,
email: Mail,
};
export function TeamMemberCard({
member,
variant = "default",
className,
}: TeamMemberCardProps) {
const renderSocial = (social: Social[]) => (
<div className="flex gap-2">
{social.map((item, i) => {
const Icon = item.type === "custom" ? null : socialIcons[item.type];
return (
<a
key={i}
href={item.type === "email" ? `mailto:${item.url}` : item.url}
target={item.type !== "email" ? "_blank" : undefined}
rel="noopener noreferrer"
className="w-8 h-8 rounded-full bg-muted hover:bg-primary hover:text-primary-foreground flex items-center justify-center transition-colors"
>
{item.icon || (Icon && <Icon className="w-4 h-4" />)}
</a>
);
})}
</div>
);
if (variant === "horizontal") {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className={cn(
"flex items-center gap-6 p-6 bg-card border rounded-xl",
className
)}
>
<div className="relative w-24 h-24 rounded-full overflow-hidden shrink-0">
<Image
src={member.image}
alt={member.name}
fill
className="object-cover"
/>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">{member.name}</h3>
<p className="text-primary">{member.role}</p>
{member.bio && (
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
{member.bio}
</p>
)}
{member.social && (
<div className="mt-3">{renderSocial(member.social)}</div>
)}
</div>
</motion.div>
);
}
if (variant === "overlay") {
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
className={cn(
"relative group overflow-hidden rounded-xl aspect-[3/4]",
className
)}
>
<Image
src={member.image}
alt={member.name}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-6 text-white">
<h3 className="text-xl font-semibold">{member.name}</h3>
<p className="text-white/80">{member.role}</p>
{member.social && (
<div className="mt-3 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{member.social.map((item, i) => {
const Icon =
item.type === "custom" ? null : socialIcons[item.type];
return (
<a
key={i}
href={
item.type === "email" ? `mailto:${item.url}` : item.url
}
target={item.type !== "email" ? "_blank" : undefined}
rel="noopener noreferrer"
className="w-8 h-8 rounded-full bg-white/20 hover:bg-white/40 flex items-center justify-center transition-colors"
>
{item.icon || (Icon && <Icon className="w-4 h-4" />)}
</a>
);
})}
</div>
)}
</div>
</motion.div>
);
}
if (variant === "minimal") {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className={cn("text-center", className)}
>
<div className="relative w-32 h-32 rounded-full overflow-hidden mx-auto mb-4">
<Image
src={member.image}
alt={member.name}
fill
className="object-cover"
/>
</div>
<h3 className="font-semibold">{member.name}</h3>
<p className="text-sm text-muted-foreground">{member.role}</p>
</motion.div>
);
}
// Default variant
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className={cn("bg-card border rounded-xl overflow-hidden", className)}
>
<div className="relative aspect-square">
<Image
src={member.image}
alt={member.name}
fill
className="object-cover"
/>
</div>
<div className="p-6 text-center">
<h3 className="text-lg font-semibold">{member.name}</h3>
<p className="text-primary">{member.role}</p>
{member.bio && (
<p className="mt-3 text-sm text-muted-foreground">{member.bio}</p>
)}
{member.social && (
<div className="mt-4 flex justify-center">
{renderSocial(member.social)}
</div>
)}
</div>
</motion.div>
);
}
// =============================================================================
// TEAM SECTION
// Full team section
// =============================================================================
interface TeamSectionProps {
title?: string;
description?: string;
members: TeamMember[];
columns?: 2 | 3 | 4;
variant?: "default" | "horizontal" | "overlay" | "minimal";
className?: string;
}
export function TeamSection({
title = "Notre équipe",
description,
members,
columns = 3,
variant = "default",
className,
}: TeamSectionProps) {
const colStyles = {
2: "md:grid-cols-2",
3: "md:grid-cols-2 lg:grid-cols-3",
4: "md:grid-cols-2 lg:grid-cols-4",
};
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
<div className="text-center mb-12">
{title && (
<h2 className="text-3xl md:text-4xl font-bold">{title}</h2>
)}
{description && (
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
{description}
</p>
)}
</div>
<div className={cn("grid gap-8", colStyles[columns])}>
{members.map((member, index) => (
<TeamMemberCard
key={member.id}
member={member}
variant={variant}
/>
))}
</div>
</div>
</section>
);
}
// =============================================================================
// TEAM CTA
// Team section with hiring CTA
// =============================================================================
interface TeamCTAProps {
title?: string;
description?: string;
members: TeamMember[];
cta?: { text: string; href: string };
ctaTitle?: string;
ctaDescription?: string;
className?: string;
}
export function TeamCTA({
title = "Notre équipe",
description,
members,
cta = { text: "Voir les offres", href: "/careers" },
ctaTitle = "Rejoignez-nous !",
ctaDescription = "Nous recherchons des talents pour agrandir notre équipe.",
className,
}: TeamCTAProps) {
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
<div className="text-center mb-12">
{title && (
<h2 className="text-3xl md:text-4xl font-bold">{title}</h2>
)}
{description && (
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
{description}
</p>
)}
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{members.slice(0, 3).map((member) => (
<TeamMemberCard key={member.id} member={member} variant="minimal" />
))}
{/* CTA Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-primary text-primary-foreground rounded-xl p-8 flex flex-col items-center justify-center text-center"
>
<div className="w-16 h-16 rounded-full border-2 border-dashed border-primary-foreground/50 flex items-center justify-center text-3xl mb-4">
+
</div>
<h3 className="text-lg font-semibold">{ctaTitle}</h3>
<p className="mt-2 text-sm text-primary-foreground/80">
{ctaDescription}
</p>
<a
href={cta.href}
className="mt-4 px-6 py-2 bg-background text-foreground rounded-lg font-medium hover:bg-background/90 transition-colors"
>
{cta.text}
</a>
</motion.div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import Image from "next/image";
import { Card, CardContent } from "@/components/ui/card";
import { FadeIn, StaggerChildren } from "@/components/animations";
import { cn } from "@/lib/utils";
import { Star } from "lucide-react";
interface Testimonial {
quote: string;
author: string;
role?: string;
avatar?: { src: string; alt: string };
rating?: number;
}
interface TestimonialsProps {
title?: string;
subtitle?: string;
testimonials: Testimonial[];
columns?: 1 | 2 | 3;
className?: string;
}
export function Testimonials({
title,
subtitle,
testimonials,
columns = 3,
className,
}: TestimonialsProps) {
const gridCols = {
1: "max-w-2xl mx-auto",
2: "md:grid-cols-2 max-w-4xl mx-auto",
3: "md:grid-cols-2 lg:grid-cols-3",
};
return (
<section className={cn("py-20 px-4 bg-muted/30", className)}>
<div className="max-w-6xl mx-auto">
{(title || subtitle) && (
<FadeIn className="text-center mb-12">
{title && (
<h2 className="text-3xl md:text-4xl font-bold mb-4">{title}</h2>
)}
{subtitle && (
<p className="text-lg text-muted-foreground">{subtitle}</p>
)}
</FadeIn>
)}
<StaggerChildren
className={cn("grid gap-6", gridCols[columns])}
staggerDelay={0.1}
>
{testimonials.map((testimonial, index) => (
<Card key={index} className="bg-background">
<CardContent className="pt-6">
{testimonial.rating && (
<div className="flex gap-1 mb-4">
{Array.from({ length: testimonial.rating }).map((_, i) => (
<Star
key={i}
className="w-5 h-5 fill-yellow-400 text-yellow-400"
/>
))}
</div>
)}
<blockquote className="text-lg mb-6">
&ldquo;{testimonial.quote}&rdquo;
</blockquote>
<div className="flex items-center gap-4">
{testimonial.avatar && (
<Image
src={testimonial.avatar.src}
alt={testimonial.avatar.alt}
width={48}
height={48}
className="rounded-full"
/>
)}
<div>
<p className="font-semibold">{testimonial.author}</p>
{testimonial.role && (
<p className="text-sm text-muted-foreground">
{testimonial.role}
</p>
)}
</div>
</div>
</CardContent>
</Card>
))}
</StaggerChildren>
</div>
</section>
);
}

View file

@ -0,0 +1,217 @@
"use client";
import { ReactNode } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
// =============================================================================
// TIMELINE
// Chronological content display
// =============================================================================
interface TimelineItem {
id: string;
date?: string;
title: string;
description?: string;
icon?: ReactNode;
content?: ReactNode;
}
interface TimelineProps {
items: TimelineItem[];
variant?: "default" | "alternating" | "compact";
lineColor?: string;
className?: string;
}
export function Timeline({
items,
variant = "default",
lineColor = "bg-border",
className,
}: TimelineProps) {
if (variant === "alternating") {
return (
<div className={cn("relative", className)}>
{/* Center line */}
<div
className={cn(
"absolute left-1/2 transform -translate-x-1/2 w-0.5 h-full",
lineColor
)}
/>
<div className="space-y-12">
{items.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className={cn(
"relative flex items-center",
index % 2 === 0 ? "justify-start" : "justify-end"
)}
>
{/* Content */}
<div
className={cn(
"w-5/12",
index % 2 === 0 ? "pr-8 text-right" : "pl-8 text-left"
)}
>
{item.date && (
<span className="text-sm text-muted-foreground">
{item.date}
</span>
)}
<h3 className="text-xl font-semibold mt-1">{item.title}</h3>
{item.description && (
<p className="mt-2 text-muted-foreground">
{item.description}
</p>
)}
{item.content}
</div>
{/* Center dot */}
<div className="absolute left-1/2 transform -translate-x-1/2 w-4 h-4 rounded-full bg-primary border-4 border-background" />
</motion.div>
))}
</div>
</div>
);
}
if (variant === "compact") {
return (
<div className={cn("relative pl-6", className)}>
{/* Line */}
<div
className={cn("absolute left-0 top-2 bottom-2 w-0.5", lineColor)}
/>
<div className="space-y-6">
{items.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className="relative"
>
{/* Dot */}
<div className="absolute -left-6 top-1.5 w-3 h-3 rounded-full bg-primary border-2 border-background" />
<div className="flex items-baseline gap-4">
{item.date && (
<span className="text-sm text-muted-foreground shrink-0">
{item.date}
</span>
)}
<div>
<h3 className="font-medium">{item.title}</h3>
{item.description && (
<p className="mt-1 text-sm text-muted-foreground">
{item.description}
</p>
)}
</div>
</div>
</motion.div>
))}
</div>
</div>
);
}
// Default variant
return (
<div className={cn("relative pl-8", className)}>
{/* Line */}
<div className={cn("absolute left-3 top-0 bottom-0 w-0.5", lineColor)} />
<div className="space-y-10">
{items.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className="relative"
>
{/* Dot/Icon */}
<div
className={cn(
"absolute -left-8 flex items-center justify-center",
item.icon
? "w-10 h-10 -ml-2 rounded-full bg-primary text-primary-foreground"
: "w-6 h-6 rounded-full bg-primary border-4 border-background"
)}
>
{item.icon}
</div>
{/* Content */}
<div className="bg-card border rounded-lg p-6">
{item.date && (
<span className="text-sm text-muted-foreground">
{item.date}
</span>
)}
<h3 className="text-xl font-semibold mt-1">{item.title}</h3>
{item.description && (
<p className="mt-2 text-muted-foreground">{item.description}</p>
)}
{item.content}
</div>
</motion.div>
))}
</div>
</div>
);
}
// =============================================================================
// TIMELINE SECTION
// Full section with timeline
// =============================================================================
interface TimelineSectionProps {
title?: string;
description?: string;
items: TimelineItem[];
variant?: "default" | "alternating" | "compact";
className?: string;
}
export function TimelineSection({
title = "Notre Histoire",
description,
items,
variant = "default",
className,
}: TimelineSectionProps) {
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold">{title}</h2>
{description && (
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
{description}
</p>
)}
</div>
<div className="max-w-4xl mx-auto">
<Timeline items={items} variant={variant} />
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,328 @@
"use client";
import { useState, useRef } from "react";
import Image from "next/image";
import { motion } from "framer-motion";
import { Play, Pause, Volume2, VolumeX, Maximize } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// VIDEO EMBED
// YouTube, Vimeo, or custom video
// =============================================================================
interface VideoEmbedProps {
src: string;
type?: "youtube" | "vimeo" | "custom";
title?: string;
poster?: string;
autoPlay?: boolean;
aspectRatio?: "video" | "square" | "cinema";
className?: string;
}
export function VideoEmbed({
src,
type = "youtube",
title = "Video",
poster,
autoPlay = false,
aspectRatio = "video",
className,
}: VideoEmbedProps) {
const [isPlaying, setIsPlaying] = useState(autoPlay);
const [showPoster, setShowPoster] = useState(!autoPlay && !!poster);
const aspectStyles = {
video: "aspect-video",
square: "aspect-square",
cinema: "aspect-[2.35/1]",
};
const getEmbedUrl = () => {
if (type === "youtube") {
// Extract video ID
const videoId = src.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/
)?.[1];
return `https://www.youtube.com/embed/${videoId}?autoplay=${isPlaying ? 1 : 0}&rel=0`;
}
if (type === "vimeo") {
const videoId = src.match(/vimeo\.com\/(\d+)/)?.[1];
return `https://player.vimeo.com/video/${videoId}?autoplay=${isPlaying ? 1 : 0}`;
}
return src;
};
const handlePlay = () => {
setShowPoster(false);
setIsPlaying(true);
};
return (
<div
className={cn(
"relative overflow-hidden rounded-xl bg-muted",
aspectStyles[aspectRatio],
className
)}
>
{showPoster && poster ? (
<div
className="absolute inset-0 cursor-pointer group"
onClick={handlePlay}
>
<Image
src={poster}
alt={title}
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-black/30 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className="w-20 h-20 rounded-full bg-primary text-primary-foreground flex items-center justify-center"
>
<Play className="w-8 h-8 ml-1" />
</motion.div>
</div>
</div>
) : type === "custom" ? (
<video
src={src}
controls
autoPlay={isPlaying}
className="absolute inset-0 w-full h-full object-cover"
>
Your browser does not support the video tag.
</video>
) : (
<iframe
src={getEmbedUrl()}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 w-full h-full"
/>
)}
</div>
);
}
// =============================================================================
// VIDEO PLAYER
// Custom HTML5 video player with controls
// =============================================================================
interface VideoPlayerProps {
src: string;
poster?: string;
title?: string;
className?: string;
}
export function VideoPlayer({
src,
poster,
title,
className,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [progress, setProgress] = useState(0);
const [showControls, setShowControls] = useState(true);
const togglePlay = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const toggleMute = () => {
if (videoRef.current) {
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
const handleTimeUpdate = () => {
if (videoRef.current) {
const progress =
(videoRef.current.currentTime / videoRef.current.duration) * 100;
setProgress(progress);
}
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (videoRef.current) {
const rect = e.currentTarget.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
videoRef.current.currentTime = pos * videoRef.current.duration;
}
};
const toggleFullscreen = () => {
if (videoRef.current) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoRef.current.requestFullscreen();
}
}
};
return (
<div
className={cn(
"relative overflow-hidden rounded-xl bg-black group",
className
)}
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
<video
ref={videoRef}
src={src}
poster={poster}
className="w-full aspect-video object-contain"
onTimeUpdate={handleTimeUpdate}
onEnded={() => setIsPlaying(false)}
onClick={togglePlay}
/>
{/* Play overlay (when paused) */}
{!isPlaying && (
<div
className="absolute inset-0 flex items-center justify-center cursor-pointer"
onClick={togglePlay}
>
<motion.div
whileHover={{ scale: 1.1 }}
className="w-20 h-20 rounded-full bg-primary/90 text-primary-foreground flex items-center justify-center"
>
<Play className="w-8 h-8 ml-1" />
</motion.div>
</div>
)}
{/* Controls */}
<motion.div
initial={false}
animate={{ opacity: showControls ? 1 : 0 }}
className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent"
>
{/* Progress bar */}
<div
className="h-1 bg-white/30 rounded-full cursor-pointer mb-3"
onClick={handleSeek}
>
<div
className="h-full bg-primary rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={togglePlay}
className="w-8 h-8 flex items-center justify-center text-white hover:text-primary transition-colors"
>
{isPlaying ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5" />
)}
</button>
<button
onClick={toggleMute}
className="w-8 h-8 flex items-center justify-center text-white hover:text-primary transition-colors"
>
{isMuted ? (
<VolumeX className="w-5 h-5" />
) : (
<Volume2 className="w-5 h-5" />
)}
</button>
</div>
{title && (
<span className="text-white text-sm truncate mx-4">{title}</span>
)}
<button
onClick={toggleFullscreen}
className="w-8 h-8 flex items-center justify-center text-white hover:text-primary transition-colors"
>
<Maximize className="w-5 h-5" />
</button>
</div>
</motion.div>
</div>
);
}
// =============================================================================
// VIDEO SECTION
// Full section with video
// =============================================================================
interface VideoSectionProps {
title?: string;
description?: string;
videoSrc: string;
videoType?: "youtube" | "vimeo" | "custom";
poster?: string;
reversed?: boolean;
className?: string;
}
export function VideoSection({
title,
description,
videoSrc,
videoType = "youtube",
poster,
reversed = false,
className,
}: VideoSectionProps) {
return (
<section className={cn("py-16 md:py-24", className)}>
<div className="container mx-auto px-4">
<div
className={cn(
"grid md:grid-cols-2 gap-12 items-center",
reversed && "md:flex-row-reverse"
)}
>
<div className={reversed ? "md:order-2" : ""}>
{title && (
<h2 className="text-3xl md:text-4xl font-bold">{title}</h2>
)}
{description && (
<p className="mt-4 text-lg text-muted-foreground">
{description}
</p>
)}
</div>
<div className={reversed ? "md:order-1" : ""}>
<VideoEmbed
src={videoSrc}
type={videoType}
poster={poster}
title={title}
/>
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,68 @@
// =============================================================================
// BLOCKS - All reusable page sections
// =============================================================================
// Hero & CTA
export { HeroSimple } from "./HeroSimple";
export { CTABanner } from "./CTABanner";
export { FeaturesGrid } from "./FeaturesGrid";
export { Footer } from "./Footer";
export { Testimonials } from "./Testimonials";
// Content
export { Accordion, FAQSection } from "./Accordion";
export { Tabs, VerticalTabs, TabSection } from "./Tabs";
export { Timeline, TimelineSection } from "./Timeline";
export {
Card,
CardGrid,
FeatureCard,
ImageCard,
ProfileCard,
StatCard,
} from "./Cards";
// Media
export { Carousel, ImageCarousel, TestimonialCarousel } from "./Carousel";
export { Gallery, FilteredGallery } from "./Gallery";
export { VideoEmbed, VideoPlayer, VideoSection } from "./Video";
export { MapEmbed, StaticMap, ContactMapSection, MultipleLocations } from "./Map";
// Marketing
export { PricingCard, PricingSection, ComparisonTable } from "./Pricing";
export { TeamMemberCard, TeamSection, TeamCTA } from "./Team";
export { LogoCloud, LogoSection, PartnersGrid } from "./LogoCloud";
export { NewsletterForm, NewsletterSection, NewsletterPopup } from "./Newsletter";
// Navigation
export { Breadcrumbs, CollapsibleBreadcrumbs, PageHeader } from "./Breadcrumbs";
export {
Pagination,
SimplePagination,
LoadMore,
PaginationInfo,
} from "./Pagination";
export {
SearchInput,
SearchWithSuggestions,
SearchModal,
SearchTrigger,
} from "./Search";
// Blog
export {
BlogPostCard,
BlogGrid,
BlogList,
BlogSidebar,
BlogSection,
} from "./Blog";
export { Comment, CommentForm, CommentsSection } from "./Comments";
// Data Display
export {
ProgressBar,
ProgressGroup,
CircularProgress,
SkillsSection,
} from "./ProgressBar";

View file

@ -0,0 +1,139 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X, Phone } from "lucide-react";
import { cn } from "@/lib/utils";
import { navigation, restaurant } from "@/lib/data/restaurant";
export function Header() {
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const pathname = usePathname();
useEffect(() => {
const onScroll = () => setIsScrolled(window.scrollY > 50);
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
setIsMobileOpen(false);
}, [pathname]);
return (
<>
<header
className={cn(
"fixed top-0 left-0 right-0 z-50 transition-all duration-500",
isScrolled
? "bg-background/95 backdrop-blur-md border-b border-gold/10 py-3"
: "bg-transparent py-6"
)}
>
<div className="max-w-7xl mx-auto px-6 flex items-center justify-between">
{/* Logo */}
<Link href="/" className="group">
<div className="flex flex-col items-center">
<span className="font-display text-xl md:text-2xl font-bold tracking-wider text-gold uppercase">
La Maison
</span>
<span className="text-[10px] md:text-xs tracking-[0.3em] text-foreground/60 uppercase">
Doree
</span>
</div>
</Link>
{/* Desktop Nav */}
<nav className="hidden lg:flex items-center gap-8">
{navigation.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"text-sm tracking-[0.15em] uppercase transition-colors duration-300 hover:text-gold",
pathname === item.href
? "text-gold"
: "text-foreground/70"
)}
>
{item.label}
</Link>
))}
</nav>
{/* Desktop CTA */}
<div className="hidden lg:flex items-center gap-6">
<a
href={`tel:${restaurant.phone}`}
className="flex items-center gap-2 text-sm text-foreground/60 hover:text-gold transition-colors"
>
<Phone className="w-4 h-4" />
<span className="tracking-wider">{restaurant.phone}</span>
</a>
<Link
href="/contact#reservation"
className="px-6 py-2.5 border border-gold text-gold text-xs tracking-[0.2em] uppercase hover:bg-gold hover:text-background transition-all duration-300"
>
Reserver
</Link>
</div>
{/* Mobile menu button */}
<button
onClick={() => setIsMobileOpen(!isMobileOpen)}
className="lg:hidden text-foreground/70 hover:text-gold transition-colors"
>
{isMobileOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
</header>
{/* Mobile Menu */}
<AnimatePresence>
{isMobileOpen && (
<motion.div
initial={{ opacity: 0, x: "100%" }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: "100%" }}
transition={{ type: "tween", duration: 0.3 }}
className="fixed inset-0 z-40 bg-background flex flex-col items-center justify-center gap-8"
>
{navigation.map((item, i) => (
<motion.div
key={item.href}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<Link
href={item.href}
className={cn(
"font-display text-2xl tracking-wider transition-colors hover:text-gold",
pathname === item.href ? "text-gold" : "text-foreground/70"
)}
>
{item.label}
</Link>
</motion.div>
))}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: navigation.length * 0.1 }}
>
<Link
href="/contact#reservation"
className="mt-4 px-8 py-3 border border-gold text-gold text-sm tracking-[0.2em] uppercase hover:bg-gold hover:text-background transition-all duration-300"
>
Reserver une table
</Link>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View file

@ -0,0 +1,134 @@
import Link from "next/link";
import { MapPin, Phone, Mail, Clock } from "lucide-react";
import { restaurant, navigation } from "@/lib/data/restaurant";
export function RestaurantFooter() {
return (
<footer className="bg-card border-t border-gold/10">
{/* Main footer */}
<div className="max-w-7xl mx-auto px-6 py-16">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12">
{/* Brand */}
<div className="lg:col-span-1">
<div className="mb-6">
<h3 className="font-display text-2xl font-bold tracking-wider text-gold uppercase">
La Maison
</h3>
<span className="text-xs tracking-[0.3em] text-foreground/40 uppercase">
Doree
</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{restaurant.description}
</p>
{/* Social */}
<div className="flex gap-4 mt-6">
<a
href={restaurant.social.instagram}
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 border border-gold/20 flex items-center justify-center text-muted-foreground hover:text-gold hover:border-gold transition-all duration-300"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
</svg>
</a>
<a
href={restaurant.social.facebook}
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 border border-gold/20 flex items-center justify-center text-muted-foreground hover:text-gold hover:border-gold transition-all duration-300"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2h-3a5 5 0 00-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 011-1h3z" />
</svg>
</a>
</div>
</div>
{/* Navigation */}
<div>
<h4 className="text-xs tracking-[0.2em] uppercase text-gold mb-6 font-semibold">
Navigation
</h4>
<ul className="space-y-3">
{navigation.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="text-sm text-muted-foreground hover:text-gold transition-colors duration-300"
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
{/* Horaires */}
<div>
<h4 className="text-xs tracking-[0.2em] uppercase text-gold mb-6 font-semibold">
Horaires
</h4>
<ul className="space-y-2">
{restaurant.openingHours.map((h) => (
<li key={h.day} className="flex justify-between text-sm">
<span className="text-muted-foreground">{h.day}</span>
<span className={h.closed ? "text-muted-foreground/50 italic" : "text-foreground/80"}>
{h.closed ? "Ferme" : h.dinner || h.lunch || ""}
</span>
</li>
))}
</ul>
</div>
{/* Contact */}
<div>
<h4 className="text-xs tracking-[0.2em] uppercase text-gold mb-6 font-semibold">
Contact
</h4>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<MapPin className="w-4 h-4 text-gold mt-0.5 shrink-0" />
<span className="text-sm text-muted-foreground">{restaurant.address}</span>
</li>
<li className="flex items-center gap-3">
<Phone className="w-4 h-4 text-gold shrink-0" />
<a href={`tel:${restaurant.phone}`} className="text-sm text-muted-foreground hover:text-gold transition-colors">
{restaurant.phone}
</a>
</li>
<li className="flex items-center gap-3">
<Mail className="w-4 h-4 text-gold shrink-0" />
<a href={`mailto:${restaurant.email}`} className="text-sm text-muted-foreground hover:text-gold transition-colors">
{restaurant.email}
</a>
</li>
<li className="flex items-center gap-3">
<Clock className="w-4 h-4 text-gold shrink-0" />
<span className="text-sm text-muted-foreground">Mar - Dim</span>
</li>
</ul>
</div>
</div>
</div>
{/* Bottom bar */}
<div className="border-t border-gold/10">
<div className="max-w-7xl mx-auto px-6 py-6 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-xs text-muted-foreground/50 tracking-wider">
&copy; {new Date().getFullYear()} La Maison Doree. Tous droits reserves.
</p>
<div className="flex gap-6">
<Link href="#" className="text-xs text-muted-foreground/50 hover:text-gold transition-colors tracking-wider">
Mentions legales
</Link>
<Link href="#" className="text-xs text-muted-foreground/50 hover:text-gold transition-colors tracking-wider">
Confidentialite
</Link>
</div>
</div>
</div>
</footer>
);
}

View file

@ -0,0 +1,21 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ReactNode } from "react";
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}

View file

@ -0,0 +1,28 @@
"use client";
import { ReactNode } from "react";
import { ThemeProvider } from "./ThemeProvider";
import { Toaster } from "@/components/ui/Toast";
// =============================================================================
// PROVIDERS
// Wrap all providers here to keep layout.tsx clean
// =============================================================================
interface ProvidersProps {
children: ReactNode;
}
export function Providers({ children }: ProvidersProps) {
return (
<ThemeProvider>
{/* Add other providers here */}
{/* <QueryClientProvider client={queryClient}> */}
{/* <SessionProvider> */}
{children}
<Toaster />
{/* </SessionProvider> */}
{/* </QueryClientProvider> */}
</ThemeProvider>
);
}

View file

@ -0,0 +1,36 @@
// =============================================================================
// JSON-LD COMPONENT
// Injects structured data into the page head
// =============================================================================
interface JsonLdProps {
data: Record<string, unknown> | Record<string, unknown>[];
}
export function JsonLd({ data }: JsonLdProps) {
const jsonLdArray = Array.isArray(data) ? data : [data];
return (
<>
{jsonLdArray.map((item, index) => (
<script
key={index}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(item) }}
/>
))}
</>
);
}
// =============================================================================
// USAGE
// =============================================================================
// import { JsonLd } from "@/components/seo/JsonLd";
// import { organizationJsonLd, articleJsonLd } from "@/lib/jsonld";
//
// // In layout.tsx (global schemas)
// <JsonLd data={[organizationJsonLd(), websiteJsonLd()]} />
//
// // In page.tsx (page-specific schema)
// <JsonLd data={articleJsonLd({ title: "...", ... })} />

View file

@ -0,0 +1,98 @@
"use client";
import { LucideIcon, LucideProps } from "lucide-react";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
// =============================================================================
// ICON COMPONENT
// Wrapper around Lucide icons for consistency and easy customization
// =============================================================================
type IconSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
const sizeMap: Record<IconSize, string> = {
xs: "w-3 h-3",
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
xl: "w-8 h-8",
"2xl": "w-10 h-10",
};
interface IconProps extends Omit<LucideProps, "size"> {
icon: LucideIcon;
size?: IconSize | number;
className?: string;
}
export const Icon = forwardRef<SVGSVGElement, IconProps>(
({ icon: IconComponent, size = "md", className, ...props }, ref) => {
const sizeClass = typeof size === "string" ? sizeMap[size] : undefined;
const sizeStyle = typeof size === "number" ? { width: size, height: size } : undefined;
return (
<IconComponent
ref={ref}
className={cn(sizeClass, className)}
style={sizeStyle}
{...props}
/>
);
}
);
Icon.displayName = "Icon";
// =============================================================================
// ICON BUTTON
// Icon wrapped in a clickable button with hover states
// =============================================================================
interface IconButtonProps extends IconProps {
onClick?: () => void;
label: string; // Required for accessibility
variant?: "ghost" | "outline" | "solid";
disabled?: boolean;
}
const variantStyles = {
ghost: "hover:bg-muted rounded-md p-1.5 transition-colors",
outline: "border hover:bg-muted rounded-md p-1.5 transition-colors",
solid: "bg-primary text-primary-foreground hover:bg-primary/90 rounded-md p-1.5 transition-colors",
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ icon, size = "md", className, onClick, label, variant = "ghost", disabled, ...props }, ref) => {
return (
<button
ref={ref}
onClick={onClick}
disabled={disabled}
aria-label={label}
className={cn(
variantStyles[variant],
disabled && "opacity-50 cursor-not-allowed",
className
)}
>
<Icon icon={icon} size={size} {...props} />
</button>
);
}
);
IconButton.displayName = "IconButton";
// =============================================================================
// USAGE EXAMPLES
// =============================================================================
// import { ArrowRight, Check, Menu } from "lucide-react";
// import { Icon, IconButton } from "@/components/ui/Icon";
//
// <Icon icon={ArrowRight} size="md" />
// <Icon icon={Check} size="lg" className="text-green-500" />
// <Icon icon={Menu} size={24} />
//
// <IconButton icon={Menu} label="Open menu" onClick={() => {}} />
// <IconButton icon={Check} label="Confirm" variant="solid" />

View file

@ -0,0 +1,95 @@
import { cn } from "@/lib/utils";
// =============================================================================
// SKELETON
// Loading placeholders for content
// =============================================================================
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={cn(
"animate-pulse rounded-md bg-muted",
className
)}
/>
);
}
// =============================================================================
// PRESET SKELETONS
// Common loading patterns
// =============================================================================
export function SkeletonText({ lines = 3 }: { lines?: number }) {
return (
<div className="space-y-2">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
className={cn(
"h-4",
i === lines - 1 ? "w-3/4" : "w-full"
)}
/>
))}
</div>
);
}
export function SkeletonCard() {
return (
<div className="rounded-lg border p-4 space-y-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
);
}
export function SkeletonAvatar({ size = "md" }: { size?: "sm" | "md" | "lg" }) {
const sizeMap = {
sm: "w-8 h-8",
md: "w-10 h-10",
lg: "w-14 h-14",
};
return <Skeleton className={cn("rounded-full", sizeMap[size])} />;
}
export function SkeletonRow() {
return (
<div className="flex items-center gap-4">
<SkeletonAvatar />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
);
}
export function SkeletonTable({ rows = 5, cols = 4 }: { rows?: number; cols?: number }) {
return (
<div className="space-y-3">
{/* Header */}
<div className="flex gap-4">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton key={i} className="h-4 flex-1" />
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={rowIndex} className="flex gap-4">
{Array.from({ length: cols }).map((_, colIndex) => (
<Skeleton key={colIndex} className="h-8 flex-1" />
))}
</div>
))}
</div>
);
}

View file

@ -0,0 +1,163 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Moon, Sun, Monitor } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// THEME TOGGLE - Simple
// Two-state toggle: light/dark
// =============================================================================
interface ThemeToggleProps {
className?: string;
size?: "sm" | "md" | "lg";
}
const sizeMap = {
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
};
const buttonSizeMap = {
sm: "p-1.5",
md: "p-2",
lg: "p-2.5",
};
export function ThemeToggle({ className, size = "md" }: ThemeToggleProps) {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<button
className={cn(
"rounded-md bg-muted",
buttonSizeMap[size],
className
)}
>
<span className={cn("block bg-muted-foreground/20 rounded", sizeMap[size])} />
</button>
);
}
const isDark = theme === "dark";
return (
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className={cn(
"rounded-md hover:bg-muted transition-colors",
buttonSizeMap[size],
className
)}
aria-label={`Switch to ${isDark ? "light" : "dark"} mode`}
>
{isDark ? (
<Sun className={cn(sizeMap[size], "text-foreground")} />
) : (
<Moon className={cn(sizeMap[size], "text-foreground")} />
)}
</button>
);
}
// =============================================================================
// THEME SWITCHER - With System Option
// Three options: light, dark, system
// =============================================================================
interface ThemeSwitcherProps {
className?: string;
}
export function ThemeSwitcher({ className }: ThemeSwitcherProps) {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<div className={cn("flex gap-1 p-1 rounded-lg bg-muted", className)}>
{[1, 2, 3].map((i) => (
<span key={i} className="w-8 h-8 rounded-md bg-muted-foreground/20" />
))}
</div>
);
}
const options = [
{ value: "light", icon: Sun, label: "Light" },
{ value: "dark", icon: Moon, label: "Dark" },
{ value: "system", icon: Monitor, label: "System" },
] as const;
return (
<div className={cn("flex gap-1 p-1 rounded-lg bg-muted", className)}>
{options.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setTheme(value)}
className={cn(
"p-2 rounded-md transition-colors",
theme === value
? "bg-background shadow-sm"
: "hover:bg-background/50"
)}
aria-label={`${label} theme`}
>
<Icon className="w-4 h-4" />
</button>
))}
</div>
);
}
// =============================================================================
// THEME SELECT - Dropdown
// For settings pages
// =============================================================================
export function ThemeSelect({ className }: { className?: string }) {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<select className={cn("bg-muted rounded-md px-3 py-2", className)} disabled>
<option>Loading...</option>
</select>
);
}
return (
<select
value={theme}
onChange={(e) => setTheme(e.target.value)}
className={cn(
"bg-background border rounded-md px-3 py-2 text-sm",
"focus:outline-none focus:ring-2 focus:ring-ring",
className
)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { Toaster as SonnerToaster, toast } from "sonner";
// =============================================================================
// TOAST / NOTIFICATIONS
// Powered by Sonner - https://sonner.emilkowal.ski
// =============================================================================
export function Toaster() {
return (
<SonnerToaster
position="bottom-right"
toastOptions={{
classNames: {
toast:
"group toast bg-background text-foreground border shadow-lg rounded-lg",
title: "font-semibold",
description: "text-muted-foreground text-sm",
actionButton: "bg-primary text-primary-foreground",
cancelButton: "bg-muted text-muted-foreground",
error: "border-destructive/50 text-destructive",
success: "border-green-500/50 text-green-600",
warning: "border-yellow-500/50 text-yellow-600",
info: "border-blue-500/50 text-blue-600",
},
}}
richColors
closeButton
/>
);
}
// Re-export toast for convenience
export { toast };
// =============================================================================
// USAGE EXAMPLES
// =============================================================================
// import { toast } from "@/components/ui/Toast";
//
// // Basic
// toast("Event created");
// toast.success("Profile saved!");
// toast.error("Something went wrong");
// toast.warning("Please check your input");
// toast.info("New update available");
//
// // With description
// toast.success("Success!", {
// description: "Your changes have been saved.",
// });
//
// // With action
// toast("File deleted", {
// action: {
// label: "Undo",
// onClick: () => restoreFile(),
// },
// });
//
// // Promise toast (loading → success/error)
// toast.promise(saveData(), {
// loading: "Saving...",
// success: "Saved!",
// error: "Could not save",
// });
//
// // Custom duration (ms)
// toast("Quick message", { duration: 2000 });
//
// // Dismiss programmatically
// const id = toast("Loading...");
// toast.dismiss(id);

View file

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View file

@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

237
src/lib/data/restaurant.ts Normal file
View file

@ -0,0 +1,237 @@
// Restaurant information
export const restaurant = {
name: "La Maison Doree",
tagline: "L'art de la gastronomie",
description:
"Nichee au coeur de Bruxelles, La Maison Doree vous invite a un voyage culinaire ou tradition et modernite se rencontrent dans un cadre elegant et intimiste.",
address: "Rue de la Colline 12, 1000 Bruxelles",
phone: "+32 2 123 45 67",
email: "reservation@lamaisondoree.be",
coordinates: { lat: 50.8466, lng: 4.3528 },
openingHours: [
{ day: "Lundi", lunch: null, dinner: null, closed: true },
{ day: "Mardi", lunch: "12h00 - 14h30", dinner: "18h30 - 22h00", closed: false },
{ day: "Mercredi", lunch: "12h00 - 14h30", dinner: "18h30 - 22h00", closed: false },
{ day: "Jeudi", lunch: "12h00 - 14h30", dinner: "18h30 - 22h00", closed: false },
{ day: "Vendredi", lunch: "12h00 - 14h30", dinner: "18h30 - 23h00", closed: false },
{ day: "Samedi", lunch: null, dinner: "18h30 - 23h00", closed: false },
{ day: "Dimanche", lunch: "12h00 - 15h00", dinner: null, closed: false },
],
social: {
instagram: "https://instagram.com/lamaisondoree",
facebook: "https://facebook.com/lamaisondoree",
tripadvisor: "https://tripadvisor.com/lamaisondoree",
},
};
// Navigation links
export const navigation = [
{ label: "Accueil", href: "/" },
{ label: "La Carte", href: "/carte" },
{ label: "Notre Histoire", href: "/histoire" },
{ label: "Galerie", href: "/galerie" },
{ label: "Contact", href: "/contact" },
];
// Menu categories and items
export const menuCategories = [
{ id: "entrees", name: "Les Entrees", description: "Pour commencer en beaute" },
{ id: "plats", name: "Les Plats", description: "Le coeur de notre cuisine" },
{ id: "desserts", name: "Les Desserts", description: "Pour finir en douceur" },
{ id: "vins", name: "Les Vins", description: "Notre selection" },
];
export const menuItems = [
// Entrees
{
id: "1",
name: "Tartare de Saint-Jacques",
description: "Agrumes, avocat, huile de noisette et pousses de shiso",
price: 24,
category: "entrees",
featured: true,
},
{
id: "2",
name: "Foie Gras Mi-Cuit",
description: "Chutney de figues, pain d'epices toaste et fleur de sel",
price: 28,
category: "entrees",
featured: true,
},
{
id: "3",
name: "Veloute de Cèpes",
description: "Creme de truffe, eclats de noisettes torrefices",
price: 18,
category: "entrees",
vegetarian: true,
},
{
id: "4",
name: "Carpaccio de Boeuf",
description: "Parmesan affine 24 mois, roquette, huile de truffe",
price: 22,
category: "entrees",
},
{
id: "5",
name: "Burrata Cremeuse",
description: "Tomates anciennes, pesto de basilic, reduction balsamique",
price: 19,
category: "entrees",
vegetarian: true,
},
// Plats
{
id: "6",
name: "Filet de Boeuf Black Angus",
description: "Sauce au poivre de Sarawak, gratin dauphinois, legumes de saison",
price: 42,
category: "plats",
featured: true,
},
{
id: "7",
name: "Homard Breton Roti",
description: "Beurre de corail, risotto crémeux au safran, bisque legere",
price: 52,
category: "plats",
featured: true,
},
{
id: "8",
name: "Filet de Bar en Croute",
description: "Ecailles de pommes de terre, sauce vierge, artichaut poivrade",
price: 38,
category: "plats",
},
{
id: "9",
name: "Magret de Canard Laque",
description: "Miel et epices, puree de patate douce, jus reduit",
price: 36,
category: "plats",
},
{
id: "10",
name: "Risotto aux Truffes",
description: "Truffe noire du Perigord, parmesan et beurre noisette",
price: 34,
category: "plats",
vegetarian: true,
},
{
id: "11",
name: "Souris d'Agneau Confite",
description: "Jus au romarin, polenta cremeuse, tomates confites",
price: 38,
category: "plats",
},
// Desserts
{
id: "12",
name: "Sphere Chocolat Valrhona",
description: "Coeur coulant caramel, glace vanille de Madagascar",
price: 16,
category: "desserts",
featured: true,
},
{
id: "13",
name: "Tarte Tatin Revisitee",
description: "Pommes caramelisees, creme glacee au calvados",
price: 14,
category: "desserts",
},
{
id: "14",
name: "Creme Brulee a la Vanille",
description: "Vanille de Tahiti, tuile croustillante",
price: 12,
category: "desserts",
},
{
id: "15",
name: "Assiette de Fromages Affines",
description: "Selection du maitre affineur, confiture de cerises noires",
price: 18,
category: "desserts",
},
// Vins
{
id: "16",
name: "Chablis Premier Cru",
description: "Domaine William Fevre, 2021 — Frais, mineral et elegant",
price: 65,
category: "vins",
},
{
id: "17",
name: "Saint-Emilion Grand Cru",
description: "Chateau Figeac, 2018 — Puissant, veloute, notes de fruits noirs",
price: 95,
category: "vins",
},
{
id: "18",
name: "Champagne Brut Reserve",
description: "Maison Billecart-Salmon — Finesse et elegance",
price: 75,
category: "vins",
},
];
// Testimonials
export const testimonials = [
{
quote: "Une experience culinaire exceptionnelle. Chaque plat est une oeuvre d'art, et le service est irreprochable.",
author: "Sophie D.",
role: "Critique gastronomique",
rating: 5,
},
{
quote: "L'ambiance feutree et le raffinement de la cuisine font de La Maison Doree un lieu unique a Bruxelles.",
author: "Marc V.",
role: "Guide Michelin",
rating: 5,
},
{
quote: "Le filet de boeuf Black Angus est tout simplement divin. Une adresse que je recommande les yeux fermes.",
author: "Claire L.",
role: "Blogueuse culinaire",
rating: 5,
},
];
// Gallery images (using Unsplash placeholders)
export const galleryImages = [
{ src: "https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=800&h=600&fit=crop", alt: "Salle principale du restaurant", category: "Interieur" },
{ src: "https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=800&h=600&fit=crop", alt: "Plat signature du chef", category: "Cuisine" },
{ src: "https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800&h=600&fit=crop", alt: "Bar et ambiance", category: "Interieur" },
{ src: "https://images.unsplash.com/photo-1546039907-7fa05f864c02?w=800&h=600&fit=crop", alt: "Dessert gastronomique", category: "Cuisine" },
{ src: "https://images.unsplash.com/photo-1559339352-11d035aa65de?w=800&h=600&fit=crop", alt: "Table dressee elegamment", category: "Interieur" },
{ src: "https://images.unsplash.com/photo-1482049016688-2d3e1b311543?w=800&h=600&fit=crop", alt: "Entree du chef", category: "Cuisine" },
{ src: "https://images.unsplash.com/photo-1550966871-3ed3cdb51f3a?w=800&h=600&fit=crop", alt: "Cave a vins", category: "Interieur" },
{ src: "https://images.unsplash.com/photo-1565299624946-b28f40a0ae38?w=800&h=600&fit=crop", alt: "Plat de saison", category: "Cuisine" },
{ src: "https://images.unsplash.com/photo-1466978913421-dad2ebd01d17?w=800&h=600&fit=crop", alt: "Terrasse du restaurant", category: "Interieur" },
];
// Chef info
export const chef = {
name: "Alexandre Dubois",
title: "Chef executif",
bio: "Forme aupres des plus grands noms de la gastronomie francaise, le Chef Alexandre Dubois apporte a La Maison Doree une vision culinaire audacieuse ou les saveurs classiques rencontrent la creativite contemporaine. Apres des passages chez Alain Ducasse et au Plaza Athenee, il fonde La Maison Doree avec l'ambition de creer un lieu ou chaque repas devient un souvenir inoubliable.",
image: "https://images.unsplash.com/photo-1577219491135-ce391730fb2c?w=600&h=800&fit=crop",
};
// Stats
export const stats = [
{ value: 15, label: "Annees d'experience", suffix: "+" },
{ value: 1, label: "Etoile Michelin", suffix: "" },
{ value: 50000, label: "Clients satisfaits", suffix: "+" },
{ value: 98, label: "Note sur 100", suffix: "/100" },
];

18
src/lib/fonts.ts Normal file
View file

@ -0,0 +1,18 @@
import { Playfair_Display, Cormorant_Garamond } from "next/font/google";
// Display font — elegant serif for headings
export const fontDisplay = Playfair_Display({
variable: "--font-display",
subsets: ["latin"],
display: "swap",
});
// Body font — refined serif for body text
export const fontSans = Cormorant_Garamond({
variable: "--font-sans",
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
display: "swap",
});
export const fontVariables = `${fontSans.variable} ${fontDisplay.variable}`;

247
src/lib/jsonld.ts Normal file
View file

@ -0,0 +1,247 @@
import { seoConfig } from "./seo.config";
// =============================================================================
// JSON-LD STRUCTURED DATA
// Generate structured data for rich snippets in search results
// =============================================================================
/**
* Organization schema
* Shows company info in search results
*/
export function organizationJsonLd() {
const { organization } = seoConfig;
return {
"@context": "https://schema.org",
"@type": "Organization",
name: organization.name,
url: organization.url,
logo: `${seoConfig.siteUrl}${organization.logo}`,
email: organization.email || undefined,
telephone: organization.phone || undefined,
sameAs: organization.sameAs,
address: organization.address.city
? {
"@type": "PostalAddress",
streetAddress: organization.address.street,
addressLocality: organization.address.city,
addressRegion: organization.address.region,
postalCode: organization.address.postalCode,
addressCountry: organization.address.country,
}
: undefined,
};
}
/**
* Website schema
* Enables sitelinks search box in Google
*/
export function websiteJsonLd() {
return {
"@context": "https://schema.org",
"@type": "WebSite",
name: seoConfig.siteName,
url: seoConfig.siteUrl,
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${seoConfig.siteUrl}/search?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
};
}
/**
* Article schema
* For blog posts and articles
*/
export function articleJsonLd({
title,
description,
image,
datePublished,
dateModified,
authorName,
url,
}: {
title: string;
description: string;
image: string;
datePublished: string;
dateModified?: string;
authorName: string;
url: string;
}) {
return {
"@context": "https://schema.org",
"@type": "Article",
headline: title,
description,
image: image.startsWith("http") ? image : `${seoConfig.siteUrl}${image}`,
datePublished,
dateModified: dateModified || datePublished,
author: {
"@type": "Person",
name: authorName,
},
publisher: {
"@type": "Organization",
name: seoConfig.organization.name,
logo: {
"@type": "ImageObject",
url: `${seoConfig.siteUrl}${seoConfig.organization.logo}`,
},
},
mainEntityOfPage: {
"@type": "WebPage",
"@id": `${seoConfig.siteUrl}${url}`,
},
};
}
/**
* Product schema
* For e-commerce product pages
*/
export function productJsonLd({
name,
description,
image,
price,
currency = "EUR",
availability = "InStock",
url,
brand,
sku,
reviewCount,
ratingValue,
}: {
name: string;
description: string;
image: string;
price: number;
currency?: string;
availability?: "InStock" | "OutOfStock" | "PreOrder";
url: string;
brand?: string;
sku?: string;
reviewCount?: number;
ratingValue?: number;
}) {
return {
"@context": "https://schema.org",
"@type": "Product",
name,
description,
image: image.startsWith("http") ? image : `${seoConfig.siteUrl}${image}`,
url: `${seoConfig.siteUrl}${url}`,
brand: brand ? { "@type": "Brand", name: brand } : undefined,
sku,
offers: {
"@type": "Offer",
price,
priceCurrency: currency,
availability: `https://schema.org/${availability}`,
url: `${seoConfig.siteUrl}${url}`,
},
aggregateRating:
reviewCount && ratingValue
? {
"@type": "AggregateRating",
ratingValue,
reviewCount,
}
: undefined,
};
}
/**
* FAQ schema
* For FAQ pages - shows expandable Q&A in search results
*/
export function faqJsonLd(
faqs: { question: string; answer: string }[]
) {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: {
"@type": "Answer",
text: faq.answer,
},
})),
};
}
/**
* Breadcrumb schema
* Shows breadcrumb trail in search results
*/
export function breadcrumbJsonLd(
items: { name: string; url: string }[]
) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: `${seoConfig.siteUrl}${item.url}`,
})),
};
}
/**
* Local Business schema
* For businesses with physical locations
*/
export function localBusinessJsonLd({
name,
description,
image,
telephone,
address,
openingHours,
priceRange,
}: {
name: string;
description: string;
image: string;
telephone: string;
address: {
street: string;
city: string;
region: string;
postalCode: string;
country: string;
};
openingHours?: string[];
priceRange?: string;
}) {
return {
"@context": "https://schema.org",
"@type": "LocalBusiness",
name,
description,
image: image.startsWith("http") ? image : `${seoConfig.siteUrl}${image}`,
telephone,
address: {
"@type": "PostalAddress",
streetAddress: address.street,
addressLocality: address.city,
addressRegion: address.region,
postalCode: address.postalCode,
addressCountry: address.country,
},
openingHoursSpecification: openingHours,
priceRange,
};
}

99
src/lib/mdx.tsx Normal file
View file

@ -0,0 +1,99 @@
// =============================================================================
// MDX UTILITIES
// Helpers for working with MDX content
// =============================================================================
import { ReactNode } from "react";
/**
* MDX components mapping
* Customize how MDX elements render
*/
export const mdxComponents = {
// Typography
h1: ({ children }: { children: ReactNode }) => (
<h1 className="text-4xl font-bold mt-8 mb-4">{children}</h1>
),
h2: ({ children }: { children: ReactNode }) => (
<h2 className="text-3xl font-semibold mt-8 mb-3">{children}</h2>
),
h3: ({ children }: { children: ReactNode }) => (
<h3 className="text-2xl font-semibold mt-6 mb-2">{children}</h3>
),
h4: ({ children }: { children: ReactNode }) => (
<h4 className="text-xl font-medium mt-4 mb-2">{children}</h4>
),
p: ({ children }: { children: ReactNode }) => (
<p className="my-4 leading-7">{children}</p>
),
// Lists
ul: ({ children }: { children: ReactNode }) => (
<ul className="my-4 ml-6 list-disc space-y-2">{children}</ul>
),
ol: ({ children }: { children: ReactNode }) => (
<ol className="my-4 ml-6 list-decimal space-y-2">{children}</ol>
),
li: ({ children }: { children: ReactNode }) => (
<li className="leading-7">{children}</li>
),
// Code
pre: ({ children }: { children: ReactNode }) => (
<pre className="my-4 overflow-x-auto rounded-lg bg-muted p-4 text-sm">
{children}
</pre>
),
code: ({ children }: { children: ReactNode }) => (
<code className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono">
{children}
</code>
),
// Blockquote
blockquote: ({ children }: { children: ReactNode }) => (
<blockquote className="my-4 border-l-4 border-primary pl-4 italic text-muted-foreground">
{children}
</blockquote>
),
// Table
table: ({ children }: { children: ReactNode }) => (
<div className="my-4 overflow-x-auto">
<table className="w-full border-collapse">{children}</table>
</div>
),
th: ({ children }: { children: ReactNode }) => (
<th className="border border-border bg-muted px-4 py-2 text-left font-semibold">
{children}
</th>
),
td: ({ children }: { children: ReactNode }) => (
<td className="border border-border px-4 py-2">{children}</td>
),
// Links
a: ({ href, children }: { href?: string; children: ReactNode }) => (
<a
href={href}
className="text-primary underline underline-offset-4 hover:text-primary/80"
target={href?.startsWith("http") ? "_blank" : undefined}
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
>
{children}
</a>
),
// Horizontal rule
hr: () => <hr className="my-8 border-border" />,
// Image (use next/image in production)
img: ({ src, alt }: { src?: string; alt?: string }) => (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt || ""}
className="my-4 rounded-lg"
/>
),
};

44
src/lib/seo.config.ts Normal file
View file

@ -0,0 +1,44 @@
export const seoConfig = {
siteName: "La Maison Doree",
siteUrl: process.env.NEXT_PUBLIC_SITE_URL || "https://lamaisondoree.be",
defaultTitle: "La Maison Doree - Restaurant Gastronomique",
titleTemplate: "%s | La Maison Doree",
defaultDescription: "Restaurant gastronomique au coeur de Bruxelles. Une cuisine raffinee qui marie tradition et modernite dans un cadre elegant et intimiste.",
defaultOgImage: "/og-image.jpg",
defaultTwitterImage: "/twitter-image.jpg",
twitterHandle: "@lamaisondoree",
organization: {
name: "La Maison Doree",
logo: "/logo.png",
url: "https://lamaisondoree.be",
email: "reservation@lamaisondoree.be",
phone: "+32 2 123 45 67",
address: {
street: "Rue de la Colline 12",
city: "Bruxelles",
region: "Bruxelles-Capitale",
postalCode: "1000",
country: "BE",
},
sameAs: [
"https://instagram.com/lamaisondoree",
"https://facebook.com/lamaisondoree",
"https://tripadvisor.com/lamaisondoree",
],
},
verification: {
google: "",
bing: "",
yandex: "",
},
locale: "fr_BE",
alternateLocales: ["en_US", "nl_BE"],
};
export type SeoConfig = typeof seoConfig;

133
src/lib/seo.ts Normal file
View file

@ -0,0 +1,133 @@
import { Metadata } from "next";
import { seoConfig } from "./seo.config";
// =============================================================================
// SEO UTILITIES
// Helper functions to generate metadata for pages
// =============================================================================
interface PageSeoProps {
title?: string;
description?: string;
image?: string;
url?: string;
type?: "website" | "article";
publishedTime?: string;
modifiedTime?: string;
authors?: string[];
keywords?: string[];
noIndex?: boolean;
noFollow?: boolean;
}
/**
* Generate metadata for a page
* @example
* export const metadata = generateMetadata({
* title: "About Us",
* description: "Learn more about our team",
* });
*/
export function generateMetadata({
title,
description = seoConfig.defaultDescription,
image = seoConfig.defaultOgImage,
url,
type = "website",
publishedTime,
modifiedTime,
authors,
keywords,
noIndex = false,
noFollow = false,
}: PageSeoProps = {}): Metadata {
const finalTitle = title
? seoConfig.titleTemplate.replace("%s", title)
: seoConfig.defaultTitle;
const imageUrl = image.startsWith("http")
? image
: `${seoConfig.siteUrl}${image}`;
const pageUrl = url
? `${seoConfig.siteUrl}${url}`
: seoConfig.siteUrl;
return {
title: finalTitle,
description,
keywords,
authors: authors?.map((name) => ({ name })),
// Robots
robots: {
index: !noIndex,
follow: !noFollow,
googleBot: {
index: !noIndex,
follow: !noFollow,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
// Open Graph
openGraph: {
title: finalTitle,
description,
url: pageUrl,
siteName: seoConfig.siteName,
locale: seoConfig.locale,
type,
images: [
{
url: imageUrl,
width: 1200,
height: 630,
alt: finalTitle,
},
],
...(type === "article" && {
publishedTime,
modifiedTime,
authors,
}),
},
// Twitter
twitter: {
card: "summary_large_image",
title: finalTitle,
description,
images: [imageUrl],
creator: seoConfig.twitterHandle,
site: seoConfig.twitterHandle,
},
// Canonical
alternates: {
canonical: pageUrl,
},
// Verification
verification: {
google: seoConfig.verification.google || undefined,
yandex: seoConfig.verification.yandex || undefined,
other: seoConfig.verification.bing
? { "msvalidate.01": seoConfig.verification.bing }
: undefined,
},
};
}
/**
* Merge page metadata with defaults
* Use in page.tsx for custom metadata
*/
export function mergeMetadata(pageMetadata: Metadata): Metadata {
return {
...generateMetadata(),
...pageMetadata,
};
}

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

Some files were not shown because too many files have changed in this diff Show more