feat(card): public pass hint + shop/listing pass badges (audit items 2 & 4)

- VenueAccessCard now has a visitor branch: a public 'this venue issues access
  passes' hint (count only, never a holder list) shown only when the venue has
  active passes. Rendered for everyone on shops and gated spots.
- New shared, cached useVenuesWithPasses() hook resolves active pass coords +
  owners in one query.
- SpotCard badges shops that issue passes; MarketplaceItemCard flags sellers
  whose venue issues passes (title explains it is the venue, not the listing).
- i18n: venueAccess.publicTitle/publicDesc/badge/sellerHint (EN + ES).
This commit is contained in:
SkateSpots 2026-10-02 15:05:49 +07:00
parent aef41ef17e
commit a6dd32e653
7 changed files with 129 additions and 12 deletions

View file

@ -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
</Badge>
)}
<TrustBadge pubkey={item.pubkey} size="sm" showLabel={false} />
{sellerIssuesPasses && (
<Badge
variant="outline"
className="text-xs border-[#ccff00]/50 text-[#ccff00] bg-[#ccff00]/10 gap-1"
title={t('venueAccess.sellerHint')}
>
🎟️ {t('venueAccess.badge')}
</Badge>
)}
</div>
</div>

View file

@ -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 */}
<div className="absolute top-3 left-3 flex flex-col gap-1">
{isShop ? (
<Badge className="bg-[#ccff00]/90 text-black backdrop-blur-sm border-2 border-white font-black uppercase text-xs">
🏪 {t('mapUi.skateShop')}
</Badge>
<>
<Badge className="bg-[#ccff00]/90 text-black backdrop-blur-sm border-2 border-white font-black uppercase text-xs">
🏪 {t('mapUi.skateShop')}
</Badge>
{hasPasses && (
<Badge className="bg-white/90 text-black backdrop-blur-sm border-2 border-black font-black uppercase text-[10px]">
🎟️ {t('venueAccess.badge')}
</Badge>
)}
</>
) : (
<>
{spot.accessType && (

View file

@ -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=<coord>`), 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=<coord>`).
* - 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 (
<Card className="bg-black/90 border-2 border-gray-700">
<CardContent className="p-5">
<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">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h3 className="font-bold text-white uppercase tracking-wide">{t('venueAccess.publicTitle')}</h3>
<Badge className="bg-[#ccff00]/15 text-[#ccff00] border-[#ccff00]/40">
{t('venueAccess.active', { count: active })}
</Badge>
</div>
<p className="text-sm text-gray-400">{t('venueAccess.publicDesc')}</p>
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1.5">
<ScanLine className="h-3.5 w-3.5 shrink-0" />
{t('venueAccess.verifyHint')}
</p>
</div>
</div>
</CardContent>
</Card>
);
}
if (!user) return null;
return (
<Card className="bg-black/90 border-2 border-gray-700">
<CardContent className="p-6">

View file

@ -205,6 +205,52 @@ export function useIssuedPasses(pubkey?: string) {
});
}
export interface VenuePassIndex {
/** Venue coords (`<kind>:<pubkey>:<d>`) with at least one active pass. */
coords: Set<string>;
/** Pubkeys that own at least one venue with an active pass. */
owners: Set<string>;
}
/**
* 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<VenuePassIndex>({
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<string, NostrEvent>();
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<string>();
const owners = new Set<string>();
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

View file

@ -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',
},
};

View file

@ -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',
},
};

View file

@ -649,8 +649,8 @@ export default function SpotDetailPage() {
<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} />
{(spot.accessType === 'private' || spot.accessType === 'locals') && (
<VenueAccessCard coord={`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`} isOwner={!!isOwner} />
)}
{/* Claim Shop Notice */}