feat(card,review): Event pass/wristband template + Claims tab in /review
- /card gains a fourth type: Event. Renders an event (kind 31923) you organise or RSVP'd going/interested to (useMyRsvpCoords, kind 31925) as a passport/ wristband: title, date/time, venue, cover, who it admits, going count. Derived from the event's own signed event — its QR verifies that event; /verify/:naddr renders kind 31923 too. React + canvas renderers; i18n. - /review gains a dedicated Claims tab (pending private-spot ownership claims), replacing the banner that sat above the tabs.
This commit is contained in:
parent
411f6e219a
commit
7cafcc0dfa
10 changed files with 312 additions and 18 deletions
|
|
@ -88,6 +88,13 @@ session with **no other memory of this project**. So:
|
|||
is the signature). `/verify/:naddr` renders kind `35006` too. Card fields:
|
||||
name/logo/home turf/founded/members/spots/vibes + **Open call** when
|
||||
`lookingFor`.
|
||||
**Event pass/wristband (2026-10-02):** a fourth `/card` type — **Event** —
|
||||
renders an event (`kind 31923`) you organise **or RSVP'd going/interested** to
|
||||
(`useMyRsvpCoords`, kind 31925). Also derived (the organiser's key signs it),
|
||||
so the QR verifies that event; `/verify/:naddr` renders kind `31923` too.
|
||||
Shows title/date/venue/cover + **who it admits** + going count.
|
||||
**Claims tab (2026-10-02):** `/review` has a **Claims** tab (pending
|
||||
private-spot ownership claims; was a banner above the tabs).
|
||||
- **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
|
||||
|
|
|
|||
10
NIP.md
10
NIP.md
|
|
@ -1577,6 +1577,16 @@ founder's key already signed the crew, so the card's QR verifies that event via
|
|||
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.
|
||||
|
||||
### Event pass / wristband (derived from the event's own kind 31923)
|
||||
|
||||
Same pattern: **no new event.** A pass is a render of the calendar event
|
||||
(`kind 31923`, addressable) that the organiser already signed, so its QR verifies
|
||||
that event via `/#/verify/<naddr>`. The `/card` editor's **Event** type lists
|
||||
events you organise **or have RSVP'd going/interested to** (`useMyRsvpCoords`,
|
||||
kind 31925), and the pass shows the event title, date/time, venue, cover image,
|
||||
**who it admits** (your display name) and the going count. `/#/verify/:naddr`
|
||||
renders kind `31923` too and links to the event page.
|
||||
|
||||
### Verification
|
||||
|
||||
Both card types are verifiable. The QR on a printed card encodes the `naddr` of
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* (the canvas export in `RiderCardPage` remains the print renderer).
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SKILL_OPTIONS, shortNpub, roleMeta, type ThemeId } from '@/lib/riderCard';
|
||||
import { SKILL_OPTIONS, shortNpub, roleMeta, formatCardDate, type ThemeId } from '@/lib/riderCard';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ export interface BadgeAward {
|
|||
}
|
||||
|
||||
export interface CardData {
|
||||
type: 'rider' | 'park' | 'crew';
|
||||
type: 'rider' | 'park' | 'crew' | 'event';
|
||||
theme: ThemeId;
|
||||
holographic: boolean;
|
||||
// Rider
|
||||
|
|
@ -66,6 +66,18 @@ export interface CardData {
|
|||
recipientName: string;
|
||||
accessLevel: string;
|
||||
validThru: string;
|
||||
// Event pass / wristband (derived from the event's own signed kind 31923)
|
||||
event?: {
|
||||
title: string;
|
||||
start: number;
|
||||
end?: number;
|
||||
location?: string;
|
||||
image?: string;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
going?: number;
|
||||
holder?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Real QR (lazy-imported so canvas API access stays out of module load). */
|
||||
|
|
@ -415,6 +427,78 @@ function CrewFront({ d }: { d: CardData }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Card visual: Event pass / wristband ─────────────────────────────────────
|
||||
function EventFront({ d }: { d: CardData }) {
|
||||
const { t } = useTranslation();
|
||||
const e = d.event;
|
||||
if (!e) return null;
|
||||
const dateText = formatCardDate(e.start, e.end);
|
||||
|
||||
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.event.pass')}</div>
|
||||
</div>
|
||||
<div className="text-right min-w-0">
|
||||
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">{t('cardUi.event.admits')}</div>
|
||||
<div className="text-[7px] font-black card-accent uppercase leading-none mt-[1px] truncate" style={{ maxWidth: '120px' }}>
|
||||
{e.holder || d.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: main body */}
|
||||
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
|
||||
{e.image ? (
|
||||
<img src={e.image} alt="" 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>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
|
||||
<div className="text-[14px] font-black uppercase truncate leading-tight">{e.title}</div>
|
||||
<div className="text-[6.5px] card-accent truncate leading-none">🗓️ {dateText}</div>
|
||||
{e.location && (
|
||||
<div className="text-[5.5px] card-muted truncate leading-none">📍 {e.location}</div>
|
||||
)}
|
||||
{typeof e.going === 'number' && e.going > 0 && (
|
||||
<div className="text-[5.5px] card-muted leading-none">{t('cardUi.event.going', { count: e.going })}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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.event.verifiedEvent')}</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' : '';
|
||||
|
|
@ -429,6 +513,7 @@ export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.
|
|||
{d.type === 'rider' && <RiderFront d={d} />}
|
||||
{d.type === 'park' && <ParkFront d={d} />}
|
||||
{d.type === 'crew' && <CrewFront d={d} />}
|
||||
{d.type === 'event' && <EventFront d={d} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,35 @@ export function eventCoord(event: Pick<SkateEvent, 'pubkey' | 'identifier'>): st
|
|||
return `${CALENDAR_EVENT_KIND}:${event.pubkey}:${event.identifier}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates of calendar events the user RSVP'd (going or interested) — powers
|
||||
* the "event pass / wristband" card picker. Declined RSVPs are excluded.
|
||||
*/
|
||||
export function useMyRsvpCoords(pubkey?: string) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
return useQuery<string[]>({
|
||||
queryKey: ['my-rsvp-coords', pubkey, getQueryKeySuffix()],
|
||||
enabled: !!pubkey,
|
||||
staleTime: 60_000,
|
||||
queryFn: async () => {
|
||||
if (!pubkey) return [];
|
||||
const signal = AbortSignal.timeout(8000);
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [CALENDAR_RSVP_KIND], authors: [pubkey], limit: 200 }],
|
||||
{ signal },
|
||||
);
|
||||
const coords = new Set<string>();
|
||||
for (const ev of events) {
|
||||
const status = ev.tags.find(([n]) => n === 'status')?.[1] ?? 'going';
|
||||
if (status === 'declined') continue;
|
||||
const a = ev.tags.find(([n]) => n === 'a')?.[1];
|
||||
if (a) coords.add(a);
|
||||
}
|
||||
return [...coords];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEventRsvps(coord: string | undefined): EventRsvpSummary {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const { user } = useCurrentUser();
|
||||
|
|
|
|||
|
|
@ -6196,6 +6196,7 @@ walletUi: {
|
|||
tabGear: 'Gear',
|
||||
tabCrews: 'Crews',
|
||||
tabLab: 'Lab',
|
||||
tabClaims: 'Claims',
|
||||
nothingToReview: 'Nothing to review — nothing of this kind trips the junk signals.',
|
||||
hideActioned: 'Hide actioned',
|
||||
showActioned: 'Show actioned ({{count}})',
|
||||
|
|
@ -6361,6 +6362,17 @@ walletUi: {
|
|||
verifiedCrew: 'Verified crew',
|
||||
viewCrew: 'View the crew',
|
||||
},
|
||||
event: {
|
||||
pass: 'EVENT PASS',
|
||||
passSub: 'A pass for an event you’re going to',
|
||||
pick: 'Choose an event',
|
||||
none: 'No events yet — create one, or RSVP to one, first.',
|
||||
hint: 'The pass is built from the event’s own signed data. Only the organiser can change it.',
|
||||
admits: 'ADMITS',
|
||||
going: '{{count}} going',
|
||||
verifiedEvent: 'Verified event',
|
||||
viewEvent: 'View the event',
|
||||
},
|
||||
verify: {
|
||||
checking: 'Checking the card…',
|
||||
notFound: 'Card not found',
|
||||
|
|
@ -6373,6 +6385,7 @@ walletUi: {
|
|||
kindPark: 'Park Access Card',
|
||||
kindRider: 'Rider Card',
|
||||
kindCrew: 'Crew Card',
|
||||
kindEvent: 'Event pass',
|
||||
issuedBy: 'Issued by',
|
||||
accessLevel: 'Access level',
|
||||
validThru: 'Valid thru',
|
||||
|
|
|
|||
|
|
@ -6196,6 +6196,7 @@ walletUi: {
|
|||
tabGear: 'Material',
|
||||
tabCrews: 'Crews',
|
||||
tabLab: 'Lab',
|
||||
tabClaims: 'Reclamaciones',
|
||||
nothingToReview: 'Nada que revisar — nada de este tipo activa las señales de basura.',
|
||||
hideActioned: 'Ocultar gestionados',
|
||||
showActioned: 'Mostrar gestionados ({{count}})',
|
||||
|
|
@ -6361,6 +6362,17 @@ walletUi: {
|
|||
verifiedCrew: 'Cuadrilla verificada',
|
||||
viewCrew: 'Ver la cuadrilla',
|
||||
},
|
||||
event: {
|
||||
pass: 'PASE DE EVENTO',
|
||||
passSub: 'Un pase para un evento al que vas',
|
||||
pick: 'Elige un evento',
|
||||
none: 'Aún no hay eventos — crea uno o confirma tu asistencia a uno primero.',
|
||||
hint: 'El pase se construye con los datos firmados del evento. Solo el organizador puede cambiarlo.',
|
||||
admits: 'ADMITE A',
|
||||
going: '{{count}} van',
|
||||
verifiedEvent: 'Evento verificado',
|
||||
viewEvent: 'Ver el evento',
|
||||
},
|
||||
verify: {
|
||||
checking: 'Comprobando el carné…',
|
||||
notFound: 'Carné no encontrado',
|
||||
|
|
@ -6373,6 +6385,7 @@ walletUi: {
|
|||
kindPark: 'Pase de acceso a parque',
|
||||
kindRider: 'Carné de rider',
|
||||
kindCrew: 'Carné de cuadrilla',
|
||||
kindEvent: 'Pase de evento',
|
||||
issuedBy: 'Emitido por',
|
||||
accessLevel: 'Nivel de acceso',
|
||||
validThru: 'Válido hasta',
|
||||
|
|
|
|||
|
|
@ -179,3 +179,15 @@ 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);
|
||||
}
|
||||
|
||||
/** Short, locale-aware date (+ time range) for the event pass. */
|
||||
export function formatCardDate(start: number, end?: number): string {
|
||||
const s = new Date(start * 1000);
|
||||
const date = s.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const time = s.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
if (end && end > start) {
|
||||
const e = new Date(end * 1000);
|
||||
return `${date} · ${time}–${e.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
return `${date} · ${time}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ import NotFound from '@/pages/NotFound';
|
|||
* hypotheses — a human decides.
|
||||
*/
|
||||
|
||||
type QueueKind = 'reports' | 'spots' | 'shops' | 'events' | 'tours' | 'gear' | 'crews' | 'lab';
|
||||
type QueueKind = 'reports' | 'spots' | 'shops' | 'events' | 'tours' | 'gear' | 'crews' | 'lab' | 'claims';
|
||||
|
||||
const TABS: Array<{ id: QueueKind; labelKey: string }> = [
|
||||
{ id: 'reports', labelKey: 'walletUi.admin.tabReports' },
|
||||
|
|
@ -86,6 +86,7 @@ const TABS: Array<{ id: QueueKind; labelKey: string }> = [
|
|||
{ id: 'gear', labelKey: 'walletUi.admin.tabGear' },
|
||||
{ id: 'crews', labelKey: 'walletUi.admin.tabCrews' },
|
||||
{ id: 'lab', labelKey: 'walletUi.admin.tabLab' },
|
||||
{ id: 'claims', labelKey: 'walletUi.admin.tabClaims' },
|
||||
];
|
||||
|
||||
/** One reviewable item, flattened from whatever kind it came from. */
|
||||
|
|
@ -667,7 +668,9 @@ function SpotClaimsQueue() {
|
|||
const { toast } = useToast();
|
||||
|
||||
const pending = claims.filter((c) => !approvals?.get(c.coord)?.has(c.claimant.toLowerCase()));
|
||||
if (pending.length === 0) return null;
|
||||
if (pending.length === 0) {
|
||||
return <p className="text-sm text-gray-500">{t('walletUi.admin.nothingToReview')}</p>;
|
||||
}
|
||||
|
||||
const approve = async (coord: string, claimant: string) => {
|
||||
try {
|
||||
|
|
@ -728,8 +731,6 @@ export default function AdminReviewPage() { const { t } = useTranslation();
|
|||
subtitle={t('walletUi.admin.subtitle')}
|
||||
/>
|
||||
|
||||
<SpotClaimsQueue />
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
|
|
@ -756,6 +757,7 @@ export default function AdminReviewPage() { const { t } = useTranslation();
|
|||
{kind === 'gear' && <GearQueue />}
|
||||
{kind === 'crews' && <CrewsQueue />}
|
||||
{kind === 'lab' && <LabQueue />}
|
||||
{kind === 'claims' && <SpotClaimsQueue />}
|
||||
|
||||
<p className="mt-6 text-[11px] leading-relaxed text-gray-600">
|
||||
<strong className="text-gray-500">Hide</strong> publishes{' '}
|
||||
|
|
|
|||
|
|
@ -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, roleMeta, type ThemeId } from '@/lib/riderCard';
|
||||
import { THEMES, SKILL_OPTIONS, STYLES, fullNpub, memberSince, roleMeta, formatCardDate, type ThemeId } from '@/lib/riderCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
|
@ -37,6 +37,8 @@ import { useRiderCard, useRiderRoles } from '@/hooks/useRiderCard';
|
|||
import { useUserCrews } from '@/hooks/useCrews';
|
||||
import { useUserSkaterProfile } from '@/hooks/useSkaters';
|
||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { useSkateEvents } from '@/hooks/useSkateEvents';
|
||||
import { useMyRsvpCoords, useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useNostr } from '@/hooks/useNostr';
|
||||
|
|
@ -45,9 +47,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, SKATE_CREW_KIND } from '@/lib/types';
|
||||
import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_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, Users } from 'lucide-react';
|
||||
import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users, Ticket } 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 +59,7 @@ const CARD_W = 856;
|
|||
const CARD_H = 540;
|
||||
|
||||
interface CanvasCardData {
|
||||
type: 'rider' | 'park' | 'crew';
|
||||
type: 'rider' | 'park' | 'crew' | 'event';
|
||||
themeId: string;
|
||||
name: string;
|
||||
nip05?: string;
|
||||
|
|
@ -82,6 +84,7 @@ interface CanvasCardData {
|
|||
crews?: string[];
|
||||
roles?: string[];
|
||||
crew?: CardData['crew'];
|
||||
event?: CardData['event'];
|
||||
// Park
|
||||
issuerName: string;
|
||||
recipientName: string;
|
||||
|
|
@ -514,7 +517,7 @@ 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 {
|
||||
} else if (data.type === 'crew') {
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// CREW LAYOUT
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -606,6 +609,74 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
|||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
} else {
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// EVENT PASS LAYOUT
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
const e = data.event;
|
||||
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';
|
||||
}
|
||||
|
||||
// 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('EVENT PASS', RX, ry + 14);
|
||||
ry += 30;
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 44px system-ui, sans-serif';
|
||||
fillTextFit(ctx, (e?.title ?? '').toUpperCase(), RX, ry + 42, RMaxW);
|
||||
ry += 54;
|
||||
if (e) {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 18px system-ui, sans-serif';
|
||||
fillTextFit(ctx, formatCardDate(e.start, e.end), RX, ry, RMaxW);
|
||||
ry += 26;
|
||||
if (e.location) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '15px system-ui, sans-serif';
|
||||
fillTextFit(ctx, `📍 ${e.location}`, RX, ry, RMaxW);
|
||||
ry += 24;
|
||||
}
|
||||
}
|
||||
// Holder
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '13px system-ui, sans-serif';
|
||||
ctx.fillText('ADMITS', RX, ry);
|
||||
ry += 4;
|
||||
ctx.fillStyle = p.accent2;
|
||||
ctx.font = 'bold 22px system-ui, sans-serif';
|
||||
fillTextFit(ctx, e?.holder ?? '', RX, ry + 20, RMaxW);
|
||||
ry += 34;
|
||||
|
||||
// Full npub
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '12px monospace';
|
||||
|
|
@ -777,7 +848,7 @@ export default function RiderCardPage() {
|
|||
const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award'>('card');
|
||||
|
||||
// ── Card state ────────────────────────────────────────────────────────────
|
||||
const [cardType, setCardType] = useState<'rider' | 'park' | 'crew'>('rider');
|
||||
const [cardType, setCardType] = useState<'rider' | 'park' | 'crew' | 'event'>('rider');
|
||||
const [theme, setTheme] = useState<ThemeId>('thug');
|
||||
const [holographic, setHolographic] = useState(true);
|
||||
|
||||
|
|
@ -809,6 +880,17 @@ export default function RiderCardPage() {
|
|||
const [savedParkD, setSavedParkD] = useState('');
|
||||
const [crewId, setCrewId] = useState('');
|
||||
const activeCrew = memberships.find(m => m.crew.id === crewId)?.crew ?? memberships[0]?.crew;
|
||||
|
||||
// Event pass / wristband: events you organize, or that you RSVP'd going to.
|
||||
const [eventId, setEventId] = useState('');
|
||||
const { data: allEvents } = useSkateEvents(200);
|
||||
const { data: rsvpCoords = [] } = useMyRsvpCoords(user?.pubkey);
|
||||
const myEvents = useMemo(() => {
|
||||
const rsvpSet = new Set(rsvpCoords);
|
||||
return (allEvents ?? []).filter(ev => ev.pubkey === user?.pubkey || rsvpSet.has(eventCoord(ev)));
|
||||
}, [allEvents, user?.pubkey, rsvpCoords]);
|
||||
const activeEvent = myEvents.find(ev => ev.id === eventId) ?? myEvents[0];
|
||||
const eventRsvp = useEventRsvps(cardType === 'event' && activeEvent ? eventCoord(activeEvent) : undefined);
|
||||
const myVenues = useMemo(
|
||||
() => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey),
|
||||
[allSpots, user?.pubkey],
|
||||
|
|
@ -846,13 +928,15 @@ export default function RiderCardPage() {
|
|||
? (user?.pubkey ?? '')
|
||||
: cardType === 'park'
|
||||
? recipientPubkey
|
||||
: (activeCrew?.pubkey ?? user?.pubkey ?? '');
|
||||
: cardType === 'crew'
|
||||
? (activeCrew?.pubkey ?? user?.pubkey ?? '')
|
||||
: (activeEvent?.pubkey ?? '');
|
||||
const cardData: CardData = useMemo(
|
||||
() => ({
|
||||
type: cardType, theme, holographic,
|
||||
name: cardType === 'rider' ? displayName : recipientName,
|
||||
nip05: metadata?.nip05,
|
||||
avatar: cardType === 'crew' ? activeCrew?.image : metadata?.picture,
|
||||
avatar: cardType === 'crew' ? activeCrew?.image : cardType === 'event' ? activeEvent?.image : metadata?.picture,
|
||||
pubkey: cardPubkey,
|
||||
npub: cardPubkey ? fullNpub(cardPubkey) : '',
|
||||
style, skills, sponsors, ridesFor,
|
||||
|
|
@ -872,7 +956,9 @@ export default function RiderCardPage() {
|
|||
? (user?.pubkey ? cardVerifyUrl(RIDER_CARD_KIND, user.pubkey, RIDER_CARD_D) : 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),
|
||||
: cardType === 'crew'
|
||||
? (activeCrew ? cardVerifyUrl(SKATE_CREW_KIND, activeCrew.pubkey, activeCrew.identifier) : undefined)
|
||||
: (activeEvent ? cardVerifyUrl(CALENDAR_EVENT_KIND, activeEvent.pubkey, activeEvent.identifier) : undefined),
|
||||
crew: cardType === 'crew' && activeCrew ? {
|
||||
name: activeCrew.name,
|
||||
image: activeCrew.image,
|
||||
|
|
@ -886,13 +972,24 @@ export default function RiderCardPage() {
|
|||
lookingFor: activeCrew.lookingFor,
|
||||
spots: activeCrew.spotRefs.length,
|
||||
} : undefined,
|
||||
event: cardType === 'event' && activeEvent ? {
|
||||
title: activeEvent.title,
|
||||
start: activeEvent.start,
|
||||
end: activeEvent.end,
|
||||
location: activeEvent.location,
|
||||
image: activeEvent.image,
|
||||
pubkey: activeEvent.pubkey,
|
||||
identifier: activeEvent.identifier,
|
||||
going: eventRsvp.goingCount,
|
||||
holder: displayName,
|
||||
} : 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, activeCrew,
|
||||
crewCrests, local, roles, showCrews, showLocal, showRoles, activeCrew, activeEvent, eventRsvp.goingCount,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -913,6 +1010,7 @@ export default function RiderCardPage() {
|
|||
crews: (cardData.crews ?? []).map(c => c.name),
|
||||
roles: cardData.roles,
|
||||
crew: cardData.crew,
|
||||
event: cardData.event,
|
||||
issuerName: cardData.issuerName, recipientName: cardData.recipientName,
|
||||
accessLevel: cardData.accessLevel, validThru: cardData.validThru,
|
||||
});
|
||||
|
|
@ -1136,6 +1234,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 },
|
||||
{ id: 'event', label: t('cardUi.event.pass'), sub: t('cardUi.event.passSub'), icon: Ticket },
|
||||
] as const).map(({ id, label, sub, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
|
|
@ -1406,6 +1505,26 @@ export default function RiderCardPage() {
|
|||
<p className="text-[10px] text-gray-600">{t('cardUi.crew.hint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── EVENT fields (pass / wristband) ── */}
|
||||
{cardType === 'event' && (
|
||||
<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.event.pick')}</p>
|
||||
{myEvents.length > 0 ? (
|
||||
<Select value={activeEvent?.id ?? ''} onValueChange={setEventId}>
|
||||
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{myEvents.map(ev => (
|
||||
<SelectItem key={ev.id} value={ev.id}>{ev.title}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">{t('cardUi.event.none')}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-600">{t('cardUi.event.hint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, SKATE_CREW_KIND } from '@/lib/types';
|
||||
import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_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') : event.kind === SKATE_CREW_KIND ? t('cardUi.verify.kindCrew') : 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') : event.kind === CALENDAR_EVENT_KIND ? t('cardUi.verify.kindEvent') : t('cardUi.verify.kindRider')} />
|
||||
<Row label={t('cardUi.verify.issuedBy')} value={issuerName} />
|
||||
{event.kind === PARK_ACCESS_CARD_KIND && (
|
||||
<>
|
||||
|
|
@ -167,6 +167,10 @@ export default function VerifyCardPage() {
|
|||
<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>
|
||||
) : parsed.kind === CALENDAR_EVENT_KIND ? (
|
||||
<Link to={`/event/${parsed.pubkey}/${parsed.identifier}`} className="text-center text-sm text-[#ccff00] hover:underline inline-flex items-center justify-center gap-1.5">
|
||||
{t('cardUi.event.viewEvent')} <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" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue