From 794fbad9bb68cc8074cb74b61fc3f99f1909c381 Mon Sep 17 00:00:00 2001 From: kokopi Date: Mon, 9 Mar 2026 16:33:46 +0900 Subject: [PATCH] add:ticket detail --- frontend/src/components/admin/AdminTable.tsx | 17 +- .../src/components/tickets/TicketDetail.tsx | 185 ++++++++++++++++++ .../src/components/tickets/TicketTable.tsx | 16 +- frontend/src/index.css | 4 +- frontend/src/lib/ticket.ts | 16 ++ frontend/src/pages/UserPage.tsx | 50 +++-- 6 files changed, 255 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/tickets/TicketDetail.tsx create mode 100644 frontend/src/lib/ticket.ts diff --git a/frontend/src/components/admin/AdminTable.tsx b/frontend/src/components/admin/AdminTable.tsx index 0ec3771..63fc512 100644 --- a/frontend/src/components/admin/AdminTable.tsx +++ b/frontend/src/components/admin/AdminTable.tsx @@ -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) { {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 ( @@ -63,9 +56,9 @@ export function AdminTable({ tickets }: AdminTableProps) { {hasBilling && ( - {txn ? ( + {hasTxn ? ( - {txn.txnLine.split(' — ')[0]} + {txnLine!.split(' — ')[0]} ) : ( diff --git a/frontend/src/components/tickets/TicketDetail.tsx b/frontend/src/components/tickets/TicketDetail.tsx new file mode 100644 index 0000000..c245e32 --- /dev/null +++ b/frontend/src/components/tickets/TicketDetail.tsx @@ -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 = { + 'bug': 'Bug', + 'billing': 'Billing', + 'account': 'Account', + 'feature-request':'Feature Request', + 'feedback': 'Feedback', + 'other': 'Other', +} + +const HOLD_DURATION = 2000 // ms + +interface HoldToCloseProps { + onComplete: () => Promise +} + +function HoldToClose({ onComplete }: HoldToCloseProps) { + const [progress, setProgress] = useState(0) // 0–1 + const [completing, setCompleting] = useState(false) + const rafRef = useRef(null) + const startRef = useRef(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 ( + + ) +} + +interface TicketDetailProps { + ticket: Ticket + onCloseTicket: (id: string) => Promise +} + +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 ( +
+ + {/* Status + meta row */} +
+ + · + {TYPE_LABELS[ticket.type]} + · + Opened {formatDate(ticket.createdAt)} +
+ + {/* Transaction card — only for billing tickets with a linked txn */} + {txn && ( +
+

Linked transaction

+
+
+ {txn.label} + {txn.id} · {txn.date} +
+ {txn.amount} +
+
+ )} + + {/* Description */} +
+

Description

+ {body ? ( +

{body}

+ ) : ( +

No description provided.

+ )} +
+ + {/* Footer: ticket ID + close action */} +
+

ID: {ticket.id}

+ {isClosed ? ( + This ticket is closed. + ) : ( + onCloseTicket(ticket.id)} /> + )} +
+ +
+ ) +} diff --git a/frontend/src/components/tickets/TicketTable.tsx b/frontend/src/components/tickets/TicketTable.tsx index 7af3659..9b466e1 100644 --- a/frontend/src/components/tickets/TicketTable.tsx +++ b/frontend/src/components/tickets/TicketTable.tsx @@ -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 (
@@ -36,16 +36,20 @@ export function TicketTable({ tickets, onDelete }: TicketTableProps) { {tickets.map((ticket) => ( - + onOpen(ticket)} + > {ticket.subject} {formatDate(ticket.createdAt)} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5db94c1..566d584 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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%); diff --git a/frontend/src/lib/ticket.ts b/frontend/src/lib/ticket.ts new file mode 100644 index 0000000..ea41f2a --- /dev/null +++ b/frontend/src/lib/ticket.ts @@ -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 } +} diff --git a/frontend/src/pages/UserPage.tsx b/frontend/src/pages/UserPage.tsx index 6888620..9670b83 100644 --- a/frontend/src/pages/UserPage.tsx +++ b/frontend/src/pages/UserPage.tsx @@ -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([]) const [serverLimitHit, setServerLimitHit] = useState(false) + const [selectedTicket, setSelectedTicket] = useState(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) => { 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 ( <>
@@ -126,18 +140,28 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
- + + {/* New ticket modal */} {showLimitScreen - ? + ? : } + + {/* Ticket detail modal */} + + {selectedTicket && } + ) }