diff --git a/NIP.md b/NIP.md index 4898b2c..d27fe08 100644 --- a/NIP.md +++ b/NIP.md @@ -1219,13 +1219,15 @@ When a match is found, the app can facilitate P2P trading via encrypted DMs (NIP 3. No price or inventory data is publicly visible unless explicitly set to "public" 4. Group DMs (NIP-17) can be used for coordinating trades between multiple parties -## Rider Cards (kinds 35679, 37097) +## Rider Cards -SkateSpots supports two types of cryptographically signed physical-print cards. Both are addressable events issued by one pubkey to another, verifiable on-chain by anyone who scans the card's QR code. +SkateSpots supports physical-print cards that are cryptographically signed on Nostr and verifiable by anyone. ### Park Access Card (kind 35679) -Issued by a private park or skatepark owner to a specific rider, granting access rights. The park's Nostr key signs the event — anyone can verify the card is genuine. +Issued by a private skatepark owner to a specific rider, granting access rights. The park's Nostr key signs the event — anyone can verify the card is genuine by scanning the QR code. + +**Constraint:** Park Access Cards for private parks (`access=private` in kind 30818) should only be issued by the park's owner pubkey. Clients verify the issuer's pubkey matches the park event's `pubkey` field. ```json { @@ -1244,31 +1246,8 @@ Issued by a private park or skatepark owner to a specific rider, granting access } ``` -### Shop Rider Card (kind 37097) - -Issued by a skate shop to a rider who represents them. Proves sponsorship/affiliation — team riders, am riders, flow, staff, ambassadors. - -```json -{ - "kind": 37097, - "content": "", - "tags": [ - ["d", ""], - ["p", ""], - ["t", "shop-rider"], - ["issuer", ""], - ["access-level", ""], - ["theme", ""], - ["valid-thru", ""], - ["alt", "Shop Rider Card"] - ] -} -``` - ### Card Themes -Both card types support these visual themes for printing: - | Value | Description | |-------|-------------| | `thug` | Black + Neon Red (default) | @@ -1286,13 +1265,140 @@ The personal Rider License is generated client-side from the user's kind 0 profi - Spots added: count of kind 30818 authored by the user - Zaps received: count of kind 9735 targeting the user - Events created: count of kind 31923 authored by the user -- Shop cards held: count of kind 37097 with `#p` = user - Park cards held: count of kind 35679 with `#p` = user +- Witnessed skill badges: count of kind 1293 with `#p` = user + +The Rider License includes two categories of skill badges: +- **Self-claimed** — selected by the rider themselves, shown in theme color +- **Witnessed** — awarded by others (kind 1293), shown in gold ⭐ + +The "Rides For" field displays the local shop the rider represents, distinct from sponsors (brands). ### Verification -The QR code on printed Park Access and Shop Rider cards encodes the Nostr address (`naddr`) of the issuing event. Verifiers scan the QR, fetch the event from any relay, and confirm the issuer's signature. +The QR code on printed Park Access cards encodes the Nostr address (`naddr`) of the issuing event. Verifiers scan the QR, fetch the event from any relay, and confirm the issuer's signature. ``` URL format: https://skate-spots.org/verify/ ``` + +## Skill Badge Awards (kind 1293) + +Anyone who witnesses a rider performing a trick can award them a cryptographically signed skill badge. This creates an on-chain proof of skill — unfakeable because the witness's Nostr key signs the event. + +### Design Rationale + +Skill badges are **regular events** (not addressable) because: +- Multiple witnesses can each award the same skill to the same rider independently +- Each badge is a distinct attestation with its own timestamp and witness +- The full history of attestations is valuable (not just the latest) + +### Event Format + +```json +{ + "kind": 1293, + "content": "", + "tags": [ + ["p", ""], + ["skill", ""], + ["skill", ""], + ["spot", ""], + ["t", "skill-badge"], + ["alt", "Skill badge awarded: , "] + ] +} +``` + +### Required Tags + +| Tag | Description | +|-----|-------------| +| `p` | Pubkey of the rider receiving the badge | +| `skill` | Skill identifier (one tag per skill, multiple allowed) | +| `t` | Must be `skill-badge` for discoverability | +| `alt` | NIP-31 human-readable description | + +### Optional Tags + +| Tag | Description | +|-----|-------------| +| `spot` | Name of the spot where the trick was witnessed | +| `a` | Reference to a kind 30818 spot event | + +### Skill Identifiers + +| Value | Label | +|-------|-------| +| `kickflip` | Kickflip | +| `heelflip` | Heelflip | +| `switch` | Switch stance | +| `nollie` | Nollie | +| `fakie` | Fakie | +| `grind` | Grinds | +| `slide` | Slides | +| `manual` | Manuals | +| `vert` | Vert | +| `bowl` | Bowl | +| `street` | Street | +| `park` | Park | +| `film` | Filmer | +| `diy` | DIY Builder | + +### Trust Model + +The value of a badge is proportional to the reputation of the witness: +- NIP-05 verified witnesses carry more weight +- Witnesses known in the community (follows, mutual follows) carry more weight +- The skill badge feed is public — anyone can query badges for any rider + +### Querying + +**All badges received by a rider:** +```json +{ + "kinds": [1293], + "#p": [""], + "#t": ["skill-badge"] +} +``` + +**All badges issued by a witness:** +```json +{ + "kinds": [1293], + "authors": [""], + "#t": ["skill-badge"] +} +``` + +**Badges for a specific skill:** +```json +{ + "kinds": [1293], + "#p": [""], + "#skill": ["kickflip"] +} +``` + +### Example + +```json +{ + "kind": 1293, + "pubkey": "witness-pubkey-hex", + "created_at": 1703001600, + "content": "", + "tags": [ + ["p", "rider-pubkey-hex"], + ["skill", "kickflip"], + ["skill", "switch"], + ["spot", "MACBA Barcelona"], + ["a", "30818:abc123:macba"], + ["t", "skill-badge"], + ["alt", "Skill badge awarded: kickflip, switch"] + ], + "id": "...", + "sig": "..." +} +``` diff --git a/src/index.css b/src/index.css index 2c68262..72af2fb 100644 --- a/src/index.css +++ b/src/index.css @@ -513,30 +513,54 @@ html, body { .card-theme-sunset .card-muted { color: rgba(255,208,160,0.5); } .card-theme-sunset .card-bg-chip { background: rgba(255,80,0,0.10); border: 1px solid rgba(255,80,0,0.3); } +/* ── Print-only card: invisible on screen, appears on print ── */ +.print-only-card { + display: none; +} + /* ── Print styles ── */ @media print { - /* Hide everything except the card */ - body > *:not(.print-card-root) { display: none !important; } - .print-card-root { display: block !important; } + /* Hide absolutely everything */ + * { + visibility: hidden !important; + } - /* Reset page margins */ + /* Show only the print-only card and all its children */ + .print-only-card, + .print-only-card * { + visibility: visible !important; + } + + /* Position the card at top-left of the page */ + .print-only-card { + display: block !important; + position: fixed !important; + top: 0 !important; + left: 0 !important; + margin: 0 !important; + padding: 0 !important; + z-index: 99999 !important; + } + + /* Reset page to card size */ @page { size: 85.6mm 54mm; margin: 0; } - .rider-card { + /* Card at exact print dimensions */ + .print-only-card .rider-card { width: 85.6mm !important; height: 54mm !important; border-radius: 4mm !important; page-break-inside: avoid; - /* Remove glow shadows for crisp print */ box-shadow: none !important; } /* Kill holographic overlay on print */ .rider-card-holo::after { display: none !important; + visibility: hidden !important; } /* Kill all animations */ @@ -544,9 +568,4 @@ html, body { animation: none !important; transition: none !important; } - - /* Hide UI chrome */ - header, footer, nav, .no-print { - display: none !important; - } } diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 50944dd..ccbb362 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -1,18 +1,20 @@ /** * RiderCardPage — /card * - * Three card types: + * Card types: * 1. Rider License — personal card auto-filled from Nostr profile - * 2. Park Access Card — issued by a park to a rider (signed Nostr event) - * 3. Shop Rider Card — "rides for shop X" proof card (signed Nostr event) + * 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 hides everything else + * Print-ready: credit-card size (85.6×54mm), @media print isolates card */ -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { Layout } from '@/components/Layout'; @@ -20,31 +22,34 @@ import { LoginArea } from '@/components/auth/LoginArea'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Badge } from '@/components/ui/badge'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; -import { Card, CardContent } from '@/components/ui/card'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Switch } from '@/components/ui/switch'; +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from '@/components/ui/dialog'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useRiderStats } from '@/hooks/useRiderStats'; 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 { cn } from '@/lib/utils'; +import type { NostrEvent } from '@nostrify/nostrify'; import { - Printer, Download, CreditCard, MapPin, Zap, Star, - Shield, Copy, ChevronRight, Palette, Check, Building2, - ShoppingBag, User, Calendar, Trophy, Skateboard, + Printer, Copy, Palette, Check, Building2, + User, Shield, Award, Plus, Search, Zap, + ChevronDown, ChevronUp, Info, } from 'lucide-react'; -// ─── Nostr kinds (generated) ────────────────────────────────────────────────── +// ─── Nostr kinds ────────────────────────────────────────────────────────────── export const PARK_ACCESS_CARD_KIND = 35679; // Park issues to rider -export const SHOP_RIDER_CARD_KIND = 37097; // Shop issues to rider +export const SKILL_BADGE_KIND = 1293; // Witness awards skill badge to rider // ─── Themes ─────────────────────────────────────────────────────────────────── const THEMES = [ @@ -78,14 +83,15 @@ const SKILL_OPTIONS = [ const STYLES = ['Street', 'Park', 'Vert', 'Bowl', 'DIY', 'All-terrain'] as const; // ─── Helpers ────────────────────────────────────────────────────────────────── -function shortPubkey(pubkey: string) { +function shortNpub(pubkey: string) { + if (!pubkey) return '—'; const npub = nip19.npubEncode(pubkey); - return `${npub.slice(0, 12)}…${npub.slice(-6)}`; + return `${npub.slice(0, 10)}…${npub.slice(-4)}`; } -function cardNumber(pubkey: string) { - // Deterministic card number from pubkey — not sensitive - const n = parseInt(pubkey.slice(0, 8), 16) % 900000 + 100000; +function riderLicenseId(pubkey: string) { + // Deterministic license ID from pubkey — human-readable, not a secret + const n = parseInt(pubkey.slice(0, 8), 16) % 9000000 + 1000000; return `SS-${n}`; } @@ -93,11 +99,20 @@ function memberSince(createdAt: number) { return new Date(createdAt * 1000).getFullYear().toString(); } -// ─── The actual card visual ─────────────────────────────────────────────────── +// ─── Badge award event type ─────────────────────────────────────────────────── +interface BadgeAward { + witnessName: string; + witnessPubkey: string; + skill: string; + spotName?: string; + timestamp: number; +} +// ─── Card data shape ────────────────────────────────────────────────────────── interface CardData { - type: 'rider' | 'park' | 'shop'; + type: 'rider' | 'park'; theme: ThemeId; + holographic: boolean; // Rider name: string; nip05?: string; @@ -106,6 +121,7 @@ interface CardData { style: string; skills: string[]; sponsors: string; + ridesFor: string; showSpots: boolean; showZaps: boolean; showEvents: boolean; @@ -113,48 +129,47 @@ interface CardData { zapsReceived: number; eventsCreated: number; memberYear: string; - cardNum: string; - // Park / Shop + licenseId: string; + receivedBadges: BadgeAward[]; + // Park issuerName: string; - issuerLogo?: string; recipientName: string; accessLevel: string; validThru: string; - role: string; // Shop card role - holographic: boolean; } -function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject }) { - const themeClass = `card-theme-${d.theme}`; - const holoClass = d.holographic ? 'rider-card-holo' : ''; - - return ( -
- {/* Top color stripe */} -
- - {d.type === 'rider' && } - {d.type === 'park' && } - {d.type === 'shop' && } - - {/* Bottom micro-text */} -
- - skate-spots.org · Powered by Nostr - - - Tap to verify - -
-
- ); +// ─── 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); +} + +// ─── Card visual: Rider front ───────────────────────────────────────────────── function RiderFront({ d }: { d: CardData }) { + // Only show first 2 witnessed badges on the card itself + const witnessedBadges = d.receivedBadges.slice(0, 3); + return (
{/* Header row */} @@ -168,16 +183,17 @@ function RiderFront({ d }: { d: CardData }) {
Rider License
-
No.
-
{d.cardNum}
+
LICENSE ID
+
{d.licenseId}
+
{shortNpub(d.pubkey)}
{/* Main content */} -
+
{/* Avatar */}
-
+
{d.avatar ? ( {d.name} ) : ( @@ -190,63 +206,82 @@ function RiderFront({ d }: { d: CardData }) { {/* Identity */}
-
{d.name}
+
{d.name}
{d.nip05 && ( -
@{d.nip05.split('@')[0]}
+
@{d.nip05.split('@')[0]}
)} -
{shortPubkey(d.pubkey)}
{/* Style + year */} -
+
{d.style && ( - + {d.style} )} - Since {d.memberYear} + Since {d.memberYear}
+ + {/* Rides for */} + {d.ridesFor && ( +
+ 🏪 {d.ridesFor} +
+ )}
{/* Stats column */}
{d.showSpots && (
- Spots - {d.spotsAdded} + Spots + {d.spotsAdded}
)} {d.showZaps && (
- ⚡ Zaps - {d.zapsReceived} + ⚡ + {d.zapsReceived}
)} {d.showEvents && (
- Events - {d.eventsCreated} + Events + {d.eventsCreated}
)}
- {/* Skills row */} - {d.skills.length > 0 && ( -
- {d.skills.slice(0, 6).map(sk => { - const s = SKILL_OPTIONS.find(o => o.id === sk); - return s ? ( - - {s.icon} {s.label} - - ) : null; - })} -
- )} + {/* Skills + witnessed badges row */} +
+ {/* Self-selected skills */} + {d.skills.slice(0, 5).map(sk => { + const s = SKILL_OPTIONS.find(o => o.id === sk); + return s ? ( + + {s.icon} {s.label} + + ) : null; + })} + {/* Witnessed badges */} + {witnessedBadges.map((b, i) => { + const s = SKILL_OPTIONS.find(o => o.id === b.skill); + return s ? ( + + ⭐ {s.label} + + ) : null; + })} +
- {/* Sponsors */} + {/* Sponsors row */} {d.sponsors && ( -
+
🤝 {d.sponsors}
)} @@ -254,6 +289,7 @@ function RiderFront({ d }: { d: CardData }) { ); } +// ─── Card visual: Park Access front ────────────────────────────────────────── function ParkFront({ d }: { d: CardData }) { return (
@@ -281,7 +317,8 @@ function ParkFront({ d }: { d: CardData }) {
{d.recipientName || 'Member Name'}
-
{shortPubkey(d.pubkey)}
+
{shortNpub(d.pubkey)}
+
Issued by park owner · Nostr-signed
Valid thru
@@ -289,11 +326,11 @@ function ParkFront({ d }: { d: CardData }) {
- {/* Card number + issued */} + {/* License ID + shield */}
-
Card No.
-
{d.cardNum}
+
License ID
+
{d.licenseId}
@@ -304,78 +341,255 @@ function ParkFront({ d }: { d: CardData }) { ); } -function ShopFront({ d }: { d: CardData }) { +// ─── Main card wrapper ──────────────────────────────────────────────────────── +function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject }) { + const themeClass = `card-theme-${d.theme}`; + const holoClass = d.holographic ? 'rider-card-holo' : ''; + return ( -
- {/* Header */} -
-
-
- 🛍️ {d.issuerName || 'Skate Shop'} -
-
Official Rider
-
-
- {d.role || 'Am Rider'} -
-
+
+ {/* Top color stripe */} +
- {/* Rider info */} -
-
- {d.avatar ? ( - {d.recipientName} - ) : ( -
🛹
- )} -
-
-
{d.recipientName || 'Rider Name'}
-
{shortPubkey(d.pubkey)}
- {d.style && ( -
- {d.style} + {d.type === 'rider' && } + {d.type === 'park' && } + + {/* Bottom micro-text */} +
+ + skate-spots.org · Powered by Nostr + + + Scan to verify + +
+
+ ); +} + +// ─── Award Badge Dialog ─────────────────────────────────────────────────────── +interface AwardBadgeDialogProps { + open: boolean; + onClose: () => void; + issuerPubkey: string; +} + +function AwardBadgeDialog({ open, onClose, issuerPubkey }: AwardBadgeDialogProps) { + const { mutate: publishEvent, isPending } = useNostrPublish(); + const { toast } = useToast(); + + const [recipientNpub, setRecipientNpub] = useState(''); + const [selectedSkills, setSelectedSkills] = useState([]); + 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: 'Fill in 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('not npub'); + recipientHex = decoded.data; + } catch { + toast({ title: 'Invalid npub', description: 'Paste a valid npub1… address', 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 to rider.` }); + setRecipientNpub(''); + setSelectedSkills([]); + setSpotName(''); + onClose(); + }, + onError: (e) => toast({ title: 'Failed', description: String(e), variant: 'destructive' }), + }, + ); + }; + + return ( + !v && onClose()}> + + + + + Award a Skill Badge + + + You saw it happen. Sign the proof on Nostr — it becomes part of their permanent record. + + + +
+ {/* Recipient */} +
+ + setRecipientNpub(e.target.value)} + placeholder="npub1…" + className="bg-black/50 border-gray-700 font-mono text-xs" + /> +
+ + {/* Skills */} +
+ +
+ {SKILL_OPTIONS.map(s => ( + + ))}
- )} -
-
-
Since
-
{d.memberYear}
- {d.validThru && ( - <> -
Thru
-
{d.validThru}
- - )} -
-
- - {/* Skills */} - {d.skills.length > 0 && ( -
- {d.skills.slice(0, 5).map(sk => { - const s = SKILL_OPTIONS.find(o => o.id === sk); - return s ? ( - - {s.icon} {s.label} - - ) : null; - })} -
-
+ + {/* Spot name */} +
+ + setSpotName(e.target.value)} + placeholder="e.g. MACBA, The Berrics, Burnside…" + className="bg-black/50 border-gray-700 text-sm" + /> +
+ +
+ 🔐 On-chain proof.{' '} + This badge is cryptographically signed by your Nostr key. The rider can + display it with your name as witness — and anyone can verify it. +
+ +
+
+
+ ); +} + +// ─── Received badges panel ──────────────────────────────────────────────────── +function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) { + const { data: badgeEvents = [] } = useReceivedBadges(pubkey); + const [expanded, setExpanded] = useState(false); + + if (badgeEvents.length === 0) { + return ( +
+
🏅
+

No badges yet. Skate in front of someone and ask them to witness your tricks!

+
+ ); + } + + const show = expanded ? badgeEvents : badgeEvents.slice(0, 3); + + return ( +
+ {show.map((event) => ( + + ))} + {badgeEvents.length > 3 && ( + )}
); } -// ─── Page ───────────────────────────────────────────────────────────────────── +function BadgeEventRow({ event }: { event: NostrEvent }) { + 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 ( +
+
+ + + {witnessName.slice(0, 2).toUpperCase()} + +
+
+
+ {skills.map(sk => { + const s = SKILL_OPTIONS.find(o => o.id === sk); + return s ? ( + + {s.icon} {s.label} + + ) : null; + })} +
+

+ Witnessed by {witnessName} + {spot && <> at {spot}} +

+
+
+ {new Date(event.created_at * 1000).toLocaleDateString()} +
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── export default function RiderCardPage() { useSeoMeta({ title: 'Rider Card — SkateSpots', - description: 'Create your printable SkateSpots Rider License, Park Access Card, or Shop Rider Card.', + description: 'Create your printable SkateSpots Rider License or Park Access Card.', }); const { user } = useCurrentUser(); @@ -389,29 +603,58 @@ export default function RiderCardPage() { const displayName = metadata?.name ?? genUserName(user?.pubkey ?? ''); const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : ''; + // Received badges for the logged-in user + const { data: receivedBadgeEvents = [] } = useReceivedBadges(user?.pubkey ?? ''); + + // Parse badge events into BadgeAward objects + 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), // simplified for card visual + witnessPubkey: event.pubkey, + spotName: spot, + timestamp: event.created_at, + })); + }); + // ── Form state ────────────────────────────────────────────────────────────── - const [cardType, setCardType] = useState<'rider' | 'park' | 'shop'>('rider'); + const [cardType, setCardType] = useState<'rider' | 'park'>('rider'); const [theme, setTheme] = useState('thug'); const [holographic, setHolographic] = useState(true); + const [showAwardDialog, setShowAwardDialog] = useState(false); + const [activeTab, setActiveTab] = useState<'card' | 'badges'>('card'); // Rider fields const [style, setStyle] = useState('Street'); const [skills, setSkills] = useState([]); const [sponsors, setSponsors] = useState(''); + const [ridesFor, setRidesFor] = useState(''); const [showSpots, setShowSpots] = useState(true); const [showZaps, setShowZaps] = useState(true); const [showEvents, setShowEvents] = useState(true); - // Park/Shop fields + // Park fields const [issuerName, setIssuerName] = useState(''); const [recipientName, setRecipientName] = useState(''); - const [recipientPubkey, setRecipientPubkey] = useState(user?.pubkey ?? ''); + const [recipientNpub, setRecipientNpub] = useState(''); const [accessLevel, setAccessLevel] = useState('Full Access'); - const [role, setRole] = useState('Am Rider'); const [validThru, setValidThru] = useState(''); const [copied, setCopied] = useState(false); + // ── Parse recipientNpub → pubkey ───────────────────────────────────────── + 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 ?? ''; + } + })(); + // ── Computed card data ─────────────────────────────────────────────────────── const cardData: CardData = { type: cardType, @@ -424,6 +667,7 @@ export default function RiderCardPage() { style, skills, sponsors, + ridesFor, showSpots, showZaps, showEvents, spotsAdded: stats?.spotsAdded ?? 0, zapsReceived: stats?.zapsReceived ?? 0, @@ -431,12 +675,12 @@ export default function RiderCardPage() { memberYear: metadata?.created_at ? memberSince(metadata.created_at as number) : new Date().getFullYear().toString(), - cardNum: user?.pubkey ? cardNumber(user.pubkey) : 'SS-000000', + licenseId: user?.pubkey ? riderLicenseId(user.pubkey) : 'SS-0000000', + receivedBadges, issuerName, recipientName, accessLevel, validThru, - role, }; // ── Actions ───────────────────────────────────────────────────────────────── @@ -450,39 +694,37 @@ export default function RiderCardPage() { setTimeout(() => setCopied(false), 2000); }, [npub]); - const handlePublishCard = useCallback(() => { + const handlePublishParkCard = useCallback(() => { if (!user) return; - const targetPubkey = cardType === 'rider' ? user.pubkey : recipientPubkey; - if (!targetPubkey) { - toast({ title: 'Enter a recipient npub', variant: 'destructive' }); + if (!recipientPubkey) { + toast({ title: "Enter the rider's npub", variant: 'destructive' }); return; } - const kind = cardType === 'park' ? PARK_ACCESS_CARD_KIND : SHOP_RIDER_CARD_KIND; const tags: string[][] = [ - ['d', `${cardType}-${targetPubkey.slice(0, 16)}-${Date.now()}`], - ['p', targetPubkey], - ['t', cardType === 'park' ? 'skatepark-access' : 'shop-rider'], - ['alt', cardType === 'park' ? 'Skatepark Access Card' : 'Shop Rider Card'], + ['d', `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`], + ['p', recipientPubkey], + ['t', 'skatepark-access'], + ['alt', 'Skatepark Access Card'], ['issuer', issuerName], - ['access-level', cardType === 'park' ? accessLevel : role], + ['access-level', accessLevel], ['theme', theme], ]; if (validThru) tags.push(['valid-thru', validThru]); publishEvent( - { kind, content: '', tags }, + { kind: PARK_ACCESS_CARD_KIND, content: '', tags }, { - onSuccess: () => toast({ title: '✅ Card published to Nostr!', description: 'The rider can now verify it.' }), + 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' }), }, ); - }, [user, cardType, recipientPubkey, issuerName, accessLevel, role, validThru, theme, publishEvent, toast]); + }, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, publishEvent, toast]); const toggleSkill = (id: string) => setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]); - // ── Render ─────────────────────────────────────────────────────────────────── + // ── Render: logged out ──────────────────────────────────────────────────── if (!user) { return ( @@ -498,37 +740,77 @@ export default function RiderCardPage() { return ( - {/* Print root — only this div is visible during print */} -
+ {/* + PRINT ISOLATION: when window.print() fires, @media print CSS in index.css + hides everything except elements with `rider-card` class via direct targeting. + The printable card is rendered in a fixed-position portal outside the layout flow. + */} -
- {/* Page header */} -
-

- SKATE - CARDS -

-

- Print-ready credit-card size (85.6 × 54 mm). Cryptographically signed on Nostr. -

-
+ {/* ── Print-only card (fixed, invisible on screen, visible on print) ── */} + +
+ {/* Page header */} +
+

+ SKATE + CARDS +

+

+ Print-ready credit-card size (85.6 × 54 mm). Cryptographically signed on Nostr. +

+
+ + {/* Inner tab nav */} +
+ + + +
+ + {/* ── Card tab ─────────────────────────────────────────────────── */} + {activeTab === 'card' && (
- {/* ── Left: Card preview ─────────────────────────────────────── */} + {/* Left: Card preview */}
- {/* Card preview */} -
- -
+ - {/* Print-only version — hidden on screen, visible when printing */} -
- -
- - {/* Action buttons */} -
+ {/* Actions */} +
- {(cardType === 'park' || cardType === 'shop') && ( + {cardType === 'park' && (
- {/* Print tip */} -

- 📄 Click Print → set paper to Custom 85.6×54mm (or Business Card), - no margins. Cut with scissors. Laminate for extra style. 🛹 +

+ 📄 Print → Custom 85.6×54 mm (or Business Card), no margins. Laminate for extra street cred. 🛹

- {/* ── Right: Controls ────────────────────────────────────────── */} -
+ {/* Right: Controls */} +
- {/* Card type tabs */} + {/* Card type */}
-
+
{([ - { id: 'rider', label: 'Rider License', icon: User }, - { id: 'park', label: 'Park Access', icon: Building2 }, - { id: 'shop', label: 'Shop Rider', icon: ShoppingBag }, + { id: 'rider', label: 'Rider License', icon: User }, + { id: 'park', label: 'Park Access', icon: Building2 }, ] as const).map(({ id, label, icon: Icon }) => (
+ {/* Park access note */} + {cardType === 'park' && ( +
+ +
+ Park owners only.{' '} + Park Access Cards should only be issued by the park's Nostr key. + For private parks this is enforced on-chain — anyone can verify the issuer matches the park owner. +
+
+ )} + {/* Theme picker */}
- {/* Type-specific fields */} + {/* ── RIDER fields ─────────────────────────────────────────── */} {cardType === 'rider' && (
{/* Style */} @@ -641,11 +932,29 @@ export default function RiderCardPage() {
+ {/* Rides For shop */} +
+ + setRidesFor(e.target.value)} + className="bg-black/50 border-gray-700 text-sm" + /> +

+ The local shop you represent. Different from sponsors. +

+
+ {/* Sponsors */}
- + setSponsors(e.target.value)} className="bg-black/50 border-gray-700 text-sm" @@ -677,10 +986,10 @@ export default function RiderCardPage() {
- {/* Skill badges */} + {/* Skill badges (self-selected) */}
{SKILL_OPTIONS.map(s => ( @@ -699,26 +1008,39 @@ export default function RiderCardPage() { ))}
+

+ ⭐ Gold badges on the card are witnessed by others — check the Badges tab. +

)} + {/* ── PARK fields ──────────────────────────────────────────── */} {cardType === 'park' && (
setIssuerName(e.target.value)} - placeholder="e.g. The Berrics, FDR Skatepark..." className="bg-black/50 border-gray-700" /> + placeholder="e.g. The Berrics, FDR Skatepark…" className="bg-black/50 border-gray-700" />
- + setRecipientName(e.target.value)} placeholder="Full name on card" className="bg-black/50 border-gray-700" />
- - setRecipientPubkey(e.target.value)} - placeholder="npub1... or leave for your own key" className="bg-black/50 border-gray-700 font-mono text-xs" /> + + setRecipientNpub(e.target.value)} + placeholder="npub1… — their Nostr identity ties the card to them" + className="bg-black/50 border-gray-700 font-mono text-xs" + /> +

+ Leave blank to issue to yourself. The card is signed by your key as the park owner. +

@@ -735,79 +1057,62 @@ export default function RiderCardPage() { setValidThru(e.target.value)} placeholder="e.g. 2026-12-31" className="bg-black/50 border-gray-700" />
-
- )} - {cardType === 'shop' && ( -
-
- - setIssuerName(e.target.value)} - placeholder="e.g. Element, Skate Warehouse..." className="bg-black/50 border-gray-700" /> -
-
- - setRecipientName(e.target.value)} - placeholder="Rider's name" className="bg-black/50 border-gray-700" /> -
-
- - setRecipientPubkey(e.target.value)} - placeholder="npub1..." className="bg-black/50 border-gray-700 font-mono text-xs" /> -
-
- - -
-
- - -
-
- -
- {SKILL_OPTIONS.map(s => ( - - ))} -
-
-
- - setValidThru(e.target.value)} - placeholder="e.g. 2026" className="bg-black/50 border-gray-700" /> +
+
🔐 Nostr-verified access
+ This card is published as a signed Nostr event from your key. + Anyone scanning the QR code can verify the card is genuine and was issued by + the park's Nostr identity.
)} - {/* Info box */} -
-
🔐 Nostr-verified cards
- Park Access and Shop Rider cards can be published as signed Nostr events. - Anyone scanning the QR can verify the card is genuine — it's cryptographically - signed by the issuer's key. -
-
+ )} + + {/* ── Badges tab ────────────────────────────────────────────────── */} + {activeTab === 'badges' && ( +
+
+
+

Your Witnessed Badges

+

+ Badges signed by people who saw you skate. Cryptographically unforgeable. +

+
+ +
+ + + +
+

🏅 How badges work

+
    +
  • Anyone can award you a badge when they witness your tricks
  • +
  • Badges are signed Nostr events — they can't be faked
  • +
  • The more trusted the witness (NIP-05 verified, known in the community), the more weight the badge carries
  • +
  • Gold ⭐ badges on your card show skills others have witnessed — different from your self-claimed skills
  • +
  • Ask friends and filmers to award you badges at spots you've skated
  • +
+
+
+ )}
+ + {/* Award badge dialog */} + setShowAwardDialog(false)} + issuerPubkey={user.pubkey} + /> ); }