SkateSpots/src/components/OfferDialog.tsx
SkateSpots 09735375c2 feat(i18n): Spanish (Castellano) — infra + Map/Spots, Marketplace, Lab (batch 1)
- 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.
2026-10-01 23:57:20 +07:00

160 lines
5.8 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">
<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>
);
}