diff --git a/api/src/public.ts b/api/src/public.ts index 43604c0..5843249 100644 --- a/api/src/public.ts +++ b/api/src/public.ts @@ -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), diff --git a/api/src/tablo_data.ts b/api/src/tablo_data.ts index c7ec936..aee1ea0 100644 --- a/api/src/tablo_data.ts +++ b/api/src/tablo_data.ts @@ -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", diff --git a/api/src/user.ts b/api/src/user.ts index 82cfd35..d879537 100644 --- a/api/src/user.ts +++ b/api/src/user.ts @@ -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, }); }); diff --git a/ui/src/hooks/profile.ts b/ui/src/hooks/profile.ts index 12abb88..dc9ae5d 100644 --- a/ui/src/hooks/profile.ts +++ b/ui/src/hooks/profile.ts @@ -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((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 }; +}; diff --git a/ui/src/lib/routes.tsx b/ui/src/lib/routes.tsx index 8d4ef60..c9d749d 100644 --- a/ui/src/lib/routes.tsx +++ b/ui/src/lib/routes.tsx @@ -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: , }, + // Embedded booking route (for iframe integration) + { + path: "/embed/book/:user_info/:event_type_standard_name", + element: , + }, // Authentication pages (redirected to "/" if user is authenticated) { path: "/", diff --git a/ui/src/pages/EmbeddedBookingPage.tsx b/ui/src/pages/EmbeddedBookingPage.tsx new file mode 100644 index 0000000..12827d3 --- /dev/null +++ b/ui/src/pages/EmbeddedBookingPage.tsx @@ -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(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 ( +
+
+ +

Chargement des disponibilités...

+
+
+ ); + } + + return ( +
+
+ {/* Left Side - Event Details */} +
+ {/* Subtle purple accent overlay */} +
+
+ {/* Logo */} +
+ Xtablo +
+ + {/* User Profile */} +
+ {(userProfile as { name: string; avatar_url?: string })?.avatar_url ? ( + {userProfile?.name + ) : ( +
+ +
+ )} +

{userProfile?.name || "Professionnel"}

+
+ + {/* Event Type Info */} +
+

{eventType?.name || "Type d'événement"}

+ + {eventType?.description && ( +

+ {eventType.description} +

+ )} + +
+ {eventType?.duration && ( +
+
+ +
+
+

Durée

+

+ {formatDuration(eventType.duration)} +

+
+
+ )} + + {eventType?.price && ( +
+
+ +
+
+

Prix

+

{eventType.price}€

+
+
+ )} + + {eventType?.location && ( +
+
+ +
+
+

Lieu

+

{eventType.location}

+
+
+ )} +
+
+ + {/* Footer */} +
+ + Powered by{" "} + + XTablo + + +
+
+
+ + {/* Right Side - Calendar & Booking */} +
+ {/* Calendar Section */} +
+ {/* Calendar */} +
+
+ + {formatMonthYear(currentDate)} + +
+ + +
+
+ + {/* Calendar Grid */} +
+ {["L", "M", "M", "J", "V", "S", "D"].map((day, i) => ( +
+ {day} +
+ ))} +
+ +
+ {getDaysInMonth(currentDate).map((date, index) => ( +
+ {date ? ( + + ) : ( +
+ )} +
+ ))} +
+
+ + {/* Time Slots */} +
+ + {selectedDate ? ( + <> + Créneaux disponibles +
+ + {selectedDate.toLocaleDateString("fr-FR", { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + })} + + + ) : ( + "Sélectionnez une date" + )} +
+ + {selectedDate ? ( +
+ {getAvailableSlots(selectedDate).map((slot, index) => ( + + ))} + + {getAvailableSlots(selectedDate).length === 0 && ( +
+ + Aucun créneau disponible + +
+ )} +
+ ) : ( +
+ + + Choisissez une date + +
+ )} +
+
+
+
+ + {/* Booking Modal */} + + {selectedSlot && ( +
+
+ + + {selectedSlot.date.toLocaleDateString("fr-FR", { + weekday: "long", + day: "numeric", + month: "long", + })} + +
+
+ + {selectedSlot.slot.time} +
+
+ )} + +
+
+ + setFormData((prev) => ({ ...prev, name: e.target.value }))} + disabled={!!user} + /> + {formErrors.name && } +
+ +
+ + setFormData((prev) => ({ ...prev, email: e.target.value }))} + disabled={!!user} + /> + {formErrors.email && } +
+ + {!user && ( +
+ + Un compte sera créé avec ces informations pour gérer votre réservation. + +
+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 9e788ca..9d9d01f 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -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(user?.avatar_url || null); + const [selectedFile, setSelectedFile] = useState(null); + const fileInputRef = useRef(null); + + const handleAvatarChange = (e: React.ChangeEvent) => { + 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 (
@@ -28,6 +85,99 @@ export default function SettingsPage() { Paramètres Gérez vos informations personnelles et vos préférences
+ {/* Avatar Section */} + + + Photo de profil + Personnalisez votre avatar + + +
+ {/* Avatar Preview */} +
+ + + + {user?.first_name?.charAt(0).toUpperCase() || + user?.name?.charAt(0).toUpperCase() || + "U"} + + + + {/* Camera overlay on hover + {!avatarPreview && ( +
+ +
+ )} */} +
+ + {/* Upload Controls */} +
+
+ +

+ JPG, PNG ou GIF. Maximum 5MB. Recommandé : 400x400px +

+
+ + + +
+ + + {avatarPreview && ( + <> + + + {selectedFile && ( + + )} + + )} +
+ + {selectedFile && ( +

+ ✓ Fichier sélectionné : {selectedFile.name} +

+ )} +
+
+
+
+ Informations personnelles