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>
  );
}

Avis

Contact Calendar Embed — React Contact Section — Incubator