feat(marketplace): trade confirmation requests (kind 31872) + fix boot smoke test
Marketplace:
- Buyer can 'Ask seller to confirm' a sold listing -> publishes a 31872
Attestation Request (d/a/p), which the seller fulfils in one tap
- Seller sees incoming requests in the DM thread ('Confirm this sale?') and the
fulfilling 31871 carries the spec's 'request' tag
- useTradeAttestationRequest / useIncomingTradeRequests / useListingTradeRequest
- NIP.md documents 31872 and states escrow is intentionally NOT implemented
(coco-cashu has no P2PK/HTLC locking, wallet is native-only, and release-on-
attestation needs a witness) so nothing looks trustless that isn't
Hardening:
- scripts/smoke-test.mjs navigated to /settings etc. WITHOUT a hash, so with
HashRouter it only ever loaded the homepage and reported PASS. It now puts the
route in the hash, covers settings/stash/collections/stats/marketplace, and
fails on the 'This page slammed' error boundary (not just boot failure)
This commit is contained in:
parent
4e17852df3
commit
b91df1b4b0
5 changed files with 340 additions and 12 deletions
56
NIP.md
56
NIP.md
|
|
@ -3125,6 +3125,37 @@ cannot observe off-app (cash/trade) settlement.
|
|||
`31871` is addressable, re-publishing the same `d` with `["s","revoked"]`
|
||||
retracts an attestation (e.g. a disputed or cancelled deal).
|
||||
|
||||
## Attestation Request (kind 31872)
|
||||
|
||||
When a buyer's deal wasn't recorded by the seller, the buyer publishes a `31872`
|
||||
asking the seller to attest it. The seller's client surfaces the request and
|
||||
fulfils it in one tap, publishing a `31871` that carries the spec's `request` tag
|
||||
pointing back at the `31872`.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31872,
|
||||
"tags": [
|
||||
["d", "<buyer_pubkey>:trade-request:<listing-d>"],
|
||||
["a", "30402:<seller>:<listing-d>"],
|
||||
["p", "<seller_pubkey>"],
|
||||
["alt", "SkateSpots: trade confirmation requested"]
|
||||
],
|
||||
"content": "Please confirm our trade for “Deck”."
|
||||
}
|
||||
```
|
||||
|
||||
| Tag | Meaning |
|
||||
| --- | --- |
|
||||
| `d` | `<requestor>:trade-request:<listing-d>` — re-requesting replaces the prior one |
|
||||
| `a` | The listing (Assertion Event) being attested |
|
||||
| `p` | The requested **attestor** (the seller / listing author) |
|
||||
| `request` (on the reply) | On the fulfilling `31871`: `31872:<requestor>:<d>` |
|
||||
|
||||
Only the listing author can attest a sale, so the UI only prompts a seller to
|
||||
fulfil requests whose `a` author is them, and hides requests they've already
|
||||
answered (they have a `31871` for that listing).
|
||||
|
||||
## Reputation semantics
|
||||
|
||||
- **sales** — listings where the attestor is the listing author
|
||||
|
|
@ -3133,10 +3164,29 @@ retracts an attestation (e.g. a disputed or cancelled deal).
|
|||
- Consumers **trust-weight counterparties** with the existing web-of-trust score
|
||||
(as with Scene vouches) so fresh-account rings cannot inflate reputation.
|
||||
|
||||
## Escrow (planned — not implemented)
|
||||
|
||||
The NIP allows a `31872` to carry a `["cashu_token", "<token>"]`: a Cashu token
|
||||
locked to spending conditions (P2PK/HTLC) so funds release when the requested
|
||||
attestation is observed. **We do not implement this yet**, and the client accepts
|
||||
an optional `cashu_token` only as a passthrough (no locking). Fully shipping it
|
||||
needs:
|
||||
|
||||
1. **Locked tokens** — `@cashu/cashu-ts` can create P2PK/HTLC outputs, but our
|
||||
wallet (`coco-cashu-core`) only exposes plain `send()`. Escrow would bypass
|
||||
coco and use `cashu-ts` directly with the same seed.
|
||||
2. **Native only** — the bundled Cashu wallet is gated to Capacitor builds
|
||||
(`useCashu` returns early on web, where mints are CORS-blocked), so an escrow
|
||||
UI can only run in the app.
|
||||
3. **A release witness** — Cashu conditions lock to a key or a hash preimage, not
|
||||
to "a Nostr event was seen". Releasing *on attestation* requires a trusted
|
||||
witness/oracle that watches for the `31871` and releases. Without one, a token
|
||||
locked to the seller's key is prepayment, not escrow.
|
||||
|
||||
Until all three exist, escrow stays off to avoid a payment flow that looks
|
||||
trustless but isn't.
|
||||
|
||||
## Future (from the NIP)
|
||||
|
||||
- `31872` **Attestation Request** — a buyer can request the seller's attestation;
|
||||
it carries a `cashu_token` lockable to spending conditions (HTLC/P2PK) → a
|
||||
Cashu-based escrow / release-on-attestation flow.
|
||||
- `31873` **Attestor Recommendation** / `11871` **Proficiency** — trusted-trader
|
||||
endorsements and attestor discovery.
|
||||
|
|
|
|||
|
|
@ -122,25 +122,43 @@ await send('Page.enable');
|
|||
const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }))?.result?.result?.value;
|
||||
|
||||
// ── boot each route (lazy route chunks can fail independently) ──────────────
|
||||
const ROUTES = ['/', '/leaderboards', '/bounties', '/messages/group/abc123'];
|
||||
// NOTE: the app uses HashRouter, so the route MUST be in the hash — a bare
|
||||
// `/settings` path loads index.html with no hash and silently renders the
|
||||
// homepage, which made this test pass without exercising those chunks.
|
||||
const ROUTES = [
|
||||
'/',
|
||||
'/marketplace',
|
||||
'/leaderboards',
|
||||
'/bounties',
|
||||
'/settings',
|
||||
'/stash',
|
||||
'/collections',
|
||||
'/stats',
|
||||
'/messages/group/abc123',
|
||||
];
|
||||
const problems = [];
|
||||
let lastText = '';
|
||||
|
||||
for (const route of ROUTES) {
|
||||
const url = appUrl.replace(/\/$/, '') + route;
|
||||
const url = appUrl.replace(/\/$/, '') + '/#' + route;
|
||||
await send('Page.navigate', { url });
|
||||
|
||||
let mounted = false;
|
||||
let stable = 0;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
lastText = (await evalJS('document.body ? document.body.innerText : ""')) || '';
|
||||
const kids = await evalJS('document.getElementById("root")?.children.length ?? 0');
|
||||
if (kids > 0) { mounted = true; break; }
|
||||
if (/failed to start/i.test(lastText)) break;
|
||||
await sleep(400);
|
||||
if (kids > 0) mounted = true;
|
||||
// Stop early on a hard crash; otherwise require the page to stay rendered
|
||||
// for a few polls so a lazy route chunk has time to load (and, if broken,
|
||||
// to fall through to the error boundary).
|
||||
if (/failed to start|this page slammed/i.test(lastText)) break;
|
||||
if (mounted) { stable++; if (stable >= 3) break; }
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
const crashed = /failed to start/i.test(lastText);
|
||||
const crashed = /failed to start|this page slammed/i.test(lastText);
|
||||
if (!mounted) problems.push(`${route}: #root never mounted`);
|
||||
if (crashed) problems.push(`${route}: crash screen`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { useSellerInfo } from '@/hooks/useSellerInfo';
|
|||
import { useTrustScore } from '@/hooks/useTrustScore';
|
||||
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||
import { useListingTrade, useTradeAttestation, useTradeReputation } from '@/hooks/useTradeAttestations';
|
||||
import { useListingTrade, useTradeAttestation, useTradeReputation, useListingTradeRequest, useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
|
||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
|
|
@ -53,6 +53,7 @@ import {
|
|||
RotateCcw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { MarketplaceItem } from '@/lib/types';
|
||||
|
|
@ -97,6 +98,8 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
const { data: trade } = useListingTrade(listingRef, user?.pubkey);
|
||||
const { data: sellerRep } = useTradeReputation(item.pubkey);
|
||||
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
||||
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
|
||||
const { publishRequest, isPending: requestingTrade } = useTradeAttestationRequest();
|
||||
|
||||
// Format price display
|
||||
const priceDisplay = item.price
|
||||
|
|
@ -200,6 +203,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
(ev) => ev.pubkey === item.pubkey && ev.tags.some(([t, v]) => t === 'p' && v === user.pubkey),
|
||||
);
|
||||
const iConfirmedTrade = !!trade?.iConfirmed;
|
||||
const iRequestedConfirmation = !!requestState?.iRequested;
|
||||
|
||||
const handleConfirmTrade = async () => {
|
||||
if (!user) return;
|
||||
|
|
@ -215,6 +219,27 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
}
|
||||
};
|
||||
|
||||
const handleRequestConfirmation = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
await publishRequest({
|
||||
listing: listingRef,
|
||||
attestor: item.pubkey,
|
||||
note: `Please confirm our trade for “${item.title}”.`,
|
||||
});
|
||||
toast({
|
||||
title: 'Confirmation requested',
|
||||
description: 'The seller can now confirm the sale and build both reputations.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not send the request',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRelist = async () => {
|
||||
try {
|
||||
await relist(item.event);
|
||||
|
|
@ -500,6 +525,25 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{/* Buyer: ask the seller to record the sale (kind 31872 request) */}
|
||||
{user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRequestConfirmation}
|
||||
disabled={requestingTrade}
|
||||
className="w-full gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||
>
|
||||
{requestingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
Ask seller to confirm
|
||||
</Button>
|
||||
)}
|
||||
{iRequestedConfirmation && !iConfirmedTrade && (
|
||||
<p className="text-xs text-center text-gray-500">
|
||||
Confirmation requested — waiting on the seller.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Contact button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
|||
*/
|
||||
export const TRADE_ATTESTATION_KIND = 31871;
|
||||
|
||||
/**
|
||||
* Attestation Request (kind 31872) — the request side of the flow. A buyer
|
||||
* whose deal wasn't recorded by the seller can ask them to publish a `31871`;
|
||||
* the seller's client surfaces it and fulfils it in one tap.
|
||||
* See NIP.md → "Trade Attestations".
|
||||
*/
|
||||
export const TRADE_REQUEST_KIND = 31872;
|
||||
|
||||
export const TRADE_METHODS = {
|
||||
cash: 'Cash',
|
||||
lightning: 'Lightning',
|
||||
|
|
@ -57,6 +65,17 @@ export function isTradeAttestation(ev: NostrEvent): boolean {
|
|||
return ev.tags.some(([t]) => t === 'p');
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this kind-31872 event a *trade* request? Same marker as attestations: a
|
||||
* 30402 assertion (`a`) plus a requested attestor (`p`).
|
||||
*/
|
||||
export function isTradeRequest(ev: NostrEvent): boolean {
|
||||
if (ev.kind !== TRADE_REQUEST_KIND) return false;
|
||||
const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]);
|
||||
if (!coord || coord.kind !== MARKETPLACE_ITEM_KIND) return false;
|
||||
return ev.tags.some(([t]) => t === 'p');
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a trade attestation (kind 31871, addressable).
|
||||
*
|
||||
|
|
@ -75,6 +94,7 @@ export function useTradeAttestation() {
|
|||
amountSats,
|
||||
state = 'valid',
|
||||
note,
|
||||
requestCoord,
|
||||
}: {
|
||||
/** The listing being attested. */
|
||||
listing: { pubkey: string; identifier: string };
|
||||
|
|
@ -85,6 +105,8 @@ export function useTradeAttestation() {
|
|||
amountSats?: number;
|
||||
state?: 'verifying' | 'valid' | 'revoked';
|
||||
note?: string;
|
||||
/** The `31872` request coord this attestation fulfils, if any (spec `request` tag). */
|
||||
requestCoord?: string;
|
||||
}) => {
|
||||
if (!user) throw new Error('You must be logged in.');
|
||||
if (counterparty === user.pubkey) throw new Error('Pick the other party, not yourself.');
|
||||
|
|
@ -102,6 +124,7 @@ export function useTradeAttestation() {
|
|||
if (typeof amountSats === 'number' && amountSats > 0) {
|
||||
tags.push(['amount', String(Math.round(amountSats)), 'SATS']);
|
||||
}
|
||||
if (requestCoord) tags.push(['request', requestCoord]);
|
||||
|
||||
const name = user.pubkey.slice(0, 8);
|
||||
const content =
|
||||
|
|
@ -306,3 +329,141 @@ export function useListingTrade(
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a trade confirmation request (kind 31872, addressable).
|
||||
*
|
||||
* A buyer who completed a deal the seller hasn't recorded asks the seller (the
|
||||
* listing author) to publish a `31871`. `d` is derived from the listing + the
|
||||
* viewer, so re-requesting replaces the previous request.
|
||||
*/
|
||||
export function useTradeAttestationRequest() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publish, isPending } = useNostrPublish();
|
||||
|
||||
const publishRequest = async ({
|
||||
listing,
|
||||
attestor,
|
||||
note,
|
||||
cashuToken,
|
||||
}: {
|
||||
/** The listing the deal was for. */
|
||||
listing: { pubkey: string; identifier: string };
|
||||
/** The party asked to attest — normally the seller (listing author). */
|
||||
attestor: string;
|
||||
note?: string;
|
||||
/**
|
||||
* Optional escrowed Cashu token (spec `cashu_token`). Only relevant when
|
||||
* you created and locked one yourself; a plain token is not escrow.
|
||||
*/
|
||||
cashuToken?: string;
|
||||
}) => {
|
||||
if (!user) throw new Error('You must be logged in.');
|
||||
if (attestor === user.pubkey) throw new Error('Pick the other party, not yourself.');
|
||||
|
||||
const coord = `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}`;
|
||||
const tags: string[][] = [
|
||||
['d', `${user.pubkey}:trade-request:${listing.identifier}`],
|
||||
['a', coord],
|
||||
['p', attestor],
|
||||
['alt', 'SkateSpots: trade confirmation requested'],
|
||||
];
|
||||
if (cashuToken?.trim()) tags.push(['cashu_token', cashuToken.trim()]);
|
||||
|
||||
await publish({
|
||||
kind: TRADE_REQUEST_KIND,
|
||||
content: note?.trim() || 'Please confirm our trade so it counts for reputation.',
|
||||
tags,
|
||||
});
|
||||
};
|
||||
|
||||
return { publishRequest, isPending };
|
||||
}
|
||||
|
||||
export interface IncomingTradeRequest {
|
||||
coord: string;
|
||||
listing: { pubkey: string; identifier: string };
|
||||
requestor: string;
|
||||
note: string;
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests naming the viewer as the attestor (i.e. they are the seller) that
|
||||
* they have not yet fulfilled. Drives the "confirm this sale" prompt.
|
||||
*/
|
||||
export function useIncomingTradeRequests(viewerPubkey: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<IncomingTradeRequest[]>({
|
||||
queryKey: ['incoming-trade-requests', viewerPubkey],
|
||||
enabled: !!viewerPubkey,
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async (c) => {
|
||||
if (!viewerPubkey) return [];
|
||||
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||||
const [requests, mine] = await Promise.all([
|
||||
nostr.query([{ kinds: [TRADE_REQUEST_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }),
|
||||
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }),
|
||||
]);
|
||||
|
||||
// Requests the viewer already fulfilled (they attested that listing).
|
||||
const fulfilled = new Set(
|
||||
mine.filter(isTradeAttestation).map((e) => e.tags.find(([t]) => t === 'a')?.[1]).filter(Boolean),
|
||||
);
|
||||
|
||||
const out: IncomingTradeRequest[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const ev of requests.filter(isTradeRequest)) {
|
||||
if (ev.pubkey === viewerPubkey) continue;
|
||||
const coord = ev.tags.find(([t]) => t === 'a')?.[1];
|
||||
const parsed = parseListingCoord(coord);
|
||||
// Only the listing author (seller) can attest the sale.
|
||||
if (!coord || !parsed || parsed.pubkey !== viewerPubkey) continue;
|
||||
if (fulfilled.has(coord) || seen.has(coord)) continue;
|
||||
seen.add(coord);
|
||||
out.push({
|
||||
coord,
|
||||
listing: { pubkey: parsed.pubkey, identifier: parsed.identifier },
|
||||
requestor: ev.pubkey,
|
||||
note: ev.content,
|
||||
event: ev,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* I added a new trade request for a listing. Replaces any previous one (same `d`).
|
||||
*
|
||||
* For a plain token this is just "a token you happen to include". Real escrow
|
||||
* needs the token locked to spending conditions (P2PK/HTLC) plus a release
|
||||
* witness — see NIP.md → Trade Attestations → Escrow (not yet implemented).
|
||||
*/
|
||||
export function useListingTradeRequest(
|
||||
listing: { pubkey: string; identifier: string } | undefined,
|
||||
viewerPubkey: string | undefined,
|
||||
) {
|
||||
const { nostr } = useNostr();
|
||||
const coord = listing ? `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}` : undefined;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['listing-trade-request', coord, viewerPubkey],
|
||||
enabled: !!coord,
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async (c) => {
|
||||
if (!coord) return { requests: [] as NostrEvent[], iRequested: false };
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||||
const events = (
|
||||
await nostr.query([{ kinds: [TRADE_REQUEST_KIND], '#a': [coord], limit: 50 }], { signal })
|
||||
).filter(isTradeRequest);
|
||||
return {
|
||||
requests: events,
|
||||
iRequested: !!viewerPubkey && events.some((e) => e.pubkey === viewerPubkey),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { useToast } from '@/hooks/useToast';
|
|||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages';
|
||||
import { usePendingTradeConfirmations, useTradeAttestation } from '@/hooks/useTradeAttestations';
|
||||
import { usePendingTradeConfirmations, useTradeAttestation, useIncomingTradeRequests } from '@/hooks/useTradeAttestations';
|
||||
import { useDMRead } from '@/hooks/useDMRead';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
AlertCircle,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
Handshake,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
|
||||
|
|
@ -105,6 +106,7 @@ export default function ConversationPage() {
|
|||
const { data: messages, isLoading, error, refetch, isRefetching } = useConversation(otherPubkey || '');
|
||||
const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage();
|
||||
const { data: pendingTrades } = usePendingTradeConfirmations(user?.pubkey);
|
||||
const { data: incomingRequests } = useIncomingTradeRequests(user?.pubkey);
|
||||
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
||||
const { markRead } = useDMRead();
|
||||
const author = useAuthor(otherPubkey || undefined);
|
||||
|
|
@ -171,7 +173,36 @@ export default function ConversationPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// The other party asked us (the seller) to record a sale (kind 31872).
|
||||
const incomingRequest = otherPubkey
|
||||
? incomingRequests?.find((r) => r.requestor === otherPubkey)
|
||||
: undefined;
|
||||
|
||||
const handleFulfillRequest = async () => {
|
||||
if (!incomingRequest || !otherPubkey) return;
|
||||
try {
|
||||
await publishTrade({
|
||||
listing: incomingRequest.listing,
|
||||
role: 'sold',
|
||||
counterparty: otherPubkey,
|
||||
requestCoord: incomingRequest.coord,
|
||||
note: `Sold to ${displayName}.`,
|
||||
});
|
||||
toast({
|
||||
title: 'Sale confirmed',
|
||||
description: `${displayName} can now confirm their side.`,
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not confirm the sale',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
|
|
@ -292,6 +323,30 @@ export default function ConversationPage() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Incoming request: the other party asked us (seller) to record the sale */}
|
||||
{incomingRequest && (
|
||||
<Card className="mb-3 flex-shrink-0 border-2 border-[#ff6600]/40 bg-[#ff6600]/5">
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<Handshake className="h-5 w-5 shrink-0 text-[#ff6600]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-white">Confirm this sale?</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{displayName} asked you to record it. Confirming builds reputation for both of you.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleFulfillRequest}
|
||||
disabled={confirmingTrade}
|
||||
className="shrink-0 gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{confirmingTrade ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Handshake className="h-3.5 w-3.5" />}
|
||||
Confirm sale
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Messages Area */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto py-3 sm:py-4 space-y-2 sm:space-y-3 min-h-0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue