diff --git a/src/app/helpers/page.test.tsx b/src/app/helpers/page.test.tsx new file mode 100644 index 0000000..a4504a0 --- /dev/null +++ b/src/app/helpers/page.test.tsx @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" + +/** + * Admin "Remove helper" flow on the Helpers page: + * 1. Kebab menu (three vertical dots) on a helper row shows "Remove helper". + * 2. Selecting it opens a confirmation dialog ("Yes" / "Cancel"). + * 3. "Yes" removes the helper and shows a success dialog with "Close". + * 4. "Cancel" closes the dialog without removing the helper. + */ + +const h = vi.hoisted(() => ({ + removeHelperMutateAsync: vi.fn(() => Promise.resolve({ helperId: "helper-1", projectId: "proj-1" })), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +// --- jsdom polyfills required by Radix dropdown/popper --- +beforeAll(() => { + ;(global as any).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } + Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {}) + Element.prototype.hasPointerCapture = Element.prototype.hasPointerCapture || (() => false) + Element.prototype.setPointerCapture = Element.prototype.setPointerCapture || (() => {}) + Element.prototype.releasePointerCapture = Element.prototype.releasePointerCapture || (() => {}) +}) + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(""), +})) + +vi.mock("next/link", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock("sonner", () => ({ + toast: { error: h.toastError, success: h.toastSuccess }, +})) + +vi.mock("@/components/layout/sidebar", () => ({ Sidebar: () => null })) +vi.mock("@/components/layout/header", () => ({ Header: () => null })) +vi.mock("@/components/drawers/add-helper-drawer", () => ({ + AddHelperDrawer: () => null, +})) +vi.mock("@/components/drawers/accept-request-drawer", () => ({ + AcceptRequestDrawer: () => null, +})) + +vi.mock("@/contexts/project-context", () => ({ + useProjectSelection: () => ({ selectedProjectId: "proj-1" }), +})) + +vi.mock("@/contexts/user-context", () => ({ + useUser: () => ({ user: { id: "admin-1", name: "Admin", role: "admin" } }), +})) + +vi.mock("@/hooks/useHelpers", () => ({ + useHelpers: () => ({ + data: [ + { + helper_id: "helper-1", + user_id: "user-2", + project_id: "proj-1", + category: "core", + user: { name: "Alice", username: "alice", avatar_url: null }, + }, + ], + isLoading: false, + }), + useCreateHelper: () => ({ mutateAsync: vi.fn(), isPending: false }), + useAddSelfAsHelper: () => ({ mutateAsync: vi.fn(), isPending: false }), + useRemoveHelper: () => ({ + mutateAsync: h.removeHelperMutateAsync, + isPending: false, + }), +})) + +vi.mock("@/hooks/usePendingRequests", () => ({ + usePendingRequests: () => ({ data: [], isLoading: false }), + useUpdatePendingRequest: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + +vi.mock("@/hooks/useProject", () => ({ + useCreateProjectInvite: () => ({ mutateAsync: vi.fn(), isPending: false }), + useListProjectInvites: () => ({ data: [], isLoading: false }), + useRevokeProjectInvite: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + +import HelpersPage from "./page" + +function openKebabMenu() { + const trigger = screen.getByRole("button", { name: /open menu for alice/i }) + fireEvent.pointerDown(trigger) + fireEvent.click(trigger) + return trigger +} + +describe("HelpersPage — remove helper flow", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("shows a 'Remove helper' option in the helper row kebab menu", async () => { + render() + expect(screen.getByText("Alice")).toBeInTheDocument() + + openKebabMenu() + + expect( + await screen.findByRole("menuitem", { name: /remove helper/i }), + ).toBeInTheDocument() + }) + + it("removes the helper after confirming, then shows the success dialog", async () => { + render() + + openKebabMenu() + fireEvent.click(await screen.findByRole("menuitem", { name: /remove helper/i })) + + // Confirmation popup + expect( + await screen.findByText("Are you sure you want to remove this helper?"), + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Yes" })) + + await waitFor(() => { + expect(h.removeHelperMutateAsync).toHaveBeenCalledWith({ + helperId: "helper-1", + projectId: "proj-1", + }) + }) + + // Success popup + expect( + await screen.findByText("The helper is now removed from the project"), + ).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Close" })) + await waitFor(() => { + expect( + screen.queryByText("The helper is now removed from the project"), + ).not.toBeInTheDocument() + }) + }) + + it("does not remove the helper when cancelling the confirmation", async () => { + render() + + openKebabMenu() + fireEvent.click(await screen.findByRole("menuitem", { name: /remove helper/i })) + + expect( + await screen.findByText("Are you sure you want to remove this helper?"), + ).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })) + + await waitFor(() => { + expect( + screen.queryByText("Are you sure you want to remove this helper?"), + ).not.toBeInTheDocument() + }) + expect(h.removeHelperMutateAsync).not.toHaveBeenCalled() + }) + + it("shows an error toast and no success dialog when removal fails", async () => { + h.removeHelperMutateAsync.mockRejectedValueOnce(new Error("boom")) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + render() + + openKebabMenu() + fireEvent.click(await screen.findByRole("menuitem", { name: /remove helper/i })) + fireEvent.click(await screen.findByRole("button", { name: "Yes" })) + + await waitFor(() => { + expect(h.toastError).toHaveBeenCalledWith( + "Failed to remove helper. Please try again.", + ) + }) + expect( + screen.queryByText("The helper is now removed from the project"), + ).not.toBeInTheDocument() + + consoleError.mockRestore() + }) +}) diff --git a/src/app/helpers/page.tsx b/src/app/helpers/page.tsx index 1d51d3a..7327ced 100644 --- a/src/app/helpers/page.tsx +++ b/src/app/helpers/page.tsx @@ -8,13 +8,26 @@ import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@ import { Switch } from "@/components/ui/switch" import { Badge } from "@/components/ui/badge" import { ProfileAvatar } from "@/components/ui/profile-avatar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { MoreVertical, Plus, Search, ChevronDown, ChevronUp, ChevronsUpDown, Copy, X, UserPlus } from "lucide-react" import { toast } from "sonner" import { Sidebar } from "@/components/layout/sidebar" import { Header } from "@/components/layout/header" import { AddHelperDrawer } from "@/components/drawers/add-helper-drawer" import { AcceptRequestDrawer } from "@/components/drawers/accept-request-drawer" -import { useHelpers, useCreateHelper, useAddSelfAsHelper } from "@/hooks/useHelpers" +import { useHelpers, useCreateHelper, useAddSelfAsHelper, useRemoveHelper } from "@/hooks/useHelpers" import { usePendingRequests, useUpdatePendingRequest } from "@/hooks/usePendingRequests" import { useCreateProjectInvite, useListProjectInvites, useRevokeProjectInvite } from "@/hooks/useProject" import { useProjectSelection } from "@/contexts/project-context" @@ -84,6 +97,9 @@ export default function HelpersPage() { const [sortField, setSortField] = useState(null) const [sortDirection, setSortDirection] = useState(null) + const [helperToRemove, setHelperToRemove] = useState<{ id: string; name: string } | null>(null) + const [isRemovedDialogOpen, setIsRemovedDialogOpen] = useState(false) + // Derive view from URL so we don't need setState in effect const currentView = (searchParams.get("view") === "requests" ? "requests" : searchParams.get("view") === "invited" ? "invited" : "added") as "added" | "requests" | "invited" @@ -100,6 +116,7 @@ export default function HelpersPage() { const updatePendingRequest = useUpdatePendingRequest() const createInvite = useCreateProjectInvite() const revokeInvite = useRevokeProjectInvite() + const removeHelper = useRemoveHelper() // Admin is a helper if their user_id appears in the project's helpers list const isCurrentUserHelper = !!( @@ -236,6 +253,21 @@ export default function HelpersPage() { setSelectedRequest(null) } + const handleConfirmRemoveHelper = async () => { + if (!helperToRemove || !projectId) return + try { + await removeHelper.mutateAsync({ + helperId: helperToRemove.id, + projectId: projectId, + }) + setHelperToRemove(null) + setIsRemovedDialogOpen(true) + } catch (error) { + console.error("Failed to remove helper:", error) + toast.error("Failed to remove helper. Please try again.") + } + } + const handleSort = (field: SortField) => { if (sortField === field) { // Cycle through: asc -> desc -> null @@ -694,9 +726,26 @@ export default function HelpersPage() { ) : ( Not registered )} - + + + + + + setHelperToRemove({ id: helper.id, name: helper.name })} + > + Remove helper + + + @@ -788,6 +837,53 @@ export default function HelpersPage() { onSubmit={handleConfirmAcceptRequest} requestData={selectedRequest} /> + + {/* Remove helper confirmation dialog */} + { + if (!open) setHelperToRemove(null) + }} + > + + + Are you sure you want to remove this helper? + + + + + + + + + {/* Helper removed confirmation dialog */} + + + + The helper is now removed from the project + + + + + + ) } diff --git a/src/app/support/chat/page.test.tsx b/src/app/support/chat/page.test.tsx new file mode 100644 index 0000000..a09bdc4 --- /dev/null +++ b/src/app/support/chat/page.test.tsx @@ -0,0 +1,286 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" + +/** + * Retry-flow coverage for the support chat page. + * + * Two failure modes are exercised: + * 1. Ticket creation itself fails → error banner + input preserved; Retry + * re-attempts creation. + * 2. Ticket creation succeeds but persisting the participant/first message + * fails → Retry must reuse the already-created ticket (no duplicate + * createTicket call) and only re-run the failed steps. + */ + +const h = vi.hoisted(() => ({ + createTicketMutateAsync: vi.fn(), + sendMessageMutateAsync: vi.fn(), + ensureParticipantMutateAsync: vi.fn(), + requestEndSessionMutateAsync: vi.fn(), + createCheckoutMutateAsync: vi.fn(), + functionsInvoke: vi.fn(() => Promise.resolve({ data: null, error: null })), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +// --- Navigation: open the chat for a project, no existing ticket --- +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams("project=proj-1"), + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), +})) + +vi.mock("sonner", () => ({ + toast: { error: h.toastError, success: h.toastSuccess }, +})) + +// --- Supabase + auth helpers --- +vi.mock("@/lib/supabase/client", () => ({ + supabase: { + auth: { + getSession: vi.fn(() => + Promise.resolve({ data: { session: null }, error: null }), + ), + }, + functions: { invoke: h.functionsInvoke }, + }, +})) +vi.mock("@/lib/supabase/auth", () => ({ loginUserGoogle: vi.fn() })) +vi.mock("@/lib/organizations", () => ({ ensureUserOrganization: vi.fn() })) + +// --- Signed-in user (required so the first message is persisted) --- +vi.mock("@/contexts/user-context", () => ({ + useUser: () => ({ + user: { id: "user-1", name: "Test User", avatar: "T" }, + setProjectRole: vi.fn(), + }), +})) + +// --- Project data --- +vi.mock("@/hooks/useProject", () => ({ + useProject: () => ({ + data: { project_id: "proj-1", name: "Acme", slug: "acme", sandbox: false }, + }), + useProjectBySlug: () => ({ data: undefined }), + useProjectPaymentSettings: () => ({ data: undefined }), + useProjectBranding: () => ({ data: undefined }), + useProjects: () => ({ data: [], isLoading: false }), +})) +vi.mock("@/hooks/useProjectRole", () => ({ + useProjectRole: () => ({ data: null }), +})) + +// --- Mutation hooks under test --- +vi.mock("@/hooks/useTickets", () => ({ + useCreateTicket: () => ({ + mutateAsync: h.createTicketMutateAsync, + isPending: false, + }), + useRequestEndSession: () => ({ + mutateAsync: h.requestEndSessionMutateAsync, + isPending: false, + }), +})) +vi.mock("@/hooks/useTicketMessages", () => ({ + useTicketMessages: () => ({ data: [] }), + useSendMessage: () => ({ + mutateAsync: h.sendMessageMutateAsync, + isPending: false, + }), +})) +vi.mock("@/hooks/useTicketParticipants", () => ({ + useTicketParticipants: () => ({ data: [], isLoading: false }), + useEnsureParticipant: () => ({ + mutateAsync: h.ensureParticipantMutateAsync, + isPending: false, + }), +})) +vi.mock("@/hooks/useCreateCheckoutForTicket", () => ({ + useCreateCheckoutForTicket: () => ({ + mutateAsync: h.createCheckoutMutateAsync, + isPending: false, + }), +})) + +// --- Remaining data hooks (inert for these tests) --- +vi.mock("@/hooks/useRealtimeMessages", () => ({ useRealtimeMessages: vi.fn() })) +vi.mock("@/hooks/useRealtimeTicket", () => ({ useRealtimeTicket: vi.fn() })) +vi.mock("@/hooks/useTicketsWithDetails", () => ({ + useTicketWithDetails: () => ({ data: undefined, isLoading: false }), + useUserActiveTicketsSidebar: () => ({ + data: { items: [], activeCount: 0 }, + }), + useLatestUserActiveTicket: () => ({ data: undefined, isLoading: false }), +})) +vi.mock("@/hooks/useTimeEntries", () => ({ + useTimeEntries: () => ({ data: [] }), + timeMillisecondsToHoursMinutes: (ms: number) => ({ + hours: Math.floor(ms / 3600000), + minutes: Math.floor(ms / 60000) % 60, + }), +})) +vi.mock("@/hooks/useTicketPaymentStatus", () => ({ + useTicketPaymentStatus: () => ({ + status: "pending", + capturedAmountSmallestUnit: null, + isReady: false, + }), +})) + +// --- Heavy UI collaborators --- +vi.mock("@/components/layout/sidebar", () => ({ Sidebar: () => null })) +vi.mock("@/components/payment/ConfirmPaymentModal", () => ({ + ConfirmPaymentModal: () => null, +})) +// Syntax-highlighter language modules are ESM-only; stub them out. +vi.mock("react-syntax-highlighter/dist/esm/languages/prism/csharp", () => ({ default: {} })) +vi.mock("react-syntax-highlighter/dist/esm/languages/prism/javascript", () => ({ default: {} })) +vi.mock("react-syntax-highlighter/dist/esm/languages/prism/typescript", () => ({ default: {} })) +vi.mock("react-syntax-highlighter/dist/esm/languages/prism/python", () => ({ default: {} })) + +// Lightweight TicketChat stub: exposes exactly the composer surface the page +// wires up (controlled input, send button, error banner slot). +vi.mock("@/components/ticket-chat/ticket-chat", () => ({ + TicketChat: (props: { + message: string + onMessageChange: (value: string) => void + onSend: () => void + sendDisabled?: boolean + errorBanner?: React.ReactNode + }) => ( +
+ props.onMessageChange(e.target.value)} + /> + + {props.errorBanner} +
+ ), +})) + +import UserSupportChatPage from "./page" + +const TICKET = { + id: "ticket-1", + title: "Help me please", + description: "Help me please", +} + +function typeAndSend(text: string) { + fireEvent.change(screen.getByLabelText("Message"), { + target: { value: text }, + }) + fireEvent.click(screen.getByRole("button", { name: "Send" })) +} + +describe("UserSupportChatPage retry flow", () => { + beforeEach(() => { + vi.clearAllMocks() + h.functionsInvoke.mockResolvedValue({ data: null, error: null }) + h.ensureParticipantMutateAsync.mockResolvedValue({}) + h.sendMessageMutateAsync.mockResolvedValue({}) + }) + + it("shows the error state and preserves the input when ticket creation fails", async () => { + h.createTicketMutateAsync.mockRejectedValueOnce(new Error("network down")) + + render() + typeAndSend("Help me please") + + const alert = await screen.findByRole("alert") + expect(alert).toHaveTextContent( + "Your message wasn't sent — we couldn't create your ticket.", + ) + // The typed message stays in the composer so the user can simply retry. + expect(screen.getByLabelText("Message")).toHaveValue("Help me please") + expect(h.createTicketMutateAsync).toHaveBeenCalledTimes(1) + expect(h.createTicketMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + project_id: "proj-1", + description: "Help me please", + created_by: "user-1", + }), + ) + // Nothing downstream ran. + expect(h.ensureParticipantMutateAsync).not.toHaveBeenCalled() + expect(h.sendMessageMutateAsync).not.toHaveBeenCalled() + }) + + it("re-attempts ticket creation on Retry and clears the error on success", async () => { + h.createTicketMutateAsync + .mockRejectedValueOnce(new Error("network down")) + .mockResolvedValueOnce(TICKET) + + render() + typeAndSend("Help me please") + await screen.findByRole("alert") + + fireEvent.click(screen.getByRole("button", { name: "Retry" })) + + await waitFor(() => + expect(h.createTicketMutateAsync).toHaveBeenCalledTimes(2), + ) + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ) + // The first message was persisted against the newly created ticket. + expect(h.ensureParticipantMutateAsync).toHaveBeenCalledWith({ + ticketId: "ticket-1", + participantId: "user-1", + claimed: false, + }) + expect(h.sendMessageMutateAsync).toHaveBeenCalledWith({ + ticket_id: "ticket-1", + sender_id: "user-1", + sender_type: "user", + content: "Help me please", + }) + expect(screen.getByLabelText("Message")).toHaveValue("") + }) + + it("does not create a duplicate ticket on Retry when the ticket was already created", async () => { + h.createTicketMutateAsync.mockResolvedValueOnce(TICKET) + // The ticket is created, but persisting the first message fails once. + h.sendMessageMutateAsync + .mockRejectedValueOnce(new Error("insert failed")) + .mockResolvedValueOnce({}) + + render() + typeAndSend("Help me please") + + const alert = await screen.findByRole("alert") + expect(alert).toHaveTextContent( + "Your ticket was created, but your message couldn't be sent.", + ) + // The message is put back in the composer for the retry. + expect(screen.getByLabelText("Message")).toHaveValue("Help me please") + expect(h.createTicketMutateAsync).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole("button", { name: "Retry" })) + + await waitFor(() => + expect(h.sendMessageMutateAsync).toHaveBeenCalledTimes(2), + ) + // Retry reused the stored ticket id — no second createTicket call. + expect(h.createTicketMutateAsync).toHaveBeenCalledTimes(1) + expect(h.sendMessageMutateAsync).toHaveBeenLastCalledWith({ + ticket_id: "ticket-1", + sender_id: "user-1", + sender_type: "user", + content: "Help me please", + }) + // ensureParticipant is idempotent and re-ran as part of the retry. + expect(h.ensureParticipantMutateAsync).toHaveBeenLastCalledWith({ + ticketId: "ticket-1", + participantId: "user-1", + claimed: false, + }) + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ) + expect(screen.getByLabelText("Message")).toHaveValue("") + }) +}) diff --git a/src/app/support/chat/page.tsx b/src/app/support/chat/page.tsx index 7a2cb2c..b3e4270 100644 --- a/src/app/support/chat/page.tsx +++ b/src/app/support/chat/page.tsx @@ -6,7 +6,7 @@ import { useUser } from "@/contexts/user-context" import { useProjectRole } from "@/hooks/useProjectRole" import { Sidebar } from "@/components/layout/sidebar" import { TicketChat, type TicketChatMessage, type TicketChatParticipant } from "@/components/ticket-chat/ticket-chat" -import { Check, Info, Search } from "lucide-react" +import { AlertCircle, Check, Info, Search } from "lucide-react" import { toast } from "sonner" import Link from "next/link" import { useState, useEffect, useMemo } from "react" @@ -73,6 +73,14 @@ export default function UserSupportChatPage() { const [projectSearch, setProjectSearch] = useState("") /** When user creates ticket without being signed in, first message is not persisted; we show it locally. */ const [pendingFirstMessage, setPendingFirstMessage] = useState(null) + /** Inline error shown above the composer when sending fails; the typed message stays in the input for retry. */ + const [sendError, setSendError] = useState(null) + /** + * Set when the ticket was created but persisting the participant/first message + * failed. Retry then reuses the stored ticketId (no duplicate ticket) and only + * re-runs the failed steps — ensureParticipant is idempotent. + */ + const [needsParticipantRetry, setNeedsParticipantRetry] = useState(false) /** Surfaces the ConfirmPaymentModal when an off-session hold lands in requires_action (SCA). */ const [pendingSca, setPendingSca] = useState<{ ticketId: string; clientSecret: string } | null>(null) @@ -476,6 +484,7 @@ export default function UserSupportChatPage() { const handleSendMessage = async () => { if (!message.trim()) return + setSendError(null) if (!ticketCreated && effectiveProjectId) { try { @@ -497,17 +506,30 @@ export default function UserSupportChatPage() { setPendingFirstMessage(firstMessageContent) if (user?.id) { - await ensureParticipant.mutateAsync({ - ticketId: ticket.id, - participantId: user.id, - claimed: false, - }) - await sendMessage.mutateAsync({ - ticket_id: ticket.id, - sender_id: user.id, - sender_type: "user", - content: firstMessageContent, - }) + try { + await ensureParticipant.mutateAsync({ + ticketId: ticket.id, + participantId: user.id, + claimed: false, + }) + await sendMessage.mutateAsync({ + ticket_id: ticket.id, + sender_id: user.id, + sender_type: "user", + content: firstMessageContent, + }) + } catch (error) { + // The ticket exists but the participant/first message wasn't + // persisted. Put the message back in the input and flag retry so + // the next send reuses this ticket instead of creating another. + console.error("Failed to send first message:", error) + setMessage(firstMessageContent) + setPendingFirstMessage(null) + setNeedsParticipantRetry(true) + setSendError("Your ticket was created, but your message couldn't be sent.") + toast.error("Your ticket was created, but your message couldn't be sent. Please retry.") + return + } } supabase.functions.invoke("classify-ticket", { @@ -519,23 +541,56 @@ export default function UserSupportChatPage() { }, }).then(() => {}).catch(() => {}) } catch (error) { + // createTicket itself failed (later steps handle their own errors and + // return above). Keep the typed message in the input so the user can + // simply resend. console.error("Failed to create ticket:", error) + setSendError("Your message wasn't sent — we couldn't create your ticket.") + toast.error("Couldn't create your ticket. Please try again.") return } return } if (!ticketId || !user?.id) return + const content = message.trim() try { + if (needsParticipantRetry) { + // Only re-run the steps that failed after ticket creation. + // ensureParticipant checks for an existing row, so this is safe. + await ensureParticipant.mutateAsync({ + ticketId, + participantId: user.id, + claimed: false, + }) + } await sendMessage.mutateAsync({ ticket_id: ticketId, sender_id: user.id, sender_type: "user", - content: message.trim(), + content, }) + if (needsParticipantRetry) { + setNeedsParticipantRetry(false) + // Show the question immediately, as the normal first-message flow does. + setPendingFirstMessage(content) + // Kick off the classification the failed first attempt never reached. + if (effectiveProjectId) { + supabase.functions.invoke("classify-ticket", { + body: { + ticket_id: ticketId, + project_id: effectiveProjectId, + title: content.substring(0, 100) || "Support Request", + description: content, + }, + }).then(() => {}).catch(() => {}) + } + } setMessage("") } catch (error) { console.error("Failed to send message:", error) + setSendError("Your message wasn't sent.") + toast.error("Couldn't send your message. Please try again.") } } @@ -729,6 +784,31 @@ export default function UserSupportChatPage() { onMessageChange={setMessage} onSend={handleSendMessage} sendDisabled={!message.trim() || createTicket.isPending} + errorBanner={ + sendError ? ( +
+ +
+

{sendError}

+

+ Your message is still in the box below — retry when you're ready. +

+
+ +
+ ) : undefined + } isEnded={ticketEnded} onRequestEndSession={ existingTicket?.id && user?.id ? () => handleRequestEndSession(false) : undefined diff --git a/src/components/ticket-chat/ticket-chat.tsx b/src/components/ticket-chat/ticket-chat.tsx index 04f9a55..52e56b0 100644 --- a/src/components/ticket-chat/ticket-chat.tsx +++ b/src/components/ticket-chat/ticket-chat.tsx @@ -94,6 +94,9 @@ export interface TicketChatProps { // Right-side extras rightSidebarFooter?: React.ReactNode + /** Optional banner rendered above the chat input (e.g. a send-failure error with a retry action). */ + errorBanner?: React.ReactNode + /** Called when the user clicks the "Add payment method" CTA on a payment_required system message. */ onPaymentCtaClick?: (msg: TicketChatMessage) => void paymentCtaLoading?: boolean @@ -124,6 +127,7 @@ export function TicketChat(props: TicketChatProps) { attachmentStoragePrefix, onImageUploaded, rightSidebarFooter, + errorBanner, onPaymentCtaClick, paymentCtaLoading, } = props @@ -300,6 +304,8 @@ export function TicketChat(props: TicketChatProps) { + {errorBanner} + {endSessionRequested && ( { wrapper: makeWrapper(), }); - const out = await result.current.mutateAsync(); + const out = await result.current.mutateAsync({}); expect(out).toEqual({ url: "https://connect.stripe.com/onboarding/helper" }); expect(supabase.functions.invoke).toHaveBeenNthCalledWith( @@ -142,7 +142,7 @@ describe("useStartHelperPaymentConnect", () => { wrapper: makeWrapper(), }); await waitFor(async () => { - await expect(result.current.mutateAsync()).rejects.toThrow("nope"); + await expect(result.current.mutateAsync({})).rejects.toThrow("nope"); }); }); }); diff --git a/src/hooks/useHelpers.ts b/src/hooks/useHelpers.ts index 9751182..f8a5005 100644 --- a/src/hooks/useHelpers.ts +++ b/src/hooks/useHelpers.ts @@ -133,6 +133,40 @@ export function useAddSelfAsHelper() { }); } +export function useRemoveHelper() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + helperId, + projectId, + }: { + helperId: string; + projectId: string; + }) => { + const { error } = await supabase + .from("projects_helpers") + .delete() + .eq("helper_id", helperId) + .eq("project_id", projectId); + + if (error) throw error; + return { helperId, projectId }; + }, + onSuccess: ({ helperId, projectId }) => { + queryClient.invalidateQueries({ + queryKey: ["helpers", projectId], + }); + queryClient.invalidateQueries({ + queryKey: ["helper", helperId], + }); + queryClient.invalidateQueries({ + queryKey: ["current-helper", projectId], + }); + }, + }); +} + export function useUpdateHelper() { const queryClient = useQueryClient();