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 */}