feat(marketplace): 1-10 deck ratings + seller dashboard

- Reviews: confirming a trade now lets the reviewer set a 1-10 skateboard-deck
  rating, stored as ["rating","n"] on the kind-31871 attestation (NIP.md updated).
  The Reputation tab shows each review's decks plus an average.
- New DeckRating component (self-drawn deck glyph) and TradeConfirmRow (rating
  picker + confirm), used for both seller-confirm and buyer-confirm in My Trades.
- New 'Selling' tab: your marketplace listings (active/sold) with one-tap
  Mark sold.
This commit is contained in:
SkateSpots 2026-09-27 16:26:24 +07:00
parent 14b3ee0bf2
commit efa8b2e521
8 changed files with 330 additions and 54 deletions

6
NIP.md
View file

@ -3165,6 +3165,7 @@ on the same listing form a **verified trade**.
["p", "<buyer_pubkey>"], ["p", "<buyer_pubkey>"],
["method", "cash|lightning|trade|other"], ["method", "cash|lightning|trade|other"],
["amount", "45000", "SATS"], ["amount", "45000", "SATS"],
["rating", "9"],
["alt", "SkateSpots: sale completed"] ["alt", "SkateSpots: sale completed"]
], ],
"content": "Sold to @buyer. Paid in cash." "content": "Sold to @buyer. Paid in cash."
@ -3185,9 +3186,10 @@ The base NIP has no counterparty field for this use case, so we add:
| `p` | The **other party** in the trade (required by us, marks a trade attestation) | | `p` | The **other party** in the trade (required by us, marks a trade attestation) |
| `method` | Self-reported payment method: `cash`, `lightning`, `trade`, `other` | | `method` | Self-reported payment method: `cash`, `lightning`, `trade`, `other` |
| `amount` | Optional self-reported amount, `["amount","45000","SATS"]` | | `amount` | Optional self-reported amount, `["amount","45000","SATS"]` |
| `rating` | Optional trade review, `1`–`10` (skateboard decks; 1 = bad, 10 = best). It's the reviewer's opinion, shown on both profiles; it is not proof or a score computed by the app. |
`method`/`amount` are **self-reported metadata, never proof of payment** — the app `method`/`amount`/`rating` are **self-reported**, never proof of payment or a
cannot observe off-app (cash/trade) settlement. verified score — the app cannot observe off-app (cash/trade) settlement.
## States & revocation ## States & revocation

View file

@ -0,0 +1,63 @@
import { cn } from '@/lib/utils';
/** A little skateboard-deck glyph for the rating. */
function Deck({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 10" className={className} fill="currentColor" aria-hidden>
<rect x="1" y="2.2" width="22" height="3.6" rx="1.8" />
<circle cx="7" cy="7.6" r="1.4" />
<circle cx="17" cy="7.6" r="1.4" />
</svg>
);
}
interface DeckRatingProps {
/** Current rating, 0 = none, 1–10 = decks. */
value: number;
/** Omit for a read-only display. */
onChange?: (value: number) => void;
/** `sm` for inline rows, `md` for dialogs. */
size?: 'sm' | 'md';
/** When true, only render the filled decks (for display). */
compact?: boolean;
className?: string;
}
/**
* A 1–10 skateboard-deck rating. 1 deck = bad, 10 decks = best-in-class.
* Interactive by default; read-only when no `onChange` is given.
*/
export function DeckRating({ value, onChange, size = 'md', compact = false, className }: DeckRatingProps) {
const readOnly = !onChange;
const count = readOnly && compact ? Math.max(value, 0) : 10;
const deckCls = size === 'sm' ? 'h-3 w-6' : 'h-3.5 w-7';
return (
<span className={cn('inline-flex items-center gap-0.5', className)} title={`${value}/10 decks`}>
{Array.from({ length: count }).map((_, i) => {
const n = i + 1;
const filled = n <= value;
if (readOnly) {
return <Deck key={n} className={cn(deckCls, 'text-[#ccff00]')} />;
}
return (
<button
key={n}
type="button"
onClick={() => onChange?.(n === value ? 0 : n)}
aria-label={`${n} of 10`}
aria-pressed={filled}
className={cn(deckCls, 'transition-transform hover:scale-110 active:scale-95', filled ? 'text-[#ccff00]' : 'text-gray-600')}
>
<Deck className="h-full w-full" />
</button>
);
})}
{(value > 0 || !readOnly) && (
<span className="ml-1 text-[11px] font-bold text-gray-400 tabular-nums">
{value > 0 ? `${value}/10` : ''}
</span>
)}
</span>
);
}

