feat(card): customization depth — custom accent, logo, back side, pass expiry

- Custom accent colour (native picker) overrides the theme accent via
  --ss-accent in CSS + the canvas palette.
- Optional personal logo/crest (Blossom upload) shown next to the name.
- Printable back side (RiderCardBack React + drawCardBack canvas); Print renders
  both sides, and the preview + public card can flip.
- Park passes enforce valid-thru: passExpiry() drives an EXPIRED badge on the
  card and verify page; the input is now a date picker.
- Card schema -> v:3 (accent/logo/back). i18n; NIP.md + AGENTS.
This commit is contained in:
SkateSpots 2026-10-02 11:47:19 +07:00
parent 9891101452
commit ce0e004a59
10 changed files with 353 additions and 29 deletions

View file

@ -104,6 +104,13 @@ session with **no other memory of this project**. So:
unverifiable) — the card shows **witnessed badges** (kind 1293) only, and
**style/level are DERIVED** from the Skater profile (kind 35000). The canvas
export + `CardData` dropped the self-claimed skills row and gained `level`.
**Customization depth (2026-10-02):** card config gained `accent` (custom hex
overriding the theme via `--ss-accent` + canvas palette), `logo` (personal
crest, uploaded), and `back` (printable reverse side — React `RiderCardBack`
+ canvas `drawCardBack`; Print renders both sides). Park passes **enforce
`valid-thru`** (`passExpiry()` → EXPIRED badge on card + verify; the input is a
date picker). Card schema → `v:3`. Public `/#/card/:npub` also flips + shows
the custom accent/logo.
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page

9
NIP.md
View file

