39246-vm/frontend/components/dashboard/RecentListings.tsx
abbashkyt-creator 7d8ce0e322 V0.1
2026-03-14 04:02:22 +03:00

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>
)
}