add:infobar
This commit is contained in:
@@ -9,6 +9,7 @@ import { useAuth } from './hooks/useAuth.ts'
|
||||
import { AuthBar } from './components/ui/AuthBar.tsx'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { NotFound } from './pages/NotFound.tsx'
|
||||
import { InfoBar } from './components/ui/InfoBar.tsx'
|
||||
|
||||
type TabValue = 'tickets' | 'admin' | 'stats'
|
||||
|
||||
@@ -50,7 +51,10 @@ function SupportApp() {
|
||||
return (
|
||||
<Layout
|
||||
subHeader={
|
||||
<AuthBar user={user} onLogin={() => setShowLogin(true)} onLogout={logout} />
|
||||
<>
|
||||
<AuthBar user={user} onLogin={() => setShowLogin(true)} onLogout={logout} />
|
||||
<InfoBar user={user} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
@@ -115,98 +115,100 @@ export function AdminTable({
|
||||
const hasBilling = tickets.some(t => t.type === 'billing')
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border-100">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-100 bg-bg-200">
|
||||
{/* Select-all checkbox */}
|
||||
<th className="w-10 px-4 py-3">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
disabled={selectableIds.length === 0}
|
||||
onChange={handleHeaderChange}
|
||||
ariaLabel="Select all owned tickets on this page"
|
||||
/>
|
||||
</th>
|
||||
{(['Subject', 'User', 'Type', 'Status', ...(hasBilling ? ['Transaction'] : []), 'Description', 'Created'] as const).map(col => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-fg-300"
|
||||
>
|
||||
{col}
|
||||
<div className="rounded-lg border border-border-100 overflow-hidden">
|
||||
<div className="overflow-x-auto overflow-y-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-100 bg-bg-200">
|
||||
{/* Select-all checkbox */}
|
||||
<th className="w-10 px-4 py-3">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
disabled={selectableIds.length === 0}
|
||||
onChange={handleHeaderChange}
|
||||
ariaLabel="Select all owned tickets on this page"
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-100 bg-bg-100">
|
||||
{tickets.map(ticket => {
|
||||
const { txnId, txnLine, body: displayDescription } = parseDescription(ticket.description)
|
||||
const hasTxn = ticket.type === 'billing' && txnId !== null
|
||||
const owned = isOwned(ticket)
|
||||
const isSelected = selection.has(ticket.id)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={ticket.id}
|
||||
className={`transition-colors cursor-pointer ${isSelected ? 'bg-bg-200' : 'hover:bg-bg-200'}`}
|
||||
onClick={() => onOpen(ticket)}
|
||||
>
|
||||
{/* Row checkbox — stop propagation so clicking it doesn't open the modal */}
|
||||
<td
|
||||
className="w-10 px-4 py-3"
|
||||
onClick={e => e.stopPropagation()}
|
||||
{(['Subject', 'User', 'Type', 'Status', ...(hasBilling ? ['Transaction'] : []), 'Description', 'Created'] as const).map(col => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-fg-300"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={!owned}
|
||||
onChange={checked => handleRowChange(ticket.id, checked)}
|
||||
ariaLabel={`Select ticket: ${ticket.subject}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-fg-100">
|
||||
<div className="flex items-center gap-2">
|
||||
{ticket.subject}
|
||||
{owned && (
|
||||
<span className="inline-flex items-center rounded-full border border-border-100 bg-bg-300 px-1.5 py-0.5 text-[10px] font-medium text-fg-300">
|
||||
mine
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-fg-200 whitespace-nowrap">
|
||||
{ticket.username ?? <span className="italic text-fg-300">guest</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs capitalize text-fg-200">
|
||||
{ticket.type.replace('-', ' ')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge status={ticket.status} />
|
||||
</td>
|
||||
{hasBilling && (
|
||||
<td className="px-4 py-3 text-xs text-fg-200 whitespace-nowrap">
|
||||
{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">
|
||||
{txnLine!.split(' — ')[0]}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-fg-300 italic">—</span>
|
||||
)}
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-100 bg-bg-100">
|
||||
{tickets.map(ticket => {
|
||||
const { txnId, txnLine, body: displayDescription } = parseDescription(ticket.description)
|
||||
const hasTxn = ticket.type === 'billing' && txnId !== null
|
||||
const owned = isOwned(ticket)
|
||||
const isSelected = selection.has(ticket.id)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={ticket.id}
|
||||
className={`transition-colors cursor-pointer ${isSelected ? 'bg-bg-200' : 'hover:bg-bg-200'}`}
|
||||
onClick={() => onOpen(ticket)}
|
||||
>
|
||||
{/* Row checkbox — stop propagation so clicking it doesn't open the modal */}
|
||||
<td
|
||||
className="w-10 px-4 py-3"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={!owned}
|
||||
onChange={checked => handleRowChange(ticket.id, checked)}
|
||||
ariaLabel={`Select ticket: ${ticket.subject}`}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="max-w-xs px-4 py-3 text-xs text-fg-300">
|
||||
<span className="line-clamp-2">
|
||||
{displayDescription || <span className="italic">No description</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-xs text-fg-300">
|
||||
{formatDate(ticket.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<td className="px-4 py-3 font-medium text-fg-100">
|
||||
<div className="flex items-center gap-2">
|
||||
{ticket.subject}
|
||||
{owned && (
|
||||
<span className="inline-flex items-center rounded-full border border-border-100 bg-bg-300 px-1.5 py-0.5 text-[10px] font-medium text-fg-300">
|
||||
mine
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-fg-200 whitespace-nowrap">
|
||||
{ticket.username ?? <span className="italic text-fg-300">guest</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs capitalize text-fg-200">
|
||||
{ticket.type.replace('-', ' ')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge status={ticket.status} />
|
||||
</td>
|
||||
{hasBilling && (
|
||||
<td className="px-4 py-3 text-xs text-fg-200 whitespace-nowrap">
|
||||
{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">
|
||||
{txnLine!.split(' — ')[0]}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-fg-300 italic">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="max-w-xs px-4 py-3 text-xs text-fg-300">
|
||||
<span className="line-clamp-2">
|
||||
{displayDescription || <span className="italic">No description</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-xs text-fg-300">
|
||||
{formatDate(ticket.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination footer */}
|
||||
<div className="flex items-center justify-between border-t border-border-100 bg-bg-200 px-4 py-3">
|
||||
|
||||
7
frontend/src/components/icons/chevronIcon.tsx
Normal file
7
frontend/src/components/icons/chevronIcon.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { IconProps } from "../../lib/types.ts";
|
||||
|
||||
export const ChevronIcon = ({ className }: IconProps) => (
|
||||
<svg className={className} viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
@@ -23,7 +23,7 @@ export function TicketTable({ tickets, onOpen }: TicketTableProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border-100">
|
||||
<div className="overflow-x-scroll overflow-y-hidden rounded-lg border border-border-100">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-100 bg-bg-200">
|
||||
|
||||
@@ -14,7 +14,7 @@ interface BadgeProps {
|
||||
export function Badge({ status }: BadgeProps) {
|
||||
return (
|
||||
<span className={`
|
||||
inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium capitalize
|
||||
inline-flex whitespace-nowrap items-center rounded-full border px-2 py-0.5 text-xs font-medium capitalize
|
||||
${variants[status]}
|
||||
`}>
|
||||
{status}
|
||||
|
||||
96
frontend/src/components/ui/InfoBar.tsx
Normal file
96
frontend/src/components/ui/InfoBar.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react"
|
||||
import type { User } from "../../lib/types"
|
||||
import { InfoIcon } from "../icons/info"
|
||||
import { CloseIcon } from "../icons/close"
|
||||
const STORAGE_KEY = "info_bar_dismissed"
|
||||
interface InfoBarProps {
|
||||
user: User | null
|
||||
}
|
||||
export function InfoBar({ user }: InfoBarProps) {
|
||||
const [open, setOpen] = useState(
|
||||
() => localStorage.getItem(STORAGE_KEY) !== "true"
|
||||
)
|
||||
function toggle() {
|
||||
const next = !open
|
||||
setOpen(next)
|
||||
if (!next) {
|
||||
localStorage.setItem(STORAGE_KEY, "true")
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
const isGuest = user === null
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border border-border-100 bg-bg-200 overflow-hidden">
|
||||
{/* Header row — always visible */}
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between px-6 py-2.5" onClick={toggle}>
|
||||
<div className="flex items-center gap-2">
|
||||
<InfoIcon className="shrink-0 size-4 text-fg-300" />
|
||||
<span className="text-xs font-medium text-fg-200">
|
||||
{isGuest ? "You're in guest mode" : "How this app works"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="flex items-center gap-1.5 text-xs text-fg-300 transition-colors hover:text-fg-100 cursor-pointer"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? (
|
||||
<>
|
||||
<CloseIcon className="size-3" />
|
||||
<span>Hide</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Show info</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Collapsible body */}
|
||||
<div
|
||||
className={`transition-[max-height,opacity] duration-200 ease-in-out overflow-hidden ${open ? "max-h-64 opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
<div className="border-t border-border-100 py-3">
|
||||
<div className="max-w-4xl px-6 mx-auto">
|
||||
{isGuest ? (
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 px-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-fg-200">Guest mode</p>
|
||||
<p className="text-xs text-fg-300">
|
||||
Tickets are stored in your browser (localStorage) only.
|
||||
No sync across devices or sessions.
|
||||
Admin panel is not available.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-fg-200">After signing in</p>
|
||||
<p className="text-xs text-fg-300">
|
||||
Tickets are saved to the server persistently.
|
||||
Admin panel: view all users' tickets.
|
||||
You can only edit or manage your own tickets.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 px-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-fg-200">Admin Tab</p>
|
||||
<p className="text-xs text-fg-300">
|
||||
You can view all users' tickets in the Admin tab. Useful for monitoring support requests
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-fg-200 pr-1">Editing</p>
|
||||
<p className="text-xs text-fg-300">
|
||||
You can only edit or manage your own tickets. Other users' tickets are read-only for you
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,11 +7,12 @@ import type { PaginatedResponse, TicketFilters } from '../lib/storage.ts'
|
||||
import { useModal } from '../hooks/useModal.ts'
|
||||
import type { Ticket, User } from '../lib/types.ts'
|
||||
import { Button } from '../components/ui/Button.tsx'
|
||||
import { ChevronIcon } from '../components/icons/chevronIcon.tsx'
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border-100 bg-bg-200 px-4 py-3">
|
||||
<p className="text-xs text-fg-300">{label}</p>
|
||||
<div className="rounded-lg border border-border-100 bg-bg-200 sm:px-4 px-1.5 sm:py-3 py-1.5 overflow-hidden">
|
||||
<p className="text-xs text-fg-300 truncate">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-fg-100">{value}</p>
|
||||
</div>
|
||||
)
|
||||
@@ -61,7 +62,7 @@ function FilterBar({ filters, isAuthenticated, onChange }: FilterBarProps) {
|
||||
>
|
||||
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<ChevronIcon />
|
||||
<ChevronIcon className="size-4 pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-fg-300" />
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
@@ -73,7 +74,7 @@ function FilterBar({ filters, isAuthenticated, onChange }: FilterBarProps) {
|
||||
>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<ChevronIcon />
|
||||
<ChevronIcon className="size-4 pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-fg-300" />
|
||||
</div>
|
||||
|
||||
{/* Mine toggle — only visible when authenticated */}
|
||||
@@ -112,17 +113,6 @@ function FilterBar({ filters, isAuthenticated, onChange }: FilterBarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-fg-300"
|
||||
width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"
|
||||
>
|
||||
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
interface AdminPageProps {
|
||||
isAuthenticated: boolean
|
||||
|
||||
Reference in New Issue
Block a user