View file

@ -0,0 +1,129 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Package, Plus, ExternalLink } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
import { useMarketplaceItems } from '@/hooks/useMarketplace';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { MarketplaceItem } from '@/lib/types';
function MyListingRow({ item }: { item: MarketplaceItem }) {
const [openSold, setOpenSold] = useState(false);
const listing = { pubkey: item.pubkey, identifier: item.identifier };
const soldOut = item.status === 'sold';
const price = item.price
? `${Number(item.price.amount).toLocaleString()} ${item.price.currency}`
: item.priceType === 'trade'
? 'Trade'
: 'Free';
return (
<>
<Card className="border-white/8 bg-white/[0.03]">
<CardContent className="flex items-center gap-3 p-3">
{item.images[0] ? (
<img src={item.images[0]} alt="" className="h-12 w-12 shrink-0 rounded-lg object-cover" />
) : (
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-white/10">
<Package className="h-5 w-5 text-gray-500" />
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-white">{item.title}</p>
<p className="truncate text-xs text-gray-400">
{price}
{item.location ? ` · ${item.location}` : ''}
</p>
</div>
<Badge
variant="outline"
className={soldOut ? 'border-red-500/40 text-red-400' : 'border-emerald-500/40 text-emerald-400'}
>
{soldOut ? 'Sold' : 'Active'}
</Badge>
<Button asChild size="sm" variant="outline" className="gap-1 border-white/15" title="View listing">
<Link to={`/listing/${item.pubkey}/${item.identifier}`}>
<ExternalLink className="h-3.5 w-3.5" />
</Link>
</Button>
{!soldOut && (
<Button
size="sm"
onClick={() => setOpenSold(true)}
className="shrink-0 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
Mark sold
</Button>
)}
</CardContent>
</Card>
<MarkSoldDialog open={openSold} onOpenChange={setOpenSold} event={item.event} listing={listing} />
</>
);
}
/**
* Marketplace → Selling tab: the seller's own listings, with a one-tap
* "mark sold" (which also records the trade for reputation).
*/
export function SellerDashboard() {
const { user } = useCurrentUser();
const { data: items, isLoading } = useMarketplaceItems(200);
const mine = (items ?? []).filter((i) => i.pubkey === user?.pubkey);
const active = mine.filter((i) => i.status !== 'sold');
const sold = mine.filter((i) => i.status === 'sold');
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<h3 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
<Package className="h-4 w-4 text-[#ccff00]" />
Your listings
{mine.length > 0 && (
<span className="rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-bold text-[#ccff00]">
{active.length} active
</span>
)}
</h3>
<Button asChild size="sm" className="gap-1.5 bg-orange-500 font-bold text-white hover:bg-orange-600">
<Link to="/create-listing?returnTo=/marketplace">
<Plus className="h-3.5 w-3.5" /> List an item
</Link>
</Button>
</div>
{isLoading ? (
<p className="text-sm text-gray-500">Loading your listings…</p>
) : mine.length === 0 ? (
<Card className="border-dashed border-white/10">
<CardContent className="space-y-3 py-10 text-center">
<Package className="mx-auto h-8 w-8 text-gray-600" />
<p className="text-sm text-gray-400">You have no listings yet.</p>
<Button asChild className="gap-1.5 bg-orange-500 font-bold text-white hover:bg-orange-600">
<Link to="/create-listing?returnTo=/marketplace">
<Plus className="h-3.5 w-3.5" /> List your first item
</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{active.map((item) => (
<MyListingRow key={item.id} item={item} />
))}
{sold.length > 0 && (
<>
<h4 className="pt-2 text-xs font-black uppercase tracking-widest text-gray-500">Sold</h4>
{sold.map((item) => (
<MyListingRow key={item.id} item={item} />
))}
</>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,70 @@
import { useState, type ComponentType, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, CheckCircle, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { DeckRating } from '@/components/DeckRating';
import { cn } from '@/lib/utils';
interface TradeConfirmRowProps {
tone: 'sale' | 'purchase';
icon: ComponentType<{ className?: string }>;
text: ReactNode;
listingUrl: string;
busy: boolean;
disabled: boolean;
actionLabel: string;
/** Called with the chosen deck rating (0 = none). */
onConfirm: (rating: number) => void;
}
/**
* A pending trade to confirm, with a 1–10 skateboard-deck rating the reviewer
* can set before confirming. The rating rides along on the kind-31871
* attestation, so it becomes the review.
*/
export function TradeConfirmRow({
tone,
icon: Icon,
text,
listingUrl,
busy,
disabled,
actionLabel,
onConfirm,
}: TradeConfirmRowProps) {
const [rating, setRating] = useState(0);
const cardCls = tone === 'sale' ? 'border-[#ff6600]/30 bg-[#ff6600]/5' : 'border-[#ccff00]/30 bg-[#ccff00]/5';
const iconCls = tone === 'sale' ? 'text-[#ff6600]' : 'text-[#ccff00]';
const btnCls = tone === 'sale' ? 'bg-[#ff6600] hover:bg-[#ff6600]/90' : 'bg-[#ccff00] hover:bg-[#ccff00]/90';
return (
<Card className={cn('border-2', cardCls)}>
<CardContent className="flex flex-col gap-2 p-3">
<div className="flex items-center gap-3">
<Icon className={cn('h-5 w-5 shrink-0', iconCls)} />
<div className="min-w-0 flex-1">
<p className="text-sm text-white">{text}</p>
<Link to={listingUrl} className="text-xs text-gray-400 hover:text-white">
View listing <ArrowRight className="inline h-3 w-3" />
</Link>
</div>
<Button
size="sm"
onClick={() => onConfirm(rating)}
disabled={disabled}
className={cn('shrink-0 gap-1.5 font-bold text-black', btnCls)}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />}
{actionLabel}
</Button>
</div>
<div className="flex items-center gap-2 pl-8">
<span className="shrink-0 text-[11px] text-gray-500">Rate the trade:</span>
<DeckRating value={rating} onChange={setRating} size="sm" />
</div>
</CardContent>
</Card>
);
}

View file

@ -5,6 +5,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { TradeReputationCard } from '@/components/TradeReputationCard'; import { TradeReputationCard } from '@/components/TradeReputationCard';
import { TradeRecommendationsCard } from '@/components/TradeRecommendationsCard'; import { TradeRecommendationsCard } from '@/components/TradeRecommendationsCard';
import { DeckRating } from '@/components/DeckRating';
import { useTradeReputation, TRADE_METHODS, type TradeRole } from '@/hooks/useTradeAttestations'; import { useTradeReputation, TRADE_METHODS, type TradeRole } from '@/hooks/useTradeAttestations';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
@ -16,6 +17,7 @@ function ReviewRow({ ev, selfPubkey }: { ev: NostrEvent; selfPubkey: string }) {
const counterparty = ev.tags.find(([t]) => t === 'p')?.[1] ?? ''; const counterparty = ev.tags.find(([t]) => t === 'p')?.[1] ?? '';
const method = ev.tags.find(([t]) => t === 'method')?.[1]; const method = ev.tags.find(([t]) => t === 'method')?.[1];
const amount = ev.tags.find(([t]) => t === 'amount')?.[1]; const amount = ev.tags.find(([t]) => t === 'amount')?.[1];
const rating = Number(ev.tags.find(([t]) => t === 'rating')?.[1] ?? 0);
const coord = ev.tags.find(([t]) => t === 'a')?.[1] ?? ''; const coord = ev.tags.find(([t]) => t === 'a')?.[1] ?? '';
const [, listingPubkey, listingId] = coord.split(':'); const [, listingPubkey, listingId] = coord.split(':');
@ -49,6 +51,7 @@ function ReviewRow({ ev, selfPubkey }: { ev: NostrEvent; selfPubkey: string }) {
</span> </span>
{!given && <span className="text-[9px] font-bold uppercase tracking-wide text-emerald-400">★ Review of you</span>} {!given && <span className="text-[9px] font-bold uppercase tracking-wide text-emerald-400">★ Review of you</span>}
{given && <span className="text-[9px] font-bold uppercase tracking-wide text-gray-500">your record</span>} {given && <span className="text-[9px] font-bold uppercase tracking-wide text-gray-500">your record</span>}
{rating > 0 && <DeckRating value={rating} compact className="ml-auto" />}
</div> </div>
{ev.content && <p className="mt-1 line-clamp-2 text-xs text-gray-300">{ev.content}</p>} {ev.content && <p className="mt-1 line-clamp-2 text-xs text-gray-300">{ev.content}</p>}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-gray-500"> <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-gray-500">
@ -74,10 +77,23 @@ export function TradeReputationPanel({ pubkey }: { pubkey: string }) {
const { data, isLoading } = useTradeReputation(pubkey); const { data, isLoading } = useTradeReputation(pubkey);
const attestations = [...(data?.attestations ?? [])].sort((a, b) => b.created_at - a.created_at); const attestations = [...(data?.attestations ?? [])].sort((a, b) => b.created_at - a.created_at);
const reviews = attestations.filter((a) => a.pubkey !== pubkey); const reviews = attestations.filter((a) => a.pubkey !== pubkey);
const ratings = attestations
.map((a) => Number(a.tags.find(([t]) => t === 'rating')?.[1] ?? 0))
.filter((n) => n >= 1 && n <= 10);
const avg = ratings.length ? ratings.reduce((s, n) => s + n, 0) / ratings.length : 0;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<TradeReputationCard pubkey={pubkey} /> <TradeReputationCard pubkey={pubkey} />
{avg > 0 && (
<div className="flex items-center gap-3 rounded-xl border border-[#ccff00]/20 bg-[#ccff00]/5 px-3 py-2.5">
<span className="text-lg font-black tabular-nums text-[#ccff00]">{avg.toFixed(1)}/10</span>
<DeckRating value={Math.round(avg)} compact className="text-[#ccff00]" />
<span className="text-xs text-gray-400">
average from {ratings.length} rating{ratings.length === 1 ? '' : 's'}
</span>
</div>
)}
<TradeRecommendationsCard pubkey={pubkey} /> <TradeRecommendationsCard pubkey={pubkey} />
<div> <div>

View file

@ -94,6 +94,7 @@ export function useTradeAttestation() {
amountSats, amountSats,
state = 'valid', state = 'valid',
note, note,
rating,
requestCoord, requestCoord,
}: { }: {
/** The listing being attested. */ /** The listing being attested. */
@ -105,6 +106,8 @@ export function useTradeAttestation() {
amountSats?: number; amountSats?: number;
state?: 'verifying' | 'valid' | 'revoked'; state?: 'verifying' | 'valid' | 'revoked';
note?: string; note?: string;
/** Trade rating, 1–10 (skateboard decks). Optional. */
rating?: number;
/** The `31872` request coord this attestation fulfils, if any (spec `request` tag). */ /** The `31872` request coord this attestation fulfils, if any (spec `request` tag). */
requestCoord?: string; requestCoord?: string;
}) => { }) => {
@ -124,6 +127,9 @@ export function useTradeAttestation() {
if (typeof amountSats === 'number' && amountSats > 0) { if (typeof amountSats === 'number' && amountSats > 0) {
tags.push(['amount', String(Math.round(amountSats)), 'SATS']); tags.push(['amount', String(Math.round(amountSats)), 'SATS']);
} }
if (typeof rating === 'number' && rating >= 1 && rating <= 10) {
tags.push(['rating', String(Math.round(rating))]);
}
if (requestCoord) tags.push(['request', requestCoord]); if (requestCoord) tags.push(['request', requestCoord]);
const name = user.pubkey.slice(0, 8); const name = user.pubkey.slice(0, 8);

View file

@ -1,14 +1,16 @@
import { useState } from 'react'; import { useState } from 'react';
import { Store, Handshake, Star } from 'lucide-react'; import { Store, Handshake, Star, Package } from 'lucide-react';
import GearSwapPage from '@/pages/GearSwapPage'; import GearSwapPage from '@/pages/GearSwapPage';
import TradesPage from '@/pages/TradesPage'; import TradesPage from '@/pages/TradesPage';
import { TradeReputationPanel } from '@/components/TradeReputationPanel'; import { TradeReputationPanel } from '@/components/TradeReputationPanel';
import { SellerDashboard } from '@/components/SellerDashboard';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const TABS = [ const TABS = [
{ id: 'browse', label: 'Browse', icon: Store }, { id: 'browse', label: 'Browse', icon: Store },
{ id: 'selling', label: 'Selling', icon: Package },
{ id: 'trades', label: 'My Trades', icon: Handshake }, { id: 'trades', label: 'My Trades', icon: Handshake },
{ id: 'reputation', label: 'Reputation', icon: Star }, { id: 'reputation', label: 'Reputation', icon: Star },
] as const; ] as const;
@ -53,6 +55,18 @@ export default function MarketplaceHubPage() {
</div> </div>
{tab === 'browse' && <GearSwapPage />} {tab === 'browse' && <GearSwapPage />}
{tab === 'selling' && (
<div className="container mx-auto max-w-3xl px-4 py-8">
{user ? (
<SellerDashboard />
) : (
<div className="space-y-4 py-16 text-center">
<p className="text-gray-400">Log in to manage your listings.</p>
<LoginArea className="justify-center" />
</div>
)}
</div>
)}
{tab === 'trades' && <TradesPage />} {tab === 'trades' && <TradesPage />}
{tab === 'reputation' && ( {tab === 'reputation' && (
<div className="container mx-auto max-w-3xl px-4 py-8"> <div className="container mx-auto max-w-3xl px-4 py-8">

View file

@ -5,6 +5,7 @@ import { nip19 } from 'nostr-tools';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { TradeConfirmRow } from '@/components/TradeConfirmRow';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { import {
Dialog, Dialog,
@ -73,6 +74,7 @@ export default function TradesPage() {
listing: { pubkey: string; identifier: string }, listing: { pubkey: string; identifier: string },
requestor: string, requestor: string,
requestCoord: string, requestCoord: string,
rating?: number,
) => { ) => {
setBusy(requestCoord); setBusy(requestCoord);
try { try {
@ -81,6 +83,7 @@ export default function TradesPage() {
role: 'sold', role: 'sold',
counterparty: requestor, counterparty: requestor,
requestCoord, requestCoord,
rating,
}); });
toast({ title: 'Sale confirmed', description: 'They can now confirm their side.' }); toast({ title: 'Sale confirmed', description: 'They can now confirm their side.' });
} catch (e) { } catch (e) {
@ -98,10 +101,11 @@ export default function TradesPage() {
listing: { pubkey: string; identifier: string }, listing: { pubkey: string; identifier: string },
seller: string, seller: string,
coord: string, coord: string,
rating?: number,
) => { ) => {
setBusy(coord); setBusy(coord);
try { try {
await publishTrade({ listing, role: 'bought', counterparty: seller }); await publishTrade({ listing, role: 'bought', counterparty: seller, rating });
toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' }); toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' });
} catch (e) { } catch (e) {
toast({ toast({
@ -192,60 +196,32 @@ export default function TradesPage() {
{/* Incoming: someone asked you (seller) to record a sale */} {/* Incoming: someone asked you (seller) to record a sale */}
{incoming?.map((r) => ( {incoming?.map((r) => (
<Card key={r.coord} className="border-2 border-[#ff6600]/30 bg-[#ff6600]/5"> <TradeConfirmRow
<CardContent className="flex items-center gap-3 p-3"> key={r.coord}
<Handshake className="h-5 w-5 shrink-0 text-[#ff6600]" /> tone="sale"
<div className="min-w-0 flex-1"> icon={Handshake}
<p className="text-sm text-white"> text={<><PeerName pubkey={r.requestor} /> asked you to confirm a sale</>}
<PeerName pubkey={r.requestor} /> asked you to confirm a sale listingUrl={listingUrl(r.listing.pubkey, r.listing.identifier)}
</p> busy={busy === r.coord}
<Link
to={listingUrl(r.listing.pubkey, r.listing.identifier)}
className="text-xs text-gray-400 hover:text-white"
>
View listing <ArrowRight className="inline h-3 w-3" />
</Link>
</div>
<Button
size="sm"
onClick={() => handleConfirmSale(r.listing, r.requestor, r.coord)}
disabled={busy === r.coord || publishing} disabled={busy === r.coord || publishing}
className="shrink-0 gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90" actionLabel="Confirm sale"
> onConfirm={(rating) => handleConfirmSale(r.listing, r.requestor, r.coord, rating)}
{busy === r.coord ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />} />
Confirm sale
</Button>
</CardContent>
</Card>
))} ))}
{/* Pending: a seller recorded a sale you haven't confirmed */} {/* Pending: a seller recorded a sale you haven't confirmed */}
{pending?.map((t) => ( {pending?.map((t) => (
<Card key={t.coord} className="border-2 border-[#ccff00]/30 bg-[#ccff00]/5"> <TradeConfirmRow
<CardContent className="flex items-center gap-3 p-3"> key={t.coord}
<CheckCircle className="h-5 w-5 shrink-0 text-[#ccff00]" /> tone="purchase"
<div className="min-w-0 flex-1"> icon={CheckCircle}
<p className="text-sm text-white"> text={<><PeerName pubkey={t.seller} /> recorded a sale — confirm your side</>}
<PeerName pubkey={t.seller} /> recorded a sale — confirm your side listingUrl={listingUrl(t.listing.pubkey, t.listing.identifier)}
</p> busy={busy === t.coord}
<Link
to={listingUrl(t.listing.pubkey, t.listing.identifier)}
className="text-xs text-gray-400 hover:text-white"
>
View listing <ArrowRight className="inline h-3 w-3" />
</Link>
</div>
<Button
size="sm"
onClick={() => handleConfirmPurchase(t.listing, t.seller, t.coord)}
disabled={busy === t.coord || publishing} disabled={busy === t.coord || publishing}
className="shrink-0 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" actionLabel="Confirm"
> onConfirm={(rating) => handleConfirmPurchase(t.listing, t.seller, t.coord, rating)}
{busy === t.coord ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />} />
Confirm
</Button>
</CardContent>
</Card>
))} ))}
{!loadingIncoming && !loadingPending && actionNeeded === 0 && ( {!loadingIncoming && !loadingPending && actionNeeded === 0 && (