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:
parent
2e32605948
commit
f4cdafa145
2 changed files with 459 additions and 415 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue