lomavuokraus/app/components/AvailabilityCalendar.tsx
Tero Halla-aho 0bb709d9c5
Some checks failed
CI / checks (push) Has been cancelled
chore: fix audit alerts and formatting
2026-02-04 12:43:03 +02:00

275 lines
7.9 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "./I18nProvider";
type MonthView = {
label: string;
days: { label: string; date: string; blocked: boolean; isFiller: boolean }[];
};
function buildMonths(
monthCount: number,
blocked: Set<string>,
startYear: number,
startMonth: number,
): MonthView[] {
const months: MonthView[] = [];
const base = new Date(Date.UTC(startYear, startMonth, 1));
for (let i = 0; i < monthCount; i += 1) {
const monthDate = new Date(base);
monthDate.setUTCMonth(base.getUTCMonth() + i);
const year = monthDate.getUTCFullYear();
const month = monthDate.getUTCMonth();
const label = monthDate.toLocaleDateString(undefined, {
month: "long",
year: "numeric",
});
const firstDay = new Date(Date.UTC(year, month, 1));
const startWeekday = firstDay.getUTCDay(); // 0=Sun
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
const days: MonthView["days"] = [];
for (let f = 0; f < startWeekday; f += 1) {
days.push({ label: "", date: "", blocked: false, isFiller: true });
}
for (let d = 1; d <= daysInMonth; d += 1) {
const date = new Date(Date.UTC(year, month, d));
const iso = date.toISOString().slice(0, 10);
days.push({
label: String(d),
date: iso,
blocked: blocked.has(iso),
isFiller: false,
});
}
months.push({ label, days });
}
return months;
}
type AvailabilityResponse = { blockedDates: string[] };
export default function AvailabilityCalendar({
listingId,
hasCalendar,
}: {
listingId: string;
hasCalendar: boolean;
}) {
const { t, locale } = useI18n();
const today = useMemo(() => new Date(), []);
const [month, setMonth] = useState<number>(today.getUTCMonth());
const [year, setYear] = useState<number>(today.getUTCFullYear());
const [blockedDates, setBlockedDates] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const monthCount = 1;
const blockedSet = useMemo(() => new Set(blockedDates), [blockedDates]);
const monthViews = useMemo(
() => buildMonths(monthCount, blockedSet, year, month),
[monthCount, blockedSet, year, month],
);
useEffect(() => {
if (!hasCalendar) return;
setLoading(true);
setError(null);
const controller = new AbortController();
const params = new URLSearchParams({
month: String(month),
year: String(year),
months: String(monthCount),
refresh: "1",
});
fetch(`/api/listings/${listingId}/availability?${params.toString()}`, {
cache: "no-store",
signal: controller.signal,
})
.then(async (res) => {
const data: AvailabilityResponse = await res.json();
if (!res.ok)
throw new Error(
(data as any)?.error || "Failed to load availability",
);
return data;
})
.then((data) => {
setBlockedDates(
Array.isArray(data.blockedDates) ? data.blockedDates : [],
);
})
.catch((err) => {
if (err.name === "AbortError") return;
setError(err.message || "Failed to load availability");
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [listingId, hasCalendar, month, year, monthCount]);
function shiftMonth(delta: number) {
const next = new Date(Date.UTC(year, month, 1));
next.setUTCMonth(next.getUTCMonth() + delta);
setMonth(next.getUTCMonth());
setYear(next.getUTCFullYear());
}
const monthOptions = useMemo(
() =>
Array.from({ length: 12 }, (_, m) => ({
value: m,
label: new Date(Date.UTC(2020, m, 1)).toLocaleString(locale, {
month: "long",
}),
})),
[locale],
);
const yearOptions = useMemo(() => {
const current = today.getUTCFullYear();
return Array.from({ length: 5 }, (_, i) => current - 1 + i);
}, [today]);
return (
<div style={{ display: "grid", gap: 12, opacity: hasCalendar ? 1 : 0.5 }}>
<div
style={{
display: "flex",
gap: 10,
alignItems: "center",
flexWrap: "wrap",
}}
>
<div
style={{
display: "flex",
gap: 8,
alignItems: "center",
flexWrap: "wrap",
}}
>
<span style={{ fontWeight: 700 }}>{t("availabilityTitle")}</span>
</div>
<div
style={{
marginLeft: "auto",
display: "flex",
alignItems: "center",
gap: 6,
flexWrap: "wrap",
}}
>
<span className="badge secondary">
{t("availabilityLegendBooked")}
</span>
<button
type="button"
className="button secondary"
onClick={() => shiftMonth(-1)}
disabled={!hasCalendar || loading}
style={{ padding: "6px 10px" }}
>
</button>
<button
type="button"
className="button secondary"
onClick={() => shiftMonth(1)}
disabled={!hasCalendar || loading}
style={{ padding: "6px 10px" }}
>
</button>
</div>
</div>
{error ? (
<div style={{ color: "#f87171", fontSize: 13 }}>{error}</div>
) : null}
{monthViews.map((monthView) => (
<div key={monthView.label} className="panel" style={{ padding: 12 }}>
<div
style={{
display: "flex",
gap: 8,
alignItems: "center",
marginBottom: 8,
}}
>
<select
value={month}
onChange={(e) => setMonth(Number(e.target.value))}
disabled={!hasCalendar || loading}
style={{ flex: 1 }}
>
{monthOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
disabled={!hasCalendar || loading}
style={{ width: 96 }}
>
{yearOptions.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 6,
fontSize: 12,
textAlign: "center",
}}
>
{["S", "M", "T", "W", "T", "F", "S"].map((d) => (
<div key={d} style={{ color: "#94a3b8", fontWeight: 600 }}>
{d}
</div>
))}
{monthView.days.map((day, idx) => (
<div
key={`${monthView.label}-${idx}`}
style={{
height: 32,
borderRadius: 8,
background: day.isFiller
? "transparent"
: day.blocked
? "rgba(248,113,113,0.2)"
: "rgba(148,163,184,0.1)",
color: day.isFiller
? "transparent"
: day.blocked
? "#ef4444"
: "#e2e8f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
aria-label={
day.date
? `${day.date}${day.blocked ? " (booked)" : ""}`
: undefined
}
>
{day.label}
</div>
))}
</div>
</div>
))}
</div>
);
}