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:
parent
aef41ef17e
commit
a6dd32e653
7 changed files with 129 additions and 12 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue