SkateSpots/src/components/EventsCalendar.tsx
SkateSpots 0b796440f1 feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~3,470 new keys (5,770 total, EN=ES), ~115 more files wired.
- nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets
  (sort/trust/weather), spot-type label helper.
- placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications,
  collectives, crew chat/coordinator. settingsUi: settings, auth/signup,
  onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints,
  app shop, newsletter, community stats, leaderboards, profile.
- Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
2026-10-02 10:11:06 +07:00

416 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Route, MapPin, Clock, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
import type { SkateEvent, SkateTour } from '@/lib/types';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface CalendarItem {
id: string;
kind: 'event' | 'tour';
title: string;
/** Start of the item (unix seconds). */
start: number;
/** End of the item (unix seconds), if multi-day/ranged. */
end?: number;
location?: string;
to: string;
/** RSVP "going" count for events. */
goingCount?: number;
/** True for date-only items with no meaningful clock time (multi-day tours). */
allDay?: boolean;
}
interface EventsCalendarProps {
events: SkateEvent[];
tours: SkateTour[];
/** RSVP going counts keyed by event id (optional). */
goingCounts?: Map<string, number>;
/** Which kinds to display. */
show: 'events' | 'tours';
/** Unix seconds to open the calendar on (e.g. the newest past event). */
anchorUnix?: number;
}
// ─── Date helpers (local time) ────────────────────────────────────────────────
const DAY_MS = 86_400_000;
const WEEKDAY_KEYS = [
'placesUi.calendar.weekdays.sun',
'placesUi.calendar.weekdays.mon',
'placesUi.calendar.weekdays.tue',
'placesUi.calendar.weekdays.wed',
'placesUi.calendar.weekdays.thu',
'placesUi.calendar.weekdays.fri',
'placesUi.calendar.weekdays.sat',
];
const WEEKDAY_SHORT_KEYS = [
'placesUi.calendar.weekdaysShort.sun',
'placesUi.calendar.weekdaysShort.mon',
'placesUi.calendar.weekdaysShort.tue',
'placesUi.calendar.weekdaysShort.wed',
'placesUi.calendar.weekdaysShort.thu',
'placesUi.calendar.weekdaysShort.fri',
'placesUi.calendar.weekdaysShort.sat',
];
const MONTH_KEYS = [
'placesUi.calendar.months.january', 'placesUi.calendar.months.february', 'placesUi.calendar.months.march',
'placesUi.calendar.months.april', 'placesUi.calendar.months.may', 'placesUi.calendar.months.june',
'placesUi.calendar.months.july', 'placesUi.calendar.months.august', 'placesUi.calendar.months.september',
'placesUi.calendar.months.october', 'placesUi.calendar.months.november', 'placesUi.calendar.months.december',
];
/** Midnight (local) for a given date. */
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
/** A stable YYYY-MM-DD key in local time. */
function dayKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function sameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
/** Build the 6-week (42-cell) grid for a month, starting on Sunday. */
function buildGrid(view: Date): Date[] {
const first = new Date(view.getFullYear(), view.getMonth(), 1);
const gridStart = new Date(first);
gridStart.setDate(first.getDate() - first.getDay()); // back to Sunday
const cells: Date[] = [];
for (let i = 0; i < 42; i++) {
cells.push(new Date(gridStart.getTime() + i * DAY_MS));
}
return cells;
}
// ─── Item mapping ─────────────────────────────────────────────────────────────
function eventsToItems(events: SkateEvent[], goingCounts?: Map<string, number>): CalendarItem[] {
return events.map((e) => ({
id: e.id,
kind: 'event' as const,
title: e.title,
start: e.start,
end: e.end,
location: e.location,
to: `/event/${e.pubkey}/${e.identifier}`,
goingCount: goingCounts?.get(e.event.id) ?? goingCounts?.get(e.id) ?? 0,
}));
}
function toursToItems(tours: SkateTour[]): CalendarItem[] {
// Only tours with a concrete date belong on a calendar: scheduled local
// sessions plus multi-day national/international/destruction tours. Open-ended
// "book anytime" local guides have no fixed date and are browsed as a list.
return tours
.filter((t) => typeof t.startDate === 'number')
.map((t) => ({
id: t.id,
kind: 'tour' as const,
title: t.title,
start: t.startDate as number,
end: t.endDate,
location: t.location ?? t.cities?.[0] ?? t.countries?.[0],
to: `/tour/${t.pubkey}/${t.identifier}`,
// Local sessions have a specific clock time; everything else is date-only.
allDay: t.category !== 'local',
}));
}
/** Does an item overlap a given calendar day? */
function itemOnDay(item: CalendarItem, day: Date): boolean {
const dayStart = startOfDay(day).getTime() / 1000;
const dayEnd = dayStart + 86_400;
const s = item.start;
const e = item.end && item.end > item.start ? item.end : item.start;
return s < dayEnd && e >= dayStart;
}
function formatTime(unix: number): string {
return new Date(unix * 1000).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function formatDayLong(d: Date): string {
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
}
// ─── Component ────────────────────────────────────────────────────────────────
export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }: EventsCalendarProps) {
const { t } = useTranslation();
const today = useMemo(() => startOfDay(new Date()), []);
const [view, setView] = useState<Date>(() => {
const base = anchorUnix ? new Date(anchorUnix * 1000) : today;
return new Date(base.getFullYear(), base.getMonth(), 1);
});
const [selected, setSelected] = useState<Date>(() => startOfDay(anchorUnix ? new Date(anchorUnix * 1000) : today));
// Follow the anchor when the caller changes tabs (React state is only the
// initial value otherwise). Without this, "Past" opened on the CURRENT month
// — which has no past events — so the calendar looked empty.
useEffect(() => {
if (!anchorUnix) return;
const d = new Date(anchorUnix * 1000);
setView(new Date(d.getFullYear(), d.getMonth(), 1));
setSelected(startOfDay(d));
}, [anchorUnix]);
const accent = show === 'events'
? { dot: 'bg-[#ccff00]', ring: 'ring-[#ccff00]', text: 'text-[#ccff00]', soft: 'bg-[#ccff00]/15', border: 'border-[#ccff00]/40', solid: 'bg-[#ccff00] text-black' }
: { dot: 'bg-cyan-400', ring: 'ring-cyan-400', text: 'text-cyan-400', soft: 'bg-cyan-500/15', border: 'border-cyan-500/40', solid: 'bg-cyan-500 text-white' };
const items = useMemo(
() => (show === 'events' ? eventsToItems(events, goingCounts) : toursToItems(tours)),
[show, events, tours, goingCounts],
);
// Map of dayKey -> items on that day, for quick dot rendering.
const grid = useMemo(() => buildGrid(view), [view]);
const itemsByDay = useMemo(() => {
const map = new Map<string, CalendarItem[]>();
for (const cell of grid) {
const onDay = items.filter((it) => itemOnDay(it, cell));
if (onDay.length) map.set(dayKey(cell), onDay.sort((a, b) => a.start - b.start));
}
return map;
}, [grid, items]);
const selectedItems = useMemo(
() => items.filter((it) => itemOnDay(it, selected)).sort((a, b) => a.start - b.start),
[items, selected],
);
const goToMonth = (delta: number) => {
setView((v) => new Date(v.getFullYear(), v.getMonth() + delta, 1));
};
const goToToday = () => {
setView(new Date(today.getFullYear(), today.getMonth(), 1));
setSelected(today);
};
// When user changes the month via arrows, keep the selection sensible:
// if the selected day is no longer in view, select the 1st of the month.
const prevViewRef = useRef(view);
useEffect(() => {
if (prevViewRef.current.getTime() !== view.getTime()) {
prevViewRef.current = view;
if (selected.getMonth() !== view.getMonth() || selected.getFullYear() !== view.getFullYear()) {
// If today is in the viewed month, prefer today; else the 1st.
if (today.getMonth() === view.getMonth() && today.getFullYear() === view.getFullYear()) {
setSelected(today);
} else {
setSelected(new Date(view.getFullYear(), view.getMonth(), 1));
}
}
}
}, [view, selected, today]);
const totalThisMonth = useMemo(
() => items.filter((it) => {
const s = new Date(it.start * 1000);
const e = it.end ? new Date(it.end * 1000) : s;
// Overlaps the viewed month?
const monthStart = new Date(view.getFullYear(), view.getMonth(), 1);
const monthEnd = new Date(view.getFullYear(), view.getMonth() + 1, 1);
return e >= monthStart && s < monthEnd;
}).length,
[items, view],
);
return (
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_360px] gap-4 md:gap-6">
{/* ── Month grid ── */}
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-3 md:p-5">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-baseline gap-2 min-w-0">
<h2 className="text-lg md:text-2xl font-black uppercase tracking-tight truncate">
{t(MONTH_KEYS[view.getMonth()])}
</h2>
<span className="text-gray-500 text-lg md:text-2xl font-black">{view.getFullYear()}</span>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={goToToday}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
>
{t('placesUi.calendar.today')}
</button>
<button
type="button"
aria-label={t('placesUi.calendar.prevMonth')}
onClick={() => goToMonth(-1)}
className="rounded-full border border-white/10 bg-white/5 p-2 text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
aria-label={t('placesUi.calendar.nextMonth')}
onClick={() => goToMonth(1)}
className="rounded-full border border-white/10 bg-white/5 p-2 text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
{/* Weekday labels */}
<div className="grid grid-cols-7 gap-1 md:gap-1.5 mb-1 md:mb-2">
{WEEKDAY_KEYS.map((key, i) => (
<div key={key} className="text-center text-[10px] md:text-xs font-bold uppercase tracking-wider text-gray-500 py-1">
<span className="md:hidden">{t(WEEKDAY_SHORT_KEYS[i])}</span>
<span className="hidden md:inline">{t(key)}</span>
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-1 md:gap-1.5">
{grid.map((cell) => {
const inMonth = cell.getMonth() === view.getMonth();
const isToday = sameDay(cell, today);
const isSelected = sameDay(cell, selected);
const dayItems = itemsByDay.get(dayKey(cell)) ?? [];
const has = dayItems.length > 0;
return (
<button
key={cell.getTime()}
type="button"
onClick={() => setSelected(startOfDay(cell))}
aria-label={`${formatDayLong(cell)}${has ? `, ${t(show === 'events' ? 'placesUi.calendar.eventsOnDay' : 'placesUi.calendar.toursOnDay', { count: dayItems.length })}` : ''}`}
aria-pressed={isSelected}
className={cn(
'relative aspect-square rounded-lg md:rounded-xl border flex flex-col items-center justify-start pt-1 md:pt-1.5 transition-all active:scale-95',
isSelected
? cn('ring-2', accent.ring, accent.soft, accent.border)
: 'border-white/5 hover:border-white/20',
!inMonth && 'opacity-35',
has && !isSelected && 'bg-white/[0.03]',
)}
>
<span
className={cn(
'text-[11px] md:text-sm font-bold leading-none flex items-center justify-center',
isToday
? cn('h-5 w-5 md:h-6 md:w-6 rounded-full', accent.solid)
: inMonth ? 'text-gray-200' : 'text-gray-500',
)}
>
{cell.getDate()}
</span>
{/* Item dots (max 3, then +N) */}
{has && (
<div className="mt-auto mb-1 md:mb-1.5 flex items-center gap-0.5">
{dayItems.slice(0, 3).map((it, i) => (
<span key={i} className={cn('h-1 w-1 md:h-1.5 md:w-1.5 rounded-full', accent.dot)} />
))}
{dayItems.length > 3 && (
<span className="text-[7px] md:text-[9px] font-bold text-gray-400 leading-none ml-0.5">
+{dayItems.length - 3}
</span>
)}
</div>
)}
</button>
);
})}
</div>
{/* Footer summary */}
<div className="mt-4 flex items-center justify-between text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<span className={cn('h-2 w-2 rounded-full', accent.dot)} />
{show === 'events' ? t('placesUi.calendar.eventDay') : t('placesUi.calendar.tourDay')}
</span>
<span>
<span className={cn('font-bold', accent.text)}>{totalThisMonth}</span>{' '}
{show === 'events'
? t('placesUi.calendar.eventsThisMonth')
: t('placesUi.calendar.toursThisMonth')}
</span>
</div>
</div>
{/* ── Selected-day agenda ── */}
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5 lg:sticky lg:top-24 lg:self-start">
<div className="flex items-center gap-2 mb-4">
{show === 'events'
? <CalendarIcon className={cn('h-4 w-4', accent.text)} />
: <Route className={cn('h-4 w-4', accent.text)} />}
<h3 className="text-sm font-black uppercase tracking-wide">
{sameDay(selected, today) ? t('placesUi.calendar.today') : formatDayLong(selected)}
</h3>
</div>
{selectedItems.length > 0 ? (
<div className="space-y-2.5">
{selectedItems.map((it) => {
const multiDay = it.end && it.end > it.start && !sameDay(new Date(it.start * 1000), new Date(it.end * 1000));
return (
<Link
key={it.id}
to={it.to}
className={cn(
'group block rounded-xl border p-3 transition-all hover:bg-white/[0.04] active:scale-[0.99]',
accent.border,
'bg-white/[0.02]',
)}
>
<div className="flex items-start gap-2.5">
<span className={cn('mt-1 h-2 w-2 flex-shrink-0 rounded-full', accent.dot)} />
<div className="min-w-0 flex-1">
<p className="font-bold text-sm leading-snug text-white group-hover:underline decoration-white/30 truncate">
{it.title}
</p>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-gray-400">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{multiDay
? `${new Date(it.start * 1000).toLocaleDateString([], { month: 'short', day: 'numeric' })} – ${new Date((it.end as number) * 1000).toLocaleDateString([], { month: 'short', day: 'numeric' })}`
: it.allDay
? t('placesUi.calendar.allDay')
: formatTime(it.start)}
</span>
{it.location && (
<span className="flex items-center gap-1 truncate">
<MapPin className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{it.location}</span>
</span>
)}
{it.kind === 'event' && (it.goingCount ?? 0) > 0 && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{it.goingCount} {t('placesUi.calendar.going')}
</span>
)}
</div>
</div>
</div>
</Link>
);
})}
</div>
) : (
<div className="rounded-xl border border-dashed border-white/10 py-8 px-4 text-center">
<p className="text-sm text-gray-500">
{t('placesUi.calendar.nothingOnDay')}
</p>
<p className="mt-1 text-xs text-gray-600">
{t('placesUi.calendar.tapHighlighted')}
</p>
</div>
)}
</div>
</div>
);
}