add:reply system

This commit is contained in:
2026-03-09 23:11:00 +09:00
parent 3c28c117a0
commit 2a81ede504
18 changed files with 772 additions and 167 deletions

View File

@@ -0,0 +1,8 @@
import type { IconProps } from "../../lib/types.ts";
export const CircleArrowIcon = ({ className }: IconProps) => (
<svg className={className} viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1.5 6a4.5 4.5 0 1 0 .9-2.7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
<path d="M1.5 2v2.5H4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);

View File

@@ -0,0 +1,7 @@
import type { IconProps } from "../../lib/types.ts";
export const CloseIcon = ({ className }: IconProps) => (
<svg viewBox="0 0 16 16" fill="none" className={className}>
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);

View File

@@ -0,0 +1,7 @@
import type { IconProps } from "../../lib/types.ts";
export const TrashIcon = ({ className }: IconProps) => (
<svg className={className} viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1.5 3h9M4.5 3V2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v1M5 5.5v3M7 5.5v3M2.5 3l.5 7a.5.5 0 0 0 .5.5h5a.5.5 0 0 0 .5-.5l.5-7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);

View File

@@ -1,8 +1,13 @@
import { useState, useRef, useEffect } from 'react'
import { Badge } from '../ui/Badge.tsx'
import { Button } from '../ui/Button.tsx'
import { FAKE_TRANSACTIONS } from './NewTicketForm.tsx'
import { parseDescription } from '../../lib/ticket.ts'
import type { Ticket } from '../../lib/types.ts'
import { storage } from '../../lib/storage.ts'
import type { Ticket, Reply } from '../../lib/types.ts'
import { CloseIcon } from '../icons/close.tsx'
import { TrashIcon } from '../icons/trash.tsx'
import { CircleArrowIcon } from '../icons/circleArrow.tsx'
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
@@ -10,16 +15,22 @@ function formatDate(iso: string): string {
})
}
const TYPE_LABELS: Record<Ticket['type'], string> = {
'bug': 'Bug',
'billing': 'Billing',
'account': 'Account',
'feature-request':'Feature Request',
'feedback': 'Feedback',
'other': 'Other',
function formatTime(iso: string): string {
return new Date(iso).toLocaleString('en-US', {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
})
}
const HOLD_DURATION = 2000 // ms
const TYPE_LABELS: Record<Ticket['type'], string> = {
'bug': 'Bug',
'billing': 'Billing',
'account': 'Account',
'feature-request': 'Feature Request',
'feedback': 'Feedback',
'other': 'Other',
}
const HOLD_DURATION = 900 // ms
interface HoldButtonProps {
onComplete: () => Promise<void>
@@ -61,16 +72,13 @@ function HoldButton({ onComplete, label, completingLabel, icon, ariaLabel }: Hol
rafRef.current = requestAnimationFrame(tick)
}
// Clean up on unmount
useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }, [])
// SVG arc helpers
const size = 28
const stroke = 2.5
const r = (size - stroke) / 2
const circ = 2 * Math.PI * r
const dash = progress * circ
const isHolding = progress > 0 && progress < 1
return (
@@ -93,32 +101,13 @@ function HoldButton({ onComplete, label, completingLabel, icon, ariaLabel }: Hol
`}
aria-label={ariaLabel}
>
{/* Progress ring */}
<span className="relative flex items-center justify-center shrink-0" style={{ width: size, height: size }}>
{/* Track */}
<svg width={size} height={size} className="absolute inset-0 -rotate-90" aria-hidden="true">
<circle
cx={size / 2} cy={size / 2} r={r}
fill="none"
stroke="currentColor"
strokeOpacity={0.15}
strokeWidth={stroke}
/>
{/* Fill arc */}
<circle
cx={size / 2} cy={size / 2} r={r}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${dash} ${circ}`}
style={{ transition: progress === 0 ? 'stroke-dasharray 0.15s ease' : 'none' }}
/>
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeOpacity={0.15} strokeWidth={stroke} />
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeDasharray={`${dash} ${circ}`} style={{ transition: progress === 0 ? 'stroke-dasharray 0.15s ease' : 'none' }} />
</svg>
{/* Icon */}
{icon}
</span>
<span className="text-xs font-medium">
{completing ? completingLabel : isHolding ? 'Keep holding…' : label}
</span>
@@ -126,46 +115,131 @@ function HoldButton({ onComplete, label, completingLabel, icon, ariaLabel }: Hol
)
}
// Close icon (×)
const CloseIcon = (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
)
function ReplyBubble({ reply }: { reply: Reply }) {
const isSupport = reply.authorRole === 'support'
return (
<div className={`flex flex-col gap-1 ${isSupport ? 'items-end' : 'items-start'}`}>
<div className={`
max-w-[85%] rounded-lg px-3.5 py-2.5
${isSupport
? 'bg-fg-100/10 border border-fg-100/15'
: 'bg-bg-300 border border-border-100'
}
`}>
<p className="text-sm text-fg-100 leading-relaxed whitespace-pre-wrap">{reply.body}</p>
</div>
<p className="text-[10px] text-fg-300 px-1">
{isSupport ? 'Support' : (reply.username ?? 'You')} · {formatTime(reply.createdAt)}
</p>
</div>
)
}
// Delete icon (trash)
const DeleteIcon = (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1.5 3h9M4.5 3V2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v1M5 5.5v3M7 5.5v3M2.5 3l.5 7a.5.5 0 0 0 .5.5h5a.5.5 0 0 0 .5-.5l.5-7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
interface ReplyComposerProps {
onSend: (body: string) => Promise<void>
disabled?: boolean
}
function ReplyComposer({ onSend, disabled }: ReplyComposerProps) {
const [body, setBody] = useState('')
const [sending, setSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSend = async () => {
if (!body.trim() || sending) return
setSending(true)
setError(null)
try {
await onSend(body.trim())
setBody('')
textareaRef.current?.focus()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send reply.')
} finally {
setSending(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleSend()
}
return (
<div className="flex flex-col gap-2 border-t border-border-100 pt-4">
{error && (
<p className="text-xs text-red-400">{error}</p>
)}
<textarea
ref={textareaRef}
value={body}
onChange={e => setBody(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled || sending}
placeholder="Write a reply… (⌘Enter to send)"
rows={3}
className="w-full rounded-md border border-border-100 bg-bg-300 px-3 py-2 text-sm text-fg-100 placeholder:text-fg-300 outline-none transition-colors focus:border-border-200 focus:ring-1 focus:ring-ring-100 resize-none disabled:opacity-50"
/>
<div className="flex justify-end">
<Button onClick={handleSend} disabled={!body.trim() || sending || disabled}>
{sending ? 'Sending…' : 'Send Reply'}
</Button>
</div>
</div>
)
}
// Reopen icon (arrow rotating back)
const ReopenIcon = (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1.5 6a4.5 4.5 0 1 0 .9-2.7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
<path d="M1.5 2v2.5H4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
interface TicketDetailProps {
ticket: Ticket
isAuthenticated: boolean
canReply: boolean
replyAs?: 'user' | 'support' // defaults to 'user'; Admin tab passes 'support'
onCloseTicket?: (id: string) => Promise<void>
onDeleteTicket?: (id: string) => Promise<void>
onReopenTicket?: (id: string) => Promise<void>
}
export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTicket }: TicketDetailProps) {
export function TicketDetail({
ticket,
isAuthenticated,
canReply,
replyAs = 'user',
onCloseTicket,
onDeleteTicket,
onReopenTicket,
}: TicketDetailProps) {
const { txnId, body } = parseDescription(ticket.description)
const txn = txnId ? FAKE_TRANSACTIONS.find(t => t.id === txnId) ?? null : null
const isClosed = ticket.status === 'closed'
const hasAnyAction = onCloseTicket || onReopenTicket || onDeleteTicket
const [replies, setReplies] = useState<Reply[]>([])
const [repliesLoading, setRepliesLoading] = useState(true)
const threadEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setRepliesLoading(true)
storage.getReplies(isAuthenticated, ticket.id)
.then(setReplies)
.finally(() => setRepliesLoading(false))
}, [isAuthenticated, ticket.id])
// Scroll to bottom when new replies arrive
useEffect(() => {
threadEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [replies.length])
const handleSendReply = async (replyBody: string) => {
const newReply = await storage.createReply(isAuthenticated, ticket.id, replyBody, replyAs === 'support')
setReplies(prev => [...prev, newReply])
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 max-h-[75vh]">
{/* Status + meta row */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap shrink-0">
<Badge status={ticket.status} />
<span className="text-xs text-fg-300">·</span>
<span className="text-xs text-fg-200 capitalize">{TYPE_LABELS[ticket.type]}</span>
@@ -173,9 +247,9 @@ export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTi
<span className="text-xs text-fg-300">Opened {formatDate(ticket.createdAt)}</span>
</div>
{/* Transaction card — only for billing tickets with a linked txn */}
{/* Transaction card */}
{txn && (
<div className="rounded-md border border-border-100 bg-bg-300 px-3 py-2.5">
<div className="rounded-md border border-border-100 bg-bg-300 px-3 py-2.5 shrink-0">
<p className="text-xs font-medium text-fg-300 mb-1.5">Linked transaction</p>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
@@ -187,8 +261,8 @@ export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTi
</div>
)}
{/* Description */}
<div className="flex flex-col gap-1.5">
{/* Original description */}
<div className="flex flex-col gap-1.5 shrink-0">
<p className="text-xs font-medium text-fg-200">Description</p>
{body ? (
<p className="text-sm text-fg-100 leading-relaxed whitespace-pre-wrap">{body}</p>
@@ -197,8 +271,39 @@ export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTi
)}
</div>
{/* Reply thread — this section scrolls, nothing else does */}
<div className="flex flex-col gap-1.5 min-h-0 flex-1">
<p className="text-xs font-medium text-fg-200 shrink-0">
Replies {!repliesLoading && replies.length > 0 && (
<span className="text-fg-300 font-normal">({replies.length})</span>
)}
</p>
<div className="overflow-y-auto flex-1 min-h-[80px]">
{repliesLoading ? (
<p className="text-xs text-fg-300 py-2">Loading</p>
) : replies.length === 0 ? (
<p className="text-xs italic text-fg-300">No replies yet.</p>
) : (
<div className="flex flex-col gap-3 py-1 pr-1">
{replies.map(r => <ReplyBubble key={r.id} reply={r} />)}
<div ref={threadEndRef} />
</div>
)}
</div>
</div>
{/* Compose — hidden for read-only viewers and closed tickets */}
{canReply && !isClosed && (
<ReplyComposer onSend={handleSendReply} />
)}
{canReply && isClosed && (
<p className="text-xs text-fg-300 italic border-t border-border-100 pt-3 shrink-0">
This ticket is closed replies are disabled.
</p>
)}
{/* Footer: ticket ID + actions */}
<div className="flex items-center justify-between border-t border-border-100 pt-3">
<div className="flex items-center justify-between border-t border-border-100 pt-3 shrink-0">
<p className="text-xs text-fg-300 font-mono">ID: {ticket.id}</p>
<div className="flex items-center gap-1">
{!hasAnyAction && (
@@ -210,7 +315,7 @@ export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTi
onComplete={() => onReopenTicket(ticket.id)}
label="Hold to reopen"
completingLabel="Reopening…"
icon={ReopenIcon}
icon={<CircleArrowIcon className="size-4" />}
ariaLabel="Hold to reopen ticket"
/>
) : onCloseTicket ? (
@@ -222,7 +327,7 @@ export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTi
onComplete={() => onCloseTicket(ticket.id)}
label="Hold to close"
completingLabel="Closing…"
icon={CloseIcon}
icon={<CloseIcon className="size-4" />}
ariaLabel="Hold to close ticket"
/>
)
@@ -232,7 +337,7 @@ export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTi
onComplete={() => onDeleteTicket(ticket.id)}
label="Hold to delete"
completingLabel="Deleting…"
icon={DeleteIcon}
icon={<TrashIcon className="size-4" />}
ariaLabel="Hold to delete ticket"
/>
)}

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { CloseIcon } from '../icons/close'
interface ModalProps {
isOpen: boolean
@@ -31,7 +32,7 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
aria-labelledby="modal-title"
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-lg border border-border-100 bg-bg-200 shadow-2xl">
<div className="relative z-10 w-full max-w-2xl rounded-lg border border-border-100 bg-bg-200 shadow-2xl">
<div className="flex items-center justify-between border-b border-border-100 px-5 py-4">
<h2 id="modal-title" className="text-sm font-semibold text-fg-100">{title}</h2>
<button
@@ -39,12 +40,10 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
className="rounded-md p-1 text-fg-300 transition-colors hover:bg-bg-300 hover:text-fg-100 cursor-pointer"
aria-label="Close"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
<CloseIcon className="size-5" />
</button>
</div>
<div className="px-5 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
<div className="px-5 py-4">{children}</div>
</div>
</div>,
document.body

View File

@@ -1,64 +1,61 @@
import type { Ticket, TicketType } from "./types";
import { env } from "../env";
import type { Ticket, TicketType, Reply } from './types'
import { env } from '../env'
const API = env.apiUrl
const API = env.apiUrl;
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly status: number
readonly code: string
constructor(status: number, code: string, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
}
}
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API}${path}`, {
...init,
credentials: "include",
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
});
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) },
})
if (!res.ok) {
// Try to parse a structured error body; fall back to a generic message
let code = `http_${res.status}`;
let message = `API error ${res.status}`;
let code = `http_${res.status}`
let message = `API error ${res.status}`
try {
const body = await res.json();
if (body?.error) code = body.error;
if (body?.message) message = body.message;
} catch {
/* non-JSON body — keep defaults */
}
throw new ApiError(res.status, code, message);
const body = await res.json()
if (body?.error) code = body.error
if (body?.message) message = body.message
} catch { /* non-JSON body — keep defaults */ }
throw new ApiError(res.status, code, message)
}
return res.json();
return res.json()
}
const LOCAL_KEY = "support_tickets";
const LOCAL_KEY = 'support_tickets'
function localGet(): Ticket[] {
try {
return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? "[]");
return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]')
} catch {
return [];
return []
}
}
function localSet(tickets: Ticket[]) {
localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets));
localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets))
}
export const localAdapter = {
getTickets: (): Ticket[] => localGet(),
createTicket: (data: {
subject: string;
description: string;
type: TicketType;
}): Ticket => {
createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => {
const ticket: Ticket = {
id: crypto.randomUUID(),
userId: null,
@@ -66,49 +63,80 @@ export const localAdapter = {
subject: data.subject,
description: data.description,
type: data.type,
status: "open",
status: 'open',
createdAt: new Date().toISOString(),
};
localSet([ticket, ...localGet()]);
return ticket;
}
localSet([ticket, ...localGet()])
return ticket
},
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
const tickets = localGet();
const idx = tickets.findIndex((t) => t.id === id);
if (idx === -1) return null;
tickets[idx] = { ...tickets[idx], ...patch };
localSet(tickets);
return tickets[idx];
const tickets = localGet()
const idx = tickets.findIndex(t => t.id === id)
if (idx === -1) return null
tickets[idx] = { ...tickets[idx], ...patch }
localSet(tickets)
return tickets[idx]
},
deleteTicket: (id: string): boolean => {
const before = localGet();
const after = before.filter((t) => t.id !== id);
localSet(after);
return after.length < before.length;
const before = localGet()
const after = before.filter(t => t.id !== id)
localSet(after)
return after.length < before.length
},
};
}
const LOCAL_REPLIES_KEY = 'support_replies'
function repliesGet(): Reply[] {
try { return JSON.parse(localStorage.getItem(LOCAL_REPLIES_KEY) ?? '[]') } catch { return [] }
}
function repliesSet(replies: Reply[]) {
localStorage.setItem(LOCAL_REPLIES_KEY, JSON.stringify(replies))
}
export const localReplyAdapter = {
getReplies: (ticketId: string): Reply[] =>
repliesGet().filter(r => r.ticketId === ticketId),
createReply: (ticketId: string, body: string): Reply => {
const reply: Reply = {
id: crypto.randomUUID(),
ticketId,
userId: null,
username: null,
body,
authorRole: 'user',
createdAt: new Date().toISOString(),
}
repliesSet([...repliesGet(), reply])
return reply
},
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
data: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface TicketFilters {
status?: Ticket["status"];
type?: TicketType;
mine?: boolean; // restrict to the current user's tickets
status?: Ticket['status']
type?: TicketType
mine?: boolean // restrict to the current user's tickets
}
export const storage = {
// User's own tickets — API when authenticated, localStorage when guest
async getTickets(isAuthenticated: boolean): Promise<Ticket[]> {
if (!isAuthenticated) return localAdapter.getTickets();
return apiFetch<Ticket[]>("/api/tickets");
if (!isAuthenticated) return localAdapter.getTickets()
return apiFetch<Ticket[]>('/api/tickets')
},
// Admin view — paginated from API when authenticated, sliced localStorage when guest
@@ -119,53 +147,59 @@ export const storage = {
filters: TicketFilters = {},
): Promise<PaginatedResponse<Ticket>> {
if (!isAuthenticated) {
let all = localAdapter.getTickets();
if (filters.status) all = all.filter((t) => t.status === filters.status);
if (filters.type) all = all.filter((t) => t.type === filters.type);
const start = (page - 1) * pageSize;
let all = localAdapter.getTickets()
if (filters.status) all = all.filter(t => t.status === filters.status)
if (filters.type) all = all.filter(t => t.type === filters.type)
const start = (page - 1) * pageSize
return {
data: all.slice(start, start + pageSize),
total: all.length,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
};
}
}
const params = new URLSearchParams({ page: String(page) });
if (filters.status) params.set("status", filters.status);
if (filters.type) params.set("type", filters.type);
if (filters.mine) params.set("mine", "true");
const params = new URLSearchParams({ page: String(page) })
if (filters.status) params.set('status', filters.status)
if (filters.type) params.set('type', filters.type)
if (filters.mine) params.set('mine', 'true')
return apiFetch<PaginatedResponse<Ticket>>(`/api/tickets/all?${params}`);
return apiFetch<PaginatedResponse<Ticket>>(`/api/tickets/all?${params}`)
},
async createTicket(
isAuthenticated: boolean,
data: { subject: string; description: string; type: TicketType },
): Promise<Ticket> {
if (!isAuthenticated) return localAdapter.createTicket(data);
return apiFetch<Ticket>("/api/tickets", {
method: "POST",
async createTicket(isAuthenticated: boolean, data: { subject: string; description: string; type: TicketType }): Promise<Ticket> {
if (!isAuthenticated) return localAdapter.createTicket(data)
return apiFetch<Ticket>('/api/tickets', {
method: 'POST',
body: JSON.stringify(data),
});
})
},
async updateTicket(
isAuthenticated: boolean,
id: string,
patch: Partial<Ticket>,
): Promise<Ticket | null> {
if (!isAuthenticated) return localAdapter.updateTicket(id, patch);
async updateTicket(isAuthenticated: boolean, id: string, patch: Partial<Ticket>): Promise<Ticket | null> {
if (!isAuthenticated) return localAdapter.updateTicket(id, patch)
return apiFetch<Ticket>(`/api/tickets/${id}`, {
method: "PATCH",
method: 'PATCH',
body: JSON.stringify(patch),
});
})
},
async deleteTicket(isAuthenticated: boolean, id: string): Promise<boolean> {
if (!isAuthenticated) return localAdapter.deleteTicket(id);
await apiFetch(`/api/tickets/${id}`, { method: "DELETE" });
return true;
if (!isAuthenticated) return localAdapter.deleteTicket(id)
await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' })
return true
},
};
async getReplies(isAuthenticated: boolean, ticketId: string): Promise<Reply[]> {
if (!isAuthenticated) return localReplyAdapter.getReplies(ticketId)
return apiFetch<Reply[]>(`/api/tickets/${ticketId}/replies`)
},
async createReply(isAuthenticated: boolean, ticketId: string, body: string, asSupport = false): Promise<Reply> {
if (!isAuthenticated) return localReplyAdapter.createReply(ticketId, body)
return apiFetch<Reply>(`/api/tickets/${ticketId}/replies`, {
method: 'POST',
body: JSON.stringify({ body, asSupport }),
})
},
}

View File

@@ -17,6 +17,16 @@ export interface Ticket {
createdAt: string
}
export interface Reply {
id: string
ticketId: string
userId: string | null
username: string | null
body: string
authorRole: 'user' | 'support'
createdAt: string
}
export interface IconProps {
className?: string;
}

View File

@@ -311,6 +311,9 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
)}
<TicketDetail
ticket={selectedTicket}
isAuthenticated={isAuthenticated}
canReply={canModify(selectedTicket)}
replyAs="support"
onCloseTicket={canModify(selectedTicket) ? handleCloseTicket : undefined}
onReopenTicket={canModify(selectedTicket) ? handleReopenTicket : undefined}
onDeleteTicket={canModify(selectedTicket) ? handleDeleteTicket : undefined}

View File

@@ -191,7 +191,12 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
<p className="text-xs leading-relaxed text-red-400">{actionError}</p>
</div>
)}
<TicketDetail ticket={selectedTicket} onCloseTicket={handleCloseTicket} />
<TicketDetail
ticket={selectedTicket}
isAuthenticated={isAuthenticated}
canReply={true}
onCloseTicket={handleCloseTicket}
/>
</>
)}
</Modal>