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=<kind>:<pubkey>:<d>).
- RiderCardPage reads the venue search param and opens the park editor with the
  matching venue + type selected.
- i18n: new venueAccess.* keys (EN + ES).
This commit is contained in:
SkateSpots 2026-10-02 15:00:24 +07:00
parent e6d202160d
commit 346c5e4a3e
6 changed files with 106 additions and 2 deletions

View file

@ -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=<coord>`), 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 (
<Card className="bg-black/90 border-2 border-gray-700">
<CardContent className="p-6">
<div className="flex items-start gap-3">
<Ticket className="h-5 w-5 text-[#ccff00] shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="font-bold text-white uppercase tracking-wide mb-1">{t('venueAccess.title')}</h3>
<p className="text-sm text-gray-400 mb-3">{t('venueAccess.desc')}</p>
{forVenue.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
<Badge className="bg-[#ccff00]/15 text-[#ccff00] border-[#ccff00]/40">
{t('venueAccess.active', { count: active })}
</Badge>
{revoked > 0 && (
<Badge variant="outline" className="text-gray-400 border-gray-600">
{t('venueAccess.revoked', { count: revoked })}
</Badge>
)}
</div>
)}
<Button asChild size="sm" className="bg-[#ccff00] text-black hover:bg-[#b8e600] font-bold uppercase">
<Link to={`/card?venue=${encodeURIComponent(coord)}`}>
{t('venueAccess.issue')}
<ArrowRight className="h-4 w-4 ml-1" />
</Link>
</Button>
<p className="text-xs text-gray-500 mt-3 flex items-center gap-1.5">
<ScanLine className="h-3.5 w-3.5 shrink-0" />
{t('venueAccess.verifyHint')}
</p>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -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;

View file

@ -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;

View file

@ -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=<kind>:<pubkey>:<d> 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(() => {

View file

@ -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() {
</Card>
)}
{/* Owner: issue &/or review access passes for this venue */}
<VenueAccessCard coord={`${SKATE_SHOP_KIND}:${shop.pubkey}:${shop.identifier}`} isOwner={!!isOwner} />
{/* Claim Shop Notice */}
{!isOwner && user && (
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/50">

View file

@ -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 */}
<SpotOwnership spot={spot} />
{/* Owner of a gated venue: issue access passes */}
{isOwner && (spot.accessType === 'private' || spot.accessType === 'locals') && (
<VenueAccessCard coord={`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`} isOwner={isOwner} />
)}
{/* Claim Shop Notice */}
{isShop && !isOwner && user && (
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/50">