275 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|