154 lines
6 KiB
TypeScript
154 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 { 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] ? t(SKATER_STYLES[s].labelKey) : s)).join(' · ');
|
|
const derivedLevel = skaterProfile?.level
|
|
? (SKATER_LEVELS[skaterProfile.level] ? t(SKATER_LEVELS[skaterProfile.level].labelKey) : '')
|
|
: (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>
|
|
);
|
|
}
|