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:
commit
c5165a407a
101 changed files with 25803 additions and 0 deletions
89
.claude/agents/architect.md
Normal file
89
.claude/agents/architect.md
Normal 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
45
.claude/agents/backend.md
Normal 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
109
.claude/agents/database.md
Normal 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
|
||||||
57
.claude/agents/debugger.md
Normal file
57
.claude/agents/debugger.md
Normal 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
50
.claude/agents/figma.md
Normal 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
|
||||||
65
.claude/agents/frontend.md
Normal file
65
.claude/agents/frontend.md
Normal 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
|
||||||
73
.claude/agents/performance.md
Normal file
73
.claude/agents/performance.md
Normal 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
|
||||||
62
.claude/agents/refactor.md
Normal file
62
.claude/agents/refactor.md
Normal 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
|
||||||
79
.claude/agents/reviewer.md
Normal file
79
.claude/agents/reviewer.md
Normal 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
|
||||||
63
.claude/agents/security.md
Normal file
63
.claude/agents/security.md
Normal 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
|
||||||
46
.claude/agents/ui-designer.md
Normal file
46
.claude/agents/ui-designer.md
Normal 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
41
.gitignore
vendored
Normal 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
125
README.md
Normal 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
23
components.json
Normal 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
18
eslint.config.mjs
Normal 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
10
mdx-components.tsx
Normal 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
101
next.config.ts
Normal 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
13593
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
39
package.json
Normal file
39
package.json
Normal 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
7
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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
140
src/app/carte/page.tsx
Normal 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'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}€
|
||||||
|
</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'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
265
src/app/contact/page.tsx
Normal 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
46
src/app/error.tsx
Normal 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'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
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
196
src/app/galerie/page.tsx
Normal 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'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
178
src/app/globals.css
Normal 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
185
src/app/histoire/page.tsx
Normal 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'histoire d'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'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
32
src/app/layout.tsx
Normal 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
10
src/app/loading.tsx
Normal 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
21
src/app/not-found.tsx
Normal 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'existe pas ou a été 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'accueil
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
448
src/app/page.tsx
Normal file
448
src/app/page.tsx
Normal 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'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}€
|
||||||
|
</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'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">
|
||||||
|
“{t.quote}”
|
||||||
|
</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'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'affaires ou une celebration,
|
||||||
|
notre equipe vous accueille dans un cadre d'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
27
src/app/robots.ts
Normal 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
43
src/app/sitemap.ts
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
52
src/components/animations/AnimatePresenceWrapper.tsx
Normal file
52
src/components/animations/AnimatePresenceWrapper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/components/animations/CountUp.tsx
Normal file
110
src/components/animations/CountUp.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
src/components/animations/DrawSVG.tsx
Normal file
198
src/components/animations/DrawSVG.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/components/animations/FadeIn.tsx
Normal file
44
src/components/animations/FadeIn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/components/animations/HoverEffects.tsx
Normal file
148
src/components/animations/HoverEffects.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/components/animations/MorphingText.tsx
Normal file
161
src/components/animations/MorphingText.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/components/animations/PageTransition.tsx
Normal file
108
src/components/animations/PageTransition.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/animations/ParallaxScroll.tsx
Normal file
76
src/components/animations/ParallaxScroll.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/animations/ScaleIn.tsx
Normal file
34
src/components/animations/ScaleIn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/animations/ScrollReveal.tsx
Normal file
74
src/components/animations/ScrollReveal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/animations/SlideIn.tsx
Normal file
43
src/components/animations/SlideIn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/animations/StaggerChildren.tsx
Normal file
74
src/components/animations/StaggerChildren.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/components/animations/TextReveal.tsx
Normal file
136
src/components/animations/TextReveal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/animations/index.ts
Normal file
32
src/components/animations/index.ts
Normal 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";
|
||||||
168
src/components/blocks/Accordion.tsx
Normal file
168
src/components/blocks/Accordion.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
463
src/components/blocks/Blog.tsx
Normal file
463
src/components/blocks/Blog.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/components/blocks/Breadcrumbs.tsx
Normal file
201
src/components/blocks/Breadcrumbs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/blocks/CTABanner.tsx
Normal file
74
src/components/blocks/CTABanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
403
src/components/blocks/Cards.tsx
Normal file
403
src/components/blocks/Cards.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
src/components/blocks/Carousel.tsx
Normal file
304
src/components/blocks/Carousel.tsx
Normal 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">
|
||||||
|
“{testimonials[current].content}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
369
src/components/blocks/Comments.tsx
Normal file
369
src/components/blocks/Comments.tsx
Normal 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'instant.</p>
|
||||||
|
<p className="text-sm mt-1">Soyez le premier à commenter !</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/blocks/FeaturesGrid.tsx
Normal file
76
src/components/blocks/FeaturesGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/blocks/Footer.tsx
Normal file
116
src/components/blocks/Footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
src/components/blocks/Gallery.tsx
Normal file
344
src/components/blocks/Gallery.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/components/blocks/HeroSimple.tsx
Normal file
93
src/components/blocks/HeroSimple.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
299
src/components/blocks/LogoCloud.tsx
Normal file
299
src/components/blocks/LogoCloud.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
328
src/components/blocks/Map.tsx
Normal file
328
src/components/blocks/Map.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
src/components/blocks/Newsletter.tsx
Normal file
331
src/components/blocks/Newsletter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
src/components/blocks/Pagination.tsx
Normal file
270
src/components/blocks/Pagination.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
358
src/components/blocks/Pricing.tsx
Normal file
358
src/components/blocks/Pricing.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
src/components/blocks/ProgressBar.tsx
Normal file
320
src/components/blocks/ProgressBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
419
src/components/blocks/Search.tsx
Normal file
419
src/components/blocks/Search.tsx
Normal 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 “{query}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
src/components/blocks/Tabs.tsx
Normal file
215
src/components/blocks/Tabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
src/components/blocks/Team.tsx
Normal file
326
src/components/blocks/Team.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/blocks/Testimonials.tsx
Normal file
98
src/components/blocks/Testimonials.tsx
Normal 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">
|
||||||
|
“{testimonial.quote}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
src/components/blocks/Timeline.tsx
Normal file
217
src/components/blocks/Timeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
328
src/components/blocks/Video.tsx
Normal file
328
src/components/blocks/Video.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/components/blocks/index.ts
Normal file
68
src/components/blocks/index.ts
Normal 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";
|
||||||
139
src/components/layout/Header.tsx
Normal file
139
src/components/layout/Header.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/components/layout/RestaurantFooter.tsx
Normal file
134
src/components/layout/RestaurantFooter.tsx
Normal 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">
|
||||||
|
© {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/providers/ThemeProvider.tsx
Normal file
21
src/components/providers/ThemeProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/providers/index.tsx
Normal file
28
src/components/providers/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/seo/JsonLd.tsx
Normal file
36
src/components/seo/JsonLd.tsx
Normal 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: "...", ... })} />
|
||||||
98
src/components/ui/Icon.tsx
Normal file
98
src/components/ui/Icon.tsx
Normal 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" />
|
||||||
95
src/components/ui/Skeleton.tsx
Normal file
95
src/components/ui/Skeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
src/components/ui/ThemeToggle.tsx
Normal file
163
src/components/ui/ThemeToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/ui/Toast.tsx
Normal file
74
src/components/ui/Toast.tsx
Normal 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);
|
||||||
66
src/components/ui/accordion.tsx
Normal file
66
src/components/ui/accordion.tsx
Normal 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 }
|
||||||
48
src/components/ui/badge.tsx
Normal file
48
src/components/ui/badge.tsx
Normal 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 }
|
||||||
64
src/components/ui/button.tsx
Normal file
64
src/components/ui/button.tsx
Normal 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 }
|
||||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||||
|
}
|
||||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal 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 }
|
||||||
91
src/components/ui/tabs.tsx
Normal file
91
src/components/ui/tabs.tsx
Normal 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 }
|
||||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal 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
237
src/lib/data/restaurant.ts
Normal 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
18
src/lib/fonts.ts
Normal 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
247
src/lib/jsonld.ts
Normal 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
99
src/lib/mdx.tsx
Normal 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
44
src/lib/seo.config.ts
Normal 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
133
src/lib/seo.ts
Normal 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
6
src/lib/utils.ts
Normal 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
Loading…
Reference in a new issue