- Tour sponsor URL/logo sanitized before href/src (SponsorSlot) — same XSS guard EventSponsors already uses. - Official SkateSpots badge only shows when the NIP-05 actually verifies (NIP19Page), not on a self-asserted handle. - Community stats reads are null-guarded on persisted-cache fields (monthly/cumulativeSpots/topMappers) — no more crash on an old cache. - Overpass response no longer assumes .elements (food layer). - user-listings query key is memoized (was Date.now() → refetch storm). - Pro grant requires the l=pro label (was accepted with none). - Verify card RSVP maps accepted/tentative (was going/interested → showed 'No RSVP' on a valid ticket). - Expired listings can't be bought via a stale/shared URL. - Batch spot photo/status hooks include the relay suffix and use querySkateContent (relay-policy parity with useSpotReports). - Offline queue requeues rows stuck in 'publishing' after a crash. - querySkateContent/queryCanonical dedupe replaceable events by author+kind (not id) so a stale profile/relay list can't win. - Route offline cache key includes the origin (was destination+mode only). - Internal links use <Link> (MarketplacePage); EventCountdown reacts to language changes.
165 lines
4.6 KiB
TypeScript
165 lines
4.6 KiB
TypeScript
/**
|
|
* SponsorSlot - Invisible placeholder for future sponsors
|
|
*
|
|
* This component renders nothing when no sponsors are provided,
|
|
* but can be "activated" by passing sponsor data.
|
|
*
|
|
* Sizes:
|
|
* - banner: Full-width horizontal banner (homepage, tour pages)
|
|
* - card: Square/rectangular card (sidebar, grid layouts)
|
|
* - inline: Small inline mention (challenge cards, footer)
|
|
*
|
|
* Usage:
|
|
* <SponsorSlot size="banner" /> // Invisible placeholder
|
|
* <SponsorSlot size="banner" sponsors={[{name: "Vans", logo: "...", url: "..."}]} />
|
|
*/
|
|
|
|
import { cn } from '@/lib/utils';
|
|
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
|
|
|
export interface Sponsor {
|
|
name: string;
|
|
logo?: string;
|
|
url?: string;
|
|
tier?: 'title' | 'gold' | 'silver' | 'challenge' | 'partner';
|
|
}
|
|
|
|
interface SponsorSlotProps {
|
|
size: 'banner' | 'card' | 'inline';
|
|
sponsors?: Sponsor[];
|
|
title?: string; // e.g., "Powered by", "Sponsored by", "Partners"
|
|
className?: string;
|
|
// For challenge sponsors
|
|
challengeId?: string;
|
|
}
|
|
|
|
export function SponsorSlot({
|
|
size,
|
|
sponsors,
|
|
title = 'Partners',
|
|
className,
|
|
}: SponsorSlotProps) {
|
|
// If no sponsors, render nothing (invisible placeholder)
|
|
if (!sponsors || sponsors.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Banner style - full width, horizontal layout
|
|
if (size === 'banner') {
|
|
return (
|
|
<div className={cn(
|
|
'w-full py-6 border-t border-b border-gray-800 bg-black/50',
|
|
className
|
|
)}>
|
|
<div className="container px-4">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-4 text-center">
|
|
{title}
|
|
</p>
|
|
<div className="flex items-center justify-center gap-8 flex-wrap">
|
|
{sponsors.map((sponsor) => (
|
|
<SponsorItem key={sponsor.name} sponsor={sponsor} size="banner" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Card style - for sidebar or grid layouts
|
|
if (size === 'card') {
|
|
return (
|
|
<div className={cn(
|
|
'p-4 rounded-lg border border-gray-800 bg-black/50',
|
|
className
|
|
)}>
|
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-3">
|
|
{title}
|
|
</p>
|
|
<div className="flex flex-col gap-3">
|
|
{sponsors.map((sponsor) => (
|
|
<SponsorItem key={sponsor.name} sponsor={sponsor} size="card" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Inline style - small, compact
|
|
if (size === 'inline') {
|
|
return (
|
|
<div className={cn(
|
|
'flex items-center gap-2 text-xs text-gray-500',
|
|
className
|
|
)}>
|
|
<span>{title}:</span>
|
|
{sponsors.map((sponsor, idx) => (
|
|
<span key={sponsor.name}>
|
|
<SponsorItem sponsor={sponsor} size="inline" />
|
|
{idx < sponsors.length - 1 && ', '}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Individual sponsor display
|
|
function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'card' | 'inline' }) {
|
|
// SECURITY: sponsor data comes from event tags (a tour author controls it),
|
|
// so sanitize before use as an href/src — same as EventSponsors.
|
|
const safeUrl = sponsor.url ? sanitizeUrl(sponsor.url) : null;
|
|
const safeLogo = sponsor.logo ? sanitizeUrl(sponsor.logo) : null;
|
|
const content = (
|
|
<div className={cn(
|
|
'flex items-center gap-2 transition-opacity hover:opacity-80',
|
|
size === 'banner' && 'flex-col',
|
|
size === 'card' && 'flex-row',
|
|
size === 'inline' && 'inline-flex',
|
|
)}>
|
|
{safeLogo && (
|
|
<img
|
|
src={safeLogo}
|
|
alt={sponsor.name}
|
|
className={cn(
|
|
'object-contain',
|
|
size === 'banner' && 'h-10 max-w-[120px]',
|
|
size === 'card' && 'h-6 max-w-[80px]',
|
|
size === 'inline' && 'h-4 max-w-[60px]',
|
|
)}
|
|
/>
|
|
)}
|
|
<span className={cn(
|
|
'font-medium',
|
|
size === 'banner' && 'text-sm text-gray-400',
|
|
size === 'card' && 'text-sm text-gray-300',
|
|
size === 'inline' && 'text-xs text-gray-400',
|
|
!safeLogo && 'text-gray-300', // More visible if no logo
|
|
)}>
|
|
{sponsor.name}
|
|
</span>
|
|
{sponsor.tier === 'title' && size !== 'inline' && (
|
|
<span className="text-[10px] text-yellow-500 uppercase">Title Sponsor</span>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (safeUrl) {
|
|
return (
|
|
<a
|
|
href={safeUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block"
|
|
>
|
|
{content}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
// Export types for use in tour schema
|
|
export type { Sponsor as TourSponsor };
|