commit
e94438e6c7
9 changed files with 381 additions and 290 deletions
|
|
@ -136,6 +136,10 @@ tabloRouter.post("/create-and-invite", async (c) => {
|
|||
email: string;
|
||||
};
|
||||
|
||||
if (ownerId === user.id) {
|
||||
return c.json({ error: "You cannot create a tablo with yourself" }, 400);
|
||||
}
|
||||
|
||||
// TODO: Verify that the event start and end correspond to a slot
|
||||
|
||||
// Check if there's already a tablo between the owner and the invited user
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
|
|||
import { Toaster } from "@xtablo/ui/components/sonner";
|
||||
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
|
||||
import { routes } from "./lib/routes";
|
||||
import { publicRoutes } from "./lib/publicRoutes";
|
||||
import { supabase } from "./lib/supabase";
|
||||
import { DatadogRumProvider } from "./providers/DatadogRumProvider";
|
||||
import { UserStoreProvider } from "./providers/UserStoreProvider";
|
||||
|
|
@ -12,18 +13,25 @@ const AppRoutes = () => {
|
|||
return element;
|
||||
};
|
||||
|
||||
const PublicRoutes = () => {
|
||||
const element = useRoutes(publicRoutes);
|
||||
return element;
|
||||
};
|
||||
|
||||
export const App = () => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<SessionProvider supabase={supabase}>
|
||||
<UserStoreProvider>
|
||||
<Toaster />
|
||||
<Router>
|
||||
<DatadogRumProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Toaster />
|
||||
<Router>
|
||||
<DatadogRumProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicRoutes />
|
||||
<UserStoreProvider>
|
||||
<AppRoutes />
|
||||
<style>
|
||||
{`
|
||||
</UserStoreProvider>
|
||||
<style>
|
||||
{`
|
||||
@keyframes slide {
|
||||
0% { transform: translateX(-100vw); }
|
||||
100% { transform: translateX(100vw); }
|
||||
|
|
@ -32,11 +40,10 @@ export const App = () => {
|
|||
animation: slide 24s linear infinite;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
</DatadogRumProvider>
|
||||
</Router>
|
||||
</UserStoreProvider>
|
||||
</style>
|
||||
</div>
|
||||
</DatadogRumProvider>
|
||||
</Router>
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
|
|
|||
22
apps/main/src/lib/publicRoutes.tsx
Normal file
22
apps/main/src/lib/publicRoutes.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { RouteObject } from "react-router-dom";
|
||||
import { LandingPage } from "../pages/landing";
|
||||
import { PublicBookingPage } from "../pages/PublicBookingPage";
|
||||
import { PublicNotePage } from "../pages/PublicNotePage";
|
||||
|
||||
export const publicRoutes: RouteObject[] = [
|
||||
// Landing page
|
||||
{
|
||||
path: "/landing",
|
||||
element: <LandingPage />,
|
||||
},
|
||||
// Public booking routes
|
||||
{
|
||||
path: "/book/:user_info/:event_type_standard_name",
|
||||
element: <PublicBookingPage />,
|
||||
},
|
||||
// Public notes route (unauthenticated access)
|
||||
{
|
||||
path: "/notes/public/:noteId",
|
||||
element: <PublicNotePage />,
|
||||
},
|
||||
];
|
||||
|
|
@ -9,13 +9,10 @@ import { ChatPage } from "../pages/chat";
|
|||
import { EventsPage } from "../pages/events";
|
||||
import { FeedbackPage } from "../pages/feedback";
|
||||
import { JoinPage } from "../pages/join";
|
||||
import { LandingPage } from "../pages/landing";
|
||||
import { LoginPage } from "../pages/login";
|
||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||
import NotesPage from "../pages/notes";
|
||||
import { OAuthSigninPage } from "../pages/oauth-signin";
|
||||
import { PublicBookingPage } from "../pages/PublicBookingPage";
|
||||
import { PublicNotePage } from "../pages/PublicNotePage";
|
||||
import { PlanningPage } from "../pages/planning";
|
||||
import { ResetPasswordPage } from "../pages/reset-password";
|
||||
import SettingsPage from "../pages/settings";
|
||||
|
|
@ -126,21 +123,6 @@ export const routes: RouteObject[] = [
|
|||
path: "/login-with-oauth",
|
||||
element: <OAuthSigninPage />,
|
||||
},
|
||||
// Landing page
|
||||
{
|
||||
path: "/landing",
|
||||
element: <LandingPage />,
|
||||
},
|
||||
// Public booking routes
|
||||
{
|
||||
path: "/book/:user_info/:event_type_standard_name",
|
||||
element: <PublicBookingPage />,
|
||||
},
|
||||
// Public notes route (unauthenticated access)
|
||||
{
|
||||
path: "/notes/public/:noteId",
|
||||
element: <PublicNotePage />,
|
||||
},
|
||||
// Authentication pages (redirected to "/" if user is authenticated)
|
||||
{
|
||||
path: "/",
|
||||
|
|
|
|||
|
|
@ -44,10 +44,10 @@
|
|||
},
|
||||
"events": {
|
||||
"title": "Mes Événements",
|
||||
"subtitle": "Gérez vos événements, réservations et types d'événements",
|
||||
"subtitle": "Gérez vos événements, réservations et types d'appels",
|
||||
"tabs": {
|
||||
"events": "Événements",
|
||||
"eventTypes": "Types d'événements"
|
||||
"eventTypes": "Types d'appels"
|
||||
},
|
||||
"createEvent": "Nouvel événement",
|
||||
"createEventType": "Nouveau type",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CustomModal } from "@ui/components/CustomModal";
|
||||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { useCreateTabloWithOwner } from "@xtablo/shared";
|
||||
import { useSession } from "@xtablo/shared/contexts/SessionContext";
|
||||
import { useTheme } from "@xtablo/shared/contexts/ThemeContext";
|
||||
|
|
@ -11,8 +10,14 @@ 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 { Strong, Text } from "@xtablo/ui/components/typography";
|
||||
import {
|
||||
Text,
|
||||
TypographyH3,
|
||||
TypographyH4,
|
||||
TypographyMuted,
|
||||
} from "@xtablo/ui/components/typography";
|
||||
import {
|
||||
CalendarCheck2,
|
||||
CalendarIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
|
|
@ -23,11 +28,11 @@ import {
|
|||
SunIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { api } from "../lib/api";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { useMaybeUser } from "../providers/UserStoreProvider";
|
||||
|
||||
export function PublicBookingPage() {
|
||||
const { user_info, event_type_standard_name } = useParams<{
|
||||
|
|
@ -36,9 +41,11 @@ export function PublicBookingPage() {
|
|||
}>();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { mutateAsync: signUpWithoutPassword } = useSignUpWithoutPassword(supabase, api);
|
||||
const { mutateAsync: signUpWithoutPassword, isPending: isSigningUpWithoutPassword } =
|
||||
useSignUpWithoutPassword(supabase, api);
|
||||
const { session } = useSession();
|
||||
const user = useMaybeUser();
|
||||
const user = session ? session.user : null;
|
||||
|
||||
const shortUserId = user_info?.substring(user_info.lastIndexOf("-") + 1);
|
||||
|
||||
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
|
||||
|
|
@ -47,12 +54,15 @@ export function PublicBookingPage() {
|
|||
event_type_standard_name || ""
|
||||
);
|
||||
|
||||
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(api, (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tablos"] });
|
||||
invalidatePublicSlots();
|
||||
navigate(`/chat/${data.id}`, { replace: true });
|
||||
navigate(0);
|
||||
});
|
||||
const { mutateAsync: createTabloWithOwner, isPending: isCreatingTabloWithOwner } =
|
||||
useCreateTabloWithOwner(api, (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tablos"] });
|
||||
invalidatePublicSlots();
|
||||
navigate(`/tablos/${data.id}`, { replace: true });
|
||||
navigate(0);
|
||||
});
|
||||
|
||||
const isPending = isSigningUpWithoutPassword || isCreatingTabloWithOwner;
|
||||
|
||||
const userProfile = publicSlots?.user;
|
||||
const eventType = publicSlots?.eventType;
|
||||
|
|
@ -77,17 +87,32 @@ export function PublicBookingPage() {
|
|||
name: "",
|
||||
});
|
||||
|
||||
// Loading messages rotation
|
||||
const loadingMessages = [
|
||||
"Nous créons votre rendez-vous, veuillez patienter",
|
||||
"Creation de votre compte, ...",
|
||||
"Préparation de votre réservation...",
|
||||
"Configuration de votre appel...",
|
||||
"Finalisation de votre créneau...",
|
||||
];
|
||||
const [currentMessageIndex, setCurrentMessageIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentMessageIndex(0);
|
||||
if (isPending) {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentMessageIndex((prev) => (prev + 1) % loadingMessages.length);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isPending]);
|
||||
|
||||
// Theme
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === "light") {
|
||||
setTheme("dark");
|
||||
} else if (theme === "dark") {
|
||||
setTheme("system");
|
||||
} else {
|
||||
setTheme("light");
|
||||
}
|
||||
setTheme(theme === "light" ? "dark" : "light");
|
||||
};
|
||||
|
||||
const getThemeIcon = () => {
|
||||
|
|
@ -195,17 +220,6 @@ export function PublicBookingPage() {
|
|||
return date.toLocaleDateString("fr-FR", { month: "long", year: "numeric" });
|
||||
};
|
||||
|
||||
if (isLoadingSlots) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner />
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Chargement des disponibilités...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
if (minutes < 60) {
|
||||
return `${minutes} min`;
|
||||
|
|
@ -321,249 +335,307 @@ export function PublicBookingPage() {
|
|||
};
|
||||
|
||||
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">
|
||||
{/* Xtablo Logo */}
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src={theme === "dark" ? "/logo_white.png" : "/logo_dark.png"}
|
||||
alt="Xtablo"
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
{/* <div className="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>
|
||||
</div>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<div className="shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={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 className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4 md:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Theme Toggle - Floating */}
|
||||
<div className="absolute top-4 right-4 z-20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm shadow-lg"
|
||||
aria-label={`Changer le thème (actuellement: ${theme})`}
|
||||
>
|
||||
{getThemeIcon()}
|
||||
</Button>
|
||||
</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 || "Type d'appel"}
|
||||
</h2>
|
||||
{/* Main Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-2xl overflow-hidden">
|
||||
<div className="flex flex-col lg:flex-row min-h-[700px]">
|
||||
{/* Left Panel - User Profile & Event Details */}
|
||||
<div className="w-full lg:w-[400px] bg-linear-to-br from-gray-900 via-gray-800 to-gray-900 dark:from-purple-900 dark:via-purple-800 dark:to-purple-900 p-8 flex flex-col relative overflow-hidden">
|
||||
{/* Subtle accent overlay */}
|
||||
<div className="absolute inset-0 bg-linear-to-br from-purple-600/5 via-transparent to-purple-600/10 pointer-events-none"></div>
|
||||
|
||||
{eventType?.description && (
|
||||
<Text className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
{eventType.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{eventType?.duration && (
|
||||
<div className="flex items-center gap-3">
|
||||
<ClockIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
<div>
|
||||
<Text className="text-gray-900 dark:text-white text-sm">
|
||||
Durée: <Strong>{formatDuration(eventType.duration)}</Strong>
|
||||
</Text>
|
||||
<div className="relative z-10 flex flex-col h-full">
|
||||
{/* User Profile */}
|
||||
<div className="mb-8">
|
||||
{userProfile?.avatar_url ? (
|
||||
<img
|
||||
src={userProfile.avatar_url}
|
||||
alt={userProfile.name || "Profile"}
|
||||
className="w-20 h-20 rounded-full object-cover border-4 border-purple-500/30 mb-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-full bg-gray-700 flex items-center justify-center border-4 border-purple-500/30 mb-4">
|
||||
<UserIcon className="w-10 h-10 text-gray-300" />
|
||||
</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-purple-600 dark:text-purple-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="ghost"
|
||||
size="icon"
|
||||
onClick={() => 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="ghost"
|
||||
size="icon"
|
||||
onClick={() => 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>
|
||||
)}
|
||||
<h2 className="text-2xl font-bold mb-1 text-white">
|
||||
{userProfile?.name || "Professionnel"}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="p-2 text-center text-sm font-medium text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Event Type Info */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold mb-3 text-white">
|
||||
{eventType?.name || "Type d'appel"}
|
||||
</h3>
|
||||
|
||||
<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={`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-purple-600 text-white"
|
||||
: isToday(date)
|
||||
? "bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 font-semibold"
|
||||
: hasAvailableSlots(date)
|
||||
? "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border-2 border-purple-200 dark:border-purple-800"
|
||||
: "text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{date.getDate()}
|
||||
</button>
|
||||
) : (
|
||||
<div></div>
|
||||
{eventType?.description && (
|
||||
<TypographyMuted className="mb-6 text-sm leading-relaxed text-gray-400">
|
||||
{eventType.description}
|
||||
</TypographyMuted>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{eventType?.duration && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-700/50 border border-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<ClockIcon className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Durée</p>
|
||||
<p className="font-semibold text-white">
|
||||
{formatDuration(eventType.duration)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventType?.price && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-700/50 border border-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-xl font-bold text-purple-400">€</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Prix</p>
|
||||
<p className="font-semibold text-white">{eventType.price}€</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventType?.location && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-700/50 border border-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<MapPinIcon className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Lieu</p>
|
||||
<p className="font-semibold text-sm text-white">{eventType.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-auto pt-6 border-t border-gray-700/50">
|
||||
<div className="mb-4">
|
||||
<img src="/logo_white.png" alt="Xtablo" className="h-8 w-auto" />
|
||||
</div>
|
||||
<TypographyMuted className="text-xs text-gray-500">
|
||||
Propulsé par{" "}
|
||||
<a
|
||||
href="https://www.xtablo.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline text-purple-400/60"
|
||||
>
|
||||
XTablo
|
||||
</a>
|
||||
</TypographyMuted>
|
||||
</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>
|
||||
{/* Right Panel - Calendar & Time Slots */}
|
||||
<div className="flex-1 flex flex-col p-6 lg:p-8 relative">
|
||||
{/* Loading Overlay for Calendar/Slots */}
|
||||
{isLoadingSlots && (
|
||||
<div className="absolute inset-0 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm z-10 flex items-center justify-center rounded-lg">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="text-center">
|
||||
<Text className="text-gray-900 dark:text-white font-semibold">
|
||||
Chargement des disponibilités...
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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"
|
||||
onClick={() => handleSlotClick(selectedDate, slot)}
|
||||
>
|
||||
{slot.time}
|
||||
</Button>
|
||||
))}
|
||||
<div className="flex-1 flex flex-col lg:flex-row gap-6">
|
||||
{/* Calendar */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<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>
|
||||
|
||||
{getAvailableSlots(selectedDate).length === 0 && (
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
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()
|
||||
? "bg-purple-600 dark:bg-purple-500 text-white font-semibold shadow-md ring-2 ring-purple-500/50"
|
||||
: isToday(date)
|
||||
? "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold border border-purple-500/30"
|
||||
: hasAvailableSlots(date)
|
||||
? "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-purple-500/50 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-full lg:w-64 lg:border-l border-t lg:border-t-0 pt-6 lg:pt-0 border-gray-200 dark:border-gray-700 lg:pl-6">
|
||||
<TypographyH4 className="font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{selectedDate ? (
|
||||
<>
|
||||
Créneaux disponibles
|
||||
<br />
|
||||
<TypographyMuted className="text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
{selectedDate.toLocaleDateString("fr-FR", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</TypographyMuted>
|
||||
</>
|
||||
) : (
|
||||
"Sélectionnez une date"
|
||||
)}
|
||||
</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="w-full justify-center text-sm py-2 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600 hover:bg-purple-600 dark:hover:bg-purple-500 hover:text-white dark:hover:text-white hover:border-purple-500/50 transition-all"
|
||||
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">
|
||||
Aucun créneau disponible ce jour
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Text className="text-gray-500 dark:text-gray-400">
|
||||
Aucun créneau disponible ce jour
|
||||
<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">
|
||||
Choisissez une date dans le calendrier
|
||||
</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>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isPending && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-8 w-md mx-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{/* Animated Icon */}
|
||||
<div className="relative">
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center animate-pulse">
|
||||
<CalendarCheck2 className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<div className="absolute inset-0 rounded-full border-4 border-purple-500/30 animate-ping"></div>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="text-center space-y-2">
|
||||
<TypographyH3 className="text-gray-900 dark:text-white">
|
||||
Réservation en cours...
|
||||
</TypographyH3>
|
||||
<TypographyMuted className="text-gray-600 dark:text-gray-400">
|
||||
{loadingMessages[currentMessageIndex]}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
|
||||
{/* Loading Spinner */}
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full bg-purple-500 animate-bounce"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
></div>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full bg-purple-500 animate-bounce"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
></div>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full bg-purple-500 animate-bounce"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking Modal */}
|
||||
<CustomModal
|
||||
isOpen={isModalOpen}
|
||||
|
|
@ -572,9 +644,9 @@ export function PublicBookingPage() {
|
|||
width="md"
|
||||
>
|
||||
{selectedSlot && (
|
||||
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-300">
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
<div className="mb-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border border-purple-500/20">
|
||||
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-100">
|
||||
<CalendarIcon className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<Text className="font-medium">
|
||||
{selectedSlot.date.toLocaleDateString("fr-FR", {
|
||||
weekday: "long",
|
||||
|
|
@ -583,8 +655,8 @@ export function PublicBookingPage() {
|
|||
})}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-300 mt-1">
|
||||
<ClockIcon className="w-4 h-4" />
|
||||
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-100 mt-1">
|
||||
<ClockIcon className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<Text className="font-medium">{selectedSlot.slot.time}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -599,7 +671,11 @@ export function PublicBookingPage() {
|
|||
id="name"
|
||||
type="text"
|
||||
placeholder="Votre nom complet"
|
||||
value={user?.name || formData.name}
|
||||
value={
|
||||
user
|
||||
? `${user.user_metadata.first_name} ${user.user_metadata.last_name}`
|
||||
: formData.name
|
||||
}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
disabled={!!user}
|
||||
/>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -25,7 +25,7 @@ export type TimeSlot = {
|
|||
|
||||
export function usePublicSlots(api: AxiosInstance, shortUserId: string, standardName: string) {
|
||||
return useQuery<{
|
||||
user: { name: string };
|
||||
user: { name: string; avatar_url?: string };
|
||||
eventType: EventTypeConfig;
|
||||
slots: { [date: string]: TimeSlot[] };
|
||||
availableSlots: TimeSlot[];
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const buildApi = (baseURL: string) =>
|
|||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 4000,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Create React Query client with default options
|
||||
|
|
|
|||
Loading…
Reference in a new issue