Compare commits
No commits in common. "454006a82684062539667f4f9eee9647b9de9587" and "b68a75d0b07e4d9b4fd4220e203f2e6506a0d7f9" have entirely different histories.
454006a826
...
b68a75d0b0
5 changed files with 32 additions and 133 deletions
|
|
@ -1,33 +0,0 @@
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { prisma } from '../../../../../lib/prisma';
|
|
||||||
import { expandBlockedDates, getCalendarRanges } from '../../../../../lib/calendar';
|
|
||||||
|
|
||||||
export async function GET(_: Request, { params }: { params: { id: string } }) {
|
|
||||||
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 monthsParam = Math.min(Number(new URL(_.url).searchParams.get('months') ?? 2), 12);
|
|
||||||
const forceRefresh = new URL(_.url).searchParams.get('refresh') === '1';
|
|
||||||
|
|
||||||
const month = Number.isFinite(monthParam) ? monthParam : new Date().getUTCMonth();
|
|
||||||
const year = Number.isFinite(yearParam) ? yearParam : new Date().getUTCFullYear();
|
|
||||||
const months = Number.isFinite(monthsParam) && monthsParam > 0 ? monthsParam : 2;
|
|
||||||
|
|
||||||
const listing = await prisma.listing.findUnique({ where: { id: params.id }, select: { calendarUrls: true } });
|
|
||||||
if (!listing) {
|
|
||||||
return NextResponse.json({ error: 'Listing not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const urls = (listing.calendarUrls ?? []).filter(Boolean);
|
|
||||||
if (!urls.length) {
|
|
||||||
return NextResponse.json({ blockedDates: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = new Date(Date.UTC(year, month, 1));
|
|
||||||
const end = new Date(start);
|
|
||||||
end.setUTCMonth(end.getUTCMonth() + months);
|
|
||||||
|
|
||||||
const ranges = await getCalendarRanges(urls, { forceRefresh });
|
|
||||||
const blockedDates = expandBlockedDates(ranges, start, end);
|
|
||||||
|
|
||||||
return NextResponse.json({ blockedDates });
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useI18n } from './I18nProvider';
|
import { useI18n } from './I18nProvider';
|
||||||
|
|
||||||
type MonthView = {
|
type MonthView = {
|
||||||
|
|
@ -8,9 +8,10 @@ type MonthView = {
|
||||||
days: { label: string; date: string; blocked: boolean; isFiller: boolean }[];
|
days: { label: string; date: string; blocked: boolean; isFiller: boolean }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildMonths(monthCount: number, blocked: Set<string>, startYear: number, startMonth: number): MonthView[] {
|
function buildMonths(monthCount: number, blocked: Set<string>): MonthView[] {
|
||||||
const months: MonthView[] = [];
|
const months: MonthView[] = [];
|
||||||
const base = new Date(Date.UTC(startYear, startMonth, 1));
|
const base = new Date();
|
||||||
|
base.setUTCDate(1);
|
||||||
|
|
||||||
for (let i = 0; i < monthCount; i += 1) {
|
for (let i = 0; i < monthCount; i += 1) {
|
||||||
const monthDate = new Date(base);
|
const monthDate = new Date(base);
|
||||||
|
|
@ -39,99 +40,25 @@ function buildMonths(monthCount: number, blocked: Set<string>, startYear: number
|
||||||
return months;
|
return months;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AvailabilityResponse = { blockedDates: string[] };
|
export default function AvailabilityCalendar({
|
||||||
|
blockedDates,
|
||||||
export default function AvailabilityCalendar({ listingId, hasCalendar, months = 2 }: { listingId: string; hasCalendar: boolean; months?: number }) {
|
months = 2,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
blockedDates: string[];
|
||||||
|
months?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = 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 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(months, blockedSet), [months, blockedSet]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasCalendar) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const controller = new AbortController();
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
month: String(month),
|
|
||||||
year: String(year),
|
|
||||||
months: String(months),
|
|
||||||
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, months]);
|
|
||||||
|
|
||||||
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(undefined, { month: 'long' }),
|
|
||||||
})),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const yearOptions = useMemo(() => {
|
|
||||||
const current = today.getUTCFullYear();
|
|
||||||
return Array.from({ length: 5 }, (_, i) => current - 1 + i);
|
|
||||||
}, [today]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 12, opacity: hasCalendar ? 1 : 0.5 }}>
|
<div style={{ display: 'grid', gap: 16, opacity: disabled ? 0.45 : 1 }}>
|
||||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
<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>
|
<span className="badge secondary">{t('availabilityLegendBooked')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
|
||||||
<button type="button" className="button secondary" onClick={() => shiftMonth(-1)} disabled={!hasCalendar || loading} style={{ padding: '6px 10px' }}>
|
|
||||||
←
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{error ? <div style={{ color: '#f87171', fontSize: 13 }}>{error}</div> : null}
|
|
||||||
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: `repeat(${months}, minmax(180px, 1fr))` }}>
|
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: `repeat(${months}, minmax(180px, 1fr))` }}>
|
||||||
{monthViews.map((month) => (
|
{monthViews.map((month) => (
|
||||||
<div key={month.label} className="panel" style={{ padding: 12 }}>
|
<div key={month.label} className="panel" style={{ padding: 12 }}>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { cookies, headers } from 'next/headers';
|
||||||
import { getListingBySlug, DEFAULT_LOCALE, withResolvedListingImages } from '../../../lib/listings';
|
import { getListingBySlug, DEFAULT_LOCALE, withResolvedListingImages } from '../../../lib/listings';
|
||||||
import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing';
|
import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing';
|
||||||
import { resolveLocale, t as translate } from '../../../lib/i18n';
|
import { resolveLocale, t as translate } from '../../../lib/i18n';
|
||||||
import AvailabilityCalendar from '../../components/AvailabilityCalendar';
|
import { getCalendarRanges, expandBlockedDates } from '../../../lib/calendar';
|
||||||
import { verifyAccessToken } from '../../../lib/jwt';
|
import { verifyAccessToken } from '../../../lib/jwt';
|
||||||
import AvailabilityCalendar from '../../components/AvailabilityCalendar';
|
import AvailabilityCalendar from '../../components/AvailabilityCalendar';
|
||||||
import { getSiteSettings } from '../../../lib/settings';
|
import { getSiteSettings } from '../../../lib/settings';
|
||||||
|
|
@ -84,6 +84,12 @@ export default async function ListingPage({ params }: ListingPageProps) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const hasCalendar = calendarUrls.length > 0;
|
const hasCalendar = calendarUrls.length > 0;
|
||||||
|
const availabilityFrom = new Date();
|
||||||
|
availabilityFrom.setUTCHours(0, 0, 0, 0);
|
||||||
|
const availabilityTo = new Date(availabilityFrom);
|
||||||
|
availabilityTo.setUTCDate(availabilityTo.getUTCDate() + 90);
|
||||||
|
const availabilityRanges = hasCalendar ? await getCalendarRanges(calendarUrls) : [];
|
||||||
|
const blockedDates = hasCalendar ? expandBlockedDates(availabilityRanges, availabilityFrom, availabilityTo) : [];
|
||||||
const amenities = [
|
const amenities = [
|
||||||
listing.hasSauna ? { icon: amenityIcons.sauna, label: t('amenitySauna') } : null,
|
listing.hasSauna ? { icon: amenityIcons.sauna, label: t('amenitySauna') } : null,
|
||||||
listing.hasFireplace ? { icon: amenityIcons.fireplace, label: t('amenityFireplace') } : null,
|
listing.hasFireplace ? { icon: amenityIcons.fireplace, label: t('amenityFireplace') } : null,
|
||||||
|
|
@ -186,7 +192,7 @@ export default async function ListingPage({ params }: ListingPageProps) {
|
||||||
</div>
|
</div>
|
||||||
<div className="panel" style={{ padding: 12 }}>
|
<div className="panel" style={{ padding: 12 }}>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<AvailabilityCalendar listingId={listing.id} hasCalendar={hasCalendar} />
|
<AvailabilityCalendar blockedDates={blockedDates} months={1} disabled={!hasCalendar} />
|
||||||
{!hasCalendar ? (
|
{!hasCalendar ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ export default function EditListingPage({ params }: { params: { id: string } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadListing();
|
loadListing();
|
||||||
}, [params.id]);
|
}, [params.id, currentLocale]);
|
||||||
|
|
||||||
function localeStatus(locale: Locale) {
|
function localeStatus(locale: Locale) {
|
||||||
const { title, description } = translations[locale];
|
const { title, description } = translations[locale];
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,10 @@ function parseIcs(text: string): TimeRange[] {
|
||||||
return ranges;
|
return ranges;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCalendarUrl(url: string, forceRefresh = false): Promise<TimeRange[]> {
|
async function fetchCalendarUrl(url: string): Promise<TimeRange[]> {
|
||||||
const cached = globalCache.get(url) as CacheEntry | undefined;
|
const cached = globalCache.get(url) as CacheEntry | undefined;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!forceRefresh && cached && cached.expiresAt > now) {
|
if (cached && cached.expiresAt > now) {
|
||||||
return cached.ranges;
|
return cached.ranges;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,11 +97,10 @@ async function fetchCalendarUrl(url: string, forceRefresh = false): Promise<Time
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCalendarRanges(urls: string[], opts?: { forceRefresh?: boolean }): Promise<TimeRange[]> {
|
export async function getCalendarRanges(urls: string[]): Promise<TimeRange[]> {
|
||||||
const forceRefresh = Boolean(opts?.forceRefresh);
|
|
||||||
const unique = Array.from(new Set(urls.filter(Boolean)));
|
const unique = Array.from(new Set(urls.filter(Boolean)));
|
||||||
if (!unique.length) return [];
|
if (!unique.length) return [];
|
||||||
const results = await Promise.all(unique.map((u) => fetchCalendarUrl(u, forceRefresh)));
|
const results = await Promise.all(unique.map(fetchCalendarUrl));
|
||||||
return results.flat();
|
return results.flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue