add:ticket detail

This commit is contained in:
2026-03-09 16:33:46 +09:00
parent 2bfd94e358
commit 794fbad9bb
6 changed files with 255 additions and 33 deletions

View File

@@ -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>

View 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) // 01
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>
)
}

View File

@@ -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>

View File

@@ -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%);

View 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 }
}

View File

@@ -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>
</>
)
}