From efa8b2e521f2a51b51f9b2e3755c75ec213b4bf9 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 27 Sep 2026 16:26:24 +0700 Subject: [PATCH] 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. --- NIP.md | 6 +- src/components/DeckRating.tsx | 63 ++++++++++++ src/components/SellerDashboard.tsx | 129 ++++++++++++++++++++++++ src/components/TradeConfirmRow.tsx | 70 +++++++++++++ src/components/TradeReputationPanel.tsx | 16 +++ src/hooks/useTradeAttestations.ts | 6 ++ src/pages/MarketplaceHubPage.tsx | 16 ++- src/pages/TradesPage.tsx | 78 +++++--------- 8 files changed, 330 insertions(+), 54 deletions(-) create mode 100644 src/components/DeckRating.tsx create mode 100644 src/components/SellerDashboard.tsx create mode 100644 src/components/TradeConfirmRow.tsx diff --git a/NIP.md b/NIP.md index 8506fb5..c159f07 100644 --- a/NIP.md +++ b/NIP.md @@ -3165,6 +3165,7 @@ on the same listing form a **verified trade**. ["p", ""], ["method", "cash|lightning|trade|other"], ["amount", "45000", "SATS"], + ["rating", "9"], ["alt", "SkateSpots: sale completed"] ], "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) | | `method` | Self-reported payment method: `cash`, `lightning`, `trade`, `other` | | `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 -cannot observe off-app (cash/trade) settlement. +`method`/`amount`/`rating` are **self-reported**, never proof of payment or a +verified score — the app cannot observe off-app (cash/trade) settlement. ## States & revocation diff --git a/src/components/DeckRating.tsx b/src/components/DeckRating.tsx new file mode 100644 index 0000000..9a0a594 --- /dev/null +++ b/src/components/DeckRating.tsx @@ -0,0 +1,63 @@ +import { cn } from '@/lib/utils'; + +/** A little skateboard-deck glyph for the rating. */ +function Deck({ className }: { className?: string }) { + return ( + + + + + + ); +} + +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 ( + + {Array.from({ length: count }).map((_, i) => { + const n = i + 1; + const filled = n <= value; + if (readOnly) { + return ; + } + return ( + + ); + })} + {(value > 0 || !readOnly) && ( + + {value > 0 ? `${value}/10` : ''} + + )} + + ); +} diff --git a/src/components/SellerDashboard.tsx b/src/components/SellerDashboard.tsx new file mode 100644 index 0000000..307c2e9 --- /dev/null +++ b/src/components/SellerDashboard.tsx @@ -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 ( + <> + + + {item.images[0] ? ( + + ) : ( +
+ +
+ )} +
+

{item.title}

+

+ {price} + {item.location ? ` · ${item.location}` : ''} +

