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
|
||||
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).
|
||||
**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
|
||||
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
||||
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"],
|
||||
["t", "rider-card"],
|
||||
["theme", "<thug|lime|chrome|wood|midnight|sunset>"],
|
||||
["v", "1"],
|
||||
["v", "2"],
|
||||
["style", "<Street|Park|Vert|Bowl|DIY|All-terrain>"],
|
||||
["skill", "<skill-id>"],
|
||||
["sponsor", "<text>"],
|
||||
|
|
@ -1549,15 +1549,24 @@ author's kind 0; on-chain stats are derived live:
|
|||
["show", "spots"],
|
||||
["show", "zaps"],
|
||||
["show", "events"],
|
||||
["show", "crews"],
|
||||
["show", "local"],
|
||||
["show", "roles"],
|
||||
["alt", "SkateSpots Rider Card"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `v:1` marks the `show` flags as explicit, so hiding a stat persists (an event
|
||||
with no `v`/`show` tags defaults all three to visible).
|
||||
- The card shows **self-claimed** skills (`skill` tags) in the theme colour and
|
||||
**witnessed** badges (kind 1293) in gold ⭐.
|
||||
- `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.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* (the canvas export in `RiderCardPage` remains the print renderer).
|
||||
*/
|
||||
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 { cn } from '@/lib/utils';
|
||||
|
||||
|
|
@ -40,6 +40,13 @@ export interface CardData {
|
|||
receivedBadges: BadgeAward[];
|
||||
/** When set, the printed QR opens the verify page for this card. */
|
||||
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
|
||||
issuerName: string;
|
||||
recipientName: string;
|
||||
|
|
@ -106,6 +113,23 @@ function RiderFront({ d }: { d: CardData }) {
|
|||
</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 */}
|
||||
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
||||
|
||||
|
|
@ -157,9 +181,17 @@ function RiderFront({ d }: { d: CardData }) {
|
|||
{d.sponsors && (
|
||||
<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]">
|
||||
{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);
|
||||
return s ? (
|
||||
<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_D,
|
||||
SKILL_BADGE_KIND,
|
||||
RIDER_ROLE_KINDS,
|
||||
RIDER_ROLE_META,
|
||||
parseRiderCard,
|
||||
type RiderCardConfig,
|
||||
} from '@/lib/riderCard';
|
||||
|
|
@ -46,7 +48,6 @@ export interface RiderBadge {
|
|||
spotName?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Skill badges witnessed for a rider (kind 1293, `#p` = rider). */
|
||||
export function useRiderBadges(pubkey: string | undefined) {
|
||||
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)',
|
||||
venueNone: 'No venue',
|
||||
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: {
|
||||
checking: 'Checking the card…',
|
||||
notFound: 'Card not found',
|
||||
|
|
|
|||
|
|
@ -6299,6 +6299,27 @@ walletUi: {
|
|||
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.',
|
||||
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: {
|
||||
checking: 'Comprobando el carné…',
|
||||
notFound: 'Carné no encontrado',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ export interface RiderCardConfig {
|
|||
showSpots: boolean;
|
||||
showZaps: boolean;
|
||||
showEvents: boolean;
|
||||
showCrews: boolean;
|
||||
showLocal: boolean;
|
||||
showRoles: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_RIDER_CARD: RiderCardConfig = {
|
||||
|
|
@ -41,14 +44,16 @@ export const DEFAULT_RIDER_CARD: RiderCardConfig = {
|
|||
showSpots: true,
|
||||
showZaps: true,
|
||||
showEvents: true,
|
||||
showCrews: true,
|
||||
showLocal: true,
|
||||
showRoles: true,
|
||||
};
|
||||
|
||||
/** Read a saved Rider Card event back into editable state. */
|
||||
export function parseRiderCard(event: NostrEvent): RiderCardConfig {
|
||||
const tag = (name: string) => event.tags.find(([n]) => n === name)?.[1] ?? '';
|
||||
// `v` marks the "show" flags as explicitly set — older/absent events default
|
||||
// all three to visible (so a card saved before a stat was ever hidden is fine).
|
||||
const versioned = event.tags.some(([n]) => n === 'v');
|
||||
const hasShow = event.tags.some(([n]) => n === 'show');
|
||||
const v2 = (Number(tag('v')) || 0) >= 2;
|
||||
const show = new Set(event.tags.filter(([n]) => n === 'show').map(([, v]) => v));
|
||||
return {
|
||||
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),
|
||||
sponsors: tag('sponsor'),
|
||||
ridesFor: tag('rides-for'),
|
||||
showSpots: versioned ? show.has('spots') : true,
|
||||
showZaps: versioned ? show.has('zaps') : true,
|
||||
showEvents: versioned ? show.has('events') : true,
|
||||
// Spot/zap/event flags have always been written; facets arrived in v2, so an
|
||||
// older card defaults them ON rather than silently hiding them.
|
||||
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'],
|
||||
['alt', 'SkateSpots Rider Card'],
|
||||
['theme', c.theme],
|
||||
['v', '1'],
|
||||
['v', '2'],
|
||||
];
|
||||
if (c.style.trim()) tags.push(['style', c.style.trim()]);
|
||||
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.showZaps) tags.push(['show', 'zaps']);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -140,3 +153,29 @@ export function fullNpub(pubkey: string) {
|
|||
export function memberSince(createdAt: number) {
|
||||
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 { 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 { useTranslation } from '@/contexts/language';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
|
@ -41,6 +43,9 @@ export default function PublicCardPage() {
|
|||
const { data: stats } = useRiderStats(pubkey ?? undefined);
|
||||
const { data: savedCard } = useRiderCard(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({
|
||||
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(),
|
||||
receivedBadges,
|
||||
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: '',
|
||||
recipientName: '',
|
||||
accessLevel: '',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { nip19 } from 'nostr-tools';
|
|||
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
|
@ -33,7 +33,9 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
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 { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
|
@ -76,6 +78,9 @@ interface CanvasCardData {
|
|||
receivedBadges: BadgeAward[];
|
||||
/** When set, the printed QR opens the verify page for this card. */
|
||||
verifyUrl?: string;
|
||||
local?: string;
|
||||
crews?: string[];
|
||||
roles?: string[];
|
||||
// Park
|
||||
issuerName: string;
|
||||
recipientName: string;
|
||||
|
|
@ -337,7 +342,19 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
|||
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) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '11px system-ui, sans-serif';
|
||||
|
|
@ -362,7 +379,7 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
|||
}
|
||||
|
||||
// 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.globalAlpha = 0.15;
|
||||
ctx.lineWidth = 1;
|
||||
|
|
@ -372,7 +389,11 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
|||
|
||||
// ── Skill chips ────────────────────────────────────────────────────
|
||||
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);
|
||||
return s ? { label: s.label, gold: false } : null;
|
||||
}).filter(Boolean),
|
||||
|
|
@ -625,6 +646,15 @@ export default function RiderCardPage() {
|
|||
const { data: stats } = useRiderStats(user?.pubkey);
|
||||
const { data: savedCard } = useRiderCard(user?.pubkey);
|
||||
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 { toast } = useToast();
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -661,6 +691,9 @@ export default function RiderCardPage() {
|
|||
const [showSpots, setShowSpots] = useState(true);
|
||||
const [showZaps, setShowZaps] = useState(true);
|
||||
const [showEvents, setShowEvents] = useState(true);
|
||||
const [showCrews, setShowCrews] = useState(true);
|
||||
const [showLocal, setShowLocal] = useState(true);
|
||||
const [showRoles, setShowRoles] = useState(true);
|
||||
|
||||
// Park fields
|
||||
const [issuerName, setIssuerName] = useState('');
|
||||
|
|
@ -696,6 +729,9 @@ export default function RiderCardPage() {
|
|||
setShowSpots(c.showSpots);
|
||||
setShowZaps(c.showZaps);
|
||||
setShowEvents(c.showEvents);
|
||||
setShowCrews(c.showCrews);
|
||||
setShowLocal(c.showLocal);
|
||||
setShowRoles(c.showRoles);
|
||||
}, [savedCard]);
|
||||
|
||||
const recipientPubkey = (() => {
|
||||
|
|
@ -717,6 +753,10 @@ export default function RiderCardPage() {
|
|||
npub: cardPubkey ? fullNpub(cardPubkey) : '',
|
||||
style, skills, sponsors, ridesFor,
|
||||
showSpots, showZaps, showEvents,
|
||||
crews: crewCrests,
|
||||
local: local || undefined,
|
||||
roles,
|
||||
showCrews, showLocal, showRoles,
|
||||
spotsAdded: stats?.spotsAdded ?? 0,
|
||||
zapsReceived: stats?.zapsReceived ?? 0,
|
||||
eventsCreated: stats?.eventsCreated ?? 0,
|
||||
|
|
@ -733,6 +773,7 @@ export default function RiderCardPage() {
|
|||
cardType, theme, holographic, displayName, recipientName, metadata,
|
||||
cardPubkey, style, skills, sponsors, ridesFor, showSpots, showZaps,
|
||||
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,
|
||||
spotsAdded: cardData.spotsAdded, zapsReceived: cardData.zapsReceived, eventsCreated: cardData.eventsCreated,
|
||||
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,
|
||||
accessLevel: cardData.accessLevel, validThru: cardData.validThru,
|
||||
});
|
||||
|
|
@ -757,7 +802,7 @@ export default function RiderCardPage() {
|
|||
|
||||
const handleSaveCard = useCallback(() => {
|
||||
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(
|
||||
{ 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' }),
|
||||
},
|
||||
);
|
||||
}, [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 dataUrl = await renderCardToImage();
|
||||
|
|
@ -1089,6 +1134,32 @@ export default function RiderCardPage() {
|
|||
</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 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
|
|
|||
Loading…
Reference in a new issue