feat(card): identity facets — crew crests, local badge, Lab role chips
- Card shows crew crests (kind 35006 memberships), a local badge (skater profile location/home_spot), and Lab role chips (kinds 35001-35014) — all DERIVED LIVE, so a card can't claim a role/crew the skater doesn't have. - Rendered in the React card + the canvas export (print/PNG); local/crews as text lines, roles as blue chips. - Per-facet show toggles in the editor; card schema bumped to v2 so the new flags default ON for cards saved before them. - Also fixed: verifyUrl wasn't passed to the canvas renderer (QR fell back to the profile path). - i18n cardUi.facets.* / cardUi.roles.*; NIP.md + AGENTS.md updated.
This commit is contained in:
parent
dbb9ea98c0
commit
e348796e05
9 changed files with 255 additions and 24 deletions
|
|
@ -77,6 +77,11 @@ session with **no other memory of this project**. So:
|
||||||
beyond what the venue issued; the card says so. A pass with no `a` tag verifies
|
beyond what the venue issued; the card says so. A pass with no `a` tag verifies
|
||||||
as a signature but is shown **not venue-bound** (any key can mint one). The QR
|
as a signature but is shown **not venue-bound** (any key can mint one). The QR
|
||||||
now encodes the verify URL (was a profile path that HashRouter ignored).
|
now encodes the verify URL (was a profile path that HashRouter ignored).
|
||||||
|
**Identity facets (2026-10-02):** the card also shows **crew crests**, a **local**
|
||||||
|
badge (skater profile `location`/`home_spot`), and **Lab role** chips — all
|
||||||
|
**derived LIVE** (kinds 35006 / 35000 / 35001–35014), never claimed on the card,
|
||||||
|
toggled by `show` flags (`v:2`; old cards default them ON). Rendered in the React
|
||||||
|
card AND the canvas export (roles as chips, local/crews as lines).
|
||||||
- **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
|
||||||
|
|
|
||||||
19
NIP.md
19
NIP.md
|
|
@ -1541,7 +1541,7 @@ 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", "1"],
|
["v", "2"],
|
||||||
["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>"],
|
||||||
|
|
@ -1549,15 +1549,24 @@ author's kind 0; on-chain stats are derived live:
|
||||||
["show", "spots"],
|
["show", "spots"],
|
||||||
["show", "zaps"],
|
["show", "zaps"],
|
||||||
["show", "events"],
|
["show", "events"],
|
||||||
|
["show", "crews"],
|
||||||
|
["show", "local"],
|
||||||
|
["show", "roles"],
|
||||||
["alt", "SkateSpots Rider Card"]
|
["alt", "SkateSpots Rider Card"]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `v:1` marks the `show` flags as explicit, so hiding a stat persists (an event
|
- `v:2` marks the `show` flags as explicit, so hiding a facet persists. An event
|
||||||
with no `v`/`show` tags defaults all three to visible).
|
with **no** `v`/`show` tags defaults every facet to visible; `v:1` cards (which
|
||||||
- The card shows **self-claimed** skills (`skill` tags) in the theme colour and
|
never had facet flags) also default the three facets ON rather than hiding them.
|
||||||
**witnessed** badges (kind 1293) in gold ⭐.
|
- The card shows **self-claimed** skills (`skill` tags) in the theme colour,
|
||||||
|
**witnessed** badges (kind 1293) in gold ⭐, and **Lab roles** as blue chips.
|
||||||
|
- **Identity facets are derived LIVE, never stored on the card:** crews come from
|
||||||
|
the author's crew memberships (kind 35006), the local from their skater profile
|
||||||
|
(kind 35000 `location`/`home_spot`), and the roles from their own Lab profiles
|
||||||
|
(kinds 35001–35014). The `show` flags above only decide what the card renders —
|
||||||
|
so the card can never claim a role or crew the skater doesn't actually have.
|
||||||
|
|
||||||
### Verification
|
### Verification
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, type ThemeId } from '@/lib/riderCard';
|
import { SKILL_OPTIONS, shortNpub, roleMeta, 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';
|
||||||
|
|
||||||
|
|
@ -40,6 +40,13 @@ 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;
|
||||||
|
// Identity facets (derived live, toggled on the card)
|
||||||
|
crews?: { name: string; image?: string }[];
|
||||||
|
local?: string;
|
||||||
|
roles?: string[];
|
||||||
|
showCrews?: boolean;
|
||||||
|
showLocal?: boolean;
|
||||||
|
showRoles?: boolean;
|
||||||
// Park
|
// Park
|
||||||
issuerName: string;
|
issuerName: string;
|
||||||
recipientName: string;
|
recipientName: string;
|
||||||
|
|
@ -106,6 +113,23 @@ function RiderFront({ d }: { d: CardData }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Identity facets: local + crew crests */}
|
||||||
|
{((d.showLocal !== false && d.local) || (d.showCrews !== false && (d.crews?.length ?? 0) > 0)) && (
|
||||||
|
<div className="flex items-center gap-[6px] mt-[2px] overflow-hidden" style={{ height: '13px' }}>
|
||||||
|
{d.showLocal !== false && d.local && (
|
||||||
|
<span className="text-[6px] font-bold card-accent truncate flex-shrink-0" style={{ maxWidth: '150px' }}>📍 {d.local}</span>
|
||||||
|
)}
|
||||||
|
{d.showCrews !== false && (d.crews ?? []).slice(0, 3).map((c, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-[2px] min-w-0 flex-shrink-0">
|
||||||
|
{c.image
|
||||||
|
? <img src={c.image} alt="" crossOrigin="anonymous" className="w-[11px] h-[11px] rounded-full object-cover border border-white/25" />
|
||||||
|
: <span className="w-[11px] h-[11px] rounded-full bg-white/15" />}
|
||||||
|
<span className="text-[5.5px] card-muted truncate" style={{ maxWidth: '66px' }}>{c.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Row 2: main body */}
|
{/* Row 2: main body */}
|
||||||
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
||||||
|
|
||||||
|
|
@ -157,9 +181,17 @@ function RiderFront({ d }: { d: CardData }) {
|
||||||
{d.sponsors && (
|
{d.sponsors && (
|
||||||
<div className="text-[5.5px] card-muted truncate leading-none">🤝 {d.sponsors}</div>
|
<div className="text-[5.5px] card-muted truncate leading-none">🤝 {d.sponsors}</div>
|
||||||
)}
|
)}
|
||||||
{/* Skills — self-claimed then witnessed (gold) */}
|
{/* Skills — roles, self-claimed, then witnessed (gold) */}
|
||||||
<div className="flex flex-wrap gap-[2px] mt-[2px]">
|
<div className="flex flex-wrap gap-[2px] mt-[2px]">
|
||||||
{d.skills.slice(0, 4).map(sk => {
|
{d.showRoles !== false && (d.roles ?? []).slice(0, 2).map(r => {
|
||||||
|
const m = roleMeta(r);
|
||||||
|
return m ? (
|
||||||
|
<span key={r} className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none" style={{ background: 'rgba(100,160,255,0.18)', border: '1px solid rgba(100,160,255,0.45)', color: '#9cc4ff' }}>
|
||||||
|
{m.emoji} {t(`cardUi.roles.${m.role}`)}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
{d.skills.slice(0, 3).map(sk => {
|
||||||
const s = SKILL_OPTIONS.find(o => o.id === sk);
|
const s = SKILL_OPTIONS.find(o => o.id === sk);
|
||||||
return s ? (
|
return s ? (
|
||||||
<span key={sk} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none">
|
<span key={sk} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none">
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import {
|
||||||
RIDER_CARD_KIND,
|
RIDER_CARD_KIND,
|
||||||
RIDER_CARD_D,
|
RIDER_CARD_D,
|
||||||
SKILL_BADGE_KIND,
|
SKILL_BADGE_KIND,
|
||||||
|
RIDER_ROLE_KINDS,
|
||||||
|
RIDER_ROLE_META,
|
||||||
parseRiderCard,
|
parseRiderCard,
|
||||||
type RiderCardConfig,
|
type RiderCardConfig,
|
||||||
} from '@/lib/riderCard';
|
} from '@/lib/riderCard';
|
||||||
|
|
@ -46,7 +48,6 @@ export interface RiderBadge {
|
||||||
spotName?: string;
|
spotName?: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Skill badges witnessed for a rider (kind 1293, `#p` = rider). */
|
/** Skill badges witnessed for a rider (kind 1293, `#p` = rider). */
|
||||||
export function useRiderBadges(pubkey: string | undefined) {
|
export function useRiderBadges(pubkey: string | undefined) {
|
||||||
const { nostr } = useNostr();
|
const { nostr } = useNostr();
|
||||||
|
|
@ -78,3 +79,27 @@ export function useRiderBadges(pubkey: string | undefined) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Lab roles a skater wears (kinds 35001–35014). Derived live from the
|
||||||
|
* author's own Lab profiles, so the card never claims a role they don't have.
|
||||||
|
*/
|
||||||
|
export function useRiderRoles(pubkey: string | undefined) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
|
||||||
|
return useQuery<string[]>({
|
||||||
|
queryKey: ['rider-roles', pubkey],
|
||||||
|
enabled: !!pubkey,
|
||||||
|
staleTime: 60_000,
|
||||||
|
queryFn: async (c) => {
|
||||||
|
if (!pubkey) return [];
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
const events = await nostr.query(
|
||||||
|
[{ kinds: RIDER_ROLE_KINDS, authors: [pubkey], limit: 50 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const kinds = new Set(events.map((e) => e.kind));
|
||||||
|
return RIDER_ROLE_META.filter((r) => kinds.has(r.kind)).map((r) => r.role);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6299,6 +6299,27 @@ walletUi: {
|
||||||
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.',
|
||||||
|
facets: {
|
||||||
|
title: 'Show identity facets',
|
||||||
|
crews: 'Crews',
|
||||||
|
local: 'Local',
|
||||||
|
roles: 'Lab roles',
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
coach: 'Coach',
|
||||||
|
filmer: 'Filmer',
|
||||||
|
editor: 'Editor',
|
||||||
|
photographer: 'Photographer',
|
||||||
|
builder: 'Builder',
|
||||||
|
artist: 'Artist',
|
||||||
|
graffiti: 'Graffiti',
|
||||||
|
tattoo: 'Tattoo',
|
||||||
|
trainer: 'Trainer',
|
||||||
|
musician: 'Musician',
|
||||||
|
judge: 'Judge',
|
||||||
|
organizer: 'Organizer',
|
||||||
|
barber: 'Barber',
|
||||||
|
},
|
||||||
verify: {
|
verify: {
|
||||||
checking: 'Checking the card…',
|
checking: 'Checking the card…',
|
||||||
notFound: 'Card not found',
|
notFound: 'Card not found',
|
||||||
|
|
|
||||||
|
|
@ -6299,6 +6299,27 @@ walletUi: {
|
||||||
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.',
|
||||||
|
facets: {
|
||||||
|
title: 'Mostrar facetas de identidad',
|
||||||
|
crews: 'Cuadrillas',
|
||||||
|
local: 'Local',
|
||||||
|
roles: 'Roles del Lab',
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
coach: 'Entrenador',
|
||||||
|
filmer: 'Filmer',
|
||||||
|
editor: 'Editor',
|
||||||
|
photographer: 'Fotógrafo',
|
||||||
|
builder: 'Constructor',
|
||||||
|
artist: 'Artista',
|
||||||
|
graffiti: 'Graffiti',
|
||||||
|
tattoo: 'Tatuador',
|
||||||
|
trainer: 'Entrenador personal',
|
||||||
|
musician: 'Músico',
|
||||||
|
judge: 'Juez',
|
||||||
|
organizer: 'Organizador',
|
||||||
|
barber: 'Barbero',
|
||||||
|
},
|
||||||
verify: {
|
verify: {
|
||||||
checking: 'Comprobando el carné…',
|
checking: 'Comprobando el carné…',
|
||||||
notFound: 'Carné no encontrado',
|
notFound: 'Carné no encontrado',
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ export interface RiderCardConfig {
|
||||||
showSpots: boolean;
|
showSpots: boolean;
|
||||||
showZaps: boolean;
|
showZaps: boolean;
|
||||||
showEvents: boolean;
|
showEvents: boolean;
|
||||||
|
showCrews: boolean;
|
||||||
|
showLocal: boolean;
|
||||||
|
showRoles: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_RIDER_CARD: RiderCardConfig = {
|
export const DEFAULT_RIDER_CARD: RiderCardConfig = {
|
||||||
|
|
@ -41,14 +44,16 @@ export const DEFAULT_RIDER_CARD: RiderCardConfig = {
|
||||||
showSpots: true,
|
showSpots: true,
|
||||||
showZaps: true,
|
showZaps: true,
|
||||||
showEvents: true,
|
showEvents: true,
|
||||||
|
showCrews: true,
|
||||||
|
showLocal: true,
|
||||||
|
showRoles: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Read a saved Rider Card event back into editable state. */
|
/** Read a saved Rider Card event back into editable state. */
|
||||||
export function parseRiderCard(event: NostrEvent): RiderCardConfig {
|
export function parseRiderCard(event: NostrEvent): RiderCardConfig {
|
||||||
const tag = (name: string) => event.tags.find(([n]) => n === name)?.[1] ?? '';
|
const tag = (name: string) => event.tags.find(([n]) => n === name)?.[1] ?? '';
|
||||||
// `v` marks the "show" flags as explicitly set — older/absent events default
|
const hasShow = event.tags.some(([n]) => n === 'show');
|
||||||
// all three to visible (so a card saved before a stat was ever hidden is fine).
|
const v2 = (Number(tag('v')) || 0) >= 2;
|
||||||
const versioned = event.tags.some(([n]) => n === 'v');
|
|
||||||
const show = new Set(event.tags.filter(([n]) => n === 'show').map(([, v]) => v));
|
const show = new Set(event.tags.filter(([n]) => n === 'show').map(([, v]) => v));
|
||||||
return {
|
return {
|
||||||
theme: tag('theme') || DEFAULT_RIDER_CARD.theme,
|
theme: tag('theme') || DEFAULT_RIDER_CARD.theme,
|
||||||
|
|
@ -56,9 +61,14 @@ export function parseRiderCard(event: NostrEvent): RiderCardConfig {
|
||||||
skills: event.tags.filter(([n]) => n === 'skill').map(([, v]) => v).filter(Boolean),
|
skills: event.tags.filter(([n]) => n === 'skill').map(([, v]) => v).filter(Boolean),
|
||||||
sponsors: tag('sponsor'),
|
sponsors: tag('sponsor'),
|
||||||
ridesFor: tag('rides-for'),
|
ridesFor: tag('rides-for'),
|
||||||
showSpots: versioned ? show.has('spots') : true,
|
// Spot/zap/event flags have always been written; facets arrived in v2, so an
|
||||||
showZaps: versioned ? show.has('zaps') : true,
|
// older card defaults them ON rather than silently hiding them.
|
||||||
showEvents: versioned ? show.has('events') : true,
|
showSpots: hasShow ? show.has('spots') : true,
|
||||||
|
showZaps: hasShow ? show.has('zaps') : true,
|
||||||
|
showEvents: hasShow ? show.has('events') : true,
|
||||||
|
showCrews: v2 ? show.has('crews') : true,
|
||||||
|
showLocal: v2 ? show.has('local') : true,
|
||||||
|
showRoles: v2 ? show.has('roles') : true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,7 +79,7 @@ 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', '1'],
|
['v', '2'],
|
||||||
];
|
];
|
||||||
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]);
|
||||||
|
|
@ -78,6 +88,9 @@ export function riderCardTags(c: RiderCardConfig): string[][] {
|
||||||
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']);
|
||||||
|
if (c.showCrews) tags.push(['show', 'crews']);
|
||||||
|
if (c.showLocal) tags.push(['show', 'local']);
|
||||||
|
if (c.showRoles) tags.push(['show', 'roles']);
|
||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,3 +153,29 @@ export function fullNpub(pubkey: string) {
|
||||||
export function memberSince(createdAt: number) {
|
export function memberSince(createdAt: number) {
|
||||||
return new Date(createdAt * 1000).getFullYear().toString();
|
return new Date(createdAt * 1000).getFullYear().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Lab role credentials (kinds 35001–35014) ─────────────────────────────────
|
||||||
|
// A skater "wears" a role on their card when they have a Lab profile of that
|
||||||
|
// kind. Labels are i18n'd on the card (`cardUi.roles.*`); `label` is the
|
||||||
|
// English fallback.
|
||||||
|
export const RIDER_ROLE_META: { kind: number; role: string; label: string; emoji: string }[] = [
|
||||||
|
{ kind: 35001, role: 'coach', label: 'Coach', emoji: '🎓' },
|
||||||
|
{ kind: 35002, role: 'filmer', label: 'Filmer', emoji: '🎥' },
|
||||||
|
{ kind: 35003, role: 'editor', label: 'Editor', emoji: '✂️' },
|
||||||
|
{ kind: 35004, role: 'photographer', label: 'Photographer', emoji: '📸' },
|
||||||
|
{ kind: 35005, role: 'builder', label: 'Builder', emoji: '🔨' },
|
||||||
|
{ kind: 35007, role: 'artist', label: 'Artist', emoji: '🎨' },
|
||||||
|
{ kind: 35008, role: 'graffiti', label: 'Graffiti', emoji: '🖌️' },
|
||||||
|
{ kind: 35009, role: 'tattoo', label: 'Tattoo', emoji: '💉' },
|
||||||
|
{ kind: 35010, role: 'trainer', label: 'Trainer', emoji: '💪' },
|
||||||
|
{ kind: 35011, role: 'musician', label: 'Musician', emoji: '🎵' },
|
||||||
|
{ kind: 35012, role: 'judge', label: 'Judge', emoji: '⚖️' },
|
||||||
|
{ kind: 35013, role: 'organizer', label: 'Organizer', emoji: '📣' },
|
||||||
|
{ kind: 35014, role: 'barber', label: 'Barber', emoji: '💈' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RIDER_ROLE_KINDS = RIDER_ROLE_META.map((r) => r.kind);
|
||||||
|
|
||||||
|
export function roleMeta(role: string) {
|
||||||
|
return RIDER_ROLE_META.find((r) => r.role === role);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ import { ArrowLeft, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useRiderStats } from '@/hooks/useRiderStats';
|
import { useRiderStats } from '@/hooks/useRiderStats';
|
||||||
import { useRiderCard, useRiderBadges } from '@/hooks/useRiderCard';
|
import { useRiderCard, useRiderBadges, useRiderRoles } from '@/hooks/useRiderCard';
|
||||||
|
import { useUserCrews } from '@/hooks/useCrews';
|
||||||
|
import { useUserSkaterProfile } from '@/hooks/useSkaters';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
|
@ -41,6 +43,9 @@ export default function PublicCardPage() {
|
||||||
const { data: stats } = useRiderStats(pubkey ?? undefined);
|
const { data: stats } = useRiderStats(pubkey ?? undefined);
|
||||||
const { data: savedCard } = useRiderCard(pubkey ?? undefined);
|
const { data: savedCard } = useRiderCard(pubkey ?? undefined);
|
||||||
const { data: badges = [] } = useRiderBadges(pubkey ?? undefined);
|
const { data: badges = [] } = useRiderBadges(pubkey ?? undefined);
|
||||||
|
const { data: roles = [] } = useRiderRoles(pubkey ?? undefined);
|
||||||
|
const { memberships } = useUserCrews(pubkey ?? undefined);
|
||||||
|
const { data: skaterProfile } = useUserSkaterProfile(pubkey ?? undefined);
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: `${displayName || 'Rider'} card — SkateSpots`,
|
title: `${displayName || 'Rider'} card — SkateSpots`,
|
||||||
|
|
@ -87,6 +92,9 @@ 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),
|
||||||
|
crews: memberships.map(m => ({ name: m.crew.name, image: m.crew.image })),
|
||||||
|
local: skaterProfile?.location || skaterProfile?.homeSpot || undefined,
|
||||||
|
roles,
|
||||||
issuerName: '',
|
issuerName: '',
|
||||||
recipientName: '',
|
recipientName: '',
|
||||||
accessLevel: '',
|
accessLevel: '',
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard';
|
import { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard';
|
||||||
import { THEMES, SKILL_OPTIONS, STYLES, fullNpub, memberSince, type ThemeId } from '@/lib/riderCard';
|
import { THEMES, SKILL_OPTIONS, STYLES, fullNpub, memberSince, roleMeta, type ThemeId } from '@/lib/riderCard';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
@ -33,7 +33,9 @@ import { Badge } from '@/components/ui/badge';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useRiderStats } from '@/hooks/useRiderStats';
|
import { useRiderStats } from '@/hooks/useRiderStats';
|
||||||
import { useRiderCard } from '@/hooks/useRiderCard';
|
import { useRiderCard, useRiderRoles } from '@/hooks/useRiderCard';
|
||||||
|
import { useUserCrews } from '@/hooks/useCrews';
|
||||||
|
import { useUserSkaterProfile } from '@/hooks/useSkaters';
|
||||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
@ -76,6 +78,9 @@ interface CanvasCardData {
|
||||||
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;
|
||||||
|
local?: string;
|
||||||
|
crews?: string[];
|
||||||
|
roles?: string[];
|
||||||
// Park
|
// Park
|
||||||
issuerName: string;
|
issuerName: string;
|
||||||
recipientName: string;
|
recipientName: string;
|
||||||
|
|
@ -337,7 +342,19 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
||||||
ry += 22;
|
ry += 22;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SKATE SHOP — distinctive color + italic font ──────────────────────
|
// Local + crew crests (identity facets)
|
||||||
|
if (data.local) {
|
||||||
|
ctx.fillStyle = p.accent;
|
||||||
|
ctx.font = 'bold 17px system-ui, sans-serif';
|
||||||
|
fillTextFit(ctx, `📍 ${data.local.toUpperCase()}`, RX, ry, RMaxW);
|
||||||
|
ry += 22;
|
||||||
|
}
|
||||||
|
if (data.crews && data.crews.length > 0) {
|
||||||
|
ctx.fillStyle = p.muted;
|
||||||
|
ctx.font = '14px system-ui, sans-serif';
|
||||||
|
fillTextFit(ctx, `⛺ ${data.crews.slice(0, 3).join(' · ')}`, RX, ry, RMaxW);
|
||||||
|
ry += 20;
|
||||||
|
}
|
||||||
if (data.ridesFor) {
|
if (data.ridesFor) {
|
||||||
ctx.fillStyle = p.muted;
|
ctx.fillStyle = p.muted;
|
||||||
ctx.font = '11px system-ui, sans-serif';
|
ctx.font = '11px system-ui, sans-serif';
|
||||||
|
|
@ -362,7 +379,7 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thin divider before skills
|
// Thin divider before skills
|
||||||
if (data.skills.length > 0 || data.receivedBadges.length > 0) {
|
if (data.skills.length > 0 || data.receivedBadges.length > 0 || (data.roles?.length ?? 0) > 0) {
|
||||||
ctx.strokeStyle = p.accent;
|
ctx.strokeStyle = p.accent;
|
||||||
ctx.globalAlpha = 0.15;
|
ctx.globalAlpha = 0.15;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
|
|
@ -372,7 +389,11 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
||||||
|
|
||||||
// ── Skill chips ────────────────────────────────────────────────────
|
// ── Skill chips ────────────────────────────────────────────────────
|
||||||
const skillsToShow = [
|
const skillsToShow = [
|
||||||
...data.skills.slice(0, 5).map(id => {
|
...(data.roles ?? []).slice(0, 3).map(r => {
|
||||||
|
const m = roleMeta(r);
|
||||||
|
return m ? { label: `${m.emoji} ${m.label}`, gold: false } : null;
|
||||||
|
}).filter(Boolean),
|
||||||
|
...data.skills.slice(0, 4).map(id => {
|
||||||
const s = SKILL_OPTIONS.find(o => o.id === id);
|
const s = SKILL_OPTIONS.find(o => o.id === id);
|
||||||
return s ? { label: s.label, gold: false } : null;
|
return s ? { label: s.label, gold: false } : null;
|
||||||
}).filter(Boolean),
|
}).filter(Boolean),
|
||||||
|
|
@ -625,6 +646,15 @@ export default function RiderCardPage() {
|
||||||
const { data: stats } = useRiderStats(user?.pubkey);
|
const { data: stats } = useRiderStats(user?.pubkey);
|
||||||
const { data: savedCard } = useRiderCard(user?.pubkey);
|
const { data: savedCard } = useRiderCard(user?.pubkey);
|
||||||
const { data: allSpots } = useSkateSpots(200);
|
const { data: allSpots } = useSkateSpots(200);
|
||||||
|
const { memberships } = useUserCrews(user?.pubkey);
|
||||||
|
const { data: skaterProfile } = useUserSkaterProfile(user?.pubkey);
|
||||||
|
const { data: roles = [] } = useRiderRoles(user?.pubkey);
|
||||||
|
|
||||||
|
const crewCrests = useMemo(
|
||||||
|
() => memberships.map(m => ({ name: m.crew.name, image: m.crew.image })),
|
||||||
|
[memberships],
|
||||||
|
);
|
||||||
|
const local = skaterProfile?.location || skaterProfile?.homeSpot || '';
|
||||||
const { mutate: publishEvent } = useNostrPublish();
|
const { mutate: publishEvent } = useNostrPublish();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const printRef = useRef<HTMLDivElement>(null);
|
const printRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -661,6 +691,9 @@ export default function RiderCardPage() {
|
||||||
const [showSpots, setShowSpots] = useState(true);
|
const [showSpots, setShowSpots] = useState(true);
|
||||||
const [showZaps, setShowZaps] = useState(true);
|
const [showZaps, setShowZaps] = useState(true);
|
||||||
const [showEvents, setShowEvents] = useState(true);
|
const [showEvents, setShowEvents] = useState(true);
|
||||||
|
const [showCrews, setShowCrews] = useState(true);
|
||||||
|
const [showLocal, setShowLocal] = useState(true);
|
||||||
|
const [showRoles, setShowRoles] = useState(true);
|
||||||
|
|
||||||
// Park fields
|
// Park fields
|
||||||
const [issuerName, setIssuerName] = useState('');
|
const [issuerName, setIssuerName] = useState('');
|
||||||
|
|
@ -696,6 +729,9 @@ export default function RiderCardPage() {
|
||||||
setShowSpots(c.showSpots);
|
setShowSpots(c.showSpots);
|
||||||
setShowZaps(c.showZaps);
|
setShowZaps(c.showZaps);
|
||||||
setShowEvents(c.showEvents);
|
setShowEvents(c.showEvents);
|
||||||
|
setShowCrews(c.showCrews);
|
||||||
|
setShowLocal(c.showLocal);
|
||||||
|
setShowRoles(c.showRoles);
|
||||||
}, [savedCard]);
|
}, [savedCard]);
|
||||||
|
|
||||||
const recipientPubkey = (() => {
|
const recipientPubkey = (() => {
|
||||||
|
|
@ -717,6 +753,10 @@ export default function RiderCardPage() {
|
||||||
npub: cardPubkey ? fullNpub(cardPubkey) : '',
|
npub: cardPubkey ? fullNpub(cardPubkey) : '',
|
||||||
style, skills, sponsors, ridesFor,
|
style, skills, sponsors, ridesFor,
|
||||||
showSpots, showZaps, showEvents,
|
showSpots, showZaps, showEvents,
|
||||||
|
crews: crewCrests,
|
||||||
|
local: local || undefined,
|
||||||
|
roles,
|
||||||
|
showCrews, showLocal, showRoles,
|
||||||
spotsAdded: stats?.spotsAdded ?? 0,
|
spotsAdded: stats?.spotsAdded ?? 0,
|
||||||
zapsReceived: stats?.zapsReceived ?? 0,
|
zapsReceived: stats?.zapsReceived ?? 0,
|
||||||
eventsCreated: stats?.eventsCreated ?? 0,
|
eventsCreated: stats?.eventsCreated ?? 0,
|
||||||
|
|
@ -733,6 +773,7 @@ export default function RiderCardPage() {
|
||||||
cardType, theme, holographic, displayName, recipientName, metadata,
|
cardType, theme, holographic, displayName, recipientName, metadata,
|
||||||
cardPubkey, style, skills, sponsors, ridesFor, showSpots, showZaps,
|
cardPubkey, style, skills, 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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -748,6 +789,10 @@ export default function RiderCardPage() {
|
||||||
showSpots: cardData.showSpots, showZaps: cardData.showZaps, showEvents: cardData.showEvents,
|
showSpots: cardData.showSpots, showZaps: cardData.showZaps, showEvents: cardData.showEvents,
|
||||||
spotsAdded: cardData.spotsAdded, zapsReceived: cardData.zapsReceived, eventsCreated: cardData.eventsCreated,
|
spotsAdded: cardData.spotsAdded, zapsReceived: cardData.zapsReceived, eventsCreated: cardData.eventsCreated,
|
||||||
memberYear: cardData.memberYear, receivedBadges: cardData.receivedBadges,
|
memberYear: cardData.memberYear, receivedBadges: cardData.receivedBadges,
|
||||||
|
verifyUrl: cardData.verifyUrl,
|
||||||
|
local: cardData.local,
|
||||||
|
crews: (cardData.crews ?? []).map(c => c.name),
|
||||||
|
roles: cardData.roles,
|
||||||
issuerName: cardData.issuerName, recipientName: cardData.recipientName,
|
issuerName: cardData.issuerName, recipientName: cardData.recipientName,
|
||||||
accessLevel: cardData.accessLevel, validThru: cardData.validThru,
|
accessLevel: cardData.accessLevel, validThru: cardData.validThru,
|
||||||
});
|
});
|
||||||
|
|
@ -757,7 +802,7 @@ export default function RiderCardPage() {
|
||||||
|
|
||||||
const handleSaveCard = useCallback(() => {
|
const handleSaveCard = useCallback(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const config: RiderCardConfig = { theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents };
|
const config: RiderCardConfig = { theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles };
|
||||||
publishEvent(
|
publishEvent(
|
||||||
{ kind: RIDER_CARD_KIND, content: '', tags: riderCardTags(config) },
|
{ kind: RIDER_CARD_KIND, content: '', tags: riderCardTags(config) },
|
||||||
{
|
{
|
||||||
|
|
@ -765,7 +810,7 @@ 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, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, publishEvent, toast, t]);
|
}, [user, theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, publishEvent, toast, t]);
|
||||||
|
|
||||||
const handlePrint = useCallback(async () => {
|
const handlePrint = useCallback(async () => {
|
||||||
const dataUrl = await renderCardToImage();
|
const dataUrl = await renderCardToImage();
|
||||||
|
|
@ -1089,6 +1134,32 @@ export default function RiderCardPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Identity facets */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">{t('cardUi.facets.title')}</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ label: t('cardUi.facets.crews'), value: crewCrests.length, state: showCrews, set: setShowCrews },
|
||||||
|
{ label: t('cardUi.facets.local'), value: local ? '📍' : '—', state: showLocal, set: setShowLocal },
|
||||||
|
{ label: t('cardUi.facets.roles'), value: roles.length, state: showRoles, set: setShowRoles },
|
||||||
|
].map(({ label, value, state, set }) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
onClick={() => set(!state)}
|
||||||
|
className={cn(
|
||||||
|
'p-3 rounded-xl border text-center transition-all',
|
||||||
|
state
|
||||||
|
? 'bg-[#ff0040]/10 border-[#ff0040]/40 text-white'
|
||||||
|
: 'bg-black/30 border-white/8 text-gray-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn('text-lg font-black', state ? 'text-[#ff0040]' : 'text-gray-700')}>{value}</div>
|
||||||
|
<div className="text-[9px] uppercase tracking-wide mt-0.5">{label}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Skills */}
|
{/* Skills */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue