From af247845a4d88ce928a90c722a5b5931b3418074 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Tue, 23 Sep 2025 22:07:54 +0200 Subject: [PATCH] Add public routes for bookings --- ...licRoute.tsx => AuthenticationGateway.tsx} | 2 +- ...est.tsx => AuthenticationGateway.unit.tsx} | 8 +- ui/src/lib/routes.tsx | 12 +- ui/src/pages/PublicBookingPage.tsx | 434 ++++++++++++++++++ ui/src/pages/event-types-page.tsx | 28 +- 5 files changed, 467 insertions(+), 17 deletions(-) rename ui/src/components/{PublicRoute.tsx => AuthenticationGateway.tsx} (96%) rename ui/src/components/{PublicRoute.test.tsx => AuthenticationGateway.unit.tsx} (89%) create mode 100644 ui/src/pages/PublicBookingPage.tsx diff --git a/ui/src/components/PublicRoute.tsx b/ui/src/components/AuthenticationGateway.tsx similarity index 96% rename from ui/src/components/PublicRoute.tsx rename to ui/src/components/AuthenticationGateway.tsx index 4aa86d7..ca959d7 100644 --- a/ui/src/components/PublicRoute.tsx +++ b/ui/src/components/AuthenticationGateway.tsx @@ -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); diff --git a/ui/src/components/PublicRoute.test.tsx b/ui/src/components/AuthenticationGateway.unit.tsx similarity index 89% rename from ui/src/components/PublicRoute.test.tsx rename to ui/src/components/AuthenticationGateway.unit.tsx index 9598874..5a9c68e 100644 --- a/ui/src/components/PublicRoute.test.tsx +++ b/ui/src/components/AuthenticationGateway.unit.tsx @@ -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( - }> + }> Login Page} /> @@ -40,7 +40,7 @@ describe("PublicRoute", () => { }} > - }> + }> Login Page} /> Home Page} /> @@ -59,7 +59,7 @@ describe("PublicRoute", () => { renderWithRouter( - }> + }> Login Page} /> diff --git a/ui/src/lib/routes.tsx b/ui/src/lib/routes.tsx index 2588c3f..2a45086 100644 --- a/ui/src/lib/routes.tsx +++ b/ui/src/lib/routes.tsx @@ -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: , }, - // Public routes (authentication pages) + // Public booking routes + { + path: "/book/:user_info/:event_type_standard_name", + element: , + }, + // Authentication pages (redirected to "/" if user is authenticated) { path: "/", - element: , + element: , children: [ { path: "login", diff --git a/ui/src/pages/PublicBookingPage.tsx b/ui/src/pages/PublicBookingPage.tsx new file mode 100644 index 0000000..cbfe4d2 --- /dev/null +++ b/ui/src/pages/PublicBookingPage.tsx @@ -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(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 ; + case "dark": + return ; + case "system": + return ; + default: + return ; + } + }; + + // 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 ; + } + + 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 ( +
+ {/* Header */} +
+
+
+ {/* Avatar */} + {/*
+ {userProfile.avatar_url ? ( + {userProfile.name + ) : ( +
+ +
+ )} +
*/} + + {/* User Info */} +
+

+ {userProfile.name || "Professionnel"} +

+ {userProfile.email && ( +
+ + {userProfile.email} +
+ )} +
+ + {/* Theme Toggle */} +
+ +
+
+
+
+ + {/* Main Content */} +
+
+ {/* Left Sidebar - Event Type Info */} +
+
+

+ {eventType.name} +

+ + {eventType.description && ( + + {eventType.description} + + )} + +
+
+ +
+ + {formatDuration(eventType.duration)} + +
+
+ + {eventType.price && ( +
+ + € + +
+ + {eventType.price}€ + +
+
+ )} + + {eventType.location && ( +
+ +
+ + {eventType.location} + +
+
+ )} + + {eventType.requiresApproval && ( +
+ +
+ + Approbation requise + +
+
+ )} +
+
+
+ + {/* Center - Calendar */} +
+
+ {/* Calendar Header */} +
+

+ {formatMonthYear(currentDate)} +

+
+ + +
+
+ + {/* Calendar Grid */} +
+ {["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"].map( + (day) => ( +
+ {day} +
+ ) + )} +
+ +
+ {getDaysInMonth(currentDate).map((date, index) => ( +
+ {date ? ( + + ) : ( +
+ )} +
+ ))} +
+
+
+ + {/* Right Sidebar - Available Slots */} +
+
+

+ {selectedDate ? ( + <> + Créneaux disponibles +
+ + {selectedDate.toLocaleDateString("fr-FR", { + weekday: "long", + day: "numeric", + month: "long", + })} + + + ) : ( + "Sélectionnez une date" + )} +

+ + {selectedDate ? ( +
+ {getAvailableSlots(selectedDate).map((slot, index) => ( + + ))} + + {getAvailableSlots(selectedDate).length === 0 && ( +
+ + Aucun créneau disponible ce jour + +
+ )} +
+ ) : ( +
+ + + Choisissez une date dans le calendrier pour voir les + créneaux disponibles + +
+ )} +
+
+
+
+
+ ); +} diff --git a/ui/src/pages/event-types-page.tsx b/ui/src/pages/event-types-page.tsx index d44bb4b..96b8914 100644 --- a/ui/src/pages/event-types-page.tsx +++ b/ui/src/pages/event-types-page.tsx @@ -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() { +