xtablo-source/apps/external/src/FloatingBookingWidget.tsx
2025-11-10 08:52:47 +01:00

708 lines
26 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, TypographyH4, TypographyMuted } from "@xtablo/ui/components/typography";
import {
CalendarIcon,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
MapPinIcon,
UserIcon,
XIcon,
} from "lucide-react";
import { useState } from "react";
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 { useCreateTabloWithOwner } from "@xtablo/shared";
import { useMaybeUser } from "./UserStoreProvider";
type ColorVariant = "black" | "white" | "blue" | "purple" | "green" | "orange" | "red";
// Color scheme configurations
const buttonColors = {
black: {
floating: "bg-gray-900 hover:bg-gray-800 text-white",
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: {
floating: "bg-white hover:bg-gray-50 text-gray-900 border border-gray-300",
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: {
floating: "bg-blue-600 hover:bg-blue-700 text-white",
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: {
floating: "bg-purple-600 hover:bg-purple-700 text-white",
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: {
floating: "bg-green-600 hover:bg-green-700 text-white",
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: {
floating: "bg-orange-600 hover:bg-orange-700 text-white",
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: {
floating: "bg-red-600 hover:bg-red-700 text-white",
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",
},
};
export function FloatingBookingWidget() {
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;
const shortUserId = userInfo?.substring(userInfo.lastIndexOf("-") + 1);
// Get view mode and variants from URL params
const view = searchParams.get("view") || "default"; // 'button', 'modal', or 'default'
const buttonVariant = (searchParams.get("buttonVariant") as ColorVariant) || "purple";
// Get color schemes based on variants
const btnColors = buttonColors[buttonVariant];
const { data: publicSlots } = usePublicSlots(api, shortUserId || "", eventTypeStandardName || "");
const { mutateAsync: bookSlot } = useBookSlot(api, session?.access_token, () => {
handleCloseModal();
if (view === "modal") {
// Send message to parent to close the modal
window.parent.postMessage("xtablo:close", "*");
} else {
setIsWidgetOpen(false);
}
});
const userProfile = publicSlots?.user;
const eventType = publicSlots?.eventType;
const slotsData = publicSlots?.slots || {};
// Widget state - auto-open if in modal view
const [isWidgetOpen, setIsWidgetOpen] = useState(view === "modal");
// Calendar state
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
// Modal state (for booking confirmation)
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 = "L'adresse email est requise";
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = "Veuillez entrer une adresse email valide";
isValid = false;
}
if (!formData.name.trim()) {
errors.name = "Le nom est requis";
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 || "",
},
});
}
};
// If view is 'button', only show the button
if (view === "button") {
return (
<div className="fixed inset-0 flex items-center justify-center">
<Button
size="lg"
className={twMerge(
"rounded-full h-14 w-14 shadow-lg hover:shadow-xl border-0 transition-all duration-200",
btnColors.floating
)}
onClick={() => window.parent.postMessage("xtablo:open", "*")}
>
<CalendarIcon className="w-6 h-6" />
</Button>
</div>
);
}
return (
<div className="fixed inset-0 pointer-events-none">
{/* Backdrop for modal view */}
{view === "modal" && isWidgetOpen && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm pointer-events-auto animate-in fade-in duration-200"
onClick={() => window.parent.postMessage("xtablo:close", "*")}
/>
)}
{/* Floating Button - only show in default view */}
{view === "default" && (
<div className="fixed bottom-6 right-6 z-50 pointer-events-auto">
<Button
size="lg"
className={twMerge(
"rounded-full h-14 w-14 shadow-lg hover:shadow-xl transition-all duration-200",
btnColors.floating,
isWidgetOpen && "scale-0 opacity-0"
)}
onClick={() => setIsWidgetOpen(true)}
>
<CalendarIcon className="w-6 h-6" />
</Button>
</div>
)}
{/* Floating Widget Popup */}
{isWidgetOpen && (
<div
className={twMerge(
"z-50 w-[450px] max-h-[650px] bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden pointer-events-auto",
view === "modal"
? "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-in fade-in zoom-in-95 duration-200"
: "fixed bottom-6 right-6 animate-in slide-in-from-bottom-4 duration-300"
)}
>
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
{(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="w-12 h-12 rounded-full object-cover border-2 border-gray-200 dark:border-gray-600 shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
<UserIcon className="w-6 h-6 text-gray-500 dark:text-gray-400" />
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-white text-sm truncate">
{eventType?.name || "Type d'appel"}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{userProfile?.name || "Professionnel"}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => {
if (view === "modal") {
window.parent.postMessage("xtablo:close", "*");
} else {
setIsWidgetOpen(false);
setSelectedDate(null);
}
}}
>
<XIcon className="w-4 h-4" />
</Button>
</div>
{/* Event Info */}
{(eventType?.duration || eventType?.location) && (
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<div className="flex flex-wrap gap-3 text-xs">
{eventType?.duration && (
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-400">
<ClockIcon className="w-3.5 h-3.5" />
<span>{formatDuration(eventType.duration)}</span>
</div>
)}
{eventType?.location && (
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-400">
<MapPinIcon className="w-3.5 h-3.5" />
<span className="truncate max-w-[200px]">{eventType.location}</span>
</div>
)}
</div>
</div>
)}
{/* Calendar and Slots */}
<div className="flex-1 overflow-y-auto p-4">
{/* Calendar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<TypographyH4 className="font-semibold text-gray-900 dark:text-white capitalize text-sm">
{formatMonthYear(currentDate)}
</TypographyH4>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigateMonth("prev")}
className="h-7 w-7 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-7 w-7 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">
{["L", "M", "M", "J", "V", "S", "D"].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-xs 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 */}
{selectedDate && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<TypographyH4 className="font-semibold text-gray-900 dark:text-white mb-2 text-sm">
Créneaux disponibles
</TypographyH4>
<TypographyMuted className="text-xs font-normal text-gray-500 dark:text-gray-400 mb-3">
{selectedDate.toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
})}
</TypographyMuted>
<div className="space-y-2 max-h-[200px] 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-4">
<Text className="text-xs text-gray-500 dark:text-gray-400">
Aucun créneau disponible
</Text>
</div>
)}
</div>
</div>
)}
{!selectedDate && (
<div className="text-center py-8 border-t border-gray-200 dark:border-gray-700">
<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">
Sélectionnez une date pour voir les créneaux disponibles
</Text>
</div>
)}
</div>
{/* Footer */}
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<TypographyMuted className="text-xs text-center text-gray-500">
Powered by{" "}
<a
href="https://www.xtablo.com"
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-gray-600 dark:text-gray-400"
>
XTablo
</a>
</TypographyMuted>
</div>
</div>
)}
{/* Booking Modal */}
<CustomModal
isOpen={isModalOpen}
onClose={handleCloseModal}
title={user ? "Confirmer la réservation" : "Créer un compte pour réserver"}
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">
Nom complet <span className="text-red-500">*</span>
</Label>
<Input
id="name"
type="text"
placeholder="Votre nom complet"
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">
Adresse email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
placeholder="votre@email.com"
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">
Un compte sera créé avec ces informations pour gérer votre réservation.
</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}>
Annuler
</Button>
<Button
variant="default"
onClick={user ? handleSubmitIfLoggedIn : handleSubmitIfNotLoggedIn}
>
{user ? "Confirmer la réservation" : "Créer le compte et réserver"}
</Button>
</div>
</CustomModal>
</div>
);
}