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

129 lines
5.3 KiB
TypeScript

/**
* ShippingStatusCard — optional shipping logistics for a `ships` deal.
*
* Self-reported (a claim, never proof): the seller marks **shipped**, the buyer
* marks **received**, either can flag an **issue**. `p`-tagged to the
* counterparty so the push service can notify while the app is closed. No
* address is ever stored — only a coarse status and an optional tracking ref.
*/
import { useState } from 'react';
import { Package, PackageCheck, AlertTriangle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useListingTrade } from '@/hooks/useTradeAttestations';
import { useShipmentStatus, useSetShipmentStatus } from '@/hooks/useShipmentStatus';
import { useToast } from '@/hooks/useToast';
import type { MarketplaceItem } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { toast } = useToast();
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
const { data: trade } = useListingTrade(listingRef, user?.pubkey);
const { data: statuses = [] } = useShipmentStatus(listingRef);
const { setStatus, isPending } = useSetShipmentStatus();
const [tracking, setTracking] = useState('');
if (!user) return null;
const shipping = item.fulfilment === 'ships' || item.fulfilment === 'both';
if (!shipping) return null;
const isSeller = user.pubkey === item.pubkey;
// Seller → the buyer they named on their own attestation; buyer → the seller.
const counterparty = isSeller
? trade?.attestations
.find((ev) => ev.pubkey === user.pubkey)
?.tags.find(([t]) => t === 'p')?.[1]
: item.pubkey;
const shipped = statuses.some((s) => s.status === 'shipped');
const received = statuses.some((s) => s.status === 'received');
const issue = statuses.some((s) => s.status === 'issue');
const mark = async (status: 'shipped' | 'received' | 'issue') => {
if (!counterparty) {
toast({
title: t('marketUi.shipping.noCounterparty'),
description: t('marketUi.shipping.noCounterpartyBody'),
variant: 'destructive',
});
return;
}
try {
await setStatus({ listing: listingRef, counterparty, status, ref: tracking });
toast({
title:
status === 'shipped' ? t('marketUi.shipping.markedShipped') : status === 'received' ? t('marketUi.shipping.markedReceived') : t('marketUi.shipping.issueReported'),
description:
status === 'received'
? t('marketUi.shipping.receivedBody')
: t('marketUi.shipping.notifiedBody'),
});
} catch (e) {
toast({
title: t('marketUi.shipping.updateFailed'),
description: e instanceof Error ? e.message : t('marketUi.shipping.somethingWrong'),
variant: 'destructive',
});
}
};
return (
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<p className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-gray-400">
<Package className="h-3.5 w-3.5 text-[#ff6600]" /> {t('marketUi.shipping.title')}
{shipped && <span className="text-[#ccff00]">· {t('marketUi.shipping.shipped')}</span>}
{received && <span className="text-[#ccff00]">· {t('marketUi.shipping.received')}</span>}
{issue && <span className="text-amber-400">· {t('marketUi.shipping.issue')}</span>}
</p>
<Input
value={tracking}
onChange={(e) => setTracking(e.target.value)}
placeholder={t('marketUi.shipping.trackingPlaceholder')}
className="h-9 border-white/15 bg-white/5 text-sm text-white"
/>
<div className="flex flex-wrap gap-2">
{isSeller && (
<Button
size="sm"
onClick={() => mark('shipped')}
disabled={isPending}
className="gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
>
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
{t('marketUi.shipping.iShipped')}
</Button>
)}
{!isSeller && (
<Button
size="sm"
onClick={() => mark('received')}
disabled={isPending}
className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <PackageCheck className="h-3.5 w-3.5" />}
{t('marketUi.shipping.iReceived')}
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => mark('issue')}
disabled={isPending}
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
>
<AlertTriangle className="h-3.5 w-3.5" /> {t('marketUi.shipping.reportIssue')}
</Button>
</div>
<p className="text-[10px] leading-relaxed text-gray-600">
{t('marketUi.shipping.noteBefore')} <strong className="text-gray-400">{t('marketUi.shipping.notProof')}</strong>{t('marketUi.shipping.noteAfter')}
</p>
</div>
);
}