SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
7.7 KB · 151 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { requireUser } from '@/lib/auth/session';4import { listCollections, portfolioHistory, indexSeries } from '@/lib/account/queries';5import { summarizePortfolio, rebase } from '@/lib/account/portfolio';6import { loadItems } from '@/lib/account/queries';7import { getDisplay } from '@/lib/account/display';8import { PageHeader, btnPrimary, btnSecondary } from '@/components/account/page-header';9import { SummaryStats, AllocationCards } from '@/components/account/portfolio-widgets';10import { LineChart } from '@/components/account/charts';11import { Card, CardHeader, Delta, EmptyState, Badge } from '@/components/ui/primitives';12import { fmtRelative } from '@/lib/format';13import { CreateCollectionForm } from './create-form';1415export const metadata: Metadata = { title: 'Collections', robots: { index: false } };1617export default async function CollectionsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {18  const sp = await searchParams;19  const u = await requireUser('/collections');20  const d = await getDisplay();21  const cols = await listCollections(u.id);22  const allItems = await loadItems(cols.map((c) => c.collection.id));23  const total = summarizePortfolio(allItems);24  const history = await portfolioHistory(u.id);25  const rare = history.length ? await indexSeries('RARE', history[0]!.date) : [];26  const mine = rebase(history.map((h) => ({ date: h.date, value: h.valueUsd })));27  const bench = rebase(rare);2829  return (30    <>31      {sp.welcome ? (32        <div className="mb-5 rounded-md border border-index/30 bg-index-bg px-4 py-3 text-sm">33          <p className="font-medium">Welcome to RareIndex{u.name ? `, ${u.name}` : ''}.</p>34          <p className="mt-0.5 text-muted">Start by creating a collection and adding what you own. Every item is valued with the RareIndex Valuation (RIV) when enough market evidence exists — and marked honestly when it does not.</p>35        </div>36      ) : null}37      <PageHeader38        title="Collections"39        description="Your portfolio across every collection, valued with RIV and your cost basis at acquisition-date exchange rates."40        actions={41          <>42            <Link href="/deals" className={btnSecondary}>43              Deal Radar44            </Link>45            <a href="#new" className={btnPrimary}>46              New collection47            </a>48          </>49        }50      />51      {d.fallback ? <p className="mb-3 text-xs text-alert">No exchange rate loaded for your display currency yet — values are shown in USD.</p> : null}52      <SummaryStats s={total} d={d} className="mb-5" />5354      <div className="mb-5 grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">55        <Card>56          <CardHeader title="Portfolio value" subtitle={history.length ? `${history.length} daily snapshots · vs RARE index (rebased to 1000)` : 'Daily snapshots begin after your first valued item'} action={<Link href="/my-index" className="text-muted hover:text-fg">My Index →</Link>} />57          <div className="p-4">58            <LineChart series={[{ name: 'My portfolio', points: mine }, ...(bench.length > 1 ? [{ name: 'RARE', points: bench, color: 'var(--ri-fg-subtle)', dashed: true }] : [])]} height={200} />59            {history.length ? (60              <div className="mt-2 flex flex-wrap gap-4 text-xs text-muted">61                <span>62                  <span className="mr-1 inline-block h-2 w-3 rounded-sm bg-index align-middle" /> My portfolio63                </span>64                {bench.length > 1 ? (65                  <span>66                    <span className="mr-1 inline-block h-0.5 w-3 border-t border-dashed border-subtle align-middle" /> RARE Global Collectibles Index67                  </span>68                ) : (69                  <span className="text-subtle">RARE index history is not available yet for this period.</span>70                )}71              </div>72            ) : null}73          </div>74        </Card>75        <Card>76          <CardHeader title="At a glance" />77          <dl className="grid grid-cols-2 gap-y-2 p-4 text-xs">78            <dt className="text-subtle">Collections</dt>79            <dd className="num text-right">{cols.length}</dd>80            <dt className="text-subtle">Items · units</dt>81            <dd className="num text-right">82              {total.itemCount} · {total.unitCount}83            </dd>84            <dt className="text-subtle">Categories</dt>85            <dd className="num text-right">{total.allocationByCategory.length}</dd>86            <dt className="text-subtle">Awaiting valuation</dt>87            <dd className="num text-right">{total.unvaluedCount}</dd>88            <dt className="text-subtle">Display currency</dt>89            <dd className="text-right">{d.currency}</dd>90          </dl>91          <div className="border-t border-border p-4 text-xs text-muted">92            RIV = RareIndex Valuation. Each item shows whether its value comes from a graded-variant RIV, the asset-level RIV, or a manual value you entered. Nothing is invented: items without market evidence stay unvalued.93          </div>94        </Card>95      </div>9697      {total.valuedCount > 0 ? <div className="mb-5"><AllocationCards s={total} d={d} /></div> : null}9899      <section className="mb-6">100        <h2 className="mb-3 text-base font-semibold tracking-tight">Your collections</h2>101        {cols.length === 0 ? (102          <Card>103            <EmptyState title="No collections yet" description="Create your first collection below. You can keep separate collections per category, per vault, or a wishlist with a budget." />104          </Card>105        ) : (106          <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">107            {cols.map(({ collection: c, summary: s }) => (108              <Link key={c.id} href={`/collections/${c.id}`} className="card block p-4 transition hover:border-border-strong">109                <div className="flex items-start justify-between gap-2">110                  <div className="min-w-0">111                    <p className="flex items-center gap-2 truncate text-sm font-semibold">112                      {c.color ? <span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: c.color }} /> : null}113                      {c.name}114                    </p>115                    <p className="mt-0.5 text-xs text-muted">116                      {s.itemCount} item{s.itemCount === 1 ? '' : 's'} · updated {fmtRelative(c.updatedAt)}117                    </p>118                  </div>119                  <div className="flex gap-1">120                    {c.kind !== 'collection' ? <Badge tone="neutral">{c.kind}</Badge> : null}121                    {c.isPublic ? <Badge tone="index">public</Badge> : null}122                  </div>123                </div>124                <div className="mt-3 flex items-end justify-between">125                  <div>126                    <p className="num text-lg font-semibold">{s.valuedCount ? d.money(s.valueUsd) : '—'}</p>127                    <p className="text-[11px] text-subtle">{s.valuedCount ? `${s.valuedCount}/${s.itemCount} valued` : 'no valuation yet'}</p>128                  </div>129                  <Delta value={s.returnPct} />130                </div>131                {c.kind === 'wishlist' && c.budgetUsd ? (132                  <p className="mt-2 text-[11px] text-muted">133                    Budget {d.money(c.budgetUsd)} · wishlist value {d.money(s.valueUsd)}134                  </p>135                ) : null}136              </Link>137            ))}138          </div>139        )}140      </section>141142      <div id="new"><Card>143        <CardHeader title="New collection" subtitle="Private by default. You can share it publicly later from its page." />144        <div className="p-4">145          <CreateCollectionForm />146        </div>147      </Card></div>148    </>149  );150}151