807 lines
30 KiB
TypeScript
807 lines
30 KiB
TypeScript
import { useSession } from "@xtablo/shared/contexts/SessionContext";
|
|
import { useSignUpWithoutPassword } from "@xtablo/shared/hooks/auth";
|
|
import { useBookSlot } from "@xtablo/shared/hooks/book";
|
|
import { TimeSlot, usePublicSlots } from "@xtablo/shared/hooks/public";
|
|
import { Button } from "@xtablo/ui/components/button";
|
|
import { FieldError } from "@xtablo/ui/components/field";
|
|
import { Input } from "@xtablo/ui/components/input";
|
|
import { Label } from "@xtablo/ui/components/label";
|
|
import {
|
|
Text,
|
|
TypographyH3,
|
|
TypographyH4,
|
|
TypographyMuted,
|
|
} from "@xtablo/ui/components/typography";
|
|
import {
|
|
CalendarIcon,
|
|
ChevronLeftIcon,
|
|
ChevronRightIcon,
|
|
ClockIcon,
|
|
MapPinIcon,
|
|
UserIcon,
|
|
} from "lucide-react";
|
|
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useParams, useSearchParams } from "react-router-dom";
|
|
import { twMerge } from "tailwind-merge";
|
|
import { CustomModal } from "./CustomModal";
|
|
import { api } from "./lib/api";
|
|
import { supabase } from "./lib/supabase";
|
|
import { useMaybeUser } from "./UserStoreProvider";
|
|
|
|
type ColorVariant = "black" | "white" | "blue" | "purple" | "green" | "orange" | "red";
|
|
|
|
// Color scheme configurations
|
|
const backgroundColors = {
|
|
black: {
|
|
gradient: "from-gray-900 via-gray-800 to-gray-900",
|
|
overlay: "from-gray-600/5 via-transparent to-gray-600/10",
|
|
iconBg: "bg-gray-700/50",
|
|
iconBorder: "border-gray-500/20",
|
|
iconText: "text-gray-400",
|
|
borderColor: "border-gray-700/50",
|
|
linkColor: "text-gray-400/60",
|
|
avatarBorder: "border-gray-500/30",
|
|
},
|
|
white: {
|
|
gradient: "from-gray-50 via-white to-gray-50",
|
|
overlay: "from-gray-200/5 via-transparent to-gray-200/10",
|
|
iconBg: "bg-gray-100/50",
|
|
iconBorder: "border-gray-300/20",
|
|
iconText: "text-gray-600",
|
|
borderColor: "border-gray-200/50",
|
|
linkColor: "text-gray-500/60",
|
|
avatarBorder: "border-gray-300/30",
|
|
},
|
|
blue: {
|
|
gradient: "from-blue-900 via-blue-800 to-blue-900",
|
|
overlay: "from-blue-600/5 via-transparent to-blue-600/10",
|
|
iconBg: "bg-blue-700/50",
|
|
iconBorder: "border-blue-500/20",
|
|
iconText: "text-blue-400",
|
|
borderColor: "border-blue-700/50",
|
|
linkColor: "text-blue-400/60",
|
|
avatarBorder: "border-blue-500/30",
|
|
},
|
|
purple: {
|
|
gradient: "from-gray-900 via-gray-800 to-gray-900",
|
|
overlay: "from-purple-600/5 via-transparent to-purple-600/10",
|
|
iconBg: "bg-gray-700/50",
|
|
iconBorder: "border-purple-500/20",
|
|
iconText: "text-purple-400",
|
|
borderColor: "border-gray-700/50",
|
|
linkColor: "text-purple-400/60",
|
|
avatarBorder: "border-purple-500/30",
|
|
},
|
|
green: {
|
|
gradient: "from-green-900 via-green-800 to-green-900",
|
|
overlay: "from-green-600/5 via-transparent to-green-600/10",
|
|
iconBg: "bg-green-700/50",
|
|
iconBorder: "border-green-500/20",
|
|
iconText: "text-green-400",
|
|
borderColor: "border-green-700/50",
|
|
linkColor: "text-green-400/60",
|
|
avatarBorder: "border-green-500/30",
|
|
},
|
|
orange: {
|
|
gradient: "from-orange-900 via-orange-800 to-orange-900",
|
|
overlay: "from-orange-600/5 via-transparent to-orange-600/10",
|
|
iconBg: "bg-orange-700/50",
|
|
iconBorder: "border-orange-500/20",
|
|
iconText: "text-orange-400",
|
|
borderColor: "border-orange-700/50",
|
|
linkColor: "text-orange-400/60",
|
|
avatarBorder: "border-orange-500/30",
|
|
},
|
|
red: {
|
|
gradient: "from-red-900 via-red-800 to-red-900",
|
|
overlay: "from-red-600/5 via-transparent to-red-600/10",
|
|
iconBg: "bg-red-700/50",
|
|
iconBorder: "border-red-500/20",
|
|
iconText: "text-red-400",
|
|
borderColor: "border-red-700/50",
|
|
linkColor: "text-red-400/60",
|
|
avatarBorder: "border-red-500/30",
|
|
},
|
|
};
|
|
|
|
const buttonColors = {
|
|
black: {
|
|
selected: "bg-gray-900 dark:bg-white text-white dark:text-gray-900",
|
|
ring: "ring-gray-500/50",
|
|
todayBorder: "border-gray-500/30",
|
|
hoverBorder: "hover:border-gray-500/50",
|
|
slotHover:
|
|
"hover:bg-gray-900 dark:hover:bg-white hover:text-white dark:hover:text-gray-900 hover:border-gray-500/50",
|
|
modalBorder: "border-gray-500/20",
|
|
modalIcon: "text-gray-600 dark:text-gray-400",
|
|
},
|
|
white: {
|
|
selected: "bg-white dark:bg-gray-100 text-gray-900 dark:text-gray-900",
|
|
ring: "ring-gray-300/50",
|
|
todayBorder: "border-gray-300/30",
|
|
hoverBorder: "hover:border-gray-300/50",
|
|
slotHover:
|
|
"hover:bg-white dark:hover:bg-gray-100 hover:text-gray-900 dark:hover:text-gray-900 hover:border-gray-300/50",
|
|
modalBorder: "border-gray-300/20",
|
|
modalIcon: "text-gray-600 dark:text-gray-500",
|
|
},
|
|
blue: {
|
|
selected: "bg-blue-600 dark:bg-blue-500 text-white dark:text-white",
|
|
ring: "ring-blue-500/50",
|
|
todayBorder: "border-blue-500/30",
|
|
hoverBorder: "hover:border-blue-500/50",
|
|
slotHover:
|
|
"hover:bg-blue-600 dark:hover:bg-blue-500 hover:text-white dark:hover:text-white hover:border-blue-500/50",
|
|
modalBorder: "border-blue-500/20",
|
|
modalIcon: "text-blue-600 dark:text-blue-400",
|
|
},
|
|
purple: {
|
|
selected: "bg-purple-600 dark:bg-purple-500 text-white dark:text-white",
|
|
ring: "ring-purple-500/50",
|
|
todayBorder: "border-purple-500/30",
|
|
hoverBorder: "hover:border-purple-500/50",
|
|
slotHover:
|
|
"hover:bg-purple-600 dark:hover:bg-purple-500 hover:text-white dark:hover:text-white hover:border-purple-500/50",
|
|
modalBorder: "border-purple-500/20",
|
|
modalIcon: "text-purple-600 dark:text-purple-400",
|
|
},
|
|
green: {
|
|
selected: "bg-green-600 dark:bg-green-500 text-white dark:text-white",
|
|
ring: "ring-green-500/50",
|
|
todayBorder: "border-green-500/30",
|
|
hoverBorder: "hover:border-green-500/50",
|
|
slotHover:
|
|
"hover:bg-green-600 dark:hover:bg-green-500 hover:text-white dark:hover:text-white hover:border-green-500/50",
|
|
modalBorder: "border-green-500/20",
|
|
modalIcon: "text-green-600 dark:text-green-400",
|
|
},
|
|
orange: {
|
|
selected: "bg-orange-600 dark:bg-orange-500 text-white dark:text-white",
|
|
ring: "ring-orange-500/50",
|
|
todayBorder: "border-orange-500/30",
|
|
hoverBorder: "hover:border-orange-500/50",
|
|
slotHover:
|
|
"hover:bg-orange-600 dark:hover:bg-orange-500 hover:text-white dark:hover:text-white hover:border-orange-500/50",
|
|
modalBorder: "border-orange-500/20",
|
|
modalIcon: "text-orange-600 dark:text-orange-400",
|
|
},
|
|
red: {
|
|
selected: "bg-red-600 dark:bg-red-500 text-white dark:text-white",
|
|
ring: "ring-red-500/50",
|
|
todayBorder: "border-red-500/30",
|
|
hoverBorder: "hover:border-red-500/50",
|
|
slotHover:
|
|
"hover:bg-red-600 dark:hover:bg-red-500 hover:text-white dark:hover:text-white hover:border-red-500/50",
|
|
modalBorder: "border-red-500/20",
|
|
modalIcon: "text-red-600 dark:text-red-400",
|
|
},
|
|
};
|
|
|
|
// Automatically infer text color based on background luminance
|
|
const getTextColorFromBackground = (variant: ColorVariant): string => {
|
|
// Dark backgrounds need light text, light backgrounds need dark text
|
|
const darkBackgrounds = ["black", "blue", "purple", "green", "orange", "red"];
|
|
return darkBackgrounds.includes(variant) ? "text-white" : "text-gray-900";
|
|
};
|
|
|
|
// Automatically infer muted text color based on background luminance
|
|
const getMutedTextColorFromBackground = (variant: ColorVariant): string => {
|
|
// Dark backgrounds need lighter muted text, light backgrounds need darker muted text
|
|
const darkBackgrounds = ["black", "blue", "purple", "green", "orange", "red"];
|
|
return darkBackgrounds.includes(variant) ? "text-gray-400" : "text-gray-600";
|
|
};
|
|
|
|
export function EmbeddedBookingPage() {
|
|
const { t } = useTranslation("booking");
|
|
const params = useParams();
|
|
const [searchParams] = useSearchParams();
|
|
const { mutateAsync: signUpWithoutPassword } = useSignUpWithoutPassword(supabase, api);
|
|
const { session } = useSession();
|
|
const user = useMaybeUser();
|
|
|
|
const userInfo = params.userInfo as string;
|
|
const eventTypeStandardName = params.eventTypeStandardName as string;
|
|
// Get variants from URL params or props, with fallback to purple
|
|
const backgroundVariant = (searchParams.get("backgroundVariant") as ColorVariant) || "black";
|
|
const buttonVariant = (searchParams.get("buttonVariant") as ColorVariant) || "purple";
|
|
|
|
// Get color schemes based on variants
|
|
const bgColors = backgroundColors[backgroundVariant];
|
|
const btnColors = buttonColors[buttonVariant];
|
|
const txtColor = getTextColorFromBackground(backgroundVariant);
|
|
const mutedTxtColor = getMutedTextColorFromBackground(backgroundVariant);
|
|
|
|
const shortUserId = userInfo?.substring(userInfo.lastIndexOf("-") + 1);
|
|
|
|
const { data: publicSlots } = usePublicSlots(api, shortUserId || "", eventTypeStandardName || "");
|
|
|
|
const { mutateAsync: bookSlot } = useBookSlot(api, session?.access_token, () => {
|
|
handleCloseModal();
|
|
});
|
|
|
|
const userProfile = publicSlots?.user;
|
|
const eventType = publicSlots?.eventType;
|
|
const slotsData = publicSlots?.slots || {};
|
|
|
|
// Calendar state
|
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
|
|
|
// Modal state
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [selectedSlot, setSelectedSlot] = useState<{
|
|
date: Date;
|
|
slot: TimeSlot;
|
|
} | null>(null);
|
|
const [formData, setFormData] = useState({
|
|
email: "",
|
|
name: "",
|
|
});
|
|
const [formErrors, setFormErrors] = useState({
|
|
email: "",
|
|
name: "",
|
|
});
|
|
|
|
// Helper function to convert date to CET timezone string (YYYY-MM-DD)
|
|
const formatDateToCET = (date: Date): string => {
|
|
return date.toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
|
};
|
|
|
|
// Helper function to get current date in CET timezone
|
|
const getCurrentDateInCET = (): Date => {
|
|
const now = new Date();
|
|
const cetTime = new Date(now.toLocaleString("en-US", { timeZone: "Europe/Paris" }));
|
|
return cetTime;
|
|
};
|
|
|
|
// Get available time slots for a specific date
|
|
const getAvailableSlots = (date: Date): TimeSlot[] => {
|
|
const dateStr = formatDateToCET(date);
|
|
return slotsData[dateStr]?.filter((slot) => slot.available) || [];
|
|
};
|
|
|
|
// Check if a date has any available slots
|
|
const hasAvailableSlots = (date: Date): boolean => {
|
|
const dateStr = formatDateToCET(date);
|
|
return slotsData[dateStr]?.some((slot) => slot.available) || false;
|
|
};
|
|
|
|
// Calendar helper functions
|
|
const getDaysInMonth = (date: Date) => {
|
|
const year = date.getFullYear();
|
|
const month = date.getMonth();
|
|
|
|
// Create first day of month and get its day of week in CET
|
|
const firstDayStr = `${year}-${String(month + 1).padStart(2, "0")}-01`;
|
|
const firstDay = new Date(`${firstDayStr}T12:00:00`);
|
|
const firstDayOfWeekInCET = new Date(
|
|
firstDay.toLocaleString("en-US", { timeZone: "Europe/Paris" })
|
|
).getDay();
|
|
|
|
// Adjust for Monday as first day of week
|
|
const mondayStartingDay = firstDayOfWeekInCET === 0 ? 6 : firstDayOfWeekInCET - 1;
|
|
|
|
// Get number of days in month
|
|
const lastDay = new Date(year, month + 1, 0);
|
|
const daysInMonth = lastDay.getDate();
|
|
|
|
const days = [];
|
|
|
|
// Add empty cells for days before the first day of the month
|
|
for (let i = 0; i < mondayStartingDay; i++) {
|
|
days.push(null);
|
|
}
|
|
|
|
// Add all days of the month
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const dayStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(
|
|
2,
|
|
"0"
|
|
)}`;
|
|
days.push(new Date(`${dayStr}T12:00:00`));
|
|
}
|
|
|
|
return days;
|
|
};
|
|
|
|
const navigateMonth = (direction: "prev" | "next") => {
|
|
setCurrentDate((prev) => {
|
|
const newDate = new Date(prev);
|
|
if (direction === "prev") {
|
|
newDate.setMonth(prev.getMonth() - 1);
|
|
} else {
|
|
newDate.setMonth(prev.getMonth() + 1);
|
|
}
|
|
return newDate;
|
|
});
|
|
};
|
|
|
|
const isToday = (date: Date) => {
|
|
const todayInCET = getCurrentDateInCET();
|
|
const todayStr = formatDateToCET(todayInCET);
|
|
const dateStr = formatDateToCET(date);
|
|
return dateStr === todayStr;
|
|
};
|
|
|
|
const isPastDate = (date: Date) => {
|
|
const todayInCET = getCurrentDateInCET();
|
|
const todayStr = formatDateToCET(todayInCET);
|
|
const dateStr = formatDateToCET(date);
|
|
return dateStr < todayStr;
|
|
};
|
|
|
|
const formatMonthYear = (date: Date) => {
|
|
return date.toLocaleDateString("fr-FR", { month: "long", year: "numeric" });
|
|
};
|
|
|
|
const formatDuration = (minutes: number) => {
|
|
if (minutes < 60) {
|
|
return `${minutes} min`;
|
|
}
|
|
const hours = Math.floor(minutes / 60);
|
|
const remainingMinutes = minutes % 60;
|
|
if (remainingMinutes === 0) {
|
|
return `${hours}h`;
|
|
}
|
|
return `${hours}h ${remainingMinutes}min`;
|
|
};
|
|
|
|
// Modal and form handlers
|
|
const handleSlotClick = (date: Date, slot: TimeSlot) => {
|
|
setSelectedSlot({ date, slot });
|
|
setIsModalOpen(true);
|
|
setFormData({ email: "", name: "" });
|
|
setFormErrors({ email: "", name: "" });
|
|
};
|
|
|
|
const handleCloseModal = () => {
|
|
setIsModalOpen(false);
|
|
setSelectedSlot(null);
|
|
setFormData({ email: "", name: "" });
|
|
setFormErrors({ email: "", name: "" });
|
|
};
|
|
|
|
const validateForm = () => {
|
|
const errors = { email: "", name: "" };
|
|
let isValid = true;
|
|
|
|
if (!formData.email.trim()) {
|
|
errors.email = t("validation.emailRequired");
|
|
isValid = false;
|
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
|
errors.email = t("validation.invalidEmail");
|
|
isValid = false;
|
|
}
|
|
|
|
if (!formData.name.trim()) {
|
|
errors.name = t("validation.nameRequired");
|
|
isValid = false;
|
|
}
|
|
|
|
setFormErrors(errors);
|
|
return isValid;
|
|
};
|
|
|
|
// Calculate end time based on start time and duration
|
|
const calculateEndTime = (startTime: string, durationMinutes: number): string => {
|
|
if (!startTime) return "";
|
|
|
|
const [hours, minutes] = startTime.split(":").map(Number);
|
|
const startDate = new Date();
|
|
startDate.setHours(hours, minutes, 0, 0);
|
|
|
|
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
|
|
|
return endDate.toTimeString().slice(0, 5); // Format as HH:MM
|
|
};
|
|
|
|
const handleSubmitIfNotLoggedIn = async () => {
|
|
if (validateForm()) {
|
|
await signUpWithoutPassword({
|
|
email: formData.email,
|
|
name: formData.name,
|
|
});
|
|
|
|
const startTime = selectedSlot?.slot.time || "";
|
|
const duration = eventType?.duration || 60; // duration in minutes
|
|
const endTime = calculateEndTime(startTime, duration);
|
|
|
|
await bookSlot({
|
|
event_type_standard_name: eventTypeStandardName || "",
|
|
owner_short_id: shortUserId || "",
|
|
event_details: {
|
|
start_date: selectedSlot?.slot.date || "",
|
|
start_time: startTime,
|
|
end_time: endTime,
|
|
},
|
|
user_details: {
|
|
name: formData.name,
|
|
email: formData.email,
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSubmitIfLoggedIn = async () => {
|
|
if (user) {
|
|
const startTime = selectedSlot?.slot.time || "";
|
|
const duration = eventType?.duration || 60; // duration in minutes
|
|
const endTime = calculateEndTime(startTime, duration);
|
|
|
|
await bookSlot({
|
|
event_type_standard_name: eventTypeStandardName || "",
|
|
owner_short_id: shortUserId || "",
|
|
event_details: {
|
|
start_date: selectedSlot?.slot.date || "",
|
|
start_time: startTime,
|
|
end_time: endTime,
|
|
},
|
|
user_details: {
|
|
name: user.name || "",
|
|
email: user.email || "",
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="w-[1130px] h-[700px] bg-transparent overflow-hidden">
|
|
<div className="h-full bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 flex overflow-hidden">
|
|
{/* Left Side - Event Details */}
|
|
<div
|
|
className={twMerge(
|
|
"w-[400px] bg-linear-to-br p-8 flex flex-col relative overflow-hidden",
|
|
bgColors.gradient,
|
|
txtColor
|
|
)}
|
|
>
|
|
{/* Subtle accent overlay */}
|
|
<div
|
|
className={twMerge(
|
|
"absolute inset-0 bg-linear-to-br pointer-events-none",
|
|
bgColors.overlay
|
|
)}
|
|
></div>
|
|
<div className="relative z-10 flex flex-col h-full">
|
|
{/* User Profile */}
|
|
<div className="mb-8">
|
|
{(userProfile as { name: string; avatar_url?: string })?.avatar_url ? (
|
|
<img
|
|
src={(userProfile as { name: string; avatar_url?: string }).avatar_url}
|
|
alt={userProfile?.name || "Profile"}
|
|
className={twMerge(
|
|
"w-20 h-20 rounded-full object-cover border-4 mb-4",
|
|
bgColors.avatarBorder
|
|
)}
|
|
/>
|
|
) : (
|
|
<div
|
|
className={twMerge(
|
|
"w-20 h-20 rounded-full bg-gray-700 flex items-center justify-center border-4 mb-4",
|
|
bgColors.avatarBorder
|
|
)}
|
|
>
|
|
<UserIcon className="w-10 h-10 text-gray-300" />
|
|
</div>
|
|
)}
|
|
<h2 className="text-2xl font-bold mb-1">
|
|
{userProfile?.name || t("hero.professional")}
|
|
</h2>
|
|
</div>
|
|
|
|
{/* Event Type Info */}
|
|
<div className="flex-1">
|
|
<h3 className="text-xl font-bold mb-3">{eventType?.name || "Type d'appel"}</h3>
|
|
|
|
{eventType?.description && (
|
|
<TypographyMuted className={twMerge("mb-6 text-sm leading-relaxed", mutedTxtColor)}>
|
|
{eventType.description}
|
|
</TypographyMuted>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
{eventType?.duration && (
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className={twMerge(
|
|
"w-10 h-10 rounded-lg border flex items-center justify-center shrink-0",
|
|
bgColors.iconBg,
|
|
bgColors.iconBorder
|
|
)}
|
|
>
|
|
<ClockIcon className={twMerge("w-5 h-5", bgColors.iconText)} />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-400">{t("modal.duration")}</p>
|
|
<p className="font-semibold">{formatDuration(eventType.duration)}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{eventType?.price && (
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className={twMerge(
|
|
"w-10 h-10 rounded-lg border flex items-center justify-center shrink-0",
|
|
bgColors.iconBg,
|
|
bgColors.iconBorder
|
|
)}
|
|
>
|
|
<span className={twMerge("text-xl font-bold", bgColors.iconText)}>€</span>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-400">{t("modal.price")}</p>
|
|
<p className="font-semibold">{eventType.price}€</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{eventType?.location && (
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className={twMerge(
|
|
"w-10 h-10 rounded-lg border flex items-center justify-center shrink-0",
|
|
bgColors.iconBg,
|
|
bgColors.iconBorder
|
|
)}
|
|
>
|
|
<MapPinIcon className={twMerge("w-5 h-5", bgColors.iconText)} />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-400">{t("modal.location")}</p>
|
|
<p className="font-semibold text-sm">{eventType.location}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className={twMerge("mt-auto pt-6 border-t", bgColors.borderColor)}>
|
|
{/* Logo */}
|
|
<div className="mb-4">
|
|
<img src="/logo_white.png" alt="Xtablo" className="h-8 w-auto" />
|
|
</div>
|
|
<TypographyMuted className="text-xs text-gray-500">
|
|
{t("hero.poweredBy")}{" "}
|
|
<a
|
|
href="https://www.xtablo.com"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={twMerge("hover:underline", bgColors.linkColor)}
|
|
>
|
|
XTablo
|
|
</a>
|
|
</TypographyMuted>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Side - Calendar & Booking */}
|
|
<div className="flex-1 flex flex-col p-6">
|
|
{/* Calendar Section */}
|
|
<div className="flex-1 flex gap-4">
|
|
{/* Calendar */}
|
|
<div className="flex-1">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<TypographyH3 className="font-semibold text-gray-900 dark:text-white capitalize">
|
|
{formatMonthYear(currentDate)}
|
|
</TypographyH3>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => navigateMonth("prev")}
|
|
className="h-8 w-8 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
>
|
|
<ChevronLeftIcon className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => navigateMonth("next")}
|
|
className="h-8 w-8 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
>
|
|
<ChevronRightIcon className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Calendar Grid */}
|
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
|
{[
|
|
t("days.monday"),
|
|
t("days.tuesday"),
|
|
t("days.wednesday"),
|
|
t("days.thursday"),
|
|
t("days.friday"),
|
|
t("days.saturday"),
|
|
t("days.sunday"),
|
|
].map((day, i) => (
|
|
<div
|
|
key={i}
|
|
className="p-1 text-center text-xs font-medium text-gray-500 dark:text-gray-400"
|
|
>
|
|
{day}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{getDaysInMonth(currentDate).map((date, index) => (
|
|
<div key={index} className="aspect-square">
|
|
{date ? (
|
|
<button
|
|
onClick={() =>
|
|
!isPastDate(date) && hasAvailableSlots(date) && setSelectedDate(date)
|
|
}
|
|
disabled={isPastDate(date) || !hasAvailableSlots(date)}
|
|
className={twMerge(
|
|
"w-full h-full flex items-center justify-center text-sm rounded-lg transition-colors",
|
|
isPastDate(date)
|
|
? "text-gray-300 dark:text-gray-600 cursor-not-allowed"
|
|
: selectedDate?.toDateString() === date.toDateString()
|
|
? `${btnColors.selected} font-semibold shadow-md ring-2 ${btnColors.ring}`
|
|
: isToday(date)
|
|
? `bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold border ${btnColors.todayBorder}`
|
|
: hasAvailableSlots(date)
|
|
? `text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 ${btnColors.hoverBorder} border border-gray-200 dark:border-gray-600`
|
|
: "text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
|
)}
|
|
>
|
|
{date.getDate()}
|
|
</button>
|
|
) : (
|
|
<div></div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Time Slots */}
|
|
<div className="w-56 border-l border-gray-200 dark:border-gray-700 pl-4">
|
|
<TypographyH4 className="font-semibold text-gray-900 dark:text-white mb-3">
|
|
{selectedDate ? (
|
|
<>
|
|
{t("timeSlots.title")}
|
|
<br />
|
|
<TypographyMuted className="text-base font-normal text-gray-500 dark:text-gray-400">
|
|
{selectedDate.toLocaleDateString("fr-FR", {
|
|
weekday: "long",
|
|
day: "numeric",
|
|
month: "long",
|
|
year: "numeric",
|
|
})}
|
|
</TypographyMuted>
|
|
</>
|
|
) : (
|
|
t("calendar.title")
|
|
)}
|
|
</TypographyH4>
|
|
|
|
{selectedDate ? (
|
|
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-1">
|
|
{getAvailableSlots(selectedDate).map((slot, index) => (
|
|
<Button
|
|
key={index}
|
|
variant="outline"
|
|
size="sm"
|
|
className={twMerge(
|
|
"w-full justify-center text-sm py-2 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600 transition-all",
|
|
btnColors.slotHover
|
|
)}
|
|
onClick={() => handleSlotClick(selectedDate, slot)}
|
|
>
|
|
{slot.time}
|
|
</Button>
|
|
))}
|
|
|
|
{getAvailableSlots(selectedDate).length === 0 && (
|
|
<div className="text-center py-8">
|
|
<Text className="text-xs text-gray-500 dark:text-gray-400">
|
|
{t("calendar.noSlotsAvailable")}
|
|
</Text>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8">
|
|
<CalendarIcon className="w-8 h-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
|
|
<Text className="text-xs text-gray-500 dark:text-gray-400">
|
|
{t("calendar.title")}
|
|
</Text>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Booking Modal */}
|
|
<CustomModal
|
|
isOpen={isModalOpen}
|
|
onClose={handleCloseModal}
|
|
title={user ? t("modal.titleExisting") : t("modal.titleNew")}
|
|
width="md"
|
|
>
|
|
{selectedSlot && (
|
|
<div
|
|
className={twMerge(
|
|
"mb-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border",
|
|
btnColors.modalBorder
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-100">
|
|
<CalendarIcon className={twMerge("w-4 h-4", btnColors.modalIcon)} />
|
|
<Text className="font-medium">
|
|
{selectedSlot.date.toLocaleDateString("fr-FR", {
|
|
weekday: "long",
|
|
day: "numeric",
|
|
month: "long",
|
|
})}
|
|
</Text>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-100 mt-1">
|
|
<ClockIcon className={twMerge("w-4 h-4", btnColors.modalIcon)} />
|
|
<Text className="font-medium">{selectedSlot.slot.time}</Text>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">
|
|
{t("modal.name")} <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
type="text"
|
|
placeholder={t("modal.namePlaceholder")}
|
|
value={user?.name || formData.name}
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
|
disabled={!!user}
|
|
/>
|
|
{formErrors.name && <FieldError errors={[{ message: formErrors.name }]} />}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">
|
|
{t("modal.email")} <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
placeholder={t("modal.emailPlaceholder")}
|
|
value={user?.email || formData.email}
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, email: e.target.value }))}
|
|
disabled={!!user}
|
|
/>
|
|
{formErrors.email && <FieldError errors={[{ message: formErrors.email }]} />}
|
|
</div>
|
|
|
|
{!user && (
|
|
<div className="pt-2">
|
|
<Text className="text-sm text-gray-600 dark:text-gray-400">
|
|
{t("modal.accountNote")}
|
|
</Text>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<Button variant="outline" onClick={handleCloseModal}>
|
|
{t("modal.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
onClick={user ? handleSubmitIfLoggedIn : handleSubmitIfNotLoggedIn}
|
|
>
|
|
{user ? t("modal.buttonExisting") : t("modal.buttonNew")}
|
|
</Button>
|
|
</div>
|
|
</CustomModal>
|
|
</div>
|
|
);
|
|
}
|