card: full UX rebuild — hero preview, 3-tab nav, single-column layout, inline award form

- Logged-out state: proper hero with value prop copy instead of bare login form
- Hero card preview: full-width dark stage with theme-matched glow behind card
- Card slightly larger (360x227) with depth shadow
- Top tab bar: My Card / Badges (with count) / Award — replaces buried sections
- Single-column max-w-2xl layout — no more awkward 2-col grid on mobile
- Primary CTA: one big Share button, Print/Save/Copy as secondary 3-col row
- Card type as prominent 2-up tile choice, not a buried dropdown-style control
- Theme picker: 6-up square grid with emoji + label, ring on active
- Rides For + Sponsors side by side (saves vertical space)
- Stats as interactive tiles showing the actual numbers
- Skills as pill grid with live counter (X/6 selected)
- Badges tab: shows ReceivedBadgesPanel (was previously never rendered)
- Award tab: inline form, no dialog — cleaner flow, redirects to Badges on success
- AwardBadgeDialog kept for backward compat but Award tab uses AwardBadgeInline

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-23 20:20:26 +07:00
parent 2e32605948
commit f4cdafa145
2 changed files with 459 additions and 415 deletions

View file

@ -556,13 +556,14 @@ select {
/* Credit-card aspect ratio: 85.6mm × 54mm = 1.586:1 */ /* Credit-card aspect ratio: 85.6mm × 54mm = 1.586:1 */
.rider-card { .rider-card {
width: 340px; width: 360px;
height: 214px; height: 227px;
border-radius: 12px; border-radius: 14px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
font-family: 'Inter Variable', 'Inter', system-ui, sans-serif; font-family: 'Inter Variable', 'Inter', system-ui, sans-serif;
box-shadow: 0 8px 40px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4);
} }
/* Holographic shimmer overlay — appears on hover/focus for screen, /* Holographic shimmer overlay — appears on hover/focus for screen,

View file

@ -1106,23 +1106,23 @@ export default function RiderCardPage() {
const displayName = metadata?.name ?? genUserName(user?.pubkey ?? ''); const displayName = metadata?.name ?? genUserName(user?.pubkey ?? '');
const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : ''; const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : '';
// Received badges for the logged-in user
const { data: receivedBadgeEvents = [] } = useReceivedBadges(user?.pubkey ?? ''); const { data: receivedBadgeEvents = [] } = useReceivedBadges(user?.pubkey ?? '');
// Parse badge events into BadgeAward objects
const receivedBadges: BadgeAward[] = receivedBadgeEvents.flatMap(event => { const receivedBadges: BadgeAward[] = receivedBadgeEvents.flatMap(event => {
const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v); const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v);
const spot = event.tags.find(([k]) => k === 'spot')?.[1]; const spot = event.tags.find(([k]) => k === 'spot')?.[1];
return skills.map(skill => ({ return skills.map(skill => ({
skill, skill,
witnessName: genUserName(event.pubkey), // simplified for card visual witnessName: genUserName(event.pubkey),
witnessPubkey: event.pubkey, witnessPubkey: event.pubkey,
spotName: spot, spotName: spot,
timestamp: event.created_at, timestamp: event.created_at,
})); }));
}); });
// ── Form state ────────────────────────────────────────────────────────────── // ── Page tab: 'card' | 'badges' | 'award' ────────────────────────────────
const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award'>('card');
// ── Card state ────────────────────────────────────────────────────────────
const [cardType, setCardType] = useState<'rider' | 'park'>('rider'); const [cardType, setCardType] = useState<'rider' | 'park'>('rider');
const [theme, setTheme] = useState<ThemeId>('thug'); const [theme, setTheme] = useState<ThemeId>('thug');
const [holographic, setHolographic] = useState(true); const [holographic, setHolographic] = useState(true);
@ -1147,32 +1147,23 @@ export default function RiderCardPage() {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [rendering, setRendering] = useState(false); const [rendering, setRendering] = useState(false);
// ── Parse recipientNpub → pubkey ─────────────────────────────────────────
const recipientPubkey = (() => { const recipientPubkey = (() => {
if (!recipientNpub.trim()) return user?.pubkey ?? ''; if (!recipientNpub.trim()) return user?.pubkey ?? '';
try { try {
const d = nip19.decode(recipientNpub.trim()); const d = nip19.decode(recipientNpub.trim());
return d.type === 'npub' ? d.data : (user?.pubkey ?? ''); return d.type === 'npub' ? d.data : (user?.pubkey ?? '');
} catch { } catch { return user?.pubkey ?? ''; }
return user?.pubkey ?? '';
}
})(); })();
// ── Computed card data ───────────────────────────────────────────────────────
const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey; const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey;
const cardData: CardData = { const cardData: CardData = {
type: cardType, type: cardType, theme, holographic,
theme,
holographic,
name: cardType === 'rider' ? displayName : recipientName, name: cardType === 'rider' ? displayName : recipientName,
nip05: metadata?.nip05, nip05: metadata?.nip05,
avatar: metadata?.picture, avatar: metadata?.picture,
pubkey: cardPubkey, pubkey: cardPubkey,
npub: cardPubkey ? fullNpub(cardPubkey) : '', npub: cardPubkey ? fullNpub(cardPubkey) : '',
style, style, skills, sponsors, ridesFor,
skills,
sponsors,
ridesFor,
showSpots, showZaps, showEvents, showSpots, showZaps, showEvents,
spotsAdded: stats?.spotsAdded ?? 0, spotsAdded: stats?.spotsAdded ?? 0,
zapsReceived: stats?.zapsReceived ?? 0, zapsReceived: stats?.zapsReceived ?? 0,
@ -1181,103 +1172,47 @@ export default function RiderCardPage() {
? memberSince(metadata.created_at as number) ? memberSince(metadata.created_at as number)
: new Date().getFullYear().toString(), : new Date().getFullYear().toString(),
receivedBadges, receivedBadges,
issuerName, issuerName, recipientName, accessLevel, validThru,
recipientName,
accessLevel,
validThru,
}; };
// ── Render card via Canvas 2D API → returns a PNG data URL ──────────────
const renderCardToImage = useCallback(async (): Promise<string | null> => { const renderCardToImage = useCallback(async (): Promise<string | null> => {
setRendering(true); setRendering(true);
try { try {
const dataUrl = await drawCardToCanvas({ return await drawCardToCanvas({
type: cardData.type, type: cardData.type, themeId: cardData.theme,
themeId: cardData.theme, name: cardData.name, nip05: cardData.nip05,
name: cardData.name, avatarUrl: cardData.avatar, pubkey: cardData.pubkey, npub: cardData.npub,
nip05: cardData.nip05, style: cardData.style, skills: cardData.skills,
avatarUrl: cardData.avatar, sponsors: cardData.sponsors, ridesFor: cardData.ridesFor,
pubkey: cardData.pubkey, showSpots: cardData.showSpots, showZaps: cardData.showZaps, showEvents: cardData.showEvents,
npub: cardData.npub, spotsAdded: cardData.spotsAdded, zapsReceived: cardData.zapsReceived, eventsCreated: cardData.eventsCreated,
style: cardData.style, memberYear: cardData.memberYear, receivedBadges: cardData.receivedBadges,
skills: cardData.skills, issuerName: cardData.issuerName, recipientName: cardData.recipientName,
sponsors: cardData.sponsors, accessLevel: cardData.accessLevel, validThru: cardData.validThru,
ridesFor: cardData.ridesFor,
showSpots: cardData.showSpots,
showZaps: cardData.showZaps,
showEvents: cardData.showEvents,
spotsAdded: cardData.spotsAdded,
zapsReceived: cardData.zapsReceived,
eventsCreated: cardData.eventsCreated,
memberYear: cardData.memberYear,
receivedBadges: cardData.receivedBadges,
issuerName: cardData.issuerName,
recipientName: cardData.recipientName,
accessLevel: cardData.accessLevel,
validThru: cardData.validThru,
}); });
return dataUrl; } catch (err) { console.error('Card render failed:', err); return null; }
} catch (err) { finally { setRendering(false); }
console.error('Card render failed:', err);
return null;
} finally {
setRendering(false);
}
}, [cardData]); }, [cardData]);
// ── Print: render to image, open print window with the image ─────────────
const handlePrint = useCallback(async () => { const handlePrint = useCallback(async () => {
const dataUrl = await renderCardToImage(); const dataUrl = await renderCardToImage();
if (!dataUrl) { if (!dataUrl) { toast({ title: 'Could not render card', variant: 'destructive' }); return; }
toast({ title: 'Could not render card', description: 'Try again in a moment.', variant: 'destructive' });
return;
}
// 85.6mm × 54mm in points (1mm ≈ 2.835pt)
const w = Math.round(85.6 * 2.835); const w = Math.round(85.6 * 2.835);
const h = Math.round(54 * 2.835); const h = Math.round(54 * 2.835);
const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`); const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`);
if (!win) { if (!win) { toast({ title: 'Popup blocked', description: 'Allow popups for this site.', variant: 'destructive' }); return; }
toast({ title: 'Popup blocked', description: 'Allow popups for this site then try again.', variant: 'destructive' }); win.document.write(`<!DOCTYPE html><html><head><title>SkateSpots Rider Card</title><style>@page{size:85.6mm 54mm;margin:0}html,body{margin:0;padding:0;background:#000}img{display:block;width:85.6mm;height:54mm}</style></head><body><img src="${dataUrl}"/><script>window.onload=function(){window.focus();window.print()}<\/script></body></html>`);
return;
}
win.document.write(`<!DOCTYPE html>
<html>
<head>
<title>SkateSpots Rider Card</title>
<style>
@page { size: 85.6mm 54mm; margin: 0; }
html, body { margin: 0; padding: 0; background: #000; }
img { display: block; width: 85.6mm; height: 54mm; }
</style>
</head>
<body>
<img src="${dataUrl}" />
<script>
window.onload = function() {
window.focus();
window.print();
};
</script>
</body>
</html>`);
win.document.close(); win.document.close();
}, [renderCardToImage, toast]); }, [renderCardToImage, toast]);
// ── Download card as PNG ──────────────────────────────────────────────────
const handleDownload = useCallback(async () => { const handleDownload = useCallback(async () => {
const dataUrl = await renderCardToImage(); const dataUrl = await renderCardToImage();
if (!dataUrl) { if (!dataUrl) { toast({ title: 'Could not render card', variant: 'destructive' }); return; }
toast({ title: 'Could not render card', variant: 'destructive' });
return;
}
const a = document.createElement('a'); const a = document.createElement('a');
a.href = dataUrl; a.href = dataUrl;
a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`; a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`;
a.click(); a.click();
}, [renderCardToImage, cardData.licenseId, toast]); }, [renderCardToImage, cardData.npub, toast]);
const handleCopyNpub = useCallback(() => { const handleCopyNpub = useCallback(() => {
navigator.clipboard.writeText(npub); navigator.clipboard.writeText(npub);
@ -1285,76 +1220,59 @@ export default function RiderCardPage() {
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}, [npub]); }, [npub]);
// ── Share card digitally ──────────────────────────────────────────────────
const handleShare = useCallback(async () => { const handleShare = useCallback(async () => {
const dataUrl = await renderCardToImage(); const dataUrl = await renderCardToImage();
if (!dataUrl) { if (!dataUrl) { toast({ title: 'Could not render card', variant: 'destructive' }); return; }
toast({ title: 'Could not render card', variant: 'destructive' });
return;
}
// Convert data URL to File for Web Share API
const res = await fetch(dataUrl); const res = await fetch(dataUrl);
const blob = await res.blob(); const blob = await res.blob();
const file = new File([blob], `skatespot-card-${cardData.name.replace(/\s+/g, '-')}.png`, { type: 'image/png' }); const file = new File([blob], `skatespot-card-${cardData.name.replace(/\s+/g, '-')}.png`, { type: 'image/png' });
if (navigator.share && navigator.canShare?.({ files: [file] })) { if (navigator.share && navigator.canShare?.({ files: [file] })) {
try { try {
await navigator.share({ await navigator.share({ title: `${cardData.name}'s SkateSpots Card`, text: `Check out my SkateSpots Skater License 🛹\nskate-spots.org/${cardData.npub}`, files: [file] });
title: `${cardData.name}'s SkateSpots Card`,
text: `Check out my SkateSpots Skater License 🛹\nskate-spots.org/${cardData.npub}`,
files: [file],
});
return; return;
} catch { } catch { /* fall through */ }
// user cancelled or share failed — fall through to copy
} }
} await navigator.clipboard.writeText(`https://skate-spots.org/${cardData.npub}`);
toast({ title: '🔗 Link copied!' });
// Fallback: copy profile link to clipboard
const link = `https://skate-spots.org/${cardData.npub}`;
await navigator.clipboard.writeText(link);
toast({ title: '🔗 Link copied!', description: 'Share it anywhere — the link opens your card profile.' });
}, [renderCardToImage, cardData.name, cardData.npub, toast]); }, [renderCardToImage, cardData.name, cardData.npub, toast]);
const handlePublishParkCard = useCallback(() => { const handlePublishParkCard = useCallback(() => {
if (!user) return; if (!user || !recipientPubkey) { toast({ title: "Enter the rider's npub", variant: 'destructive' }); return; }
if (!recipientPubkey) {
toast({ title: "Enter the rider's npub", variant: 'destructive' });
return;
}
const tags: string[][] = [ const tags: string[][] = [
['d', `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`], ['d', `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`],
['p', recipientPubkey], ['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'],
['t', 'skatepark-access'], ['issuer', issuerName], ['access-level', accessLevel], ['theme', theme],
['alt', 'Skatepark Access Card'],
['issuer', issuerName],
['access-level', accessLevel],
['theme', theme],
]; ];
if (validThru) tags.push(['valid-thru', validThru]); if (validThru) tags.push(['valid-thru', validThru]);
publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, {
publishEvent( onSuccess: () => toast({ title: '✅ Park Access Card published!' }),
{ kind: PARK_ACCESS_CARD_KIND, content: '', tags },
{
onSuccess: () => toast({ title: '✅ Park Access Card published!', description: 'The rider can now verify it.' }),
onError: (e) => toast({ title: 'Publish failed', description: String(e), variant: 'destructive' }), onError: (e) => toast({ title: 'Publish failed', description: String(e), variant: 'destructive' }),
}, });
);
}, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, publishEvent, toast]); }, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, publishEvent, toast]);
const toggleSkill = (id: string) => const toggleSkill = (id: string) =>
setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]); setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
// ── Render: logged out ──────────────────────────────────────────────────── // ── Logged out ────────────────────────────────────────────────────────────
if (!user) { if (!user) {
return ( return (
<Layout> <Layout>
<div className="container max-w-lg px-4 py-20 text-center"> <div className="min-h-[70vh] flex flex-col items-center justify-center px-4 text-center">
<div className="text-6xl mb-4">🛹</div> {/* Hero */}
<h1 className="text-3xl font-black uppercase mb-3 neon-red">Rider Card</h1> <div className="mb-8 relative">
<p className="text-gray-400 mb-6">Log in to generate your personal Rider Card.</p> <div className="absolute inset-0 blur-3xl opacity-20 bg-gradient-to-br from-[#ff0040] to-[#ccff00] rounded-full" />
<div className="relative text-7xl mb-4">🛹</div>
</div>
<h1 className="text-4xl font-black uppercase mb-2">
<span className="neon-red">RIDER</span>
<span className="text-white"> CARD</span>
</h1>
<p className="text-gray-400 max-w-sm mb-2">
Your verifiable skater identity. Nostr-signed, print-ready, impossible to fake.
</p>
<p className="text-gray-600 text-sm mb-8 max-w-xs">
Earn witnessed skill badges from other skaters. Get park access passes. Share your card anywhere.
</p>
<LoginArea className="justify-center" /> <LoginArea className="justify-center" />
</div> </div>
</Layout> </Layout>
@ -1363,180 +1281,171 @@ export default function RiderCardPage() {
return ( return (
<Layout> <Layout>
<div className="container max-w-5xl px-4 py-10"> <div className="max-w-2xl mx-auto px-4 pb-16">
{/* Page header */}
<div className="mb-8"> {/* ── Page header ── */}
<h1 className="text-4xl font-black uppercase mb-2"> <div className="pt-8 pb-6 text-center">
<span className="neon-red">SKATE</span> <h1 className="text-3xl font-black uppercase tracking-tight mb-1">
<span className="text-white"> CARDS</span> <span className="neon-red">RIDER</span>
<span className="text-white"> CARD</span>
</h1> </h1>
<p className="text-gray-400 text-sm"> <p className="text-gray-500 text-xs tracking-widest uppercase">Nostr-signed · Print-ready · QR-verified</p>
Print-ready credit-card size (85.6 × 54 mm). QR-verified via Nostr.
</p>
</div> </div>
<div className="grid lg:grid-cols-2 gap-8"> {/* ── Top tabs: My Card / My Badges / Award Badge ── */}
<div className="flex rounded-xl overflow-hidden border border-white/10 mb-8 bg-black/40">
{/* Left: Card preview */} {([
<div className="flex flex-col items-center gap-5"> { id: 'card', label: '🪪 My Card' },
<RiderCardFront d={cardData} printRef={printRef} /> { id: 'badges', label: `🏅 Badges${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` },
{ id: 'award', label: '⭐ Award' },
{/* Primary actions */} ] as const).map(tab => (
<div className="flex gap-2 flex-wrap justify-center w-full"> <button
<Button key={tab.id}
onClick={handlePrint} onClick={() => setPageTab(tab.id)}
disabled={rendering} className={cn(
className="bg-[#ff0040] hover:bg-[#cc0030] gap-2 font-black uppercase flex-1 min-w-[130px]" 'flex-1 py-3 text-xs font-black uppercase tracking-widest transition-all',
pageTab === tab.id
? 'bg-[#ff0040] text-white'
: 'text-gray-500 hover:text-gray-300 hover:bg-white/5'
)}
> >
{rendering ? <Loader2 className="h-4 w-4 animate-spin" /> : <Printer className="h-4 w-4" />} {tab.label}
{rendering ? 'Rendering…' : 'Print'} </button>
</Button> ))}
</div>
{/* ══════════════════════════════════════════════
TAB: MY CARD
══════════════════════════════════════════════ */}
{pageTab === 'card' && (
<div className="space-y-8">
{/* ── Hero card preview ── */}
<div className="flex flex-col items-center gap-4">
{/* Stage */}
<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)' }}
>
{/* Subtle glow behind card */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-32 blur-3xl opacity-30"
style={{ background: `radial-gradient(ellipse, ${theme === 'lime' ? '#ccff00' : theme === 'midnight' ? '#64a0ff' : theme === 'wood' ? '#f5c842' : theme === 'chrome' ? '#888' : theme === 'sunset' ? '#ff6020' : '#ff0040'}, transparent)` }}
/>
<RiderCardFront d={cardData} printRef={printRef} />
</div>
{/* Primary CTA */}
<Button <Button
onClick={handleShare} onClick={handleShare}
disabled={rendering} disabled={rendering}
className="bg-[#ccff00] hover:bg-[#b8e600] text-black gap-2 font-black uppercase flex-1 min-w-[130px]" size="lg"
className="w-full h-14 bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:opacity-90 font-black uppercase text-base rounded-xl shadow-xl shadow-[#ff0040]/20 gap-2"
> >
{rendering ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />} {rendering ? <Loader2 className="h-5 w-5 animate-spin" /> : <Share2 className="h-5 w-5" />}
Share {rendering ? 'Rendering…' : 'Share Card'}
</Button> </Button>
{/* Secondary actions row */}
<div className="grid grid-cols-3 gap-2 w-full">
<Button <Button
variant="outline" variant="outline"
onClick={handleDownload} onClick={handleDownload}
disabled={rendering} disabled={rendering}
className="gap-2 border-gray-700 hover:border-gray-500 flex-1 min-w-[130px]" className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
> >
{rendering ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />} <Download className="h-3.5 w-3.5" />
Save PNG Save PNG
</Button> </Button>
</div> <Button
variant="outline"
{/* Secondary actions */} onClick={handlePrint}
<div className="flex gap-2 flex-wrap justify-center w-full"> disabled={rendering}
className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
>
<Printer className="h-3.5 w-3.5" />
Print
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={handleCopyNpub} onClick={handleCopyNpub}
className="gap-2 border-gray-700 text-xs" className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
> >
{copied ? <Check className="h-3 w-3 text-green-400" /> : <Copy className="h-3 w-3" />} {copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? 'Copied!' : 'Copy npub'} {copied ? 'Copied!' : 'Copy ID'}
</Button> </Button>
<Button </div>
variant="outline"
onClick={() => setShowAwardDialog(true)}
className="gap-2 border-yellow-500/40 text-yellow-400 hover:bg-yellow-500/10 text-xs"
>
<Award className="h-3 w-3" />
Award Badge
</Button>
{cardType === 'park' && (
<Button
variant="outline"
onClick={handlePublishParkCard}
className="gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs"
>
<Zap className="h-3 w-3" />
Publish to Nostr
</Button>
)}
</div> </div>
<p className="text-gray-600 text-xs text-center max-w-xs"> {/* ── Card type toggle ── */}
<strong className="text-gray-500">Share</strong> uses your device's share sheet (WhatsApp, iMessage, etc.) or copies the link. <strong className="text-gray-500">Save PNG</strong> downloads for print shops.
</p>
</div>
{/* Right: Controls */}
<div className="space-y-5">
{/* Card type */}
<div> <div>
<Label className="mb-2 block text-xs uppercase tracking-widest text-gray-500">Card Type</Label> <p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2">Card Type</p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{([ {([
{ id: 'rider', label: 'Skater License', icon: User }, { id: 'rider', label: 'Skater License', sub: 'Your personal rider identity', icon: User },
{ id: 'park', label: 'Park Access', icon: Building2 }, { id: 'park', label: 'Park Access', sub: 'Issue passes as a park owner', icon: Building2 },
] as const).map(({ id, label, icon: Icon }) => ( ] as const).map(({ id, label, sub, icon: Icon }) => (
<button <button
key={id} key={id}
onClick={() => setCardType(id)} onClick={() => setCardType(id)}
className={cn( className={cn(
'p-3 rounded-xl border text-xs font-bold uppercase flex flex-col items-center gap-1 transition-all', 'p-4 rounded-xl border text-left transition-all',
cardType === id cardType === id
? 'bg-[#ff0040]/20 border-[#ff0040] text-white' ? 'bg-[#ff0040]/10 border-[#ff0040]/60 text-white'
: 'bg-black/40 border-gray-700 text-gray-400 hover:border-gray-500' : 'bg-black/30 border-white/8 text-gray-500 hover:border-white/20 hover:text-gray-300'
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className={cn('h-5 w-5 mb-2', cardType === id ? 'text-[#ff0040]' : 'text-gray-600')} />
{label} <div className="text-xs font-black uppercase">{label}</div>
<div className="text-[10px] mt-0.5 opacity-60">{sub}</div>
</button> </button>
))} ))}
</div> </div>
</div> </div>
{/* Park access note */} {/* ── Theme picker ── */}
{cardType === 'park' && (
<div className="flex items-start gap-2 p-3 rounded-xl bg-blue-500/5 border border-blue-500/20 text-xs text-gray-400">
<Info className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1.5">
<div> <div>
<span className="font-bold text-blue-400">Park owners only.</span>{' '} <p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2 flex items-center gap-1.5">
Park Access Cards should only be issued by the park's own Nostr key — the same key that published the park listing on SkateSpots.
</div>
<div>
<span className="font-bold text-gray-300">How verification works:</span>{' '}
The card is a signed message on the Nostr network (like a public post only you could have written).
Anyone can scan the QR, look up the event on any Nostr relay, and see which key signed it —
if that key matches the park's registered identity, the card is genuine.
No central authority needed.
</div>
<div className="text-gray-500">
Self-issued cards for private parks will show a mismatch warning — the park owner's key won't match.
</div>
</div>
</div>
)}
{/* Theme picker */}
<div>
<Label className="mb-2 flex items-center gap-2 text-xs uppercase tracking-widest text-gray-500">
<Palette className="h-3 w-3" /> Theme <Palette className="h-3 w-3" /> Theme
</Label> </p>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-6 gap-2">
{THEMES.map(t => ( {THEMES.map(t => (
<button <button
key={t.id} key={t.id}
onClick={() => setTheme(t.id)} onClick={() => setTheme(t.id)}
className={cn( className={cn(
'p-2.5 rounded-lg border text-xs font-bold transition-all', 'aspect-square rounded-xl border-2 transition-all flex flex-col items-center justify-center gap-0.5',
`card-theme-${t.id}`, `card-theme-${t.id}`,
theme === t.id ? 'ring-2 ring-white/40 scale-105' : 'opacity-70 hover:opacity-100' theme === t.id
? 'ring-2 ring-white/50 ring-offset-1 ring-offset-black scale-105 border-white/30'
: 'border-white/10 opacity-60 hover:opacity-90'
)} )}
> >
<div className="text-base mb-0.5">{t.emoji}</div> <span className="text-xl">{t.emoji}</span>
<div className="text-[10px] font-black">{t.label}</div> <span className="text-[8px] font-black uppercase opacity-80">{t.label}</span>
<div className="text-[9px] opacity-60">{t.desc}</div>
</button> </button>
))} ))}
</div> </div>
</div> </div>
{/* Holographic toggle */} {/* ── Holographic toggle ── */}
<div className="flex items-center justify-between p-3 rounded-xl bg-black/40 border border-gray-700"> <div className="flex items-center justify-between px-4 py-3 rounded-xl bg-white/3 border border-white/8">
<div> <div>
<div className="text-sm font-bold">✨ Holographic Shimmer</div> <div className="text-sm font-bold">✨ Holographic Shimmer</div>
<div className="text-xs text-gray-500">Animated foil effect on screen</div> <div className="text-xs text-gray-500">Animated foil effect</div>
</div> </div>
<Switch checked={holographic} onCheckedChange={setHolographic} /> <Switch checked={holographic} onCheckedChange={setHolographic} />
</div> </div>
{/* ── RIDER fields ─────────────────────────────────────────── */} {/* ── RIDER fields ── */}
{cardType === 'rider' && ( {cardType === 'rider' && (
<div className="space-y-4"> <div className="space-y-5">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">Card Details</p>
{/* Style */} {/* Style */}
<div> <div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Skate Style</Label> <Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Skate Style</Label>
<Select value={style} onValueChange={setStyle}> <Select value={style} onValueChange={setStyle}>
<SelectTrigger className="bg-black/50 border-gray-700"> <SelectTrigger className="bg-black/50 border-white/10 h-11">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1545,65 +1454,60 @@ export default function RiderCardPage() {
</Select> </Select>
</div> </div>
{/* Rides For shop */} {/* Rides For + Sponsors — side by side */}
<div className="grid grid-cols-2 gap-3">
<div> <div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block"> <Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Rides For</Label>
Rides For (shop)
</Label>
<Input <Input
placeholder="e.g. Familia Skateshop, Supra Skateboards…" placeholder="Local shop…"
value={ridesFor} value={ridesFor}
onChange={e => setRidesFor(e.target.value)} onChange={e => setRidesFor(e.target.value)}
className="bg-black/50 border-gray-700 text-sm" className="bg-black/50 border-white/10 text-sm h-11"
/> />
<p className="text-[11px] text-gray-600 mt-1">
The local shop you represent. Different from sponsors.
</p>
</div> </div>
{/* Sponsors */}
<div> <div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block"> <Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Sponsors</Label>
Sponsors (brands/companies)
</Label>
<Input <Input
placeholder="e.g. Baker, Vans, Independent…" placeholder="Brands…"
value={sponsors} value={sponsors}
onChange={e => setSponsors(e.target.value)} onChange={e => setSponsors(e.target.value)}
className="bg-black/50 border-gray-700 text-sm" className="bg-black/50 border-white/10 text-sm h-11"
/> />
</div> </div>
</div>
{/* Stats toggles */} {/* Stats toggles */}
<div> <div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-2 block">Show Stats</Label> <Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">Show on Card</Label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{[ {[
{ label: `Spots (${stats?.spotsAdded ?? 0})`, state: showSpots, set: setShowSpots }, { label: 'Spots', value: stats?.spotsAdded ?? 0, state: showSpots, set: setShowSpots },
{ label: `Zaps (${stats?.zapsReceived ?? 0})`, state: showZaps, set: setShowZaps }, { label: 'Zaps ⚡', value: stats?.zapsReceived ?? 0, state: showZaps, set: setShowZaps },
{ label: `Events (${stats?.eventsCreated ?? 0})`, state: showEvents, set: setShowEvents }, { label: 'Events', value: stats?.eventsCreated ?? 0, state: showEvents, set: setShowEvents },
].map(({ label, state, set }) => ( ].map(({ label, value, state, set }) => (
<button <button
key={label} key={label}
onClick={() => set(!state)} onClick={() => set(!state)}
className={cn( className={cn(
'p-2 rounded-lg border text-[10px] font-bold transition-all', 'p-3 rounded-xl border text-center transition-all',
state state
? 'bg-[#ff0040]/20 border-[#ff0040] text-white' ? 'bg-[#ff0040]/10 border-[#ff0040]/40 text-white'
: 'bg-black/40 border-gray-700 text-gray-500' : 'bg-black/30 border-white/8 text-gray-600'
)} )}
> >
{label} <div className={cn('text-lg font-black', state ? 'text-[#ff0040]' : 'text-gray-700')}>{value}</div>
<div className="text-[9px] uppercase tracking-wide mt-0.5">{label}</div>
</button> </button>
))} ))}
</div> </div>
</div> </div>
{/* Skill badges (self-selected) */} {/* Skills */}
<div> <div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-2 block"> <div className="flex items-center justify-between mb-2">
Skills (self-claimed, up to 6) <Label className="text-[10px] uppercase tracking-widest text-gray-500">Skills</Label>
</Label> <span className="text-[10px] text-gray-600">{skills.length}/6 selected</span>
</div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{SKILL_OPTIONS.map(s => ( {SKILL_OPTIONS.map(s => (
<button <button
@ -1611,54 +1515,64 @@ export default function RiderCardPage() {
onClick={() => toggleSkill(s.id)} onClick={() => toggleSkill(s.id)}
disabled={!skills.includes(s.id) && skills.length >= 6} disabled={!skills.includes(s.id) && skills.length >= 6}
className={cn( className={cn(
'px-2 py-1 rounded-full text-[11px] font-bold border transition-all', 'px-2.5 py-1.5 rounded-lg text-xs font-bold border transition-all',
skills.includes(s.id) skills.includes(s.id)
? 'bg-[#ff0040]/20 border-[#ff0040] text-white' ? 'bg-[#ff0040]/15 border-[#ff0040]/50 text-white'
: 'bg-black/40 border-gray-700 text-gray-400 hover:border-gray-500 disabled:opacity-30' : 'bg-black/30 border-white/8 text-gray-500 hover:border-white/20 disabled:opacity-25 disabled:cursor-not-allowed'
)} )}
> >
{s.icon} {s.label} {s.icon} {s.label}
</button> </button>
))} ))}
</div> </div>
<p className="text-[11px] text-gray-600 mt-1.5"> <p className="text-[10px] text-gray-600 mt-2">
⭐ Gold badges on the card are <em>witnessed</em> by others — check the Badges tab. ⭐ Gold badges on your card come from other skaters witnessing you — see the <button onClick={() => setPageTab('badges')} className="text-yellow-500 underline">Badges tab</button>.
</p> </p>
</div> </div>
</div> </div>
)} )}
{/* ── PARK fields ──────────────────────────────────────────── */} {/* ── PARK fields ── */}
{cardType === 'park' && ( {cardType === 'park' && (
<div className="space-y-4"> <div className="space-y-5">
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/5 border border-blue-500/20 text-xs text-gray-400">
<Info className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div> <div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Park Name</Label> <span className="font-bold text-blue-300">Park owners only.</span>{' '}
Issue this using the same Nostr key that published your park listing. Anyone can scan the QR and verify the signature on-chain — no central authority needed.
</div>
</div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">Park Details</p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Park Name</Label>
<Input value={issuerName} onChange={e => setIssuerName(e.target.value)} <Input value={issuerName} onChange={e => setIssuerName(e.target.value)}
placeholder="e.g. The Berrics, FDR Skatepark…" className="bg-black/50 border-gray-700" /> placeholder="The Berrics…" className="bg-black/50 border-white/10 h-11" />
</div> </div>
<div> <div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Rider Name (on card)</Label> <Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Rider Name</Label>
<Input value={recipientName} onChange={e => setRecipientName(e.target.value)} <Input value={recipientName} onChange={e => setRecipientName(e.target.value)}
placeholder="Full name on card" className="bg-black/50 border-gray-700" /> placeholder="Name on card" className="bg-black/50 border-white/10 h-11" />
</div> </div>
</div>
<div> <div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block"> <Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Rider's Nostr ID (npub)</Label>
Rider's Nostr Address (npub)
</Label>
<Input <Input
value={recipientNpub} value={recipientNpub}
onChange={e => setRecipientNpub(e.target.value)} onChange={e => setRecipientNpub(e.target.value)}
placeholder="npub1… — their Nostr identity ties the card to them" placeholder="npub1…"
className="bg-black/50 border-gray-700 font-mono text-xs" className="bg-black/50 border-white/10 font-mono text-xs h-11"
/> />
<p className="text-[11px] text-gray-600 mt-1">
Leave blank to issue to yourself. The card is signed by <em>your</em> key as the park owner.
</p>
</div> </div>
<div className="grid grid-cols-2 gap-3">
<div> <div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Access Level</Label> <Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Access Level</Label>
<Select value={accessLevel} onValueChange={setAccessLevel}> <Select value={accessLevel} onValueChange={setAccessLevel}>
<SelectTrigger className="bg-black/50 border-gray-700"><SelectValue /></SelectTrigger> <SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{['Full Access','Supervised','Beginner Zone','Street Only','Bowl Only','Members Only'].map(l => {['Full Access','Supervised','Beginner Zone','Street Only','Bowl Only','Members Only'].map(l =>
<SelectItem key={l} value={l}>{l}</SelectItem>)} <SelectItem key={l} value={l}>{l}</SelectItem>)}
@ -1666,40 +1580,169 @@ export default function RiderCardPage() {
</Select> </Select>
</div> </div>
<div> <div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Valid Thru</Label> <Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Valid Thru</Label>
<Input type="text" value={validThru} onChange={e => setValidThru(e.target.value)} <Input type="text" value={validThru} onChange={e => setValidThru(e.target.value)}
placeholder="e.g. 2026-12-31" className="bg-black/50 border-gray-700" /> placeholder="2026-12-31" className="bg-black/50 border-white/10 h-11" />
</div>
</div> </div>
<div className="p-3 rounded-xl bg-[#ccff00]/5 border border-[#ccff00]/20 text-xs text-gray-400 space-y-2"> <Button
<div className="font-bold text-[#ccff00]">🔐 How the park verifies it's real</div> onClick={handlePublishParkCard}
<div> className="w-full h-12 bg-[#ccff00] hover:bg-[#b8e600] text-black font-black uppercase gap-2"
When you publish this card, it's signed with your Nostr private key and broadcast to relays. >
The park's staff can scan the QR on any printed card and instantly see: <Zap className="h-4 w-4" />
Publish to Nostr
</Button>
</div> </div>
<ul className="list-disc list-inside space-y-1 text-gray-500"> )}
<li>Who issued it (your npub)</li>
<li>Who it was issued to (the rider's npub)</li>
<li>The access level and expiry</li>
<li>The exact timestamp it was signed</li>
</ul>
<div>
A rider can't fake this card — they'd need your private key to forge your signature.
</div> </div>
)}
{/* ══════════════════════════════════════════════
TAB: MY BADGES
══════════════════════════════════════════════ */}
{pageTab === 'badges' && (
<div className="space-y-6">
<div className="text-center py-2">
<p className="text-gray-400 text-sm max-w-xs mx-auto">
These badges were witnessed and signed by other skaters on Nostr. They appear as gold ⭐ chips on your card.
</p>
</div>
<ReceivedBadgesPanel pubkey={user.pubkey} />
<div className="pt-2 border-t border-white/8">
<p className="text-xs text-gray-600 text-center mb-3">Want to earn more? Skate in front of someone and ask them to witness your trick.</p>
<Button
variant="outline"
onClick={() => setPageTab('award')}
className="w-full border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 gap-2"
>
<Award className="h-4 w-4" />
Award a Badge to Someone Else
</Button>
</div> </div>
</div> </div>
)} )}
</div> {/* ══════════════════════════════════════════════
TAB: AWARD BADGE (inline, no dialog)
══════════════════════════════════════════════ */}
{pageTab === 'award' && (
<div className="space-y-5">
<div className="flex items-center gap-3 p-4 rounded-xl bg-yellow-500/5 border border-yellow-500/20">
<Award className="h-5 w-5 text-yellow-400 flex-shrink-0" />
<div>
<div className="text-sm font-bold text-yellow-300">You saw it happen</div>
<div className="text-xs text-gray-400 mt-0.5">Sign the proof on Nostr. It becomes part of their permanent record — cryptographically tied to your key.</div>
</div> </div>
</div> </div>
{/* Award badge dialog */}
<AwardBadgeDialog <AwardBadgeDialog
open={showAwardDialog} open={showAwardDialog}
onClose={() => setShowAwardDialog(false)} onClose={() => setShowAwardDialog(false)}
issuerPubkey={user.pubkey} issuerPubkey={user.pubkey}
/> />
{/* Inline award form — replaces the dialog on this tab */}
<AwardBadgeInline issuerPubkey={user.pubkey} onSuccess={() => setPageTab('badges')} />
</div>
)}
</div>
</Layout> </Layout>
); );
} }
// ─── Inline award form (used on the Award tab) ────────────────────────────────
function AwardBadgeInline({ issuerPubkey, onSuccess }: { issuerPubkey: string; onSuccess: () => void }) {
const { mutate: publishEvent, isPending } = useNostrPublish();
const { toast } = useToast();
const [recipientNpub, setRecipientNpub] = useState('');
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [spotName, setSpotName] = useState('');
const toggleSkill = (id: string) =>
setSelectedSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
const handleAward = () => {
if (!recipientNpub.trim() || selectedSkills.length === 0) {
toast({ title: 'Add rider npub + at least one skill', variant: 'destructive' });
return;
}
let recipientHex = '';
try {
const decoded = nip19.decode(recipientNpub.trim());
if (decoded.type !== 'npub') throw new Error();
recipientHex = decoded.data;
} catch {
toast({ title: 'Invalid npub', variant: 'destructive' });
return;
}
const tags: string[][] = [
['p', recipientHex], ['t', 'skill-badge'],
['alt', `Skill badge awarded: ${selectedSkills.join(', ')}`],
...selectedSkills.map(s => ['skill', s]),
];
if (spotName.trim()) tags.push(['spot', spotName.trim()]);
publishEvent({ kind: SKILL_BADGE_KIND, content: '', tags }, {
onSuccess: () => {
toast({ title: '🏅 Badge awarded!', description: `${selectedSkills.join(', ')} sent.` });
setRecipientNpub(''); setSelectedSkills([]); setSpotName('');
onSuccess();
},
onError: (e) => toast({ title: 'Failed', description: String(e), variant: 'destructive' }),
});
};
return (
<div className="space-y-5">
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Rider's Nostr ID (npub)</Label>
<Input
value={recipientNpub}
onChange={e => setRecipientNpub(e.target.value)}
placeholder="npub1…"
className="bg-black/50 border-white/10 font-mono text-xs h-11"
/>
</div>
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">Skills you witnessed</Label>
<div className="flex flex-wrap gap-1.5">
{SKILL_OPTIONS.map(s => (
<button
key={s.id}
onClick={() => toggleSkill(s.id)}
className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-bold border transition-all',
selectedSkills.includes(s.id)
? 'bg-yellow-500/15 border-yellow-400/50 text-yellow-200'
: 'bg-black/30 border-white/8 text-gray-500 hover:border-white/20'
)}
>
{s.icon} {s.label}
</button>
))}
</div>
</div>
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Where did you see it? <span className="normal-case text-gray-600">(optional)</span></Label>
<Input
value={spotName}
onChange={e => setSpotName(e.target.value)}
placeholder="MACBA, The Berrics, Burnside…"
className="bg-black/50 border-white/10 text-sm h-11"
/>
</div>
<Button
onClick={handleAward}
disabled={isPending || selectedSkills.length === 0 || !recipientNpub.trim()}
className="w-full h-12 bg-yellow-500 hover:bg-yellow-400 text-black font-black uppercase gap-2"
>
<Award className="h-4 w-4" />
{isPending ? 'Signing on Nostr…' : 'Award Badge'}
</Button>
</div>
);
}