diff --git a/AGENTS.md b/AGENTS.md index ba73ee7..eea5ffb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/NIP.md b/NIP.md index 9e923dd..07aa016 100644 --- a/NIP.md +++ b/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/`. 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 diff --git a/src/components/RiderCard.tsx b/src/components/RiderCard.tsx index 352524c..3631ae4 100644 --- a/src/components/RiderCard.tsx +++ b/src/components/RiderCard.tsx @@ -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 ( +
+ + {/* Row 1: header */} +
+
+
+ 🛹 SkateSpots +
+
{t('cardUi.event.pass')}
+
+
+
{t('cardUi.event.admits')}
+
+ {e.holder || d.name} +
+
+
+ + {/* Row 2: main body */} +
+ +
+
+ {e.image ? ( + + ) : ( +
🎟️
+ )} +
+
+ +
+
{e.title}
+
🗓️ {dateText}
+ {e.location && ( +
📍 {e.location}
+ )} + {typeof e.going === 'number' && e.going > 0 && ( +
{t('cardUi.event.going', { count: e.going })}
+ )} +
+ +
+ +
+ {d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')} +
+
+
+ + {/* Bottom bar */} +
+ + skate-spots.org · Powered by Nostr + + {t('cardUi.event.verifiedEvent')} +
+
+ ); +} + export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject }) { 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' && } {d.type === 'park' && } {d.type === 'crew' && } + {d.type === 'event' && } ); } diff --git a/src/hooks/useEventRsvps.ts b/src/hooks/useEventRsvps.ts index eb4c583..2c8f9b6 100644 --- a/src/hooks/useEventRsvps.ts +++ b/src/hooks/useEventRsvps.ts @@ -45,6 +45,35 @@ export function eventCoord(event: Pick): 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({ + 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(); + 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(); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 9cf8ece..f6ad58c 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 62fb352..b99894d 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/lib/riderCard.ts b/src/lib/riderCard.ts index 670d922..ce25054 100644 --- a/src/lib/riderCard.ts +++ b/src/lib/riderCard.ts @@ -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}`; +} diff --git a/src/pages/AdminReviewPage.tsx b/src/pages/AdminReviewPage.tsx index ec3a13a..67a52e6 100644 --- a/src/pages/AdminReviewPage.tsx +++ b/src/pages/AdminReviewPage.tsx @@ -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

{t('walletUi.admin.nothingToReview')}

; + } const approve = async (coord: string, claimant: string) => { try { @@ -728,8 +731,6 @@ export default function AdminReviewPage() { const { t } = useTranslation(); subtitle={t('walletUi.admin.subtitle')} /> - -
{TABS.map((tab) => (
)} + + {/* ── EVENT fields (pass / wristband) ── */} + {cardType === 'event' && ( +
+

{t('cardUi.event.pick')}

+ {myEvents.length > 0 ? ( + + ) : ( +

{t('cardUi.event.none')}

+ )} +

{t('cardUi.event.hint')}

+
+ )} )} diff --git a/src/pages/VerifyCardPage.tsx b/src/pages/VerifyCardPage.tsx index 1a93a29..f8448c9 100644 --- a/src/pages/VerifyCardPage.tsx +++ b/src/pages/VerifyCardPage.tsx @@ -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() { } title={t('cardUi.verify.authentic')} body={t('cardUi.verify.authenticDesc')} />
- + {event.kind === PARK_ACCESS_CARD_KIND && ( <> @@ -167,6 +167,10 @@ export default function VerifyCardPage() { {t('cardUi.crew.viewCrew')} + ) : parsed.kind === CALENDAR_EVENT_KIND ? ( + + {t('cardUi.event.viewEvent')} + ) : ( {t('cardUi.verify.viewCard')}