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>
304 lines
8.7 KiB
TypeScript
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">
|
|
“{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>
|
|
);
|
|
}
|