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 { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
import { useListingReports } from '@/hooks/useListingReports';
|
import { useListingReports } from '@/hooks/useListingReports';
|
||||||
import { useSellerInfo } from '@/hooks/useSellerInfo';
|
import { useSellerInfo } from '@/hooks/useSellerInfo';
|
||||||
|
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
|
||||||
import { useTrustScore } from '@/hooks/useTrustScore';
|
import { useTrustScore } from '@/hooks/useTrustScore';
|
||||||
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
||||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||||
|
|
@ -57,6 +58,10 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
// Fetch seller info
|
// Fetch seller info
|
||||||
const { data: sellerInfo } = useSellerInfo(item.pubkey);
|
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
|
// Fetch trust score
|
||||||
const { data: trustScore } = useTrustScore(item.pubkey);
|
const { data: trustScore } = useTrustScore(item.pubkey);
|
||||||
|
|
||||||
|
|
@ -415,6 +420,15 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<TrustBadge pubkey={item.pubkey} size="sm" showLabel={false} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
import { MapPin, Store, Bookmark, MoreVertical, Flag } from 'lucide-react';
|
import { MapPin, Store, Bookmark, MoreVertical, Flag } from 'lucide-react';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
|
|
@ -22,7 +23,7 @@ import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
|
||||||
import { ReportDialog } from '@/components/ReportDialog';
|
import { ReportDialog } from '@/components/ReportDialog';
|
||||||
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
||||||
import type { SkateSpot } from '@/lib/types';
|
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 type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -47,6 +48,10 @@ export function SpotCard({ spot, status }: SpotCardProps) {
|
||||||
const isOwner = user?.pubkey === spot.pubkey;
|
const isOwner = user?.pubkey === spot.pubkey;
|
||||||
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
|
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.
|
// Dim spots the community has reported as gone/demolished.
|
||||||
const isGone = status?.status === 'gone';
|
const isGone = status?.status === 'gone';
|
||||||
|
|
||||||
|
|
@ -90,9 +95,16 @@ export function SpotCard({ spot, status }: SpotCardProps) {
|
||||||
{/* Shop badge or Type badges overlay */}
|
{/* Shop badge or Type badges overlay */}
|
||||||
<div className="absolute top-3 left-3 flex flex-col gap-1">
|
<div className="absolute top-3 left-3 flex flex-col gap-1">
|
||||||
{isShop ? (
|
{isShop ? (
|
||||||
<Badge className="bg-[#ccff00]/90 text-black backdrop-blur-sm border-2 border-white font-black uppercase text-xs">
|
<>
|
||||||
🏪 {t('mapUi.skateShop')}
|
<Badge className="bg-[#ccff00]/90 text-black backdrop-blur-sm border-2 border-white font-black uppercase text-xs">
|
||||||
</Badge>
|
🏪 {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 && (
|
{spot.accessType && (
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,59 @@ import { useIssuedPasses } from '@/hooks/useRiderCard';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owner-only card on a venue page (spot or shop) that surfaces access-pass
|
* Venue access passes, rendered on a shop or gated-spot page.
|
||||||
* 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
|
* - Owner: a management card — counts of active/revoked passes for this venue,
|
||||||
* venue has issued. Renders nothing for non-owners, so no relay query fires.
|
* 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 }) {
|
export function VenueAccessCard({ coord, isOwner }: { coord: string; isOwner?: boolean }) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { t } = useTranslation();
|
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 forVenue = (passes ?? []).filter((p) => p.coord === coord);
|
||||||
const active = forVenue.filter((p) => p.status !== 'revoked').length;
|
const active = forVenue.filter((p) => p.status !== 'revoked').length;
|
||||||
const revoked = forVenue.length - active;
|
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 (
|
return (
|
||||||
<Card className="bg-black/90 border-2 border-gray-700">
|
<Card className="bg-black/90 border-2 border-gray-700">
|
||||||
<CardContent className="p-6">
|
<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`,
|
* 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
|
* which supersedes it by addressable replacement (works on every relay, unlike
|
||||||
|
|
|
||||||
|
|
@ -6520,6 +6520,10 @@ walletUi: {
|
||||||
revoked: '{{count}} revoked',
|
revoked: '{{count}} revoked',
|
||||||
issue: 'Issue a pass',
|
issue: 'Issue a pass',
|
||||||
verifyHint: 'Verified by scanning — no app or account needed to check.',
|
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',
|
revoked: '{{count}} revocados',
|
||||||
issue: 'Emitir un pase',
|
issue: 'Emitir un pase',
|
||||||
verifyHint: 'Se verifica escaneando, sin app ni cuenta.',
|
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} />
|
<SpotOwnership spot={spot} />
|
||||||
|
|
||||||
{/* Owner of a gated venue: issue access passes */}
|
{/* Owner of a gated venue: issue access passes */}
|
||||||
{isOwner && (spot.accessType === 'private' || spot.accessType === 'locals') && (
|
{(spot.accessType === 'private' || spot.accessType === 'locals') && (
|
||||||
<VenueAccessCard coord={`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`} isOwner={isOwner} />
|
<VenueAccessCard coord={`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`} isOwner={!!isOwner} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Claim Shop Notice */}
|
{/* Claim Shop Notice */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue