feat: SkateSpots Rider Cards — 3 card types, 6 themes, holographic shimmer, print-ready

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-16 14:29:54 +07:00
parent f9327c952d
commit 838ef3f355
7 changed files with 1148 additions and 4 deletions

78
NIP.md
View file

@ -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 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" 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 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", "<unique-id>"],
["p", "<rider-pubkey>"],
["t", "skatepark-access"],
["issuer", "<park name>"],
["access-level", "<Full Access|Supervised|Beginner Zone|Street Only|Bowl Only|Members Only>"],
["theme", "<thug|lime|chrome|wood|midnight|sunset>"],
["valid-thru", "<YYYY-MM-DD>"],
["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", "<unique-id>"],
["p", "<rider-pubkey>"],
["t", "shop-rider"],
["issuer", "<shop name>"],
["access-level", "<Am Rider|Team Rider|Flow|Ambassador|Staff|Filmer|Coach>"],
["theme", "<thug|lime|chrome|wood|midnight|sunset>"],
["valid-thru", "<YYYY or YYYY-MM-DD, optional>"],
["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/<naddr>
```

View file

@ -40,6 +40,7 @@ import StashPage from "./pages/StashPage";
import { NIP19Page } from "./pages/NIP19Page"; import { NIP19Page } from "./pages/NIP19Page";
import LicensePage from "./pages/LicensePage"; import LicensePage from "./pages/LicensePage";
import SupportersPage from "./pages/SupportersPage"; import SupportersPage from "./pages/SupportersPage";
import RiderCardPage from "./pages/RiderCardPage";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
export function AppRouter() { export function AppRouter() {
@ -89,6 +90,7 @@ export function AppRouter() {
{/* Stash - Private Inventory */} {/* Stash - Private Inventory */}
<Route path="/stash" element={<StashPage />} /> <Route path="/stash" element={<StashPage />} />
{/* Info pages */} {/* Info pages */}
<Route path="/card" element={<RiderCardPage />} />
<Route path="/license" element={<LicensePage />} /> <Route path="/license" element={<LicensePage />} />
<Route path="/supporters" element={<SupportersPage />} /> <Route path="/supporters" element={<SupportersPage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}

View file

@ -44,6 +44,7 @@ import {
Package, Package,
Zap, Zap,
Heart, Heart,
CreditCard,
} from 'lucide-react'; } from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator'; import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -426,11 +427,17 @@ export function Layout({ children }: LayoutProps) {
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/supporters" className="flex items-center gap-2 cursor-pointer"> <Link to="/card" className="flex items-center gap-2 cursor-pointer">
<Heart className="h-4 w-4 text-[#ff0040]" /> <CreditCard className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ff0040]">Supporters ⚡</span> <span className="text-[#ccff00]">Rider Card</span>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/supporters" className="flex items-center gap-2 cursor-pointer">
<Heart className="h-4 w-4 text-[#ff0040]" />
<span className="text-[#ff0040]">Supporters ⚡</span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</nav> </nav>
@ -647,6 +654,13 @@ export function Layout({ children }: LayoutProps) {
isActive={isActive('/about')} isActive={isActive('/about')}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
/> />
<NavLink
href="/card"
label="Rider Card"
icon={CreditCard}
isActive={isActive('/card')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink <NavLink
href="/supporters" href="/supporters"
label="Supporters ⚡" label="Supporters ⚡"

View file

@ -0,0 +1,64 @@
/**
* useRiderStats
*
* Fetches on-chain stats for a given pubkey to display on the Rider Card:
* - spotsAdded: how many skate spots (kind 30818) this user has published
* - eventsCreated: how many calendar events (kind 31923) they created
* - zapsReceived: count of zap receipts (kind 9735) targeting their pubkey
* - shopCards: kind 37097 shop rider cards issued TO this user
* - parkCards: kind 35679 park access grants issued TO this user
*
* All queries hit the SkateSpots community relay so results are skate-specific.
*/
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
export interface RiderStats {
spotsAdded: number;
eventsCreated: number;
zapsReceived: number;
shopCards: number;
parkCards: number;
}
export function useRiderStats(pubkey: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['rider-stats', pubkey],
enabled: !!pubkey,
staleTime: 60_000,
queryFn: async (c) => {
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;
},
});
}

View file

@ -383,3 +383,170 @@ html, body {
display: inline; 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;
}
}

View file

@ -16,7 +16,7 @@ import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useTranslation } from '@/contexts/LanguageContext'; import { useTranslation } from '@/contexts/LanguageContext';
import { genUserName } from '@/lib/genUserName'; 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 { 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'; import { cn } from '@/lib/utils';
const COACH_SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS); const COACH_SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
@ -130,6 +130,12 @@ export default function LabPage() {
Offer Editing Offer Editing
</Button> </Button>
</Link> </Link>
<Link to="/card">
<Button variant="outline" className="gap-2 uppercase font-black border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10">
<CreditCard className="h-4 w-4" />
Get Your Card
</Button>
</Link>
</div> </div>
) : ( ) : (
<Badge variant="outline" className="border-yellow-500/50 text-yellow-400 px-3 py-1.5"> <Badge variant="outline" className="border-yellow-500/50 text-yellow-400 px-3 py-1.5">

813
src/pages/RiderCardPage.tsx Normal file
View file

@ -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<HTMLDivElement> }) {
const themeClass = `card-theme-${d.theme}`;
const holoClass = d.holographic ? 'rider-card-holo' : '';
return (
<div
ref={printRef}
className={cn('rider-card', themeClass, holoClass, 'select-none')}
style={{ fontFamily: "'Inter Variable', 'Inter', system-ui, sans-serif" }}
>
{/* Top color stripe */}
<div className="card-stripe absolute top-0 left-0 right-0 h-[4px]" />
{d.type === 'rider' && <RiderFront d={d} />}
{d.type === 'park' && <ParkFront d={d} />}
{d.type === 'shop' && <ShopFront d={d} />}
{/* Bottom micro-text */}
<div className="absolute bottom-[6px] left-[10px] right-[10px] flex items-center justify-between">
<span className="card-muted text-[6px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<span className="card-muted text-[6px] uppercase tracking-widest">
Tap to verify
</span>
</div>
</div>
);
}
function RiderFront({ d }: { d: CardData }) {
return (
<div className="absolute inset-0 p-[10px] flex flex-col justify-between">
{/* Header row */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-1 mb-0.5">
<span className="text-[8px] font-black uppercase tracking-[0.2em] card-accent">
🛹 SkateSpots
</span>
</div>
<div className="text-[6px] card-muted uppercase tracking-widest">Rider License</div>
</div>
<div className="text-right">
<div className="text-[7px] card-muted uppercase">No.</div>
<div className="text-[9px] font-black card-accent tracking-wider">{d.cardNum}</div>
</div>
</div>
{/* Main content */}
<div className="flex items-center gap-[8px] flex-1 my-[6px]">
{/* Avatar */}
<div className="relative flex-shrink-0">
<div className="w-[46px] h-[46px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{d.avatar ? (
<img src={d.avatar} alt={d.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[14px]">
🛹
</div>
)}
</div>
</div>
{/* Identity */}
<div className="flex-1 min-w-0">
<div className="text-[13px] font-black uppercase truncate leading-tight">{d.name}</div>
{d.nip05 && (
<div className="text-[7px] card-accent truncate mt-0.5">@{d.nip05.split('@')[0]}</div>
)}
<div className="text-[6px] card-muted font-mono mt-0.5 truncate">{shortPubkey(d.pubkey)}</div>
{/* Style + year */}
<div className="flex items-center gap-1.5 mt-[4px]">
{d.style && (
<span className="card-bg-chip text-[6px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] card-accent">
{d.style}
</span>
)}
<span className="card-muted text-[6px]">Since {d.memberYear}</span>
</div>
</div>
{/* Stats column */}
<div className="flex-shrink-0 text-right space-y-[3px]">
{d.showSpots && (
<div className="flex items-center justify-end gap-1">
<span className="text-[6px] card-muted">Spots</span>
<span className="text-[8px] font-black card-accent">{d.spotsAdded}</span>
</div>
)}
{d.showZaps && (
<div className="flex items-center justify-end gap-1">
<span className="text-[6px] card-muted">⚡ Zaps</span>
<span className="text-[8px] font-black card-accent2">{d.zapsReceived}</span>
</div>
)}
{d.showEvents && (
<div className="flex items-center justify-end gap-1">
<span className="text-[6px] card-muted">Events</span>
<span className="text-[8px] font-black" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
</div>
)}
</div>
</div>
{/* Skills row */}
{d.skills.length > 0 && (
<div className="flex flex-wrap gap-[3px] mb-[3px]">
{d.skills.slice(0, 6).map(sk => {
const s = SKILL_OPTIONS.find(o => o.id === sk);
return s ? (
<span key={sk} className="card-bg-chip text-[5.5px] font-bold px-[3px] py-[1px] rounded-[2px]">
{s.icon} {s.label}
</span>
) : null;
})}
</div>
)}
{/* Sponsors */}
{d.sponsors && (
<div className="text-[6px] card-muted truncate">
🤝 {d.sponsors}
</div>
)}
</div>
);
}
function ParkFront({ d }: { d: CardData }) {
return (
<div className="absolute inset-0 p-[10px] flex flex-col justify-between">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent">
🏟️ {d.issuerName || 'Skatepark'}
</div>
<div className="text-[6px] card-muted uppercase tracking-widest">Access Pass</div>
</div>
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]">
<span className="text-[7px] font-black uppercase card-accent">{d.accessLevel || 'Full Access'}</span>
</div>
</div>
{/* Member info */}
<div className="flex items-center gap-[8px] flex-1 my-[6px]">
<div className="w-[46px] h-[46px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20 flex-shrink-0">
{d.avatar ? (
<img src={d.avatar} alt={d.recipientName} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[14px]">🛹</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-black uppercase truncate">{d.recipientName || 'Member Name'}</div>
<div className="text-[6px] card-muted font-mono mt-0.5 truncate">{shortPubkey(d.pubkey)}</div>
</div>
<div className="text-right flex-shrink-0">
<div className="text-[6px] card-muted uppercase">Valid thru</div>
<div className="text-[9px] font-black card-accent">{d.validThru || '—'}</div>
</div>
</div>
{/* Card number + issued */}
<div className="flex items-center justify-between">
<div>
<div className="text-[6px] card-muted uppercase">Card No.</div>
<div className="text-[8px] font-black card-accent tracking-wider">{d.cardNum}</div>
</div>
<div className="flex items-center gap-1">
<Shield className="h-[10px] w-[10px] card-accent opacity-60" />
<span className="text-[6px] card-muted">Nostr-verified</span>
</div>
</div>
</div>
);
}
function ShopFront({ d }: { d: CardData }) {
return (
<div className="absolute inset-0 p-[10px] flex flex-col justify-between">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent">
🛍️ {d.issuerName || 'Skate Shop'}
</div>
<div className="text-[6px] card-muted uppercase tracking-widest">Official Rider</div>
</div>
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]">
<span className="text-[7px] font-black uppercase card-accent">{d.role || 'Am Rider'}</span>
</div>
</div>
{/* Rider info */}
<div className="flex items-center gap-[8px] flex-1 my-[6px]">
<div className="w-[46px] h-[46px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20 flex-shrink-0">
{d.avatar ? (
<img src={d.avatar} alt={d.recipientName} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[14px]">🛹</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-black uppercase truncate">{d.recipientName || 'Rider Name'}</div>
<div className="text-[6px] card-muted font-mono mt-0.5 truncate">{shortPubkey(d.pubkey)}</div>
{d.style && (
<div className="card-bg-chip inline-block text-[6px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] card-accent mt-1">
{d.style}
</div>
)}
</div>
<div className="text-right flex-shrink-0">
<div className="text-[6px] card-muted uppercase">Since</div>
<div className="text-[9px] font-black card-accent">{d.memberYear}</div>
{d.validThru && (
<>
<div className="text-[6px] card-muted uppercase mt-1">Thru</div>
<div className="text-[8px] font-black card-accent">{d.validThru}</div>
</>
)}
</div>
</div>
{/* Skills */}
{d.skills.length > 0 && (
<div className="flex flex-wrap gap-[3px]">
{d.skills.slice(0, 5).map(sk => {
const s = SKILL_OPTIONS.find(o => o.id === sk);
return s ? (
<span key={sk} className="card-bg-chip text-[5.5px] font-bold px-[3px] py-[1px] rounded-[2px]">
{s.icon} {s.label}
</span>
) : null;
})}
<div className="ml-auto flex items-center gap-0.5">
<Shield className="h-[8px] w-[8px] opacity-50" />
</div>
</div>
)}
</div>
);
}
// ─── 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<HTMLDivElement>(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<ThemeId>('thug');
const [holographic, setHolographic] = useState(true);
// Rider fields
const [style, setStyle] = useState('Street');
const [skills, setSkills] = useState<string[]>([]);
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 (
<Layout>
<div className="container max-w-lg px-4 py-20 text-center">
<div className="text-6xl mb-4">🛹</div>
<h1 className="text-3xl font-black uppercase mb-3 neon-red">Rider Card</h1>
<p className="text-gray-400 mb-6">Log in to generate your personal Rider Card.</p>
<LoginArea className="justify-center" />
</div>
</Layout>
);
}
return (
<Layout>
{/* Print root — only this div is visible during print */}
<div className="print-card-root">
<div className="container max-w-5xl px-4 py-10">
{/* Page header */}
<div className="mb-8">
<h1 className="text-4xl font-black uppercase mb-2">
<span className="neon-red">SKATE</span>
<span className="text-white"> CARDS</span>
</h1>
<p className="text-gray-400 text-sm">
Print-ready credit-card size (85.6 × 54 mm). Cryptographically signed on Nostr.
</p>
</div>
<div className="grid lg:grid-cols-2 gap-8">
{/* ── Left: Card preview ─────────────────────────────────────── */}
<div className="flex flex-col items-center gap-6">
{/* Card preview */}
<div className="no-print">
<RiderCardFront d={cardData} printRef={printRef} />
</div>
{/* Print-only version — hidden on screen, visible when printing */}
<div className="hidden print:block">
<RiderCardFront d={cardData} />
</div>
{/* Action buttons */}
<div className="flex gap-3 no-print">
<Button
onClick={handlePrint}
className="bg-[#ff0040] hover:bg-[#cc0030] gap-2 font-black uppercase"
>
<Printer className="h-4 w-4" />
Print Card
</Button>
<Button
variant="outline"
onClick={handleCopyNpub}
className="gap-2 border-gray-700"
>
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
{copied ? 'Copied!' : 'Copy npub'}
</Button>
{(cardType === 'park' || cardType === 'shop') && (
<Button
variant="outline"
onClick={handlePublishCard}
className="gap-2 border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
>
<Zap className="h-4 w-4" />
Publish to Nostr
</Button>
)}
</div>
{/* Print tip */}
<p className="text-gray-600 text-xs text-center no-print max-w-xs">
📄 Click Print → set paper to <strong>Custom 85.6×54mm</strong> (or Business Card),
no margins. Cut with scissors. Laminate for extra style. 🛹
</p>
</div>
{/* ── Right: Controls ────────────────────────────────────────── */}
<div className="space-y-5 no-print">
{/* Card type tabs */}
<div>
<Label className="mb-2 block text-xs uppercase tracking-widest text-gray-500">Card Type</Label>
<div className="grid grid-cols-3 gap-2">
{([
{ 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 }) => (
<button
key={id}
onClick={() => setCardType(id)}
className={cn(
'p-3 rounded-xl border text-xs font-bold uppercase flex flex-col items-center gap-1 transition-all',
cardType === id
? 'bg-[#ff0040]/20 border-[#ff0040] text-white'
: 'bg-black/40 border-gray-700 text-gray-400 hover:border-gray-500'
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
</div>
{/* Theme picker */}
<div>
<Label className="mb-2 flex items-center gap-2 text-xs uppercase tracking-widest text-gray-500">
<Palette className="h-3 w-3" /> Theme
</Label>
<div className="grid grid-cols-3 gap-2">
{THEMES.map(t => (
<button
key={t.id}
onClick={() => setTheme(t.id)}
className={cn(
'p-2.5 rounded-lg border text-xs font-bold transition-all',
`card-theme-${t.id}`,
theme === t.id ? 'ring-2 ring-white/40 scale-105' : 'opacity-70 hover:opacity-100'
)}
>
<div className="text-base mb-0.5">{t.emoji}</div>
<div className="text-[10px] font-black">{t.label}</div>
<div className="text-[9px] opacity-60">{t.desc}</div>
</button>
))}
</div>
</div>
{/* Holographic toggle */}
<div className="flex items-center justify-between p-3 rounded-xl bg-black/40 border border-gray-700">
<div>
<div className="text-sm font-bold">✨ Holographic Shimmer</div>
<div className="text-xs text-gray-500">Animated foil effect on screen</div>
</div>
<Switch checked={holographic} onCheckedChange={setHolographic} />
</div>
{/* Type-specific fields */}
{cardType === 'rider' && (
<div className="space-y-4">
{/* Style */}
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Skate Style</Label>
<Select value={style} onValueChange={setStyle}>
<SelectTrigger className="bg-black/50 border-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STYLES.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* Sponsors */}
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Sponsors (optional)</Label>
<Input
placeholder="e.g. Baker, Vans, Independent..."
value={sponsors}
onChange={e => setSponsors(e.target.value)}
className="bg-black/50 border-gray-700 text-sm"
/>
</div>
{/* Stats toggles */}
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-2 block">Show Stats</Label>
<div className="grid grid-cols-3 gap-2">
{[
{ 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 }) => (
<button
key={label}
onClick={() => set(!state)}
className={cn(
'p-2 rounded-lg border text-[10px] font-bold transition-all',
state
? 'bg-[#ff0040]/20 border-[#ff0040] text-white'
: 'bg-black/40 border-gray-700 text-gray-500'
)}
>
{label}
</button>
))}
</div>
</div>
{/* Skill badges */}
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-2 block">
Skill Badges (pick up to 6)
</Label>
<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 py-1 rounded-full text-[11px] font-bold border transition-all',
skills.includes(s.id)
? 'bg-[#ff0040]/20 border-[#ff0040] text-white'
: 'bg-black/40 border-gray-700 text-gray-400 hover:border-gray-500 disabled:opacity-30'
)}
>
{s.icon} {s.label}
</button>
))}
</div>
</div>
</div>
)}
{cardType === 'park' && (
<div className="space-y-4">
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Park Name</Label>
<Input value={issuerName} onChange={e => setIssuerName(e.target.value)}
placeholder="e.g. The Berrics, FDR Skatepark..." className="bg-black/50 border-gray-700" />
</div>
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Member Name</Label>
<Input value={recipientName} onChange={e => setRecipientName(e.target.value)}
placeholder="Full name on card" className="bg-black/50 border-gray-700" />
</div>
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Member npub (optional)</Label>
<Input value={recipientPubkey} onChange={e => setRecipientPubkey(e.target.value)}
placeholder="npub1... or leave for your own key" className="bg-black/50 border-gray-700 font-mono text-xs" />
</div>
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Access Level</Label>
<Select value={accessLevel} onValueChange={setAccessLevel}>
<SelectTrigger className="bg-black/50 border-gray-700"><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-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Valid Thru</Label>
<Input type="text" value={validThru} onChange={e => setValidThru(e.target.value)}
placeholder="e.g. 2026-12-31" className="bg-black/50 border-gray-700" />
</div>
</div>
)}
{cardType === 'shop' && (
<div className="space-y-4">
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Shop Name</Label>
<Input value={issuerName} onChange={e => setIssuerName(e.target.value)}
placeholder="e.g. Element, Skate Warehouse..." className="bg-black/50 border-gray-700" />
</div>
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Rider Name</Label>
<Input value={recipientName} onChange={e => setRecipientName(e.target.value)}
placeholder="Rider's name" className="bg-black/50 border-gray-700" />
</div>
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Rider npub (optional)</Label>
<Input value={recipientPubkey} onChange={e => setRecipientPubkey(e.target.value)}
placeholder="npub1..." className="bg-black/50 border-gray-700 font-mono text-xs" />
</div>
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger className="bg-black/50 border-gray-700"><SelectValue /></SelectTrigger>
<SelectContent>
{['Am Rider','Team Rider','Flow','Ambassador','Staff','Filmer','Coach'].map(r =>
<SelectItem key={r} value={r}>{r}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Skate Style</Label>
<Select value={style} onValueChange={setStyle}>
<SelectTrigger className="bg-black/50 border-gray-700"><SelectValue /></SelectTrigger>
<SelectContent>
{STYLES.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-2 block">Skill Badges</Label>
<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 py-1 rounded-full text-[11px] font-bold border transition-all',
skills.includes(s.id)
? 'bg-[#ff0040]/20 border-[#ff0040] text-white'
: 'bg-black/40 border-gray-700 text-gray-400 hover:border-gray-500 disabled:opacity-30')}>
{s.icon} {s.label}
</button>
))}
</div>
</div>
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Valid Thru (optional)</Label>
<Input type="text" value={validThru} onChange={e => setValidThru(e.target.value)}
placeholder="e.g. 2026" className="bg-black/50 border-gray-700" />
</div>
</div>
)}
{/* Info box */}
<div className="p-3 rounded-xl bg-[#ccff00]/5 border border-[#ccff00]/20 text-xs text-gray-400">
<div className="font-bold text-[#ccff00] mb-1">🔐 Nostr-verified cards</div>
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.
</div>
</div>
</div>
</div>
</div>
</Layout>
);
}