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 unverifiable) — the card shows **witnessed badges** (kind 1293) only, and
**style/level are DERIVED** from the Skater profile (kind 35000). The canvas **style/level are DERIVED** from the Skater profile (kind 35000). The canvas
export + `CardData` dropped the self-claimed skills row and gained `level`. 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 - **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page 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"], ["d", "rider-card"],
["t", "rider-card"], ["t", "rider-card"],
["theme", "<thug|lime|chrome|wood|midnight|sunset>"], ["theme", "<thug|lime|chrome|wood|midnight|sunset>"],
["v", "2"], ["v", "3"],
["style", "<Street|Park|Vert|Bowl|DIY|All-terrain>"], ["style", "<Street|Park|Vert|Bowl|DIY|All-terrain>"],
["skill", "<skill-id>"], ["skill", "<skill-id>"],
["sponsor", "<text>"], ["sponsor", "<text>"],
["rides-for", "<text>"], ["rides-for", "<text>"],
["accent", "<#rrggbb>"],
["logo", "<image-url>"],
["back", "1"],
["show", "spots"], ["show", "spots"],
["show", "zaps"], ["show", "zaps"],
["show", "events"], ["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 - `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 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. 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). * (the canvas export in `RiderCardPage` remains the print renderer).
*/ */
import { useEffect, useState } from 'react'; 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 { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -42,6 +42,14 @@ export interface CardData {
receivedBadges: BadgeAward[]; receivedBadges: BadgeAward[];
/** When set, the printed QR opens the verify page for this card. */ /** When set, the printed QR opens the verify page for this card. */
verifyUrl?: string; 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) // Identity facets (derived live, toggled on the card)
crews?: { name: string; image?: string }[]; crews?: { name: string; image?: string }[];
local?: string; local?: string;
@ -194,7 +202,12 @@ function RiderFront({ d }: { d: CardData }) {
{/* Identity block */} {/* Identity block */}
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]"> <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 && ( {d.nip05 && (
<div className="text-[6.5px] card-accent truncate leading-none">@{d.nip05.split('@')[0]}</div> <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 className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewAccessPass')}</div>
</div> </div>
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]"> <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>
</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> }) { export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject<HTMLDivElement> }) {
const themeClass = `card-theme-${d.theme}`; const themeClass = `card-theme-${d.theme}`;
const holoClass = d.holographic ? 'rider-card-holo' : ''; 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 ( return (
<div <div
ref={printRef} ref={printRef}
className={cn('rider-card', themeClass, holoClass, 'select-none')} className={cn('rider-card', themeClass, holoClass, customClass, side === 'back' && 'rider-card-back', 'select-none')}
style={{ fontFamily: "'Inter Variable', 'Inter', system-ui, sans-serif" }} style={{ fontFamily: "'Inter Variable', 'Inter', system-ui, sans-serif", ...vars }}
> >
<div className="card-stripe absolute top-0 left-0 right-0 h-[4px]" /> {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 === 'rider' && <RiderFront d={d} />}
{d.type === 'park' && <ParkFront d={d} />} {d.type === 'park' && <ParkFront d={d} />}
{d.type === 'crew' && <CrewFront d={d} />} {d.type === 'crew' && <CrewFront d={d} />}
{d.type === 'event' && <EventFront d={d} />} {d.type === 'event' && <EventFront d={d} />}
</>}
</div> </div>
); );
} }

View file

