- 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.
129 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|