Upload avatar

This commit is contained in:
Arthur Belleville 2025-10-18 23:43:42 +02:00
parent babf1c49af
commit 599633b29d
No known key found for this signature in database
7 changed files with 923 additions and 42 deletions

View file

@ -30,7 +30,8 @@ function getCETTime(): Date {
const parts = formatter.formatToParts(utcNow);
const year = parseInt(parts.find((p) => p.type === "year")?.value || "0");
const month = parseInt(parts.find((p) => p.type === "month")?.value || "0") - 1; // Month is 0-indexed
const month =
parseInt(parts.find((p) => p.type === "month")?.value || "0") - 1; // Month is 0-indexed
const day = parseInt(parts.find((p) => p.type === "day")?.value || "0");
const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0");
const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0");
@ -78,22 +79,25 @@ publicRouter.get("/slots/:shortUserId/:standardName", async (c) => {
return c.json({ error: "Event type not found" }, 404);
}
const eventType = eventTypeData as Database["public"]["Tables"]["event_types"]["Row"];
const eventType =
eventTypeData as Database["public"]["Tables"]["event_types"]["Row"];
const eventTypeConfig = eventType.config as EventTypeConfig;
// Get user's availabilities
const { data: availabilitiesData, error: availabilitiesError } = await supabase
.from("availabilities")
.select("*")
.eq("user_id", user.id)
.single();
const { data: availabilitiesData, error: availabilitiesError } =
await supabase
.from("availabilities")
.select("*")
.eq("user_id", user.id)
.single();
if (availabilitiesError) {
return c.json({ error: "Availabilities not found" }, 404);
}
const availabilities = availabilitiesData as Tables<"availabilities">;
const weeklyAvailability = availabilities.availability_data as WeeklyAvailability;
const weeklyAvailability =
availabilities.availability_data as WeeklyAvailability;
const exceptions = (availabilities.exceptions as Exception[]) || [];
// Get existing events for the next month
@ -149,7 +153,7 @@ publicRouter.get("/slots/:shortUserId/:standardName", async (c) => {
});
return c.json({
user: { name: user.name },
user: { name: user.name, avatar_url: user.avatar_url },
eventType: eventTypeConfig,
slots: slotsByDate,
availableSlots: slots.filter((slot) => slot.available),

View file

@ -1,4 +1,4 @@
import type { S3Client } from "@aws-sdk/client-s3";
import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import { type Context, Hono, type Next } from "hono";
import { getTabloFileNames, isTabloAdmin, isTabloMember } from "./helpers.js";
@ -106,8 +106,6 @@ tabloDataRouter.post("/:tabloId/:fileName", checkTabloAdmin, async (c) => {
return c.json({ error: "Content is required" }, 400);
}
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
await s3_client.send(
new PutObjectCommand({
Bucket: "tablo-data",

View file

@ -3,8 +3,13 @@ import { Hono } from "hono";
import type { Transporter } from "nodemailer";
import { StreamChat } from "stream-chat";
import type { Tables } from "./database.types.ts";
import { authMiddleware, streamChatMiddleware } from "./middleware.js";
import {
authMiddleware,
r2Middleware,
streamChatMiddleware,
} from "./middleware.js";
import { transporter } from "./transporter.js";
import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
export const userRouter = new Hono<{
Variables: {
@ -12,11 +17,13 @@ export const userRouter = new Hono<{
supabase: SupabaseClient;
transporter: Transporter;
streamServerClient: StreamChat;
s3_client: S3Client;
};
}>();
userRouter.use(authMiddleware);
userRouter.use(streamChatMiddleware);
userRouter.use(r2Middleware);
userRouter.post("/sign-up-to-stream", async (c) => {
const { id } = c.get("user");
@ -149,23 +156,78 @@ L'équipe XTablo`,
});
});
userRouter.put("/profile", async (c) => {
// userRouter.put("/profile", async (c) => {
// const user = c.get("user");
// const supabase = c.get("supabase");
// const body = await c.req.json();
// const { first_name, last_name } = body;
// // Deprecated: name field is deprecated, use first_name and last_name instead
// // Combine first_name and last_name into a single name field
// const name = [first_name, last_name].filter(Boolean).join(" ");
// const updateData =
// first_name && last_name
// ? {
// name,
// first_name,
// last_name,
// }
// : {};
// const { data: profile, error } = await supabase
// .from("profiles")
// .update(updateData)
// .eq("id", user.id)
// .select()
// .single();
// if (error) {
// return c.json({ error: error.message }, 500);
// }
// return c.json({
// message: "Profile updated successfully",
// profile,
// });
// });
userRouter.post("/profile/avatar", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3Client = c.get("s3_client");
const body = await c.req.json();
const { first_name, last_name, introduction_email } = body;
const { content, contentType = "image/jpeg" } = body;
// Combine first_name and last_name into a single name field
const name = [first_name, last_name].filter(Boolean).join(" ");
if (!content) {
return c.json({ error: "Content is required" }, 400);
}
const { data: profile, error } = await supabase
const base64Content = Buffer.from(content, "base64");
const key = `${user.id}/public_avatar.${contentType.split("/")[1]}`;
try {
await s3Client.send(
new PutObjectCommand({
Bucket: "web-assets",
Key: key,
Body: base64Content,
ContentType: contentType,
ContentEncoding: "base64",
})
);
} catch (error) {
console.error("Failed to upload avatar:", error);
return c.json({ error: "Failed to upload avatar" }, 500);
}
const avatarUrl = `https://assets.xtablo.com/${key}`;
const { data, error } = await supabase
.from("profiles")
.update({
name: name || null,
first_name: first_name || null,
last_name: last_name || null,
})
.update({ avatar_url: avatarUrl })
.eq("id", user.id)
.select()
.single();
@ -174,22 +236,8 @@ userRouter.put("/profile", async (c) => {
return c.json({ error: error.message }, 500);
}
// Update user metadata in Supabase Auth using updateUser
const { error: authError } = await supabase.auth.updateUser({
data: {
first_name: first_name || "",
last_name: last_name || "",
introduction_email: introduction_email || "",
},
});
if (authError) {
console.error("Failed to update user metadata:", authError);
// Don't fail the request if metadata update fails
}
return c.json({
message: "Profile updated successfully",
profile,
message: "Avatar updated successfully",
profile: data,
});
});

View file

@ -1,8 +1,9 @@
import { QueryKey, useMutation } from "@tanstack/react-query";
import { supabase } from "@ui/hooks/auth";
import { api, queryClient } from "@ui/lib/api";
import { toast } from "@ui/lib/toast";
import { useUser } from "@ui/providers/UserStoreProvider";
import { queryClient } from "src/lib/api";
import { useSession } from "@ui/contexts/SessionContext";
/**
* Hook to update user profile using Supabase client
@ -57,3 +58,86 @@ export function useUpdateProfile() {
});
return { mutate, isPending };
}
type FileUploadRequest = {
content: string; // base64 encoded file content
contentType: string; // MIME type (e.g., "image/jpeg")
filename: string; // Original filename
};
export const useUploadAvatar = () => {
const { session } = useSession();
const { mutate, isPending } = useMutation({
mutationFn: async ({ file }: { file: File | null }) => {
if (!file) {
throw new Error("No file selected");
}
// Read file as base64 using FileReader
const base64Content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
// Remove data URL prefix (e.g., "data:image/jpeg;base64,")
const base64 = reader.result.split(",")[1];
resolve(base64);
} else {
reject(new Error("Failed to read file"));
}
};
reader.onerror = () => {
reject(new Error("Error reading file"));
};
reader.readAsDataURL(file);
});
// Prepare upload request
const uploadRequest: FileUploadRequest = {
content: base64Content,
contentType: file.type || "image/jpeg",
filename: file.name,
};
// Upload to backend
const response = await api.post(
"/api/v1/users/profile/avatar",
uploadRequest,
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
"Content-Type": "application/json",
},
}
);
if (response.status !== 200) {
throw new Error("Failed to upload avatar");
}
return response.data;
},
onSuccess: () => {
toast.add({
title: "Avatar mis à jour",
description: "Votre photo de profil a été mise à jour avec succès",
type: "success",
position: "top-center",
});
// Refresh user data
queryClient.invalidateQueries({ queryKey: ["user"] as QueryKey });
},
onError: (error: Error) => {
toast.add({
title: "Erreur",
description:
error.message || "Une erreur est survenue lors de l'upload",
type: "error",
position: "top-center",
});
},
});
return { mutate, isPending };
};

View file

@ -14,6 +14,7 @@ import { LoginPage } from "@ui/pages/login";
import { NotFoundPage } from "@ui/pages/NotFoundPage";
import { OAuthSigninPage } from "@ui/pages/oauth-signin";
import { PublicBookingPage } from "@ui/pages/PublicBookingPage";
import { EmbeddedBookingPage } from "@ui/pages/EmbeddedBookingPage";
import { PlanningPage } from "@ui/pages/planning";
import { ResetPasswordPage } from "@ui/pages/reset-password";
import SettingsPage from "@ui/pages/settings";
@ -127,6 +128,11 @@ export const routes: RouteObject[] = [
path: "/book/:user_info/:event_type_standard_name",
element: <PublicBookingPage />,
},
// Embedded booking route (for iframe integration)
{
path: "/embed/book/:user_info/:event_type_standard_name",
element: <EmbeddedBookingPage />,
},
// Authentication pages (redirected to "/" if user is authenticated)
{
path: "/",

View file

@ -0,0 +1,591 @@
import { CustomModal } from "@ui/components/CustomModal";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { Button } from "@ui/components/ui/button";
import { FieldError } from "@ui/components/ui/field";
import { Input } from "@ui/components/ui/input";
import { Label } from "@ui/components/ui/label";
import { Text, TypographyH3, TypographyH4, TypographyMuted } from "@ui/components/ui/typography";
import { useSession } from "@ui/contexts/SessionContext";
import { useSignUpWithoutPassword } from "@ui/hooks/auth";
import { TimeSlot, usePublicSlots } from "@ui/hooks/public";
import { useCreateTabloWithOwner } from "@ui/hooks/tablos";
import { useMaybeUser } from "@ui/providers/UserStoreProvider";
import { EventInsertInTablo } from "@ui/types/events.types";
import {
CalendarIcon,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
MapPinIcon,
UserIcon,
} from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
export function EmbeddedBookingPage() {
const { user_info, event_type_standard_name } = useParams<{
user_info: string;
event_type_standard_name: string;
}>();
const { mutateAsync: signUpWithoutPassword } = useSignUpWithoutPassword();
const { session } = useSession();
const user = useMaybeUser();
const shortUserId = user_info?.substring(user_info.lastIndexOf("-") + 1);
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
shortUserId || "",
event_type_standard_name || ""
);
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner();
const userProfile = publicSlots?.user;
console.log(userProfile);
const eventType = publicSlots?.eventType;
const slotsData = publicSlots?.slots || {};
// Calendar state
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedSlot, setSelectedSlot] = useState<{
date: Date;
slot: TimeSlot;
} | null>(null);
const [formData, setFormData] = useState({
email: "",
name: "",
});
const [formErrors, setFormErrors] = useState({
email: "",
name: "",
});
// Helper function to convert date to CET timezone string (YYYY-MM-DD)
const formatDateToCET = (date: Date): string => {
return date.toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
};
// Helper function to get current date in CET timezone
const getCurrentDateInCET = (): Date => {
const now = new Date();
const cetTime = new Date(now.toLocaleString("en-US", { timeZone: "Europe/Paris" }));
return cetTime;
};
// Get available time slots for a specific date
const getAvailableSlots = (date: Date): TimeSlot[] => {
const dateStr = formatDateToCET(date);
return slotsData[dateStr]?.filter((slot) => slot.available) || [];
};
// Check if a date has any available slots
const hasAvailableSlots = (date: Date): boolean => {
const dateStr = formatDateToCET(date);
return slotsData[dateStr]?.some((slot) => slot.available) || false;
};
// Calendar helper functions
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth();
// Create first day of month and get its day of week in CET
const firstDayStr = `${year}-${String(month + 1).padStart(2, "0")}-01`;
const firstDay = new Date(`${firstDayStr}T12:00:00`);
const firstDayOfWeekInCET = new Date(
firstDay.toLocaleString("en-US", { timeZone: "Europe/Paris" })
).getDay();
// Adjust for Monday as first day of week
const mondayStartingDay = firstDayOfWeekInCET === 0 ? 6 : firstDayOfWeekInCET - 1;
// Get number of days in month
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const days = [];
// Add empty cells for days before the first day of the month
for (let i = 0; i < mondayStartingDay; i++) {
days.push(null);
}
// Add all days of the month
for (let day = 1; day <= daysInMonth; day++) {
const dayStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(
2,
"0"
)}`;
days.push(new Date(`${dayStr}T12:00:00`));
}
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 todayInCET = getCurrentDateInCET();
const todayStr = formatDateToCET(todayInCET);
const dateStr = formatDateToCET(date);
return dateStr === todayStr;
};
const isPastDate = (date: Date) => {
const todayInCET = getCurrentDateInCET();
const todayStr = formatDateToCET(todayInCET);
const dateStr = formatDateToCET(date);
return dateStr < todayStr;
};
const formatMonthYear = (date: Date) => {
return date.toLocaleDateString("fr-FR", { month: "long", year: "numeric" });
};
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`;
};
// Modal and form handlers
const handleSlotClick = (date: Date, slot: TimeSlot) => {
setSelectedSlot({ date, slot });
setIsModalOpen(true);
setFormData({ email: "", name: "" });
setFormErrors({ email: "", name: "" });
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedSlot(null);
setFormData({ email: "", name: "" });
setFormErrors({ email: "", name: "" });
};
const validateForm = () => {
const errors = { email: "", name: "" };
let isValid = true;
if (!formData.email.trim()) {
errors.email = "L'adresse email est requise";
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = "Veuillez entrer une adresse email valide";
isValid = false;
}
if (!formData.name.trim()) {
errors.name = "Le nom est requis";
isValid = false;
}
setFormErrors(errors);
return isValid;
};
// Calculate end time based on start time and duration
const calculateEndTime = (startTime: string, durationMinutes: number): string => {
if (!startTime) return "";
const [hours, minutes] = startTime.split(":").map(Number);
const startDate = new Date();
startDate.setHours(hours, minutes, 0, 0);
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
return endDate.toTimeString().slice(0, 5); // Format as HH:MM
};
const handleSubmitIfNotLoggedIn = async () => {
if (validateForm()) {
const { session: sessionFromSignUp } = await signUpWithoutPassword({
email: formData.email,
name: formData.name,
});
const startTime = selectedSlot?.slot.time || "";
const duration = eventType?.duration || 60; // duration in minutes
const endTime = calculateEndTime(startTime, duration);
await createTabloWithOwner({
name: eventType?.name || "",
status: "todo",
owner_short_id: shortUserId || "",
event: {
description: eventType?.description || "",
end_time: endTime || "",
start_date: selectedSlot?.slot.date || "",
start_time: selectedSlot?.slot.time || "",
title: eventType?.name || "",
} as EventInsertInTablo,
access_token: sessionFromSignUp?.access_token || "",
});
handleCloseModal();
}
};
const handleSubmitIfLoggedIn = async () => {
if (user) {
const startTime = selectedSlot?.slot.time || "";
const duration = eventType?.duration || 60; // duration in minutes
const endTime = calculateEndTime(startTime, duration);
await createTabloWithOwner({
name: eventType?.name || "",
status: "todo",
owner_short_id: shortUserId || "",
event: {
description: eventType?.description || "",
end_time: endTime || "",
start_date: selectedSlot?.slot.date || "",
start_time: selectedSlot?.slot.time || "",
title: eventType?.name || "",
} as EventInsertInTablo,
access_token: session?.access_token || "",
});
handleCloseModal();
}
};
if (isLoadingSlots) {
return (
<div className="w-[1130px] h-[700px] flex items-center justify-center bg-gray-50 dark:from-gray-900 dark:to-gray-800">
<div className="text-center">
<LoadingSpinner />
<p className="mt-4 text-gray-600 dark:text-gray-400">Chargement des disponibilités...</p>
</div>
</div>
);
}
return (
<div className="w-[1130px] h-[700px] p-6 bg-gray-50 dark:bg-gray-900 overflow-hidden">
<div className="h-full bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 flex overflow-hidden">
{/* Left Side - Event Details */}
<div className="w-[400px] bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 p-8 flex flex-col text-white relative overflow-hidden">
{/* Subtle purple accent overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-purple-600/5 via-transparent to-purple-600/10 pointer-events-none"></div>
<div className="relative z-10 flex flex-col h-full">
{/* Logo */}
<div className="mb-8">
<img src="/logo_white.png" alt="Xtablo" className="h-10 w-auto" />
</div>
{/* User Profile */}
<div className="mb-8">
{(userProfile as { name: string; avatar_url?: string })?.avatar_url ? (
<img
src={(userProfile as { name: string; avatar_url?: string }).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>
)}
<h2 className="text-2xl font-bold mb-1">{userProfile?.name || "Professionnel"}</h2>
</div>
{/* Event Type Info */}
<div className="flex-1">
<h3 className="text-xl font-bold mb-3">{eventType?.name || "Type d'événement"}</h3>
{eventType?.description && (
<p className="text-white/90 mb-6 text-sm leading-relaxed">
{eventType.description}
</p>
)}
<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 flex-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 flex-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 flex-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-white text-sm">{eventType.location}</p>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="mt-auto pt-6 border-t border-gray-700/50">
<TypographyMuted className="text-xs text-gray-500">
Powered by{" "}
<a
href="https://www.xtablo.com"
target="_blank"
rel="noopener noreferrer"
className="text-purple-400/60 hover:underline"
>
XTablo
</a>
</TypographyMuted>
</div>
</div>
</div>
{/* Right Side - Calendar & Booking */}
<div className="flex-1 flex flex-col p-6">
{/* Calendar Section */}
<div className="flex-1 flex gap-4">
{/* Calendar */}
<div className="flex-1">
<div className="flex items-center justify-between mb-4">
<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>
{/* Calendar Grid */}
<div className="grid grid-cols-7 gap-1 mb-2">
{["L", "M", "M", "J", "V", "S", "D"].map((day, i) => (
<div
key={i}
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-gray-900 dark:bg-white text-white dark:text-gray-900 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-56 border-l border-gray-200 dark:border-gray-700 pl-4">
<TypographyH4 className="font-semibold text-gray-900 dark:text-white mb-3">
{selectedDate ? (
<>
Créneaux disponibles
<br />
<TypographyMuted className="text-base font-normal text-gray-500 dark:text-gray-400">
{selectedDate.toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})}
</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-gray-900 dark:hover:bg-white hover:text-white dark:hover:text-gray-900 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
</Text>
</div>
)}
</div>
) : (
<div className="text-center py-8">
<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
</Text>
</div>
)}
</div>
</div>
</div>
</div>
{/* Booking Modal */}
<CustomModal
isOpen={isModalOpen}
onClose={handleCloseModal}
title={user ? "Confirmer la réservation" : "Créer un compte pour réserver"}
width="md"
>
{selectedSlot && (
<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",
day: "numeric",
month: "long",
})}
</Text>
</div>
<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>
)}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">
Nom complet <span className="text-red-500">*</span>
</Label>
<Input
id="name"
type="text"
placeholder="Votre nom complet"
value={user?.name || formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
disabled={!!user}
/>
{formErrors.name && <FieldError errors={[{ message: formErrors.name }]} />}
</div>
<div className="space-y-2">
<Label htmlFor="email">
Adresse email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
placeholder="votre@email.com"
value={user?.email || formData.email}
onChange={(e) => setFormData((prev) => ({ ...prev, email: e.target.value }))}
disabled={!!user}
/>
{formErrors.email && <FieldError errors={[{ message: formErrors.email }]} />}
</div>
{!user && (
<div className="pt-2">
<Text className="text-sm text-gray-600 dark:text-gray-400">
Un compte sera créé avec ces informations pour gérer votre réservation.
</Text>
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button variant="outline" onClick={handleCloseModal}>
Annuler
</Button>
<Button
variant="default"
onClick={user ? handleSubmitIfLoggedIn : handleSubmitIfNotLoggedIn}
>
{user ? "Confirmer la réservation" : "Créer le compte et réserver"}
</Button>
</div>
</CustomModal>
</div>
);
}

View file

@ -3,11 +3,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@ui/c
import { Input } from "@ui/components/ui/input";
import { Label } from "@ui/components/ui/label";
import { Textarea } from "@ui/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/ui/avatar";
import { useUser } from "@ui/providers/UserStoreProvider";
import { useState } from "react";
import { useState, useRef } from "react";
import { TypographyH3, TypographyMuted } from "src/components/ui/typography";
import { useIntroduction } from "src/hooks/intros";
import { useUpdateProfile } from "src/hooks/profile";
import { useUpdateProfile, useUploadAvatar } from "src/hooks/profile";
import { Trash2Icon, UploadIcon } from "lucide-react";
import { toast } from "src/lib/toast";
export default function SettingsPage() {
const user = useUser();
@ -18,9 +21,63 @@ export default function SettingsPage() {
isPending: updateIntroductionPending,
} = useIntroduction();
const { mutate: updateProfile, isPending: updateProfilePending } = useUpdateProfile();
const { mutate: uploadAvatar } = useUploadAvatar();
const [firstName, setFirstName] = useState(user?.first_name || "");
const [lastName, setLastName] = useState(user?.last_name || "");
const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.avatar_url || null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Check file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast.add({
title: "Erreur",
description: "Le fichier est trop volumineux. Maximum 5MB.",
type: "error",
position: "top-center",
});
return;
}
// Check file type
if (!file.type.startsWith("image/")) {
toast.add({
title: "Erreur",
description: "Veuillez sélectionner une image valide.",
type: "error",
position: "top-center",
});
return;
}
setSelectedFile(file);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleRemoveAvatar = () => {
setAvatarPreview(null);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleUploadAvatar = () => {
if (selectedFile) {
uploadAvatar({ file: selectedFile });
}
};
return (
<div className="min-h-screen bg-background">
@ -28,6 +85,99 @@ export default function SettingsPage() {
<TypographyH3>Paramètres</TypographyH3>
<TypographyMuted>Gérez vos informations personnelles et vos préférences</TypographyMuted>
<div className="space-y-6 mt-6">
{/* Avatar Section */}
<Card>
<CardHeader>
<CardTitle>Photo de profil</CardTitle>
<CardDescription>Personnalisez votre avatar</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-start gap-6">
{/* Avatar Preview */}
<div className="relative group">
<Avatar className="w-32 h-32 ring-4 ring-gray-100 dark:ring-gray-800">
<AvatarImage src={avatarPreview || undefined} alt="Avatar" />
<AvatarFallback className="text-3xl bg-gradient-to-br from-purple-500 to-blue-500 text-white">
{user?.first_name?.charAt(0).toUpperCase() ||
user?.name?.charAt(0).toUpperCase() ||
"U"}
</AvatarFallback>
</Avatar>
{/* Camera overlay on hover
{!avatarPreview && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
<CameraIcon className="w-8 h-8 text-white" />
</div>
)} */}
</div>
{/* Upload Controls */}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<Label htmlFor="avatar-upload" className="text-sm font-medium">
Choisir une image
</Label>
<p className="text-xs text-muted-foreground">
JPG, PNG ou GIF. Maximum 5MB. Recommandé : 400x400px
</p>
</div>
<Input
ref={fileInputRef}
id="avatar-upload"
type="file"
accept="image/*"
onChange={handleAvatarChange}
hidden
/>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="gap-2"
>
<UploadIcon className="w-4 h-4" />
Choisir un fichier
</Button>
{avatarPreview && (
<>
<Button
variant="outline"
size="sm"
onClick={handleRemoveAvatar}
className="gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
>
<Trash2Icon className="w-4 h-4" />
Supprimer
</Button>
{selectedFile && (
<Button
size="sm"
onClick={handleUploadAvatar}
className="gap-2 bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700"
>
Enregistrer l'avatar
</Button>
)}
</>
)}
</div>
{selectedFile && (
<p className="text-xs text-green-600 dark:text-green-400">
Fichier sélectionné : {selectedFile.name}
</p>
)}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Informations personnelles</CardTitle>