From ad75120a4351ba643bafb234aba37ebef866c732 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 10:42:26 +0700 Subject: [PATCH] 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. --- AGENTS.md | 6 ++ NIP.md | 9 ++ src/components/RiderCard.tsx | 113 ++++++++++++++++++++++++- src/i18n/en.ts | 14 +++ src/i18n/es.ts | 14 +++ src/pages/RiderCardPage.tsx | 159 +++++++++++++++++++++++++++++++++-- src/pages/VerifyCardPage.tsx | 16 ++-- 7 files changed, 316 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b8d9fb9..f8c6e53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/NIP.md b/NIP.md index d21c101..c8fb67a 100644 --- a/NIP.md +++ b/NIP.md @@ -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/` 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 diff --git a/src/components/RiderCard.tsx b/src/components/RiderCard.tsx index 77eb8fb..352524c 100644 --- a/src/components/RiderCard.tsx +++ b/src/components/RiderCard.tsx @@ -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 ( +
+ + {/* Row 1: header */} +
+
+
+ 🛹 SkateSpots +
+
{t('cardUi.crew.crewCard')}
+
+
+
npub
+
+ {shortNpub(c.pubkey)} +
+ {c.founded &&
{t('cardUi.crew.est', { year: c.founded })}
} +
+
+ + {/* Row 2: main body */} +
+ + {/* Logo + stats */} +
+
+ {c.image ? ( + {c.name} + ) : ( +
🏕️
+ )} +
+
+
+ {t('cardUi.crew.members')} + {c.members} +
+
+ {t('cardUi.crew.spots')} + {c.spots} +
+
+
+ + {/* Identity block */} +
+
{c.name}
+ {c.location && ( +
📍 {c.location}
+ )} + {c.lookingFor.length > 0 && ( + + {t('cardUi.crew.openCall')} + + )} +
+ {c.vibes.slice(0, 5).map(v => ( + + {v} + + ))} +
+
+ + {/* QR */} +
+ +
+ {d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')} +
+
+
+ + {/* Bottom bar */} +
+ + skate-spots.org · Powered by Nostr + + {t('cardUi.crew.verifiedCrew')} +
+
+ ); +} + export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject }) { 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.
{d.type === 'rider' && } {d.type === 'park' && } + {d.type === 'crew' && }
); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6a945ce..1e34007 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 18f864d..dd70fac 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 17d1393..f9d6619 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -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 { 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 { 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('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 }) => (