From 118b23bfb1a45e0cd6df10fd88599dd2716a5ce8 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 14:18:27 +0200 Subject: [PATCH] feat(main): add client invite UI to share dialog Adds three React Query hooks (usePendingClientInvites, useCreateClientInvite, useCancelClientInvite) and a new Client Access section in the share dialog with email input, pending invite list, expiry countdown, and orange warning badge for invites expiring in less than 5 days. Co-Authored-By: Claude Sonnet 4.6 --- apps/main/src/hooks/client_invites.ts | 89 ++++++++++++++++++ apps/main/src/pages/tablo-details.tsx | 126 ++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 apps/main/src/hooks/client_invites.ts diff --git a/apps/main/src/hooks/client_invites.ts b/apps/main/src/hooks/client_invites.ts new file mode 100644 index 0000000..ea6d49b --- /dev/null +++ b/apps/main/src/hooks/client_invites.ts @@ -0,0 +1,89 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast, useSession } from "@xtablo/shared"; +import { useAuthedApi } from "./auth"; + +type PendingClientInvite = { + id: number; + invited_email: string; + expires_at: string; + is_pending: boolean; + created_at: string; +}; + +export const usePendingClientInvites = (tabloId: string) => { + const api = useAuthedApi(); + const { session } = useSession(); + + return useQuery({ + queryKey: ["client-invites", tabloId], + queryFn: async () => { + const { data } = await api.get( + `/api/v1/client-invites/${tabloId}/pending` + ); + return data; + }, + enabled: !!tabloId && !!session, + }); +}; + +export const useCreateClientInvite = () => { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tabloId, email }: { tabloId: string; email: string }) => { + const { data } = await api.post( + `/api/v1/client-invites/${tabloId}`, + { email } + ); + return data; + }, + onSuccess: (_data, { tabloId }) => { + queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] }); + toast.add( + { + title: "Lien magique envoyé", + description: "L'invitation client a été envoyée avec succès", + type: "success", + }, + { timeout: 3000 } + ); + }, + onError: (error) => { + console.error("Error creating client invite:", error); + toast.add( + { + title: "Erreur", + description: "Impossible d'envoyer l'invitation client", + type: "error", + }, + { timeout: 5000 } + ); + }, + }); +}; + +export const useCancelClientInvite = () => { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tabloId, inviteId }: { tabloId: string; inviteId: number }) => { + await api.delete(`/api/v1/client-invites/${tabloId}/${inviteId}`); + }, + onSuccess: (_data, { tabloId }) => { + queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] }); + }, + onError: (error) => { + console.error("Error cancelling client invite:", error); + toast.add( + { + title: "Erreur", + description: "Impossible d'annuler l'invitation client", + type: "error", + }, + { timeout: 5000 } + ); + }, + }); +}; diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index 940ba76..42d82f2 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -34,6 +34,7 @@ import { Sun, UserPlusIcon, Waves, + XIcon, Zap, } from "lucide-react"; import { useEffect, useState } from "react"; @@ -52,6 +53,11 @@ import { useInviteUser } from "../hooks/invite"; import { useTabloFileNames, useDownloadTabloFile, useUploadTabloFile, useDeleteTabloFile } from "../hooks/tablo_data"; import { useTabloFolders, useCreateTabloFolder, useUpdateTabloFolder, useDeleteTabloFolder } from "../hooks/tablo_folders"; import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; +import { + usePendingClientInvites, + useCreateClientInvite, + useCancelClientInvite, +} from "../hooks/client_invites"; import { useTabloMembers, useTabloOverviewLayout, @@ -187,6 +193,7 @@ export const TabloDetailsPage = () => { const [showAllOverviewTasks, setShowAllOverviewTasks] = useState(false); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); const [inviteEmail, setInviteEmail] = useState(""); + const [clientInviteEmail, setClientInviteEmail] = useState(""); const [isLayoutEditMode, setIsLayoutEditMode] = useState(false); const [draggedOverviewBlock, setDraggedOverviewBlock] = useState<{ zone: "left" | "right"; @@ -200,6 +207,9 @@ export const TabloDetailsPage = () => { const { data: pendingInvites } = usePendingTabloInvitesByTablo(tabloId ?? ""); const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite(); const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser(); + const { data: pendingClientInvites } = usePendingClientInvites(tabloId ?? ""); + const { mutate: createClientInvite, isPending: isCreatingClientInvite } = useCreateClientInvite(); + const { mutate: cancelClientInvite, isPending: isCancellingClientInvite } = useCancelClientInvite(); const { mutate: updateTask } = useUpdateTask(); const { mutate: updateTablo, mutateAsync: updateTabloAsync } = useUpdateTablo(); const { mutate: createTask } = useCreateTask(); @@ -1032,6 +1042,122 @@ export const TabloDetailsPage = () => { )} + + {/* Separator */} +
+ {/* Client Access Section */} +
+

Accès client

+

+ Invitez des clients externes via un lien magique +

+
+ + {/* Client Invite Input */} +
+ setClientInviteEmail(e.target.value)} + placeholder="Email du client" + className="flex-1 min-h-[44px]" + /> + {isCreatingClientInvite ? ( +
+
+
+ ) : ( + + )} +
+ + {/* Pending Client Invites */} + {pendingClientInvites && pendingClientInvites.length > 0 && ( +
+

+ Invitations client en attente ({pendingClientInvites.length}) +

+
+ {pendingClientInvites.map((invite) => { + const daysUntilExpiry = Math.ceil( + (new Date(invite.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + const isExpiringSoon = daysUntilExpiry < 5; + return ( +
+
+ + + +
+
+ + {invite.invited_email} + + + {isExpiringSoon && "⚠ "} + Expire dans {daysUntilExpiry} jour{daysUntilExpiry !== 1 ? "s" : ""} + +
+ {isExpiringSoon && ( + + Bientôt expiré + + )} + +
+ ); + })} +
+
+ )} +