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:
parent
14b3ee0bf2
commit
efa8b2e521
8 changed files with 330 additions and 54 deletions
6
NIP.md
6
NIP.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
63
src/components/DeckRating.tsx
Normal file
63
src/components/DeckRating.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/components/SellerDashboard.tsx
Normal file
129
src/components/SellerDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/TradeConfirmRow.tsx
Normal file
70
src/components/TradeConfirmRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue