commit
68ce02b268
12 changed files with 1281 additions and 43 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
145
api/src/user.ts
145
api/src/user.ts
|
|
@ -3,8 +3,18 @@ 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 {
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
type S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
|
||||
export const userRouter = new Hono<{
|
||||
Variables: {
|
||||
|
|
@ -12,11 +22,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 +161,81 @@ 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 randomString = Math.random().toString(36).substring(2, 15);
|
||||
const base64Content = Buffer.from(content, "base64");
|
||||
const key = `${user.id}/public_avatar_${randomString}.${
|
||||
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 +244,51 @@ 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 || "",
|
||||
},
|
||||
return c.json({
|
||||
message: "Avatar updated successfully",
|
||||
profile: data,
|
||||
});
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
console.error("Failed to update user metadata:", authError);
|
||||
// Don't fail the request if metadata update fails
|
||||
userRouter.delete("/profile/avatar", async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const s3Client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const listedObjects = await s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: "web-assets",
|
||||
Prefix: `${user.id}/`,
|
||||
})
|
||||
);
|
||||
|
||||
if (listedObjects.Contents.length === 0)
|
||||
return c.json({ error: "No objects found" }, 404);
|
||||
|
||||
await s3Client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: "web-assets",
|
||||
Delete: { Objects: listedObjects.Contents.map(({ Key }) => ({ Key })) },
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete avatar:", error);
|
||||
return c.json({ error: "Failed to delete avatar" }, 500);
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({ avatar_url: null })
|
||||
.eq("id", user.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Profile updated successfully",
|
||||
profile,
|
||||
message: "Avatar deleted successfully",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@
|
|||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
|
|
@ -97,6 +98,7 @@
|
|||
"jspdf": "^3.0.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-easy-crop": "^5.5.3",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-stately": "^3.36.1",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ importers:
|
|||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@radix-ui/react-slider':
|
||||
specifier: ^1.3.6
|
||||
version: 1.3.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(@types/react@19.0.10)(react@19.0.0)
|
||||
|
|
@ -105,6 +108,9 @@ importers:
|
|||
react-day-picker:
|
||||
specifier: ^9.11.1
|
||||
version: 9.11.1(react@19.0.0)
|
||||
react-easy-crop:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
react-hook-form:
|
||||
specifier: ^7.65.0
|
||||
version: 7.65.0(react@19.0.0)
|
||||
|
|
@ -1546,6 +1552,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slider@1.3.6':
|
||||
resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
|
|
@ -4682,6 +4701,9 @@ packages:
|
|||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
normalize-wheel@1.0.1:
|
||||
resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==}
|
||||
|
||||
npm-run-path@4.0.1:
|
||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -4974,6 +4996,12 @@ packages:
|
|||
peerDependencies:
|
||||
react: '>= 16.8 || 18.0.0'
|
||||
|
||||
react-easy-crop@5.5.3:
|
||||
resolution: {integrity: sha512-iKwFTnAsq+IVuyF6N0Q3zjRx9DG1NMySkwWxVfM/xAOeHYH1vhvM+V2kFiq5HOIQGWouITjfltCx54mbDpMpmA==}
|
||||
peerDependencies:
|
||||
react: '>=16.4.0'
|
||||
react-dom: '>=16.4.0'
|
||||
|
||||
react-fast-compare@3.2.2:
|
||||
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
|
||||
|
||||
|
|
@ -7175,6 +7203,25 @@ snapshots:
|
|||
'@types/react': 19.0.10
|
||||
'@types/react-dom': 19.0.4(@types/react@19.0.10)
|
||||
|
||||
'@radix-ui/react-slider@1.3.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.0.10)(react@19.0.0)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.0.10)(react@19.0.0)
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.0.10
|
||||
'@types/react-dom': 19.0.4(@types/react@19.0.10)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@19.0.10)(react@19.0.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
|
||||
|
|
@ -11365,6 +11412,8 @@ snapshots:
|
|||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
normalize-wheel@1.0.1: {}
|
||||
|
||||
npm-run-path@4.0.1:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
|
@ -11674,6 +11723,13 @@ snapshots:
|
|||
prop-types: 15.8.1
|
||||
react: 19.0.0
|
||||
|
||||
react-easy-crop@5.5.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
normalize-wheel: 1.0.1
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
tslib: 2.8.1
|
||||
|
||||
react-fast-compare@3.2.2: {}
|
||||
|
||||
react-hook-form@7.65.0(react@19.0.0):
|
||||
|
|
|
|||
158
ui/src/components/ImageCropDialog.tsx
Normal file
158
ui/src/components/ImageCropDialog.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import Cropper from "react-easy-crop";
|
||||
import type { Area } from "react-easy-crop";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@ui/components/ui/dialog";
|
||||
import { Button } from "@ui/components/ui/button";
|
||||
import { Slider } from "@ui/components/ui/slider";
|
||||
import { Label } from "@ui/components/ui/label";
|
||||
|
||||
interface ImageCropDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
imageSrc: string;
|
||||
onCropComplete: (croppedImage: Blob) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cropped image from the original image and crop area
|
||||
*/
|
||||
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<Blob> {
|
||||
const image = await createImage(imageSrc);
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error("Failed to get canvas context");
|
||||
}
|
||||
|
||||
// Set canvas size to the desired crop size
|
||||
canvas.width = pixelCrop.width;
|
||||
canvas.height = pixelCrop.height;
|
||||
|
||||
// Draw the cropped portion of the image
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height
|
||||
);
|
||||
|
||||
// Convert canvas to blob
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error("Canvas is empty"));
|
||||
}
|
||||
}, "image/jpeg");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an image from a URL
|
||||
*/
|
||||
function createImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener("load", () => resolve(image));
|
||||
image.addEventListener("error", (error) => reject(error));
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export function ImageCropDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
imageSrc,
|
||||
onCropComplete,
|
||||
}: ImageCropDialogProps) {
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
const [isCropping, setIsCropping] = useState(false);
|
||||
|
||||
const onCropAreaChange = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels);
|
||||
}, []);
|
||||
|
||||
const handleCropConfirm = useCallback(async () => {
|
||||
if (!croppedAreaPixels) return;
|
||||
|
||||
try {
|
||||
setIsCropping(true);
|
||||
const croppedImage = await getCroppedImg(imageSrc, croppedAreaPixels);
|
||||
onCropComplete(croppedImage);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Error cropping image:", error);
|
||||
} finally {
|
||||
setIsCropping(false);
|
||||
}
|
||||
}, [croppedAreaPixels, imageSrc, onCropComplete, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Recadrer l'image</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ajustez la position et le zoom pour recadrer votre photo de profil
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative w-full h-[400px] bg-gray-100 dark:bg-gray-900 rounded-lg overflow-hidden">
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={onCropAreaChange}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="zoom" className="text-sm font-medium">
|
||||
Zoom
|
||||
</Label>
|
||||
<Slider
|
||||
id="zoom"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={[zoom]}
|
||||
onValueChange={(value) => setZoom(value[0])}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCropping}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={handleCropConfirm} disabled={isCropping}>
|
||||
{isCropping ? "Traitement..." : "Confirmer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -215,8 +215,9 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
}
|
||||
|
||||
export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const isCollapsable = !isMobileMenuOpen;
|
||||
const [isCollapsed, setIsCollapsed] = useState(!isCollapsable);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Main navigation"
|
||||
|
|
@ -362,10 +363,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
}
|
||||
|
||||
const { path, label, icon, isDisabled } = item;
|
||||
const isActive = location.pathname === path;
|
||||
|
||||
return !isDisabled ? (
|
||||
<li key={label}>
|
||||
<NavLink isActive={location.pathname === path}>
|
||||
<NavLink isActive={isActive}>
|
||||
<RouterLink
|
||||
to={path}
|
||||
className="w-full"
|
||||
|
|
@ -375,7 +377,8 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
{icon}
|
||||
<TypographyLarge
|
||||
className={twMerge(
|
||||
"text-sm transition-all duration-300 font-normal text-gray-300/90",
|
||||
"text-sm transition-all duration-300 font-normal",
|
||||
isActive ? "text-white" : "text-gray-300/90",
|
||||
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
|
||||
)}
|
||||
>
|
||||
|
|
@ -423,7 +426,8 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
<SendIcon className="w-5 h-5" aria-hidden="true" />
|
||||
<TypographyLarge
|
||||
className={twMerge(
|
||||
"text-sm transition-all duration-300 font-normal text-gray-300/90",
|
||||
"text-sm transition-all duration-300 font-normal",
|
||||
location.pathname === "/feedback" ? "text-white" : "text-gray-300/90",
|
||||
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
24
ui/src/components/ui/slider.tsx
Normal file
24
ui/src/components/ui/slider.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import * as React from "react";
|
||||
import { cn } from "@ui/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
|
|
@ -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,109 @@ 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"] });
|
||||
},
|
||||
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 };
|
||||
};
|
||||
|
||||
export const useRemoveAvatar = () => {
|
||||
const { session } = useSession();
|
||||
const { mutateAsync, isPending } = useMutation({
|
||||
mutationFn: async () => {
|
||||
await api.delete("/api/v1/users/profile/avatar", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.add({
|
||||
title: "Avatar supprimé",
|
||||
description: "Votre photo de profil a été supprimée avec succès",
|
||||
type: "success",
|
||||
position: "top-center",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
},
|
||||
});
|
||||
return { mutateAsync, isPending };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: "/",
|
||||
|
|
|
|||
591
ui/src/pages/EmbeddedBookingPage.tsx
Normal file
591
ui/src/pages/EmbeddedBookingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,11 +3,15 @@ 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 { TypographyH3, TypographyMuted } from "src/components/ui/typography";
|
||||
import { useState, useRef } from "react";
|
||||
import { TypographyH3, TypographyMuted, TypographySmall } from "@ui/components/ui/typography";
|
||||
import { useIntroduction } from "src/hooks/intros";
|
||||
import { useUpdateProfile } from "src/hooks/profile";
|
||||
import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "@ui/hooks/profile";
|
||||
import { CameraIcon, Trash2Icon, UploadIcon } from "lucide-react";
|
||||
import { toast } from "@ui/lib/toast";
|
||||
import { ImageCropDialog } from "@ui/components/ImageCropDialog";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const user = useUser();
|
||||
|
|
@ -18,9 +22,87 @@ export default function SettingsPage() {
|
|||
isPending: updateIntroductionPending,
|
||||
} = useIntroduction();
|
||||
const { mutate: updateProfile, isPending: updateProfilePending } = useUpdateProfile();
|
||||
const { mutate: uploadAvatar } = useUploadAvatar();
|
||||
const { mutateAsync: removeAvatar } = useRemoveAvatar();
|
||||
|
||||
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 [imageToCrop, setImageToCrop] = useState<string | null>(null);
|
||||
const [isCropDialogOpen, setIsCropDialogOpen] = useState(false);
|
||||
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;
|
||||
}
|
||||
|
||||
// Load image for cropping
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImageToCrop(reader.result as string);
|
||||
setIsCropDialogOpen(true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = async () => {
|
||||
await removeAvatar();
|
||||
setAvatarPreview(null);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadAvatar = () => {
|
||||
if (selectedFile) {
|
||||
uploadAvatar({ file: selectedFile });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropComplete = (croppedImageBlob: Blob) => {
|
||||
// Convert blob to File
|
||||
const croppedFile = new File([croppedImageBlob], "avatar.jpg", {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
|
||||
setSelectedFile(croppedFile);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(croppedImageBlob);
|
||||
|
||||
// Reset file input to allow selecting the same file again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
|
|
@ -28,6 +110,103 @@ 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>
|
||||
|
||||
{imageToCrop && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsCropDialogOpen(true)}
|
||||
>
|
||||
<CameraIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</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 && (
|
||||
<>
|
||||
{selectedFile && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleUploadAvatar}
|
||||
className="gap-2 bg-gradient-to-r from-purple-500/80 to-blue-500/80 hover:from-purple-500 hover:to-blue-500 dark:from-purple-500/80 dark:to-blue-500/80 dark:hover:from-purple-500 dark:hover:to-blue-500"
|
||||
>
|
||||
<TypographySmall>Enregistrer l'avatar</TypographySmall>
|
||||
</Button>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
|
@ -117,6 +296,16 @@ export default function SettingsPage() {
|
|||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Crop Dialog */}
|
||||
{imageToCrop && (
|
||||
<ImageCropDialog
|
||||
open={isCropDialogOpen}
|
||||
onOpenChange={setIsCropDialogOpen}
|
||||
imageSrc={imageToCrop}
|
||||
onCropComplete={handleCropComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue