SkateSpots/src/components/RiderCard.tsx
SkateSpots ad75120a43 feat(card): Crew Card template (derived from the crew's signed event)
- /card gains a third type: Crew. Pick one of your crews and render a printable
  crew card (name, logo, home turf, founded, members, spots, vibes, Open call).
- No new event: the card is derived from the crew's own signed kind 35006, so its
  QR verifies that event (the founder's key IS the signature).
- Rendered in the React card + canvas export; /verify/:naddr now renders kind
  35006 with a link to the crew page.
- i18n cardUi.crew.* / verify.kindCrew; NIP.md + AGENTS.md updated.
2026-10-02 10:42:26 +07:00

434 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* RiderCard — the visual card used by both the `/card` editor and the public
* `/card/:npub` view. Extracted so there is ONE React rendering of the card
* (the canvas export in `RiderCardPage` remains the print renderer).
*/
import { useEffect, useState } from 'react';
import { SKILL_OPTIONS, shortNpub, roleMeta, type ThemeId } from '@/lib/riderCard';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
export interface BadgeAward {
witnessName: string;
witnessPubkey: string;
skill: string;
spotName?: string;
timestamp: number;
}
export interface CardData {
type: 'rider' | 'park' | 'crew';
theme: ThemeId;
holographic: boolean;
// Rider
name: string;
nip05?: string;
avatar?: string;
pubkey: string;
npub: string;
style: string;
skills: string[];
sponsors: string;
ridesFor: string;
showSpots: boolean;
showZaps: boolean;
showEvents: boolean;
spotsAdded: number;
zapsReceived: number;
eventsCreated: number;
memberYear: string;
receivedBadges: BadgeAward[];
/** When set, the printed QR opens the verify page for this card. */
verifyUrl?: string;
// Identity facets (derived live, toggled on the card)
crews?: { name: string; image?: string }[];
local?: string;
roles?: string[];
showCrews?: boolean;
showLocal?: boolean;
showRoles?: boolean;
// Crew card (derived from the crew's own signed kind 35006 event)
crew?: {
name: string;
image?: string;
pubkey: string;
identifier: string;
location?: string;
founded?: string;
members: number;
focus?: string;
vibes: string[];
lookingFor: string[];
spots: number;
};
// Park
issuerName: string;
recipientName: string;
accessLevel: string;
validThru: string;
}
/** Real QR (lazy-imported so canvas API access stays out of module load). */
function CardQr({ url }: { url?: string }) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!url) { setSrc(null); return; }
(async () => {
try {
const QRCode = (await import('qrcode')).default;
const dataUrl = await QRCode.toDataURL(url, {
width: 88,
margin: 0,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#ffffff' },
});
if (!cancelled) setSrc(dataUrl);
} catch {
if (!cancelled) setSrc(null);
}
})();
return () => { cancelled = true; };
}, [url]);
return (
<div className="rounded-[3px] flex-shrink-0" style={{ background: '#fff', padding: '3px', width: '44px', height: '44px' }}>
{src
? <img src={src} alt="QR" width={38} height={38} style={{ display: 'block', width: '38px', height: '38px' }} />
: <div style={{ width: '38px', height: '38px' }} />}
</div>
);
}
// ─── Card visual: Rider front ─────────────────────────────────────────────────
// Layout is STRICTLY pixel-fixed to match the 340×214px card container.
function RiderFront({ d }: { d: CardData }) {
const { t } = useTranslation();
const witnessedBadges = d.receivedBadges.slice(0, 2);
return (
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
{/* Row 1: header */}
<div className="flex items-start justify-between" style={{ height: '26px' }}>
<div>
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
🛹 SkateSpots
</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewLicense')}</div>
</div>
<div className="text-right">
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
<div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
{shortNpub(d.pubkey)}
</div>
<div className="text-[5px] card-muted mt-[1px]">{t('labUi.rider.previewSince', { year: d.memberYear })}</div>
</div>
</div>
{/* Identity facets: local + crew crests */}
{((d.showLocal !== false && d.local) || (d.showCrews !== false && (d.crews?.length ?? 0) > 0)) && (
<div className="flex items-center gap-[6px] mt-[2px] overflow-hidden" style={{ height: '13px' }}>
{d.showLocal !== false && d.local && (
<span className="text-[6px] font-bold card-accent truncate flex-shrink-0" style={{ maxWidth: '150px' }}>📍 {d.local}</span>
)}
{d.showCrews !== false && (d.crews ?? []).slice(0, 3).map((c, i) => (
<span key={i} className="flex items-center gap-[2px] min-w-0 flex-shrink-0">
{c.image
? <img src={c.image} alt="" crossOrigin="anonymous" className="w-[11px] h-[11px] rounded-full object-cover border border-white/25" />
: <span className="w-[11px] h-[11px] rounded-full bg-white/15" />}
<span className="text-[5.5px] card-muted truncate" style={{ maxWidth: '66px' }}>{c.name}</span>
</span>
))}
</div>
)}
{/* Row 2: main body */}
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
{/* Avatar */}
<div className="flex-shrink-0 flex flex-col gap-[4px]">
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{d.avatar ? (
<img src={d.avatar} alt={d.name} className="w-full h-full object-cover" crossOrigin="anonymous" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
)}
</div>
<div className="space-y-[2px]">
{d.showSpots && (
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">{t('labUi.pyramid.spots')}</span>
<span className="text-[7px] font-black card-accent leading-none">{d.spotsAdded}</span>
</div>
)}
{d.showZaps && (
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">⚡</span>
<span className="text-[7px] font-black card-accent2 leading-none">{d.zapsReceived}</span>
</div>
)}
{d.showEvents && (
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">{t('labUi.rider.previewEvts')}</span>
<span className="text-[7px] font-black leading-none" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
</div>
)}
</div>
</div>
{/* Identity block */}
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="text-[14px] font-black uppercase truncate leading-tight">{d.name}</div>
{d.nip05 && (
<div className="text-[6.5px] card-accent truncate leading-none">@{d.nip05.split('@')[0]}</div>
)}
{d.style && (
<span className="card-bg-chip text-[5.5px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] card-accent w-fit">
{d.style}
</span>
)}
{d.ridesFor && (
<div className="text-[5.5px] card-muted truncate leading-none">🏪 {d.ridesFor}</div>
)}
{d.sponsors && (
<div className="text-[5.5px] card-muted truncate leading-none">🤝 {d.sponsors}</div>
)}
{/* Skills — roles, self-claimed, then witnessed (gold) */}
<div className="flex flex-wrap gap-[2px] mt-[2px]">
{d.showRoles !== false && (d.roles ?? []).slice(0, 2).map(r => {
const m = roleMeta(r);
return m ? (
<span key={r} className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none" style={{ background: 'rgba(100,160,255,0.18)', border: '1px solid rgba(100,160,255,0.45)', color: '#9cc4ff' }}>
{m.emoji} {t(`cardUi.roles.${m.role}`)}
</span>
) : null;
})}
{d.skills.slice(0, 3).map(sk => {
const s = SKILL_OPTIONS.find(o => o.id === sk);
return s ? (
<span key={sk} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none">
{s.icon} {s.label}
</span>
) : null;
})}
{witnessedBadges.map((b, i) => {
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
return s ? (
<span
key={`w${i}`}
className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none"
style={{ background: 'rgba(255,215,0,0.2)', border: '1px solid rgba(255,215,0,0.5)', color: '#ffd700' }}
>
⭐ {s.label}
</span>
) : null;
})}
</div>
</div>
{/* QR */}
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<CardQr url={d.verifyUrl} />
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
</div>
</div>
</div>
{/* Bottom bar */}
<div
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
style={{ bottom: '6px' }}
>
<span className="card-muted text-[5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<span className="card-muted text-[5px] font-mono" style={{ maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shortNpub(d.pubkey)}
</span>
</div>
</div>
);
}
// ─── Card visual: Park Access front ──────────────────────────────────────────
function ParkFront({ d }: { d: CardData }) {
const { t } = useTranslation();
return (
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
<div className="flex items-center justify-between" style={{ height: '26px' }}>
<div>
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent leading-none">
🏟️ {d.issuerName || t('labUi.rider.previewSkatepark')}
</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewAccessPass')}</div>
</div>
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]">
<span className="text-[7px] font-black uppercase card-accent">{d.accessLevel || 'Full Access'}</span>
</div>
</div>
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
<div className="flex-shrink-0">
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{d.avatar ? (
<img src={d.avatar} alt={d.recipientName} className="w-full h-full object-cover" crossOrigin="anonymous" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
)}
</div>
</div>
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="text-[13px] font-black uppercase truncate leading-tight">{d.recipientName || t('labUi.rider.previewMemberName')}</div>
<div className="text-[5.5px] card-muted font-mono leading-none truncate">{shortNpub(d.pubkey)}</div>
<div className="text-[5.5px] card-muted leading-none">{t('labUi.rider.previewIssuedBy')}</div>
{d.validThru && (
<div className="flex items-center gap-1 mt-[3px]">
<span className="text-[5.5px] card-muted uppercase">{t('labUi.rider.previewValidThru')}</span>
<span className="text-[8px] font-black card-accent leading-none">{d.validThru}</span>
</div>
)}
</div>
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<CardQr url={d.verifyUrl} />
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
</div>
</div>
</div>
<div
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
style={{ bottom: '6px' }}
>
<span className="card-muted text-[5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<div className="flex items-center gap-1">
<span className="card-muted text-[5px] uppercase tracking-widest">{t('labUi.rider.previewNostrVerified')}</span>
</div>
</div>
</div>
);
}
// ─── Card visual: Crew front ─────────────────────────────────────────────────
function CrewFront({ d }: { d: CardData }) {
const { t } = useTranslation();
const c = d.crew;
if (!c) return null;
return (
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
{/* Row 1: header */}
<div className="flex items-start justify-between" style={{ height: '26px' }}>
<div>
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
🛹 SkateSpots
</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('cardUi.crew.crewCard')}</div>
</div>
<div className="text-right">
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
<div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
{shortNpub(c.pubkey)}
</div>
{c.founded && <div className="text-[5px] card-muted mt-[1px]">{t('cardUi.crew.est', { year: c.founded })}</div>}
</div>
</div>
{/* Row 2: main body */}
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
{/* Logo + stats */}
<div className="flex-shrink-0 flex flex-col gap-[4px]">
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{c.image ? (
<img src={c.image} alt={c.name} className="w-full h-full object-cover" crossOrigin="anonymous" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🏕️</div>
)}
</div>
<div className="space-y-[2px]">
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">{t('cardUi.crew.members')}</span>
<span className="text-[7px] font-black card-accent leading-none">{c.members}</span>
</div>
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">{t('cardUi.crew.spots')}</span>
<span className="text-[7px] font-black card-accent2 leading-none">{c.spots}</span>
</div>
</div>
</div>
{/* Identity block */}
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="text-[14px] font-black uppercase truncate leading-tight">{c.name}</div>
{c.location && (
<div className="text-[6.5px] card-accent truncate leading-none">📍 {c.location}</div>
)}
{c.lookingFor.length > 0 && (
<span
className="text-[5px] font-black uppercase px-[4px] py-[1px] rounded-[3px] w-fit leading-none"
style={{ background: 'rgba(204,255,0,0.2)', border: '1px solid rgba(204,255,0,0.5)', color: '#ccff00' }}
>
{t('cardUi.crew.openCall')}
</span>
)}
<div className="flex flex-wrap gap-[2px] mt-[2px]">
{c.vibes.slice(0, 5).map(v => (
<span key={v} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none">
{v}
</span>
))}
</div>
</div>
{/* QR */}
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<CardQr url={d.verifyUrl} />
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
</div>
</div>
</div>
{/* Bottom bar */}
<div
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
style={{ bottom: '6px' }}
>
<span className="card-muted text-[5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<span className="card-muted text-[5px] uppercase tracking-widest">{t('cardUi.crew.verifiedCrew')}</span>
</div>
</div>
);
}
export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject<HTMLDivElement> }) {
const themeClass = `card-theme-${d.theme}`;
const holoClass = d.holographic ? 'rider-card-holo' : '';
return (
<div
ref={printRef}
className={cn('rider-card', themeClass, holoClass, 'select-none')}
style={{ fontFamily: "'Inter Variable', 'Inter', system-ui, sans-serif" }}
>
<div className="card-stripe absolute top-0 left-0 right-0 h-[4px]" />
{d.type === 'rider' && <RiderFront d={d} />}
{d.type === 'park' && <ParkFront d={d} />}
{d.type === 'crew' && <CrewFront d={d} />}
</div>
);
}