Improve public page

This commit is contained in:
Arthur Belleville 2025-10-26 09:46:26 +01:00
parent 5a0314ae87
commit ba30079d03
No known key found for this signature in database
2 changed files with 235 additions and 233 deletions

View file

@ -11,7 +11,12 @@ 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 {
CalendarIcon,
ChevronLeftIcon,
@ -25,6 +30,7 @@ import {
} from "lucide-react";
import { 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";
@ -81,13 +87,7 @@ export function PublicBookingPage() {
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 = () => {
@ -321,244 +321,246 @@ 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">
<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>
{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>
))}
{/* 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>
{getAvailableSlots(selectedDate).length === 0 && (
<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>
@ -572,9 +574,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 +585,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>

View file

@ -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[];