@@ -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 && }
+
>
)
}
|