~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).
416 lines
18 KiB
TypeScript
416 lines
18 KiB
TypeScript
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>
|
||
);
|
||
}
|