TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { requireUser } from '@/lib/auth/session';4import { activeListingsForAssets, loadWatchlist } from '@/lib/account/queries';5import { getDisplay } from '@/lib/account/display';6import { toggleWatchAction } from '@/lib/account/actions';7import { PageHeader, btnDanger } from '@/components/account/page-header';8import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';9import { fmtRelative, fmtPct, confidenceLabel } from '@/lib/format';10import { WatchAddForm, WatchNoteForm } from './forms';1112export const metadata: Metadata = { title: 'Watchlist', robots: { index: false } };1314export default async function WatchlistPage() {15 const u = await requireUser('/watchlist');16 const d = await getDisplay();17 const rows = await loadWatchlist(u.id);18 const assetRows = rows.filter((r) => r.item.targetType === 'asset');19 const otherRows = rows.filter((r) => r.item.targetType !== 'asset');20 const listingAgg = await activeListingsForAssets(assetRows.map((r) => r.item.targetId));21 const lMap = new Map(listingAgg.map((l) => [l.assetId, l]));2223 return (24 <>25 <PageHeader title="Watchlist" description="Assets, categories, sets and sources you follow, with live RareIndex Valuation, change and the cheapest active ask." />26 <Card className="mb-5">27 <CardHeader title={`Assets · ${assetRows.length}`} subtitle="Change since watching uses the RIV at the time you added the asset." />28 {assetRows.length === 0 ? (29 <EmptyState title="No assets watched yet" description="Add an asset below, or use the watch button on any asset page." />30 ) : (31 <Table>32 <thead>33 <tr>34 <th className={th}>Asset</th>35 <th className={`${th} text-right`}>RIV</th>36 <th className={`${th} text-right`}>1d</th>37 <th className={`${th} text-right`}>30d</th>38 <th className={`${th} text-right`}>Since watch</th>39 <th className={`${th} text-right`}>Listings</th>40 <th className={`${th} text-right`}>Min ask</th>41 <th className={`${th} text-right`}>Target</th>42 <th className={th}>Note</th>43 <th className={th}></th>44 </tr>45 </thead>46 <tbody>47 {assetRows.map(({ item, asset, stats }) => {48 const l = lMap.get(item.targetId);49 const since = item.baselineUsd && stats?.rivUsd ? (stats.rivUsd - item.baselineUsd) / item.baselineUsd : null;50 const targetHit = item.targetPriceUsd && stats?.rivUsd ? stats.rivUsd <= item.targetPriceUsd : false;51 return (52 <tr key={item.id} className="hover:bg-sunken">53 <td className={td}>54 {asset ? (55 <div className="max-w-[280px]">56 <Link href={`/asset/${asset.slug}`} className="block truncate font-medium hover:underline">57 {asset.title}58 </Link>59 <p className="text-[11px] text-subtle">60 {asset.categorySlug.replace(/_/g, ' ')} · added {fmtRelative(item.createdAt)}61 </p>62 </div>63 ) : (64 <span className="text-muted">Asset removed</span>65 )}66 </td>67 <td className={tdNum}>{stats?.rivUsd ? <span title={`confidence ${confidenceLabel(stats.rivConfidence)} · ${stats.rivSampleSize} sales`}>{d.money(stats.rivUsd)}</span> : <span className="text-subtle">—</span>}</td>68 <td className={tdNum}>69 <Delta value={stats?.change1d} />70 </td>71 <td className={tdNum}>72 <Delta value={stats?.change30d} />73 </td>74 <td className={tdNum}>75 <Delta value={since} />76 </td>77 <td className={tdNum}>{l ? Number(l.n) : 0}</td>78 <td className={tdNum}>{l?.minAsk ? d.money(Number(l.minAsk)) : <span className="text-subtle">—</span>}</td>79 <td className={tdNum}>80 {item.targetPriceUsd ? (81 <span className={targetHit ? 'text-gain' : ''}>82 {d.money(item.targetPriceUsd)}83 {targetHit ? <Badge tone="gain" className="ml-1">hit</Badge> : null}84 </span>85 ) : (86 <span className="text-subtle">—</span>87 )}88 </td>89 <td className={`${td} max-w-[220px]`}>90 <WatchNoteForm itemId={item.id} note={item.note} targetPriceUsd={item.targetPriceUsd} />91 </td>92 <td className={td}>93 <form action={toggleWatchAction}>94 <input type="hidden" name="targetType" value="asset" />95 <input type="hidden" name="targetId" value={item.targetId} />96 <button className={btnDanger}>Unwatch</button>97 </form>98 </td>99 </tr>100 );101 })}102 </tbody>103 </Table>104 )}105 </Card>106 <div className="grid gap-4 lg:grid-cols-2">107 <Card>108 <CardHeader title={`Markets, sets & sources · ${otherRows.length}`} subtitle="Category watches feed Deal Radar and market-move alerts." />109 {otherRows.length === 0 ? (110 <p className="px-4 py-6 text-xs text-muted">Nothing here yet. Watch a category from its market page, or add one below.</p>111 ) : (112 <ul className="divide-y divide-border">113 {otherRows.map(({ item, category }) => (114 <li key={item.id} className="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">115 <div>116 <Badge tone="neutral" className="mr-2">117 {item.targetType}118 </Badge>119 {item.targetType === 'category' ? (120 <Link href={`/markets/${item.targetId}`} className="hover:underline">121 {category?.name ?? item.targetId}122 </Link>123 ) : (124 <span>{item.label ?? item.targetId}</span>125 )}126 {item.targetType === 'category' && category ? <span className="ml-2 text-xs text-subtle">{fmtPct(null)}</span> : null}127 </div>128 <form action={toggleWatchAction}>129 <input type="hidden" name="targetType" value={item.targetType} />130 <input type="hidden" name="targetId" value={item.targetId} />131 <button className={btnDanger}>Unwatch</button>132 </form>133 </li>134 ))}135 </ul>136 )}137 </Card>138 <Card>139 <CardHeader title="Add to watchlist" />140 <div className="p-4">141 <WatchAddForm />142 </div>143 </Card>144 </div>145 </>146 );147}148