fix/availability-calendar #20

Merged
thalla merged 3 commits from fix/availability-calendar into master 2025-12-21 22:49:16 +02:00
2 changed files with 60 additions and 60 deletions

View file

@ -5,12 +5,12 @@ import { expandBlockedDates, getCalendarRanges } from '../../../../../lib/calend
export async function GET(_: Request, { params }: { params: { id: string } }) { export async function GET(_: Request, { params }: { params: { id: string } }) {
const monthParam = Number(new URL(_.url).searchParams.get('month') ?? new Date().getUTCMonth()); const monthParam = Number(new URL(_.url).searchParams.get('month') ?? new Date().getUTCMonth());
const yearParam = Number(new URL(_.url).searchParams.get('year') ?? new Date().getUTCFullYear()); const yearParam = Number(new URL(_.url).searchParams.get('year') ?? new Date().getUTCFullYear());
const monthsParam = Math.min(Number(new URL(_.url).searchParams.get('months') ?? 2), 12); const monthsParam = Math.min(Number(new URL(_.url).searchParams.get('months') ?? 1), 12);
const forceRefresh = new URL(_.url).searchParams.get('refresh') === '1'; const forceRefresh = new URL(_.url).searchParams.get('refresh') === '1';
const month = Number.isFinite(monthParam) ? monthParam : new Date().getUTCMonth(); const month = Number.isFinite(monthParam) ? monthParam : new Date().getUTCMonth();
const year = Number.isFinite(yearParam) ? yearParam : new Date().getUTCFullYear(); const year = Number.isFinite(yearParam) ? yearParam : new Date().getUTCFullYear();
const months = Number.isFinite(monthsParam) && monthsParam > 0 ? monthsParam : 2; const months = Number.isFinite(monthsParam) && monthsParam > 0 ? monthsParam : 1;
const listing = await prisma.listing.findUnique({ where: { id: params.id }, select: { calendarUrls: true } }); const listing = await prisma.listing.findUnique({ where: { id: params.id }, select: { calendarUrls: true } });
if (!listing) { if (!listing) {

View file

@ -41,16 +41,17 @@ function buildMonths(monthCount: number, blocked: Set<string>, startYear: number
type AvailabilityResponse = { blockedDates: string[] }; type AvailabilityResponse = { blockedDates: string[] };
export default function AvailabilityCalendar({ listingId, hasCalendar, months = 2 }: { listingId: string; hasCalendar: boolean; months?: number }) { export default function AvailabilityCalendar({ listingId, hasCalendar }: { listingId: string; hasCalendar: boolean }) {
const { t } = useI18n(); const { t, locale } = useI18n();
const today = useMemo(() => new Date(), []); const today = useMemo(() => new Date(), []);
const [month, setMonth] = useState<number>(today.getUTCMonth()); const [month, setMonth] = useState<number>(today.getUTCMonth());
const [year, setYear] = useState<number>(today.getUTCFullYear()); const [year, setYear] = useState<number>(today.getUTCFullYear());
const [blockedDates, setBlockedDates] = useState<string[]>([]); const [blockedDates, setBlockedDates] = useState<string[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const monthCount = 1;
const blockedSet = useMemo(() => new Set(blockedDates), [blockedDates]); const blockedSet = useMemo(() => new Set(blockedDates), [blockedDates]);
const monthViews = useMemo(() => buildMonths(months, blockedSet, year, month), [months, blockedSet, year, month]); const monthViews = useMemo(() => buildMonths(monthCount, blockedSet, year, month), [monthCount, blockedSet, year, month]);
useEffect(() => { useEffect(() => {
if (!hasCalendar) return; if (!hasCalendar) return;
@ -60,7 +61,7 @@ export default function AvailabilityCalendar({ listingId, hasCalendar, months =
const params = new URLSearchParams({ const params = new URLSearchParams({
month: String(month), month: String(month),
year: String(year), year: String(year),
months: String(months), months: String(monthCount),
refresh: '1', refresh: '1',
}); });
fetch(`/api/listings/${listingId}/availability?${params.toString()}`, { cache: 'no-store', signal: controller.signal }) fetch(`/api/listings/${listingId}/availability?${params.toString()}`, { cache: 'no-store', signal: controller.signal })
@ -79,7 +80,7 @@ export default function AvailabilityCalendar({ listingId, hasCalendar, months =
.finally(() => setLoading(false)); .finally(() => setLoading(false));
return () => controller.abort(); return () => controller.abort();
}, [listingId, hasCalendar, month, year, months]); }, [listingId, hasCalendar, month, year, monthCount]);
function shiftMonth(delta: number) { function shiftMonth(delta: number) {
const next = new Date(Date.UTC(year, month, 1)); const next = new Date(Date.UTC(year, month, 1));
@ -92,9 +93,9 @@ export default function AvailabilityCalendar({ listingId, hasCalendar, months =
() => () =>
Array.from({ length: 12 }, (_, m) => ({ Array.from({ length: 12 }, (_, m) => ({
value: m, value: m,
label: new Date(Date.UTC(2020, m, 1)).toLocaleString(undefined, { month: 'long' }), label: new Date(Date.UTC(2020, m, 1)).toLocaleString(locale, { month: 'long' }),
})), })),
[], [locale],
); );
const yearOptions = useMemo(() => { const yearOptions = useMemo(() => {
const current = today.getUTCFullYear(); const current = today.getUTCFullYear();
@ -106,71 +107,70 @@ export default function AvailabilityCalendar({ listingId, hasCalendar, months =
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 700 }}>{t('availabilityTitle')}</span> <span style={{ fontWeight: 700 }}>{t('availabilityTitle')}</span>
<span className="badge secondary">{t('availabilityLegendBooked')}</span>
</div> </div>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> <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 type="button" className="button secondary" onClick={() => shiftMonth(-1)} disabled={!hasCalendar || loading} style={{ padding: '6px 10px' }}>
</button> </button>
<select value={month} onChange={(e) => setMonth(Number(e.target.value))} disabled={!hasCalendar || loading}>
{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}>
{yearOptions.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<button type="button" className="button secondary" onClick={() => shiftMonth(1)} disabled={!hasCalendar || loading} style={{ padding: '6px 10px' }}> <button type="button" className="button secondary" onClick={() => shiftMonth(1)} disabled={!hasCalendar || loading} style={{ padding: '6px 10px' }}>
</button> </button>
</div> </div>
</div> </div>
{error ? <div style={{ color: '#f87171', fontSize: 13 }}>{error}</div> : null} {error ? <div style={{ color: '#f87171', fontSize: 13 }}>{error}</div> : null}
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: `repeat(${months}, minmax(180px, 1fr))` }}> {monthViews.map((monthView) => (
{monthViews.map((month) => ( <div key={monthView.label} className="panel" style={{ padding: 12 }}>
<div key={month.label} className="panel" style={{ padding: 12 }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{month.label}</div> <select value={month} onChange={(e) => setMonth(Number(e.target.value))} disabled={!hasCalendar || loading} style={{ flex: 1 }}>
<div {monthOptions.map((opt) => (
style={{ <option key={opt.value} value={opt.value}>
display: 'grid', {opt.label}
gridTemplateColumns: 'repeat(7, 1fr)', </option>
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>
))} ))}
{month.days.map((day, idx) => ( </select>
<div <select value={year} onChange={(e) => setYear(Number(e.target.value))} disabled={!hasCalendar || loading} style={{ width: 96 }}>
key={`${month.label}-${idx}`} {yearOptions.map((y) => (
style={{ <option key={y} value={y}>
height: 32, {y}
borderRadius: 8, </option>
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> </select>
</div> </div>
))} <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> </div>
); );
} }