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:
SkateSpots 2026-10-02 10:35:06 +07:00
parent dbb9ea98c0
commit e348796e05
9 changed files with 255 additions and 24 deletions

View file

@ -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
View file

@ -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

View file

@ -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">

View file

@ -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);
},
});
}

View file

@ -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',

View file

@ -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',

View file

@ -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);
}

View file

@ -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: '',

View file

@ -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">