feat(market): counter-offer negotiations + trade notifications
Offers were one-way (buyer proposes, seller accepts/declines) and fixed-slot addressable events, so the buyer could never counter back and the haggling left no trace. Now it's a thread. - useMarketplaceOffers: every message gets a UNIQUE d (append-only) with a 'role' tag (offer = buyer / response = seller), 'e' = the message it answers, and status. New counterOffer() plus respondToOffer(previous, status). Parsing still accepts the legacy fixed-slot d form when the role tag is absent. - useListingOffers returns OfferThread[] (oldest first) with = whose turn it is; useIncomingOffers returns the threads awaiting me (across listings). - ListingOffersPanel: shows the volley, and accept/decline/counter (inline amount) for whichever side's turn it is, with withdraw on your own open offer. Counter notifications are nudged over DM too. - New useTradeNotifications: incoming offers, counters and 'confirm our trade' requests, derived from events already addressed to the user. - Bell: trade items render first in All and count toward the badge (vs seenAt). - OS notifications: new 'trades' category in notificationPrefs + a trades source in OsNotificationBridge. - NIP.md updated for the thread model (legacy form documented).
This commit is contained in:
parent
6ea5751675
commit
a6aeb2feb0
9 changed files with 559 additions and 185 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -80,8 +80,16 @@ session with **no other memory of this project**. So:
|
||||||
manages incoming requests, pending confirmations, and the user's trade records,
|
manages incoming requests, pending confirmations, and the user's trade records,
|
||||||
and can **revoke** a record (`s=revoked`, excluded from reputation) for a dispute
|
and can **revoke** a record (`s=revoked`, excluded from reputation) for a dispute
|
||||||
or mistake. Structured **offers** (kind `30403`, `useMarketplaceOffers` + the
|
or mistake. Structured **offers** (kind `30403`, `useMarketplaceOffers` + the
|
||||||
`ListingOffersPanel`/`OfferDialog`) let a buyer propose an amount and the seller
|
`ListingOffersPanel`/`OfferDialog`) are a **counter-offer negotiation thread
|
||||||
accept/decline.
|
(2026-09-29):** every message is its own event with a **unique `d`** (append-only)
|
||||||
|
carrying `role` (`offer` = buyer / `response` = seller), `e` = the message it
|
||||||
|
answers, and `status`; either side can **accept / decline / withdraw / counter**
|
||||||
|
and `ListingOffersPanel` shows the volley + whose turn it is. Legacy fixed-slot
|
||||||
|
`d`s are still parsed (role inferred when the `role` tag is absent) — see NIP.md
|
||||||
|
→ "Marketplace Offers". Trade/offer steps also feed **`useTradeNotifications`**
|
||||||
|
→ the bell's **All** tab (`TradeNotificationCard`, counted in the badge against
|
||||||
|
`seenAt`) and OS notifications (new **`trades`** category in `notificationPrefs`,
|
||||||
|
wired in `OsNotificationBridge`).
|
||||||
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
|
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
|
||||||
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
|
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
|
||||||
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a
|
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a
|
||||||
|
|
|
||||||
42
NIP.md
42
NIP.md
|
|
@ -779,24 +779,36 @@ For trust and spam prevention, sellers should have a verified NIP-05 identifier.
|
||||||
|
|
||||||
## Marketplace Offers (kind 30403)
|
## Marketplace Offers (kind 30403)
|
||||||
|
|
||||||
A buyer's structured proposal for a listing, which the seller can accept or
|
A structured **negotiation** over a listing. Either side can offer, counter,
|
||||||
decline. SkateSpots-native (no existing NIP defines offers). Addressable, so an
|
accept, decline, or withdraw — a back-and-forth thread, not a single request.
|
||||||
offer/response can be updated in place.
|
|
||||||
|
|
||||||
An **offer** is authored by the buyer; a **response** by the seller:
|
**Every message is its own event with a UNIQUE `d`**, so counters never
|
||||||
|
overwrite the message they answer (the thread is append-only). The current state
|
||||||
|
of a negotiation is simply its newest message.
|
||||||
|
|
||||||
| Field | Offer (`d = <buyer>:offer:<listing-d>`) | Response (`d = <seller>:offer-response:<buyer>`) |
|
| Field | Value |
|
||||||
| --- | --- | --- |
|
| --- | --- |
|
||||||
| `a` | the listing coord (`30402:<seller>:<d>`) | the listing coord |
|
| `d` | `<author>:<role>:<listing-d>:<nonce>` — **unique**, one per message |
|
||||||
| `p` | the **seller** | the **buyer** |
|
| `a` | the listing coord (`30402:<seller>:<d>`) |
|
||||||
| `e` | — | the **offer** event id being answered |
|
| `p` | the **other party** (offer → seller; response → buyer) |
|
||||||
| `status` | `open` \| `withdrawn` | `accepted` \| `declined` |
|
| `e` | the message this one answers (a counter / accept / decline) — absent only on the first offer |
|
||||||
| `amount` | optional `["amount","45000","SATS"]` | mirrored |
|
| `role` | `offer` (authored by the buyer) \| `response` (authored by the seller) |
|
||||||
| `alt` | `SkateSpots: marketplace offer` | same |
|
| `status` | `open` \| `accepted` \| `declined` \| `withdrawn` |
|
||||||
|
| `amount` | optional `["amount","45000","SATS"]` |
|
||||||
|
| `alt` | `SkateSpots: marketplace offer` |
|
||||||
|
|
||||||
`content` is an optional human message. Clients show a seller their incoming
|
`content` is an optional human message. Either party may answer the newest `open`
|
||||||
offers (open, `#p` = them) and a buyer their own offer plus the seller's
|
message with a **counter** (`status=open`, new `amount`, `e` = the message being
|
||||||
response. Payment is still arranged directly between the two skaters.
|
answered), or **accept/decline** it. Negotiations are grouped by
|
||||||
|
`(listing, buyer)` into threads; a thread is "awaiting" whoever did not author
|
||||||
|
its newest `open` message.
|
||||||
|
|
||||||
|
> ⚠️ Legacy events used fixed slots (`d = <buyer>:offer:<listing-d>` and
|
||||||
|
> `<seller>:offer-response:<buyer>`, where a re-publish replaced the message).
|
||||||
|
> Clients MUST still parse those (`role` inferred from the `d` marker when the
|
||||||
|
> `role` tag is absent), but new messages use the unique-`d` thread form.
|
||||||
|
|
||||||
|
Payment is still arranged directly between the two skaters.
|
||||||
|
|
||||||
## Community Contributions
|
## Community Contributions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,20 @@ import { nip19 } from 'nostr-tools';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useOfferActions, useListingOffers, type MarketplaceOffer } from '@/hooks/useMarketplaceOffers';
|
import {
|
||||||
|
useOfferActions,
|
||||||
|
useListingOffers,
|
||||||
|
type MarketplaceOffer,
|
||||||
|
type OfferThread,
|
||||||
|
} from '@/hooks/useMarketplaceOffers';
|
||||||
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
|
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import type { MarketplaceItem } from '@/lib/types';
|
import type { MarketplaceItem } from '@/lib/types';
|
||||||
import { Handshake, CheckCircle, X, Loader2 } from 'lucide-react';
|
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2 } from 'lucide-react';
|
||||||
|
|
||||||
function PeerName({ pubkey }: { pubkey: string }) {
|
function PeerName({ pubkey }: { pubkey: string }) {
|
||||||
const author = useAuthor(pubkey);
|
const author = useAuthor(pubkey);
|
||||||
|
|
@ -36,40 +42,49 @@ function statusBadge(status: MarketplaceOffer['status']) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function amountLabel(m: MarketplaceOffer): string {
|
||||||
|
return m.amount ? `${m.amount.toLocaleString()} ${m.currency ?? 'SATS'}` : 'no amount';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offers for a listing: the seller sees incoming offers with accept/decline;
|
* Offers for a listing, as a **negotiation thread**. Either side can accept,
|
||||||
* a buyer sees their own offer and the seller's response.
|
* decline, withdraw, or **counter** with a new amount — and it's always clear
|
||||||
|
* whose turn it is.
|
||||||
*/
|
*/
|
||||||
export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) {
|
export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { data } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier });
|
const { data } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier });
|
||||||
const { respondToOffer } = useOfferActions();
|
const { respondToOffer, counterOffer } = useOfferActions();
|
||||||
const { mutateAsync: sendDM } = useSendDirectMessage();
|
const { mutateAsync: sendDM } = useSendDirectMessage();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [counterFor, setCounterFor] = useState<string | null>(null);
|
||||||
|
const [counterAmount, setCounterAmount] = useState('');
|
||||||
|
|
||||||
if (!user || !data) return null;
|
if (!user || !data) return null;
|
||||||
|
|
||||||
const respond = async (offer: MarketplaceOffer, status: 'accepted' | 'declined') => {
|
const mine = data.threads.filter((t) => t.buyer === user.pubkey || t.seller === user.pubkey);
|
||||||
setBusy(offer.id);
|
if (mine.length === 0) return null;
|
||||||
|
|
||||||
|
const nudge = async (to: string, content: string) => {
|
||||||
try {
|
try {
|
||||||
await respondToOffer(offer, status);
|
await sendDM({ recipients: to, content });
|
||||||
// Best-effort nudge to the buyer via NIP-17 DM.
|
} catch {
|
||||||
try {
|
/* DM is a best-effort extra nudge; the event itself already carries it */
|
||||||
await sendDM({
|
}
|
||||||
recipients: offer.buyer,
|
};
|
||||||
content:
|
|
||||||
status === 'accepted'
|
const respond = async (thread: OfferThread, status: 'accepted' | 'declined') => {
|
||||||
? '✅ Your offer was accepted! Message the seller to arrange the handoff.'
|
setBusy(thread.latest.id);
|
||||||
: '❌ Your offer was declined.',
|
try {
|
||||||
});
|
await respondToOffer(thread.latest, status);
|
||||||
} catch {
|
await nudge(
|
||||||
/* DM optional */
|
thread.latest.author,
|
||||||
}
|
status === 'accepted'
|
||||||
toast({
|
? '✅ Your offer was accepted! Message to arrange the handoff.'
|
||||||
title: status === 'accepted' ? 'Offer accepted' : 'Offer declined',
|
: '❌ Your offer was declined.',
|
||||||
description: 'The buyer will see your response.',
|
);
|
||||||
});
|
toast({ title: status === 'accepted' ? 'Offer accepted' : 'Offer declined' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Could not respond',
|
title: 'Could not respond',
|
||||||
|
|
@ -81,10 +96,10 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const withdraw = async (offer: MarketplaceOffer) => {
|
const withdraw = async (thread: OfferThread) => {
|
||||||
setBusy(offer.id);
|
setBusy(thread.latest.id);
|
||||||
try {
|
try {
|
||||||
await respondToOffer(offer, 'withdrawn');
|
await respondToOffer(thread.latest, 'withdrawn');
|
||||||
toast({ title: 'Offer withdrawn' });
|
toast({ title: 'Offer withdrawn' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -97,94 +112,159 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Seller view: incoming offers on their listing.
|
const sendCounter = async (thread: OfferThread) => {
|
||||||
if (isOwner) {
|
const amount = Number(counterAmount);
|
||||||
const incoming = data.offers.filter((o) => o.seller === user.pubkey);
|
if (!counterAmount.trim() || !Number.isFinite(amount) || amount <= 0) {
|
||||||
if (incoming.length === 0) return null;
|
toast({ title: 'Enter a counter amount', variant: 'destructive' });
|
||||||
return (
|
return;
|
||||||
<Card className="bg-black/90 border-2 border-orange-500/25">
|
}
|
||||||
<CardContent className="space-y-3 p-4">
|
setBusy(thread.latest.id);
|
||||||
<h3 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-orange-400">
|
try {
|
||||||
<Handshake className="h-4 w-4" /> Offers ({incoming.length})
|
await counterOffer({
|
||||||
</h3>
|
listing: { pubkey: item.pubkey, identifier: item.identifier },
|
||||||
{incoming.map((offer) => {
|
previous: thread.latest,
|
||||||
const response = data.responses.find((r) => r.offerId === offer.id);
|
amount,
|
||||||
return (
|
});
|
||||||
<div key={offer.id} className="rounded-xl border border-white/10 bg-white/[0.03] p-3 space-y-1.5">
|
await nudge(thread.latest.author, `↩️ Counter-offer: ${amount.toLocaleString()} sats`);
|
||||||
<div className="flex items-center gap-2">
|
toast({ title: 'Counter-offer sent', description: 'It’s their turn now.' });
|
||||||
<span className="text-sm text-gray-200"><PeerName pubkey={offer.buyer} /></span>
|
setCounterFor(null);
|
||||||
{offer.amount ? (
|
setCounterAmount('');
|
||||||
<span className="text-sm font-bold text-[#ccff00]">
|
} catch (e) {
|
||||||
{offer.amount.toLocaleString()} {offer.currency ?? 'SATS'}
|
toast({
|
||||||
</span>
|
title: 'Could not send the counter',
|
||||||
) : null}
|
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||||
{response ? statusBadge(response.status) : statusBadge(offer.status)}
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-black/90 border-2 border-orange-500/25">
|
||||||
|
<CardContent className="space-y-3 p-4">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-orange-400">
|
||||||
|
<Handshake className="h-4 w-4" /> {isOwner ? `Offers (${mine.length})` : 'Your negotiations'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{mine.map((thread) => {
|
||||||
|
const latest = thread.latest;
|
||||||
|
const other = latest.author === user.pubkey
|
||||||
|
? latest.buyer === user.pubkey
|
||||||
|
? latest.seller
|
||||||
|
: latest.buyer
|
||||||
|
: latest.author;
|
||||||
|
const myTurn = thread.awaiting === user.pubkey;
|
||||||
|
const history = thread.messages.slice(0, -1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${thread.listingCoord}:${thread.buyer}`}
|
||||||
|
className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-200">
|
||||||
|
<PeerName pubkey={other} />
|
||||||
|
</span>
|
||||||
|
{statusBadge(latest.status)}
|
||||||
|
<span className="ml-auto text-sm font-bold text-[#ccff00]">{amountLabel(latest)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Earlier rounds — the volley, so the haggling is visible. */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="space-y-0.5 border-l border-white/10 pl-2">
|
||||||
|
{history.map((m) => (
|
||||||
|
<p key={m.id} className="text-[11px] text-gray-500">
|
||||||
|
{m.author === user.pubkey ? 'You' : 'Them'}: {amountLabel(m)}
|
||||||
|
{m.status !== 'open' ? ` · ${m.status}` : ''}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{offer.message && <p className="text-xs text-gray-400">{offer.message}</p>}
|
)}
|
||||||
{!response && offer.status === 'open' && (
|
|
||||||
|
{latest.message && <p className="text-xs text-gray-400">{latest.message}</p>}
|
||||||
|
|
||||||
|
{/* My turn: accept, decline, or counter. */}
|
||||||
|
{myTurn && (
|
||||||
|
<>
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => respond(offer, 'accepted')}
|
onClick={() => respond(thread, 'accepted')}
|
||||||
disabled={busy === offer.id}
|
disabled={busy === latest.id}
|
||||||
className="flex-1 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
className="flex-1 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||||
>
|
>
|
||||||
{busy === offer.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />}
|
{busy === latest.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />}
|
||||||
Accept
|
Accept
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => respond(offer, 'declined')}
|
onClick={() => respond(thread, 'declined')}
|
||||||
disabled={busy === offer.id}
|
disabled={busy === latest.id}
|
||||||
className="flex-1 gap-1.5 border-red-500/30 text-red-400 hover:bg-red-500/10"
|
className="flex-1 gap-1.5 border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" /> Decline
|
<X className="h-3.5 w-3.5" /> Decline
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setCounterFor(counterFor === latest.id ? null : latest.id);
|
||||||
|
setCounterAmount(latest.amount ? String(Math.round(latest.amount)) : '');
|
||||||
|
}}
|
||||||
|
className="flex-1 gap-1.5 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-3.5 w-3.5" /> Counter
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{counterFor === latest.id && (
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
);
|
<Input
|
||||||
})}
|
autoFocus
|
||||||
</CardContent>
|
type="number"
|
||||||
</Card>
|
inputMode="numeric"
|
||||||
);
|
value={counterAmount}
|
||||||
}
|
onChange={(e) => setCounterAmount(e.target.value)}
|
||||||
|
placeholder="Your counter (sats)"
|
||||||
|
className="h-9 border-white/15 bg-white/5 text-sm text-white"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => sendCounter(thread)}
|
||||||
|
disabled={busy === latest.id}
|
||||||
|
className="shrink-0 gap-1.5 bg-orange-500 font-bold text-black hover:bg-orange-500/90"
|
||||||
|
>
|
||||||
|
{busy === latest.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
// Buyer view: my own offer + the seller's response.
|
{/* Their turn: waiting, with the option to pull my own offer. */}
|
||||||
const myOffer = data.offers.find((o) => o.buyer === user.pubkey);
|
{!myTurn && latest.status === 'open' && (
|
||||||
if (!myOffer) return null;
|
<div className="flex items-center gap-2">
|
||||||
const response = data.responses.find((r) => r.offerId === myOffer.id);
|
<p className="flex-1 text-xs text-gray-500">
|
||||||
|
Waiting on <PeerName pubkey={other} /> — your turn will come back if they counter.
|
||||||
return (
|
</p>
|
||||||
<Card className="bg-black/90 border-2 border-orange-500/25">
|
{latest.author === user.pubkey && (
|
||||||
<CardContent className="space-y-2 p-4">
|
<Button
|
||||||
<h3 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-orange-400">
|
size="sm"
|
||||||
<Handshake className="h-4 w-4" /> Your offer
|
variant="outline"
|
||||||
</h3>
|
onClick={() => withdraw(thread)}
|
||||||
<div className="flex items-center gap-2">
|
disabled={busy === latest.id}
|
||||||
{myOffer.amount ? (
|
className="shrink-0 gap-1.5 border-white/15"
|
||||||
<span className="text-sm font-bold text-[#ccff00]">
|
>
|
||||||
{myOffer.amount.toLocaleString()} {myOffer.currency ?? 'SATS'}
|
<Undo2 className="h-3.5 w-3.5" /> Withdraw
|
||||||
</span>
|
</Button>
|
||||||
) : (
|
)}
|
||||||
<span className="text-sm text-gray-300">Offer</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
{statusBadge(response ? response.status : myOffer.status)}
|
</div>
|
||||||
</div>
|
);
|
||||||
{response?.message && <p className="text-xs text-gray-400">{response.message}</p>}
|
})}
|
||||||
{!response && myOffer.status === 'open' && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => withdraw(myOffer)}
|
|
||||||
disabled={busy === myOffer.id}
|
|
||||||
className="w-full border-white/15 gap-1.5"
|
|
||||||
>
|
|
||||||
{busy === myOffer.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
|
||||||
Withdraw offer
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@ import { CrewInviteCard } from '@/components/CrewInviteCard';
|
||||||
import { JoinRequestCard } from '@/components/JoinRequestCard';
|
import { JoinRequestCard } from '@/components/JoinRequestCard';
|
||||||
import { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard';
|
import { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard';
|
||||||
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
|
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
|
||||||
|
import { TradeNotificationCard } from '@/components/TradeNotificationCard';
|
||||||
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||||
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
|
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
|
||||||
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
||||||
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
|
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
|
||||||
|
import { useTradeNotifications } from '@/hooks/useTradeNotifications';
|
||||||
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
|
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
|
||||||
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
|
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
|
||||||
import { useNewsletterRead } from '@/hooks/useNewsletterRead';
|
import { useNewsletterRead } from '@/hooks/useNewsletterRead';
|
||||||
|
|
@ -207,6 +209,10 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
||||||
const { lastRead, seenAt, markAllRead } = useNotificationsRead();
|
const { lastRead, seenAt, markAllRead } = useNotificationsRead();
|
||||||
|
|
||||||
|
// Buying/selling steps: an offer on your listing, a counter it's your turn to
|
||||||
|
// answer, or a request to confirm a completed trade.
|
||||||
|
const { notifications: tradeNotifs, isLoading: tradeLoading } = useTradeNotifications(user?.pubkey);
|
||||||
|
|
||||||
// Newsletter mailbox — only nags subscribers, but always browsable in the tab.
|
// Newsletter mailbox — only nags subscribers, but always browsable in the tab.
|
||||||
const { data: newsletterIssues = [], isLoading: newsletterLoading } = useNewsletterIssues();
|
const { data: newsletterIssues = [], isLoading: newsletterLoading } = useNewsletterIssues();
|
||||||
const { lastRead: newsletterLastRead, markNewsletterRead } = useNewsletterRead();
|
const { lastRead: newsletterLastRead, markNewsletterRead } = useNewsletterRead();
|
||||||
|
|
@ -238,6 +244,12 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
[activity, lastRead],
|
[activity, lastRead],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Trades are actionable like crew items — they count until acknowledged.
|
||||||
|
const unreadTrades = useMemo(
|
||||||
|
() => tradeNotifs.filter((t) => t.createdAt > seenAt).length,
|
||||||
|
[tradeNotifs, seenAt],
|
||||||
|
);
|
||||||
|
|
||||||
// Unread newsletter issues (only counts for subscribers).
|
// Unread newsletter issues (only counts for subscribers).
|
||||||
const unreadNewsletter = useMemo(
|
const unreadNewsletter = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -248,7 +260,7 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Badge = only UNACKNOWLEDGED items, so "Mark read" actually clears it.
|
// Badge = only UNACKNOWLEDGED items, so "Mark read" actually clears it.
|
||||||
const badgeTotal = unreadCrew + unreadActivity + unreadNewsletter;
|
const badgeTotal = unreadCrew + unreadActivity + unreadNewsletter + unreadTrades;
|
||||||
|
|
||||||
// Opening the bell marks social + crew as read (and newsletter, since the
|
// Opening the bell marks social + crew as read (and newsletter, since the
|
||||||
// News tab is right there). Newsletter read state also clears on the /newsletter page.
|
// News tab is right there). Newsletter read state also clears on the /newsletter page.
|
||||||
|
|
@ -259,7 +271,7 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
}
|
}
|
||||||
}, [open, markAllRead, markNewsletterRead]);
|
}, [open, markAllRead, markNewsletterRead]);
|
||||||
|
|
||||||
const isLoading = crewLoading || activityLoading;
|
const isLoading = crewLoading || activityLoading || tradeLoading;
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
|
@ -373,10 +385,19 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && crewTotal === 0 && activity.length === 0 && newsletterIssues.length === 0 && (
|
{!isLoading && crewTotal === 0 && activity.length === 0 && newsletterIssues.length === 0 && tradeNotifs.length === 0 && (
|
||||||
<EmptyState label="Zaps, reactions, replies, newsletter drops and crew activity will show up here." />
|
<EmptyState label="Offers, trades, zaps, replies, newsletter drops and crew activity will show up here." />
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
|
{/* Buying/selling steps first — they're the most time-sensitive. */}
|
||||||
|
{tradeNotifs.map((n) => (
|
||||||
|
<TradeNotificationCard
|
||||||
|
key={n.id}
|
||||||
|
notification={n}
|
||||||
|
unread={n.createdAt > seenAt}
|
||||||
|
onNavigate={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{joinRequests.map(({ request, crew, endorsements }) => (
|
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||||
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
|
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useActivityNotifications, type ActivityNotification } from '@/hooks/useActivityNotifications';
|
import { useActivityNotifications, type ActivityNotification } from '@/hooks/useActivityNotifications';
|
||||||
|
import { useTradeNotifications, tradeNotificationText, type TradeNotification } from '@/hooks/useTradeNotifications';
|
||||||
import { useCrewNotifications } from '@/hooks/useCrewNotifications';
|
import { useCrewNotifications } from '@/hooks/useCrewNotifications';
|
||||||
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
|
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
|
||||||
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
|
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
|
||||||
|
|
@ -190,6 +191,23 @@ function dmCandidate(envelope: DmEnvelope): Candidate {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tradeCandidate(n: TradeNotification): Candidate {
|
||||||
|
const title =
|
||||||
|
n.type === 'trade-request'
|
||||||
|
? 'Confirm a trade'
|
||||||
|
: n.type === 'counter'
|
||||||
|
? 'Counter-offer'
|
||||||
|
: 'New offer on your listing';
|
||||||
|
return {
|
||||||
|
key: `trade:${n.id}`,
|
||||||
|
source: 'trades',
|
||||||
|
createdAt: n.createdAt,
|
||||||
|
title,
|
||||||
|
body: tradeNotificationText(n),
|
||||||
|
path: `/listing/${n.listingPubkey}/${n.listingIdentifier}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function newsletterCandidate(issue: SkateNewsletterIssue): Candidate {
|
function newsletterCandidate(issue: SkateNewsletterIssue): Candidate {
|
||||||
return {
|
return {
|
||||||
key: `newsletter:${issue.id}`,
|
key: `newsletter:${issue.id}`,
|
||||||
|
|
@ -239,12 +257,14 @@ export function OsNotificationBridge() {
|
||||||
|
|
||||||
function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
|
function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
||||||
const { invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
|
const { invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
|
||||||
useCrewNotifications();
|
useCrewNotifications();
|
||||||
const { data: issues = [], isLoading: newsletterLoading } = useNewsletterIssues();
|
const { data: issues = [], isLoading: newsletterLoading } = useNewsletterIssues();
|
||||||
const { isSubscribed } = useNewsletterSubscription();
|
const { isSubscribed } = useNewsletterSubscription();
|
||||||
const { data: envelopes = [], isFetched: dmFetched } = useDmEnvelopes(true);
|
const { data: envelopes = [], isFetched: dmFetched } = useDmEnvelopes(true);
|
||||||
|
const { notifications: tradeNotifs, isLoading: tradeLoading } = useTradeNotifications(user?.pubkey);
|
||||||
|
|
||||||
// Loaded lazily and mutated directly (never state) so bookkeeping can't cause
|
// Loaded lazily and mutated directly (never state) so bookkeeping can't cause
|
||||||
// a render loop.
|
// a render loop.
|
||||||
|
|
@ -275,6 +295,7 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
|
||||||
[issues, isSubscribed],
|
[issues, isSubscribed],
|
||||||
);
|
);
|
||||||
const dmItems = useMemo(() => envelopes.map(dmCandidate), [envelopes]);
|
const dmItems = useMemo(() => envelopes.map(dmCandidate), [envelopes]);
|
||||||
|
const tradeItems = useMemo(() => tradeNotifs.map(tradeCandidate), [tradeNotifs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const seen = seenRef.current;
|
const seen = seenRef.current;
|
||||||
|
|
@ -285,6 +306,7 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
|
||||||
{ name: 'crew', ready: !crewLoading, items: crewItems },
|
{ name: 'crew', ready: !crewLoading, items: crewItems },
|
||||||
{ name: 'newsletter', ready: !newsletterLoading, items: newsletterItems },
|
{ name: 'newsletter', ready: !newsletterLoading, items: newsletterItems },
|
||||||
{ name: 'dms', ready: dmFetched, items: dmItems },
|
{ name: 'dms', ready: dmFetched, items: dmItems },
|
||||||
|
{ name: 'trades', ready: !tradeLoading, items: tradeItems },
|
||||||
];
|
];
|
||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
@ -326,10 +348,12 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
|
||||||
crewItems,
|
crewItems,
|
||||||
newsletterItems,
|
newsletterItems,
|
||||||
dmItems,
|
dmItems,
|
||||||
|
tradeItems,
|
||||||
activityLoading,
|
activityLoading,
|
||||||
crewLoading,
|
crewLoading,
|
||||||
newsletterLoading,
|
newsletterLoading,
|
||||||
dmFetched,
|
dmFetched,
|
||||||
|
tradeLoading,
|
||||||
prefs,
|
prefs,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
64
src/components/TradeNotificationCard.tsx
Normal file
64
src/components/TradeNotificationCard.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { ArrowLeftRight, Handshake, BadgeCheck } from 'lucide-react';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { tradeNotificationText, type TradeNotification } from '@/hooks/useTradeNotifications';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const TYPE_META: Record<
|
||||||
|
TradeNotification['type'],
|
||||||
|
{ icon: typeof Handshake; className: string; label: string }
|
||||||
|
> = {
|
||||||
|
offer: { icon: Handshake, className: 'bg-[#ccff00]/15 text-[#ccff00]', label: 'New offer' },
|
||||||
|
counter: { icon: ArrowLeftRight, className: 'bg-orange-500/15 text-orange-400', label: 'Counter-offer' },
|
||||||
|
'trade-request': { icon: BadgeCheck, className: 'bg-teal-500/15 text-teal-300', label: 'Confirm a trade' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A buying/selling step in the notification bell. */
|
||||||
|
export function TradeNotificationCard({
|
||||||
|
notification,
|
||||||
|
unread,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
notification: TradeNotification;
|
||||||
|
unread: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}) {
|
||||||
|
const author = useAuthor(notification.counterparty);
|
||||||
|
const name = author.data?.metadata?.name ?? genUserName(notification.counterparty);
|
||||||
|
const pic = author.data?.metadata?.picture;
|
||||||
|
const meta = TYPE_META[notification.type];
|
||||||
|
const Icon = meta.icon;
|
||||||
|
const to = `/listing/${notification.listingPubkey}/${notification.listingIdentifier}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 rounded-xl border p-3 transition-colors hover:border-[#ccff00]/40 hover:bg-white/[0.05]',
|
||||||
|
unread ? 'border-[#ccff00]/25 bg-[#ccff00]/[0.05]' : 'border-white/10 bg-white/[0.02]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn('flex h-9 w-9 shrink-0 items-center justify-center rounded-xl', meta.className)}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">{meta.label}</p>
|
||||||
|
<p className="flex items-center gap-1.5 text-sm leading-snug text-gray-200">
|
||||||
|
<Avatar className="h-4 w-4">
|
||||||
|
<AvatarImage src={pic} alt={name} />
|
||||||
|
<AvatarFallback className="text-[8px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="font-bold text-white">{name}</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-400">{tradeNotificationText(notification)}</p>
|
||||||
|
<p className="mt-1 text-[10px] font-bold uppercase tracking-wide text-[#ccff00]">
|
||||||
|
{notification.type === 'trade-request' ? 'Open listing' : 'Review the offer'} →
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,16 +6,21 @@ import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marketplace offers — a buyer's structured proposal ("I'll give X for this"),
|
* Marketplace offers — a structured negotiation over a listing, documented in
|
||||||
* which the seller can accept or decline. SkateSpots-native, documented in
|
|
||||||
* `NIP.md`.
|
* `NIP.md`.
|
||||||
*
|
*
|
||||||
* Kind `30403` (addressable):
|
* Kind `30403` (addressable), one event per message:
|
||||||
* offer → d = `<buyer>:offer:<listing-d>`, a = listing, p = seller
|
* a = the listing (`30402:<pubkey>:<d>`)
|
||||||
* response → d = `<seller>:offer-response:<buyer>`, a = listing, p = buyer,
|
* p = the OTHER party
|
||||||
* e = the buyer's offer event id
|
* e = the message this one answers (a counter / an accept / a decline)
|
||||||
* state lives in a `status` tag: open | accepted | declined | withdrawn.
|
* role = 'offer' (sent by the buyer) | 'response' (sent by the seller)
|
||||||
|
* status = open | accepted | declined | withdrawn
|
||||||
|
* amount = <sats> SATS
|
||||||
|
* Every message uses a UNIQUE `d`, so the whole back-and-forth is a durable
|
||||||
|
* thread (both sides can counter as many times as they like). The current state
|
||||||
|
* of a negotiation is simply its newest message.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const OFFER_KIND = 30403;
|
export const OFFER_KIND = 30403;
|
||||||
|
|
||||||
export type OfferStatus = 'open' | 'accepted' | 'declined' | 'withdrawn';
|
export type OfferStatus = 'open' | 'accepted' | 'declined' | 'withdrawn';
|
||||||
|
|
@ -24,7 +29,7 @@ export type OfferRole = 'offer' | 'response';
|
||||||
export interface MarketplaceOffer {
|
export interface MarketplaceOffer {
|
||||||
id: string;
|
id: string;
|
||||||
role: OfferRole;
|
role: OfferRole;
|
||||||
/** Author: the buyer for an offer, the seller for a response. */
|
/** Author of this message (buyer for `offer`, seller for `response`). */
|
||||||
author: string;
|
author: string;
|
||||||
buyer: string;
|
buyer: string;
|
||||||
seller: string;
|
seller: string;
|
||||||
|
|
@ -35,8 +40,8 @@ export interface MarketplaceOffer {
|
||||||
currency?: string;
|
currency?: string;
|
||||||
status: OfferStatus;
|
status: OfferStatus;
|
||||||
message: string;
|
message: string;
|
||||||
/** The offer event id this response answers (responses only). */
|
/** The message this one answers (`e`), when it's a counter/accept/decline. */
|
||||||
offerId?: string;
|
replyTo?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +65,15 @@ export function parseOffer(event: NostrEvent): MarketplaceOffer | null {
|
||||||
const coord = parseListingCoord(a);
|
const coord = parseListingCoord(a);
|
||||||
if (!d || !a || !coord) return null;
|
if (!d || !a || !coord) return null;
|
||||||
|
|
||||||
const role: OfferRole = d.includes(':offer-response:') ? 'response' : 'offer';
|
// Explicit `role` tag wins; fall back to the legacy `d` marker.
|
||||||
|
const roleTag = tag(event, 'role');
|
||||||
|
const role: OfferRole =
|
||||||
|
roleTag === 'response' || roleTag === 'offer'
|
||||||
|
? roleTag
|
||||||
|
: d.includes(':offer-response:')
|
||||||
|
? 'response'
|
||||||
|
: 'offer';
|
||||||
|
|
||||||
const counterparty = tag(event, 'p');
|
const counterparty = tag(event, 'p');
|
||||||
const amountTag = event.tags.find(([n]) => n === 'amount');
|
const amountTag = event.tags.find(([n]) => n === 'amount');
|
||||||
const status = (tag(event, 'status') as OfferStatus) ?? 'open';
|
const status = (tag(event, 'status') as OfferStatus) ?? 'open';
|
||||||
|
|
@ -81,7 +94,7 @@ export function parseOffer(event: NostrEvent): MarketplaceOffer | null {
|
||||||
currency: amountTag?.[2],
|
currency: amountTag?.[2],
|
||||||
status,
|
status,
|
||||||
message: event.content,
|
message: event.content,
|
||||||
offerId: tag(event, 'e'),
|
replyTo: tag(event, 'e'),
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
@ -97,49 +110,92 @@ function dedupeNewest(events: NostrEvent[]): NostrEvent[] {
|
||||||
return [...byD.values()];
|
return [...byD.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All offers + responses for a listing. */
|
/** One negotiation between a buyer and the seller, oldest message first. */
|
||||||
|
export interface OfferThread {
|
||||||
|
buyer: string;
|
||||||
|
seller: string;
|
||||||
|
listingCoord: string;
|
||||||
|
messages: MarketplaceOffer[];
|
||||||
|
/** The message that currently represents the thread's state. */
|
||||||
|
latest: MarketplaceOffer;
|
||||||
|
/** Whose turn it is to answer (null when accepted/declined/withdrawn). */
|
||||||
|
awaiting: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toThreads(messages: MarketplaceOffer[]): OfferThread[] {
|
||||||
|
const byPair = new Map<string, MarketplaceOffer[]>();
|
||||||
|
for (const m of messages) {
|
||||||
|
if (!m.buyer || !m.seller) continue;
|
||||||
|
const key = `${m.listingCoord}|${m.buyer}`;
|
||||||
|
const list = byPair.get(key) ?? [];
|
||||||
|
list.push(m);
|
||||||
|
byPair.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threads: OfferThread[] = [];
|
||||||
|
for (const list of byPair.values()) {
|
||||||
|
const sorted = [...list].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
|
||||||
|
const latest = sorted[sorted.length - 1];
|
||||||
|
// `open` means the other party must answer; any terminal status ends it.
|
||||||
|
const awaiting =
|
||||||
|
latest.status === 'open'
|
||||||
|
? latest.author === latest.buyer
|
||||||
|
? latest.seller
|
||||||
|
: latest.buyer
|
||||||
|
: null;
|
||||||
|
threads.push({
|
||||||
|
buyer: latest.buyer,
|
||||||
|
seller: latest.seller,
|
||||||
|
listingCoord: latest.listingCoord,
|
||||||
|
messages: sorted,
|
||||||
|
latest,
|
||||||
|
awaiting,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return threads.sort((a, b) => b.latest.createdAt - a.latest.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every negotiation thread on a listing. */
|
||||||
export function useListingOffers(listing?: { pubkey: string; identifier: string }) {
|
export function useListingOffers(listing?: { pubkey: string; identifier: string }) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
const coord = listing ? `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}` : undefined;
|
const coord = listing ? `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}` : undefined;
|
||||||
|
|
||||||
return useQuery<{ offers: MarketplaceOffer[]; responses: MarketplaceOffer[] }>({
|
return useQuery<{ threads: OfferThread[] }>({
|
||||||
queryKey: ['listing-offers', coord, getQueryKeySuffix()],
|
queryKey: ['listing-offers', coord, getQueryKeySuffix()],
|
||||||
enabled: !!coord,
|
enabled: !!coord,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!coord) return { offers: [], responses: [] };
|
if (!coord) return { threads: [] };
|
||||||
const events = await querySkateContent(
|
const events = await querySkateContent(
|
||||||
[{ kinds: [OFFER_KIND], '#a': [coord], limit: 100 }],
|
[{ kinds: [OFFER_KIND], '#a': [coord], limit: 200 }],
|
||||||
{ signal: AbortSignal.timeout(8000) },
|
{ signal: AbortSignal.timeout(8000) },
|
||||||
);
|
);
|
||||||
const parsed = dedupeNewest(events)
|
const messages = dedupeNewest(events)
|
||||||
.map(parseOffer)
|
.map(parseOffer)
|
||||||
.filter((o): o is MarketplaceOffer => !!o);
|
.filter((o): o is MarketplaceOffer => !!o);
|
||||||
return {
|
return { threads: toThreads(messages) };
|
||||||
offers: parsed.filter((o) => o.role === 'offer'),
|
|
||||||
responses: parsed.filter((o) => o.role === 'response'),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Offers received by a seller (open, addressed to them). */
|
/** Negotiations where it's `pubkey`'s turn to answer (across every listing). */
|
||||||
export function useIncomingOffers(sellerPubkey: string | undefined) {
|
export function useIncomingOffers(pubkey: string | undefined) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
|
||||||
return useQuery<MarketplaceOffer[]>({
|
return useQuery<OfferThread[]>({
|
||||||
queryKey: ['incoming-offers', sellerPubkey, getQueryKeySuffix()],
|
queryKey: ['incoming-offers', pubkey, getQueryKeySuffix()],
|
||||||
enabled: !!sellerPubkey,
|
enabled: !!pubkey,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!sellerPubkey) return [];
|
if (!pubkey) return [];
|
||||||
const events = await querySkateContent(
|
const events = await querySkateContent(
|
||||||
[{ kinds: [OFFER_KIND], '#p': [sellerPubkey], limit: 100 }],
|
[{ kinds: [OFFER_KIND], '#p': [pubkey], limit: 200 }],
|
||||||
{ signal: AbortSignal.timeout(8000) },
|
{ signal: AbortSignal.timeout(8000) },
|
||||||
);
|
);
|
||||||
return dedupeNewest(events)
|
const messages = dedupeNewest(events)
|
||||||
.map(parseOffer)
|
.map(parseOffer)
|
||||||
.filter((o): o is MarketplaceOffer => !!o && o.role === 'offer' && o.status === 'open');
|
.filter((o): o is MarketplaceOffer => !!o);
|
||||||
|
return toThreads(messages).filter((t) => t.awaiting === pubkey);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -148,61 +204,85 @@ export function useOfferActions() {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { mutateAsync: publish } = useNostrPublish();
|
const { mutateAsync: publish } = useNostrPublish();
|
||||||
|
|
||||||
const createOffer = async ({
|
/**
|
||||||
|
* Publish one negotiation message. `d` is unique per message so the thread is
|
||||||
|
* append-only (a counter never overwrites the message it answers).
|
||||||
|
*/
|
||||||
|
const publishMessage = async ({
|
||||||
listing,
|
listing,
|
||||||
|
counterparty,
|
||||||
amount,
|
amount,
|
||||||
currency = 'SATS',
|
currency = 'SATS',
|
||||||
note,
|
note,
|
||||||
|
status = 'open',
|
||||||
|
replyTo,
|
||||||
}: {
|
}: {
|
||||||
listing: { pubkey: string; identifier: string };
|
listing: { pubkey: string; identifier: string };
|
||||||
|
/** The other party — tagged `p` so the message reaches them. */
|
||||||
|
counterparty: string;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
status?: OfferStatus;
|
||||||
|
replyTo?: string;
|
||||||
}) => {
|
}) => {
|
||||||
if (!user) throw new Error('Log in to make an offer.');
|
if (!user) throw new Error('Log in to make an offer.');
|
||||||
|
if (counterparty === user.pubkey) throw new Error('Pick the other party, not yourself.');
|
||||||
|
// A buyer's messages are `offer`s; the listing owner's are `response`s.
|
||||||
|
const role: OfferRole = user.pubkey === listing.pubkey ? 'response' : 'offer';
|
||||||
const coord = `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}`;
|
const coord = `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}`;
|
||||||
|
|
||||||
const tags: string[][] = [
|
const tags: string[][] = [
|
||||||
['d', `${user.pubkey}:offer:${listing.identifier}`],
|
['d', `${user.pubkey}:${role}:${listing.identifier}:${Date.now().toString(36)}`],
|
||||||
['a', coord],
|
['a', coord],
|
||||||
['p', listing.pubkey],
|
['role', role],
|
||||||
['status', 'open'],
|
['status', status],
|
||||||
|
['p', counterparty],
|
||||||
['alt', 'SkateSpots: marketplace offer'],
|
['alt', 'SkateSpots: marketplace offer'],
|
||||||
];
|
];
|
||||||
|
if (replyTo) tags.push(['e', replyTo]);
|
||||||
if (amount && amount > 0) tags.push(['amount', String(amount), currency]);
|
if (amount && amount > 0) tags.push(['amount', String(amount), currency]);
|
||||||
await publish({
|
|
||||||
kind: OFFER_KIND,
|
await publish({ kind: OFFER_KIND, content: note?.trim() || '', tags });
|
||||||
content: note?.trim() || '',
|
|
||||||
tags,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Seller responds to a buyer's offer (accept/decline), or buyer withdraws. */
|
/** Buyer: open the negotiation. */
|
||||||
const respondToOffer = async (offer: MarketplaceOffer, status: OfferStatus, note?: string) => {
|
const createOffer = (args: { listing: { pubkey: string; identifier: string }; amount?: number; currency?: string; note?: string }) =>
|
||||||
if (!user) throw new Error('Log in to respond.');
|
publishMessage({ ...args, counterparty: args.listing.pubkey });
|
||||||
const isBuyerWithdrawing = user.pubkey === offer.buyer && status === 'withdrawn';
|
|
||||||
|
|
||||||
const tags: string[][] = isBuyerWithdrawing
|
/** Either side answers with a new amount — the counter-offer volley. */
|
||||||
? [
|
const counterOffer = (args: {
|
||||||
['d', `${offer.buyer}:offer:${offer.listingIdentifier}`],
|
listing: { pubkey: string; identifier: string };
|
||||||
['a', offer.listingCoord],
|
previous: MarketplaceOffer;
|
||||||
['p', offer.seller],
|
amount?: number;
|
||||||
]
|
currency?: string;
|
||||||
: [
|
note?: string;
|
||||||
['d', `${user.pubkey}:offer-response:${offer.buyer}`],
|
}) =>
|
||||||
['a', offer.listingCoord],
|
publishMessage({
|
||||||
['p', offer.buyer],
|
listing: args.listing,
|
||||||
['e', offer.id],
|
counterparty: args.previous.author,
|
||||||
];
|
amount: args.amount,
|
||||||
tags.push(['status', status], ['alt', 'SkateSpots: marketplace offer']);
|
currency: args.currency,
|
||||||
const amountTag = offer.event.tags.find(([n]) => n === 'amount');
|
note: args.note,
|
||||||
if (amountTag && !isBuyerWithdrawing) tags.push(amountTag);
|
status: 'open',
|
||||||
|
replyTo: args.previous.id,
|
||||||
await publish({
|
|
||||||
kind: OFFER_KIND,
|
|
||||||
content: note?.trim() || offer.message,
|
|
||||||
tags,
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
return { createOffer, respondToOffer };
|
/** Accept / decline / withdraw — always answering the current message. */
|
||||||
|
const respondToOffer = (
|
||||||
|
previous: MarketplaceOffer,
|
||||||
|
status: Extract<OfferStatus, 'accepted' | 'declined' | 'withdrawn'>,
|
||||||
|
note?: string,
|
||||||
|
) =>
|
||||||
|
publishMessage({
|
||||||
|
listing: { pubkey: previous.listingPubkey, identifier: previous.listingIdentifier },
|
||||||
|
counterparty: previous.author,
|
||||||
|
amount: status === 'accepted' ? previous.amount : undefined,
|
||||||
|
currency: previous.currency,
|
||||||
|
note: note ?? previous.message,
|
||||||
|
status,
|
||||||
|
replyTo: previous.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { createOffer, counterOffer, respondToOffer };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
src/hooks/useTradeNotifications.ts
Normal file
81
src/hooks/useTradeNotifications.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useIncomingOffers, type MarketplaceOffer } from '@/hooks/useMarketplaceOffers';
|
||||||
|
import { useIncomingTradeRequests } from '@/hooks/useTradeAttestations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade/offer activity for the logged-in user — the buying/selling steps that
|
||||||
|
* deserve a nudge: a new offer on your listing, a counter-offer it's your turn
|
||||||
|
* to answer, and a request to confirm a completed trade.
|
||||||
|
*
|
||||||
|
* All of it is derived from events already addressed to the user, so it needs no
|
||||||
|
* extra infrastructure — the same events power the bell and the OS notifications.
|
||||||
|
*/
|
||||||
|
export type TradeNotificationType = 'offer' | 'counter' | 'trade-request';
|
||||||
|
|
||||||
|
export interface TradeNotification {
|
||||||
|
id: string;
|
||||||
|
type: TradeNotificationType;
|
||||||
|
createdAt: number;
|
||||||
|
/** The other party (buyer for an offer, the requester for a request). */
|
||||||
|
counterparty: string;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
listingPubkey: string;
|
||||||
|
listingIdentifier: string;
|
||||||
|
/** Present for offer/counter items, so the UI can act inline. */
|
||||||
|
offer?: MarketplaceOffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTradeNotifications(pubkey: string | undefined) {
|
||||||
|
const { data: incoming = [], isLoading: offersLoading } = useIncomingOffers(pubkey);
|
||||||
|
const { data: requests = [], isLoading: requestsLoading } = useIncomingTradeRequests(pubkey);
|
||||||
|
|
||||||
|
const notifications = useMemo<TradeNotification[]>(() => {
|
||||||
|
const out: TradeNotification[] = [];
|
||||||
|
|
||||||
|
// Offers / counter-offers where it is the user's turn to answer.
|
||||||
|
for (const thread of incoming) {
|
||||||
|
const latest = thread.latest;
|
||||||
|
out.push({
|
||||||
|
id: latest.id,
|
||||||
|
type: latest.replyTo ? 'counter' : 'offer',
|
||||||
|
createdAt: latest.createdAt,
|
||||||
|
counterparty: latest.author,
|
||||||
|
amount: latest.amount,
|
||||||
|
currency: latest.currency,
|
||||||
|
listingPubkey: latest.listingPubkey,
|
||||||
|
listingIdentifier: latest.listingIdentifier,
|
||||||
|
offer: latest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Please confirm our trade" requests (buyer → seller).
|
||||||
|
for (const r of requests) {
|
||||||
|
out.push({
|
||||||
|
id: r.event.id,
|
||||||
|
type: 'trade-request',
|
||||||
|
createdAt: r.event.created_at,
|
||||||
|
counterparty: r.requestor,
|
||||||
|
listingPubkey: r.listing.pubkey,
|
||||||
|
listingIdentifier: r.listing.identifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
}, [incoming, requests]);
|
||||||
|
|
||||||
|
return { notifications, isLoading: offersLoading || requestsLoading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tradeNotificationText(n: TradeNotification): string {
|
||||||
|
switch (n.type) {
|
||||||
|
case 'offer':
|
||||||
|
return n.amount ? `offered ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'made an offer';
|
||||||
|
case 'counter':
|
||||||
|
return n.amount ? `countered with ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'sent a counter-offer';
|
||||||
|
case 'trade-request':
|
||||||
|
return 'asked you to confirm a trade';
|
||||||
|
default:
|
||||||
|
return 'needs your attention';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
* nothing is requested from the OS until the user turns them on in Settings.
|
* nothing is requested from the OS until the user turns them on in Settings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type NotificationCategory = 'dms' | 'crew' | 'social' | 'newsletter' | 'reminders';
|
export type NotificationCategory = 'dms' | 'crew' | 'social' | 'newsletter' | 'reminders' | 'trades';
|
||||||
|
|
||||||
export interface NotificationPrefs {
|
export interface NotificationPrefs {
|
||||||
/** Master switch. Off until the user explicitly enables notifications. */
|
/** Master switch. Off until the user explicitly enables notifications. */
|
||||||
|
|
@ -22,6 +22,8 @@ export interface NotificationPrefs {
|
||||||
social: boolean;
|
social: boolean;
|
||||||
/** New SkateSpots newsletter drops (subscribers only). */
|
/** New SkateSpots newsletter drops (subscribers only). */
|
||||||
newsletter: boolean;
|
newsletter: boolean;
|
||||||
|
/** Marketplace offers, counter-offers and trade confirmations. */
|
||||||
|
trades: boolean;
|
||||||
/** Scheduled event reminders (Android app — fires even when the app is closed). */
|
/** Scheduled event reminders (Android app — fires even when the app is closed). */
|
||||||
reminders: boolean;
|
reminders: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -34,6 +36,7 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
||||||
crew: true,
|
crew: true,
|
||||||
social: true,
|
social: true,
|
||||||
newsletter: true,
|
newsletter: true,
|
||||||
|
trades: true,
|
||||||
reminders: true,
|
reminders: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -47,5 +50,6 @@ export const NOTIFICATION_CATEGORIES: Array<{
|
||||||
{ key: 'crew', label: 'Crews', hint: 'Invites, join requests and membership confirmations' },
|
{ key: 'crew', label: 'Crews', hint: 'Invites, join requests and membership confirmations' },
|
||||||
{ key: 'social', label: 'Zaps, replies & mentions', hint: 'Reactions, reposts, zaps, replies and event updates' },
|
{ key: 'social', label: 'Zaps, replies & mentions', hint: 'Reactions, reposts, zaps, replies and event updates' },
|
||||||
{ key: 'newsletter', label: 'Newsletter', hint: 'New SkateSpots drops (subscribers only)' },
|
{ key: 'newsletter', label: 'Newsletter', hint: 'New SkateSpots drops (subscribers only)' },
|
||||||
|
{ key: 'trades', label: 'Offers & trades', hint: 'Offers, counter-offers and trade confirmations' },
|
||||||
{ key: 'reminders', label: 'Event reminders', hint: "A nudge an hour before events you're going to (Android app)" },
|
{ key: 'reminders', label: 'Event reminders', hint: "A nudge an hour before events you're going to (Android app)" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue