From 838ef3f3559ddff759f5ad63553f6c510b82969b Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Tue, 16 Jun 2026 14:29:54 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20SkateSpots=20Rider=20Cards=20=E2=80=94?= =?UTF-8?q?=203=20card=20types,=206=20themes,=20holographic=20shimmer,=20p?= =?UTF-8?q?rint-ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: shakespeare.diy --- NIP.md | 78 ++++ src/AppRouter.tsx | 2 + src/components/Layout.tsx | 20 +- src/hooks/useRiderStats.ts | 64 +++ src/index.css | 167 ++++++++ src/pages/LabPage.tsx | 8 +- src/pages/RiderCardPage.tsx | 813 ++++++++++++++++++++++++++++++++++++ 7 files changed, 1148 insertions(+), 4 deletions(-) create mode 100644 src/hooks/useRiderStats.ts create mode 100644 src/pages/RiderCardPage.tsx diff --git a/NIP.md b/NIP.md index eda9ee6..4898b2c 100644 --- a/NIP.md +++ b/NIP.md @@ -1218,3 +1218,81 @@ When a match is found, the app can facilitate P2P trading via encrypted DMs (NIP 2. Selective disclosure allows users to share specific items via DM 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) + +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. + +### 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. + +```json +{ + "kind": 35679, + "content": "", + "tags": [ + ["d", ""], + ["p", ""], + ["t", "skatepark-access"], + ["issuer", ""], + ["access-level", ""], + ["theme", ""], + ["valid-thru", ""], + ["alt", "Skatepark Access Card"] + ] +} +``` + +### 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) | +| `lime` | Black + Neon Lime | +| `chrome` | Steel & Silver | +| `wood` | Woodgrain Classic | +| `midnight` | Deep Blue | +| `sunset` | Orange & Pink | + +### Rider License (client-side only) + +The personal Rider License is generated client-side from the user's kind 0 profile metadata and on-chain stats. It is not a separate Nostr event — it renders from existing data: + +- Name, avatar, NIP-05: from kind 0 +- 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 + +### 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. + +``` +URL format: https://skate-spots.org/verify/ +``` diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 3c04134..28bff0c 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -40,6 +40,7 @@ import StashPage from "./pages/StashPage"; import { NIP19Page } from "./pages/NIP19Page"; import LicensePage from "./pages/LicensePage"; import SupportersPage from "./pages/SupportersPage"; +import RiderCardPage from "./pages/RiderCardPage"; import NotFound from "./pages/NotFound"; export function AppRouter() { @@ -89,6 +90,7 @@ export function AppRouter() { {/* Stash - Private Inventory */} } /> {/* Info pages */} + } /> } /> } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5542908..68e8d9c 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -44,6 +44,7 @@ import { Package, Zap, Heart, + CreditCard, } from 'lucide-react'; import { OfflineIndicator } from '@/components/OfflineIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator'; @@ -426,11 +427,17 @@ export function Layout({ children }: LayoutProps) { - - - Supporters ⚡ + + + Rider Card + + + + Supporters ⚡ + + @@ -647,6 +654,13 @@ export function Layout({ children }: LayoutProps) { isActive={isActive('/about')} onClick={() => setMobileMenuOpen(false)} /> + setMobileMenuOpen(false)} + /> { + if (!pubkey) return null; + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); + + // Single query — multiple filter objects, one round-trip + const events = await nostr.query( + [ + // Spots the user added + { kinds: [30818], authors: [pubkey], '#t': ['skatespot'], limit: 200 }, + // Events they created + { kinds: [31923], authors: [pubkey], limit: 100 }, + // Zap receipts targeting their pubkey + { kinds: [9735], '#p': [pubkey], limit: 200 }, + // Shop rider cards issued TO this user (kind 37097) + { kinds: [37097], '#p': [pubkey], limit: 50 }, + // Park access cards issued TO this user (kind 35679) + { kinds: [35679], '#p': [pubkey], limit: 50 }, + ], + { signal }, + ); + + const stats: RiderStats = { + spotsAdded: events.filter(e => e.kind === 30818).length, + eventsCreated: events.filter(e => e.kind === 31923).length, + zapsReceived: events.filter(e => e.kind === 9735).length, + shopCards: events.filter(e => e.kind === 37097).length, + parkCards: events.filter(e => e.kind === 35679).length, + }; + + return stats; + }, + }); +} diff --git a/src/index.css b/src/index.css index d6ced38..2c68262 100644 --- a/src/index.css +++ b/src/index.css @@ -383,3 +383,170 @@ html, body { display: inline; } } + +/* ─── SkateSpots Rider Card ──────────────────────────────────────────────── */ + +/* Credit-card aspect ratio: 85.6mm × 54mm = 1.586:1 */ +.rider-card { + width: 340px; + height: 214px; + border-radius: 12px; + position: relative; + overflow: hidden; + flex-shrink: 0; + font-family: 'Inter Variable', 'Inter', system-ui, sans-serif; +} + +/* Holographic shimmer overlay — appears on hover/focus for screen, + hidden on print so the card prints cleanly */ +@keyframes holo-shift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +.rider-card-holo::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient( + 125deg, + transparent 20%, + rgba(255,255,255,0.08) 30%, + rgba(255,255,255,0.18) 40%, + rgba(255,0,64,0.08) 50%, + rgba(204,255,0,0.10) 60%, + rgba(255,255,255,0.08) 70%, + transparent 80% + ); + background-size: 300% 300%; + animation: holo-shift 4s ease infinite; + pointer-events: none; + z-index: 10; + mix-blend-mode: screen; +} + +/* ── Theme: THUG (default — black + neon red) ── */ +.card-theme-thug { + background: linear-gradient(135deg, #0a0a0a 0%, #1a0008 60%, #0d0d0d 100%); + border: 1.5px solid rgba(255,0,64,0.5); + box-shadow: 0 0 30px rgba(255,0,64,0.2), inset 0 1px 0 rgba(255,255,255,0.05); + color: #fff; +} +.card-theme-thug .card-accent { color: #ff0040; } +.card-theme-thug .card-accent2 { color: #ccff00; } +.card-theme-thug .card-stripe { background: linear-gradient(90deg, #ff0040, #ff6600); } +.card-theme-thug .card-muted { color: rgba(255,255,255,0.45); } +.card-theme-thug .card-bg-chip { background: rgba(255,0,64,0.12); border: 1px solid rgba(255,0,64,0.3); } + +/* ── Theme: NEON LIME ── */ +.card-theme-lime { + background: linear-gradient(135deg, #050f00 0%, #0a1a00 60%, #050f00 100%); + border: 1.5px solid rgba(204,255,0,0.5); + box-shadow: 0 0 30px rgba(204,255,0,0.15), inset 0 1px 0 rgba(255,255,255,0.05); + color: #fff; +} +.card-theme-lime .card-accent { color: #ccff00; } +.card-theme-lime .card-accent2 { color: #ff6600; } +.card-theme-lime .card-stripe { background: linear-gradient(90deg, #ccff00, #88ff00); } +.card-theme-lime .card-muted { color: rgba(255,255,255,0.45); } +.card-theme-lime .card-bg-chip { background: rgba(204,255,0,0.10); border: 1px solid rgba(204,255,0,0.3); } + +/* ── Theme: CHROME (silver/steel) ── */ +.card-theme-chrome { + background: linear-gradient(135deg, #1a1a1a 0%, #2e2e2e 40%, #111 100%); + border: 1.5px solid rgba(200,200,200,0.4); + box-shadow: 0 0 20px rgba(180,180,180,0.1), inset 0 1px 0 rgba(255,255,255,0.15); + color: #e8e8e8; +} +.card-theme-chrome .card-accent { color: #c8c8c8; } +.card-theme-chrome .card-accent2 { color: #ff6600; } +.card-theme-chrome .card-stripe { background: linear-gradient(90deg, #888, #ccc, #888); } +.card-theme-chrome .card-muted { color: rgba(232,232,232,0.45); } +.card-theme-chrome .card-bg-chip { background: rgba(200,200,200,0.08); border: 1px solid rgba(200,200,200,0.2); } + +/* ── Theme: WOODGRAIN ── */ +.card-theme-wood { + background: + repeating-linear-gradient( + 90deg, + transparent, + transparent 2px, + rgba(0,0,0,0.03) 2px, + rgba(0,0,0,0.03) 4px + ), + linear-gradient(160deg, #3d2008 0%, #5c3210 30%, #2e1804 60%, #4a2a0e 100%); + border: 1.5px solid rgba(160,100,40,0.6); + box-shadow: 0 0 20px rgba(120,70,20,0.2), inset 0 1px 0 rgba(255,200,100,0.1); + color: #f5e6c8; +} +.card-theme-wood .card-accent { color: #f5c842; } +.card-theme-wood .card-accent2 { color: #ff6600; } +.card-theme-wood .card-stripe { background: linear-gradient(90deg, #8b5e3c, #d4a05a, #8b5e3c); } +.card-theme-wood .card-muted { color: rgba(245,230,200,0.5); } +.card-theme-wood .card-bg-chip { background: rgba(160,100,40,0.15); border: 1px solid rgba(160,100,40,0.4); } + +/* ── Theme: MIDNIGHT BLUE ── */ +.card-theme-midnight { + background: linear-gradient(135deg, #020818 0%, #050e2a 60%, #030b1a 100%); + border: 1.5px solid rgba(100,160,255,0.4); + box-shadow: 0 0 30px rgba(60,120,255,0.15), inset 0 1px 0 rgba(100,160,255,0.08); + color: #d0e4ff; +} +.card-theme-midnight .card-accent { color: #64a0ff; } +.card-theme-midnight .card-accent2 { color: #ccff00; } +.card-theme-midnight .card-stripe { background: linear-gradient(90deg, #3060cc, #64a0ff); } +.card-theme-midnight .card-muted { color: rgba(208,228,255,0.45); } +.card-theme-midnight .card-bg-chip { background: rgba(100,160,255,0.08); border: 1px solid rgba(100,160,255,0.25); } + +/* ── Theme: SUNSET (orange/pink) ── */ +.card-theme-sunset { + background: linear-gradient(135deg, #120006 0%, #1a0808 40%, #0e0810 100%); + border: 1.5px solid rgba(255,80,0,0.5); + box-shadow: 0 0 30px rgba(255,60,0,0.2), inset 0 1px 0 rgba(255,150,50,0.08); + color: #ffd0a0; +} +.card-theme-sunset .card-accent { color: #ff6020; } +.card-theme-sunset .card-accent2 { color: #ff0080; } +.card-theme-sunset .card-stripe { background: linear-gradient(90deg, #ff0040, #ff6020, #ffaa00); } +.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 styles ── */ +@media print { + /* Hide everything except the card */ + body > *:not(.print-card-root) { display: none !important; } + .print-card-root { display: block !important; } + + /* Reset page margins */ + @page { + size: 85.6mm 54mm; + margin: 0; + } + + .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; + } + + /* Kill all animations */ + *, *::before, *::after { + animation: none !important; + transition: none !important; + } + + /* Hide UI chrome */ + header, footer, nav, .no-print { + display: none !important; + } +} diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index 0e83565..369b2a0 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -16,7 +16,7 @@ import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useTranslation } from '@/contexts/LanguageContext'; import { genUserName } from '@/lib/genUserName'; import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS } from '@/lib/types'; -import { GraduationCap, Search, Plus, MapPin, Filter, Video, Scissors } from 'lucide-react'; +import { GraduationCap, Search, Plus, MapPin, Filter, Video, Scissors, CreditCard } from 'lucide-react'; import { cn } from '@/lib/utils'; const COACH_SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS); @@ -130,6 +130,12 @@ export default function LabPage() { Offer Editing + + + ) : ( diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx new file mode 100644 index 0000000..50944dd --- /dev/null +++ b/src/pages/RiderCardPage.tsx @@ -0,0 +1,813 @@ +/** + * RiderCardPage — /card + * + * Three 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) + * + * 6 visual themes: THUG · LIME · CHROME · WOOD · MIDNIGHT · SUNSET + * Print-ready: credit-card size (85.6×54mm), @media print hides everything else + */ + +import { useState, useRef, useCallback } from 'react'; +import { useSeoMeta } from '@unhead/react'; +import { Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; + +import { Layout } from '@/components/Layout'; +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 { 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 { genUserName } from '@/lib/genUserName'; +import { cn } from '@/lib/utils'; + +import { + Printer, Download, CreditCard, MapPin, Zap, Star, + Shield, Copy, ChevronRight, Palette, Check, Building2, + ShoppingBag, User, Calendar, Trophy, Skateboard, +} from 'lucide-react'; + +// ─── Nostr kinds (generated) ────────────────────────────────────────────────── +export const PARK_ACCESS_CARD_KIND = 35679; // Park issues to rider +export const SHOP_RIDER_CARD_KIND = 37097; // Shop issues to rider + +// ─── Themes ─────────────────────────────────────────────────────────────────── +const THEMES = [ + { id: 'thug', label: 'THUG', emoji: '🔴', desc: 'Black + Neon Red' }, + { id: 'lime', label: 'LIME', emoji: '🟢', desc: 'Black + Neon Lime' }, + { id: 'chrome', label: 'CHROME', emoji: '⚪', desc: 'Steel & Silver' }, + { id: 'wood', label: 'WOOD', emoji: '🟤', desc: 'Woodgrain Classic' }, + { id: 'midnight', label: 'MIDNIGHT', emoji: '🔵', desc: 'Deep Blue' }, + { id: 'sunset', label: 'SUNSET', emoji: '🟠', desc: 'Orange & Pink' }, +] as const; +type ThemeId = typeof THEMES[number]['id']; + +// ─── Skill badges ───────────────────────────────────────────────────────────── +const SKILL_OPTIONS = [ + { id: 'kickflip', label: 'Kickflip', icon: '🛹' }, + { id: 'heelflip', label: 'Heelflip', icon: '👟' }, + { id: 'switch', label: 'Switch', icon: '🔄' }, + { id: 'nollie', label: 'Nollie', icon: '⬆️' }, + { id: 'fakie', label: 'Fakie', icon: '↩️' }, + { id: 'grind', label: 'Grinds', icon: '⚡' }, + { id: 'slide', label: 'Slides', icon: '➡️' }, + { id: 'manual', label: 'Manuals', icon: '🎯' }, + { id: 'vert', label: 'Vert', icon: '🏄' }, + { id: 'bowl', label: 'Bowl', icon: '🥣' }, + { id: 'street', label: 'Street', icon: '🏙️' }, + { id: 'park', label: 'Park', icon: '🌿' }, + { id: 'film', label: 'Filmer', icon: '🎥' }, + { id: 'diy', label: 'DIY Builder', icon: '🔨' }, +] as const; + +const STYLES = ['Street', 'Park', 'Vert', 'Bowl', 'DIY', 'All-terrain'] as const; + +// ─── Helpers ────────────────────────────────────────────────────────────────── +function shortPubkey(pubkey: string) { + const npub = nip19.npubEncode(pubkey); + return `${npub.slice(0, 12)}…${npub.slice(-6)}`; +} + +function cardNumber(pubkey: string) { + // Deterministic card number from pubkey — not sensitive + const n = parseInt(pubkey.slice(0, 8), 16) % 900000 + 100000; + return `SS-${n}`; +} + +function memberSince(createdAt: number) { + return new Date(createdAt * 1000).getFullYear().toString(); +} + +// ─── The actual card visual ─────────────────────────────────────────────────── + +interface CardData { + type: 'rider' | 'park' | 'shop'; + theme: ThemeId; + // Rider + name: string; + nip05?: string; + avatar?: string; + pubkey: string; + style: string; + skills: string[]; + sponsors: string; + showSpots: boolean; + showZaps: boolean; + showEvents: boolean; + spotsAdded: number; + zapsReceived: number; + eventsCreated: number; + memberYear: string; + cardNum: string; + // Park / Shop + 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 + +
+
+ ); +} + +function RiderFront({ d }: { d: CardData }) { + return ( +
+ {/* Header row */} +
+
+
+ + 🛹 SkateSpots + +
+
Rider License
+
+
+
No.
+
{d.cardNum}
+
+
+ + {/* Main content */} +
+ {/* Avatar */} +
+
+ {d.avatar ? ( + {d.name} + ) : ( +
+ 🛹 +
+ )} +
+
+ + {/* Identity */} +
+
{d.name}
+ {d.nip05 && ( +
@{d.nip05.split('@')[0]}
+ )} +
{shortPubkey(d.pubkey)}
+ + {/* Style + year */} +
+ {d.style && ( + + {d.style} + + )} + Since {d.memberYear} +
+
+ + {/* Stats column */} +
+ {d.showSpots && ( +
+ Spots + {d.spotsAdded} +
+ )} + {d.showZaps && ( +
+ ⚡ Zaps + {d.zapsReceived} +
+ )} + {d.showEvents && ( +
+ 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; + })} +
+ )} + + {/* Sponsors */} + {d.sponsors && ( +
+ 🤝 {d.sponsors} +
+ )} +
+ ); +} + +function ParkFront({ d }: { d: CardData }) { + return ( +
+ {/* Header */} +
+
+
+ 🏟️ {d.issuerName || 'Skatepark'} +
+
Access Pass
+
+
+ {d.accessLevel || 'Full Access'} +
+
+ + {/* Member info */} +
+
+ {d.avatar ? ( + {d.recipientName} + ) : ( +
🛹
+ )} +
+
+
{d.recipientName || 'Member Name'}
+
{shortPubkey(d.pubkey)}
+
+
+
Valid thru
+
{d.validThru || '—'}
+
+
+ + {/* Card number + issued */} +
+
+
Card No.
+
{d.cardNum}
+
+
+ + Nostr-verified +
+
+
+ ); +} + +function ShopFront({ d }: { d: CardData }) { + return ( +
+ {/* Header */} +
+
+
+ 🛍️ {d.issuerName || 'Skate Shop'} +
+
Official Rider
+
+
+ {d.role || 'Am Rider'} +
+
+ + {/* Rider info */} +
+
+ {d.avatar ? ( + {d.recipientName} + ) : ( +
🛹
+ )} +
+
+
{d.recipientName || 'Rider Name'}
+
{shortPubkey(d.pubkey)}
+ {d.style && ( +
+ {d.style} +
+ )} +
+
+
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; + })} +
+ +
+
+ )} +
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function RiderCardPage() { + useSeoMeta({ + title: 'Rider Card — SkateSpots', + description: 'Create your printable SkateSpots Rider License, Park Access Card, or Shop Rider Card.', + }); + + const { user } = useCurrentUser(); + const author = useAuthor(user?.pubkey ?? ''); + const metadata = author.data?.metadata; + const { data: stats } = useRiderStats(user?.pubkey); + const { mutate: publishEvent } = useNostrPublish(); + const { toast } = useToast(); + const printRef = useRef(null); + + const displayName = metadata?.name ?? genUserName(user?.pubkey ?? ''); + const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : ''; + + // ── Form state ────────────────────────────────────────────────────────────── + const [cardType, setCardType] = useState<'rider' | 'park' | 'shop'>('rider'); + const [theme, setTheme] = useState('thug'); + const [holographic, setHolographic] = useState(true); + + // Rider fields + const [style, setStyle] = useState('Street'); + const [skills, setSkills] = useState([]); + const [sponsors, setSponsors] = useState(''); + const [showSpots, setShowSpots] = useState(true); + const [showZaps, setShowZaps] = useState(true); + const [showEvents, setShowEvents] = useState(true); + + // Park/Shop fields + const [issuerName, setIssuerName] = useState(''); + const [recipientName, setRecipientName] = useState(''); + const [recipientPubkey, setRecipientPubkey] = useState(user?.pubkey ?? ''); + const [accessLevel, setAccessLevel] = useState('Full Access'); + const [role, setRole] = useState('Am Rider'); + const [validThru, setValidThru] = useState(''); + + const [copied, setCopied] = useState(false); + + // ── Computed card data ─────────────────────────────────────────────────────── + const cardData: CardData = { + type: cardType, + theme, + holographic, + name: cardType === 'rider' ? displayName : recipientName, + nip05: metadata?.nip05, + avatar: metadata?.picture, + pubkey: cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey, + style, + skills, + sponsors, + showSpots, showZaps, showEvents, + 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(), + cardNum: user?.pubkey ? cardNumber(user.pubkey) : 'SS-000000', + issuerName, + recipientName, + accessLevel, + validThru, + role, + }; + + // ── Actions ───────────────────────────────────────────────────────────────── + const handlePrint = useCallback(() => { + window.print(); + }, []); + + const handleCopyNpub = useCallback(() => { + navigator.clipboard.writeText(npub); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [npub]); + + const handlePublishCard = useCallback(() => { + if (!user) return; + const targetPubkey = cardType === 'rider' ? user.pubkey : recipientPubkey; + if (!targetPubkey) { + toast({ title: 'Enter a recipient 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'], + ['issuer', issuerName], + ['access-level', cardType === 'park' ? accessLevel : role], + ['theme', theme], + ]; + if (validThru) tags.push(['valid-thru', validThru]); + + publishEvent( + { kind, content: '', tags }, + { + onSuccess: () => toast({ title: '✅ Card published to Nostr!', 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]); + + const toggleSkill = (id: string) => + setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]); + + // ── Render ─────────────────────────────────────────────────────────────────── + if (!user) { + return ( + +
+
🛹
+

Rider Card

+

Log in to generate your personal Rider Card.

+ +
+
+ ); + } + + return ( + + {/* Print root — only this div is visible during print */} +
+ +
+ {/* Page header */} +
+

+ SKATE + CARDS +

+

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

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

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

+
+ + {/* ── Right: Controls ────────────────────────────────────────── */} +
+ + {/* Card type tabs */} +
+ +
+ {([ + { id: 'rider', label: 'Rider License', icon: User }, + { id: 'park', label: 'Park Access', icon: Building2 }, + { id: 'shop', label: 'Shop Rider', icon: ShoppingBag }, + ] as const).map(({ id, label, icon: Icon }) => ( + + ))} +
+
+ + {/* Theme picker */} +
+ +
+ {THEMES.map(t => ( + + ))} +
+
+ + {/* Holographic toggle */} +
+
+
✨ Holographic Shimmer
+
Animated foil effect on screen
+
+ +
+ + {/* Type-specific fields */} + {cardType === 'rider' && ( +
+ {/* Style */} +
+ + +
+ + {/* Sponsors */} +
+ + setSponsors(e.target.value)} + className="bg-black/50 border-gray-700 text-sm" + /> +
+ + {/* Stats toggles */} +
+ +
+ {[ + { label: `Spots (${stats?.spotsAdded ?? 0})`, state: showSpots, set: setShowSpots }, + { label: `Zaps (${stats?.zapsReceived ?? 0})`, state: showZaps, set: setShowZaps }, + { label: `Events (${stats?.eventsCreated ?? 0})`, state: showEvents, set: setShowEvents }, + ].map(({ label, state, set }) => ( + + ))} +
+
+ + {/* Skill badges */} +
+ +
+ {SKILL_OPTIONS.map(s => ( + + ))} +
+
+
+ )} + + {cardType === 'park' && ( +
+
+ + setIssuerName(e.target.value)} + 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" /> +
+
+ + +
+
+ + 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" /> +
+
+ )} + + {/* 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. +
+
+
+
+
+
+ ); +}