feat(card): Crew Card template (derived from the crew's signed event)

- /card gains a third type: Crew. Pick one of your crews and render a printable
  crew card (name, logo, home turf, founded, members, spots, vibes, Open call).
- No new event: the card is derived from the crew's own signed kind 35006, so its
  QR verifies that event (the founder's key IS the signature).
- Rendered in the React card + canvas export; /verify/:naddr now renders kind
  35006 with a link to the crew page.
- i18n cardUi.crew.* / verify.kindCrew; NIP.md + AGENTS.md updated.
This commit is contained in:
SkateSpots 2026-10-02 10:42:26 +07:00
parent e348796e05
commit ad75120a43
7 changed files with 316 additions and 15 deletions

View file

@ -82,6 +82,12 @@ session with **no other memory of this project**. So:
**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).
**Crew Card (2026-10-02):** the `/card` editor has a third type — **Crew** —
which renders a selected crew as a card. **No new event:** it is derived from the
crew's own signed kind `35006`, so its QR verifies that event (the founder's key
is the signature). `/verify/:naddr` renders kind `35006` too. Card fields:
name/logo/home turf/founded/members/spots/vibes + **Open call** when
`lookingFor`.
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page

9
NIP.md
View file

@ -1568,6 +1568,15 @@ author's kind 0; on-chain stats are derived live:
(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.
### Crew Card (derived from the crew's own kind 35006)
There is **no separate crew-card event** — a crew card is a *render of the crew's
own signed event* (kind 35006, addressable). Nothing extra is published: the
founder's key already signed the crew, so the card's QR verifies that event via
`/#/verify/<naddr>` exactly like a pass. The card shows the crew name, logo, home
turf, founded year, member count, home-spot count, vibes, and an **Open call**
badge when the crew is hiring (`lookingFor`). Pick a crew in the `/card` editor.
### Verification
Both card types are verifiable. The QR on a printed card encodes the `naddr` of

View file

@ -17,7 +17,7 @@ export interface BadgeAward {
}
export interface CardData {
type: 'rider' | 'park';
type: 'rider' | 'park' | 'crew';
theme: ThemeId;
holographic: boolean;
// Rider
@ -47,6 +47,20 @@ export interface CardData {
showCrews?: boolean;
showLocal?: boolean;
showRoles?: boolean;
// Crew card (derived from the crew's own signed kind 35006 event)
crew?: {
name: string;
image?: string;
pubkey: string;
identifier: string;
location?: string;
founded?: string;
members: number;
focus?: string;
vibes: string[];
lookingFor: string[];
spots: number;
};
// Park
issuerName: string;
recipientName: string;
@ -305,6 +319,102 @@ function ParkFront({ d }: { d: CardData }) {
);
}
// ─── Card visual: Crew front ─────────────────────────────────────────────────
function CrewFront({ d }: { d: CardData }) {
const { t } = useTranslation();
const c = d.crew;
if (!c) return null;
return (
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
{/* Row 1: header */}
<div className="flex items-start justify-between" style={{ height: '26px' }}>
<div>
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
🛹 SkateSpots
</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('cardUi.crew.crewCard')}</div>
</div>
<div className="text-right">
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
<div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
{shortNpub(c.pubkey)}
</div>
{c.founded && <div className="text-[5px] card-muted mt-[1px]">{t('cardUi.crew.est', { year: c.founded })}</div>}
</div>
</div>
{/* Row 2: main body */}
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
{/* Logo + stats */}
<div className="flex-shrink-0 flex flex-col gap-[4px]">
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{c.image ? (
<img src={c.image} alt={c.name} className="w-full h-full object-cover" crossOrigin="anonymous" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🏕️</div>
)}
</div>
<div className="space-y-[2px]">
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">{t('cardUi.crew.members')}</span>
<span className="text-[7px] font-black card-accent leading-none">{c.members}</span>
</div>
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">{t('cardUi.crew.spots')}</span>
<span className="text-[7px] font-black card-accent2 leading-none">{c.spots}</span>
</div>
</div>
</div>
{/* Identity block */}
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="text-[14px] font-black uppercase truncate leading-tight">{c.name}</div>
{c.location && (
<div className="text-[6.5px] card-accent truncate leading-none">📍 {c.location}</div>
)}
{c.lookingFor.length > 0 && (
<span
className="text-[5px] font-black uppercase px-[4px] py-[1px] rounded-[3px] w-fit leading-none"
style={{ background: 'rgba(204,255,0,0.2)', border: '1px solid rgba(204,255,0,0.5)', color: '#ccff00' }}
>
{t('cardUi.crew.openCall')}
</span>
)}
<div className="flex flex-wrap gap-[2px] mt-[2px]">
{c.vibes.slice(0, 5).map(v => (
<span key={v} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none">
{v}
</span>
))}
</div>
</div>
{/* QR */}
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<CardQr url={d.verifyUrl} />
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
</div>
</div>
</div>
{/* Bottom bar */}
<div
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
style={{ bottom: '6px' }}
>
<span className="card-muted text-[5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<span className="card-muted text-[5px] uppercase tracking-widest">{t('cardUi.crew.verifiedCrew')}</span>
</div>
</div>
);
}
export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject<HTMLDivElement> }) {
const themeClass = `card-theme-${d.theme}`;
const holoClass = d.holographic ? 'rider-card-holo' : '';
@ -318,6 +428,7 @@ export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.
<div className="card-stripe absolute top-0 left-0 right-0 h-[4px]" />
{d.type === 'rider' && <RiderFront d={d} />}
{d.type === 'park' && <ParkFront d={d} />}
{d.type === 'crew' && <CrewFront d={d} />}
</div>
);
}

