SkateSpots/src/components/MarkSoldDialog.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

171 lines
5.9 KiB
TypeScript

import { useState } from 'react';
import { nip19 } from 'nostr-tools';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useTradeAttestation, TRADE_METHODS, type TradeMethod } from '@/hooks/useTradeAttestations';
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { Check, Loader2 } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useTranslation } from '@/contexts/language';
function resolvePubkey(input: string): string | null {
const t = input.trim();
if (/^[a-f0-9]{64}$/i.test(t)) return t.toLowerCase();
if (t.startsWith('npub1')) {
try {
const decoded = nip19.decode(t);
return decoded.type === 'npub' ? decoded.data : null;
} catch {
return null;
}
}
return null;
}
/**
* Seller's "close the deal" flow: mark the listing sold AND publish a trade
* attestation (kind 31871) naming the buyer, so both sides can build
* reputation. Payment method is self-reported — the app can't see cash/trade
* deals and never treats it as proof of payment.
*/
export function MarkSoldDialog({
open,
onOpenChange,
event,
listing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
event: NostrEvent;
listing: { pubkey: string; identifier: string };
}) {
const { t } = useTranslation();
const { publishTrade, isPending: publishing } = useTradeAttestation();
const { markSold, isPending: marking } = useMarkListingSold();
const { toast } = useToast();
const [buyer, setBuyer] = useState('');
const [method, setMethod] = useState<TradeMethod>('cash');
const [amount, setAmount] = useState('');
const buyerPubkey = resolvePubkey(buyer);
const busy = publishing || marking;
const submit = async () => {
if (!buyerPubkey) {
toast({ title: t('marketUi.markSold.enterBuyer'), variant: 'destructive' });
return;
}
try {
await markSold(event);
await publishTrade({
listing,
role: 'sold',
counterparty: buyerPubkey,
method,
amountSats: amount.trim() ? Number(amount) : undefined,
});
toast({
title: t('marketUi.markSold.dealClosed'),
description: t('marketUi.markSold.dealClosedBody'),
});
onOpenChange(false);
setBuyer('');
setAmount('');
} catch (e) {
toast({
title: t('marketUi.markSold.closeFailed'),
description: e instanceof Error ? e.message : t('marketUi.markSold.somethingWrong'),
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="mx-4 max-w-[calc(100vw-2rem)] border-2 border-[#ccff00]/40 bg-black sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-white">{t('marketUi.markSold.title')}</DialogTitle>
<DialogDescription className="text-sm text-gray-400">
{t('marketUi.markSold.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="buyer" className="flex items-center gap-2 text-xs uppercase tracking-wide text-gray-400">
{t('marketUi.markSold.buyerLabel')}
{buyerPubkey && <Check className="h-3.5 w-3.5 text-[#ccff00]" />}
</Label>
<Input
id="buyer"
value={buyer}
onChange={(e) => setBuyer(e.target.value)}
placeholder="npub1…"
className="h-11 border-gray-700 bg-gray-900 font-mono text-xs text-white"
/>
</div>
<div className="space-y-2">
<Label className="text-xs uppercase tracking-wide text-gray-400">{t('marketUi.markSold.methodLabel')}</Label>
<div className="flex flex-wrap gap-2">
{(Object.keys(TRADE_METHODS) as TradeMethod[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMethod(m)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-bold transition-colors',
method === m
? 'border-[#ccff00] bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/15 text-gray-400 hover:border-white/30',
)}
>
{TRADE_METHODS[m]}
</button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="amount" className="text-xs uppercase tracking-wide text-gray-400">
{t('marketUi.markSold.amountLabel')}
</Label>
<Input
id="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="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 || !buyerPubkey}
className="flex-1 gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
{t('marketUi.markSold.submit')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}