Add public routes for bookings
This commit is contained in:
parent
8d9c7332b3
commit
af247845a4
5 changed files with 467 additions and 17 deletions
|
|
@ -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);
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
434
ui/src/pages/PublicBookingPage.tsx
Normal file
434
ui/src/pages/PublicBookingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue