180 lines
6.7 KiB
TypeScript
180 lines
6.7 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useOfferActions } from '@/hooks/useMarketplaceOffers';
|
|
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { Handshake, Loader2 } from 'lucide-react';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
/** Buyer: make a structured offer (amount + note) on a listing. */
|
|
export function OfferDialog({
|
|
open,
|
|
onOpenChange,
|
|
listing,
|
|
title,
|
|
sellerName,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
listing: { pubkey: string; identifier: string };
|
|
title: string;
|
|
sellerName: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { createOffer } = useOfferActions();
|
|
const { mutateAsync: sendDM } = useSendDirectMessage();
|
|
const { toast } = useToast();
|
|
const [amount, setAmount] = useState('');
|
|
const [offerItem, setOfferItem] = useState('');
|
|
const [note, setNote] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const submit = async () => {
|
|
const parsed = amount.trim() ? Number(amount) : undefined;
|
|
if (parsed !== undefined && (!Number.isFinite(parsed) || parsed <= 0)) {
|
|
toast({ title: t('marketUi.offer.validAmount'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
if (!parsed && !offerItem.trim() && !note.trim()) {
|
|
toast({ title: t('marketUi.offer.describeOffer'), description: t('marketUi.offer.describeOfferBody'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
await createOffer({ listing, amount: parsed, currency: 'SATS', offerItem, note });
|
|
|
|
// Best-effort nudge to the seller via NIP-17 DM — never fail the offer.
|
|
try {
|
|
const amountLine = parsed ? t('marketUi.offer.dmAmount', { amount: parsed.toLocaleString() }) : '';
|
|
const itemLine = offerItem.trim() ? t('marketUi.offer.dmItem', { item: offerItem.trim() }) : '';
|
|
const noteLine = note.trim() ? `\n\n“${note.trim()}”` : '';
|
|
await sendDM({
|
|
recipients: listing.pubkey,
|
|
content: t('marketUi.offer.dmBody', { title, amount: amountLine, item: itemLine, note: noteLine }),
|
|
});
|
|
} catch {
|
|
/* DM optional (no NIP-44 signer, etc.) */
|
|
}
|
|
|
|
toast({
|
|
title: t('marketUi.offer.sent'),
|
|
description: t('marketUi.offer.sentBody', { sellerName }),
|
|
});
|
|
onOpenChange(false);
|
|
setAmount('');
|
|
setOfferItem('');
|
|
setNote('');
|
|
} catch (e) {
|
|
toast({
|
|
title: t('marketUi.offer.sendFailed'),
|
|
description: e instanceof Error ? e.message : t('marketUi.offer.somethingWrong'),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="mx-4 max-w-[calc(100vw-2rem)] border-2 border-orange-500/40 bg-black sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-white">
|
|
<Handshake className="h-5 w-5 text-orange-400" /> {t('marketUi.offer.title')}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-sm text-gray-400">
|
|
{t('marketUi.offer.description', { sellerName })}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="offer-amount" className="text-xs uppercase tracking-wide text-gray-400">
|
|
{t('marketUi.offer.amountLabel')}
|
|
</Label>
|
|
<Input
|
|
id="offer-amount"
|
|
value={amount}
|
|
onChange={(e) => setAmount(e.target.value)}
|
|
inputMode="numeric"
|
|
placeholder={t('marketUi.offer.amountPlaceholder')}
|
|
className="h-11 border-gray-700 bg-gray-900 text-white"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="offer-item" className="text-xs uppercase tracking-wide text-gray-400">
|
|
{t('marketUi.offer.itemLabel')}
|
|
</Label>
|
|
<Input
|
|
id="offer-item"
|
|
value={offerItem}
|
|
onChange={(e) => setOfferItem(e.target.value)}
|
|
placeholder={t('marketUi.offer.itemPlaceholder')}
|
|
className="h-11 border-gray-700 bg-gray-900 text-white"
|
|
/>
|
|
<p className="text-[11px] text-gray-500">
|
|
{t('marketUi.offer.itemHint')}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">
|
|
{t('marketplaceExtrasUi.offerTemplates')}
|
|
</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{[
|
|
t('marketplaceExtrasUi.offerDiscount'),
|
|
t('marketplaceExtrasUi.offerSwap'),
|
|
t('marketplaceExtrasUi.offerPickup'),
|
|
t('marketplaceExtrasUi.offerShipping'),
|
|
].map((template) => (
|
|
<button
|
|
key={template}
|
|
type="button"
|
|
onClick={() => setNote(template)}
|
|
className="min-h-0 rounded-full border border-orange-500/25 bg-orange-500/5 px-2.5 py-1 text-[11px] text-orange-200 hover:bg-orange-500/15"
|
|
>
|
|
{template}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<Label htmlFor="offer-note" className="text-xs uppercase tracking-wide text-gray-400">
|
|
{t('marketUi.offer.messageLabel')}
|
|
</Label>
|
|
<Textarea
|
|
id="offer-note"
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value)}
|
|
rows={3}
|
|
placeholder={t('marketUi.offer.messagePlaceholder')}
|
|
className="border-gray-700 bg-gray-900 text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-1">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
onClick={submit}
|
|
disabled={busy}
|
|
className="flex-1 gap-2 bg-orange-500 font-bold text-black hover:bg-orange-500/90"
|
|
>
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Handshake className="h-4 w-4" />}
|
|
{t('marketUi.offer.send')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|