From ce0e004a5973c898c6974a8825b0e79fe1a43a55 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 11:47:19 +0700 Subject: [PATCH] =?UTF-8?q?feat(card):=20customization=20depth=20=E2=80=94?= =?UTF-8?q?=20custom=20accent,=20logo,=20back=20side,=20pass=20expiry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- AGENTS.md | 7 ++ NIP.md | 9 +- src/components/RiderCard.tsx | 95 ++++++++++++++++++--- src/i18n/en.ts | 19 +++++ src/i18n/es.ts | 19 +++++ src/index.css | 13 +++ src/lib/riderCard.ts | 29 ++++++- src/pages/PublicCardPage.tsx | 22 ++++- src/pages/RiderCardPage.tsx | 158 ++++++++++++++++++++++++++++++++--- src/pages/VerifyCardPage.tsx | 11 ++- 10 files changed, 353 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e52e81b..d4c13ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/NIP.md b/NIP.md index 331e9a1..b7ebf83 100644 --- a/NIP.md +++ b/NIP.md @@ -1541,11 +1541,14 @@ author's kind 0; on-chain stats are derived live: ["d", "rider-card"], ["t", "rider-card"], ["theme", ""], - ["v", "2"], + ["v", "3"], ["style", ""], ["skill", ""], ["sponsor", ""], ["rides-for", ""], + ["accent", "<#rrggbb>"], + ["logo", ""], + ["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. diff --git a/src/components/RiderCard.tsx b/src/components/RiderCard.tsx index 2b81812..38fe057 100644 --- a/src/components/RiderCard.tsx +++ b/src/components/RiderCard.tsx @@ -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 */}
-
{d.name}
+
+ {d.logo && ( + + )} + {d.name} +
{d.nip05 && (
@{d.nip05.split('@')[0]}
)} @@ -279,7 +292,9 @@ function ParkFront({ d }: { d: CardData }) {
{t('labUi.rider.previewAccessPass')}
- {d.accessLevel || 'Full Access'} + {passExpiry(d.validThru) === 'expired' + ? {t('cardUi.expired')} + : {d.accessLevel || 'Full Access'}}
@@ -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 ( +
+
+ 🛹 SkateSpots + {t('cardUi.back.title')} +
+ +
+
+ {crewNames.length > 0 && ( +
{t('cardUi.facets.crews')}: {crewNames.join(' · ')}
+ )} + {roleLabels.length > 0 && ( +
{t('cardUi.facets.roles')}: {roleLabels.join(' · ')}
+ )} + {d.local &&
📍 {d.local}
} + {d.ridesFor &&
🏪 {d.ridesFor}
} + {d.sponsors &&
🤝 {d.sponsors}
} + {passExpiry(d.validThru) !== 'none' && ( +
{t('cardUi.verify.validThru')}: {d.validThru}
+ )} + {crewNames.length === 0 && roleLabels.length === 0 && !d.local && !d.ridesFor && !d.sponsors && ( +
{t('cardUi.back.empty')}
+ )} +
+ +
+ +
+ {d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')} +
+
+
+ +
+ skate-spots.org · Powered by Nostr + {shortNpub(d.pubkey)} +
+
+ ); +} + export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject }) { 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 (
-
- {d.type === 'rider' && } - {d.type === 'park' && } - {d.type === 'crew' && } - {d.type === 'event' && } + {side === 'front' &&
} + {side === 'back' + ? + : <> + {d.type === 'rider' && } + {d.type === 'park' && } + {d.type === 'crew' && } + {d.type === 'event' && } + }
); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 54951d0..92c13f8 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index ffbb53f..b369fda 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/index.css b/src/index.css index fefadf6..7fd1cc1 100644 --- a/src/index.css +++ b/src/index.css @@ -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. ── */ diff --git a/src/lib/riderCard.ts b/src/lib/riderCard.ts index ce25054..01dffb1 100644 --- a/src/lib/riderCard.ts +++ b/src/lib/riderCard.ts @@ -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 }); diff --git a/src/pages/PublicCardPage.tsx b/src/pages/PublicCardPage.tsx index 93d8a07..107e3e5 100644 --- a/src/pages/PublicCardPage.tsx +++ b/src/pages/PublicCardPage.tsx @@ -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() {
+ {config.back && ( +
+ +
+ )} + {!savedCard?.config && (

{t('cardUi.public.notCustomised')}

)} diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 63ce1f1..47a1392 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -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 { +/** 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 { 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(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 => { + const renderCardToImage = useCallback(async (side: 'front' | 'back' = 'front'): Promise => { 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(`SkateSpots Rider Cardwindow.onload=function(){window.focus();window.print()}`); + const imgs = `${backImg ? `` : ''}`; + win.document.write(`SkateSpots Card${imgs}window.onload=function(){window.focus();window.print()}`); 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() {
+ {/* Flip front/back (personal card) */} + {cardType === 'rider' && ( +
+ +
+ )} + {/* Saved state */} {cardType === 'rider' && (
@@ -1317,6 +1406,49 @@ export default function RiderCardPage() {
+ {/* Design: custom accent + logo (rider only) */} + {cardType === 'rider' && ( + <> +
+

{t('cardUi.studio.accent')}

+
+ 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 + ? + : {t('cardUi.studio.accentHint')}} +
+
+ +
+

{t('cardUi.studio.logo')}

+
+ {logo && } + + {logo && } +
+
+ + )} + + {/* Printable back side */} +
+
+
{t('cardUi.back.title')}
+
{t('cardUi.back.desc')}
+
+ +
+ {/* Show on card — ONE clear list */} {cardType === 'rider' && ( <> @@ -1440,7 +1572,7 @@ export default function RiderCardPage() {
- setValidThru(e.target.value)} + setValidThru(e.target.value)} placeholder="2026-12-31" className="h-11 border-white/10 bg-black/50" />
diff --git a/src/pages/VerifyCardPage.tsx b/src/pages/VerifyCardPage.tsx index f8448c9..dc6ac20 100644 --- a/src/pages/VerifyCardPage.tsx +++ b/src/pages/VerifyCardPage.tsx @@ -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 (
@@ -132,8 +134,11 @@ export default function VerifyCardPage() { {event.kind === PARK_ACCESS_CARD_KIND && ( <> n === 'access-level')?.[1] ?? '—'} /> - {event.tags.find(([n]) => n === 'valid-thru')?.[1] && ( - n === 'valid-thru')?.[1] ?? ''} /> + {validThru && ( + )} )}