@ -1541,11 +1541,14 @@ author's kind 0; on-chain stats are derived live:
["d", "rider-card"],
["t", "rider-card"],
["theme", "<thug|lime|chrome|wood|midnight|sunset>"],
["v", "2"],
["v", "3"],
["style", "<Street|Park|Vert|Bowl|DIY|All-terrain>"],
["skill", "<skill-id>"],
["sponsor", "<text>"],
["rides-for", "<text>"],
["accent", "<#rrggbb>"],
["logo", "<image-url>"],
["back", "1"],
["show", "spots"],
["show", "zaps"],
["show", "events"],
@ -1557,6 +1560,10 @@ author's kind 0; on-chain stats are derived live:
}
```
- `accent` overrides the theme's primary accent (CSS `--ss-accent`; the canvas
palette too). `logo` is an optional personal crest (Blossom URL). `back:1`
enables a printable back side (`back:0` disables). `v:3` added these.
- `v:2` marks the `show` flags as explicit, so hiding a facet persists. An event
with **no** `v`/`show` tags defaults every facet to visible; `v:1` cards (which
never had facet flags) also default the three facets ON rather than hiding them.

View file

@ -4,7 +4,7 @@
* (the canvas export in `RiderCardPage` remains the print renderer).
*/
import { useEffect, useState } from 'react';
import { SKILL_OPTIONS, shortNpub, roleMeta, formatCardDate, type ThemeId } from '@/lib/riderCard';
import { SKILL_OPTIONS, shortNpub, roleMeta, formatCardDate, passExpiry, type ThemeId } from '@/lib/riderCard';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
@ -42,6 +42,14 @@ export interface CardData {
receivedBadges: BadgeAward[];
/** When set, the printed QR opens the verify page for this card. */
verifyUrl?: string;
/** Custom accent hex overriding the theme's primary accent. */
accent?: string;
/** Optional second accent (stripe gradient end). */
accent2?: string;
/** Optional personal logo / crest URL. */
logo?: string;
/** Which side to render (default front). */
side?: 'front' | 'back';
// Identity facets (derived live, toggled on the card)
crews?: { name: string; image?: string }[];
local?: string;
@ -194,7 +202,12 @@ function RiderFront({ d }: { d: CardData }) {
{/* Identity block */}
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="text-[14px] font-black uppercase truncate leading-tight">{d.name}</div>
<div className="flex items-center gap-1.5 min-w-0">
{d.logo && (
<img src={d.logo} alt="" crossOrigin="anonymous" className="h-[13px] w-[13px] flex-shrink-0 rounded-[3px] object-cover border border-white/25" />
)}
<span className="text-[14px] font-black uppercase truncate leading-tight">{d.name}</span>
</div>
{d.nip05 && (
<div className="text-[6.5px] card-accent truncate leading-none">@{d.nip05.split('@')[0]}</div>
)}
@ -279,7 +292,9 @@ function ParkFront({ d }: { d: CardData }) {
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewAccessPass')}</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>
{passExpiry(d.validThru) === 'expired'
? <span className="text-[7px] font-black uppercase" style={{ color: '#ff4d4d' }}>{t('cardUi.expired')}</span>
: <span className="text-[7px] font-black uppercase card-accent">{d.accessLevel || 'Full Access'}</span>}
</div>
</div>
@ -498,21 +513,81 @@ function EventFront({ d }: { d: CardData }) {
);
}
// ─── Card back side ───────────────────────────────────────────────────────────
function RiderCardBack({ d }: { d: CardData }) {
const { t } = useTranslation();
const crewNames = (d.crews ?? []).map(c => c.name);
const roleLabels = (d.roles ?? []).map(r => {
const m = roleMeta(r);
return m ? `${m.emoji} ${t(`cardUi.roles.${m.role}`)}` : null;
}).filter(Boolean) as string[];
return (
<div className="absolute inset-0" style={{ padding: '10px 10px 16px 10px' }}>
<div className="flex items-center justify-between" style={{ height: '24px' }}>
<span className="text-[8px] font-black uppercase tracking-[0.18em] card-accent">🛹 SkateSpots</span>
<span className="text-[6px] card-muted uppercase tracking-widest">{t('cardUi.back.title')}</span>
</div>
<div className="mt-[4px] flex gap-[8px]" style={{ height: '120px' }}>
<div className="flex-1 min-w-0 flex flex-col gap-[3px] text-[6.5px] leading-tight">
{crewNames.length > 0 && (
<div className="truncate"><span className="card-muted uppercase">{t('cardUi.facets.crews')}: </span><span className="font-bold">{crewNames.join(' · ')}</span></div>
)}
{roleLabels.length > 0 && (
<div className="truncate"><span className="card-muted uppercase">{t('cardUi.facets.roles')}: </span><span className="font-bold">{roleLabels.join(' · ')}</span></div>
)}
{d.local && <div className="truncate">📍 <span className="font-bold">{d.local}</span></div>}
{d.ridesFor && <div className="truncate">🏪 <span className="font-bold">{d.ridesFor}</span></div>}
{d.sponsors && <div className="truncate">🤝 <span className="font-bold">{d.sponsors}</span></div>}
{passExpiry(d.validThru) !== 'none' && (
<div className="truncate"><span className="card-muted uppercase">{t('cardUi.verify.validThru')}: </span><span className="font-bold">{d.validThru}</span></div>
)}
{crewNames.length === 0 && roleLabels.length === 0 && !d.local && !d.ridesFor && !d.sponsors && (
<div className="card-muted">{t('cardUi.back.empty')}</div>
)}
</div>
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<CardQr url={d.verifyUrl} />
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
</div>
</div>
</div>
<div className="absolute left-0 right-0 flex items-center justify-between px-[10px]" style={{ bottom: '6px' }}>
<span className="card-muted text-[5px] uppercase tracking-widest">skate-spots.org · Powered by Nostr</span>
<span className="card-muted text-[5px] font-mono">{shortNpub(d.pubkey)}</span>
</div>
</div>
);
}
export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject<HTMLDivElement> }) {
const themeClass = `card-theme-${d.theme}`;
const holoClass = d.holographic ? 'rider-card-holo' : '';
const customClass = d.accent ? 'card-custom-accent' : '';
const side = d.side ?? 'front';
const vars = d.accent
? ({ '--ss-accent': d.accent, '--ss-accent2': d.accent2 || d.accent } as React.CSSProperties)
: undefined;
return (
<div
ref={printRef}
className={cn('rider-card', themeClass, holoClass, 'select-none')}
style={{ fontFamily: "'Inter Variable', 'Inter', system-ui, sans-serif" }}
className={cn('rider-card', themeClass, holoClass, customClass, side === 'back' && 'rider-card-back', 'select-none')}
style={{ fontFamily: "'Inter Variable', 'Inter', system-ui, sans-serif", ...vars }}
>
<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 === 'crew' && <CrewFront d={d} />}
{d.type === 'event' && <EventFront d={d} />}
{side === 'front' && <div className="card-stripe absolute top-0 left-0 right-0 h-[4px]" />}
{side === 'back'
? <RiderCardBack d={d} />
: <>
{d.type === 'rider' && <RiderFront d={d} />}
{d.type === 'park' && <ParkFront d={d} />}
{d.type === 'crew' && <CrewFront d={d} />}
{d.type === 'event' && <EventFront d={d} />}
</>}
</div>
);
}

View file

@ -6325,6 +6325,9 @@ walletUi: {
savedDesc: 'Your card is now signed and verifiable.',
saveFailed: 'Could not save the card',
notPublished: 'Not published',
expired: 'EXPIRED',
logoUploaded: 'Logo uploaded',
logoUploadFailed: 'Could not upload the logo',
venue: 'Linked venue (optional)',
venueNone: 'No venue',
venueHint: 'Pick one of your own spots to bind this pass to a venue. A verifier will confirm you own it.',
@ -6382,6 +6385,20 @@ walletUi: {
derived: 'From your Skater profile',
noSkaterProfile: 'No Skater profile yet —',
editSkaterProfile: 'add your styles & level',
accent: 'Accent colour',
accentHint: 'Overrides the theme accent',
accentClear: 'Use theme colour',
logo: 'Custom logo / crest',
logoAdd: 'Upload',
logoChange: 'Change',
logoClear: 'Remove',
},
back: {
title: 'Printable back side',
desc: 'Crews, roles, sponsors & links on the reverse',
showBack: 'Show back',
showFront: 'Show front',
empty: 'Nothing here yet — add crews, roles or sponsors.',
},
verify: {
checking: 'Checking the card…',
@ -6396,6 +6413,8 @@ walletUi: {
kindRider: 'Rider Card',
kindCrew: 'Crew Card',
kindEvent: 'Event pass',
expired: 'Expired',
valid: 'Valid',
issuedBy: 'Issued by',
accessLevel: 'Access level',
validThru: 'Valid thru',

View file

@ -6325,6 +6325,9 @@ walletUi: {
savedDesc: 'Tu carné está firmado y se puede verificar.',
saveFailed: 'No se pudo guardar el carné',
notPublished: 'Sin publicar',
expired: 'CADUCADO',
logoUploaded: 'Logo subido',
logoUploadFailed: 'No se pudo subir el logo',
venue: 'Sede vinculada (opcional)',
venueNone: 'Sin sede',
venueHint: 'Elige uno de tus propios spots para vincular este pase a una sede. Un verificador confirmará que es tuya.',
@ -6382,6 +6385,20 @@ walletUi: {
derived: 'Desde tu perfil de skater',
noSkaterProfile: 'Aún no tienes perfil de skater —',
editSkaterProfile: 'añade tus estilos y nivel',
accent: 'Color de acento',
accentHint: 'Sustituye el acento del tema',
accentClear: 'Usar color del tema',
logo: 'Logo / escudo propio',
logoAdd: 'Subir',
logoChange: 'Cambiar',
logoClear: 'Quitar',
},
back: {
title: 'Reverso imprimible',
desc: 'Cuadrillas, roles, patrocinadores y enlaces en el reverso',
showBack: 'Ver reverso',
showFront: 'Ver anverso',
empty: 'Aquí no hay nada aún — añade cuadrillas, roles o patrocinadores.',
},
verify: {
checking: 'Comprobando el carné…',
@ -6396,6 +6413,8 @@ walletUi: {
kindRider: 'Carné de rider',
kindCrew: 'Carné de cuadrilla',
kindEvent: 'Pase de evento',
expired: 'Caducado',
valid: 'Válido',
issuedBy: 'Emitido por',
accessLevel: 'Nivel de acceso',
validThru: 'Válido hasta',

View file

@ -838,6 +838,19 @@ select {
.card-theme-sunset .card-muted { color: rgba(255,208,160,0.65) !important; }
.card-theme-sunset .card-bg-chip { background: rgba(255,80,0,0.2) !important; border: 1px solid rgba(255,80,0,0.5) !important; }
/* ── Custom accent: overrides the theme's primary accent (applied on top of
any theme via --ss-accent / --ss-accent2 set inline on the card). Placed
last so it wins at equal specificity. ── */
.card-custom-accent .card-accent { color: var(--ss-accent) !important; }
.card-custom-accent .card-stripe { background: linear-gradient(90deg, var(--ss-accent), var(--ss-accent2, var(--ss-accent))) !important; }
.card-custom-accent .card-bg-chip { border-color: var(--ss-accent) !important; }
/* ── Card back side ── */
.rider-card-back {
background: #0b0b0b !important;
color: #e8e8e8 !important;
}
/* ── Print: the card is rendered to PNG via html2canvas before printing.
The @media print block below is only a safety fallback for direct Ctrl+P.
It will never look as good as the canvas approach. ── */

View file

@ -33,6 +33,12 @@ export interface RiderCardConfig {
showCrews: boolean;
showLocal: boolean;
showRoles: boolean;
/** Custom accent hex that overrides the theme's primary accent ('' = theme). */
accent: string;
/** Optional personal logo / crest URL. */
logo: string;
/** Whether the printable card has a back side. */
back: boolean;
}
export const DEFAULT_RIDER_CARD: RiderCardConfig = {
@ -47,6 +53,9 @@ export const DEFAULT_RIDER_CARD: RiderCardConfig = {
showCrews: true,
showLocal: true,
showRoles: true,
accent: '',
logo: '',
back: true,
};
/** Read a saved Rider Card event back into editable state. */
@ -69,6 +78,10 @@ export function parseRiderCard(event: NostrEvent): RiderCardConfig {
showCrews: v2 ? show.has('crews') : true,
showLocal: v2 ? show.has('local') : true,
showRoles: v2 ? show.has('roles') : true,
accent: tag('accent'),
logo: tag('logo'),
// Back side defaults ON; an explicit ['back','0'] turns it off.
back: !(event.tags.some(([n, v]) => n === 'back' && v === '0')),
};
}
@ -79,12 +92,15 @@ export function riderCardTags(c: RiderCardConfig): string[][] {
['t', 'rider-card'],
['alt', 'SkateSpots Rider Card'],
['theme', c.theme],
['v', '2'],
['v', '3'],
];
if (c.style.trim()) tags.push(['style', c.style.trim()]);
for (const s of c.skills) tags.push(['skill', s]);
if (c.sponsors.trim()) tags.push(['sponsor', c.sponsors.trim()]);
if (c.ridesFor.trim()) tags.push(['rides-for', c.ridesFor.trim()]);
if (c.accent.trim()) tags.push(['accent', c.accent.trim()]);
if (c.logo.trim()) tags.push(['logo', c.logo.trim()]);
tags.push(['back', c.back ? '1' : '0']);
if (c.showSpots) tags.push(['show', 'spots']);
if (c.showZaps) tags.push(['show', 'zaps']);
if (c.showEvents) tags.push(['show', 'events']);
@ -94,6 +110,17 @@ export function riderCardTags(c: RiderCardConfig): string[][] {
return tags;
}
/**
* Validity of a pass's `valid-thru` (free text; we accept `YYYY-MM-DD` or any
* Date-parseable string). Unknown/empty → 'none' (never pretend it's valid).
*/
export function passExpiry(validThru?: string): 'none' | 'valid' | 'expired' {
if (!validThru || !validThru.trim()) return 'none';
const t = Date.parse(validThru.trim());
if (Number.isNaN(t)) return 'none';
return t < Date.now() ? 'expired' : 'valid';
}
/** naddr for an addressable card event. */
export function cardNaddr(kind: number, pubkey: string, d: string): string {
return nip19.naddrEncode({ kind, pubkey, identifier: d });

View file

@ -4,10 +4,11 @@
* A read-only view of a skater's Rider Card, shareable and scannable. The QR on
* the card opens /verify so anyone can confirm the card is genuine.
*/
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft, Sparkles } from 'lucide-react';
import { ArrowLeft, Sparkles, RefreshCw } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useRiderStats } from '@/hooks/useRiderStats';
@ -47,6 +48,8 @@ export default function PublicCardPage() {
const { memberships } = useUserCrews(pubkey ?? undefined);
const { data: skaterProfile } = useUserSkaterProfile(pubkey ?? undefined);
const [side, setSide] = useState<'front' | 'back'>('front');
useSeoMeta({
title: `${displayName || 'Rider'} card — SkateSpots`,
description: `A SkateSpots Rider Card${displayName ? ` for ${displayName}` : ''}.`,
@ -92,6 +95,10 @@ export default function PublicCardPage() {
memberYear: metadata?.created_at ? memberSince(metadata.created_at as number) : new Date().getFullYear().toString(),
receivedBadges,
verifyUrl: cardVerifyUrl(RIDER_CARD_KIND, pubkey, RIDER_CARD_D),
accent: config.accent || undefined,
accent2: config.accent || undefined,
logo: config.logo || undefined,
side,
crews: memberships.map(m => ({ name: m.crew.name, image: m.crew.image })),
local: skaterProfile?.location || skaterProfile?.homeSpot || undefined,
roles,
@ -118,6 +125,19 @@ export default function PublicCardPage() {
<RiderCardFront d={cardData} />
</div>
{config.back && (
<div className="mt-3 flex justify-center">
<button
type="button"
onClick={() => setSide(s => (s === 'front' ? 'back' : 'front'))}
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-300 hover:bg-white/10"
>
<RefreshCw className="h-3 w-3" />
{side === 'front' ? t('cardUi.back.showBack') : t('cardUi.back.showFront')}
</button>
</div>
)}
{!savedCard?.config && (
<p className="text-center text-xs text-gray-600 mt-4">{t('cardUi.public.notCustomised')}</p>
)}

View file

@ -41,6 +41,7 @@ import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useSkateEvents } from '@/hooks/useSkateEvents';
import { useMyRsvpCoords, useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useNostr } from '@/hooks/useNostr';
import { useQuery } from '@tanstack/react-query';
@ -51,7 +52,7 @@ import { RIDER_CARD_KIND, RIDER_CARD_D, PARK_ACCESS_CARD_KIND, SKILL_BADGE_KIND,
import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
import { Link } from 'react-router-dom';
import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users, Ticket, MoreHorizontal, Pencil, CheckCircle2 } from 'lucide-react';
import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users, Ticket, MoreHorizontal, Pencil, CheckCircle2, RefreshCw, Upload } from 'lucide-react';
// ─── Card canvas renderer ─────────────────────────────────────────────────────
// Draws the card entirely via Canvas 2D API — 100% reliable export,
// no CSS capture, no scaling issues, no background-stripping.
@ -169,13 +170,58 @@ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe
ctx.closePath();
}
async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
/** The card back — printed in reverse (no QR; the front carries it). */
function drawCardBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]): string {
ctx.fillStyle = '#0b0b0b';
roundRect(ctx, 0, 0, CARD_W, CARD_H, 40);
ctx.fill();
ctx.fillStyle = p.accent;
ctx.fillRect(0, 0, CARD_W, 10);
ctx.fillStyle = p.accent;
ctx.font = 'bold 22px system-ui, sans-serif';
ctx.fillText('🛹 SKATESPOTS', 40, 70);
ctx.fillStyle = p.muted;
ctx.font = '15px system-ui, sans-serif';
ctx.fillText('CARD BACK', 40, 96);
const roles = (data.roles ?? []).map(r => roleMeta(r)).filter(Boolean).map(m => `${m!.emoji} ${m!.label}`).join(' · ');
const lines: Array<[string, string]> = [];
if (data.crews?.length) lines.push(['CREWS', data.crews.join(' · ')]);
if (roles) lines.push(['LAB ROLES', roles]);
if (data.local) lines.push(['LOCAL', data.local]);
if (data.ridesFor) lines.push(['RIDES FOR', data.ridesFor]);
if (data.sponsors) lines.push(['SPONSORS', data.sponsors]);
if (data.validThru) lines.push(['VALID THRU', data.validThru]);
let y = 168;
ctx.font = '15px system-ui, sans-serif';
for (const [label, value] of lines.slice(0, 6)) {
ctx.fillStyle = p.muted;
ctx.fillText(label, 40, y);
ctx.fillStyle = p.text;
ctx.font = 'bold 20px system-ui, sans-serif';
fillTextFit(ctx, value, 190, y, CARD_W - 230);
ctx.font = '15px system-ui, sans-serif';
y += 44;
}
ctx.fillStyle = p.muted;
ctx.font = '14px system-ui, sans-serif';
ctx.fillText('SKATE-SPOTS.ORG · POWERED BY NOSTR', 40, CARD_H - 40);
return ctx.canvas.toDataURL('image/png');
}
async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'back' = 'front'): Promise<string> {
const canvas = document.createElement('canvas');
canvas.width = CARD_W;
canvas.height = CARD_H;
const ctx = canvas.getContext('2d')!;
const p = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug;
if (side === 'back') return drawCardBack(ctx, data, p);
// ─── 1. Background ───────────────────────────────────────────────────────
const bgGrad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
bgGrad.addColorStop(0, p.bg1);
@ -848,6 +894,7 @@ export default function RiderCardPage() {
? (SKATER_LEVELS[skaterProfile.level]?.label ?? '')
: (skaterProfile?.customLevel ?? '');
const { mutate: publishEvent } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: uploadingLogo } = useUploadFile();
const { toast } = useToast();
const printRef = useRef<HTMLDivElement>(null);
@ -885,6 +932,12 @@ export default function RiderCardPage() {
const [showLocal, setShowLocal] = useState(true);
const [showRoles, setShowRoles] = useState(true);
// Customization depth
const [accent, setAccent] = useState('');
const [logo, setLogo] = useState('');
const [back, setBack] = useState(true);
const [cardSide, setCardSide] = useState<'front' | 'back'>('front');
// Park fields
const [issuerName, setIssuerName] = useState('');
const [recipientName, setRecipientName] = useState('');
@ -933,6 +986,9 @@ export default function RiderCardPage() {
setShowCrews(c.showCrews);
setShowLocal(c.showLocal);
setShowRoles(c.showRoles);
setAccent(c.accent);
setLogo(c.logo);
setBack(c.back);
}, [savedCard]);
// Unsaved-changes tracking for the personal card (manual save — an
@ -943,9 +999,10 @@ export default function RiderCardPage() {
return (
c.theme !== theme || c.sponsors !== sponsors || c.ridesFor !== ridesFor ||
c.showSpots !== showSpots || c.showZaps !== showZaps || c.showEvents !== showEvents ||
c.showCrews !== showCrews || c.showLocal !== showLocal || c.showRoles !== showRoles
c.showCrews !== showCrews || c.showLocal !== showLocal || c.showRoles !== showRoles ||
c.accent !== accent || c.logo !== logo || c.back !== back
);
}, [savedCard, theme, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles]);
}, [savedCard, theme, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, accent, logo, back]);
const recipientPubkey = (() => {
if (!recipientNpub.trim()) return user?.pubkey ?? '';
@ -973,6 +1030,10 @@ export default function RiderCardPage() {
style: derivedStyle,
level: derivedLevel || undefined,
skills: [],
accent: accent || undefined,
accent2: accent || undefined,
logo: logo || undefined,
side: cardSide,
sponsors, ridesFor,
showSpots, showZaps, showEvents,
crews: crewCrests,
@ -1024,10 +1085,11 @@ export default function RiderCardPage() {
cardPubkey, derivedStyle, derivedLevel, sponsors, ridesFor, showSpots, showZaps,
showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey,
crewCrests, local, roles, showCrews, showLocal, showRoles, activeCrew, activeEvent, eventRsvp.goingCount,
accent, logo, cardSide,
],
);
const renderCardToImage = useCallback(async (): Promise<string | null> => {
const renderCardToImage = useCallback(async (side: 'front' | 'back' = 'front'): Promise<string | null> => {
setRendering(true);
try {
return await drawCardToCanvas({
@ -1047,14 +1109,14 @@ export default function RiderCardPage() {
event: cardData.event,
issuerName: cardData.issuerName, recipientName: cardData.recipientName,
accessLevel: cardData.accessLevel, validThru: cardData.validThru,
});
}, side);
} catch (err) { console.error('Card render failed:', err); return null; }
finally { setRendering(false); }
}, [cardData]);
const handleSaveCard = useCallback(() => {
if (!user) return;
const config: RiderCardConfig = { theme, style: '', skills: [], sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles };
const config: RiderCardConfig = { theme, style: '', skills: [], sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, accent, logo, back };
publishEvent(
{ kind: RIDER_CARD_KIND, content: '', tags: riderCardTags(config) },
{
@ -1062,18 +1124,20 @@ export default function RiderCardPage() {
onError: (e) => toast({ title: t('cardUi.saveFailed'), description: String(e), variant: 'destructive' }),
},
);
}, [user, theme, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, publishEvent, toast, t]);
}, [user, theme, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, accent, logo, back, publishEvent, toast, t]);
const handlePrint = useCallback(async () => {
const dataUrl = await renderCardToImage();
if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
const front = await renderCardToImage('front');
if (!front) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
const backImg = back ? await renderCardToImage('back') : null;
const w = Math.round(85.6 * 2.835);
const h = Math.round(54 * 2.835);
const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`);
if (!win) { toast({ title: t('labUi.rider.popupBlocked'), description: t('labUi.rider.popupBlockedDesc'), variant: 'destructive' }); return; }
win.document.write(`<!DOCTYPE html><html><head><title>SkateSpots Rider Card</title><style>@page{size:85.6mm 54mm;margin:0}html,body{margin:0;padding:0;background:#000}img{display:block;width:85.6mm;height:54mm}</style></head><body><img src="${dataUrl}"/><scr${''}ipt>window.onload=function(){window.focus();window.print()}</scr${''}ipt></body></html>`);
const imgs = `<img src="${front}"/>${backImg ? `<img src="${backImg}"/>` : ''}`;
win.document.write(`<!DOCTYPE html><html><head><title>SkateSpots Card</title><style>@page{size:85.6mm 54mm;margin:0}html,body{margin:0;padding:0;background:#000}img{display:block;width:85.6mm;height:54mm;break-after:page}img:last-child{break-after:auto}</style></head><body>${imgs}<scr${''}ipt>window.onload=function(){window.focus();window.print()}</scr${''}ipt></body></html>`);
win.document.close();
}, [renderCardToImage, toast, t]);
}, [renderCardToImage, back, toast, t]);
const handleDownload = useCallback(async () => {
const dataUrl = await renderCardToImage();
@ -1084,6 +1148,17 @@ export default function RiderCardPage() {
a.click();
}, [renderCardToImage, cardData.npub, toast, t]);
const handleLogoUpload = useCallback(async (file: File) => {
try {
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) setLogo(url);
toast({ title: t('cardUi.logoUploaded') });
} catch (e) {
toast({ title: t('cardUi.logoUploadFailed'), description: String(e), variant: 'destructive' });
}
}, [uploadFile, toast, t]);
const handleCopyNpub = useCallback(() => {
navigator.clipboard.writeText(npub);
setCopied(true);
@ -1198,6 +1273,20 @@ export default function RiderCardPage() {
<RiderCardFront d={cardData} printRef={printRef} />
</div>
{/* Flip front/back (personal card) */}
{cardType === 'rider' && (
<div className="flex justify-center">
<button
type="button"
onClick={() => setCardSide(s => s === 'front' ? 'back' : 'front')}
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-300 hover:bg-white/10"
>
<RefreshCw className="h-3 w-3" />
{cardSide === 'front' ? t('cardUi.back.showBack') : t('cardUi.back.showFront')}
</button>
</div>
)}
{/* Saved state */}
{cardType === 'rider' && (
<div className="flex items-center justify-center text-[10px] font-black uppercase tracking-widest">
@ -1317,6 +1406,49 @@ export default function RiderCardPage() {
<Switch checked={holographic} onCheckedChange={setHolographic} />
</div>
{/* Design: custom accent + logo (rider only) */}
{cardType === 'rider' && (
<>
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.studio.accent')}</p>
<div className="flex items-center gap-3">
<input
type="color"
value={accent || '#ff0040'}
onChange={(e) => setAccent(e.target.value)}
className="h-10 w-14 cursor-pointer rounded-lg border border-white/10 bg-transparent"
aria-label={t('cardUi.studio.accent')}
/>
{accent
? <button type="button" onClick={() => setAccent('')} className="text-xs text-gray-400 underline">{t('cardUi.studio.accentClear')}</button>
: <span className="text-[11px] text-gray-600">{t('cardUi.studio.accentHint')}</span>}
</div>
</div>
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.studio.logo')}</p>
<div className="flex items-center gap-3">
{logo && <img src={logo} alt="" className="h-11 w-11 rounded-lg border border-white/10 object-cover" />}
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs font-bold text-gray-300 hover:bg-white/5">
{uploadingLogo ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
{logo ? t('cardUi.studio.logoChange') : t('cardUi.studio.logoAdd')}
<input type="file" accept="image/*" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleLogoUpload(f); }} />
</label>
{logo && <button type="button" onClick={() => setLogo('')} className="text-xs text-gray-400 underline">{t('cardUi.studio.logoClear')}</button>}
</div>
</div>
</>
)}
{/* Printable back side */}
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-white/3 px-4 py-3">
<div>
<div className="text-sm font-bold">{t('cardUi.back.title')}</div>
<div className="text-xs text-gray-500">{t('cardUi.back.desc')}</div>
</div>
<Switch checked={back} onCheckedChange={setBack} />
</div>
{/* Show on card — ONE clear list */}
{cardType === 'rider' && (
<>
@ -1440,7 +1572,7 @@ export default function RiderCardPage() {
</div>
<div>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.validThru')}</Label>
<Input type="text" value={validThru} onChange={e => setValidThru(e.target.value)}
<Input type="date" value={validThru} onChange={e => setValidThru(e.target.value)}
placeholder="2026-12-31" className="h-11 border-white/10 bg-black/50" />
</div>
</div>

View file

@ -17,7 +17,7 @@ import { useNostr } from '@/hooks/useNostr';
import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { RIDER_CARD_KIND, PARK_ACCESS_CARD_KIND } from '@/lib/riderCard';
import { RIDER_CARD_KIND, PARK_ACCESS_CARD_KIND, passExpiry } from '@/lib/riderCard';
import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
@ -95,6 +95,8 @@ export default function VerifyCardPage() {
const valid = data && data.status === 'valid' ? data : undefined;
const event = valid?.event;
const venue = valid?.venue;
const validThru = event?.tags.find(([n]) => n === 'valid-thru')?.[1];
const expiry = passExpiry(validThru);
return (
<div className="max-w-lg mx-auto px-4 py-10">
@ -132,8 +134,11 @@ export default function VerifyCardPage() {
{event.kind === PARK_ACCESS_CARD_KIND && (
<>
<Row label={t('cardUi.verify.accessLevel')} value={event.tags.find(([n]) => n === 'access-level')?.[1] ?? '—'} />
{event.tags.find(([n]) => n === 'valid-thru')?.[1] && (
<Row label={t('cardUi.verify.validThru')} value={event.tags.find(([n]) => n === 'valid-thru')?.[1] ?? ''} />
{validThru && (
<Row
label={t('cardUi.verify.validThru')}
value={expiry === 'expired' ? `${validThru} · ${t('cardUi.verify.expired')}` : validThru}
/>
)}
</>
)}