- First-visit language auto-detect (Spanish devices land in es; stored choice wins). - Sasquatch AI replies in the app language (skate trick names kept English). - i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t() key missing from en/es or any en/es key drift. - ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace, trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters. - Policy: only skate TRICK names stay English; user-generated content untouched.
339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { nip19 } from 'nostr-tools';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Input } from '@/components/ui/input';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import {
|
|
useOfferActions,
|
|
useListingOffers,
|
|
type MarketplaceOffer,
|
|
type OfferThread,
|
|
} from '@/hooks/useMarketplaceOffers';
|
|
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
|
|
import { saveReceipt } from '@/lib/receipts';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import type { MarketplaceItem } from '@/lib/types';
|
|
import { PayWithSats } from '@/components/PayWithSats';
|
|
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
type TFunc = (key: string, params?: Record<string, string | number>) => string;
|
|
|
|
function PeerName({ pubkey }: { pubkey: string }) {
|
|
const author = useAuthor(pubkey);
|
|
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
|
return (
|
|
<Link to={`/${nip19.npubEncode(pubkey)}`} className="font-bold text-white hover:text-[#ccff00]">
|
|
{name}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function statusBadge(status: MarketplaceOffer['status'], t: TFunc) {
|
|
switch (status) {
|
|
case 'accepted':
|
|
return <Badge className="bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30">{t('marketUi.offers.accepted')}</Badge>;
|
|
case 'declined':
|
|
return <Badge className="bg-red-500/15 text-red-400 border border-red-500/30">{t('marketUi.offers.declined')}</Badge>;
|
|
case 'withdrawn':
|
|
return <Badge variant="secondary" className="bg-gray-700 text-gray-400">{t('marketUi.offers.withdrawn')}</Badge>;
|
|
default:
|
|
return <Badge className="bg-orange-500/15 text-orange-400 border border-orange-500/30">{t('marketUi.offers.open')}</Badge>;
|
|
}
|
|
}
|
|
|
|
function amountLabel(m: MarketplaceOffer, t: TFunc): string {
|
|
const parts: string[] = [];
|
|
if (m.amount) parts.push(`${m.amount.toLocaleString()} ${m.currency ?? 'SATS'}`);
|
|
if (m.offerItem) parts.push(m.offerItem);
|
|
return parts.length ? parts.join(' + ') : t('marketUi.offers.noAmount');
|
|
}
|
|
|
|
/**
|
|
* Offers for a listing, as a **negotiation thread**. Either side can accept,
|
|
* 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 }) {
|
|
const { t } = useTranslation();
|
|
const { user, metadata } = useCurrentUser();
|
|
const { data } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier });
|
|
const { respondToOffer, counterOffer } = useOfferActions();
|
|
const { mutateAsync: sendDM } = useSendDirectMessage();
|
|
const { toast } = useToast();
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [counterFor, setCounterFor] = useState<string | null>(null);
|
|
const [counterAmount, setCounterAmount] = useState('');
|
|
const [counterItem, setCounterItem] = useState('');
|
|
const [payFor, setPayFor] = useState<OfferThread | null>(null);
|
|
const [paidThreads, setPaidThreads] = useState<Set<string>>(() => new Set());
|
|
|
|
if (!user || !data) return null;
|
|
|
|
const mine = data.threads.filter((t) => t.buyer === user.pubkey || t.seller === user.pubkey);
|
|
if (mine.length === 0) return null;
|
|
|
|
const nudge = async (to: string, content: string) => {
|
|
try {
|
|
await sendDM({ recipients: to, content });
|
|
} catch {
|
|
/* DM is a best-effort extra nudge; the event itself already carries it */
|
|
}
|
|
};
|
|
|
|
const respond = async (thread: OfferThread, status: 'accepted' | 'declined') => {
|
|
setBusy(thread.latest.id);
|
|
try {
|
|
// On accept, attach my Lightning address (if I have one) so the buyer can
|
|
// pay in-app. If I have none, their side falls back to my profile `lud16`.
|
|
const myLud16 = metadata?.lud16?.trim();
|
|
await respondToOffer(
|
|
thread.latest,
|
|
status,
|
|
undefined,
|
|
status === 'accepted' && myLud16 ? { pointer: myLud16, kind: 'lnaddress' } : undefined,
|
|
);
|
|
await nudge(
|
|
thread.latest.author,
|
|
status === 'accepted'
|
|
? t('marketUi.offers.dmAccepted')
|
|
: t('marketUi.offers.dmDeclined'),
|
|
);
|
|
toast({ title: status === 'accepted' ? t('marketUi.offers.offerAccepted') : t('marketUi.offers.offerDeclined') });
|
|
} catch (e) {
|
|
toast({
|
|
title: t('marketUi.offers.respondFailed'),
|
|
description: e instanceof Error ? e.message : t('marketUi.offers.somethingWrong'),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const withdraw = async (thread: OfferThread) => {
|
|
setBusy(thread.latest.id);
|
|
try {
|
|
await respondToOffer(thread.latest, 'withdrawn');
|
|
toast({ title: t('marketUi.offers.offerWithdrawn') });
|
|
} catch (e) {
|
|
toast({
|
|
title: t('marketUi.offers.withdrawFailed'),
|
|
description: e instanceof Error ? e.message : t('marketUi.offers.somethingWrong'),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const sendCounter = async (thread: OfferThread) => {
|
|
const amount = Number(counterAmount);
|
|
const hasAmount = counterAmount.trim() !== '' && Number.isFinite(amount) && amount > 0;
|
|
if (!hasAmount && !counterItem.trim()) {
|
|
toast({ title: t('marketUi.offers.enterAmountOrItem'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
setBusy(thread.latest.id);
|
|
try {
|
|
await counterOffer({
|
|
listing: { pubkey: item.pubkey, identifier: item.identifier },
|
|
previous: thread.latest,
|
|
amount: hasAmount ? amount : undefined,
|
|
offerItem: counterItem,
|
|
});
|
|
const what = [hasAmount ? `${amount.toLocaleString()} sats` : null, counterItem.trim() || null]
|
|
.filter(Boolean)
|
|
.join(' + ');
|
|
await nudge(thread.latest.author, `↩️ ${t('marketUi.offers.counterDm')}: ${what}`);
|
|
toast({ title: t('marketUi.offers.counterSent'), description: t('marketUi.offers.counterSentBody') });
|
|
setCounterFor(null);
|
|
setCounterAmount('');
|
|
setCounterItem('');
|
|
} catch (e) {
|
|
toast({
|
|
title: t('marketUi.offers.counterFailed'),
|
|
description: e instanceof Error ? e.message : t('marketUi.offers.somethingWrong'),
|
|
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 ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')}
|
|
</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, t)}
|
|
<span className="ml-auto text-sm font-bold text-[#ccff00]">{amountLabel(latest, t)}</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 ? t('marketUi.offers.you') : t('marketUi.offers.them')}: {amountLabel(m, t)}
|
|
{m.status !== 'open' ? ` · ${m.status}` : ''}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{latest.message && <p className="text-xs text-gray-400">{latest.message}</p>}
|
|
|
|
{/* Buyer on an accepted deal: settle it straight from the wallet. */}
|
|
{latest.status === 'accepted' && user.pubkey === thread.buyer && (
|
|
paidThreads.has(`${thread.listingCoord}:${thread.buyer}`) ? (
|
|
<p className="flex items-center gap-1.5 text-xs font-bold text-green-400">
|
|
<CheckCircle className="h-3.5 w-3.5" /> {t('marketUi.offers.paidAskConfirm')}
|
|
</p>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setPayFor(thread)}
|
|
className="w-full gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
|
>
|
|
<Zap className="h-3.5 w-3.5" /> {t('marketUi.offers.payWithSats')}
|
|
</Button>
|
|
)
|
|
)}
|
|
|
|
{/* My turn: accept, decline, or counter. */}
|
|
{myTurn && (
|
|
<>
|
|
<div className="flex gap-2 pt-1">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => respond(thread, 'accepted')}
|
|
disabled={busy === latest.id}
|
|
className="flex-1 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
|
>
|
|
{busy === latest.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />}
|
|
{t('marketUi.offers.accept')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => respond(thread, 'declined')}
|
|
disabled={busy === latest.id}
|
|
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" /> {t('marketUi.offers.decline')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setCounterFor(counterFor === latest.id ? null : latest.id);
|
|
setCounterAmount(latest.amount ? String(Math.round(latest.amount)) : '');
|
|
setCounterItem(latest.offerItem ?? '');
|
|
}}
|
|
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" /> {t('marketUi.offers.counter')}
|
|
</Button>
|
|
</div>
|
|
{counterFor === latest.id && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
autoFocus
|
|
type="number"
|
|
inputMode="numeric"
|
|
value={counterAmount}
|
|
onChange={(e) => setCounterAmount(e.target.value)}
|
|
placeholder={t('marketUi.offers.counterPlaceholder')}
|
|
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}
|
|
{t('marketUi.offers.send')}
|
|
</Button>
|
|
</div>
|
|
<Input
|
|
value={counterItem}
|
|
onChange={(e) => setCounterItem(e.target.value)}
|
|
placeholder={t('marketUi.offers.counterItemPlaceholder')}
|
|
className="h-9 border-white/15 bg-white/5 text-sm text-white"
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Their turn: waiting, with the option to pull my own offer. */}
|
|
{!myTurn && latest.status === 'open' && (
|
|
<div className="flex items-center gap-2">
|
|
<p className="flex-1 text-xs text-gray-500">
|
|
{t('marketUi.offers.waitingOn')} <PeerName pubkey={other} /> {t('marketUi.offers.waitingOnSuffix')}
|
|
</p>
|
|
{latest.author === user.pubkey && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => withdraw(thread)}
|
|
disabled={busy === latest.id}
|
|
className="shrink-0 gap-1.5 border-white/15"
|
|
>
|
|
<Undo2 className="h-3.5 w-3.5" /> {t('marketUi.offers.withdraw')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{payFor && (
|
|
<PayWithSats
|
|
open={!!payFor}
|
|
onOpenChange={(o) => { if (!o) setPayFor(null); }}
|
|
sellerPubkey={payFor.latest.seller}
|
|
amountSats={payFor.latest.amount}
|
|
pointer={payFor.latest.payment}
|
|
pointerKind={payFor.latest.paymentKind}
|
|
onPaid={(receipt) => {
|
|
// Carry the verifiable receipt to the confirm step (same coord key).
|
|
if (receipt) saveReceipt(payFor.listingCoord, receipt);
|
|
setPaidThreads((prev) => new Set(prev).add(`${payFor.listingCoord}:${payFor.buyer}`));
|
|
}}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|