SkateSpots/src/components/CrossLinkCards.tsx
shakespeare.diy c9b0571de9 feat(ux): graceful image fallbacks for broken content images (1.2.18)
Added <ImageWithFallback> — a drop-in <img> that shows a subtle placeholder
(skate emoji or a caller-supplied icon) instead of the browser's broken-image
glyph when a user-supplied URL is missing or 404s. Fills the same layout box so
nothing shifts.

Swapped it into the content-image surfaces that display user-uploaded URLs:
- Marketplace item cards (grid + purchase dialog)
- Spot detail (hero + gallery thumbs) and map list/popup cards
- Shop detail (hero + gallery)
- Crew & Lab cross-link cards (icon fallback)
- Crew agent panel, newsletter issue view + notification bell newsletter thumb
- Stash gear cards (list + grid), profile banner (NIP19), newsletter spot cards

Left untouched: avatars (shadcn Avatar already has fallbacks), QR codes and
rider-card/print images (data URLs, never 404), and in-form upload previews
(local object URLs).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-07 19:46:40 +07:00

165 lines
6.2 KiB
TypeScript

import { Link } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Users, GraduationCap, ChevronRight, Link2, BadgeCheck } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCrew } from '@/hooks/useCrews';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { parseAddrCoord } from '@/lib/types';
/**
* Small "✓ Verified" pill shown when a link is confirmed from BOTH sides
* (the crew links the Lab profile AND the Lab profile links the crew). A
* one-sided claim shows nothing — we never imply trust that isn't mutual.
*/
function MutualBadge() {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-0.5 rounded-full bg-green-500/15 px-1.5 py-0.5 text-[10px] font-black uppercase leading-none text-green-400">
<BadgeCheck className="h-3 w-3" />
Verified
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[200px] text-xs">
Both sides confirm this link — the crew and the Lab profile each point at
the other.
</TooltipContent>
</Tooltip>
);
}
const LAB_TYPE_LABEL: Record<number, string> = {
35001: 'Coach',
35002: 'Filmer',
35003: 'Editor',
35004: 'Photographer',
35005: 'Builder',
35007: 'Artist',
35008: 'Graffiti',
35009: 'Tattoo',
35010: 'Trainer',
35011: 'Musician',
};
/**
* A single linked-crew row, resolved from a `35006:pubkey:d` coordinate.
* `selfCoord` is the coordinate of the Lab profile showing this card — used to
* check whether the crew links BACK (mutual = verified).
*/
function CrewRow({ coord, selfCoord }: { coord: string; selfCoord?: string }) {
const parsed = parseAddrCoord(coord);
const { data: crew } = useCrew(parsed?.identifier ?? '', parsed?.pubkey);
if (!parsed) return null;
const mutual = !!selfCoord && !!crew?.labRefs?.includes(selfCoord);
return (
<Link
to={`/crew/${parsed.pubkey}/${parsed.identifier}`}
className="flex items-center gap-3 rounded-lg border border-gray-700 bg-black/40 p-2.5 hover:bg-black/60 hover:border-teal-500/40 transition-colors"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-teal-500/15">
<ImageWithFallback
src={crew?.image}
alt={crew?.name ?? 'Crew'}
className="h-full w-full object-cover"
fallback={<Users className="h-4 w-4 text-teal-400" />}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-bold text-white">{crew?.name ?? 'Skate Crew'}</p>
{mutual && <MutualBadge />}
</div>
<p className="text-xs text-teal-400">Crew{crew?.location ? ` · ${crew.location}` : ''}</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-gray-500" />
</Link>
);
}
/**
* A single linked-Lab-profile row, resolved from a `kind:pubkey:d` coordinate.
* `selfCoord` is the coordinate of the crew showing this card — used to check
* whether the Lab profile links BACK (mutual = verified).
*/
function LabRow({ coord, selfCoord }: { coord: string; selfCoord?: string }) {
const parsed = parseAddrCoord(coord);
const { data: profile } = useLabProfile(parsed?.identifier ?? '', parsed?.pubkey);
if (!parsed) return null;
const typeLabel = LAB_TYPE_LABEL[parsed.kind] ?? 'Lab';
const mutual = !!selfCoord && profile?.crewRef === selfCoord;
return (
<Link
to={`/coach/${parsed.pubkey}/${parsed.identifier}`}
className="flex items-center gap-3 rounded-lg border border-gray-700 bg-black/40 p-2.5 hover:bg-black/60 hover:border-teal-500/40 transition-colors"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/5">
<ImageWithFallback
src={profile?.image}
alt={profile?.title ?? 'Lab profile'}
className="h-full w-full object-cover"
fallback={<GraduationCap className="h-4 w-4 text-gray-300" />}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-bold text-white">{profile?.title ?? `${typeLabel} profile`}</p>
{mutual && <MutualBadge />}
</div>
<p className="text-xs text-gray-400">In The Lab · {typeLabel}</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-gray-500" />
</Link>
);
}
/**
* Card shown on a Lab profile: the crew it belongs to.
* `selfCoord` is this Lab profile's own coordinate (`kind:pubkey:d`) so the row
* can show a "Verified" badge when the crew links back.
*/
export function LinkedCrewCard({ crewRef, selfCoord }: { crewRef?: string; selfCoord?: string }) {
if (!crewRef) return null;
return (
<Card className="bg-gray-900 border-gray-700">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Link2 className="h-4 w-4 text-teal-400" /> Part of a Crew
</CardTitle>
</CardHeader>
<CardContent>
<CrewRow coord={crewRef} selfCoord={selfCoord} />
</CardContent>
</Card>
);
}
/**
* Card shown on a crew: the Lab service profiles it links to.
* `selfCoord` is the crew's own coordinate (`35006:pubkey:d`) so each row can
* show a "Verified" badge when that Lab profile links back to this crew.
*/
export function LinkedLabCard({ labRefs, selfCoord }: { labRefs: string[]; selfCoord?: string }) {
if (!labRefs || labRefs.length === 0) return null;
return (
<Card className="bg-gray-900 border-gray-700">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Link2 className="h-4 w-4 text-teal-400" /> Hire This Crew
</CardTitle>
<p className="text-xs text-gray-500">Services this crew offers in The Lab</p>
</CardHeader>
<CardContent className="space-y-2">
{labRefs.map((coord) => (
<LabRow key={coord} coord={coord} selfCoord={selfCoord} />
))}
</CardContent>
</Card>
);
}