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:
parent
9891101452
commit
ce0e004a59
10 changed files with 353 additions and 29 deletions
|
|
@ -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
9
NIP.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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. ── */
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue