- 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.
130 lines
5 KiB
TypeScript
130 lines
5 KiB
TypeScript
/**
|
|
* Guided handoff — turns the blank "message the seller" DM into a real
|
|
* arrangement, WITHOUT the app ever touching an address. Each button opens the
|
|
* trade chat with a pre-filled template; the actual details stay in the NIP-17
|
|
* DM (push already covers it). Also carries the anti-scam guidance.
|
|
*/
|
|
import { useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { nip19 } from 'nostr-tools';
|
|
import ngeohash from 'ngeohash';
|
|
import { Package, MapPin, Lock, ShieldCheck } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
|
import { itemDistanceKm } from '@/lib/sortOptions';
|
|
import type { MarketplaceItem } from '@/lib/types';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
export function HandoffHelper({ item }: { item: MarketplaceItem }) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const npub = nip19.npubEncode(item.pubkey);
|
|
const dm = (message: string) => `/messages/${npub}?message=${encodeURIComponent(message)}`;
|
|
|
|
const ships = item.fulfilment === 'ships' || item.fulfilment === 'both';
|
|
const pickup = item.fulfilment === 'pickup' || item.fulfilment === 'both';
|
|
|
|
// Suggest nearby PUBLIC skate spots as a meetup place. Uses only the listing's
|
|
// own public geohash + public spot data — never the viewer's live location.
|
|
const { data: spots = [] } = useSkateSpots(200);
|
|
const meetSpots = useMemo(() => {
|
|
if (!pickup || !item.geohash) return [];
|
|
try {
|
|
const { latitude, longitude } = ngeohash.decode(item.geohash);
|
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return [];
|
|
const origin = { lat: latitude, lng: longitude };
|
|
return spots
|
|
.filter((s) => s.geohash && s.identifier !== item.identifier)
|
|
.map((s) => ({ spot: s, km: itemDistanceKm(s, origin) ?? Infinity }))
|
|
.sort((a, b) => a.km - b.km)
|
|
.slice(0, 3);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}, [spots, item.geohash, item.identifier, pickup]);
|
|
|
|
if (!ships && !pickup) return null;
|
|
|
|
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">
|
|
<ShieldCheck className="h-3.5 w-3.5 text-[#ccff00]" /> {t('marketUi.handoff.title')}
|
|
</p>
|
|
|
|
{pickup && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full justify-start gap-2 border-white/15"
|
|
onClick={() =>
|
|
navigate(
|
|
dm(t('marketUi.handoff.suggestDm', { title: item.title })),
|
|
)
|
|
}
|
|
>
|
|
<MapPin className="h-4 w-4" /> {t('marketUi.handoff.suggest')}
|
|
</Button>
|
|
)}
|
|
|
|
{meetSpots.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<p className="text-[11px] text-gray-500">{t('marketUi.handoff.orMeetAt')}</p>
|
|
{meetSpots.map(({ spot, km }) => (
|
|
<Button
|
|
key={spot.id}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full justify-start gap-2 text-left hover:bg-white/5"
|
|
onClick={() =>
|
|
navigate(
|
|
dm(
|
|
t('marketUi.handoff.spotDm', { title: item.title, spot: spot.title, url: `/spot/${spot.pubkey}/${spot.identifier}` }),
|
|
),
|
|
)
|
|
}
|
|
>
|
|
<MapPin className="h-3.5 w-3.5 shrink-0 text-[#ccff00]" />
|
|
<span className="truncate">{spot.title}</span>
|
|
{Number.isFinite(km) && (
|
|
<span className="ml-auto shrink-0 text-[10px] text-gray-500">
|
|
{km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(1)} km`}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{ships && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full justify-start gap-2 border-white/15"
|
|
onClick={() =>
|
|
navigate(
|
|
dm(t('marketUi.handoff.deliveryDm', { title: item.title })),
|
|
)
|
|
}
|
|
>
|
|
<Package className="h-4 w-4" /> {t('marketUi.handoff.shareDelivery')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full justify-start gap-2 border-white/15"
|
|
onClick={() =>
|
|
navigate(dm(t('marketUi.handoff.lockerDm', { title: item.title })))
|
|
}
|
|
>
|
|
<Lock className="h-4 w-4" /> {t('marketUi.handoff.useLocker')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
<p className="text-[10px] leading-relaxed text-gray-600">
|
|
{t('marketUi.handoff.noteBefore')} <strong className="text-gray-400">{t('marketUi.handoff.escrow')}</strong>{t('marketUi.handoff.noteMiddle')} <strong className="text-gray-400">{t('marketUi.handoff.neverPay')}</strong>{t('marketUi.handoff.noteAfter')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|