add:ticket detail
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Badge } from '../ui/Badge.tsx'
|
||||
import { parseDescription } from '../../lib/ticket.ts'
|
||||
import type { Ticket } from '../../lib/types.ts'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -7,14 +8,6 @@ function formatDate(iso: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
// Parse transaction reference encoded by NewTicketForm into the description
|
||||
// Format: "[Transaction: TXN-XXXXX — Label $X.XX on Date]\n\n..."
|
||||
function parseTransaction(description: string): { txnLine: string; body: string } | null {
|
||||
const match = description.match(/^\[Transaction: ([^\]]+)\]\n?\n?(.*)$/s)
|
||||
if (!match) return null
|
||||
return { txnLine: match[1].trim(), body: match[2].trim() }
|
||||
}
|
||||
|
||||
interface AdminTableProps {
|
||||
tickets: Ticket[]
|
||||
}
|
||||
@@ -47,8 +40,8 @@ export function AdminTable({ tickets }: AdminTableProps) {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-100 bg-bg-100">
|
||||
{tickets.map(ticket => {
|
||||
const txn = ticket.type === 'billing' ? parseTransaction(ticket.description) : null
|
||||
const displayDescription = txn ? txn.body : ticket.description
|
||||
const { txnId, txnLine, body: displayDescription } = parseDescription(ticket.description)
|
||||
const hasTxn = ticket.type === 'billing' && txnId !== null
|
||||
|
||||
return (
|
||||
<tr key={ticket.id} className="transition-colors hover:bg-bg-200">
|
||||
@@ -63,9 +56,9 @@ export function AdminTable({ tickets }: AdminTableProps) {
|
||||
</td>
|
||||
{hasBilling && (
|
||||
<td className="px-4 py-3 text-xs text-fg-200 whitespace-nowrap">
|
||||
{txn ? (
|
||||
{hasTxn ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-border-100 bg-bg-300 px-2 py-1 font-mono text-fg-200">
|
||||
{txn.txnLine.split(' — ')[0]}
|
||||
{txnLine!.split(' — ')[0]}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-fg-300 italic">—</span>
|
||||
|
||||
185
frontend/src/components/tickets/TicketDetail.tsx
Normal file
185
frontend/src/components/tickets/TicketDetail.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Badge } from '../ui/Badge.tsx'
|
||||
import { FAKE_TRANSACTIONS } from './NewTicketForm.tsx'
|
||||
import { parseDescription } from '../../lib/ticket.ts'
|
||||
import type { Ticket } from '../../lib/types.ts'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
month: 'long', day: 'numeric', year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<Ticket['type'], string> = {
|
||||
'bug': 'Bug',
|
||||
'billing': 'Billing',
|
||||
'account': 'Account',
|
||||
'feature-request':'Feature Request',
|
||||
'feedback': 'Feedback',
|
||||
'other': 'Other',
|
||||
}
|
||||
|
||||
const HOLD_DURATION = 2000 // ms
|
||||
|
||||
interface HoldToCloseProps {
|
||||
onComplete: () => Promise<void>
|
||||
}
|
||||
|
||||
function HoldToClose({ onComplete }: HoldToCloseProps) {
|
||||
const [progress, setProgress] = useState(0) // 0–1
|
||||
const [completing, setCompleting] = useState(false)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const startRef = useRef<number | null>(null)
|
||||
|
||||
const cancel = () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
startRef.current = null
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
const tick = (now: number) => {
|
||||
if (!startRef.current) return
|
||||
const elapsed = now - startRef.current
|
||||
const next = Math.min(elapsed / HOLD_DURATION, 1)
|
||||
setProgress(next)
|
||||
if (next < 1) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} else {
|
||||
setCompleting(true)
|
||||
onComplete().finally(() => setCompleting(false))
|
||||
}
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (completing) return
|
||||
startRef.current = performance.now()
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={start}
|
||||
onMouseUp={cancel}
|
||||
onMouseLeave={cancel}
|
||||
onTouchStart={start}
|
||||
onTouchEnd={cancel}
|
||||
disabled={completing}
|
||||
className={`
|
||||
relative inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm
|
||||
select-none transition-colors duration-150 cursor-pointer
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
${isHolding
|
||||
? 'text-red-400 bg-red-950/40'
|
||||
: 'text-fg-300 hover:text-fg-100 hover:bg-bg-300'
|
||||
}
|
||||
`}
|
||||
aria-label="Hold to close ticket"
|
||||
>
|
||||
{/* 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' }}
|
||||
/>
|
||||
</svg>
|
||||
{/* Icon */}
|
||||
<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>
|
||||
</span>
|
||||
|
||||
<span className="text-xs font-medium">
|
||||
{completing ? 'Closing…' : isHolding ? 'Keep holding…' : 'Hold to close'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface TicketDetailProps {
|
||||
ticket: Ticket
|
||||
onCloseTicket: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function TicketDetail({ ticket, onCloseTicket }: TicketDetailProps) {
|
||||
const { txnId, body } = parseDescription(ticket.description)
|
||||
const txn = txnId ? FAKE_TRANSACTIONS.find(t => t.id === txnId) ?? null : null
|
||||
const isClosed = ticket.status === 'closed'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* Status + meta row */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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>
|
||||
<span className="text-xs text-fg-300">·</span>
|
||||
<span className="text-xs text-fg-300">Opened {formatDate(ticket.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Transaction card — only for billing tickets with a linked txn */}
|
||||
{txn && (
|
||||
<div className="rounded-md border border-border-100 bg-bg-300 px-3 py-2.5">
|
||||
<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">
|
||||
<span className="text-xs font-medium text-fg-100">{txn.label}</span>
|
||||
<span className="text-xs text-fg-300">{txn.id} · {txn.date}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-fg-100 shrink-0">{txn.amount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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>
|
||||
) : (
|
||||
<p className="text-xs italic text-fg-300">No description provided.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: ticket ID + close action */}
|
||||
<div className="flex items-center justify-between border-t border-border-100 pt-3">
|
||||
<p className="text-xs text-fg-300 font-mono">ID: {ticket.id}</p>
|
||||
{isClosed ? (
|
||||
<span className="text-xs text-fg-300 italic">This ticket is closed.</span>
|
||||
) : (
|
||||
<HoldToClose onComplete={() => onCloseTicket(ticket.id)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,10 +10,10 @@ function formatDate(iso: string): string {
|
||||
|
||||
interface TicketTableProps {
|
||||
tickets: Ticket[]
|
||||
onDelete: (id: string) => void
|
||||
onOpen: (ticket: Ticket) => void
|
||||
}
|
||||
|
||||
export function TicketTable({ tickets, onDelete }: TicketTableProps) {
|
||||
export function TicketTable({ tickets, onOpen }: TicketTableProps) {
|
||||
if (tickets.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-border-100 bg-bg-200 py-16 text-center">
|
||||
@@ -36,16 +36,20 @@ export function TicketTable({ tickets, onDelete }: TicketTableProps) {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-100 bg-bg-100">
|
||||
{tickets.map((ticket) => (
|
||||
<tr key={ticket.id} className="transition-colors hover:bg-bg-200">
|
||||
<tr
|
||||
key={ticket.id}
|
||||
className="transition-colors hover:bg-bg-200 cursor-pointer"
|
||||
onClick={() => onOpen(ticket)}
|
||||
>
|
||||
<td className="px-4 py-3 text-fg-100">{ticket.subject}</td>
|
||||
<td className="px-4 py-3"><Badge status={ticket.status} /></td>
|
||||
<td className="px-4 py-3 text-xs text-fg-300">{formatDate(ticket.createdAt)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => onDelete(ticket.id)}
|
||||
variant="ghost"
|
||||
onClick={e => { e.stopPropagation(); onOpen(ticket) }}
|
||||
>
|
||||
Delete
|
||||
Open
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
--color-bg-400: oklch(0.18 0 0);
|
||||
|
||||
--color-fg-100: oklch(0.92 0 0);
|
||||
--color-fg-200: oklch(0.6 0 0);
|
||||
--color-fg-300: oklch(0.4 0 0);
|
||||
--color-fg-200: oklch(0.65 0 0);
|
||||
--color-fg-300: oklch(0.5 0 0);
|
||||
|
||||
--color-border-100: oklch(1 0 0 / 9%);
|
||||
--color-border-200: oklch(1 0 0 / 30%);
|
||||
|
||||
16
frontend/src/lib/ticket.ts
Normal file
16
frontend/src/lib/ticket.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface ParsedDescription {
|
||||
txnId: string | null // e.g. "TXN-48291"
|
||||
txnLine: string | null // e.g. "TXN-48291 — Pro Plan — Monthly $12.00 on Mar 1, 2026"
|
||||
body: string // the user-written description, without the prefix
|
||||
}
|
||||
|
||||
export function parseDescription(description: string): ParsedDescription {
|
||||
const match = description.match(/^\[Transaction: ([^ ]+)([^\]]*)\]\n?\n?(.*)$/s)
|
||||
if (!match) return { txnId: null, txnLine: null, body: description }
|
||||
|
||||
const txnId = match[1].trim()
|
||||
const txnLine = (txnId + match[2]).trim()
|
||||
const body = match[3].trim()
|
||||
|
||||
return { txnId, txnLine, body }
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import { useState, useEffect } from 'react'
|
||||
import { Modal } from '../components/ui/Modal.tsx'
|
||||
import { Button } from '../components/ui/Button.tsx'
|
||||
import { TicketTable } from '../components/tickets/TicketTable.tsx'
|
||||
import { TicketDetail } from '../components/tickets/TicketDetail.tsx'
|
||||
import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx'
|
||||
import { useModal } from '../hooks/useModal.ts'
|
||||
import { storage } from '../lib/storage.ts'
|
||||
import type { Ticket } from '../lib/types.ts'
|
||||
import { PlusIcon } from '../components/icons/plus.tsx'
|
||||
|
||||
const TICKET_LIMIT = 3
|
||||
const TICKET_LIMIT = 10
|
||||
|
||||
interface UserPageProps {
|
||||
isAuthenticated: boolean
|
||||
@@ -65,7 +66,10 @@ function TicketLimitReached({ onClose, fromServer }: { onClose: () => void; from
|
||||
export function UserPage({ isAuthenticated }: UserPageProps) {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||
const [serverLimitHit, setServerLimitHit] = useState(false)
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null)
|
||||
|
||||
const newTicketModal = useModal()
|
||||
const detailModal = useModal()
|
||||
|
||||
const atLimit = isAuthenticated && tickets.length >= TICKET_LIMIT
|
||||
const showLimitScreen = atLimit || serverLimitHit
|
||||
@@ -74,12 +78,29 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
||||
storage.getTickets().then(setTickets)
|
||||
}, [isAuthenticated])
|
||||
|
||||
// Reset server limit flag whenever the modal closes
|
||||
const handleClose = () => {
|
||||
const handleNewClose = () => {
|
||||
newTicketModal.close()
|
||||
setServerLimitHit(false)
|
||||
}
|
||||
|
||||
const handleOpen = (ticket: Ticket) => {
|
||||
setSelectedTicket(ticket)
|
||||
detailModal.open()
|
||||
}
|
||||
|
||||
const handleDetailClose = () => {
|
||||
detailModal.close()
|
||||
setSelectedTicket(null)
|
||||
}
|
||||
|
||||
const handleCloseTicket = async (id: string) => {
|
||||
const updated = await storage.updateTicket(id, { status: 'closed' })
|
||||
if (updated) {
|
||||
setTickets(prev => prev.map(t => t.id === id ? updated : t))
|
||||
setSelectedTicket(updated)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
||||
if (atLimit) return
|
||||
try {
|
||||
@@ -88,19 +109,12 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
||||
newTicketModal.close()
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ticket_limit_reached') {
|
||||
// Backend rejected — switch the open modal to the limit screen immediately
|
||||
// and re-sync the ticket list so atLimit also becomes true
|
||||
setServerLimitHit(true)
|
||||
storage.getTickets().then(setTickets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await storage.deleteTicket(id)
|
||||
setTickets(prev => prev.filter(t => t.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
@@ -126,18 +140,28 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TicketTable tickets={tickets} onDelete={handleDelete} />
|
||||
<TicketTable tickets={tickets} onOpen={handleOpen} />
|
||||
|
||||
{/* New ticket modal */}
|
||||
<Modal
|
||||
isOpen={newTicketModal.isOpen}
|
||||
onClose={handleClose}
|
||||
onClose={handleNewClose}
|
||||
title={showLimitScreen ? 'Ticket Limit Reached' : 'New Ticket'}
|
||||
>
|
||||
{showLimitScreen
|
||||
? <TicketLimitReached onClose={handleClose} fromServer={serverLimitHit && !atLimit} />
|
||||
? <TicketLimitReached onClose={handleNewClose} fromServer={serverLimitHit && !atLimit} />
|
||||
: <NewTicketForm onSubmit={handleCreate} />
|
||||
}
|
||||
</Modal>
|
||||
|
||||
{/* Ticket detail modal */}
|
||||
<Modal
|
||||
isOpen={detailModal.isOpen}
|
||||
onClose={handleDetailClose}
|
||||
title={selectedTicket?.subject ?? ''}
|
||||
>
|
||||
{selectedTicket && <TicketDetail ticket={selectedTicket} onCloseTicket={handleCloseTicket} />}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user