View file

@ -6320,6 +6320,19 @@ walletUi: {
organizer: 'Organizer',
barber: 'Barber',
},
crew: {
crewCard: 'CREW CARD',
crewSub: 'Your crew, on a card',
pick: 'Choose a crew',
none: 'You are not in a crew yet — join or start one first.',
hint: 'The card is built from the crew’s own signed data. Edit the crew on its page to change it.',
members: 'MEMBERS',
spots: 'SPOTS',
est: 'Est. {{year}}',
openCall: 'Open call',
verifiedCrew: 'Verified crew',
viewCrew: 'View the crew',
},
verify: {
checking: 'Checking the card…',
notFound: 'Card not found',
@ -6331,6 +6344,7 @@ walletUi: {
kind: 'Type',
kindPark: 'Park Access Card',
kindRider: 'Rider Card',
kindCrew: 'Crew Card',
issuedBy: 'Issued by',
accessLevel: 'Access level',
validThru: 'Valid thru',

View file

@ -6320,6 +6320,19 @@ walletUi: {
organizer: 'Organizador',
barber: 'Barbero',
},
crew: {
crewCard: 'CARNÉ DE CUADRILLA',
crewSub: 'Tu cuadrilla, en un carné',
pick: 'Elige una cuadrilla',
none: 'Aún no estás en ninguna cuadrilla — únete a una o crea la tuya.',
hint: 'El carné se construye con los datos firmados de la cuadrilla. Edita la cuadrilla en su página para cambiarlo.',
members: 'MIEMBROS',
spots: 'SPOTS',
est: 'Desde {{year}}',
openCall: 'Convocatoria abierta',
verifiedCrew: 'Cuadrilla verificada',
viewCrew: 'Ver la cuadrilla',
},
verify: {
checking: 'Comprobando el carné…',
notFound: 'Carné no encontrado',
@ -6331,6 +6344,7 @@ walletUi: {
kind: 'Tipo',
kindPark: 'Pase de acceso a parque',
kindRider: 'Carné de rider',
kindCrew: 'Carné de cuadrilla',
issuedBy: 'Emitido por',
accessLevel: 'Nivel de acceso',
validThru: 'Válido hasta',

View file

@ -45,9 +45,9 @@ import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { RIDER_CARD_KIND, RIDER_CARD_D, PARK_ACCESS_CARD_KIND, SKILL_BADGE_KIND, cardVerifyUrl, riderCardTags, type RiderCardConfig } from '@/lib/riderCard';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { SKATE_SPOT_KIND, SKATE_CREW_KIND } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2 } from 'lucide-react';
import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users } from 'lucide-react';
// ─── Card canvas renderer ─────────────────────────────────────────────────────
// Draws the card entirely via Canvas 2D API — 100% reliable export,
// no CSS capture, no scaling issues, no background-stripping.
@ -57,7 +57,7 @@ const CARD_W = 856;
const CARD_H = 540;
interface CanvasCardData {
type: 'rider' | 'park';
type: 'rider' | 'park' | 'crew';
themeId: string;
name: string;
nip05?: string;
@ -81,6 +81,7 @@ interface CanvasCardData {
local?: string;
crews?: string[];
roles?: string[];
crew?: CardData['crew'];
// Park
issuerName: string;
recipientName: string;
@ -428,7 +429,7 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
ctx.fillText(data.npub.slice(0, 44), RX, npubY);
ctx.fillText(data.npub.slice(44), RX, npubY + 16);
} else {
} else if (data.type === 'park') {
// ═══════════════════════════════════════════════════════════════════════
// PARK ACCESS LAYOUT
// ═══════════════════════════════════════════════════════════════════════
@ -513,6 +514,103 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
ctx.fillText(data.npub.slice(0, 44), RX, ry);
ry += 20;
ctx.fillText(data.npub.slice(44), RX, ry);
} else {
// ═══════════════════════════════════════════════════════════════════════
// CREW LAYOUT
// ═══════════════════════════════════════════════════════════════════════
const c = data.crew;
const avSize = 176;
const avX = (SIDEBAR_W - avSize) / 2;
const avY = 22;
roundRect(ctx, avX, avY, avSize, avSize, 14);
ctx.fillStyle = p.chipBg;
ctx.fill();
if (avatarImg) {
ctx.save();
roundRect(ctx, avX, avY, avSize, avSize, 14);
ctx.clip();
ctx.drawImage(avatarImg, avX, avY, avSize, avSize);
ctx.restore();
} else {
ctx.fillStyle = p.accent;
ctx.font = '80px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('🏕️', SIDEBAR_W / 2, avY + 118);
ctx.textAlign = 'left';
}
// Left: members + spots
ctx.textAlign = 'center';
ctx.fillStyle = p.muted;
ctx.font = '12px system-ui, sans-serif';
ctx.fillText('MEMBERS', SIDEBAR_W / 4 + 8, avY + avSize + 32);
ctx.fillText('SPOTS', (SIDEBAR_W * 3) / 4 - 8, avY + avSize + 32);
ctx.fillStyle = p.accent;
ctx.font = 'bold 34px system-ui, sans-serif';
ctx.fillText(String(c?.members ?? 0), SIDEBAR_W / 4 + 8, avY + avSize + 68);
ctx.fillStyle = p.accent2;
ctx.fillText(String(c?.spots ?? 0), (SIDEBAR_W * 3) / 4 - 8, avY + avSize + 68);
ctx.textAlign = 'left';
// Right zone
const RX = SIDEBAR_W + 28;
const RMaxW = qrX - RX - 20;
let ry = 52;
ctx.fillStyle = p.accent;
ctx.font = 'bold 14px system-ui, sans-serif';
ctx.fillText('🛹 SKATESPOTS', RX, ry);
ry += 6;
ctx.fillStyle = p.muted;
ctx.font = '12px system-ui, sans-serif';
ctx.fillText('CREW CARD', RX, ry + 14);
ry += 30;
ctx.fillStyle = p.text;
ctx.font = 'bold 46px system-ui, sans-serif';
fillTextFit(ctx, (c?.name ?? '').toUpperCase(), RX, ry + 44, RMaxW);
ry += 56;
if (c?.location) {
ctx.fillStyle = p.accent;
ctx.font = 'bold 18px system-ui, sans-serif';
fillTextFit(ctx, `📍 ${c.location}`, RX, ry, RMaxW);
ry += 24;
}
if (c?.founded) {
ctx.fillStyle = p.muted;
ctx.font = '13px system-ui, sans-serif';
ctx.fillText(`EST. ${c.founded}`, RX, ry);
ry += 22;
}
if (c && c.lookingFor.length > 0) {
ctx.fillStyle = p.accent2;
ctx.font = 'bold 15px system-ui, sans-serif';
ctx.fillText('OPEN CALL', RX, ry);
ry += 24;
}
if (c && c.vibes.length > 0) {
let chipX = RX;
let chipY = ry + 4;
ctx.font = 'bold 14px system-ui, sans-serif';
c.vibes.slice(0, 5).forEach(v => {
const label = v.toUpperCase();
const chipW = ctx.measureText(label).width + 18;
if (chipX + chipW > RX + RMaxW) { chipX = RX; chipY += 28; }
roundRect(ctx, chipX, chipY, chipW, 22, 5);
ctx.fillStyle = p.chipBg;
ctx.fill();
ctx.strokeStyle = p.chipBorder;
ctx.lineWidth = 0.8;
ctx.stroke();
ctx.fillStyle = p.accent;
ctx.fillText(label, chipX + 9, chipY + 16);
chipX += chipW + 6;
});
}
// Full npub
ctx.fillStyle = p.muted;
ctx.font = '12px monospace';
ctx.fillText(data.npub.slice(0, 44), RX, CARD_H - 64);
ctx.fillText(data.npub.slice(44), RX, CARD_H - 48);
}
// ─── 6. Bottom bar ───────────────────────────────────────────────────────
@ -679,7 +777,7 @@ export default function RiderCardPage() {
const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award'>('card');
// ── Card state ────────────────────────────────────────────────────────────
const [cardType, setCardType] = useState<'rider' | 'park'>('rider');
const [cardType, setCardType] = useState<'rider' | 'park' | 'crew'>('rider');
const [theme, setTheme] = useState<ThemeId>('thug');
const [holographic, setHolographic] = useState(true);
@ -709,6 +807,8 @@ export default function RiderCardPage() {
// verifier can confirm the issuer really owns the venue (see /verify).
const [venueId, setVenueId] = useState('');
const [savedParkD, setSavedParkD] = useState('');
const [crewId, setCrewId] = useState('');
const activeCrew = memberships.find(m => m.crew.id === crewId)?.crew ?? memberships[0]?.crew;
const myVenues = useMemo(
() => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey),
[allSpots, user?.pubkey],
@ -742,13 +842,17 @@ export default function RiderCardPage() {
} catch { return user?.pubkey ?? ''; }
})();
const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey;
const cardPubkey = cardType === 'rider'
? (user?.pubkey ?? '')
: cardType === 'park'
? recipientPubkey
: (activeCrew?.pubkey ?? user?.pubkey ?? '');
const cardData: CardData = useMemo(
() => ({
type: cardType, theme, holographic,
name: cardType === 'rider' ? displayName : recipientName,
nip05: metadata?.nip05,
avatar: metadata?.picture,
avatar: cardType === 'crew' ? activeCrew?.image : metadata?.picture,
pubkey: cardPubkey,
npub: cardPubkey ? fullNpub(cardPubkey) : '',
style, skills, sponsors, ridesFor,
@ -766,14 +870,29 @@ export default function RiderCardPage() {
receivedBadges,
verifyUrl: cardType === 'rider'
? (user?.pubkey ? cardVerifyUrl(RIDER_CARD_KIND, user.pubkey, RIDER_CARD_D) : undefined)
: (savedParkD && user?.pubkey ? cardVerifyUrl(PARK_ACCESS_CARD_KIND, user.pubkey, savedParkD) : undefined),
: cardType === 'park'
? (savedParkD && user?.pubkey ? cardVerifyUrl(PARK_ACCESS_CARD_KIND, user.pubkey, savedParkD) : undefined)
: (activeCrew ? cardVerifyUrl(SKATE_CREW_KIND, activeCrew.pubkey, activeCrew.identifier) : undefined),
crew: cardType === 'crew' && activeCrew ? {
name: activeCrew.name,
image: activeCrew.image,
pubkey: activeCrew.pubkey,
identifier: activeCrew.identifier,
location: activeCrew.location,
founded: activeCrew.founded,
members: activeCrew.members.length,
focus: activeCrew.focus,
vibes: activeCrew.vibes,
lookingFor: activeCrew.lookingFor,
spots: activeCrew.spotRefs.length,
} : undefined,
issuerName, recipientName, accessLevel, validThru,
}),
[
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,
crewCrests, local, roles, showCrews, showLocal, showRoles, activeCrew,
],
);
@ -793,6 +912,7 @@ export default function RiderCardPage() {
local: cardData.local,
crews: (cardData.crews ?? []).map(c => c.name),
roles: cardData.roles,
crew: cardData.crew,
issuerName: cardData.issuerName, recipientName: cardData.recipientName,
accessLevel: cardData.accessLevel, validThru: cardData.validThru,
});
@ -1015,6 +1135,7 @@ export default function RiderCardPage() {
{([
{ id: 'rider', label: t('labUi.rider.licenseLabel'), sub: t('labUi.rider.licenseSub'), icon: User },
{ id: 'park', label: t('labUi.rider.parkLabel'), sub: t('labUi.rider.parkSub'), icon: Building2 },
{ id: 'crew', label: t('cardUi.crew.crewCard'), sub: t('cardUi.crew.crewSub'), icon: Users },
] as const).map(({ id, label, sub, icon: Icon }) => (
<button
key={id}
@ -1265,6 +1386,26 @@ export default function RiderCardPage() {
</Button>
</div>
)}
{/* ── CREW fields ── */}
{cardType === 'crew' && (
<div className="space-y-5">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('cardUi.crew.pick')}</p>
{memberships.length > 0 ? (
<Select value={activeCrew?.id ?? ''} onValueChange={setCrewId}>
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger>
<SelectContent>
{memberships.map(m => (
<SelectItem key={m.crew.id} value={m.crew.id}>{m.crew.name} · {m.role}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-xs text-gray-500">{t('cardUi.crew.none')}</p>
)}
<p className="text-[10px] text-gray-600">{t('cardUi.crew.hint')}</p>
</div>
)}
</div>
)}

View file

@ -18,7 +18,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { RIDER_CARD_KIND, PARK_ACCESS_CARD_KIND } from '@/lib/riderCard';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { SKATE_SPOT_KIND, SKATE_CREW_KIND } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
interface ParsedNaddr {
@ -127,7 +127,7 @@ export default function VerifyCardPage() {
<StatusCard tone="good" icon={<BadgeCheck className="h-10 w-10" />} title={t('cardUi.verify.authentic')} body={t('cardUi.verify.authenticDesc')} />
<div className="mt-5 rounded-2xl border border-white/10 bg-black/40 divide-y divide-white/5">
<Row label={t('cardUi.verify.kind')} value={event.kind === PARK_ACCESS_CARD_KIND ? t('cardUi.verify.kindPark') : t('cardUi.verify.kindRider')} />
<Row label={t('cardUi.verify.kind')} value={event.kind === PARK_ACCESS_CARD_KIND ? t('cardUi.verify.kindPark') : event.kind === SKATE_CREW_KIND ? t('cardUi.verify.kindCrew') : t('cardUi.verify.kindRider')} />
<Row label={t('cardUi.verify.issuedBy')} value={issuerName} />
{event.kind === PARK_ACCESS_CARD_KIND && (
<>
@ -163,9 +163,15 @@ export default function VerifyCardPage() {
<div className="mt-6 flex flex-col gap-2">
{parsed && (
<Link to={`/card/${nip19.npubEncode(parsed.pubkey)}`} className="text-center text-sm text-[#ccff00] hover:underline inline-flex items-center justify-center gap-1.5">
{t('cardUi.verify.viewCard')} <ExternalLink className="h-3.5 w-3.5" />
</Link>
parsed.kind === SKATE_CREW_KIND ? (
<Link to={`/crew/${parsed.pubkey}/${parsed.identifier}`} className="text-center text-sm text-[#ccff00] hover:underline inline-flex items-center justify-center gap-1.5">
{t('cardUi.crew.viewCrew')} <ExternalLink className="h-3.5 w-3.5" />
</Link>
) : (
<Link to={`/card/${nip19.npubEncode(parsed.pubkey)}`} className="text-center text-sm text-[#ccff00] hover:underline inline-flex items-center justify-center gap-1.5">
{t('cardUi.verify.viewCard')} <ExternalLink className="h-3.5 w-3.5" />
</Link>
)
)}
<p className="text-[11px] text-gray-600 text-center mt-1">{t('cardUi.verify.disclaimer')}</p>
</div>