diff --git a/src/components/MarketplaceItemCard.tsx b/src/components/MarketplaceItemCard.tsx index 23575b8..e7ca5dd 100644 --- a/src/components/MarketplaceItemCard.tsx +++ b/src/components/MarketplaceItemCard.tsx @@ -16,6 +16,7 @@ import { useToast } from '@/hooks/useToast'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useListingReports } from '@/hooks/useListingReports'; import { useSellerInfo } from '@/hooks/useSellerInfo'; +import { useVenuesWithPasses } from '@/hooks/useRiderCard'; import { useTrustScore } from '@/hooks/useTrustScore'; import { useMarkListingSold } from '@/hooks/useMarkListingSold'; import { MarkSoldDialog } from '@/components/MarkSoldDialog'; @@ -56,6 +57,10 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa // Fetch seller info const { data: sellerInfo } = useSellerInfo(item.pubkey); + + // Does this seller own a venue that issues access passes? (one shared query) + const { data: passIndex } = useVenuesWithPasses(); + const sellerIssuesPasses = !!passIndex?.owners.has(item.pubkey); // Fetch trust score const { data: trustScore } = useTrustScore(item.pubkey); @@ -415,6 +420,15 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa )} + {sellerIssuesPasses && ( + + 🎟️ {t('venueAccess.badge')} + + )} diff --git a/src/components/SpotCard.tsx b/src/components/SpotCard.tsx index 58935da..4bd9a6e 100644 --- a/src/components/SpotCard.tsx +++ b/src/components/SpotCard.tsx @@ -14,6 +14,7 @@ import { import { MapPin, Store, Bookmark, MoreVertical, Flag } from 'lucide-react'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useVenuesWithPasses } from '@/hooks/useRiderCard'; import { genUserName } from '@/lib/genUserName'; import { ZapButton } from '@/components/ZapButton'; import { TrustBadge } from '@/components/TrustBadge'; @@ -22,7 +23,7 @@ import { AddToCollectionDialog } from '@/components/AddToCollectionDialog'; import { ReportDialog } from '@/components/ReportDialog'; import { SpotStatusBadge } from '@/components/SpotStatusBadge'; import type { SkateSpot } from '@/lib/types'; -import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO, SHELTER_INFO } from '@/lib/types'; +import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types'; import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus'; import { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; @@ -47,6 +48,10 @@ export function SpotCard({ spot, status }: SpotCardProps) { const isOwner = user?.pubkey === spot.pubkey; const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`; + // Shops that actually issue access passes get a badge (one shared, cached query). + const { data: passIndex } = useVenuesWithPasses(); + const hasPasses = isShop && !!passIndex?.coords.has(`${SKATE_SHOP_KIND}:${spot.pubkey}:${spot.identifier}`); + // Dim spots the community has reported as gone/demolished. const isGone = status?.status === 'gone'; @@ -90,9 +95,16 @@ export function SpotCard({ spot, status }: SpotCardProps) { {/* Shop badge or Type badges overlay */}
{isShop ? ( - - 🏪 {t('mapUi.skateShop')} - + <> + + 🏪 {t('mapUi.skateShop')} + + {hasPasses && ( + + 🎟️ {t('venueAccess.badge')} + + )} + ) : ( <> {spot.accessType && ( diff --git a/src/components/VenueAccessCard.tsx b/src/components/VenueAccessCard.tsx index 07817e8..04bdc33 100644 --- a/src/components/VenueAccessCard.tsx +++ b/src/components/VenueAccessCard.tsx @@ -9,22 +9,59 @@ 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. + * Venue access passes, rendered on a shop or gated-spot page. + * + * - Owner: a management card — counts of active/revoked passes for this venue, + * and a link into the card studio with the venue preselected + * (`/card?venue=`). + * - Everyone else: a public hint shown ONLY when the venue actually issues + * passes. It names no holder (passes are public events, but we never list who + * holds one) — just the count and "verified by scanning". + * + * Renders nothing when there is nothing to say, so a venue that issues no passes + * adds no clutter to its page. */ 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; + // Passes are authored by the venue's owner key (issuance is owner-gated), so + // querying that pubkey and filtering by the `a` coord is exact. + const ownerPubkey = coord.split(':')[1]; + const { data: passes } = useIssuedPasses(ownerPubkey); const forVenue = (passes ?? []).filter((p) => p.coord === coord); const active = forVenue.filter((p) => p.status !== 'revoked').length; const revoked = forVenue.length - active; + if (!isOwner) { + if (active === 0) return null; + return ( + + +
+ +
+
+

{t('venueAccess.publicTitle')}

+ + {t('venueAccess.active', { count: active })} + +
+

{t('venueAccess.publicDesc')}

+

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

+
+
+
+
+ ); + } + + if (!user) return null; + return ( diff --git a/src/hooks/useRiderCard.ts b/src/hooks/useRiderCard.ts index 74681f6..a2b0eed 100644 --- a/src/hooks/useRiderCard.ts +++ b/src/hooks/useRiderCard.ts @@ -205,6 +205,52 @@ export function useIssuedPasses(pubkey?: string) { }); } +export interface VenuePassIndex { + /** Venue coords (`::`) with at least one active pass. */ + coords: Set; + /** Pubkeys that own at least one venue with an active pass. */ + owners: Set; +} + +/** + * One cached query for the whole app: which venues currently have active access + * passes. Lets a card list badge shops without a relay query per card. A pass is + * active unless its newest version carries `status=revoked` (addressable + * supersede — the same rule `/verify` and "My passes" use). + */ +export function useVenuesWithPasses() { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + + return useQuery({ + queryKey: ['venues-with-passes', getQueryKeySuffix()], + staleTime: 120_000, + queryFn: async () => { + const signal = AbortSignal.timeout(9000); + const events = await querySkateContent( + [{ kinds: [PARK_ACCESS_CARD_KIND], limit: 500 }], + { signal }, + ); + const byD = new Map(); + for (const ev of events) { + const d = ev.tags.find(([n]) => n === 'd')?.[1] ?? ev.id; + const cur = byD.get(d); + if (!cur || ev.created_at > cur.created_at) byD.set(d, ev); + } + const coords = new Set(); + const owners = new Set(); + for (const ev of byD.values()) { + if (ev.tags.find(([n]) => n === 'status')?.[1] === 'revoked') continue; + const a = ev.tags.find(([n]) => n === 'a')?.[1]; + if (!a) continue; + coords.add(a); + const owner = a.split(':')[1]; + if (owner) owners.add(owner); + } + return { coords, owners }; + }, + }); +} + /** * Revoke an issued pass: re-publish it under the SAME `d` with `status=revoked`, * which supersedes it by addressable replacement (works on every relay, unlike diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 122655c..52e757e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6520,6 +6520,10 @@ walletUi: { revoked: '{{count}} revoked', issue: 'Issue a pass', verifyHint: 'Verified by scanning — no app or account needed to check.', + publicTitle: 'Access passes', + publicDesc: 'This venue issues access passes. Scan a rider’s card at the door to verify it.', + badge: 'Passes', + sellerHint: 'This seller’s venue issues access passes', }, }; diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 3b38ae5..394d2c2 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -6520,6 +6520,10 @@ walletUi: { revoked: '{{count}} revocados', issue: 'Emitir un pase', verifyHint: 'Se verifica escaneando, sin app ni cuenta.', + publicTitle: 'Pases de acceso', + publicDesc: 'Este lugar emite pases de acceso. Escanea el carné del rider en la puerta para verificarlo.', + badge: 'Pases', + sellerHint: 'El local de este vendedor emite pases de acceso', }, }; diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index ae35daa..2c65b54 100644 --- a/src/pages/SpotDetailPage.tsx +++ b/src/pages/SpotDetailPage.tsx @@ -649,8 +649,8 @@ export default function SpotDetailPage() { {/* Owner of a gated venue: issue access passes */} - {isOwner && (spot.accessType === 'private' || spot.accessType === 'locals') && ( - + {(spot.accessType === 'private' || spot.accessType === 'locals') && ( + )} {/* Claim Shop Notice */}