- 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.
92 lines
3 KiB
TypeScript
92 lines
3 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
export type EventStatus = 'soon' | 'upcoming' | 'live' | 'ended';
|
|
|
|
interface CountdownInfo {
|
|
status: EventStatus;
|
|
label: string;
|
|
}
|
|
|
|
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
|
|
|
function computeStatus(start: number, end: number | undefined, t: Translate): CountdownInfo {
|
|
const now = Date.now() / 1000;
|
|
const effectiveEnd = end ?? start + 3 * 3600; // assume ~3h if no end
|
|
|
|
if (now >= start && now < effectiveEnd) {
|
|
return { status: 'live', label: t('placesUi.countdown.happeningNow') };
|
|
}
|
|
if (now >= effectiveEnd) {
|
|
return { status: 'ended', label: t('placesUi.countdown.ended') };
|
|
}
|
|
|
|
const diff = start - now;
|
|
const days = Math.floor(diff / 86400);
|
|
const hours = Math.floor((diff % 86400) / 3600);
|
|
const mins = Math.floor((diff % 3600) / 60);
|
|
|
|
if (days >= 1) {
|
|
return { status: days <= 3 ? 'soon' : 'upcoming', label: days === 1 ? t('placesUi.countdown.tomorrow') : t('placesUi.countdown.inDays', { count: days }) };
|
|
}
|
|
if (hours >= 1) {
|
|
return { status: 'soon', label: t('placesUi.countdown.inHoursMins', { hours, mins }) };
|
|
}
|
|
if (mins >= 1) {
|
|
return { status: 'soon', label: t('placesUi.countdown.inMins', { count: mins }) };
|
|
}
|
|
return { status: 'soon', label: t('placesUi.countdown.startingNow') };
|
|
}
|
|
|
|
/**
|
|
* Live status pill for an event — the single most valuable at-a-glance signal:
|
|
* "IN 3 DAYS" / "IN 2H 14M" / "🔴 HAPPENING NOW" / "ENDED". Ticks every 30s.
|
|
*/
|
|
export function EventCountdown({
|
|
start,
|
|
end,
|
|
className,
|
|
}: {
|
|
start: number;
|
|
end?: number;
|
|
className?: string;
|
|
}) {
|
|
const [tick, forceTick] = useState(0);
|
|
const { t } = useTranslation();
|
|
|
|
// Re-render periodically so the countdown stays fresh without a heavy timer.
|
|
useEffect(() => {
|
|
const id = setInterval(() => forceTick((n) => n + 1), 30_000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
// `tick` forces a periodic recompute; `t` so a language switch updates it.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
const info = useMemo(() => computeStatus(start, end, t), [start, end, tick, t]);
|
|
|
|
const styles: Record<EventStatus, string> = {
|
|
live: 'bg-[#ff0040] text-white border-[#ff0040] animate-pulse',
|
|
soon: 'bg-[#ccff00] text-black border-[#ccff00]',
|
|
upcoming: 'bg-white/10 text-white border-white/20',
|
|
ended: 'bg-white/5 text-gray-400 border-white/10',
|
|
};
|
|
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-black uppercase tracking-wide backdrop-blur',
|
|
styles[info.status],
|
|
className,
|
|
)}
|
|
>
|
|
{info.status === 'live' && (
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75" />
|
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-white" />
|
|
</span>
|
|
)}
|
|
{info.label}
|
|
</span>
|
|
);
|
|
}
|