From e348796e0557985ce12e2905cdb804a8c6d03ca8 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 10:35:06 +0700 Subject: [PATCH] =?UTF-8?q?feat(card):=20identity=20facets=20=E2=80=94=20c?= =?UTF-8?q?rew=20crests,=20local=20badge,=20Lab=20role=20chips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- AGENTS.md | 5 +++ NIP.md | 19 +++++--- src/components/RiderCard.tsx | 38 ++++++++++++++-- src/hooks/useRiderCard.ts | 27 +++++++++++- src/i18n/en.ts | 21 +++++++++ src/i18n/es.ts | 21 +++++++++ src/lib/riderCard.ts | 53 +++++++++++++++++++--- src/pages/PublicCardPage.tsx | 10 ++++- src/pages/RiderCardPage.tsx | 85 +++++++++++++++++++++++++++++++++--- 9 files changed, 255 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ad12996..b8d9fb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/NIP.md b/NIP.md index aeb1d45..d21c101 100644 --- a/NIP.md +++ b/NIP.md @@ -1541,7 +1541,7 @@ author's kind 0; on-chain stats are derived live: ["d", "rider-card"], ["t", "rider-card"], ["theme", ""], - ["v", "1"], + ["v", "2"], ["style", ""], ["skill", ""], ["sponsor", ""], @@ -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 diff --git a/src/components/RiderCard.tsx b/src/components/RiderCard.tsx index 93266d0..77eb8fb 100644 --- a/src/components/RiderCard.tsx +++ b/src/components/RiderCard.tsx @@ -4,7 +4,7 @@ * (the canvas export in `RiderCardPage` remains the print renderer). */ import { useEffect, useState } from 'react'; -import { SKILL_OPTIONS, shortNpub, 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 }) { + {/* Identity facets: local + crew crests */} + {((d.showLocal !== false && d.local) || (d.showCrews !== false && (d.crews?.length ?? 0) > 0)) && ( +
+ {d.showLocal !== false && d.local && ( + 📍 {d.local} + )} + {d.showCrews !== false && (d.crews ?? []).slice(0, 3).map((c, i) => ( + + {c.image + ? + : } + {c.name} + + ))} +
+ )} + {/* Row 2: main body */}
@@ -157,9 +181,17 @@ function RiderFront({ d }: { d: CardData }) { {d.sponsors && (
🤝 {d.sponsors}
)} - {/* Skills — self-claimed then witnessed (gold) */} + {/* Skills — roles, self-claimed, then witnessed (gold) */}
- {d.skills.slice(0, 4).map(sk => { + {d.showRoles !== false && (d.roles ?? []).slice(0, 2).map(r => { + const m = roleMeta(r); + return m ? ( + + {m.emoji} {t(`cardUi.roles.${m.role}`)} + + ) : null; + })} + {d.skills.slice(0, 3).map(sk => { const s = SKILL_OPTIONS.find(o => o.id === sk); return s ? ( diff --git a/src/hooks/useRiderCard.ts b/src/hooks/useRiderCard.ts index 916f7c9..2066415 100644 --- a/src/hooks/useRiderCard.ts +++ b/src/hooks/useRiderCard.ts @@ -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({ + 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); + }, + }); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 77d2012..6a945ce 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index d9452b2..18f864d 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/lib/riderCard.ts b/src/lib/riderCard.ts index 077f03c..670d922 100644 --- a/src/lib/riderCard.ts +++ b/src/lib/riderCard.ts @@ -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); +} diff --git a/src/pages/PublicCardPage.tsx b/src/pages/PublicCardPage.tsx index ba4c4f0..93d8a07 100644 --- a/src/pages/PublicCardPage.tsx +++ b/src/pages/PublicCardPage.tsx @@ -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: '', diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index d4e0445..17d1393 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -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 { 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 { } // 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 { // ── 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(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() {
+ {/* Identity facets */} +
+ +
+ {[ + { 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 }) => ( + + ))} +
+
+ {/* Skills */}