From 346c5e4a3eaae251c8e688442f2cb79e28a04a52 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 15:00:24 +0700 Subject: [PATCH] feat(card): manage access passes from the venue page - New owner-only VenueAccessCard on shop and gated spot pages: shows active/ revoked pass counts for that venue and links into the card studio with the venue preselected (/card?venue=::). - RiderCardPage reads the venue search param and opens the park editor with the matching venue + type selected. - i18n: new venueAccess.* keys (EN + ES). --- src/components/VenueAccessCard.tsx | 66 ++++++++++++++++++++++++++++++ src/i18n/en.ts | 8 ++++ src/i18n/es.ts | 8 ++++ src/pages/RiderCardPage.tsx | 14 ++++++- src/pages/ShopDetailPage.tsx | 6 ++- src/pages/SpotDetailPage.tsx | 6 +++ 6 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 src/components/VenueAccessCard.tsx diff --git a/src/components/VenueAccessCard.tsx b/src/components/VenueAccessCard.tsx new file mode 100644 index 0000000..07817e8 --- /dev/null +++ b/src/components/VenueAccessCard.tsx @@ -0,0 +1,66 @@ +import { Link } from 'react-router-dom'; +import { Ticket, ScanLine, ArrowRight } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useIssuedPasses } from '@/hooks/useRiderCard'; +import { useTranslation } from '@/contexts/language'; + +/** + * Owner-only card on a venue page (spot or shop) that surfaces access-pass + * issuance right where the venue is managed. It links into the card studio with + * the venue preselected (`/card?venue=`), and shows how many passes this + * venue has issued. Renders nothing for non-owners, so no relay query fires. + */ +export function VenueAccessCard({ coord, isOwner }: { coord: string; isOwner?: boolean }) { + const { user } = useCurrentUser(); + const { t } = useTranslation(); + const { data: passes } = useIssuedPasses(isOwner ? user?.pubkey : undefined); + + if (!isOwner || !user) return null; + + const forVenue = (passes ?? []).filter((p) => p.coord === coord); + const active = forVenue.filter((p) => p.status !== 'revoked').length; + const revoked = forVenue.length - active; + + return ( + + +
+ +
+

{t('venueAccess.title')}

+

{t('venueAccess.desc')}

+ + {forVenue.length > 0 && ( +
+ + {t('venueAccess.active', { count: active })} + + {revoked > 0 && ( + + {t('venueAccess.revoked', { count: revoked })} + + )} +
+ )} + + + +

+ + {t('venueAccess.verifyHint')} +

+
+
+
+
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 3048deb..122655c 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6513,6 +6513,14 @@ walletUi: { writtenDesc: 'Tap the tag to a phone to open the verify page.', failed: 'Could not write the tag', }, + venueAccess: { + title: 'Access passes', + desc: 'Issue a signed pass to a rider so they can prove access at the door — they show their card and anyone can scan the QR to verify it.', + active: '{{count}} active', + revoked: '{{count}} revoked', + issue: 'Issue a pass', + verifyHint: 'Verified by scanning — no app or account needed to check.', + }, }; export default en; diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 72bd426..3b38ae5 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -6513,6 +6513,14 @@ walletUi: { writtenDesc: 'Acerca la etiqueta a un móvil para abrir la verificación.', failed: 'No se pudo escribir la etiqueta', }, + venueAccess: { + title: 'Pases de acceso', + desc: 'Emite un pase firmado a un rider para que pueda demostrar el acceso en la puerta: muestra su carné y cualquiera puede escanear el QR para verificarlo.', + active: '{{count}} activos', + revoked: '{{count}} revocados', + issue: 'Emitir un pase', + verifyHint: 'Se verifica escaneando, sin app ni cuenta.', + }, }; export default es; diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 973f517..9a98f16 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -53,7 +53,7 @@ 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_SHOP_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; -import { Link } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users, Ticket, MoreHorizontal, Pencil, CheckCircle2, RefreshCw, Upload } from 'lucide-react'; // ─── Hook: fetch skill badges received by a pubkey ──────────────────────────── function useReceivedBadges(pubkey: string) { @@ -288,6 +288,18 @@ export default function RiderCardPage() { }, [allSpots, allShops, user?.pubkey]); const venueSpot = myVenues.find((s) => s.id === venueId); + // Deep link from a venue page: /card?venue=:: opens the park + // pass editor with that venue preselected. + const [searchParams] = useSearchParams(); + const venueParam = searchParams.get('venue'); + useEffect(() => { + if (!venueParam) return; + const match = myVenues.find((v) => `${v.kind}:${v.pubkey}:${v.identifier}` === venueParam); + if (!match) return; + setCardType('park'); + setVenueId(match.id); + }, [venueParam, myVenues]); + // Hydrate the editor from the saved Rider Card the first time it loads. const hydratedRef = useRef(false); useEffect(() => { diff --git a/src/pages/ShopDetailPage.tsx b/src/pages/ShopDetailPage.tsx index e3d82a1..418af9c 100644 --- a/src/pages/ShopDetailPage.tsx +++ b/src/pages/ShopDetailPage.tsx @@ -8,7 +8,7 @@ import { ImageWithFallback } from '@/components/ImageWithFallback'; import { ImageLightbox } from '@/components/ImageLightbox'; import { SpotMap } from '@/components/SpotMap'; import { RouteDialog } from '@/components/RouteDialog'; -import type { SkateSpot } from '@/lib/types'; +import { SKATE_SHOP_KIND, type SkateSpot } from '@/lib/types'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Skeleton } from '@/components/ui/skeleton'; @@ -19,6 +19,7 @@ import { CommentsSection } from '@/components/comments/CommentsSection'; import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl'; import { OfficialBadge } from '@/components/OfficialBadge'; import { ControllerBadge } from '@/components/ControllerBadge'; +import { VenueAccessCard } from '@/components/VenueAccessCard'; import { useShop } from '@/hooks/useShops'; import { useAuthor } from '@/hooks/useAuthor'; import { useDeleteEntity } from '@/hooks/useDeleteEntity'; @@ -331,6 +332,9 @@ export default function ShopDetailPage() { )} + {/* Owner: issue &/or review access passes for this venue */} + + {/* Claim Shop Notice */} {!isOwner && user && ( diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index 5c7b511..ae35daa 100644 --- a/src/pages/SpotDetailPage.tsx +++ b/src/pages/SpotDetailPage.tsx @@ -82,6 +82,7 @@ import { AddToCollectionDialog } from '@/components/AddToCollectionDialog'; import { AddPhotoToSpot } from '@/components/AddPhotoToSpot'; import { AddTagsToSpot } from '@/components/AddTagsToSpot'; import { SpotOwnership } from '@/components/SpotOwnership'; +import { VenueAccessCard } from '@/components/VenueAccessCard'; import { SpotStatusBadge } from '@/components/SpotStatusBadge'; import { ReportSpotStatusDialog } from '@/components/ReportSpotStatusDialog'; import { useSpotStatus } from '@/hooks/useSpotStatus'; @@ -647,6 +648,11 @@ export default function SpotDetailPage() { {/* Spot Ownership - Transfer / Claim */} + {/* Owner of a gated venue: issue access passes */} + {isOwner && (spot.accessType === 'private' || spot.accessType === 'locals') && ( + + )} + {/* Claim Shop Notice */} {isShop && !isOwner && user && (