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:
parent
f9327c952d
commit
838ef3f355
7 changed files with 1148 additions and 4 deletions
78
NIP.md
78
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", "<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>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<Route path="/stash" element={<StashPage />} />
|
||||
{/* Info pages */}
|
||||
<Route path="/card" element={<RiderCardPage />} />
|
||||
<Route path="/license" element={<LicensePage />} />
|
||||
<Route path="/supporters" element={<SupportersPage />} />
|
||||
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</Link>
|
||||
</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 to="/card" className="flex items-center gap-2 cursor-pointer">
|
||||
<CreditCard className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-[#ccff00]">Rider Card</span>
|
||||
</Link>
|
||||
</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>
|
||||
</DropdownMenu>
|
||||
</nav>
|
||||
|
|
@ -647,6 +654,13 @@ export function Layout({ children }: LayoutProps) {
|
|||
isActive={isActive('/about')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<NavLink
|
||||
href="/card"
|
||||
label="Rider Card"
|
||||
icon={CreditCard}
|
||||
isActive={isActive('/card')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<NavLink
|
||||
href="/supporters"
|
||||
label="Supporters ⚡"
|
||||
|
|
|
|||
64
src/hooks/useRiderStats.ts
Normal file
64
src/hooks/useRiderStats.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
167
src/index.css
167
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</Button>
|
||||
</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>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-yellow-500/50 text-yellow-400 px-3 py-1.5">
|
||||
|
|
|
|||
813
src/pages/RiderCardPage.tsx
Normal file
813
src/pages/RiderCardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue