mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 00:08:18 +00:00
feat: make the support require nothing
This commit is contained in:
@@ -17,6 +17,8 @@ import { JwtGuard } from '../../common/jwt.guard';
|
|||||||
import {
|
import {
|
||||||
CreateConversationDto,
|
CreateConversationDto,
|
||||||
CreateGuestConversationDto,
|
CreateGuestConversationDto,
|
||||||
|
DeviceIdBodyDto,
|
||||||
|
DeviceSendMessageDto,
|
||||||
GuestIdBodyDto,
|
GuestIdBodyDto,
|
||||||
GuestSendMessageDto,
|
GuestSendMessageDto,
|
||||||
ListConversationsQueryDto,
|
ListConversationsQueryDto,
|
||||||
@@ -103,7 +105,39 @@ export class SupportController {
|
|||||||
return this.service.unreadCount('USER', { iamUserId: userId(req) });
|
return this.service.unreadCount('USER', { iamUserId: userId(req) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- customer: guest (unauthenticated) --------------------------------
|
// ---- customer: device-scoped single thread (portal) -------------------
|
||||||
|
// No auth, no forms. One conversation per device id (localStorage). Anyone
|
||||||
|
// with the device id can see that thread — accepted MVP trade-off.
|
||||||
|
|
||||||
|
@Get('device/thread')
|
||||||
|
@IsPublic()
|
||||||
|
@ApiOperation({ summary: "Get the device's support thread + messages" })
|
||||||
|
deviceThread(@Query('deviceId') deviceId: string) {
|
||||||
|
return this.service.getDeviceThread(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('device/messages')
|
||||||
|
@IsPublic()
|
||||||
|
@ApiOperation({ summary: 'Send a message (creates the thread on first send)' })
|
||||||
|
deviceSend(@Body() body: DeviceSendMessageDto) {
|
||||||
|
return this.service.sendDeviceMessage(body.deviceId, body.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('device/read')
|
||||||
|
@IsPublic()
|
||||||
|
@ApiOperation({ summary: 'Mark the device thread read' })
|
||||||
|
deviceRead(@Body() body: DeviceIdBodyDto) {
|
||||||
|
return this.service.markDeviceRead(body.deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('device/unread-count')
|
||||||
|
@IsPublic()
|
||||||
|
@ApiOperation({ summary: "Count the device thread's unread messages" })
|
||||||
|
deviceUnread(@Query('deviceId') deviceId: string) {
|
||||||
|
return this.service.unreadCount('USER', { guestId: deviceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- customer: guest (unauthenticated, multi-ticket) ------------------
|
||||||
// No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer
|
// No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer
|
||||||
// of access — anyone with it sees that thread; accepted MVP trade-off).
|
// of access — anyone with it sees that thread; accepted MVP trade-off).
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,26 @@ export class GuestIdBodyDto {
|
|||||||
guestId!: string;
|
guestId!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class DeviceSendMessageDto {
|
||||||
|
@ApiProperty({ description: 'Client device id (localStorage).' })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 120)
|
||||||
|
deviceId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Message text.' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(4000)
|
||||||
|
text!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceIdBodyDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 120)
|
||||||
|
deviceId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateStatusDto {
|
export class UpdateStatusDto {
|
||||||
@ApiProperty({ enum: SupportStatusDto })
|
@ApiProperty({ enum: SupportStatusDto })
|
||||||
@IsEnum(SupportStatusDto)
|
@IsEnum(SupportStatusDto)
|
||||||
|
|||||||
@@ -113,6 +113,61 @@ export class SupportService {
|
|||||||
return this.firstMessage(conversation, input.initialMessage);
|
return this.firstMessage(conversation, input.initialMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- customer: device-scoped single thread (portal) -------------------
|
||||||
|
|
||||||
|
/** The device's single conversation + its messages ({conversation:null} if none). */
|
||||||
|
async getDeviceThread(deviceId: string): Promise<T.PassengerSupportThreadDto> {
|
||||||
|
if (!deviceId) return { conversation: null, messages: [] };
|
||||||
|
const c = (await this.prisma.supportConversation.findFirst({
|
||||||
|
where: { guestId: deviceId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})) as ConversationRow | null;
|
||||||
|
if (!c) return { conversation: null, messages: [] };
|
||||||
|
const rows = await this.prisma.supportMessage.findMany({
|
||||||
|
where: { conversationId: c.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
const unread = await this.computeUnread([c], 'USER');
|
||||||
|
return {
|
||||||
|
conversation: this.toConversationDto(c, unread.get(c.id) ?? 0),
|
||||||
|
messages: rows.map((m) => this.toMessageDto(m)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append a message to the device's thread, creating it on first message. */
|
||||||
|
async sendDeviceMessage(
|
||||||
|
deviceId: string,
|
||||||
|
text: string,
|
||||||
|
): Promise<T.PassengerSupportMessageDto> {
|
||||||
|
let c = (await this.prisma.supportConversation.findFirst({
|
||||||
|
where: { guestId: deviceId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})) as ConversationRow | null;
|
||||||
|
if (!c) {
|
||||||
|
c = (await this.prisma.supportConversation.create({
|
||||||
|
data: { guestId: deviceId, subject: 'Support chat', status: 'OPEN' },
|
||||||
|
})) as ConversationRow;
|
||||||
|
}
|
||||||
|
const updated = await this.appendMessage(c, 'USER', text);
|
||||||
|
const last = updated.messages[updated.messages.length - 1];
|
||||||
|
return this.toMessageDto(last);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark the device's thread read (customer side). */
|
||||||
|
async markDeviceRead(deviceId: string): Promise<{ unreadCount: number }> {
|
||||||
|
const c = await this.prisma.supportConversation.findFirst({
|
||||||
|
where: { guestId: deviceId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
if (c) {
|
||||||
|
await this.prisma.supportConversation.update({
|
||||||
|
where: { id: c.id },
|
||||||
|
data: { userLastReadAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.unreadCount('USER', { guestId: deviceId });
|
||||||
|
}
|
||||||
|
|
||||||
async listForCustomer(
|
async listForCustomer(
|
||||||
owner: CustomerOwner,
|
owner: CustomerOwner,
|
||||||
query: ListQuery,
|
query: ListQuery,
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Passenger } from '@edr/types';
|
import { Passenger } from '@edr/types';
|
||||||
import { ArrowLeft, Headset, Plus, Send, X } from 'lucide-react';
|
import { Headset, Send, X } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import { useMarkRead, useSendMessage, useThread } from './useSupport';
|
||||||
useConversations,
|
|
||||||
useCreateConversation,
|
|
||||||
useMarkRead,
|
|
||||||
useMessages,
|
|
||||||
useSendMessage,
|
|
||||||
} from './useSupport';
|
|
||||||
|
|
||||||
const GREEN = 'rgb(20 113 76)';
|
const GREEN = 'rgb(20 113 76)';
|
||||||
type ConversationDto = Passenger.PassengerSupportConversationDto;
|
|
||||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||||
|
|
||||||
const STATUS_CLASS: Record<string, string> = {
|
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatTime(iso?: string | null): string {
|
function formatTime(iso?: string | null): string {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -31,330 +18,24 @@ function formatTime(iso?: string | null): string {
|
|||||||
: d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
: d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
type View = { kind: 'list' } | { kind: 'new' } | { kind: 'thread'; id: string };
|
export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||||
|
const { data, isLoading } = useThread();
|
||||||
export function SupportPanel({
|
const send = useSendMessage();
|
||||||
onClose,
|
|
||||||
isGuest = false,
|
|
||||||
}: {
|
|
||||||
onClose: () => void;
|
|
||||||
isGuest?: boolean;
|
|
||||||
}) {
|
|
||||||
const [view, setView] = useState<View>({ kind: 'list' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-[560px] max-h-[calc(100vh-120px)] w-[min(384px,calc(100vw-32px))] flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
|
|
||||||
{view.kind === 'list' && (
|
|
||||||
<ConversationList
|
|
||||||
onClose={onClose}
|
|
||||||
onNew={() => setView({ kind: 'new' })}
|
|
||||||
onOpen={(id) => setView({ kind: 'thread', id })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{view.kind === 'new' && (
|
|
||||||
<NewConversation
|
|
||||||
isGuest={isGuest}
|
|
||||||
onClose={onClose}
|
|
||||||
onBack={() => setView({ kind: 'list' })}
|
|
||||||
onCreated={(id) => setView({ kind: 'thread', id })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{view.kind === 'thread' && (
|
|
||||||
<Thread
|
|
||||||
conversationId={view.id}
|
|
||||||
onClose={onClose}
|
|
||||||
onBack={() => setView({ kind: 'list' })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Header({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
onClose,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onBack?: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between gap-2 px-4 py-3 text-white"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(135deg, ${GREEN}, rgb(30 140 96))`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
{onBack ? (
|
|
||||||
<button
|
|
||||||
onClick={onBack}
|
|
||||||
className="rounded-full p-1 hover:bg-white/20"
|
|
||||||
aria-label="Back"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={20} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
|
|
||||||
<Headset size={18} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-semibold">{title}</p>
|
|
||||||
{subtitle && (
|
|
||||||
<p className="truncate text-xs text-white/80">{subtitle}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-full p-1 hover:bg-white/20"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConversationList({
|
|
||||||
onClose,
|
|
||||||
onNew,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
|
||||||
onClose: () => void;
|
|
||||||
onNew: () => void;
|
|
||||||
onOpen: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const { data, isLoading } = useConversations();
|
|
||||||
const items = data?.items ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header
|
|
||||||
title="Support"
|
|
||||||
subtitle="We usually reply within a few minutes"
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="p-8 text-center text-sm text-gray-400">Loading…</div>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center gap-2 px-6 py-12 text-center text-sm text-gray-500 dark:text-slate-400">
|
|
||||||
<span
|
|
||||||
className="flex h-12 w-12 items-center justify-center rounded-full"
|
|
||||||
style={{ background: 'rgba(20,113,76,0.1)', color: GREEN }}
|
|
||||||
>
|
|
||||||
<Headset size={24} />
|
|
||||||
</span>
|
|
||||||
No conversations yet. Start one and our team will help you out.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
items.map((c) => (
|
|
||||||
<ConversationRow key={c.id} c={c} onClick={() => onOpen(c.id)} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-gray-100 p-3 dark:border-slate-700">
|
|
||||||
<button
|
|
||||||
onClick={onNew}
|
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
|
||||||
style={{ background: GREEN }}
|
|
||||||
>
|
|
||||||
<Plus size={16} /> New request
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConversationRow({
|
|
||||||
c,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
c: ConversationDto;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
const unread = c.unreadCount > 0;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`block w-full border-b border-gray-100 px-4 py-3 text-left transition hover:bg-gray-50 dark:border-slate-800 dark:hover:bg-slate-800/60 ${
|
|
||||||
unread ? 'bg-emerald-50/60 dark:bg-emerald-900/10' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span
|
|
||||||
className={`truncate text-sm ${
|
|
||||||
unread
|
|
||||||
? 'font-bold text-gray-900 dark:text-white'
|
|
||||||
: 'font-semibold text-gray-800 dark:text-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{c.subject || 'Support request'}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs text-gray-400">
|
|
||||||
{formatTime(c.lastMessageAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center justify-between gap-2">
|
|
||||||
<span className="truncate text-xs text-gray-500 dark:text-slate-400">
|
|
||||||
{c.lastMessageSender === 'AGENT' ? 'Support: ' : ''}
|
|
||||||
{c.lastMessagePreview ?? '—'}
|
|
||||||
</span>
|
|
||||||
{unread ? (
|
|
||||||
<span
|
|
||||||
className="flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold text-white"
|
|
||||||
style={{ background: GREEN }}
|
|
||||||
>
|
|
||||||
{c.unreadCount}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${
|
|
||||||
STATUS_CLASS[c.status] ?? ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{c.status.toLowerCase()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NewConversation({
|
|
||||||
isGuest,
|
|
||||||
onClose,
|
|
||||||
onBack,
|
|
||||||
onCreated,
|
|
||||||
}: {
|
|
||||||
isGuest: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onBack: () => void;
|
|
||||||
onCreated: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [subject, setSubject] = useState('');
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const create = useCreateConversation();
|
|
||||||
|
|
||||||
const guestValid =
|
|
||||||
!isGuest || (name.trim().length > 0 && /.+@.+\..+/.test(email.trim()));
|
|
||||||
const valid =
|
|
||||||
subject.trim().length >= 3 && message.trim().length > 0 && guestValid;
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (!valid) return;
|
|
||||||
const conv = await create.mutateAsync({
|
|
||||||
subject: subject.trim(),
|
|
||||||
initialMessage: message.trim(),
|
|
||||||
...(isGuest ? { name: name.trim(), email: email.trim() } : {}),
|
|
||||||
});
|
|
||||||
onCreated(conv.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header title="New request" onClose={onClose} onBack={onBack} />
|
|
||||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
||||||
{isGuest && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
|
|
||||||
Your name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Full name"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
|
|
||||||
Subject
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={subject}
|
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
|
||||||
placeholder="e.g. Refund for booking EDR-1234"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 flex-col">
|
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
|
|
||||||
How can we help?
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
placeholder="Describe your issue…"
|
|
||||||
className="min-h-[120px] flex-1 resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={submit}
|
|
||||||
disabled={!valid || create.isPending}
|
|
||||||
className="flex items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50"
|
|
||||||
style={{ background: GREEN }}
|
|
||||||
>
|
|
||||||
<Send size={16} /> {create.isPending ? 'Sending…' : 'Send request'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Thread({
|
|
||||||
conversationId,
|
|
||||||
onClose,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
conversationId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onBack: () => void;
|
|
||||||
}) {
|
|
||||||
const { data: conversations } = useConversations();
|
|
||||||
const conversation = useMemo(
|
|
||||||
() => conversations?.items.find((c) => c.id === conversationId),
|
|
||||||
[conversations, conversationId],
|
|
||||||
);
|
|
||||||
const { data: messages, isLoading } = useMessages(conversationId);
|
|
||||||
const send = useSendMessage(conversationId);
|
|
||||||
const markRead = useMarkRead();
|
const markRead = useMarkRead();
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const viewport = useRef<HTMLDivElement>(null);
|
const viewport = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const messages = data?.messages ?? [];
|
||||||
|
|
||||||
|
// Mark read on open + whenever new messages arrive.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (conversationId) markRead.mutate(conversationId);
|
markRead.mutate();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [conversationId, messages?.length]);
|
}, [messages.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||||
}, [messages?.length]);
|
}, [messages.length]);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
@@ -364,22 +45,51 @@ function Thread({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex h-[560px] max-h-[calc(100vh-120px)] w-[min(384px,calc(100vw-32px))] flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
|
||||||
<Header
|
{/* Header */}
|
||||||
title={conversation?.subject || 'Conversation'}
|
<div
|
||||||
subtitle={
|
className="flex items-center justify-between gap-2 px-4 py-3 text-white"
|
||||||
conversation ? `Status: ${conversation.status.toLowerCase()}` : undefined
|
style={{ background: `linear-gradient(135deg, ${GREEN}, rgb(30 140 96))` }}
|
||||||
}
|
>
|
||||||
onClose={onClose}
|
<div className="flex items-center gap-2">
|
||||||
onBack={onBack}
|
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
|
||||||
/>
|
<Headset size={18} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">Support</p>
|
||||||
|
<p className="text-xs text-white/80">We usually reply in a few minutes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full p-1 hover:bg-white/20"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
<div ref={viewport} className="flex-1 space-y-3 overflow-y-auto p-4">
|
<div ref={viewport} className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-8 text-center text-sm text-gray-400">Loading…</div>
|
<div className="p-8 text-center text-sm text-gray-400">Loading…</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 px-6 py-10 text-center text-sm text-gray-500 dark:text-slate-400">
|
||||||
|
<span
|
||||||
|
className="flex h-12 w-12 items-center justify-center rounded-full"
|
||||||
|
style={{ background: 'rgba(20,113,76,0.1)', color: GREEN }}
|
||||||
|
>
|
||||||
|
<Headset size={24} />
|
||||||
|
</span>
|
||||||
|
Hi! 👋 How can we help you today? Send us a message and our team will
|
||||||
|
get back to you.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(messages ?? []).map((m) => <MessageBubble key={m.id} m={m} />)
|
messages.map((m) => <MessageBubble key={m.id} m={m} />)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
<div className="border-t border-gray-100 p-3 dark:border-slate-700">
|
<div className="border-t border-gray-100 p-3 dark:border-slate-700">
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
@@ -406,7 +116,7 @@ function Thread({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import { Headset, MessageCircle } from 'lucide-react';
|
import { Headset, MessageCircle } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
|
||||||
|
|
||||||
import { SupportPanel } from './SupportPanel';
|
import { SupportPanel } from './SupportPanel';
|
||||||
import { useUnreadCount } from './useSupport';
|
import { useUnreadCount } from './useSupport';
|
||||||
import { useSupportSocket } from './useSupportSocket';
|
import { useSupportSocket } from './useSupportSocket';
|
||||||
@@ -12,23 +10,20 @@ import { useSupportSocket } from './useSupportSocket';
|
|||||||
const GREEN = 'rgb(20 113 76)';
|
const GREEN = 'rgb(20 113 76)';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Floating passenger-support launcher, mounted in the app shell for everyone —
|
* Floating support launcher for the portal. One device-scoped thread — opens
|
||||||
* authenticated passengers and guests (guests are scoped by a localStorage
|
* straight into the conversation, no login or form. Live pushes keep the badge
|
||||||
* guestId). Live pushes keep the unread badge fresh.
|
* fresh via a `guest:<deviceId>` socket room.
|
||||||
*/
|
*/
|
||||||
export function SupportWidget() {
|
export function SupportWidget() {
|
||||||
const { isAuthenticated } = useAuthStore();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { data } = useUnreadCount(true);
|
const { data } = useUnreadCount(true);
|
||||||
const unread = data?.unreadCount ?? 0;
|
const unread = data?.unreadCount ?? 0;
|
||||||
|
|
||||||
// Authenticated users keep a live socket for background pushes; guests connect
|
useSupportSocket(true);
|
||||||
// once they open the panel (avoids idle sockets for visitors who never chat).
|
|
||||||
useSupportSocket(isAuthenticated || open);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-6 right-6 z-[100] flex flex-col items-end gap-3">
|
<div className="fixed bottom-6 right-6 z-[100] flex flex-col items-end gap-3">
|
||||||
{open && <SupportPanel onClose={() => setOpen(false)} isGuest={!isAuthenticated} />}
|
{open && <SupportPanel onClose={() => setOpen(false)} />}
|
||||||
{!open && (
|
{!open && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
const DEVICE_KEY = 'support_device_id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable per-device id for the portal's single support thread. Persisted in
|
||||||
|
* localStorage — no login or form required; the visitor is dropped straight
|
||||||
|
* into their one ongoing conversation.
|
||||||
|
*/
|
||||||
|
export function getDeviceId(): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
let id = localStorage.getItem(DEVICE_KEY);
|
||||||
|
if (!id) {
|
||||||
|
id =
|
||||||
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `device-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
|
||||||
|
localStorage.setItem(DEVICE_KEY, id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
const GUEST_KEY = 'support_guest_id';
|
|
||||||
|
|
||||||
/** True when a real passenger auth token is present. */
|
|
||||||
export function isAuthed(): boolean {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
const t = localStorage.getItem('auth_token');
|
|
||||||
return !!t && t !== 'null' && t !== 'undefined';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stable anonymous id for guest conversations (persisted in localStorage). */
|
|
||||||
export function getGuestId(): string {
|
|
||||||
if (typeof window === 'undefined') return '';
|
|
||||||
let id = localStorage.getItem(GUEST_KEY);
|
|
||||||
if (!id) {
|
|
||||||
id =
|
|
||||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `guest-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
|
|
||||||
localStorage.setItem(GUEST_KEY, id);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
@@ -1,75 +1,29 @@
|
|||||||
import type { Passenger } from '@edr/types';
|
import type { Passenger } from '@edr/types';
|
||||||
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { getGuestId, isAuthed } from './guestIdentity';
|
import { getDeviceId } from './deviceIdentity';
|
||||||
|
|
||||||
type ConversationDto = Passenger.PassengerSupportConversationDto;
|
type ThreadDto = Passenger.PassengerSupportThreadDto;
|
||||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||||
type ListResult = Passenger.PassengerSupportConversationListResult;
|
|
||||||
|
|
||||||
export interface CreateInput {
|
|
||||||
subject: string;
|
|
||||||
initialMessage: string;
|
|
||||||
// Required only for guests:
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Passenger portal support-chat calls. Transparently uses the authenticated
|
* Passenger portal support-chat calls — a single device-scoped thread. No auth,
|
||||||
* (`/support/...`) or guest (`/support/guest/...`) endpoints depending on whether
|
* no forms; everything is keyed by a localStorage device id.
|
||||||
* a passenger auth token is present. Guests are scoped by a localStorage guestId.
|
|
||||||
*/
|
*/
|
||||||
export const supportApi = {
|
export const supportApi = {
|
||||||
listConversations: (): Promise<ListResult> =>
|
getThread: (): Promise<ThreadDto> =>
|
||||||
isAuthed()
|
apiClient.get('/support/device/thread', {
|
||||||
? apiClient.get('/support/conversations')
|
params: { deviceId: getDeviceId() },
|
||||||
: apiClient.get('/support/guest/conversations', {
|
}),
|
||||||
params: { guestId: getGuestId() },
|
sendMessage: (text: string): Promise<MessageDto> =>
|
||||||
}),
|
apiClient.post('/support/device/messages', {
|
||||||
|
deviceId: getDeviceId(),
|
||||||
createConversation: (input: CreateInput): Promise<ConversationDto> =>
|
text,
|
||||||
isAuthed()
|
}),
|
||||||
? apiClient.post('/support/conversations', {
|
markRead: (): Promise<{ unreadCount: number }> =>
|
||||||
subject: input.subject,
|
apiClient.post('/support/device/read', { deviceId: getDeviceId() }),
|
||||||
initialMessage: input.initialMessage,
|
|
||||||
})
|
|
||||||
: apiClient.post('/support/guest/conversations', {
|
|
||||||
guestId: getGuestId(),
|
|
||||||
name: input.name,
|
|
||||||
email: input.email,
|
|
||||||
phone: input.phone,
|
|
||||||
subject: input.subject,
|
|
||||||
initialMessage: input.initialMessage,
|
|
||||||
}),
|
|
||||||
|
|
||||||
listMessages: (id: string): Promise<MessageDto[]> =>
|
|
||||||
isAuthed()
|
|
||||||
? apiClient.get(`/support/conversations/${id}/messages`)
|
|
||||||
: apiClient.get(`/support/guest/conversations/${id}/messages`, {
|
|
||||||
params: { guestId: getGuestId() },
|
|
||||||
}),
|
|
||||||
|
|
||||||
sendMessage: (id: string, text: string): Promise<MessageDto> =>
|
|
||||||
isAuthed()
|
|
||||||
? apiClient.post(`/support/conversations/${id}/messages`, { text })
|
|
||||||
: apiClient.post(`/support/guest/conversations/${id}/messages`, {
|
|
||||||
guestId: getGuestId(),
|
|
||||||
text,
|
|
||||||
}),
|
|
||||||
|
|
||||||
markRead: (id: string): Promise<{ unreadCount: number }> =>
|
|
||||||
isAuthed()
|
|
||||||
? apiClient.post(`/support/conversations/${id}/read`)
|
|
||||||
: apiClient.post(`/support/guest/conversations/${id}/read`, {
|
|
||||||
guestId: getGuestId(),
|
|
||||||
}),
|
|
||||||
|
|
||||||
unreadCount: (): Promise<{ unreadCount: number }> =>
|
unreadCount: (): Promise<{ unreadCount: number }> =>
|
||||||
isAuthed()
|
apiClient.get('/support/device/unread-count', {
|
||||||
? apiClient.get('/support/unread-count')
|
params: { deviceId: getDeviceId() },
|
||||||
: apiClient.get('/support/guest/unread-count', {
|
}),
|
||||||
params: { guestId: getGuestId() },
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,28 +2,20 @@
|
|||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { supportApi, type CreateInput } from './supportApi';
|
import { supportApi } from './supportApi';
|
||||||
|
|
||||||
export const SUPPORT_CONVERSATIONS_KEY = ['support', 'conversations'];
|
export const SUPPORT_THREAD_KEY = ['support', 'thread'];
|
||||||
export const SUPPORT_UNREAD_KEY = ['support', 'unread'];
|
export const SUPPORT_UNREAD_KEY = ['support', 'unread'];
|
||||||
export const supportMessagesKey = (id: string) => ['support', 'messages', id];
|
|
||||||
|
|
||||||
export function useConversations(enabled = true) {
|
/** The device's single support thread (conversation + messages). */
|
||||||
|
export function useThread(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: SUPPORT_CONVERSATIONS_KEY,
|
queryKey: SUPPORT_THREAD_KEY,
|
||||||
queryFn: () => supportApi.listConversations(),
|
queryFn: () => supportApi.getThread(),
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMessages(conversationId: string | null) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: supportMessagesKey(conversationId ?? ''),
|
|
||||||
queryFn: () => supportApi.listMessages(conversationId as string),
|
|
||||||
enabled: !!conversationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUnreadCount(enabled = true) {
|
export function useUnreadCount(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: SUPPORT_UNREAD_KEY,
|
queryKey: SUPPORT_UNREAD_KEY,
|
||||||
@@ -33,22 +25,13 @@ export function useUnreadCount(enabled = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateConversation() {
|
export function useSendMessage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (input: CreateInput) => supportApi.createConversation(input),
|
mutationFn: (text: string) => supportApi.sendMessage(text),
|
||||||
onSuccess: () =>
|
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSendMessage(conversationId: string) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (text: string) => supportApi.sendMessage(conversationId, text),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
|
qc.invalidateQueries({ queryKey: SUPPORT_THREAD_KEY });
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -56,9 +39,9 @@ export function useSendMessage(conversationId: string) {
|
|||||||
export function useMarkRead() {
|
export function useMarkRead() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => supportApi.markRead(id),
|
mutationFn: () => supportApi.markRead(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
qc.invalidateQueries({ queryKey: SUPPORT_THREAD_KEY });
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,66 +2,41 @@
|
|||||||
|
|
||||||
import { Passenger } from '@edr/types';
|
import { Passenger } from '@edr/types';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
import { getGuestId, isAuthed } from './guestIdentity';
|
import { getDeviceId } from './deviceIdentity';
|
||||||
import {
|
import { SUPPORT_THREAD_KEY, SUPPORT_UNREAD_KEY } from './useSupport';
|
||||||
SUPPORT_CONVERSATIONS_KEY,
|
|
||||||
SUPPORT_UNREAD_KEY,
|
|
||||||
supportMessagesKey,
|
|
||||||
} from './useSupport';
|
|
||||||
|
|
||||||
const SOCKET_ORIGIN = String(
|
const SOCKET_ORIGIN = String(
|
||||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
|
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
|
||||||
).replace(/\/api\/?$/, '');
|
).replace(/\/api\/?$/, '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes the signed-in passenger to live support pushes. New messages
|
* Subscribes the device to live support pushes. The gateway joins a
|
||||||
* refresh the affected thread + list + unread badge, and fire `onMessage`
|
* `guest:<deviceId>` room; any message/status change refreshes the thread and
|
||||||
* (the widget toasts when closed).
|
* the unread badge.
|
||||||
*/
|
*/
|
||||||
export function useSupportSocket(
|
export function useSupportSocket(enabled: boolean) {
|
||||||
enabled: boolean,
|
|
||||||
onMessage?: (event: Passenger.PassengerSupportMessageEvent) => void,
|
|
||||||
) {
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const onMessageRef = useRef(onMessage);
|
|
||||||
onMessageRef.current = onMessage;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || typeof window === 'undefined') return;
|
if (!enabled || typeof window === 'undefined') return;
|
||||||
// Authenticated passengers connect with their token; guests connect with
|
|
||||||
// their anonymous guestId so the gateway can join their `guest:<id>` room.
|
|
||||||
const auth = isAuthed()
|
|
||||||
? { token: localStorage.getItem('auth_token') }
|
|
||||||
: { guestId: getGuestId() };
|
|
||||||
|
|
||||||
const socket = io(
|
const socket = io(
|
||||||
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
|
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
|
||||||
{
|
{
|
||||||
auth,
|
auth: { guestId: getDeviceId() },
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
socket.on(
|
const refresh = () => {
|
||||||
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
|
qc.invalidateQueries({ queryKey: SUPPORT_THREAD_KEY });
|
||||||
(event: Passenger.PassengerSupportMessageEvent) => {
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: supportMessagesKey(event.message.conversationId),
|
|
||||||
});
|
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
|
||||||
onMessageRef.current?.(event);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, () => {
|
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
|
||||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||||
});
|
};
|
||||||
|
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW, refresh);
|
||||||
|
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, refresh);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off();
|
socket.off();
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ export interface SendPassengerSupportMessageDto {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The single device-scoped thread for the portal: conversation + its messages. */
|
||||||
|
export interface PassengerSupportThreadDto {
|
||||||
|
conversation: PassengerSupportConversationDto | null;
|
||||||
|
messages: PassengerSupportMessageDto[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Paginated list envelope for the conversations list endpoints. */
|
/** Paginated list envelope for the conversations list endpoints. */
|
||||||
export interface PassengerSupportConversationListResult {
|
export interface PassengerSupportConversationListResult {
|
||||||
items: PassengerSupportConversationDto[];
|
items: PassengerSupportConversationDto[];
|
||||||
|
|||||||
Reference in New Issue
Block a user