SkateSpots/src/pages/PublicCardPage.tsx
SkateSpots 84665adc8a feat(card): redesign as a 'Credential' + unify to ONE canvas renderer
Design: the card read like a spec sheet (16 competing elements, 5-6px type).
Rebuilt as an editorial credential:
- Left full-height portrait panel; right column = brand, ONE huge name, a single
  identity line (style / level / local), nip05, crew monograms + witnessed
  badges, and a clean QR verify stub. Dropped the tiny stats row, npub clutter,
  'Powered by Nostr' filler and the 'SKATER LICENSE' chrome.
- Same system applied to Park / Crew / Event fronts; typed-up back side.

Engine: the studio preview and /card/:npub now render the canvas PNG, so there
is a SINGLE renderer (src/lib/riderCardCanvas.ts) — no React/canvas drift. The
old React card components + CardQr are deleted. Holographic is now a canvas
sheen. Also fixed several fillText calls missing an x-coord.
2026-10-02 11:57:32 +07:00

155 lines
6 KiB
TypeScript

/**
* PublicCardPage — /card/:npub
*
* A read-only view of a skater's Rider Card, shareable and scannable. The QR on
* the card opens /verify so anyone can confirm the card is genuine.
*/
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft, Sparkles, RefreshCw } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useRiderStats } from '@/hooks/useRiderStats';
import { useRiderCard, useRiderBadges, useRiderRoles } from '@/hooks/useRiderCard';
import { useUserCrews } from '@/hooks/useCrews';
import { useUserSkaterProfile } from '@/hooks/useSkaters';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { Button } from '@/components/ui/button';
import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard';
import { RIDER_CARD_KIND, RIDER_CARD_D, DEFAULT_RIDER_CARD, cardVerifyUrl, memberSince } from '@/lib/riderCard';
function decodePubkey(value: string): string | null {
if (/^[0-9a-f]{64}$/i.test(value)) return value.toLowerCase();
try {
const d = nip19.decode(value);
return d.type === 'npub' ? d.data : null;
} catch {
return null;
}
}
export default function PublicCardPage() {
const { t } = useTranslation();
const { npub = '' } = useParams<{ npub: string }>();
const pubkey = decodePubkey(npub);
const { user } = useCurrentUser();
const author = useAuthor(pubkey ?? '');
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : '');
const { data: stats } = useRiderStats(pubkey ?? undefined);
const { data: savedCard } = useRiderCard(pubkey ?? undefined);
const { data: badges = [] } = useRiderBadges(pubkey ?? undefined);
const { data: roles = [] } = useRiderRoles(pubkey ?? undefined);
const { memberships } = useUserCrews(pubkey ?? undefined);
const { data: skaterProfile } = useUserSkaterProfile(pubkey ?? undefined);
const [side, setSide] = useState<'front' | 'back'>('front');
useSeoMeta({
title: `${displayName || 'Rider'} card — SkateSpots`,
description: `A SkateSpots Rider Card${displayName ? ` for ${displayName}` : ''}.`,
});
if (!pubkey) {
return (
<div className="max-w-lg mx-auto px-4 py-20 text-center">
<p className="text-gray-400">{t('cardUi.public.invalid')}</p>
<Button asChild variant="outline" className="mt-6"><Link to="/card">{t('cardUi.public.createYourOwn')}</Link></Button>
</div>
);
}
const config = savedCard?.config ?? DEFAULT_RIDER_CARD;
const receivedBadges: BadgeAward[] = badges.map(b => ({
skill: b.skill,
witnessName: genUserName(b.witnessPubkey),
witnessPubkey: b.witnessPubkey,
spotName: b.spotName,
timestamp: b.timestamp,
}));
const cardData: CardData = {
type: 'rider',
theme: config.theme as CardData['theme'],
holographic: true,
name: displayName,
nip05: metadata?.nip05,
avatar: metadata?.picture,
pubkey,
npub: nip19.npubEncode(pubkey),
style: config.style,
skills: config.skills,
sponsors: config.sponsors,
ridesFor: config.ridesFor,
showSpots: config.showSpots,
showZaps: config.showZaps,
showEvents: config.showEvents,
spotsAdded: stats?.spotsAdded ?? 0,
zapsReceived: stats?.zapsReceived ?? 0,
eventsCreated: stats?.eventsCreated ?? 0,
memberYear: metadata?.created_at ? memberSince(metadata.created_at as number) : new Date().getFullYear().toString(),
receivedBadges,
verifyUrl: cardVerifyUrl(RIDER_CARD_KIND, pubkey, RIDER_CARD_D),
accent: config.accent || undefined,
logo: config.logo || undefined,
side,
crews: memberships.map(m => ({ name: m.crew.name, image: m.crew.image })),
local: skaterProfile?.location || skaterProfile?.homeSpot || undefined,
roles,
issuerName: '',
recipientName: '',
accessLevel: '',
validThru: '',
};
const isMe = user?.pubkey === pubkey;
return (
<div className="max-w-2xl mx-auto px-4 pb-16">
<div className="pt-8 pb-6">
<Link to={isMe ? '/card' : '/'} className="inline-flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 uppercase tracking-widest">
<ArrowLeft className="h-3.5 w-3.5" /> {isMe ? t('cardUi.public.backToEditor') : t('cardUi.public.backHome')}
</Link>
</div>
<div
className="w-full rounded-2xl flex items-center justify-center py-8 relative overflow-hidden"
style={{ background: 'radial-gradient(ellipse at 50% 60%, rgba(255,0,64,0.08) 0%, rgba(0,0,0,0) 70%), #0a0a0a', border: '1px solid rgba(255,255,255,0.06)' }}
>
<CardPreview d={cardData} />
</div>
{config.back && (
<div className="mt-3 flex justify-center">
<button
type="button"
onClick={() => setSide(s => (s === 'front' ? 'back' : 'front'))}
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-300 hover:bg-white/10"
>
<RefreshCw className="h-3 w-3" />
{side === 'front' ? t('cardUi.back.showBack') : t('cardUi.back.showFront')}
</button>
</div>
)}
{!savedCard?.config && (
<p className="text-center text-xs text-gray-600 mt-4">{t('cardUi.public.notCustomised')}</p>
)}
<div className="mt-6 flex flex-col gap-2">
<Button asChild className="h-12 bg-gradient-to-r from-[#ff0040] to-[#ff6600] font-black uppercase gap-2">
<Link to={isMe ? '/card' : '/card'}>
<Sparkles className="h-4 w-4" />
{isMe ? t('cardUi.public.editYourCard') : t('cardUi.public.createYourOwn')}
</Link>
</Button>
<p className="text-[11px] text-gray-600 text-center">{t('cardUi.public.verifyNote')}</p>
</div>
</div>
);
}