+
+ + {soldOut ? 'Sold' : 'Active'} + + + {!soldOut && ( + + )} +
+
+ + + ); +} + +/** + * 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 ( +
+
+

+ + Your listings + {mine.length > 0 && ( + + {active.length} active + + )} +

+ +
+ + {isLoading ? ( +

Loading your listings…

+ ) : mine.length === 0 ? ( + + + +

You have no listings yet.

+ +
+
+ ) : ( +
+ {active.map((item) => ( + + ))} + {sold.length > 0 && ( + <> +

Sold

+ {sold.map((item) => ( + + ))} + + )} +
+ )} +
+ ); +} diff --git a/src/components/TradeConfirmRow.tsx b/src/components/TradeConfirmRow.tsx new file mode 100644 index 0000000..bac2933 --- /dev/null +++ b/src/components/TradeConfirmRow.tsx @@ -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 ( + + +
+ +
+

{text}

+ + View listing + +
+ +
+
+ Rate the trade: + +
+
+
+ ); +} diff --git a/src/components/TradeReputationPanel.tsx b/src/components/TradeReputationPanel.tsx index 6197f97..572e2c4 100644 --- a/src/components/TradeReputationPanel.tsx +++ b/src/components/TradeReputationPanel.tsx @@ -5,6 +5,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { TradeReputationCard } from '@/components/TradeReputationCard'; import { TradeRecommendationsCard } from '@/components/TradeRecommendationsCard'; +import { DeckRating } from '@/components/DeckRating'; import { useTradeReputation, TRADE_METHODS, type TradeRole } from '@/hooks/useTradeAttestations'; import { useAuthor } from '@/hooks/useAuthor'; 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 method = ev.tags.find(([t]) => t === 'method')?.[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 [, listingPubkey, listingId] = coord.split(':'); @@ -49,6 +51,7 @@ function ReviewRow({ ev, selfPubkey }: { ev: NostrEvent; selfPubkey: string }) { {!given && ★ Review of you} {given && your record} + {rating > 0 && } {ev.content &&

{ev.content}

}
@@ -74,10 +77,23 @@ export function TradeReputationPanel({ pubkey }: { pubkey: string }) { const { data, isLoading } = useTradeReputation(pubkey); const attestations = [...(data?.attestations ?? [])].sort((a, b) => b.created_at - a.created_at); 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 (
+ {avg > 0 && ( +
+ {avg.toFixed(1)}/10 + + + average from {ratings.length} rating{ratings.length === 1 ? '' : 's'} + +
+ )}
diff --git a/src/hooks/useTradeAttestations.ts b/src/hooks/useTradeAttestations.ts index b004888..48692ad 100644 --- a/src/hooks/useTradeAttestations.ts +++ b/src/hooks/useTradeAttestations.ts @@ -94,6 +94,7 @@ export function useTradeAttestation() { amountSats, state = 'valid', note, + rating, requestCoord, }: { /** The listing being attested. */ @@ -105,6 +106,8 @@ export function useTradeAttestation() { amountSats?: number; state?: 'verifying' | 'valid' | 'revoked'; note?: string; + /** Trade rating, 1–10 (skateboard decks). Optional. */ + rating?: number; /** The `31872` request coord this attestation fulfils, if any (spec `request` tag). */ requestCoord?: string; }) => { @@ -124,6 +127,9 @@ export function useTradeAttestation() { if (typeof amountSats === 'number' && amountSats > 0) { 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]); const name = user.pubkey.slice(0, 8); diff --git a/src/pages/MarketplaceHubPage.tsx b/src/pages/MarketplaceHubPage.tsx index 18d7db3..da5dfef 100644 --- a/src/pages/MarketplaceHubPage.tsx +++ b/src/pages/MarketplaceHubPage.tsx @@ -1,14 +1,16 @@ 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 TradesPage from '@/pages/TradesPage'; import { TradeReputationPanel } from '@/components/TradeReputationPanel'; +import { SellerDashboard } from '@/components/SellerDashboard'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { LoginArea } from '@/components/auth/LoginArea'; import { cn } from '@/lib/utils'; const TABS = [ { id: 'browse', label: 'Browse', icon: Store }, + { id: 'selling', label: 'Selling', icon: Package }, { id: 'trades', label: 'My Trades', icon: Handshake }, { id: 'reputation', label: 'Reputation', icon: Star }, ] as const; @@ -53,6 +55,18 @@ export default function MarketplaceHubPage() {
{tab === 'browse' && } + {tab === 'selling' && ( +
+ {user ? ( + + ) : ( +
+

Log in to manage your listings.

+ +
+ )} +
+ )} {tab === 'trades' && } {tab === 'reputation' && (
diff --git a/src/pages/TradesPage.tsx b/src/pages/TradesPage.tsx index 1e154da..093bef6 100644 --- a/src/pages/TradesPage.tsx +++ b/src/pages/TradesPage.tsx @@ -5,6 +5,7 @@ import { nip19 } from 'nostr-tools'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { TradeConfirmRow } from '@/components/TradeConfirmRow'; import { Skeleton } from '@/components/ui/skeleton'; import { Dialog, @@ -73,6 +74,7 @@ export default function TradesPage() { listing: { pubkey: string; identifier: string }, requestor: string, requestCoord: string, + rating?: number, ) => { setBusy(requestCoord); try { @@ -81,6 +83,7 @@ export default function TradesPage() { role: 'sold', counterparty: requestor, requestCoord, + rating, }); toast({ title: 'Sale confirmed', description: 'They can now confirm their side.' }); } catch (e) { @@ -98,10 +101,11 @@ export default function TradesPage() { listing: { pubkey: string; identifier: string }, seller: string, coord: string, + rating?: number, ) => { setBusy(coord); 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.' }); } catch (e) { toast({ @@ -192,60 +196,32 @@ export default function TradesPage() { {/* Incoming: someone asked you (seller) to record a sale */} {incoming?.map((r) => ( - - - -
-

- asked you to confirm a sale -

- - View listing - -
- -
-
+ asked you to confirm a sale} + listingUrl={listingUrl(r.listing.pubkey, r.listing.identifier)} + busy={busy === r.coord} + disabled={busy === r.coord || publishing} + actionLabel="Confirm sale" + onConfirm={(rating) => handleConfirmSale(r.listing, r.requestor, r.coord, rating)} + /> ))} {/* Pending: a seller recorded a sale you haven't confirmed */} {pending?.map((t) => ( - - - -
-

- recorded a sale — confirm your side -

- - View listing - -
- -
-
+ recorded a sale — confirm your side} + listingUrl={listingUrl(t.listing.pubkey, t.listing.identifier)} + busy={busy === t.coord} + disabled={busy === t.coord || publishing} + actionLabel="Confirm" + onConfirm={(rating) => handleConfirmPurchase(t.listing, t.seller, t.coord, rating)} + /> ))} {!loadingIncoming && !loadingPending && actionNeeded === 0 && (