Prod ready let's goooo
This commit is contained in:
parent
f5e02a50fa
commit
3d040c065a
6 changed files with 212 additions and 78 deletions
144
api/src/tablo.ts
144
api/src/tablo.ts
|
|
@ -123,13 +123,13 @@ tabloRouter.post("/create-and-invite", async (c) => {
|
|||
// TODO: Verify that the owner_id is correct
|
||||
const { data: ownerData, error: ownerError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, name")
|
||||
.select("id, name, email")
|
||||
.eq("short_user_id", typedPayload.owner_short_id)
|
||||
.single();
|
||||
|
||||
const { data: invitedUser, error: invitedUserError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, name")
|
||||
.select("id, name, email")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
|
|
@ -137,40 +137,68 @@ tabloRouter.post("/create-and-invite", async (c) => {
|
|||
return c.json({ error: "owner_id is incorrect" }, 400);
|
||||
}
|
||||
|
||||
const ownerDataTyped = ownerData as { id: string; name: string };
|
||||
const ownerDataTyped = ownerData as {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
const ownerId = ownerDataTyped.id;
|
||||
const invitedUserDataTyped = invitedUser as { id: string; name: string };
|
||||
const invitedUserDataTyped = invitedUser as {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
// TODO: Verify that the event start and end correspond to a slot
|
||||
|
||||
// Create the tablo with the specified owner
|
||||
const { data: insertedTablo, error } = await supabase
|
||||
// Check if there's already a tablo between the owner and the invited user
|
||||
const { data: existingTablo, error: existingTabloError } = await supabase
|
||||
.from("tablos")
|
||||
.insert({
|
||||
name: `${ownerDataTyped.name || "Propriétaire"} / ${
|
||||
invitedUserDataTyped.name || "Invité"
|
||||
}`,
|
||||
color: "bg-blue-500",
|
||||
status: "todo",
|
||||
owner_id: ownerId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
name,
|
||||
owner_id,
|
||||
tablo_access!inner(user_id)
|
||||
`
|
||||
)
|
||||
.eq("owner_id", ownerId)
|
||||
.eq("tablo_access.user_id", user.id)
|
||||
.is("deleted_at", null)
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
if (existingTabloError) {
|
||||
console.error("existingTabloError", existingTabloError);
|
||||
return c.json({ error: existingTabloError.message }, 500);
|
||||
}
|
||||
|
||||
const tabloData = insertedTablo as Tables<"tablos">;
|
||||
console.log("existingTablo", existingTablo);
|
||||
|
||||
// Create Stream chat channel with the owner as creator
|
||||
const channel = streamServerClient.channel("messaging", tabloData.id, {
|
||||
// @ts-ignore
|
||||
name: tabloData.name,
|
||||
created_by_id: ownerId,
|
||||
members: [ownerId, user.id],
|
||||
});
|
||||
await channel.create();
|
||||
let tabloData: { id: string; name: string } | null = null;
|
||||
|
||||
if (!existingTablo.length) {
|
||||
// Create the tablo with the specified owner
|
||||
const { data: insertedTablo, error } = await supabase
|
||||
.from("tablos")
|
||||
.insert({
|
||||
name: `${ownerDataTyped.name || "Propriétaire"} / ${
|
||||
invitedUserDataTyped.name || "Invité"
|
||||
}`,
|
||||
color: "bg-blue-500",
|
||||
status: "todo",
|
||||
owner_id: ownerId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
|
||||
tabloData = insertedTablo as { id: string; name: string };
|
||||
} else {
|
||||
tabloData = existingTablo[0] as { id: string; name: string };
|
||||
}
|
||||
|
||||
// Grant access to the current user (invited user) as a non-admin member
|
||||
const { error: tabloAccessError } = await supabase
|
||||
|
|
@ -190,13 +218,73 @@ tabloRouter.post("/create-and-invite", async (c) => {
|
|||
return c.json({ error: tabloAccessError.message }, 500);
|
||||
}
|
||||
|
||||
// Create Stream chat channel with the owner as creator
|
||||
const channel = streamServerClient.channel("messaging", tabloData.id, {
|
||||
// @ts-ignore
|
||||
name: tabloData.name,
|
||||
created_by_id: ownerId,
|
||||
members: [ownerId, user.id],
|
||||
});
|
||||
await channel.create();
|
||||
|
||||
// Send a welcome message to the channel
|
||||
await channel.sendMessage({
|
||||
text: `🎉 Bienvenue dans votre nouveau tablo "${tabloData.name}" ! Votre rendez-vous "${typedPayload.event.title}" est confirmé pour le ${typedPayload.event.start_date} de ${typedPayload.event.start_time} à ${typedPayload.event.end_time}.`,
|
||||
user_id: ownerId,
|
||||
});
|
||||
|
||||
await supabase.from("events").insert({
|
||||
...typedPayload.event,
|
||||
tablo_id: tabloData.id,
|
||||
created_by: ownerId,
|
||||
});
|
||||
|
||||
return c.json({ message: "Tablo created and user invited successfully" });
|
||||
// Send email notifications to both owner and invited user
|
||||
const transporter = c.get("transporter");
|
||||
|
||||
if (transporter) {
|
||||
// Send email to the owner
|
||||
await transporter.sendMail({
|
||||
to: ownerDataTyped.email,
|
||||
subject: "Nouveau tablo créé - Réservation confirmée",
|
||||
html: `
|
||||
<h2>Votre tablo a été créé avec succès !</h2>
|
||||
<p>Bonjour ${ownerDataTyped.name},</p>
|
||||
<p>Un nouveau tablo "${tabloData.name}" a été créé suite à une réservation.</p>
|
||||
<p><strong>Détails de l'événement :</strong></p>
|
||||
<ul>
|
||||
<li>Titre : ${typedPayload.event.title}</li>
|
||||
<li>Date : ${typedPayload.event.start_date}</li>
|
||||
<li>Heure : ${typedPayload.event.start_time} - ${typedPayload.event.end_time}</li>
|
||||
<li>Description : ${typedPayload.event.description}</li>
|
||||
</ul>
|
||||
<p>Participant : ${invitedUserDataTyped.name} (${invitedUserDataTyped.email})</p>
|
||||
<p>Vous pouvez gérer ce tablo depuis votre tableau de bord.</p>
|
||||
`,
|
||||
});
|
||||
|
||||
// Send email to the invited user
|
||||
await transporter.sendMail({
|
||||
to: invitedUserDataTyped.email,
|
||||
subject: "Réservation confirmée - Nouveau tablo créé",
|
||||
html: `
|
||||
<h2>Votre réservation est confirmée !</h2>
|
||||
<p>Bonjour ${invitedUserDataTyped.name},</p>
|
||||
<p>Votre réservation a été confirmée et un tablo "${tabloData.name}" a été créé.</p>
|
||||
<p><strong>Détails de votre rendez-vous :</strong></p>
|
||||
<ul>
|
||||
<li>Titre : ${typedPayload.event.title}</li>
|
||||
<li>Date : ${typedPayload.event.start_date}</li>
|
||||
<li>Heure : ${typedPayload.event.start_time} - ${typedPayload.event.end_time}</li>
|
||||
<li>Description : ${typedPayload.event.description}</li>
|
||||
</ul>
|
||||
<p>Avec : ${ownerDataTyped.name}</p>
|
||||
<p>Vous recevrez bientôt plus d'informations pour accéder à votre espace de collaboration.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ id: tabloData.id });
|
||||
});
|
||||
|
||||
tabloRouter.patch("/update", async (c) => {
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ L'équipe XTablo`,
|
|||
<p><strong>Mot de passe temporaire :</strong> <code style="background-color: #e1e1e1; padding: 2px 4px; border-radius: 3px;">${temporary_password}</code></p>
|
||||
</div>
|
||||
|
||||
<p style="color: #d9534f;"><strong>Important :</strong> Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.</p>
|
||||
<p style="color: #d9534f; margin-bottom: 20px;"><strong>Important :</strong> Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.</p>
|
||||
|
||||
<p>
|
||||
<a href="${process.env.FRONTEND_URL || "https://app.tablo.fr"}"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { RemoveNullFromObject } from "@ui/types/removeNull";
|
|||
import { useUser } from "@ui/providers/UserStoreProvider";
|
||||
import { CreateTablo } from "@ui/types/tablos.types";
|
||||
import { EventInsertInTablo } from "@ui/types/events.types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type Tablo = Database["public"]["Tables"]["tablos"];
|
||||
|
||||
|
|
@ -106,9 +107,10 @@ export const useCreateTablo = () => {
|
|||
// Create tablo with owner
|
||||
export const useCreateTabloWithOwner = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useMutation<
|
||||
unknown,
|
||||
{ id: string },
|
||||
unknown,
|
||||
CreateTablo & {
|
||||
owner_short_id: string;
|
||||
|
|
@ -137,8 +139,10 @@ export const useCreateTabloWithOwner = () => {
|
|||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tablos"] });
|
||||
navigate(`/chat/${data.id}`, { replace: true });
|
||||
navigate(0);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from "@ui/ui-library/button";
|
|||
import { CustomModal } from "@ui/components/CustomModal";
|
||||
import { TextField, Label, Input, FieldError } from "@ui/ui-library/field";
|
||||
import { useTheme } from "@ui/contexts/ThemeContext";
|
||||
import { useMaybeUser } from "@ui/providers/UserStoreProvider";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
|
|
@ -21,6 +22,7 @@ import { usePublicSlots, TimeSlot } from "@ui/hooks/public";
|
|||
import { useSignUpWithoutPassword } from "@ui/hooks/auth";
|
||||
import { useCreateTabloWithOwner } from "@ui/hooks/tablos";
|
||||
import { EventInsertInTablo } from "@ui/types/events.types";
|
||||
import { useSession } from "@ui/contexts/SessionContext";
|
||||
|
||||
export function PublicBookingPage() {
|
||||
const { user_info, event_type_standard_name } = useParams<{
|
||||
|
|
@ -31,6 +33,8 @@ export function PublicBookingPage() {
|
|||
mutateAsync: signUpWithoutPassword,
|
||||
isPending: isSigningUpWithoutPassword,
|
||||
} = useSignUpWithoutPassword();
|
||||
const { session } = useSession();
|
||||
const user = useMaybeUser();
|
||||
const shortUserId = user_info?.substring(user_info.lastIndexOf("-") + 1);
|
||||
|
||||
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
|
||||
|
|
@ -38,7 +42,8 @@ export function PublicBookingPage() {
|
|||
event_type_standard_name || ""
|
||||
);
|
||||
|
||||
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner();
|
||||
const { mutateAsync: createTabloWithOwner, isPending: isCreatingTablo } =
|
||||
useCreateTabloWithOwner();
|
||||
|
||||
const userProfile = publicSlots?.user;
|
||||
const eventType = publicSlots?.eventType;
|
||||
|
|
@ -244,32 +249,55 @@ export function PublicBookingPage() {
|
|||
return isValid;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 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 } = await signUpWithoutPassword({
|
||||
const { session: sessionFromSignUp } = await signUpWithoutPassword({
|
||||
email: formData.email,
|
||||
name: formData.name,
|
||||
});
|
||||
|
||||
// Calculate end time based on start time and duration
|
||||
const startTime = selectedSlot?.slot.time || "";
|
||||
const duration = eventType?.duration || 60; // duration in minutes
|
||||
const endTime = calculateEndTime(startTime, duration);
|
||||
|
||||
const calculateEndTime = (
|
||||
startTime: string,
|
||||
durationMinutes: number
|
||||
): string => {
|
||||
if (!startTime) return "";
|
||||
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 || "",
|
||||
});
|
||||
|
||||
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
|
||||
};
|
||||
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({
|
||||
|
|
@ -536,7 +564,9 @@ export function PublicBookingPage() {
|
|||
<CustomModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
title="Créer un compte pour réserver"
|
||||
title={
|
||||
user ? "Confirmer la réservation" : "Créer un compte pour réserver"
|
||||
}
|
||||
width="md"
|
||||
>
|
||||
{selectedSlot && (
|
||||
|
|
@ -559,38 +589,42 @@ export function PublicBookingPage() {
|
|||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<TextField isRequired isInvalid={!!formErrors.name}>
|
||||
<TextField
|
||||
isRequired
|
||||
value={user?.name || formData.name}
|
||||
onChange={(value) =>
|
||||
setFormData((prev) => ({ ...prev, name: value }))
|
||||
}
|
||||
isInvalid={!!formErrors.name}
|
||||
isDisabled={!!user}
|
||||
>
|
||||
<Label>Nom complet</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="Votre nom complet"
|
||||
/>
|
||||
<Input type="text" placeholder="Votre nom complet" />
|
||||
{formErrors.name && <FieldError>{formErrors.name}</FieldError>}
|
||||
</TextField>
|
||||
|
||||
<TextField isRequired isInvalid={!!formErrors.email}>
|
||||
<TextField
|
||||
isRequired
|
||||
value={user?.email || formData.email}
|
||||
onChange={(value) =>
|
||||
setFormData((prev) => ({ ...prev, email: value }))
|
||||
}
|
||||
isInvalid={!!formErrors.email}
|
||||
isDisabled={!!user}
|
||||
>
|
||||
<Label>Adresse email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, email: e.target.value }))
|
||||
}
|
||||
placeholder="votre@email.com"
|
||||
/>
|
||||
<Input type="email" placeholder="votre@email.com" />
|
||||
{formErrors.email && <FieldError>{formErrors.email}</FieldError>}
|
||||
</TextField>
|
||||
|
||||
<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>
|
||||
{!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">
|
||||
|
|
@ -599,11 +633,11 @@ export function PublicBookingPage() {
|
|||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
onPress={handleSubmit}
|
||||
isPending={isSigningUpWithoutPassword}
|
||||
pendingLabel="Création du compte..."
|
||||
onPress={user ? handleSubmitIfLoggedIn : handleSubmitIfNotLoggedIn}
|
||||
isPending={user ? isCreatingTablo : isSigningUpWithoutPassword}
|
||||
pendingLabel={user ? "Réservation..." : "Création du compte..."}
|
||||
>
|
||||
Créer le compte et réserver
|
||||
{user ? "Confirmer la réservation" : "Créer le compte et réserver"}
|
||||
</Button>
|
||||
</div>
|
||||
</CustomModal>
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export const BookingsPage = () => {
|
|||
return filtered.sort((a, b) => {
|
||||
if (!a.start_date || !b.start_date) return 0;
|
||||
return (
|
||||
new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
|
||||
new Date(a.start_date).getTime() - new Date(b.start_date).getTime()
|
||||
);
|
||||
});
|
||||
}, [events, searchTerm, statusFilter]);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,14 @@ export const useUser = () => {
|
|||
return useStore(store);
|
||||
};
|
||||
|
||||
export const useMaybeUser = () => {
|
||||
const store = React.useContext(UserStoreContext);
|
||||
if (!store) {
|
||||
return null;
|
||||
}
|
||||
return useStore(store);
|
||||
};
|
||||
|
||||
// TestUserStoreProvider component
|
||||
export const TestUserStoreProvider = ({
|
||||
children,
|
||||
|
|
|
|||
Loading…
Reference in a new issue