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:
parent
e348796e05
commit
ad75120a43
7 changed files with 316 additions and 15 deletions
|
|
@ -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,
|
**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
|
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).
|
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
|
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
|
||||||
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
||||||
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page
|
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page
|
||||||
|
|
|
||||||
9
NIP.md
9
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 —
|
(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.
|
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
|
### Verification
|
||||||
|
|
||||||
Both card types are verifiable. The QR on a printed card encodes the `naddr` of
|
Both card types are verifiable. The QR on a printed card encodes the `naddr` of
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export interface BadgeAward {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardData {
|
export interface CardData {
|
||||||
type: 'rider' | 'park';
|
type: 'rider' | 'park' | 'crew';
|
||||||
theme: ThemeId;
|
theme: ThemeId;
|
||||||
holographic: boolean;
|
holographic: boolean;
|
||||||
// Rider
|
// Rider
|
||||||
|
|
@ -47,6 +47,20 @@ export interface CardData {
|
||||||
showCrews?: boolean;
|
showCrews?: boolean;
|
||||||
showLocal?: boolean;
|
showLocal?: boolean;
|
||||||
showRoles?: 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
|
// Park
|
||||||
issuerName: string;
|
issuerName: string;
|
||||||
recipientName: 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> }) {
|
export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject<HTMLDivElement> }) {
|
||||||
const themeClass = `card-theme-${d.theme}`;
|
const themeClass = `card-theme-${d.theme}`;
|
||||||
const holoClass = d.holographic ? 'rider-card-holo' : '';
|
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]" />
|
<div className="card-stripe absolute top-0 left-0 right-0 h-[4px]" />
|
||||||
{d.type === 'rider' && <RiderFront d={d} />}
|
{d.type === 'rider' && <RiderFront d={d} />}
|
||||||
{d.type === 'park' && <ParkFront d={d} />}
|
{d.type === 'park' && <ParkFront d={d} />}
|
||||||
|
{d.type === 'crew' && <CrewFront d={d} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6320,6 +6320,19 @@ walletUi: {
|
||||||
organizer: 'Organizer',
|
organizer: 'Organizer',
|
||||||
barber: 'Barber',
|
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: {
|
verify: {
|
||||||
checking: 'Checking the card…',
|
checking: 'Checking the card…',
|
||||||
notFound: 'Card not found',
|
notFound: 'Card not found',
|
||||||
|
|
@ -6331,6 +6344,7 @@ walletUi: {
|
||||||
kind: 'Type',
|
kind: 'Type',
|
||||||
kindPark: 'Park Access Card',
|
kindPark: 'Park Access Card',
|
||||||
kindRider: 'Rider Card',
|
kindRider: 'Rider Card',
|
||||||
|
kindCrew: 'Crew Card',
|
||||||
issuedBy: 'Issued by',
|
issuedBy: 'Issued by',
|
||||||
accessLevel: 'Access level',
|
accessLevel: 'Access level',
|
||||||
validThru: 'Valid thru',
|
validThru: 'Valid thru',
|
||||||
|
|
|
||||||
|
|
@ -6320,6 +6320,19 @@ walletUi: {
|
||||||
organizer: 'Organizador',
|
organizer: 'Organizador',
|
||||||
barber: 'Barbero',
|
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: {
|
verify: {
|
||||||
checking: 'Comprobando el carné…',
|
checking: 'Comprobando el carné…',
|
||||||
notFound: 'Carné no encontrado',
|
notFound: 'Carné no encontrado',
|
||||||
|
|
@ -6331,6 +6344,7 @@ walletUi: {
|
||||||
kind: 'Tipo',
|
kind: 'Tipo',
|
||||||
kindPark: 'Pase de acceso a parque',
|
kindPark: 'Pase de acceso a parque',
|
||||||
kindRider: 'Carné de rider',
|
kindRider: 'Carné de rider',
|
||||||
|
kindCrew: 'Carné de cuadrilla',
|
||||||
issuedBy: 'Emitido por',
|
issuedBy: 'Emitido por',
|
||||||
accessLevel: 'Nivel de acceso',
|
accessLevel: 'Nivel de acceso',
|
||||||
validThru: 'Válido hasta',
|
validThru: 'Válido hasta',
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,9 @@ import { genUserName } from '@/lib/genUserName';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { cn } from '@/lib/utils';
|
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 { 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 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 ─────────────────────────────────────────────────────
|
// ─── Card canvas renderer ─────────────────────────────────────────────────────
|
||||||
// Draws the card entirely via Canvas 2D API — 100% reliable export,
|
// Draws the card entirely via Canvas 2D API — 100% reliable export,
|
||||||
// no CSS capture, no scaling issues, no background-stripping.
|
// no CSS capture, no scaling issues, no background-stripping.
|
||||||
|
|
@ -57,7 +57,7 @@ const CARD_W = 856;
|
||||||
const CARD_H = 540;
|
const CARD_H = 540;
|
||||||
|
|
||||||
interface CanvasCardData {
|
interface CanvasCardData {
|
||||||
type: 'rider' | 'park';
|
type: 'rider' | 'park' | 'crew';
|
||||||
themeId: string;
|
themeId: string;
|
||||||
name: string;
|
name: string;
|
||||||
nip05?: string;
|
nip05?: string;
|
||||||
|
|
@ -81,6 +81,7 @@ interface CanvasCardData {
|
||||||
local?: string;
|
local?: string;
|
||||||
crews?: string[];
|
crews?: string[];
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
|
crew?: CardData['crew'];
|
||||||
// Park
|
// Park
|
||||||
issuerName: string;
|
issuerName: string;
|
||||||
recipientName: 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(0, 44), RX, npubY);
|
||||||
ctx.fillText(data.npub.slice(44), RX, npubY + 16);
|
ctx.fillText(data.npub.slice(44), RX, npubY + 16);
|
||||||
|
|
||||||
} else {
|
} else if (data.type === 'park') {
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// PARK ACCESS LAYOUT
|
// PARK ACCESS LAYOUT
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -513,6 +514,103 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
||||||
ctx.fillText(data.npub.slice(0, 44), RX, ry);
|
ctx.fillText(data.npub.slice(0, 44), RX, ry);
|
||||||
ry += 20;
|
ry += 20;
|
||||||
ctx.fillText(data.npub.slice(44), RX, ry);
|
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 ───────────────────────────────────────────────────────
|
// ─── 6. Bottom bar ───────────────────────────────────────────────────────
|
||||||
|
|
@ -679,7 +777,7 @@ export default function RiderCardPage() {
|
||||||
const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award'>('card');
|
const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award'>('card');
|
||||||
|
|
||||||
// ── Card state ────────────────────────────────────────────────────────────
|
// ── Card state ────────────────────────────────────────────────────────────
|
||||||
const [cardType, setCardType] = useState<'rider' | 'park'>('rider');
|
const [cardType, setCardType] = useState<'rider' | 'park' | 'crew'>('rider');
|
||||||
const [theme, setTheme] = useState<ThemeId>('thug');
|
const [theme, setTheme] = useState<ThemeId>('thug');
|
||||||
const [holographic, setHolographic] = useState(true);
|
const [holographic, setHolographic] = useState(true);
|
||||||
|
|
||||||
|
|
@ -709,6 +807,8 @@ export default function RiderCardPage() {
|
||||||
// verifier can confirm the issuer really owns the venue (see /verify).
|
// verifier can confirm the issuer really owns the venue (see /verify).
|
||||||
const [venueId, setVenueId] = useState('');
|
const [venueId, setVenueId] = useState('');
|
||||||
const [savedParkD, setSavedParkD] = 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(
|
const myVenues = useMemo(
|
||||||
() => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey),
|
() => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey),
|
||||||
[allSpots, user?.pubkey],
|
[allSpots, user?.pubkey],
|
||||||
|
|
@ -742,13 +842,17 @@ export default function RiderCardPage() {
|
||||||
} catch { return user?.pubkey ?? ''; }
|
} 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(
|
const cardData: CardData = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
type: cardType, theme, holographic,
|
type: cardType, theme, holographic,
|
||||||
name: cardType === 'rider' ? displayName : recipientName,
|
name: cardType === 'rider' ? displayName : recipientName,
|
||||||
nip05: metadata?.nip05,
|
nip05: metadata?.nip05,
|
||||||
avatar: metadata?.picture,
|
avatar: cardType === 'crew' ? activeCrew?.image : metadata?.picture,
|
||||||
pubkey: cardPubkey,
|
pubkey: cardPubkey,
|
||||||
npub: cardPubkey ? fullNpub(cardPubkey) : '',
|
npub: cardPubkey ? fullNpub(cardPubkey) : '',
|
||||||
style, skills, sponsors, ridesFor,
|
style, skills, sponsors, ridesFor,
|
||||||
|
|
@ -766,14 +870,29 @@ export default function RiderCardPage() {
|
||||||
receivedBadges,
|
receivedBadges,
|
||||||
verifyUrl: cardType === 'rider'
|
verifyUrl: cardType === 'rider'
|
||||||
? (user?.pubkey ? cardVerifyUrl(RIDER_CARD_KIND, user.pubkey, RIDER_CARD_D) : undefined)
|
? (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,
|
issuerName, recipientName, accessLevel, validThru,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
cardType, theme, holographic, displayName, recipientName, metadata,
|
cardType, theme, holographic, displayName, recipientName, metadata,
|
||||||
cardPubkey, style, skills, sponsors, ridesFor, showSpots, showZaps,
|
cardPubkey, style, skills, sponsors, ridesFor, showSpots, showZaps,
|
||||||
showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey,
|
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,
|
local: cardData.local,
|
||||||
crews: (cardData.crews ?? []).map(c => c.name),
|
crews: (cardData.crews ?? []).map(c => c.name),
|
||||||
roles: cardData.roles,
|
roles: cardData.roles,
|
||||||
|
crew: cardData.crew,
|
||||||
issuerName: cardData.issuerName, recipientName: cardData.recipientName,
|
issuerName: cardData.issuerName, recipientName: cardData.recipientName,
|
||||||
accessLevel: cardData.accessLevel, validThru: cardData.validThru,
|
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: '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: '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 }) => (
|
] as const).map(({ id, label, sub, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
|
|
@ -1265,6 +1386,26 @@ export default function RiderCardPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { RIDER_CARD_KIND, PARK_ACCESS_CARD_KIND } from '@/lib/riderCard';
|
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';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
interface ParsedNaddr {
|
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')} />
|
<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">
|
<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} />
|
<Row label={t('cardUi.verify.issuedBy')} value={issuerName} />
|
||||||
{event.kind === PARK_ACCESS_CARD_KIND && (
|
{event.kind === PARK_ACCESS_CARD_KIND && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -163,9 +163,15 @@ export default function VerifyCardPage() {
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-2">
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
{parsed && (
|
{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">
|
parsed.kind === SKATE_CREW_KIND ? (
|
||||||
{t('cardUi.verify.viewCard')} <ExternalLink className="h-3.5 w-3.5" />
|
<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">
|
||||||
</Link>
|
{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>
|
<p className="text-[11px] text-gray-600 text-center mt-1">{t('cardUi.verify.disclaimer')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue