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%
6.3 KB · 147 lines tsx
Raw Blame History
1'use client';23import { useEffect, useRef, useState } from 'react';4import { Search, X } from 'lucide-react';5import { inputClass } from './form';6import { fmtMoney } from '@/lib/format';78export interface PickedAsset {9  id: string;10  title: string;11  categorySlug: string;12  year: number | null;13  heroImageUrl: string | null;14  rivUsd: number | null;15}16interface Variant {17  id: string;18  label: string;19  rivUsd: number | null;20}2122/**23 * Typeahead over RareIndex assets (server search) + variant picker. Emits hidden inputs24 * `assetId` and `variantId` for the surrounding form.25 */26export function AssetPicker({ name = 'assetId', initial, error, withVariant = true, label = 'Asset' }: { name?: string; initial?: PickedAsset | null; error?: string | null; withVariant?: boolean; label?: string }) {27  const [q, setQ] = useState('');28  const [hits, setHits] = useState<PickedAsset[]>([]);29  const [open, setOpen] = useState(false);30  const [picked, setPicked] = useState<PickedAsset | null>(initial ?? null);31  const [variants, setVariants] = useState<Variant[]>([]);32  const [loading, setLoading] = useState(false);33  const box = useRef<HTMLDivElement>(null);3435  useEffect(() => {36    if (q.trim().length < 2) return;37    const ctrl = new AbortController();38    const t = setTimeout(async () => {39      setLoading(true);40      try {41        const res = await fetch(`/api/account/assets/search?q=${encodeURIComponent(q.trim())}`, { signal: ctrl.signal });42        if (res.ok) setHits((await res.json()) as PickedAsset[]);43      } catch {44        /* aborted */45      } finally {46        setLoading(false);47      }48    }, 180);49    return () => {50      clearTimeout(t);51      ctrl.abort();52    };53  }, [q]);5455  useEffect(() => {56    if (!picked || !withVariant) return;57    fetch(`/api/account/assets/${picked.id}/variants`)58      .then((r) => (r.ok ? r.json() : []))59      .then((v: Variant[]) => setVariants(v))60      .catch(() => setVariants([]));61  }, [picked, withVariant]);6263  useEffect(() => {64    const onDoc = (e: MouseEvent) => {65      if (box.current && !box.current.contains(e.target as Node)) setOpen(false);66    };67    document.addEventListener('mousedown', onDoc);68    return () => document.removeEventListener('mousedown', onDoc);69  }, []);7071  return (72    <div className="space-y-1.5" ref={box}>73      <label className="block text-xs font-medium text-muted">{label}</label>74      <input type="hidden" name={name} value={picked?.id ?? ''} />75      {picked ? (76        <div className="flex items-center gap-3 rounded-md border border-border bg-sunken px-3 py-2">77          {picked.heroImageUrl ? (78            // eslint-disable-next-line @next/next/no-img-element79            <img src={picked.heroImageUrl} alt="" className="h-10 w-10 rounded-sm object-cover" />80          ) : (81            <span className="h-10 w-10 rounded-sm bg-inset" />82          )}83          <div className="min-w-0 flex-1">84            <p className="truncate text-sm font-medium">{picked.title}</p>85            <p className="text-xs text-muted">86              {picked.categorySlug.replace(/_/g, ' ')}87              {picked.year ? ` · ${picked.year}` : ''}88              {picked.rivUsd ? ` · RIV ${fmtMoney(picked.rivUsd)}` : ' · no valuation yet'}89            </p>90          </div>91          <button type="button" aria-label="Change asset" className="text-subtle hover:text-fg" onClick={() => setPicked(null)}>92            <X className="h-4 w-4" />93          </button>94        </div>95      ) : (96        <div className="relative">97          <Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-subtle" />98          <input value={q} onChange={(e) => { setQ(e.target.value); setOpen(true); if (e.target.value.trim().length < 2) setHits([]); }} onFocus={() => setOpen(true)} placeholder="Search RareIndex assets… e.g. 1999 Charizard, Rolex 116500LN, LEGO 75192" className={`${inputClass} pl-8`} autoComplete="off" aria-autocomplete="list" />99          {open && (hits.length > 0 || loading || q.trim().length >= 2) ? (100            <ul className="absolute z-20 mt-1 max-h-72 w-full overflow-auto rounded-md border border-border bg-elevated shadow-pop" role="listbox">101              {hits.map((h) => (102                <li key={h.id}>103                  <button type="button" className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-inset" onClick={() => { setPicked(h); setOpen(false); setQ(''); }}>104                    {h.heroImageUrl ? (105                      // eslint-disable-next-line @next/next/no-img-element106                      <img src={h.heroImageUrl} alt="" className="h-9 w-9 rounded-sm object-cover" />107                    ) : (108                      <span className="h-9 w-9 rounded-sm bg-inset" />109                    )}110                    <span className="min-w-0 flex-1">111                      <span className="block truncate text-sm">{h.title}</span>112                      <span className="block text-xs text-muted">113                        {h.categorySlug.replace(/_/g, ' ')}114                        {h.year ? ` · ${h.year}` : ''}115                      </span>116                    </span>117                    <span className="num text-xs text-muted">{h.rivUsd ? fmtMoney(h.rivUsd) : '—'}</span>118                  </button>119                </li>120              ))}121              {!loading && hits.length === 0 && q.trim().length >= 2 ? <li className="px-3 py-3 text-xs text-muted">No asset matches yet. RareIndex adds assets as connectors ingest them; try a different spelling or set name.</li> : null}122              {loading ? <li className="px-3 py-2 text-xs text-subtle">Searching…</li> : null}123            </ul>124          ) : null}125        </div>126      )}127      {error ? <p className="text-xs text-loss">{error}</p> : null}128      {withVariant && picked ? (129        <div className="pt-1">130          <label className="block text-xs font-medium text-muted" htmlFor="variantId">131            Variant / grade132          </label>133          <select name="variantId" id="variantId" className={`${inputClass} mt-1.5`} defaultValue="">134            <option value="">Not specified — use grader/grade fields below</option>135            {variants.map((v) => (136              <option key={v.id} value={v.id}>137                {v.label}138                {v.rivUsd ? ` · RIV ${fmtMoney(v.rivUsd)}` : ''}139              </option>140            ))}141          </select>142        </div>143      ) : null}144    </div>145  );146}147