SkateSpots/src/components/SponsorSlot.tsx
SkateSpots e016ee20bd fix: tier 3 — medium bugs & hardening
- 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.
2026-10-02 22:26:46 +07:00

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 };