SkateSpots/src/pages/PublicCardPage.tsx
SkateSpots 733dc5200e chore(card): audit cosmetics — zoom preview, translated title, drop legacy fields, clean URLs
- U2: CardPreview is zoomable (click for a full-screen overlay); used in the
  studio, 'My passes' and the public card.
- U4: the page title is translated (Rider Card / Carné de Rider) — was hardcoded
  English.
- L4: removed the legacy style/skills from RiderCardConfig + CardData (the card
  derives style/level and shows only witnessed badges).
- I2: cardVerifyUrl/cardPublicUrl are clean (/verify/<naddr>, /card/<npub>) and a
  startup shim rewrites those paths to the hash (old /#/ links still work).
2026-10-02 14:53:12 +07:00

154 lines
5.9 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 { useRiderCard, useRiderBadges, useRiderRoles, useCardProfile } 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 } from '@/lib/riderCard';
import { SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
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 cardProfile = useCardProfile(pubkey ?? undefined);
const metadata = cardProfile.data;
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : '');
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 derivedStyle = (skaterProfile?.styles ?? []).map((s) => SKATER_STYLES[s]?.label ?? s).join(' · ');
const derivedLevel = skaterProfile?.level
? (SKATER_LEVELS[skaterProfile.level]?.label ?? '')
: (skaterProfile?.customLevel ?? '');
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: config.holographic ?? true,
name: displayName,
nip05: metadata?.nip05,
avatar: metadata?.picture,
pubkey,
npub: nip19.npubEncode(pubkey),
style: derivedStyle,
level: derivedLevel || undefined,
sponsors: config.sponsors,
ridesFor: config.ridesFor,
showCrews: config.showCrews,
showLocal: config.showLocal,
showRoles: config.showRoles,
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} zoomable />
</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>
);
}