95 lines
3.9 KiB
TypeScript
95 lines
3.9 KiB
TypeScript
'use client'
|
|
import { useState } from 'react'
|
|
import { motion } from 'framer-motion'
|
|
import ListingRow from './ListingRow'
|
|
import ListingDetailPanel from './ListingDetailPanel'
|
|
import { useListings, useDeleteAllListings } from '@/hooks/useListings'
|
|
import { getExportUrl } from '@/lib/api/listings'
|
|
import type { Listing } from '@/lib/types'
|
|
|
|
export default function ListingsTable() {
|
|
const { data: listings, isLoading, isError } = useListings()
|
|
const deleteAll = useDeleteAllListings()
|
|
const [selected, setSelected] = useState<Listing | null>(null)
|
|
const [search, setSearch] = useState('')
|
|
|
|
if (isLoading) return <SkeletonTable />
|
|
if (isError) return <ErrorBanner />
|
|
|
|
const filtered = search
|
|
? (listings ?? []).filter((l) =>
|
|
l.title.toLowerCase().includes(search.toLowerCase()) ||
|
|
l.keyword.toLowerCase().includes(search.toLowerCase())
|
|
)
|
|
: (listings ?? [])
|
|
|
|
return (
|
|
<>
|
|
<motion.div
|
|
initial={{ opacity: 1, y: -12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4 }}
|
|
className="g-card"
|
|
>
|
|
{/* Toolbar */}
|
|
<div className="flex gap-3 items-center flex-wrap px-5 py-4 border-b border-g-border/40">
|
|
<div className="relative">
|
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-g-faint/40 text-xs">⌕</span>
|
|
<input
|
|
value={search} onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Search listings…"
|
|
className="g-input pl-7 w-56 h-9 text-sm"
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-g-faint tabular-nums">{filtered.length} <span className="text-g-faint/50">lots</span></span>
|
|
<div className="ml-auto flex gap-2">
|
|
<button onClick={() => window.open(getExportUrl('csv'))} className="g-btn text-xs">Export CSV</button>
|
|
<button onClick={() => window.open(getExportUrl('json'))} className="g-btn text-xs">Export JSON</button>
|
|
<button onClick={() => { if (confirm('Clear all?')) deleteAll.mutate() }} className="g-btn-danger text-xs">Clear all</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="g-table">
|
|
<thead><tr>
|
|
<th className="w-16"></th><th>Title</th><th>Price</th>
|
|
<th>Time left</th><th className="text-center">Score</th>
|
|
<th>Keyword</th><th className="text-center">AI</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{filtered.map((l) => <ListingRow key={l.id} listing={l} onSelect={setSelected} />)}
|
|
</tbody>
|
|
</table>
|
|
{!filtered.length && (
|
|
<div className="flex flex-col items-center justify-center py-20 text-g-faint gap-3">
|
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-g-raised to-g-panel flex items-center justify-center border border-g-border/30">
|
|
<span className="text-2xl opacity-30">≡</span>
|
|
</div>
|
|
<p className="text-sm font-medium">No listings captured yet</p>
|
|
<p className="text-xs text-g-faint/50">Start the engine and add target sites</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
<ListingDetailPanel listing={selected} onClose={() => setSelected(null)} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
const SkeletonTable = () => (
|
|
<div className="g-card p-5 space-y-3">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} style={{ opacity: 1 - i * 0.15, animationDelay: `${i * 0.1}s` }}
|
|
className="h-14 bg-gradient-to-r from-g-raised/60 to-g-panel/30 rounded-xl animate-pulse" />
|
|
))}
|
|
</div>
|
|
)
|
|
|
|
const ErrorBanner = () => (
|
|
<div className="g-card border-g-red/20 p-5 text-g-red text-sm flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-g-red animate-pulse" />
|
|
Engine offline — cannot reach server
|
|
</div>
|
|
)
|