resto-demo/src/components/blocks/Carousel.tsx
thewebmasterpro c5165a407a 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>
2026-03-04 23:34:21 +01:00

304 lines
8.7 KiB
TypeScript

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