@ -6325,6 +6325,9 @@ walletUi: {
savedDesc: 'Your card is now signed and verifiable.', savedDesc: 'Your card is now signed and verifiable.',
saveFailed: 'Could not save the card', saveFailed: 'Could not save the card',
notPublished: 'Not published', notPublished: 'Not published',
expired: 'EXPIRED',
logoUploaded: 'Logo uploaded',
logoUploadFailed: 'Could not upload the logo',
venue: 'Linked venue (optional)', venue: 'Linked venue (optional)',
venueNone: 'No venue', venueNone: 'No venue',
venueHint: 'Pick one of your own spots to bind this pass to a venue. A verifier will confirm you own it.', 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', derived: 'From your Skater profile',
noSkaterProfile: 'No Skater profile yet —', noSkaterProfile: 'No Skater profile yet —',
editSkaterProfile: 'add your styles & level', 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: { verify: {
checking: 'Checking the card…', checking: 'Checking the card…',
@ -6396,6 +6413,8 @@ walletUi: {
kindRider: 'Rider Card', kindRider: 'Rider Card',
kindCrew: 'Crew Card', kindCrew: 'Crew Card',
kindEvent: 'Event pass', kindEvent: 'Event pass',
expired: 'Expired',
valid: 'Valid',
issuedBy: 'Issued by', issuedBy: 'Issued by',
accessLevel: 'Access level', accessLevel: 'Access level',
validThru: 'Valid thru', validThru: 'Valid thru',

View file

@ -6325,6 +6325,9 @@ walletUi: {
savedDesc: 'Tu carné está firmado y se puede verificar.', savedDesc: 'Tu carné está firmado y se puede verificar.',
saveFailed: 'No se pudo guardar el carné', saveFailed: 'No se pudo guardar el carné',
notPublished: 'Sin publicar', notPublished: 'Sin publicar',
expired: 'CADUCADO',
logoUploaded: 'Logo subido',
logoUploadFailed: 'No se pudo subir el logo',
venue: 'Sede vinculada (opcional)', venue: 'Sede vinculada (opcional)',
venueNone: 'Sin sede', venueNone: 'Sin sede',
venueHint: 'Elige uno de tus propios spots para vincular este pase a una sede. Un verificador confirmará que es tuya.', 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', derived: 'Desde tu perfil de skater',
noSkaterProfile: 'Aún no tienes perfil de skater —', noSkaterProfile: 'Aún no tienes perfil de skater —',
editSkaterProfile: 'añade tus estilos y nivel', 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: { verify: {
checking: 'Comprobando el carné…', checking: 'Comprobando el carné…',
@ -6396,6 +6413,8 @@ walletUi: {
kindRider: 'Carné de rider', kindRider: 'Carné de rider',
kindCrew: 'Carné de cuadrilla', kindCrew: 'Carné de cuadrilla',
kindEvent: 'Pase de evento', kindEvent: 'Pase de evento',
expired: 'Caducado',
valid: 'Válido',
issuedBy: 'Emitido por', issuedBy: 'Emitido por',
accessLevel: 'Nivel de acceso', accessLevel: 'Nivel de acceso',
validThru: 'Válido hasta', 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-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; } .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. /* ── 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. The @media print block below is only a safety fallback for direct Ctrl+P.
It will never look as good as the canvas approach. ── */ It will never look as good as the canvas approach. ── */

View file

@ -33,6 +33,12 @@ export interface RiderCardConfig {
showCrews: boolean; showCrews: boolean;
showLocal: boolean; showLocal: boolean;
showRoles: 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 = { export const DEFAULT_RIDER_CARD: RiderCardConfig = {
@ -47,6 +53,9 @@ export const DEFAULT_RIDER_CARD: RiderCardConfig = {
showCrews: true, showCrews: true,
showLocal: true, showLocal: true,
showRoles: true, showRoles: true,
accent: '',
logo: '',
back: true,
}; };
/** Read a saved Rider Card event back into editable state. */ /** 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, showCrews: v2 ? show.has('crews') : true,
showLocal: v2 ? show.has('local') : true, showLocal: v2 ? show.has('local') : true,
showRoles: v2 ? show.has('roles') : 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'], ['t', 'rider-card'],
['alt', 'SkateSpots Rider Card'], ['alt', 'SkateSpots Rider Card'],
['theme', c.theme], ['theme', c.theme],
['v', '2'], ['v', '3'],
]; ];
if (c.style.trim()) tags.push(['style', c.style.trim()]); if (c.style.trim()) tags.push(['style', c.style.trim()]);
for (const s of c.skills) tags.push(['skill', s]); for (const s of c.skills) tags.push(['skill', s]);
if (c.sponsors.trim()) tags.push(['sponsor', c.sponsors.trim()]); if (c.sponsors.trim()) tags.push(['sponsor', c.sponsors.trim()]);
if (c.ridesFor.trim()) tags.push(['rides-for', c.ridesFor.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.showSpots) tags.push(['show', 'spots']);
if (c.showZaps) tags.push(['show', 'zaps']); if (c.showZaps) tags.push(['show', 'zaps']);
if (c.showEvents) tags.push(['show', 'events']); if (c.showEvents) tags.push(['show', 'events']);
@ -94,6 +110,17 @@ export function riderCardTags(c: RiderCardConfig): string[][] {
return tags; 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. */ /** naddr for an addressable card event. */
export function cardNaddr(kind: number, pubkey: string, d: string): string { export function cardNaddr(kind: number, pubkey: string, d: string): string {
return nip19.naddrEncode({ kind, pubkey, identifier: d }); 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 * 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. * the card opens /verify so anyone can confirm the card is genuine.
*/ */
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { ArrowLeft, Sparkles } from 'lucide-react'; import { ArrowLeft, Sparkles, RefreshCw } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useRiderStats } from '@/hooks/useRiderStats'; import { useRiderStats } from '@/hooks/useRiderStats';
@ -47,6 +48,8 @@ export default function PublicCardPage() {
const { memberships } = useUserCrews(pubkey ?? undefined); const { memberships } = useUserCrews(pubkey ?? undefined);
const { data: skaterProfile } = useUserSkaterProfile(pubkey ?? undefined); const { data: skaterProfile } = useUserSkaterProfile(pubkey ?? undefined);
const [side, setSide] = useState<'front' | 'back'>('front');
useSeoMeta({ useSeoMeta({
title: `${displayName || 'Rider'} card — SkateSpots`, title: `${displayName || 'Rider'} card — SkateSpots`,
description: `A SkateSpots Rider Card${displayName ? ` for ${displayName}` : ''}.`, 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(), memberYear: metadata?.created_at ? memberSince(metadata.created_at as number) : new Date().getFullYear().toString(),
receivedBadges, receivedBadges,
verifyUrl: cardVerifyUrl(RIDER_CARD_KIND, pubkey, RIDER_CARD_D), 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 })), crews: memberships.map(m => ({ name: m.crew.name, image: m.crew.image })),
local: skaterProfile?.location || skaterProfile?.homeSpot || undefined, local: skaterProfile?.location || skaterProfile?.homeSpot || undefined,
roles, roles,
@ -118,6 +125,19 @@ export default function PublicCardPage() {
<RiderCardFront d={cardData} /> <RiderCardFront d={cardData} />
</div> </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 && ( {!savedCard?.config && (
<p className="text-center text-xs text-gray-600 mt-4">{t('cardUi.public.notCustomised')}</p> <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 { useSkateEvents } from '@/hooks/useSkateEvents';
import { useMyRsvpCoords, useEventRsvps, eventCoord } from '@/hooks/useEventRsvps'; import { useMyRsvpCoords, useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useNostr } from '@/hooks/useNostr'; import { useNostr } from '@/hooks/useNostr';
import { useQuery } from '@tanstack/react-query'; 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 { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import { Link } from 'react-router-dom'; 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 ───────────────────────────────────────────────────── // ─── Card canvas renderer ─────────────────────────────────────────────────────
// Draws the card entirely via Canvas 2D API — 100% reliable export, // Draws the card entirely via Canvas 2D API — 100% reliable export,
// no CSS capture, no scaling issues, no background-stripping. // 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(); 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'); const canvas = document.createElement('canvas');
canvas.width = CARD_W; canvas.width = CARD_W;
canvas.height = CARD_H; canvas.height = CARD_H;
const ctx = canvas.getContext('2d')!; const ctx = canvas.getContext('2d')!;
const p = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug; const p = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug;
if (side === 'back') return drawCardBack(ctx, data, p);
// ─── 1. Background ─────────────────────────────────────────────────────── // ─── 1. Background ───────────────────────────────────────────────────────
const bgGrad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H); const bgGrad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
bgGrad.addColorStop(0, p.bg1); bgGrad.addColorStop(0, p.bg1);
@ -848,6 +894,7 @@ export default function RiderCardPage() {
? (SKATER_LEVELS[skaterProfile.level]?.label ?? '') ? (SKATER_LEVELS[skaterProfile.level]?.label ?? '')
: (skaterProfile?.customLevel ?? ''); : (skaterProfile?.customLevel ?? '');
const { mutate: publishEvent } = useNostrPublish(); const { mutate: publishEvent } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: uploadingLogo } = useUploadFile();
const { toast } = useToast(); const { toast } = useToast();
const printRef = useRef<HTMLDivElement>(null); const printRef = useRef<HTMLDivElement>(null);
@ -885,6 +932,12 @@ export default function RiderCardPage() {
const [showLocal, setShowLocal] = useState(true); const [showLocal, setShowLocal] = useState(true);
const [showRoles, setShowRoles] = 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 // Park fields
const [issuerName, setIssuerName] = useState(''); const [issuerName, setIssuerName] = useState('');
const [recipientName, setRecipientName] = useState(''); const [recipientName, setRecipientName] = useState('');
@ -933,6 +986,9 @@ export default function RiderCardPage() {
setShowCrews(c.showCrews); setShowCrews(c.showCrews);
setShowLocal(c.showLocal); setShowLocal(c.showLocal);
setShowRoles(c.showRoles); setShowRoles(c.showRoles);
setAccent(c.accent);
setLogo(c.logo);
setBack(c.back);
}, [savedCard]); }, [savedCard]);
// Unsaved-changes tracking for the personal card (manual save — an // Unsaved-changes tracking for the personal card (manual save — an
@ -943,9 +999,10 @@ export default function RiderCardPage() {
return ( return (
c.theme !== theme || c.sponsors !== sponsors || c.ridesFor !== ridesFor || c.theme !== theme || c.sponsors !== sponsors || c.ridesFor !== ridesFor ||
c.showSpots !== showSpots || c.showZaps !== showZaps || c.showEvents !== showEvents || 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 = (() => { const recipientPubkey = (() => {
if (!recipientNpub.trim()) return user?.pubkey ?? ''; if (!recipientNpub.trim()) return user?.pubkey ?? '';
@ -973,6 +1030,10 @@ export default function RiderCardPage() {
style: derivedStyle, style: derivedStyle,
level: derivedLevel || undefined, level: derivedLevel || undefined,
skills: [], skills: [],
accent: accent || undefined,
accent2: accent || undefined,
logo: logo || undefined,
side: cardSide,
sponsors, ridesFor, sponsors, ridesFor,
showSpots, showZaps, showEvents, showSpots, showZaps, showEvents,
crews: crewCrests, crews: crewCrests,
@ -1024,10 +1085,11 @@ export default function RiderCardPage() {
cardPubkey, derivedStyle, derivedLevel, sponsors, ridesFor, showSpots, showZaps, cardPubkey, derivedStyle, derivedLevel, sponsors, ridesFor, showSpots, showZaps,
showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey, showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey,
crewCrests, local, roles, showCrews, showLocal, showRoles, activeCrew, activeEvent, eventRsvp.goingCount, 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); setRendering(true);
try { try {
return await drawCardToCanvas({ return await drawCardToCanvas({
@ -1047,14 +1109,14 @@ export default function RiderCardPage() {
event: cardData.event, event: cardData.event,
issuerName: cardData.issuerName, recipientName: cardData.recipientName, issuerName: cardData.issuerName, recipientName: cardData.recipientName,
accessLevel: cardData.accessLevel, validThru: cardData.validThru, accessLevel: cardData.accessLevel, validThru: cardData.validThru,
}); }, side);
} catch (err) { console.error('Card render failed:', err); return null; } } catch (err) { console.error('Card render failed:', err); return null; }
finally { setRendering(false); } finally { setRendering(false); }
}, [cardData]); }, [cardData]);
const handleSaveCard = useCallback(() => { const handleSaveCard = useCallback(() => {
if (!user) return; 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( publishEvent(
{ kind: RIDER_CARD_KIND, content: '', tags: riderCardTags(config) }, { 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' }), 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 handlePrint = useCallback(async () => {
const dataUrl = await renderCardToImage(); const front = await renderCardToImage('front');
if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; } 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 w = Math.round(85.6 * 2.835);
const h = Math.round(54 * 2.835); const h = Math.round(54 * 2.835);
const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`); 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; } 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(); win.document.close();
}, [renderCardToImage, toast, t]); }, [renderCardToImage, back, toast, t]);
const handleDownload = useCallback(async () => { const handleDownload = useCallback(async () => {
const dataUrl = await renderCardToImage(); const dataUrl = await renderCardToImage();
@ -1084,6 +1148,17 @@ export default function RiderCardPage() {
a.click(); a.click();
}, [renderCardToImage, cardData.npub, toast, t]); }, [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(() => { const handleCopyNpub = useCallback(() => {
navigator.clipboard.writeText(npub); navigator.clipboard.writeText(npub);
setCopied(true); setCopied(true);
@ -1198,6 +1273,20 @@ export default function RiderCardPage() {
<RiderCardFront d={cardData} printRef={printRef} /> <RiderCardFront d={cardData} printRef={printRef} />
</div> </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 */} {/* Saved state */}
{cardType === 'rider' && ( {cardType === 'rider' && (
<div className="flex items-center justify-center text-[10px] font-black uppercase tracking-widest"> <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} /> <Switch checked={holographic} onCheckedChange={setHolographic} />
</div> </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 */} {/* Show on card — ONE clear list */}
{cardType === 'rider' && ( {cardType === 'rider' && (
<> <>
@ -1440,7 +1572,7 @@ export default function RiderCardPage() {
</div> </div>
<div> <div>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.validThru')}</Label> <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" /> placeholder="2026-12-31" className="h-11 border-white/10 bg-black/50" />
</div> </div>
</div> </div>

View file

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