277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
'use client'
|
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { cn, formatPrice, formatMins } from '@/lib/utils'
|
|
import type { Listing } from '@/lib/types'
|
|
import { fetchListings } from '@/lib/api/listings'
|
|
|
|
const REFRESH_MS = 10_000
|
|
|
|
function UrgencyBadge({ mins }: { mins: number | null }) {
|
|
if (mins === null) return <span className="text-[10px] text-g-faint font-mono">—</span>
|
|
|
|
const isUrgent = mins <= 60
|
|
const isWarning = mins > 60 && mins <= 180
|
|
|
|
if (isUrgent) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-g-red tabular-nums">
|
|
<span
|
|
className="w-1.5 h-1.5 rounded-full bg-g-red shrink-0"
|
|
style={{ animation: 'pulse-ring 1.5s ease-out infinite', boxShadow: '0 0 6px #f43f5e' }}
|
|
/>
|
|
{formatMins(mins)}
|
|
</span>
|
|
)
|
|
}
|
|
if (isWarning) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-g-amber tabular-nums">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-g-amber/60 shrink-0" />
|
|
{formatMins(mins)}
|
|
</span>
|
|
)
|
|
}
|
|
return (
|
|
<span className="text-[10px] text-g-faint/70 font-mono tabular-nums">
|
|
{formatMins(mins)}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function AiBadge({ match }: { match: 1 | 0 | null }) {
|
|
if (match === 1)
|
|
return <span className="g-badge g-badge-green text-[9px] tracking-wide">✓ AI</span>
|
|
if (match === 0)
|
|
return <span className="g-badge g-badge-red text-[9px] tracking-wide">✗ AI</span>
|
|
return null
|
|
}
|
|
|
|
function ScorePill({ score }: { score: number }) {
|
|
const color =
|
|
score >= 50
|
|
? 'text-g-green'
|
|
: score >= 20
|
|
? 'text-g-amber'
|
|
: score >= 0
|
|
? 'text-g-muted'
|
|
: 'text-g-red'
|
|
return (
|
|
<span className={cn('text-[11px] font-bold tabular-nums font-mono', color)}>
|
|
{score > 0 ? `+${score}` : score}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function Thumbnail({ src }: { src: string | undefined }) {
|
|
const [err, setErr] = useState(false)
|
|
|
|
if (!src || err) {
|
|
return (
|
|
<div className="w-9 h-9 rounded-lg bg-g-raised border border-g-border/40 flex items-center justify-center shrink-0">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#3d4f78" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
|
|
</svg>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
onError={() => setErr(true)}
|
|
className="w-9 h-9 rounded-lg object-cover shrink-0 border border-g-border/30 bg-g-raised"
|
|
loading="lazy"
|
|
/>
|
|
)
|
|
}
|
|
|
|
export default function RecentListings() {
|
|
const [listings, setListings] = useState<Listing[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null)
|
|
const alive = useRef(true)
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const data = await fetchListings(10)
|
|
if (alive.current) {
|
|
setListings(data)
|
|
setLastRefresh(new Date())
|
|
setLoading(false)
|
|
}
|
|
} catch {
|
|
if (alive.current) setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
alive.current = true
|
|
refresh()
|
|
const id = setInterval(refresh, REFRESH_MS)
|
|
return () => {
|
|
alive.current = false
|
|
clearInterval(id)
|
|
}
|
|
}, [refresh])
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 1, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.1, duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
|
className="g-card flex flex-col overflow-hidden"
|
|
>
|
|
{/* ── Header ── */}
|
|
<div className="flex items-center gap-3 px-5 py-3.5 border-b border-g-border/40 shrink-0">
|
|
<div className="flex items-center gap-2.5">
|
|
<span className="g-pulse-dot" />
|
|
<span className="text-sm font-bold text-g-text tracking-tight">Recent Captures</span>
|
|
</div>
|
|
<span className="text-[10px] text-g-faint tabular-nums font-mono">
|
|
{listings.length} lots
|
|
</span>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{lastRefresh && (
|
|
<span className="text-[10px] text-g-faint/50 font-mono hidden sm:block">
|
|
{lastRefresh.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={refresh}
|
|
className="g-btn h-7 px-2.5 text-xs gap-1.5"
|
|
title="Refresh now"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.51"/>
|
|
</svg>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Body ── */}
|
|
<div className="flex-1 overflow-y-auto min-h-0" style={{ maxHeight: 440 }}>
|
|
{loading ? (
|
|
<div className="space-y-0">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="flex items-center gap-3 px-5 py-3 border-b border-g-border/20 last:border-0">
|
|
<div className="w-9 h-9 rounded-lg bg-g-raised shrink-0 shimmer" />
|
|
<div className="flex-1 space-y-1.5">
|
|
<div className="h-3 bg-g-raised rounded shimmer w-3/4" />
|
|
<div className="h-2.5 bg-g-raised rounded shimmer w-1/3" />
|
|
</div>
|
|
<div className="h-3 w-12 bg-g-raised rounded shimmer" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : listings.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
|
<div className="w-14 h-14 rounded-2xl bg-g-raised border border-g-border/40 flex items-center justify-center">
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#3d4f78" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
|
</svg>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm text-g-muted font-medium">No lots captured yet</p>
|
|
<p className="text-xs text-g-faint mt-1">Start the engine to begin scanning</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<AnimatePresence mode="popLayout" initial={false}>
|
|
{listings.map((lot, i) => (
|
|
<motion.a
|
|
key={lot.id}
|
|
href={lot.link}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
initial={{ opacity: 1, x: -8 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 8, height: 0 }}
|
|
transition={{ delay: i * 0.03, duration: 0.3 }}
|
|
className="group flex items-center gap-3 px-5 py-3 border-b border-g-border/20 last:border-0 hover:bg-g-raised/50 transition-colors duration-150 cursor-pointer"
|
|
>
|
|
{/* Thumbnail */}
|
|
<Thumbnail src={lot.images?.[0]} />
|
|
|
|
{/* Title + meta */}
|
|
<div className="flex-1 min-w-0 space-y-0.5">
|
|
<p
|
|
className="text-[12px] font-medium text-g-text group-hover:text-g-green transition-colors leading-snug line-clamp-1"
|
|
title={lot.title}
|
|
>
|
|
{lot.title}
|
|
</p>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-[10px] text-g-faint bg-g-raised px-1.5 py-0.5 rounded font-medium border border-g-border/30">
|
|
{lot.site_name}
|
|
</span>
|
|
<span className="text-[10px] text-g-faint/60 font-mono">
|
|
#{lot.keyword}
|
|
</span>
|
|
<AiBadge match={lot.ai_match} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Price */}
|
|
<div className="text-right shrink-0 min-w-[64px]">
|
|
<p className="text-[13px] font-bold text-g-text tabular-nums leading-none">
|
|
{formatPrice(lot.price, lot.currency, lot.price_usd)}
|
|
</p>
|
|
{lot.price_usd && lot.currency !== 'USD' && (
|
|
<p className="text-[10px] text-g-faint/50 tabular-nums mt-0.5">
|
|
${lot.price_usd.toFixed(0)} USD
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Time left */}
|
|
<div className="shrink-0 w-[52px] text-right">
|
|
<UrgencyBadge mins={lot.time_left_mins} />
|
|
</div>
|
|
|
|
{/* Score */}
|
|
<div className="shrink-0 w-[36px] text-right">
|
|
<ScorePill score={lot.score} />
|
|
</div>
|
|
|
|
{/* Open arrow — visible on hover */}
|
|
<div className="shrink-0 w-4 flex items-center justify-end">
|
|
<svg
|
|
width="11" height="11"
|
|
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
|
className="text-g-faint/30 group-hover:text-g-green transition-colors"
|
|
>
|
|
<polyline points="9 18 15 12 9 6"/>
|
|
</svg>
|
|
</div>
|
|
</motion.a>
|
|
))}
|
|
</AnimatePresence>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Footer ── */}
|
|
{listings.length > 0 && (
|
|
<div className="px-5 py-2.5 border-t border-g-border/30 flex items-center justify-between shrink-0 bg-g-base/30">
|
|
<span className="text-[10px] text-g-faint/50">
|
|
Refreshes every {REFRESH_MS / 1000}s
|
|
</span>
|
|
<a
|
|
href="/listings"
|
|
className="text-[10px] text-g-muted hover:text-g-green transition-colors font-semibold flex items-center gap-1"
|
|
>
|
|
View all listings
|
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="9 18 15 12 9 6"/>
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
)
|
|
}
|