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.',
|
writtenDesc: 'Tap the tag to a phone to open the verify page.',
|
||||||
failed: 'Could not write the tag',
|
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;
|
export default en;
|
||||||
|
|
|
||||||
|
|
@ -6513,6 +6513,14 @@ walletUi: {
|
||||||
writtenDesc: 'Acerca la etiqueta a un móvil para abrir la verificación.',
|
writtenDesc: 'Acerca la etiqueta a un móvil para abrir la verificación.',
|
||||||
failed: 'No se pudo escribir la etiqueta',
|
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;
|
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 { 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 { 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 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';
|
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 ────────────────────────────
|
// ─── Hook: fetch skill badges received by a pubkey ────────────────────────────
|
||||||
function useReceivedBadges(pubkey: string) {
|
function useReceivedBadges(pubkey: string) {
|
||||||
|
|
@ -288,6 +288,18 @@ export default function RiderCardPage() {
|
||||||
}, [allSpots, allShops, user?.pubkey]);
|
}, [allSpots, allShops, user?.pubkey]);
|
||||||
const venueSpot = myVenues.find((s) => s.id === venueId);
|
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.
|
// Hydrate the editor from the saved Rider Card the first time it loads.
|
||||||
const hydratedRef = useRef(false);
|
const hydratedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||||
import { ImageLightbox } from '@/components/ImageLightbox';
|
import { ImageLightbox } from '@/components/ImageLightbox';
|
||||||
import { SpotMap } from '@/components/SpotMap';
|
import { SpotMap } from '@/components/SpotMap';
|
||||||
import { RouteDialog } from '@/components/RouteDialog';
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
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 { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
||||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||||
import { ControllerBadge } from '@/components/ControllerBadge';
|
import { ControllerBadge } from '@/components/ControllerBadge';
|
||||||
|
import { VenueAccessCard } from '@/components/VenueAccessCard';
|
||||||
import { useShop } from '@/hooks/useShops';
|
import { useShop } from '@/hooks/useShops';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||||||
|
|
@ -331,6 +332,9 @@ export default function ShopDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Owner: issue &/or review access passes for this venue */}
|
||||||
|
<VenueAccessCard coord={`${SKATE_SHOP_KIND}:${shop.pubkey}:${shop.identifier}`} isOwner={!!isOwner} />
|
||||||
|
|
||||||
{/* Claim Shop Notice */}
|
{/* Claim Shop Notice */}
|
||||||
{!isOwner && user && (
|
{!isOwner && user && (
|
||||||
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/50">
|
<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 { AddPhotoToSpot } from '@/components/AddPhotoToSpot';
|
||||||
import { AddTagsToSpot } from '@/components/AddTagsToSpot';
|
import { AddTagsToSpot } from '@/components/AddTagsToSpot';
|
||||||
import { SpotOwnership } from '@/components/SpotOwnership';
|
import { SpotOwnership } from '@/components/SpotOwnership';
|
||||||
|
import { VenueAccessCard } from '@/components/VenueAccessCard';
|
||||||
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
||||||
import { ReportSpotStatusDialog } from '@/components/ReportSpotStatusDialog';
|
import { ReportSpotStatusDialog } from '@/components/ReportSpotStatusDialog';
|
||||||
import { useSpotStatus } from '@/hooks/useSpotStatus';
|
import { useSpotStatus } from '@/hooks/useSpotStatus';
|
||||||
|
|
@ -647,6 +648,11 @@ export default function SpotDetailPage() {
|
||||||
{/* Spot Ownership - Transfer / Claim */}
|
{/* Spot Ownership - Transfer / Claim */}
|
||||||
<SpotOwnership spot={spot} />
|
<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 */}
|
{/* Claim Shop Notice */}
|
||||||
{isShop && !isOwner && user && (
|
{isShop && !isOwner && user && (
|
||||||
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/50">
|
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/50">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue