SkateSpots/src/components/MarkSoldDialog.tsx
SkateSpots 883338f060 feat(marketplace): mutual trade attestations + reputation (kind 31871)
Built on the Attestations NIP (31871); a listing is the Assertion Event:
- Seller 'Mark as sold' now opens a dialog (buyer npub + self-reported method +
  optional amount) that marks the listing sold AND publishes a trade attestation
- Buyer sees 'Confirm this trade' on the listing and publishes the mirror
  attestation -> mutual pair = a verified trade
- useTradeReputation aggregates sales/purchases/verified + counterparties
- Profile 'Trades' card; seller 'N verified trades' badge on listing cards
- NIP.md documents kind 31871 usage + our p/method/amount extensions
Payment method is self-reported metadata, never proof of payment.
2026-09-26 20:17:38 +07:00

169 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import { nip19 } from 'nostr-tools';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useTradeAttestation, TRADE_METHODS, type TradeMethod } from '@/hooks/useTradeAttestations';
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { Check, Loader2 } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
function resolvePubkey(input: string): string | null {
const t = input.trim();
if (/^[a-f0-9]{64}$/i.test(t)) return t.toLowerCase();
if (t.startsWith('npub1')) {
try {
const decoded = nip19.decode(t);
return decoded.type === 'npub' ? decoded.data : null;
} catch {
return null;
}
}
return null;
}
/**
* Seller's "close the deal" flow: mark the listing sold AND publish a trade
* attestation (kind 31871) naming the buyer, so both sides can build
* reputation. Payment method is self-reported — the app can't see cash/trade
* deals and never treats it as proof of payment.
*/
export function MarkSoldDialog({
open,
onOpenChange,
event,
listing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
event: NostrEvent;
listing: { pubkey: string; identifier: string };
}) {
const { publishTrade, isPending: publishing } = useTradeAttestation();
const { markSold, isPending: marking } = useMarkListingSold();
const { toast } = useToast();
const [buyer, setBuyer] = useState('');
const [method, setMethod] = useState<TradeMethod>('cash');
const [amount, setAmount] = useState('');
const buyerPubkey = resolvePubkey(buyer);
const busy = publishing || marking;
const submit = async () => {
if (!buyerPubkey) {
toast({ title: 'Enter the buyer’s npub', variant: 'destructive' });
return;
}
try {
await markSold(event);
await publishTrade({
listing,
role: 'sold',
counterparty: buyerPubkey,
method,
amountSats: amount.trim() ? Number(amount) : undefined,
});
toast({
title: 'Deal closed 🎉',
description: 'Listing marked sold and the trade was published for reputation.',
});
onOpenChange(false);
setBuyer('');
setAmount('');
} catch (e) {
toast({
title: 'Could not close the deal',
description: e instanceof Error ? e.message : 'Something went wrong.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="mx-4 max-w-[calc(100vw-2rem)] border-2 border-[#ccff00]/40 bg-black sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-white">Close the deal</DialogTitle>
<DialogDescription className="text-sm text-gray-400">
Marks this listing sold and records a signed trade attestation for you and the buyer.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="buyer" className="flex items-center gap-2 text-xs uppercase tracking-wide text-gray-400">
Buyer&apos;s npub
{buyerPubkey && <Check className="h-3.5 w-3.5 text-[#ccff00]" />}
</Label>
<Input
id="buyer"
value={buyer}
onChange={(e) => setBuyer(e.target.value)}
placeholder="npub1…"
className="h-11 border-gray-700 bg-gray-900 font-mono text-xs text-white"
/>
</div>
<div className="space-y-2">
<Label className="text-xs uppercase tracking-wide text-gray-400">How was it paid? (self-reported)</Label>
<div className="flex flex-wrap gap-2">
{(Object.keys(TRADE_METHODS) as TradeMethod[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMethod(m)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-bold transition-colors',
method === m
? 'border-[#ccff00] bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/15 text-gray-400 hover:border-white/30',
)}
>
{TRADE_METHODS[m]}
</button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="amount" className="text-xs uppercase tracking-wide text-gray-400">
Amount in sats (optional)
</Label>
<Input
id="amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
inputMode="numeric"
placeholder="e.g. 45000"
className="h-11 border-gray-700 bg-gray-900 text-white"
/>
</div>
<div className="flex gap-2 pt-1">
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
Cancel
</Button>
<Button
onClick={submit}
disabled={busy || !buyerPubkey}
className="flex-1 gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Mark sold
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}