- /card gains a fourth type: Event. Renders an event (kind 31923) you organise or RSVP'd going/interested to (useMyRsvpCoords, kind 31925) as a passport/ wristband: title, date/time, venue, cover, who it admits, going count. Derived from the event's own signed event — its QR verifies that event; /verify/:naddr renders kind 31923 too. React + canvas renderers; i18n. - /review gains a dedicated Claims tab (pending private-spot ownership claims), replacing the banner that sat above the tabs.
1673 lines
76 KiB
TypeScript
1673 lines
76 KiB
TypeScript
/**
|
||
* RiderCardPage — /card
|
||
*
|
||
* Card types:
|
||
* 1. Rider License — personal card auto-filled from Nostr profile
|
||
* 2. Park Access Card — issued by a park owner to a rider (signed Nostr event)
|
||
*
|
||
* Badge System (kind 1293):
|
||
* - Anyone can award a skill badge to any rider
|
||
* - Badges appear on the rider's card with the witness's name
|
||
*
|
||
* 6 visual themes: THUG · LIME · CHROME · WOOD · MIDNIGHT · SUNSET
|
||
* Print-ready: credit-card size (85.6×54mm), @media print isolates card
|
||
*/
|
||
|
||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||
import { useSeoMeta } from '@unhead/react';
|
||
import { nip19 } from 'nostr-tools';
|
||
// QRCode is imported lazily inside drawCardToCanvas() to avoid canvas API
|
||
// access at module load time (crashes the preview environment)
|
||
|
||
import { LoginArea } from '@/components/auth/LoginArea';
|
||
import { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard';
|
||
import { THEMES, SKILL_OPTIONS, STYLES, fullNpub, memberSince, roleMeta, formatCardDate, type ThemeId } from '@/lib/riderCard';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Switch } from '@/components/ui/switch';
|
||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||
import { Badge } from '@/components/ui/badge';
|
||
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useAuthor } from '@/hooks/useAuthor';
|
||
import { useRiderStats } from '@/hooks/useRiderStats';
|
||
import { useRiderCard, useRiderRoles } from '@/hooks/useRiderCard';
|
||
import { useUserCrews } from '@/hooks/useCrews';
|
||
import { useUserSkaterProfile } from '@/hooks/useSkaters';
|
||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||
import { useSkateEvents } from '@/hooks/useSkateEvents';
|
||
import { useMyRsvpCoords, useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import { useNostr } from '@/hooks/useNostr';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { genUserName } from '@/lib/genUserName';
|
||
import { useTranslation } from '@/contexts/language';
|
||
import { cn } from '@/lib/utils';
|
||
import { RIDER_CARD_KIND, RIDER_CARD_D, PARK_ACCESS_CARD_KIND, SKILL_BADGE_KIND, cardVerifyUrl, riderCardTags, type RiderCardConfig } from '@/lib/riderCard';
|
||
import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND } from '@/lib/types';
|
||
import type { NostrEvent } from '@nostrify/nostrify';
|
||
import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users, Ticket } from 'lucide-react';
|
||
// ─── Card canvas renderer ─────────────────────────────────────────────────────
|
||
// Draws the card entirely via Canvas 2D API — 100% reliable export,
|
||
// no CSS capture, no scaling issues, no background-stripping.
|
||
// Card is 856×540px (10× credit-card mm, high-res for printing).
|
||
|
||
const CARD_W = 856;
|
||
const CARD_H = 540;
|
||
|
||
interface CanvasCardData {
|
||
type: 'rider' | 'park' | 'crew' | 'event';
|
||
themeId: string;
|
||
name: string;
|
||
nip05?: string;
|
||
avatarUrl?: string;
|
||
pubkey: string;
|
||
npub: string;
|
||
style: string;
|
||
skills: string[];
|
||
sponsors: string;
|
||
ridesFor: string;
|
||
showSpots: boolean;
|
||
showZaps: boolean;
|
||
showEvents: boolean;
|
||
spotsAdded: number;
|
||
zapsReceived: number;
|
||
eventsCreated: number;
|
||
memberYear: string;
|
||
receivedBadges: BadgeAward[];
|
||
/** When set, the printed QR opens the verify page for this card. */
|
||
verifyUrl?: string;
|
||
local?: string;
|
||
crews?: string[];
|
||
roles?: string[];
|
||
crew?: CardData['crew'];
|
||
event?: CardData['event'];
|
||
// Park
|
||
issuerName: string;
|
||
recipientName: string;
|
||
accessLevel: string;
|
||
validThru: string;
|
||
}
|
||
|
||
// Theme palette for canvas drawing
|
||
const THEME_PALETTE: Record<string, {
|
||
bg1: string; bg2: string; bg3: string;
|
||
accent: string; accent2: string;
|
||
stripe1: string; stripe2: string;
|
||
muted: string; chipBg: string; chipBorder: string;
|
||
text: string; shopColor: string; sponsorColor: string;
|
||
sidebarBg: string;
|
||
}> = {
|
||
thug: { bg1: '#0a0a0a', bg2: '#180006', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.5)', chipBg: 'rgba(255,0,64,0.18)', chipBorder: 'rgba(255,0,64,0.45)', text: '#ffffff', shopColor: '#ccff00', sponsorColor: '#ff9900', sidebarBg: 'rgba(255,0,64,0.07)' },
|
||
lime: { bg1: '#040c00', bg2: '#081500', bg3: '#040c00', accent: '#ccff00', accent2: '#ff6600', stripe1: '#ccff00', stripe2: '#88ff00', muted: 'rgba(255,255,255,0.5)', chipBg: 'rgba(204,255,0,0.14)', chipBorder: 'rgba(204,255,0,0.4)', text: '#ffffff', shopColor: '#ff6600', sponsorColor: '#ff0040', sidebarBg: 'rgba(204,255,0,0.05)' },
|
||
chrome: { bg1: '#111111', bg2: '#252525', bg3: '#0e0e0e', accent: '#d0d0d0', accent2: '#ff6600', stripe1: '#777777', stripe2: '#cccccc', muted: 'rgba(220,220,220,0.55)', chipBg: 'rgba(200,200,200,0.12)', chipBorder: 'rgba(200,200,200,0.3)', text: '#e8e8e8', shopColor: '#ff9900', sponsorColor: '#64cfff', sidebarBg: 'rgba(200,200,200,0.04)' },
|
||
wood: { bg1: '#2e1804', bg2: '#4a2a0c', bg3: '#261404', accent: '#f5c842', accent2: '#ff6600', stripe1: '#8b5e3c', stripe2: '#d4a05a', muted: 'rgba(245,230,200,0.6)', chipBg: 'rgba(160,100,40,0.25)', chipBorder: 'rgba(200,140,60,0.5)', text: '#f5e6c8', shopColor: '#ff9944', sponsorColor: '#aaffcc', sidebarBg: 'rgba(160,100,40,0.12)' },
|
||
midnight: { bg1: '#010610', bg2: '#040c22', bg3: '#020814', accent: '#64a0ff', accent2: '#ccff00', stripe1: '#2040aa', stripe2: '#64a0ff', muted: 'rgba(200,220,255,0.55)', chipBg: 'rgba(100,160,255,0.12)', chipBorder: 'rgba(100,160,255,0.35)', text: '#d0e4ff', shopColor: '#ccff00', sponsorColor: '#ff88cc', sidebarBg: 'rgba(100,160,255,0.05)' },
|
||
sunset: { bg1: '#0e0004', bg2: '#160606', bg3: '#0a040c', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,200,150,0.6)', chipBg: 'rgba(255,80,0,0.16)', chipBorder: 'rgba(255,80,0,0.4)', text: '#ffd0a0', shopColor: '#ffdd00', sponsorColor: '#ff88ff', sidebarBg: 'rgba(255,80,0,0.07)' },
|
||
};
|
||
|
||
async function loadImage(url: string): Promise<HTMLImageElement | null> {
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.onload = () => resolve(img);
|
||
img.onerror = () => resolve(null);
|
||
// Only proxy actual HTTP URLs — data: URIs must never go through proxy
|
||
if (url.startsWith('data:') || url.startsWith('blob:')) {
|
||
img.src = url;
|
||
} else {
|
||
img.src = `https://proxy.shakespeare.diy/?url=${encodeURIComponent(url)}`;
|
||
}
|
||
setTimeout(() => resolve(null), 5000);
|
||
});
|
||
}
|
||
|
||
// Draw text that wraps or truncates to fit maxWidth, returns lines drawn
|
||
function fillTextFit(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number): void {
|
||
let t = text;
|
||
while (ctx.measureText(t).width > maxWidth && t.length > 2) {
|
||
t = t.slice(0, -1);
|
||
}
|
||
if (t.length < text.length) t = t.slice(0, -1) + '…';
|
||
ctx.fillText(t, x, y);
|
||
}
|
||
|
||
// Draw a labeled row: small muted label above, value below
|
||
function _drawLabeledRow(
|
||
ctx: CanvasRenderingContext2D,
|
||
label: string, value: string,
|
||
x: number, y: number, maxW: number,
|
||
labelColor: string, valueColor: string,
|
||
labelSize: number, valueSize: number,
|
||
bold = false,
|
||
) {
|
||
ctx.fillStyle = labelColor;
|
||
ctx.font = `${labelSize}px system-ui, sans-serif`;
|
||
ctx.fillText(label.toUpperCase(), x, y);
|
||
ctx.fillStyle = valueColor;
|
||
ctx.font = `${bold ? 'bold ' : ''}${valueSize}px system-ui, sans-serif`;
|
||
fillTextFit(ctx, value, x, y + valueSize + 2, maxW);
|
||
}
|
||
|
||
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.lineTo(x + w - r, y);
|
||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||
ctx.lineTo(x + w, y + h - r);
|
||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||
ctx.lineTo(x + r, y + h);
|
||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||
ctx.lineTo(x, y + r);
|
||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||
ctx.closePath();
|
||
}
|
||
|
||
async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = CARD_W;
|
||
canvas.height = CARD_H;
|
||
const ctx = canvas.getContext('2d')!;
|
||
const p = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug;
|
||
|
||
// ─── 1. Background ───────────────────────────────────────────────────────
|
||
const bgGrad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
|
||
bgGrad.addColorStop(0, p.bg1);
|
||
bgGrad.addColorStop(0.5, p.bg2);
|
||
bgGrad.addColorStop(1, p.bg3);
|
||
roundRect(ctx, 0, 0, CARD_W, CARD_H, 40);
|
||
ctx.fillStyle = bgGrad;
|
||
ctx.fill();
|
||
|
||
// ─── 2. Left sidebar tint ────────────────────────────────────────────────
|
||
const SIDEBAR_W = 240;
|
||
roundRect(ctx, 0, 0, SIDEBAR_W, CARD_H, 40);
|
||
ctx.fillStyle = p.sidebarBg;
|
||
ctx.fill();
|
||
// vertical separator
|
||
ctx.strokeStyle = p.accent;
|
||
ctx.globalAlpha = 0.15;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(SIDEBAR_W, 40); ctx.lineTo(SIDEBAR_W, CARD_H - 40); ctx.stroke();
|
||
ctx.globalAlpha = 1;
|
||
|
||
// ─── 3. Top stripe ───────────────────────────────────────────────────────
|
||
const stripe = ctx.createLinearGradient(0, 0, CARD_W, 0);
|
||
stripe.addColorStop(0, p.stripe1);
|
||
stripe.addColorStop(1, p.stripe2);
|
||
ctx.fillStyle = stripe;
|
||
ctx.fillRect(0, 0, CARD_W, 10);
|
||
|
||
// ─── 4. QR code (top-right, generated first — data: URI safe) ────────────
|
||
// The QR opens the card's verify page when published; otherwise the profile.
|
||
const qrUrl = data.verifyUrl || `https://skate-spots.org/${data.npub}`;
|
||
const qrSize = 168;
|
||
const qrX = CARD_W - 32 - qrSize;
|
||
const qrY = 28;
|
||
let qrImg: HTMLImageElement | null = null;
|
||
try {
|
||
// Dynamic import — avoids canvas API access at module load time
|
||
const QRCode = (await import('qrcode')).default;
|
||
const qrDataUrl = await QRCode.toDataURL(qrUrl, {
|
||
width: qrSize * 2,
|
||
margin: 2,
|
||
errorCorrectionLevel: 'M',
|
||
color: { dark: '#000000', light: '#ffffff' },
|
||
});
|
||
qrImg = await loadImage(qrDataUrl); // data: URI — no proxy
|
||
} catch { /* skip */ }
|
||
|
||
if (qrImg) {
|
||
roundRect(ctx, qrX - 8, qrY - 8, qrSize + 16, qrSize + 16, 10);
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fill();
|
||
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
|
||
}
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '13px system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('SCAN TO VERIFY', qrX + qrSize / 2, qrY + qrSize + 20);
|
||
ctx.textAlign = 'left';
|
||
|
||
// ─── 5. Load avatar ───────────────────────────────────────────────────────
|
||
let avatarImg: HTMLImageElement | null = null;
|
||
if (data.avatarUrl) avatarImg = await loadImage(data.avatarUrl);
|
||
|
||
if (data.type === 'rider') {
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// SKATER LICENSE LAYOUT
|
||
// Left sidebar: avatar + stats + since
|
||
// Right zone: name / nip05 / style / shop / sponsors / skills
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
// ── Left: Avatar ─────────────────────────────────────────────────────
|
||
const avSize = 176;
|
||
const avX = (SIDEBAR_W - avSize) / 2;
|
||
const avY = 22;
|
||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||
ctx.fillStyle = p.chipBg;
|
||
ctx.fill();
|
||
if (avatarImg) {
|
||
ctx.save();
|
||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||
ctx.clip();
|
||
ctx.drawImage(avatarImg, avX, avY, avSize, avSize);
|
||
ctx.restore();
|
||
} else {
|
||
ctx.font = '72px serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillStyle = p.accent;
|
||
ctx.fillText('🛹', avX + avSize / 2, avY + avSize / 2 + 24);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
// ── Left: Stats block ─────────────────────────────────────────────────
|
||
const statsStartY = avY + avSize + 20;
|
||
const statItems = [
|
||
data.showSpots ? { label: 'SPOTS', value: String(data.spotsAdded), color: p.accent } : null,
|
||
data.showZaps ? { label: 'ZAPS ⚡', value: String(data.zapsReceived), color: p.accent2 } : null,
|
||
data.showEvents ? { label: 'EVENTS', value: String(data.eventsCreated), color: '#ff7733' } : null,
|
||
].filter(Boolean) as { label: string; value: string; color: string }[];
|
||
|
||
const statColW = SIDEBAR_W / Math.max(statItems.length, 1);
|
||
statItems.forEach((s, i) => {
|
||
const sx = i * statColW + statColW / 2;
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '12px system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(s.label, sx, statsStartY);
|
||
ctx.fillStyle = s.color;
|
||
ctx.font = 'bold 30px system-ui, sans-serif';
|
||
ctx.fillText(s.value, sx, statsStartY + 34);
|
||
});
|
||
ctx.textAlign = 'left';
|
||
|
||
// ── Left: Since + npub ────────────────────────────────────────────────
|
||
const sinceY = statsStartY + (statItems.length > 0 ? 56 : 0);
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '13px system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`SINCE ${data.memberYear}`, SIDEBAR_W / 2, sinceY);
|
||
ctx.textAlign = 'left';
|
||
|
||
// ── Right zone ────────────────────────────────────────────────────────
|
||
const RX = SIDEBAR_W + 28; // right content X start
|
||
const RMaxW = qrX - RX - 20; // max width before QR
|
||
let ry = 52; // right Y cursor
|
||
|
||
// Brand tag (small, above name)
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||
ctx.fillText('🛹 SKATESPOTS', RX, ry);
|
||
ry += 6;
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '12px system-ui, sans-serif';
|
||
ctx.fillText('SKATER LICENSE', RX, ry + 14);
|
||
ry += 30;
|
||
|
||
// Name — big and bold
|
||
ctx.fillStyle = p.text;
|
||
ctx.font = 'bold 52px system-ui, sans-serif';
|
||
fillTextFit(ctx, data.name.toUpperCase(), RX, ry + 48, RMaxW);
|
||
ry += 58;
|
||
|
||
// nip05
|
||
if (data.nip05) {
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = 'italic 20px Georgia, serif';
|
||
fillTextFit(ctx, `@${data.nip05.split('@')[0]}`, RX, ry, RMaxW);
|
||
ry += 28;
|
||
}
|
||
|
||
// Thin divider
|
||
ctx.strokeStyle = p.accent;
|
||
ctx.globalAlpha = 0.2;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(RX, ry); ctx.lineTo(RX + RMaxW, ry); ctx.stroke();
|
||
ctx.globalAlpha = 1;
|
||
ry += 18;
|
||
|
||
// Style chip
|
||
if (data.style) {
|
||
ctx.font = 'bold 16px system-ui, sans-serif';
|
||
const chipTxt = data.style.toUpperCase();
|
||
const chipW = ctx.measureText(chipTxt).width + 24;
|
||
roundRect(ctx, RX, ry - 18, chipW, 26, 6);
|
||
ctx.fillStyle = p.chipBg;
|
||
ctx.fill();
|
||
ctx.strokeStyle = p.chipBorder;
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
ctx.fillStyle = p.accent;
|
||
ctx.fillText(chipTxt, RX + 12, ry - 1);
|
||
ry += 22;
|
||
}
|
||
|
||
// Local + crew crests (identity facets)
|
||
if (data.local) {
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = 'bold 17px system-ui, sans-serif';
|
||
fillTextFit(ctx, `📍 ${data.local.toUpperCase()}`, RX, ry, RMaxW);
|
||
ry += 22;
|
||
}
|
||
if (data.crews && data.crews.length > 0) {
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '14px system-ui, sans-serif';
|
||
fillTextFit(ctx, `⛺ ${data.crews.slice(0, 3).join(' · ')}`, RX, ry, RMaxW);
|
||
ry += 20;
|
||
}
|
||
if (data.ridesFor) {
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '11px system-ui, sans-serif';
|
||
ctx.fillText('RIDES FOR', RX, ry);
|
||
ry += 4;
|
||
ctx.fillStyle = p.shopColor;
|
||
ctx.font = 'bold italic 22px Georgia, serif';
|
||
fillTextFit(ctx, data.ridesFor, RX, ry + 20, RMaxW);
|
||
ry += 32;
|
||
}
|
||
|
||
// ── SPONSORS — different color + lighter weight ────────────────────────
|
||
if (data.sponsors) {
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '11px system-ui, sans-serif';
|
||
ctx.fillText('SPONSORS', RX, ry);
|
||
ry += 4;
|
||
ctx.fillStyle = p.sponsorColor;
|
||
ctx.font = '19px system-ui, sans-serif';
|
||
fillTextFit(ctx, data.sponsors, RX, ry + 18, RMaxW);
|
||
ry += 30;
|
||
}
|
||
|
||
// Thin divider before skills
|
||
if (data.skills.length > 0 || data.receivedBadges.length > 0 || (data.roles?.length ?? 0) > 0) {
|
||
ctx.strokeStyle = p.accent;
|
||
ctx.globalAlpha = 0.15;
|
||
ctx.lineWidth = 1;
|
||
const divY = Math.max(ry + 4, CARD_H - 130);
|
||
ctx.beginPath(); ctx.moveTo(RX, divY); ctx.lineTo(RX + RMaxW, divY); ctx.stroke();
|
||
ctx.globalAlpha = 1;
|
||
|
||
// ── Skill chips ────────────────────────────────────────────────────
|
||
const skillsToShow = [
|
||
...(data.roles ?? []).slice(0, 3).map(r => {
|
||
const m = roleMeta(r);
|
||
return m ? { label: `${m.emoji} ${m.label}`, gold: false } : null;
|
||
}).filter(Boolean),
|
||
...data.skills.slice(0, 4).map(id => {
|
||
const s = SKILL_OPTIONS.find(o => o.id === id);
|
||
return s ? { label: s.label, gold: false } : null;
|
||
}).filter(Boolean),
|
||
...data.receivedBadges.slice(0, 3).map(b => {
|
||
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
|
||
return s ? { label: `★ ${s.label}`, gold: true } : null;
|
||
}).filter(Boolean),
|
||
] as { label: string; gold: boolean }[];
|
||
|
||
let chipX = RX;
|
||
let chipY = divY + 14;
|
||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||
skillsToShow.forEach(chip => {
|
||
const chipW = ctx.measureText(chip.label).width + 18;
|
||
if (chipX + chipW > RX + RMaxW) { chipX = RX; chipY += 28; }
|
||
roundRect(ctx, chipX, chipY, chipW, 22, 5);
|
||
ctx.fillStyle = chip.gold ? 'rgba(255,215,0,0.18)' : p.chipBg;
|
||
ctx.fill();
|
||
ctx.strokeStyle = chip.gold ? 'rgba(255,215,0,0.5)' : p.chipBorder;
|
||
ctx.lineWidth = 0.8;
|
||
ctx.stroke();
|
||
ctx.fillStyle = chip.gold ? '#ffd700' : p.accent;
|
||
ctx.fillText(chip.label, chipX + 9, chipY + 16);
|
||
chipX += chipW + 6;
|
||
});
|
||
}
|
||
|
||
// ── Full npub — bottom of right zone ──────────────────────────────────
|
||
const npubY = CARD_H - 44;
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '12px monospace';
|
||
ctx.fillText(data.npub.slice(0, 44), RX, npubY);
|
||
ctx.fillText(data.npub.slice(44), RX, npubY + 16);
|
||
|
||
} else if (data.type === 'park') {
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// PARK ACCESS LAYOUT
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
// ── Left: Avatar ─────────────────────────────────────────────────────
|
||
const avSize = 176;
|
||
const avX = (SIDEBAR_W - avSize) / 2;
|
||
const avY = 22;
|
||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||
ctx.fillStyle = p.chipBg;
|
||
ctx.fill();
|
||
if (avatarImg) {
|
||
ctx.save();
|
||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||
ctx.clip();
|
||
ctx.drawImage(avatarImg, avX, avY, avSize, avSize);
|
||
ctx.restore();
|
||
}
|
||
|
||
// Access level chip under avatar
|
||
const alTxt = (data.accessLevel || 'FULL ACCESS').toUpperCase();
|
||
ctx.font = 'bold 16px system-ui, sans-serif';
|
||
const alW = ctx.measureText(alTxt).width + 24;
|
||
const alX = (SIDEBAR_W - alW) / 2;
|
||
const alY = avY + avSize + 16;
|
||
roundRect(ctx, alX, alY, alW, 30, 7);
|
||
ctx.fillStyle = p.accent;
|
||
ctx.fill();
|
||
ctx.fillStyle = p.bg1;
|
||
ctx.fillText(alTxt, alX + 12, alY + 21);
|
||
|
||
// valid thru under access level
|
||
if (data.validThru) {
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '12px system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('VALID THRU', SIDEBAR_W / 2, alY + 52);
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = 'bold 20px system-ui, sans-serif';
|
||
ctx.fillText(data.validThru, SIDEBAR_W / 2, alY + 76);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
// ── Right zone ────────────────────────────────────────────────────────
|
||
const RX = SIDEBAR_W + 28;
|
||
const RMaxW = qrX - RX - 20;
|
||
let ry = 52;
|
||
|
||
// Park name + label
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||
ctx.fillText(`🏟️ ${(data.issuerName || 'SKATEPARK').toUpperCase()}`, RX, ry);
|
||
ry += 6;
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '12px system-ui, sans-serif';
|
||
ctx.fillText('PARK ACCESS CARD', RX, ry + 14);
|
||
ry += 34;
|
||
|
||
// Member name
|
||
ctx.fillStyle = p.text;
|
||
ctx.font = 'bold 50px system-ui, sans-serif';
|
||
fillTextFit(ctx, (data.recipientName || 'MEMBER').toUpperCase(), RX, ry + 46, RMaxW);
|
||
ry += 60;
|
||
|
||
// Divider
|
||
ctx.strokeStyle = p.accent;
|
||
ctx.globalAlpha = 0.2;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(RX, ry); ctx.lineTo(RX + RMaxW, ry); ctx.stroke();
|
||
ctx.globalAlpha = 1;
|
||
ry += 22;
|
||
|
||
// Issued by
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '13px system-ui, sans-serif';
|
||
ctx.fillText('ISSUED BY PARK OWNER · NOSTR-SIGNED', RX, ry);
|
||
ry += 26;
|
||
|
||
// Full npub
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = '14px monospace';
|
||
ctx.fillText(data.npub.slice(0, 44), RX, ry);
|
||
ry += 20;
|
||
ctx.fillText(data.npub.slice(44), RX, ry);
|
||
} else if (data.type === 'crew') {
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// CREW LAYOUT
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
const c = data.crew;
|
||
const avSize = 176;
|
||
const avX = (SIDEBAR_W - avSize) / 2;
|
||
const avY = 22;
|
||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||
ctx.fillStyle = p.chipBg;
|
||
ctx.fill();
|
||
if (avatarImg) {
|
||
ctx.save();
|
||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||
ctx.clip();
|
||
ctx.drawImage(avatarImg, avX, avY, avSize, avSize);
|
||
ctx.restore();
|
||
} else {
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = '80px system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('🏕️', SIDEBAR_W / 2, avY + 118);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
// Left: members + spots
|
||
ctx.textAlign = 'center';
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '12px system-ui, sans-serif';
|
||
ctx.fillText('MEMBERS', SIDEBAR_W / 4 + 8, avY + avSize + 32);
|
||
ctx.fillText('SPOTS', (SIDEBAR_W * 3) / 4 - 8, avY + avSize + 32);
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = 'bold 34px system-ui, sans-serif';
|
||
ctx.fillText(String(c?.members ?? 0), SIDEBAR_W / 4 + 8, avY + avSize + 68);
|
||
ctx.fillStyle = p.accent2;
|
||
ctx.fillText(String(c?.spots ?? 0), (SIDEBAR_W * 3) / 4 - 8, avY + avSize + 68);
|
||
ctx.textAlign = 'left';
|
||
|
||
// Right zone
|
||
const RX = SIDEBAR_W + 28;
|
||
const RMaxW = qrX - RX - 20;
|
||
let ry = 52;
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||
ctx.fillText('🛹 SKATESPOTS', RX, ry);
|
||
ry += 6;
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '12px system-ui, sans-serif';
|
||
ctx.fillText('CREW CARD', RX, ry + 14);
|
||
ry += 30;
|
||
ctx.fillStyle = p.text;
|
||
ctx.font = 'bold 46px system-ui, sans-serif';
|
||
fillTextFit(ctx, (c?.name ?? '').toUpperCase(), RX, ry + 44, RMaxW);
|
||
ry += 56;
|
||
if (c?.location) {
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = 'bold 18px system-ui, sans-serif';
|
||
fillTextFit(ctx, `📍 ${c.location}`, RX, ry, RMaxW);
|
||
ry += 24;
|
||
}
|
||
if (c?.founded) {
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '13px system-ui, sans-serif';
|
||
ctx.fillText(`EST. ${c.founded}`, RX, ry);
|
||
ry += 22;
|
||
}
|
||
if (c && c.lookingFor.length > 0) {
|
||
ctx.fillStyle = p.accent2;
|
||
ctx.font = 'bold 15px system-ui, sans-serif';
|
||
ctx.fillText('OPEN CALL', RX, ry);
|
||
ry += 24;
|
||
}
|
||
if (c && c.vibes.length > 0) {
|
||
let chipX = RX;
|
||
let chipY = ry + 4;
|
||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||
c.vibes.slice(0, 5).forEach(v => {
|
||
const label = v.toUpperCase();
|
||
const chipW = ctx.measureText(label).width + 18;
|
||
if (chipX + chipW > RX + RMaxW) { chipX = RX; chipY += 28; }
|
||
roundRect(ctx, chipX, chipY, chipW, 22, 5);
|
||
ctx.fillStyle = p.chipBg;
|
||
ctx.fill();
|
||
ctx.strokeStyle = p.chipBorder;
|
||
ctx.lineWidth = 0.8;
|
||
ctx.stroke();
|
||
ctx.fillStyle = p.accent;
|
||
ctx.fillText(label, chipX + 9, chipY + 16);
|
||
chipX += chipW + 6;
|
||
});
|
||
}
|
||
|
||
// Full npub
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '12px monospace';
|
||
ctx.fillText(data.npub.slice(0, 44), RX, CARD_H - 64);
|
||
ctx.fillText(data.npub.slice(44), RX, CARD_H - 48);
|
||
} else {
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// EVENT PASS LAYOUT
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
const e = data.event;
|
||
const avSize = 176;
|
||
const avX = (SIDEBAR_W - avSize) / 2;
|
||
const avY = 22;
|
||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||
ctx.fillStyle = p.chipBg;
|
||
ctx.fill();
|
||
if (avatarImg) {
|
||
ctx.save();
|
||
roundRect(ctx, avX, avY, avSize, avSize, 14);
|
||
ctx.clip();
|
||
ctx.drawImage(avatarImg, avX, avY, avSize, avSize);
|
||
ctx.restore();
|
||
} else {
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = '80px system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('🎟️', SIDEBAR_W / 2, avY + 118);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
// Right zone
|
||
const RX = SIDEBAR_W + 28;
|
||
const RMaxW = qrX - RX - 20;
|
||
let ry = 52;
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = 'bold 14px system-ui, sans-serif';
|
||
ctx.fillText('🛹 SKATESPOTS', RX, ry);
|
||
ry += 6;
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '12px system-ui, sans-serif';
|
||
ctx.fillText('EVENT PASS', RX, ry + 14);
|
||
ry += 30;
|
||
ctx.fillStyle = p.text;
|
||
ctx.font = 'bold 44px system-ui, sans-serif';
|
||
fillTextFit(ctx, (e?.title ?? '').toUpperCase(), RX, ry + 42, RMaxW);
|
||
ry += 54;
|
||
if (e) {
|
||
ctx.fillStyle = p.accent;
|
||
ctx.font = 'bold 18px system-ui, sans-serif';
|
||
fillTextFit(ctx, formatCardDate(e.start, e.end), RX, ry, RMaxW);
|
||
ry += 26;
|
||
if (e.location) {
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '15px system-ui, sans-serif';
|
||
fillTextFit(ctx, `📍 ${e.location}`, RX, ry, RMaxW);
|
||
ry += 24;
|
||
}
|
||
}
|
||
// Holder
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '13px system-ui, sans-serif';
|
||
ctx.fillText('ADMITS', RX, ry);
|
||
ry += 4;
|
||
ctx.fillStyle = p.accent2;
|
||
ctx.font = 'bold 22px system-ui, sans-serif';
|
||
fillTextFit(ctx, e?.holder ?? '', RX, ry + 20, RMaxW);
|
||
ry += 34;
|
||
|
||
// Full npub
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '12px monospace';
|
||
ctx.fillText(data.npub.slice(0, 44), RX, CARD_H - 64);
|
||
ctx.fillText(data.npub.slice(44), RX, CARD_H - 48);
|
||
}
|
||
|
||
// ─── 6. Bottom bar ───────────────────────────────────────────────────────
|
||
ctx.fillStyle = p.muted;
|
||
ctx.globalAlpha = 0.18;
|
||
ctx.fillRect(0, CARD_H - 36, CARD_W, 36);
|
||
ctx.globalAlpha = 1;
|
||
|
||
ctx.fillStyle = p.muted;
|
||
ctx.font = '13px system-ui, sans-serif';
|
||
ctx.fillText('SKATE-SPOTS.ORG · POWERED BY NOSTR', 32, CARD_H - 13);
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(data.memberYear, CARD_W - 32, CARD_H - 13);
|
||
ctx.textAlign = 'left';
|
||
|
||
return canvas.toDataURL('image/png');
|
||
}
|
||
|
||
// ─── Hook: fetch skill badges received by a pubkey ────────────────────────────
|
||
function useReceivedBadges(pubkey: string) {
|
||
const { nostr } = useNostr();
|
||
return useQuery({
|
||
queryKey: ['skill-badges', pubkey],
|
||
queryFn: async (c) => {
|
||
if (!pubkey) return [];
|
||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
|
||
const events = await nostr.query(
|
||
[{ kinds: [SKILL_BADGE_KIND], '#p': [pubkey], limit: 50 }],
|
||
{ signal },
|
||
);
|
||
return events.filter(e =>
|
||
e.tags.some(([k]) => k === 'p') &&
|
||
e.tags.some(([k]) => k === 'skill')
|
||
);
|
||
},
|
||
enabled: !!pubkey,
|
||
});
|
||
}
|
||
|
||
// ─── Hook: fetch author name for a pubkey ─────────────────────────────────────
|
||
function useWitnessName(pubkey: string) {
|
||
const author = useAuthor(pubkey);
|
||
return author.data?.metadata?.name ?? genUserName(pubkey);
|
||
}
|
||
|
||
// ─── Received badges panel ────────────────────────────────────────────────────
|
||
function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) {
|
||
const { t } = useTranslation();
|
||
const { data: badgeEvents = [] } = useReceivedBadges(pubkey);
|
||
const [expanded, setExpanded] = useState(false);
|
||
|
||
if (badgeEvents.length === 0) {
|
||
return (
|
||
<div className="p-3 rounded-xl bg-black/40 border border-gray-700 text-center">
|
||
<div className="text-2xl mb-1">🏅</div>
|
||
<p className="text-xs text-gray-500">{t('labUi.rider.noBadges')}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const show = expanded ? badgeEvents : badgeEvents.slice(0, 3);
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{show.map((event) => (
|
||
<BadgeEventRow key={event.id} event={event} />
|
||
))}
|
||
{badgeEvents.length > 3 && (
|
||
<button
|
||
onClick={() => setExpanded(p => !p)}
|
||
className="w-full text-xs text-gray-500 hover:text-gray-300 flex items-center justify-center gap-1 py-1"
|
||
>
|
||
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||
{expanded ? t('common.showLess') : t('labUi.rider.showMore', { count: badgeEvents.length - 3 })}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BadgeEventRow({ event }: { event: NostrEvent }) {
|
||
const { t } = useTranslation();
|
||
const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v);
|
||
const spot = event.tags.find(([k]) => k === 'spot')?.[1];
|
||
const witnessName = useWitnessName(event.pubkey);
|
||
const witnessAuthor = useAuthor(event.pubkey);
|
||
const witnessAvatar = witnessAuthor.data?.metadata?.picture;
|
||
|
||
return (
|
||
<div className="flex items-center gap-3 p-2.5 rounded-lg bg-yellow-500/5 border border-yellow-500/20">
|
||
<div className="flex-shrink-0">
|
||
<Avatar className="h-7 w-7">
|
||
<AvatarImage src={witnessAvatar} />
|
||
<AvatarFallback className="text-[10px] bg-yellow-900">{witnessName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||
</Avatar>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex flex-wrap gap-1 mb-0.5">
|
||
{skills.map(sk => {
|
||
const s = SKILL_OPTIONS.find(o => o.id === sk);
|
||
return s ? (
|
||
<Badge key={sk} variant="secondary" className="text-[9px] px-1.5 py-0 bg-yellow-500/20 text-yellow-300 border-yellow-500/30 h-4">
|
||
{s.icon} {s.label}
|
||
</Badge>
|
||
) : null;
|
||
})}
|
||
</div>
|
||
<p className="text-[10px] text-gray-400">
|
||
{t('labUi.rider.witnessedBy')} <span className="text-yellow-400 font-medium">{witnessName}</span>
|
||
{spot && <> {t('labUi.rider.at')} <span className="text-gray-300">{spot}</span></>}
|
||
</p>
|
||
</div>
|
||
<div className="text-[9px] text-gray-600 flex-shrink-0">
|
||
{new Date(event.created_at * 1000).toLocaleDateString()}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||
export default function RiderCardPage() {
|
||
const { t } = useTranslation();
|
||
useSeoMeta({
|
||
title: 'Skater Card — SkateSpots',
|
||
description: 'Create your printable SkateSpots Skater License or Park Access Card.',
|
||
});
|
||
|
||
const { user } = useCurrentUser();
|
||
const author = useAuthor(user?.pubkey ?? '');
|
||
const metadata = author.data?.metadata;
|
||
const { data: stats } = useRiderStats(user?.pubkey);
|
||
const { data: savedCard } = useRiderCard(user?.pubkey);
|
||
const { data: allSpots } = useSkateSpots(200);
|
||
const { memberships } = useUserCrews(user?.pubkey);
|
||
const { data: skaterProfile } = useUserSkaterProfile(user?.pubkey);
|
||
const { data: roles = [] } = useRiderRoles(user?.pubkey);
|
||
|
||
const crewCrests = useMemo(
|
||
() => memberships.map(m => ({ name: m.crew.name, image: m.crew.image })),
|
||
[memberships],
|
||
);
|
||
const local = skaterProfile?.location || skaterProfile?.homeSpot || '';
|
||
const { mutate: publishEvent } = useNostrPublish();
|
||
const { toast } = useToast();
|
||
const printRef = useRef<HTMLDivElement>(null);
|
||
|
||
const displayName = metadata?.name ?? genUserName(user?.pubkey ?? '');
|
||
const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : '';
|
||
|
||
const { data: receivedBadgeEvents = [] } = useReceivedBadges(user?.pubkey ?? '');
|
||
const receivedBadges: BadgeAward[] = receivedBadgeEvents.flatMap(event => {
|
||
const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v);
|
||
const spot = event.tags.find(([k]) => k === 'spot')?.[1];
|
||
return skills.map(skill => ({
|
||
skill,
|
||
witnessName: genUserName(event.pubkey),
|
||
witnessPubkey: event.pubkey,
|
||
spotName: spot,
|
||
timestamp: event.created_at,
|
||
}));
|
||
});
|
||
|
||
// ── Page tab: 'card' | 'badges' | 'award' ────────────────────────────────
|
||
const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award'>('card');
|
||
|
||
// ── Card state ────────────────────────────────────────────────────────────
|
||
const [cardType, setCardType] = useState<'rider' | 'park' | 'crew' | 'event'>('rider');
|
||
const [theme, setTheme] = useState<ThemeId>('thug');
|
||
const [holographic, setHolographic] = useState(true);
|
||
|
||
// Rider fields
|
||
const [style, setStyle] = useState('Street');
|
||
const [skills, setSkills] = useState<string[]>([]);
|
||
const [sponsors, setSponsors] = useState('');
|
||
const [ridesFor, setRidesFor] = useState('');
|
||
const [showSpots, setShowSpots] = useState(true);
|
||
const [showZaps, setShowZaps] = useState(true);
|
||
const [showEvents, setShowEvents] = useState(true);
|
||
const [showCrews, setShowCrews] = useState(true);
|
||
const [showLocal, setShowLocal] = useState(true);
|
||
const [showRoles, setShowRoles] = useState(true);
|
||
|
||
// Park fields
|
||
const [issuerName, setIssuerName] = useState('');
|
||
const [recipientName, setRecipientName] = useState('');
|
||
const [recipientNpub, setRecipientNpub] = useState('');
|
||
const [accessLevel, setAccessLevel] = useState('Full Access');
|
||
const [validThru, setValidThru] = useState('');
|
||
|
||
const [copied, setCopied] = useState(false);
|
||
const [rendering, setRendering] = useState(false);
|
||
|
||
// Venue binding: a park pass can name one of the issuer's OWN spots so a
|
||
// verifier can confirm the issuer really owns the venue (see /verify).
|
||
const [venueId, setVenueId] = useState('');
|
||
const [savedParkD, setSavedParkD] = useState('');
|
||
const [crewId, setCrewId] = useState('');
|
||
const activeCrew = memberships.find(m => m.crew.id === crewId)?.crew ?? memberships[0]?.crew;
|
||
|
||
// Event pass / wristband: events you organize, or that you RSVP'd going to.
|
||
const [eventId, setEventId] = useState('');
|
||
const { data: allEvents } = useSkateEvents(200);
|
||
const { data: rsvpCoords = [] } = useMyRsvpCoords(user?.pubkey);
|
||
const myEvents = useMemo(() => {
|
||
const rsvpSet = new Set(rsvpCoords);
|
||
return (allEvents ?? []).filter(ev => ev.pubkey === user?.pubkey || rsvpSet.has(eventCoord(ev)));
|
||
}, [allEvents, user?.pubkey, rsvpCoords]);
|
||
const activeEvent = myEvents.find(ev => ev.id === eventId) ?? myEvents[0];
|
||
const eventRsvp = useEventRsvps(cardType === 'event' && activeEvent ? eventCoord(activeEvent) : undefined);
|
||
const myVenues = useMemo(
|
||
() => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey),
|
||
[allSpots, user?.pubkey],
|
||
);
|
||
const venueSpot = myVenues.find((s) => s.id === venueId);
|
||
|
||
// Hydrate the editor from the saved Rider Card the first time it loads.
|
||
const hydratedRef = useRef(false);
|
||
useEffect(() => {
|
||
if (hydratedRef.current || !savedCard?.config) return;
|
||
hydratedRef.current = true;
|
||
const c = savedCard.config;
|
||
setTheme(c.theme as ThemeId);
|
||
setStyle(c.style);
|
||
setSkills(c.skills);
|
||
setSponsors(c.sponsors);
|
||
setRidesFor(c.ridesFor);
|
||
setShowSpots(c.showSpots);
|
||
setShowZaps(c.showZaps);
|
||
setShowEvents(c.showEvents);
|
||
setShowCrews(c.showCrews);
|
||
setShowLocal(c.showLocal);
|
||
setShowRoles(c.showRoles);
|
||
}, [savedCard]);
|
||
|
||
const recipientPubkey = (() => {
|
||
if (!recipientNpub.trim()) return user?.pubkey ?? '';
|
||
try {
|
||
const d = nip19.decode(recipientNpub.trim());
|
||
return d.type === 'npub' ? d.data : (user?.pubkey ?? '');
|
||
} catch { return user?.pubkey ?? ''; }
|
||
})();
|
||
|
||
const cardPubkey = cardType === 'rider'
|
||
? (user?.pubkey ?? '')
|
||
: cardType === 'park'
|
||
? recipientPubkey
|
||
: cardType === 'crew'
|
||
? (activeCrew?.pubkey ?? user?.pubkey ?? '')
|
||
: (activeEvent?.pubkey ?? '');
|
||
const cardData: CardData = useMemo(
|
||
() => ({
|
||
type: cardType, theme, holographic,
|
||
name: cardType === 'rider' ? displayName : recipientName,
|
||
nip05: metadata?.nip05,
|
||
avatar: cardType === 'crew' ? activeCrew?.image : cardType === 'event' ? activeEvent?.image : metadata?.picture,
|
||
pubkey: cardPubkey,
|
||
npub: cardPubkey ? fullNpub(cardPubkey) : '',
|
||
style, skills, sponsors, ridesFor,
|
||
showSpots, showZaps, showEvents,
|
||
crews: crewCrests,
|
||
local: local || undefined,
|
||
roles,
|
||
showCrews, showLocal, showRoles,
|
||
spotsAdded: stats?.spotsAdded ?? 0,
|
||
zapsReceived: stats?.zapsReceived ?? 0,
|
||
eventsCreated: stats?.eventsCreated ?? 0,
|
||
memberYear: metadata?.created_at
|
||
? memberSince(metadata.created_at as number)
|
||
: new Date().getFullYear().toString(),
|
||
receivedBadges,
|
||
verifyUrl: cardType === 'rider'
|
||
? (user?.pubkey ? cardVerifyUrl(RIDER_CARD_KIND, user.pubkey, RIDER_CARD_D) : undefined)
|
||
: cardType === 'park'
|
||
? (savedParkD && user?.pubkey ? cardVerifyUrl(PARK_ACCESS_CARD_KIND, user.pubkey, savedParkD) : undefined)
|
||
: cardType === 'crew'
|
||
? (activeCrew ? cardVerifyUrl(SKATE_CREW_KIND, activeCrew.pubkey, activeCrew.identifier) : undefined)
|
||
: (activeEvent ? cardVerifyUrl(CALENDAR_EVENT_KIND, activeEvent.pubkey, activeEvent.identifier) : undefined),
|
||
crew: cardType === 'crew' && activeCrew ? {
|
||
name: activeCrew.name,
|
||
image: activeCrew.image,
|
||
pubkey: activeCrew.pubkey,
|
||
identifier: activeCrew.identifier,
|
||
location: activeCrew.location,
|
||
founded: activeCrew.founded,
|
||
members: activeCrew.members.length,
|
||
focus: activeCrew.focus,
|
||
vibes: activeCrew.vibes,
|
||
lookingFor: activeCrew.lookingFor,
|
||
spots: activeCrew.spotRefs.length,
|
||
} : undefined,
|
||
event: cardType === 'event' && activeEvent ? {
|
||
title: activeEvent.title,
|
||
start: activeEvent.start,
|
||
end: activeEvent.end,
|
||
location: activeEvent.location,
|
||
image: activeEvent.image,
|
||
pubkey: activeEvent.pubkey,
|
||
identifier: activeEvent.identifier,
|
||
going: eventRsvp.goingCount,
|
||
holder: displayName,
|
||
} : undefined,
|
||
issuerName, recipientName, accessLevel, validThru,
|
||
}),
|
||
[
|
||
cardType, theme, holographic, displayName, recipientName, metadata,
|
||
cardPubkey, style, skills, sponsors, ridesFor, showSpots, showZaps,
|
||
showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey,
|
||
crewCrests, local, roles, showCrews, showLocal, showRoles, activeCrew, activeEvent, eventRsvp.goingCount,
|
||
],
|
||
);
|
||
|
||
const renderCardToImage = useCallback(async (): Promise<string | null> => {
|
||
setRendering(true);
|
||
try {
|
||
return await drawCardToCanvas({
|
||
type: cardData.type, themeId: cardData.theme,
|
||
name: cardData.name, nip05: cardData.nip05,
|
||
avatarUrl: cardData.avatar, pubkey: cardData.pubkey, npub: cardData.npub,
|
||
style: cardData.style, skills: cardData.skills,
|
||
sponsors: cardData.sponsors, 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,
|
||
verifyUrl: cardData.verifyUrl,
|
||
local: cardData.local,
|
||
crews: (cardData.crews ?? []).map(c => c.name),
|
||
roles: cardData.roles,
|
||
crew: cardData.crew,
|
||
event: cardData.event,
|
||
issuerName: cardData.issuerName, recipientName: cardData.recipientName,
|
||
accessLevel: cardData.accessLevel, validThru: cardData.validThru,
|
||
});
|
||
} catch (err) { console.error('Card render failed:', err); return null; }
|
||
finally { setRendering(false); }
|
||
}, [cardData]);
|
||
|
||
const handleSaveCard = useCallback(() => {
|
||
if (!user) return;
|
||
const config: RiderCardConfig = { theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles };
|
||
publishEvent(
|
||
{ kind: RIDER_CARD_KIND, content: '', tags: riderCardTags(config) },
|
||
{
|
||
onSuccess: () => toast({ title: t('cardUi.saved'), description: t('cardUi.savedDesc') }),
|
||
onError: (e) => toast({ title: t('cardUi.saveFailed'), description: String(e), variant: 'destructive' }),
|
||
},
|
||
);
|
||
}, [user, theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, publishEvent, toast, t]);
|
||
|
||
const handlePrint = useCallback(async () => {
|
||
const dataUrl = await renderCardToImage();
|
||
if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
|
||
const w = Math.round(85.6 * 2.835);
|
||
const h = Math.round(54 * 2.835);
|
||
const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`);
|
||
if (!win) { toast({ title: t('labUi.rider.popupBlocked'), description: t('labUi.rider.popupBlockedDesc'), variant: 'destructive' }); 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}"/><scr${''}ipt>window.onload=function(){window.focus();window.print()}</scr${''}ipt></body></html>`);
|
||
win.document.close();
|
||
}, [renderCardToImage, toast, t]);
|
||
|
||
const handleDownload = useCallback(async () => {
|
||
const dataUrl = await renderCardToImage();
|
||
if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
|
||
const a = document.createElement('a');
|
||
a.href = dataUrl;
|
||
a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`;
|
||
a.click();
|
||
}, [renderCardToImage, cardData.npub, toast, t]);
|
||
|
||
const handleCopyNpub = useCallback(() => {
|
||
navigator.clipboard.writeText(npub);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
}, [npub]);
|
||
|
||
const handleShare = useCallback(async () => {
|
||
const dataUrl = await renderCardToImage();
|
||
if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
|
||
const res = await fetch(dataUrl);
|
||
const blob = await res.blob();
|
||
const file = new File([blob], `skatespot-card-${cardData.name.replace(/\s+/g, '-')}.png`, { type: 'image/png' });
|
||
if (navigator.share && navigator.canShare?.({ files: [file] })) {
|
||
try {
|
||
await navigator.share({ title: `${cardData.name}'s SkateSpots Card`, text: `Check out my SkateSpots Skater License 🛹\nskate-spots.org/${cardData.npub}`, files: [file] });
|
||
return;
|
||
} catch { /* fall through */ }
|
||
}
|
||
await navigator.clipboard.writeText(`https://skate-spots.org/${cardData.npub}`);
|
||
toast({ title: t('labUi.rider.linkCopied') });
|
||
}, [renderCardToImage, cardData.name, cardData.npub, toast, t]);
|
||
|
||
const handlePublishParkCard = useCallback(() => {
|
||
if (!user || !recipientPubkey) { toast({ title: t('labUi.rider.enterRiderNpub'), variant: 'destructive' }); return; }
|
||
const d = `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`;
|
||
const tags: string[][] = [
|
||
['d', d],
|
||
['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'],
|
||
['issuer', issuerName], ['access-level', accessLevel], ['theme', theme],
|
||
];
|
||
if (validThru) tags.push(['valid-thru', validThru]);
|
||
// Bind the pass to the venue the issuer owns, so /verify can confirm it.
|
||
if (venueSpot) tags.push(['a', `${SKATE_SPOT_KIND}:${venueSpot.pubkey}:${venueSpot.identifier}`]);
|
||
publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, {
|
||
onSuccess: () => { setSavedParkD(d); toast({ title: t('labUi.rider.parkPublished') }); },
|
||
onError: (e) => toast({ title: t('labUi.rider.publishFailed'), description: String(e), variant: 'destructive' }),
|
||
});
|
||
}, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, venueSpot, publishEvent, toast, t]);
|
||
|
||
const toggleSkill = (id: string) =>
|
||
setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
|
||
|
||
// ── Logged out ────────────────────────────────────────────────────────────
|
||
if (!user) {
|
||
return (
|
||
<>
|
||
<div className="min-h-[70vh] flex flex-col items-center justify-center px-4 text-center">
|
||
{/* Hero */}
|
||
<div className="mb-8 relative">
|
||
<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">
|
||
{t('labUi.rider.heroDesc')}
|
||
</p>
|
||
<p className="text-gray-600 text-sm mb-8 max-w-xs">
|
||
{t('labUi.rider.heroDesc2')}
|
||
</p>
|
||
<LoginArea className="justify-center" />
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="max-w-2xl mx-auto px-4 pb-16">
|
||
|
||
{/* ── Page header ── */}
|
||
<div className="pt-8 pb-6 text-center">
|
||
<h1 className="text-3xl font-black uppercase tracking-tight mb-1">
|
||
<span className="neon-red">RIDER</span>
|
||
<span className="text-white"> CARD</span>
|
||
</h1>
|
||
<p className="text-gray-500 text-xs tracking-widest uppercase">{t('labUi.rider.headerTagline')}</p>
|
||
</div>
|
||
|
||
{/* ── Top tabs: My Card / My Badges / Award Badge ── */}
|
||
<div className="flex rounded-xl overflow-hidden border border-white/10 mb-8 bg-black/40">
|
||
{([
|
||
{ id: 'card', label: `🪪 ${t('labUi.rider.tabCard')}` },
|
||
{ id: 'badges', label: `🏅 ${t('labUi.rider.tabBadges')}${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` },
|
||
{ id: 'award', label: `⭐ ${t('labUi.rider.tabAward')}` },
|
||
] as const).map(tab => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setPageTab(tab.id)}
|
||
className={cn(
|
||
'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'
|
||
)}
|
||
>
|
||
{tab.label}
|
||
</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
|
||
onClick={handleShare}
|
||
disabled={rendering}
|
||
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-5 w-5 animate-spin" /> : <Share2 className="h-5 w-5" />}
|
||
{rendering ? t('labUi.rider.rendering') : t('labUi.rider.shareCard')}
|
||
</Button>
|
||
|
||
{/* Secondary actions row */}
|
||
<div className="grid grid-cols-3 gap-2 w-full">
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleDownload}
|
||
disabled={rendering}
|
||
className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
|
||
>
|
||
<Download className="h-3.5 w-3.5" />
|
||
{t('labUi.rider.savePng')}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={handlePrint}
|
||
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" />
|
||
{t('labUi.rider.print')}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleCopyNpub}
|
||
className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
|
||
>
|
||
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
|
||
{copied ? t('common.copied') : t('labUi.rider.copyId')}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Save the card to Nostr so it persists & verifies */}
|
||
{cardType === 'rider' && (
|
||
<Button
|
||
onClick={handleSaveCard}
|
||
className="w-full h-12 bg-[#ccff00] hover:bg-[#b8e600] text-black font-black uppercase gap-2"
|
||
>
|
||
<Check className="h-4 w-4" />
|
||
{t('cardUi.save')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Card type toggle ── */}
|
||
<div>
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2">{t('labUi.rider.cardType')}</p>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{([
|
||
{ id: 'rider', label: t('labUi.rider.licenseLabel'), sub: t('labUi.rider.licenseSub'), icon: User },
|
||
{ id: 'park', label: t('labUi.rider.parkLabel'), sub: t('labUi.rider.parkSub'), icon: Building2 },
|
||
{ id: 'crew', label: t('cardUi.crew.crewCard'), sub: t('cardUi.crew.crewSub'), icon: Users },
|
||
{ id: 'event', label: t('cardUi.event.pass'), sub: t('cardUi.event.passSub'), icon: Ticket },
|
||
] as const).map(({ id, label, sub, icon: Icon }) => (
|
||
<button
|
||
key={id}
|
||
onClick={() => setCardType(id)}
|
||
className={cn(
|
||
'p-4 rounded-xl border text-left transition-all',
|
||
cardType === id
|
||
? 'bg-[#ff0040]/10 border-[#ff0040]/60 text-white'
|
||
: 'bg-black/30 border-white/8 text-gray-500 hover:border-white/20 hover:text-gray-300'
|
||
)}
|
||
>
|
||
<Icon className={cn('h-5 w-5 mb-2', cardType === id ? 'text-[#ff0040]' : 'text-gray-600')} />
|
||
<div className="text-xs font-black uppercase">{label}</div>
|
||
<div className="text-[10px] mt-0.5 opacity-60">{sub}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Theme picker ── */}
|
||
<div>
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2 flex items-center gap-1.5">
|
||
<Palette className="h-3 w-3" /> {t('labUi.rider.theme')}
|
||
</p>
|
||
<div className="grid grid-cols-6 gap-2">
|
||
{THEMES.map((th) => (
|
||
<button
|
||
key={th.id}
|
||
onClick={() => setTheme(th.id)}
|
||
className={cn(
|
||
'aspect-square rounded-xl border-2 transition-all flex flex-col items-center justify-center gap-0.5',
|
||
`card-theme-${th.id}`,
|
||
theme === th.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'
|
||
)}
|
||
>
|
||
<span className="text-xl">{th.emoji}</span>
|
||
<span className="text-[8px] font-black uppercase opacity-80">{th.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Holographic toggle ── */}
|
||
<div className="flex items-center justify-between px-4 py-3 rounded-xl bg-white/3 border border-white/8">
|
||
<div>
|
||
<div className="text-sm font-bold">✨ {t('labUi.rider.holoTitle')}</div>
|
||
<div className="text-xs text-gray-500">{t('labUi.rider.holoDesc')}</div>
|
||
</div>
|
||
<Switch checked={holographic} onCheckedChange={setHolographic} />
|
||
</div>
|
||
|
||
{/* ── RIDER fields ── */}
|
||
{cardType === 'rider' && (
|
||
<div className="space-y-5">
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('labUi.rider.cardDetails')}</p>
|
||
|
||
{/* Style */}
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.skateStyle')}</Label>
|
||
<Select value={style} onValueChange={setStyle}>
|
||
<SelectTrigger className="bg-black/50 border-white/10 h-11">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{STYLES.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* Rides For + Sponsors — side by side */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.ridesFor')}</Label>
|
||
<Input
|
||
placeholder={t('labUi.rider.ridesForPlaceholder')}
|
||
value={ridesFor}
|
||
onChange={e => setRidesFor(e.target.value)}
|
||
className="bg-black/50 border-white/10 text-sm h-11"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.sponsors')}</Label>
|
||
<Input
|
||
placeholder={t('labUi.rider.sponsorsPlaceholder')}
|
||
value={sponsors}
|
||
onChange={e => setSponsors(e.target.value)}
|
||
className="bg-black/50 border-white/10 text-sm h-11"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats toggles */}
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">{t('labUi.rider.showOnCard')}</Label>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{[
|
||
{ label: t('labUi.pyramid.spots'), value: stats?.spotsAdded ?? 0, state: showSpots, set: setShowSpots },
|
||
{ label: t('labUi.rider.zaps'), value: stats?.zapsReceived ?? 0, state: showZaps, set: setShowZaps },
|
||
{ label: t('labUi.pyramid.events'), value: stats?.eventsCreated ?? 0, state: showEvents, set: setShowEvents },
|
||
].map(({ label, value, state, set }) => (
|
||
<button
|
||
key={label}
|
||
onClick={() => set(!state)}
|
||
className={cn(
|
||
'p-3 rounded-xl border text-center transition-all',
|
||
state
|
||
? 'bg-[#ff0040]/10 border-[#ff0040]/40 text-white'
|
||
: 'bg-black/30 border-white/8 text-gray-600'
|
||
)}
|
||
>
|
||
<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>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Identity facets */}
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">{t('cardUi.facets.title')}</Label>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{[
|
||
{ label: t('cardUi.facets.crews'), value: crewCrests.length, state: showCrews, set: setShowCrews },
|
||
{ label: t('cardUi.facets.local'), value: local ? '📍' : '—', state: showLocal, set: setShowLocal },
|
||
{ label: t('cardUi.facets.roles'), value: roles.length, state: showRoles, set: setShowRoles },
|
||
].map(({ label, value, state, set }) => (
|
||
<button
|
||
key={label}
|
||
onClick={() => set(!state)}
|
||
className={cn(
|
||
'p-3 rounded-xl border text-center transition-all',
|
||
state
|
||
? 'bg-[#ff0040]/10 border-[#ff0040]/40 text-white'
|
||
: 'bg-black/30 border-white/8 text-gray-600'
|
||
)}
|
||
>
|
||
<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>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Skills */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.skills')}</Label>
|
||
<span className="text-[10px] text-gray-600">{t('labUi.rider.selected', { count: skills.length })}</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{SKILL_OPTIONS.map(s => (
|
||
<button
|
||
key={s.id}
|
||
onClick={() => toggleSkill(s.id)}
|
||
disabled={!skills.includes(s.id) && skills.length >= 6}
|
||
className={cn(
|
||
'px-2.5 py-1.5 rounded-lg text-xs font-bold border transition-all',
|
||
skills.includes(s.id)
|
||
? 'bg-[#ff0040]/15 border-[#ff0040]/50 text-white'
|
||
: 'bg-black/30 border-white/8 text-gray-500 hover:border-white/20 disabled:opacity-25 disabled:cursor-not-allowed'
|
||
)}
|
||
>
|
||
{s.icon} {s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="text-[10px] text-gray-600 mt-2">
|
||
⭐ {t('labUi.rider.goldBadgesHint1')} <button onClick={() => setPageTab('badges')} className="text-yellow-500 underline">{t('labUi.rider.tabBadges')}</button>{t('labUi.rider.goldBadgesHint2')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── PARK fields ── */}
|
||
{cardType === 'park' && (
|
||
<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>
|
||
<span className="font-bold text-blue-300">{t('labUi.rider.parkOwnersOnly')}</span>{' '}
|
||
{t('labUi.rider.parkOwnersDesc')}
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('labUi.rider.parkDetails')}</p>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.parkName')}</Label>
|
||
<Input value={issuerName} onChange={e => setIssuerName(e.target.value)}
|
||
placeholder={t('labUi.rider.parkNamePlaceholder')} className="bg-black/50 border-white/10 h-11" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.riderName')}</Label>
|
||
<Input value={recipientName} onChange={e => setRecipientName(e.target.value)}
|
||
placeholder={t('labUi.rider.riderNamePlaceholder')} className="bg-black/50 border-white/10 h-11" />
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.riderNpub')}</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 className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.accessLevel')}</Label>
|
||
<Select value={accessLevel} onValueChange={setAccessLevel}>
|
||
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{['Full Access','Supervised','Beginner Zone','Street Only','Bowl Only','Members Only'].map(l =>
|
||
<SelectItem key={l} value={l}>{l}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.validThru')}</Label>
|
||
<Input type="text" value={validThru} onChange={e => setValidThru(e.target.value)}
|
||
placeholder="2026-12-31" className="bg-black/50 border-white/10 h-11" />
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('cardUi.venue')}</Label>
|
||
<Select value={venueId || '__none'} onValueChange={(v) => setVenueId(v === '__none' ? '' : v)}>
|
||
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue placeholder={t('cardUi.venueNone')} /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__none">{t('cardUi.venueNone')}</SelectItem>
|
||
{myVenues.map(s => <SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-[10px] text-gray-600 mt-1.5">{t('cardUi.venueHint')}</p>
|
||
</div>
|
||
|
||
<Button
|
||
onClick={handlePublishParkCard}
|
||
className="w-full h-12 bg-[#ccff00] hover:bg-[#b8e600] text-black font-black uppercase gap-2"
|
||
>
|
||
<Zap className="h-4 w-4" />
|
||
{t('labUi.rider.publishToNostr')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── CREW fields ── */}
|
||
{cardType === 'crew' && (
|
||
<div className="space-y-5">
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('cardUi.crew.pick')}</p>
|
||
{memberships.length > 0 ? (
|
||
<Select value={activeCrew?.id ?? ''} onValueChange={setCrewId}>
|
||
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{memberships.map(m => (
|
||
<SelectItem key={m.crew.id} value={m.crew.id}>{m.crew.name} · {m.role}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<p className="text-xs text-gray-500">{t('cardUi.crew.none')}</p>
|
||
)}
|
||
<p className="text-[10px] text-gray-600">{t('cardUi.crew.hint')}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── EVENT fields (pass / wristband) ── */}
|
||
{cardType === 'event' && (
|
||
<div className="space-y-5">
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('cardUi.event.pick')}</p>
|
||
{myEvents.length > 0 ? (
|
||
<Select value={activeEvent?.id ?? ''} onValueChange={setEventId}>
|
||
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{myEvents.map(ev => (
|
||
<SelectItem key={ev.id} value={ev.id}>{ev.title}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<p className="text-xs text-gray-500">{t('cardUi.event.none')}</p>
|
||
)}
|
||
<p className="text-[10px] text-gray-600">{t('cardUi.event.hint')}</p>
|
||
</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">
|
||
{t('labUi.rider.badgesIntro')}
|
||
</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">{t('labUi.rider.earnMore')}</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" />
|
||
{t('labUi.rider.awardSomeone')}
|
||
</Button>
|
||
</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">{t('labUi.rider.youSawIt')}</div>
|
||
<div className="text-xs text-gray-400 mt-0.5">{t('labUi.rider.youSawItDesc')}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Inline award form — the dialog was removed as dead code */}
|
||
<AwardBadgeInline issuerPubkey={user.pubkey} onSuccess={() => setPageTab('badges')} />
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ─── Inline award form (used on the Award tab) ────────────────────────────────
|
||
function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPubkey: string; onSuccess: () => void }) {
|
||
const { t } = useTranslation();
|
||
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: t('labUi.rider.addRiderSkill'), 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: t('labUi.rider.invalidNpub'), 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: t('labUi.rider.badgeAwarded'), description: t('labUi.rider.badgeSent', { skills: selectedSkills.join(', ') }) });
|
||
setRecipientNpub(''); setSelectedSkills([]); setSpotName('');
|
||
onSuccess();
|
||
},
|
||
onError: (e) => toast({ title: t('labUi.rider.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">{t('labUi.rider.riderNpub')}</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">{t('labUi.rider.skillsWitnessed')}</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">{t('labUi.rider.whereSeen')} <span className="normal-case text-gray-600">{t('labUi.rider.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 ? t('labUi.rider.signingOnNostr') : t('labUi.rider.awardBadge')}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|