From 9cd3ac42b46f859ceb07834f50777282dbbb0606 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 7 Jul 2026 14:09:28 +0000 Subject: [PATCH] feat: add the support to the client --- .../backoffice/src/app/support/page.tsx | 396 ++++++++++++++-- .../src/components/layout/Sidebar.tsx | 2 +- .../src/features/support/supportApi.ts | 36 ++ .../src/features/support/useSupport.ts | 65 +++ .../src/features/support/useSupportSocket.ts | 67 +++ .../portal/src/app/layout.tsx | 2 + .../src/features/support/SupportPanel.tsx | 445 ++++++++++++++++++ .../src/features/support/SupportWidget.tsx | 51 ++ .../src/features/support/guestIdentity.ts | 24 + .../portal/src/features/support/supportApi.ts | 75 +++ .../portal/src/features/support/useSupport.ts | 65 +++ .../src/features/support/useSupportSocket.ts | 71 +++ .../portal/src/lib/api-client.ts | 51 +- packages/types/src/passenger/index.ts | 2 + packages/types/src/passenger/support-chat.ts | 103 ++++ 15 files changed, 1389 insertions(+), 66 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/features/support/supportApi.ts create mode 100644 apps/edr-passenger-web/backoffice/src/features/support/useSupport.ts create mode 100644 apps/edr-passenger-web/backoffice/src/features/support/useSupportSocket.ts create mode 100644 apps/edr-passenger-web/portal/src/features/support/SupportPanel.tsx create mode 100644 apps/edr-passenger-web/portal/src/features/support/SupportWidget.tsx create mode 100644 apps/edr-passenger-web/portal/src/features/support/guestIdentity.ts create mode 100644 apps/edr-passenger-web/portal/src/features/support/supportApi.ts create mode 100644 apps/edr-passenger-web/portal/src/features/support/useSupport.ts create mode 100644 apps/edr-passenger-web/portal/src/features/support/useSupportSocket.ts create mode 100644 packages/types/src/passenger/support-chat.ts diff --git a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx index d6ad8fb38..8102cc556 100644 --- a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx @@ -1,66 +1,362 @@ 'use client'; -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { Download } from 'lucide-react'; -import DataTable from '@/components/ui/DataTable'; -import Badge from '@/components/ui/Badge'; -import ActionButton from '@/components/ui/ActionButton'; -import { supportApi } from '@/lib/api'; -import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { Passenger } from '@edr/types'; +import { Headset, Search, Send, User } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { + useConversations, + useMarkRead, + useMessages, + useSendMessage, + useSetStatus, +} from '@/features/support/useSupport'; +import { useSupportSocket } from '@/features/support/useSupportSocket'; + +const GREEN = 'rgb(20 113 76)'; +type ConversationDto = Passenger.PassengerSupportConversationDto; +type MessageDto = Passenger.PassengerSupportMessageDto; + +const STATUS_CLASS: Record = { + OPEN: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', + RESOLVED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + CLOSED: 'bg-gray-200 text-gray-600 dark:bg-slate-700 dark:text-slate-300', +}; + +const FILTERS = ['ALL', 'OPEN', 'RESOLVED', 'CLOSED'] as const; + +function formatTime(iso?: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + const now = new Date(); + return d.toDateString() === now.toDateString() + ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : d.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} export default function SupportPage() { - const [filters, setFilters] = useState({ search: '', status: '' }); + const [status, setStatus] = useState<(typeof FILTERS)[number]>('ALL'); + const [search, setSearch] = useState(''); + const [selectedId, setSelectedId] = useState(null); - const { data, isLoading } = useQuery({ - queryKey: ['support', filters], - queryFn: () => supportApi.getConversations(filters), - }); + const { data, isLoading } = useConversations( + status === 'ALL' ? { search } : { status, search }, + ); + const items = data?.items ?? []; - const columns = [ - { key: 'subject', label: 'Subject', render: (conv: any) => conv.subject || 'No Subject' }, - { key: 'passenger', label: 'Passenger', render: (conv: any) => conv.passenger?.fullName || 'N/A' }, - { key: 'status', label: 'Status', render: (conv: any) => {conv.status} }, - { key: 'createdAt', label: 'Created', render: (conv: any) => formatDateTime(conv.createdAt) }, - ]; + useSupportSocket(true); + + const selected = useMemo( + () => items.find((c) => c.id === selectedId) ?? null, + [items, selectedId], + ); return ( -
-
+
+
+ + +

Support Center

-

Manage customer support conversations

-
- Export -
- -
-
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
-
- - -
- +

+ Shared inbox — respond to passenger requests in real time +

- +
+ {/* Conversation list */} +
+
+
+ + setSearch(e.target.value)} + placeholder="Search subject or passenger" + className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm outline-none focus:border-emerald-500" + /> +
+
+ {FILTERS.map((f) => ( + + ))} +
+
+
+ {isLoading ? ( +
+ Loading… +
+ ) : items.length === 0 ? ( +
+ No conversations. +
+ ) : ( + items.map((c) => ( + setSelectedId(c.id)} + /> + )) + )} +
+
+ + {/* Thread */} +
+ {selected ? ( + + ) : ( +
+ + + +

Select a conversation to start replying.

+
+ )} +
+
+
+ ); +} + +function InboxRow({ + c, + active, + onClick, +}: { + c: ConversationDto; + active: boolean; + onClick: () => void; +}) { + const unread = c.unreadCount > 0; + return ( + + ); +} + +function ConversationThread({ conversation }: { conversation: ConversationDto }) { + const { data: messages, isLoading } = useMessages(conversation.id); + const send = useSendMessage(conversation.id); + const setStatus = useSetStatus(); + const markRead = useMarkRead(); + const [draft, setDraft] = useState(''); + const viewport = useRef(null); + + useEffect(() => { + markRead.mutate(conversation.id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conversation.id, messages?.length]); + + useEffect(() => { + viewport.current?.scrollTo({ top: viewport.current.scrollHeight }); + }, [messages?.length, conversation.id]); + + const submit = async () => { + const text = draft.trim(); + if (!text) return; + setDraft(''); + await send.mutateAsync(text); + }; + + const changeStatus = (status: string) => + setStatus.mutate({ id: conversation.id, status }); + + return ( +
+
+
+
+ + {conversation.subject || 'Conversation'} + + + {conversation.status.toLowerCase()} + +
+

+ {conversation.passengerName || 'Passenger'} + {conversation.guestId ? ' · Guest' : ''} + {conversation.guestEmail ? ` · ${conversation.guestEmail}` : ''} + {conversation.guestPhone ? ` · ${conversation.guestPhone}` : ''} +

+
+
+ {conversation.status !== 'OPEN' && ( + + )} + {conversation.status === 'OPEN' && ( + + )} + {conversation.status !== 'CLOSED' && ( + + )} +
+
+ +
+ {isLoading ? ( +
+ Loading… +
+ ) : ( + (messages ?? []).map((m) => ) + )} +
+ +
+
+