Retour au catalogue
Contact Calendar Embed
Section contact avec selecteur de creneaux horaires style calendrier. L'utilisateur choisit un jour et un horaire pour un rendez-vous.
contactcomplex Both Responsive a11y
corporateelegantminimalsaasagencymedicallegaluniversalsplit
Theme
"use client";
import { useState, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronLeft, ChevronRight, Clock, Calendar, Check } from "lucide-react";
interface ContactCalendarEmbedProps {
title?: string;
subtitle?: string;
description?: string;
timeSlots?: string[];
daysOfWeek?: string[];
confirmLabel?: string;
duration?: string;
}
const E: [number, number, number, number] = [0.16, 1, 0.3, 1];
function getDaysInMonth(year: number, month: number) {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfWeek(year: number, month: number) {
const day = new Date(year, month, 1).getDay();
return day === 0 ? 6 : day - 1;
}
const MONTHS = ["Janvier", "Fevrier", "Mars", "Avril", "Mai", "Juin", "Juillet", "Aout", "Septembre", "Octobre", "Novembre", "Decembre"];
export default function ContactCalendarEmbed({
title = "Reservez un creneau",
subtitle = "RENDEZ-VOUS",
description = "",
timeSlots = [],
daysOfWeek = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"],
confirmLabel = "Confirmer",
duration = "30 min",
}: ContactCalendarEmbedProps) {
const today = useMemo(() => new Date(), []);
const [month, setMonth] = useState(today.getMonth());
const [year, setYear] = useState(today.getFullYear());
const [selectedDay, setSelectedDay] = useState<number | null>(null);
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfWeek(year, month);
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const blanks = Array.from({ length: firstDay }, (_, i) => i);
const prevMonth = () => {
if (month === 0) { setMonth(11); setYear((y) => y - 1); }
else setMonth((m) => m - 1);
setSelectedDay(null);
setSelectedSlot(null);
};
const nextMonth = () => {
if (month === 11) { setMonth(0); setYear((y) => y + 1); }
else setMonth((m) => m + 1);
setSelectedDay(null);
setSelectedSlot(null);
};
const isWeekend = (day: number) => {
const d = new Date(year, month, day).getDay();
return d === 0 || d === 6;
};
return (
<section
style={{
paddingTop: "var(--section-padding-y)",
paddingBottom: "var(--section-padding-y)",
background: "var(--color-background)",
}}
>
<div
className="mx-auto"
style={{
maxWidth: "var(--container-max-width)",
paddingLeft: "var(--container-padding-x)",
paddingRight: "var(--container-padding-x)",
}}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, ease: E }}
className="text-center mb-12"
>
<p className="text-xs font-semibold uppercase tracking-widest mb-2" style={{ color: "var(--color-accent)" }}>{subtitle}</p>
<h2 className="text-3xl md:text-4xl font-bold tracking-tight" style={{ color: "var(--color-foreground)" }}>{title}</h2>
{description && <p className="mt-3 text-sm" style={{ color: "var(--color-foreground-muted)" }}>{description}</p>}
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6 max-w-4xl mx-auto">
{/* Calendar */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, ease: E }}
className="lg:col-span-3 rounded-xl p-6"
style={{
background: "var(--color-background-alt)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-xl)",
}}
>
<div className="flex items-center justify-between mb-5">
<button onClick={prevMonth} className="p-1.5 rounded-md transition-opacity hover:opacity-70" style={{ color: "var(--color-foreground)" }}>
<ChevronLeft size={18} />
</button>
<span className="text-sm font-semibold" style={{ color: "var(--color-foreground)" }}>
{MONTHS[month]} {year}
</span>
<button onClick={nextMonth} className="p-1.5 rounded-md transition-opacity hover:opacity-70" style={{ color: "var(--color-foreground)" }}>
<ChevronRight size={18} />
</button>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{daysOfWeek.map((d) => (
<div key={d} className="text-center text-xs font-medium py-1" style={{ color: "var(--color-foreground-muted)" }}>{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{blanks.map((b) => <div key={`b-${b}`} />)}
{days.map((day) => {
const weekend = isWeekend(day);
const selected = selectedDay === day;
return (
<button
key={day}
onClick={() => { if (!weekend) { setSelectedDay(day); setSelectedSlot(null); } }}
disabled={weekend}
className="aspect-square flex items-center justify-center text-sm rounded-lg transition-all disabled:opacity-30"
style={{
background: selected ? "var(--color-accent)" : "transparent",
color: selected ? "var(--color-background)" : "var(--color-foreground)",
fontWeight: selected ? 600 : 400,
}}
>
{day}
</button>
);
})}
</div>
</motion.div>
{/* Time slots */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1, ease: E }}
className="lg:col-span-2 rounded-xl p-6"
style={{
background: "var(--color-background-alt)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-xl)",
}}
>
<div className="flex items-center gap-2 mb-4">
<Clock size={14} style={{ color: "var(--color-accent)" }} />
<span className="text-sm font-semibold" style={{ color: "var(--color-foreground)" }}>
{selectedDay ? `${selectedDay} ${MONTHS[month]}` : "Choisissez un jour"}
</span>
<span className="text-xs ml-auto" style={{ color: "var(--color-foreground-muted)" }}>{duration}</span>
</div>
<AnimatePresence mode="wait">
{selectedDay ? (
<motion.div
key={selectedDay}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid grid-cols-2 gap-2"
>
{timeSlots.map((slot) => (
<button
key={slot}
onClick={() => setSelectedSlot(slot)}
className="px-3 py-2 rounded-md text-sm font-medium transition-all"
style={{
background: selectedSlot === slot ? "var(--color-accent)" : "var(--color-background)",
color: selectedSlot === slot ? "var(--color-background)" : "var(--color-foreground)",
border: `1px solid ${selectedSlot === slot ? "var(--color-accent)" : "var(--color-border)"}`,
borderRadius: "var(--radius-sm)",
}}
>
{slot}
</button>
))}
</motion.div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center py-8"
>
<Calendar size={32} style={{ color: "var(--color-border)" }} />
<p className="mt-3 text-sm text-center" style={{ color: "var(--color-foreground-muted)" }}>
Selectionnez un jour dans le calendrier
</p>
</motion.div>
)}
</AnimatePresence>
{selectedSlot && (
<motion.button
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: E }}
className="w-full mt-4 inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-semibold transition-opacity hover:opacity-90"
style={{
background: "var(--color-accent)",
color: "var(--color-background)",
borderRadius: "var(--radius-md)",
}}
>
<Check size={14} /> {confirmLabel}
</motion.button>
)}
</motion.div>
</div>
</div>
</section>
);
}