SkateSpots/src/components/EventCountdown.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

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