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:
parent
e6d202160d
commit
346c5e4a3e
6 changed files with 106 additions and 2 deletions
66
src/components/VenueAccessCard.tsx
Normal file
66
src/components/VenueAccessCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue