Add public routes for bookings

This commit is contained in:
Arthur Belleville 2025-09-23 22:07:54 +02:00
parent 8d9c7332b3
commit af247845a4
No known key found for this signature in database
5 changed files with 467 additions and 17 deletions

View file

@ -4,7 +4,7 @@ import { Navigate, Outlet, useSearchParams } from "react-router-dom";
import { match } from "ts-pattern";
import { LoadingSpinner } from "./LoadingSpinner";
export const PublicRoute = () => {
export const AuthenticationGateway = () => {
const { session } = useSession();
const [isLoading, setIsLoading] = useState(true);

View file

@ -1,5 +1,5 @@
import { screen, waitFor } from "@testing-library/react";
import { PublicRoute } from "@ui/components/PublicRoute";
import { AuthenticationGateway } from "@ui/components/AuthenticationGateway";
import { Routes, Route } from "react-router-dom";
import { SessionTestProvider } from "@ui/contexts/SessionContext";
import { renderWithRouter } from "@ui/utils/testHelpers";
@ -9,7 +9,7 @@ describe("PublicRoute", () => {
renderWithRouter(
<SessionTestProvider>
<Routes>
<Route element={<PublicRoute />}>
<Route element={<AuthenticationGateway />}>
<Route path="/login" element={<div>Login Page</div>} />
</Route>
</Routes>
@ -40,7 +40,7 @@ describe("PublicRoute", () => {
}}
>
<Routes>
<Route element={<PublicRoute />}>
<Route element={<AuthenticationGateway />}>
<Route path="/login" element={<div>Login Page</div>} />
</Route>
<Route path="/" element={<div>Home Page</div>} />
@ -59,7 +59,7 @@ describe("PublicRoute", () => {
renderWithRouter(
<SessionTestProvider>
<Routes>
<Route element={<PublicRoute />}>
<Route element={<AuthenticationGateway />}>
<Route path="/login" element={<div>Login Page</div>} />
</Route>
</Routes>

View file

@ -12,7 +12,7 @@ import { LandingPage } from "@ui/pages/landing";
import { LoginPage } from "@ui/pages/login";
import { SignUpPage } from "@ui/pages/signup";
import { ResetPasswordPage } from "@ui/pages/reset-password";
import { PublicRoute } from "@ui/components/PublicRoute";
import { AuthenticationGateway } from "@ui/components/AuthenticationGateway";
import ChatProvider from "@ui/providers/ChatProvider";
import { CreateEventModal } from "@ui/components/CreateEventModal";
import { ChantiersPage } from "@ui/pages/chantiers";
@ -22,6 +22,7 @@ import { SupportPage } from "@ui/pages/support";
import { AvailabilitiesPage } from "@ui/pages/availabilities";
import { BookingsPage } from "@ui/pages/bookings";
import { EventTypesPage } from "@ui/pages/event-types-page";
import { PublicBookingPage } from "@ui/pages/PublicBookingPage";
export const routes: RouteObject[] = [
// Protected routes
@ -128,10 +129,15 @@ export const routes: RouteObject[] = [
path: "/landing",
element: <LandingPage />,
},
// Public routes (authentication pages)
// Public booking routes
{
path: "/book/:user_info/:event_type_standard_name",
element: <PublicBookingPage />,
},
// Authentication pages (redirected to "/" if user is authenticated)
{
path: "/",
element: <PublicRoute />,
element: <AuthenticationGateway />,
children: [
{
path: "login",

View file

@ -0,0 +1,434 @@
import { useParams } from "react-router-dom";
import { useState } from "react";
import { EventType } from "@ui/hooks/event-types";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { Strong, Text } from "@ui/ui-library/text";
import { Button } from "@ui/ui-library/button";
import { useTheme } from "@ui/contexts/ThemeContext";
import {
CalendarIcon,
ClockIcon,
MapPinIcon,
UserIcon,
MailIcon,
ChevronLeftIcon,
ChevronRightIcon,
SunIcon,
MoonIcon,
MonitorIcon,
} from "lucide-react";
type PublicProfile = {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
};
export function PublicBookingPage() {
const { user_info, event_type_standard_name } = useParams<{
user_info: string;
event_type_standard_name: string;
}>();
const shortUserId = user_info?.substring(user_info.lastIndexOf("-") + 1);
const name = user_info?.substring(0, user_info.lastIndexOf("-"));
const displayName = name
?.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
const userProfile: PublicProfile = {
id: shortUserId || "",
name: displayName || "",
avatar_url: null,
email: null,
};
const eventType: EventType = {
id: "fake-event-type-id",
name: "Consultation Stratégique",
description:
"Séance de consultation pour définir votre stratégie business et identifier les opportunités de croissance.",
duration: 90,
isActive: true,
bufferTime: 15,
maxBookingsPerDay: 4,
requiresApproval: true,
price: 150,
location: "Bureau - 123 Rue de la Paix, Paris",
minAdvanceBooking: {
value: 24,
unit: "hours",
},
standardName: event_type_standard_name || "consultation-strategique",
};
// Calendar state
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
// Theme
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
if (theme === "light") {
setTheme("dark");
} else if (theme === "dark") {
setTheme("system");
} else {
setTheme("light");
}
};
const getThemeIcon = () => {
switch (theme) {
case "light":
return <SunIcon className="w-5 h-5" />;
case "dark":
return <MoonIcon className="w-5 h-5" />;
case "system":
return <MonitorIcon className="w-5 h-5" />;
default:
return <SunIcon className="w-5 h-5" />;
}
};
// Fake available time slots for demo
const getAvailableSlots = (date: Date) => {
// Generate some fake available slots for the selected date
const slots = [
"09:00",
"09:30",
"10:00",
"10:30",
"11:00",
"11:30",
"14:00",
"14:30",
"15:00",
"15:30",
"16:00",
"16:30",
];
// Randomly make some slots unavailable for demo
const dayOfMonth = date.getDate();
return slots.filter((_, index) => (dayOfMonth + index) % 3 !== 0);
};
// Calendar helper functions
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDayOfWeek = firstDay.getDay();
const days = [];
// Add empty cells for days before the first day of the month
for (let i = 0; i < startingDayOfWeek; i++) {
days.push(null);
}
// Add all days of the month
for (let day = 1; day <= daysInMonth; day++) {
days.push(new Date(year, month, day));
}
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 today = new Date();
return date.toDateString() === today.toDateString();
};
const isPastDate = (date: Date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return date < today;
};
const formatMonthYear = (date: Date) => {
return date.toLocaleDateString("fr-FR", { month: "long", year: "numeric" });
};
// Simulate loading state briefly for demo
const isLoading = false;
if (isLoading) {
return <LoadingSpinner />;
}
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`;
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto py-6 px-4">
<div className="flex items-center gap-4">
{/* Avatar */}
{/* <div className="flex-shrink-0">
{userProfile.avatar_url ? (
<img
src={userProfile.avatar_url}
alt={userProfile.name || "Profile"}
className="w-16 h-16 rounded-full object-cover border-2 border-blue-100 dark:border-blue-900"
/>
) : (
<div className="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center border-2 border-blue-200 dark:border-blue-800">
<UserIcon className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
)}
</div> */}
{/* User Info */}
<div className="flex-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{userProfile.name || "Professionnel"}
</h1>
{userProfile.email && (
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400 mt-1">
<MailIcon className="w-4 h-4" />
<span>{userProfile.email}</span>
</div>
)}
</div>
{/* Theme Toggle */}
<div className="flex-shrink-0">
<Button
variant="plain"
isIconOnly
onPress={toggleTheme}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 p-2"
aria-label={`Changer le thème (actuellement: ${theme})`}
>
{getThemeIcon()}
</Button>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-7xl mx-auto py-8 px-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Sidebar - Event Type Info */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 sticky top-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-3">
{eventType.name}
</h2>
{eventType.description && (
<Text className="text-gray-600 dark:text-gray-400 mb-6">
{eventType.description}
</Text>
)}
<div className="space-y-4">
<div className="flex items-center gap-3">
<ClockIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<div>
<Strong className="text-gray-900 dark:text-white text-sm">
{formatDuration(eventType.duration)}
</Strong>
</div>
</div>
{eventType.price && (
<div className="flex items-center gap-3">
<span className="w-5 h-5 flex items-center justify-center text-yellow-600 dark:text-yellow-400 font-bold">
</span>
<div>
<Strong className="text-gray-900 dark:text-white text-sm">
{eventType.price}
</Strong>
</div>
</div>
)}
{eventType.location && (
<div className="flex items-center gap-3">
<MapPinIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
<div>
<Text className="text-gray-600 dark:text-gray-400 text-sm">
{eventType.location}
</Text>
</div>
</div>
)}
{eventType.requiresApproval && (
<div className="flex items-center gap-3">
<UserIcon className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />
<div>
<Text className="text-gray-600 dark:text-gray-400 text-sm">
Approbation requise
</Text>
</div>
</div>
)}
</div>
</div>
</div>
{/* Center - Calendar */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
{/* Calendar Header */}
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white capitalize">
{formatMonthYear(currentDate)}
</h3>
<div className="flex gap-2">
<Button
variant="plain"
isIconOnly
onPress={() => navigateMonth("prev")}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<ChevronLeftIcon className="w-5 h-5" />
</Button>
<Button
variant="plain"
isIconOnly
onPress={() => navigateMonth("next")}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<ChevronRightIcon className="w-5 h-5" />
</Button>
</div>
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7 gap-1 mb-2">
{["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"].map(
(day) => (
<div
key={day}
className="p-2 text-center text-sm 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) && setSelectedDate(date)
}
disabled={isPastDate(date)}
className={`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()
? "bg-blue-600 text-white"
: isToday(date)
? "bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 font-semibold"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
}`}
>
{date.getDate()}
</button>
) : (
<div></div>
)}
</div>
))}
</div>
</div>
</div>
{/* Right Sidebar - Available Slots */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{selectedDate ? (
<>
Créneaux disponibles
<br />
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
{selectedDate.toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
})}
</span>
</>
) : (
"Sélectionnez une date"
)}
</h3>
{selectedDate ? (
<div className="space-y-2">
{getAvailableSlots(selectedDate).map((slot, index) => (
<Button
key={index}
variant="outline"
className="w-full justify-center py-3 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:border-blue-300 dark:hover:border-blue-600"
>
{slot}
</Button>
))}
{getAvailableSlots(selectedDate).length === 0 && (
<div className="text-center py-8">
<Text className="text-gray-500 dark:text-gray-400">
Aucun créneau disponible ce jour
</Text>
</div>
)}
</div>
) : (
<div className="text-center py-8">
<CalendarIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
<Text className="text-gray-500 dark:text-gray-400">
Choisissez une date dans le calendrier pour voir les
créneaux disponibles
</Text>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -7,12 +7,13 @@ import {
TrashIcon,
CheckIcon,
XIcon,
LinkIcon,
ExternalLinkIcon,
} from "lucide-react";
import { toast } from "@ui/ui-library/toast/toast-queue";
import { EventTypeModal } from "@ui/components/EventTypeModal";
import { EventType, useEventTypes } from "@ui/hooks/event-types";
import { useUser } from "@ui/providers/UserStoreProvider";
import { CopyButton } from "@ui/ui-library/clipboard";
export function EventTypesPage() {
const user = useUser();
@ -80,7 +81,7 @@ export function EventTypesPage() {
setEditingEventType(null);
};
const handleOpenPublicLink = (eventType: EventType) => {
const getPublicLink = (eventType: EventType) => {
// Sanitize user name for URL (replace spaces with hyphens, lowercase, remove special chars)
const sanitizedUserName = user.name
?.toLowerCase()
@ -91,10 +92,8 @@ export function EventTypesPage() {
// Construct the public booking URL
const baseUrl = window.location.origin;
const publicUrl = `${baseUrl}/book/${sanitizedUserName}-${shortUserId}/${eventType.standardName}`;
console.log(publicUrl);
// Open in new tab
// window.open(publicUrl, "_blank");
return publicUrl;
};
return (
@ -133,12 +132,23 @@ export function EventTypesPage() {
<Button
variant="plain"
isIconOnly
onPress={() => handleOpenPublicLink(eventType as EventType)}
className="text-gray-500 hover:text-green-600"
tooltip="Ouvrir le lien de réservation public"
onPress={() =>
window.open(
getPublicLink(eventType as EventType),
"_blank"
)
}
className="text-gray-500 hover:text-blue-600"
tooltip="Aperçu"
>
<LinkIcon className="w-4 h-4" />
<ExternalLinkIcon className="w-4 h-4" />
</Button>
<CopyButton
copyValue={getPublicLink(eventType as EventType)}
label="Copier le lien"
labelAfterCopied="Lien copié"
className="text-gray-500 hover:text-blue-600"
></CopyButton>
<Button
variant="plain"
isIconOnly