From 8f83993854ef7d40d9d5547d5fadd5f887722c2e Mon Sep 17 00:00:00 2001 From: Tero Halla-aho Date: Sun, 21 Dec 2025 16:52:24 +0200 Subject: [PATCH] Preserve edits and add availability navigation --- app/api/listings/[id]/availability/route.ts | 33 ++++++ app/components/AvailabilityCalendar.tsx | 109 ++++++++++++++++---- app/listings/[slug]/page.tsx | 10 +- app/listings/edit/[id]/page.tsx | 4 +- lib/calendar.ts | 9 +- 5 files changed, 133 insertions(+), 32 deletions(-) create mode 100644 app/api/listings/[id]/availability/route.ts diff --git a/app/api/listings/[id]/availability/route.ts b/app/api/listings/[id]/availability/route.ts new file mode 100644 index 0000000..90a6539 --- /dev/null +++ b/app/api/listings/[id]/availability/route.ts @@ -0,0 +1,33 @@ +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 }); +} diff --git a/app/components/AvailabilityCalendar.tsx b/app/components/AvailabilityCalendar.tsx index 9264eb4..9a151c4 100644 --- a/app/components/AvailabilityCalendar.tsx +++ b/app/components/AvailabilityCalendar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useI18n } from './I18nProvider'; type MonthView = { @@ -8,10 +8,9 @@ type MonthView = { days: { label: string; date: string; blocked: boolean; isFiller: boolean }[]; }; -function buildMonths(monthCount: number, blocked: Set): MonthView[] { +function buildMonths(monthCount: number, blocked: Set, startYear: number, startMonth: number): MonthView[] { const months: MonthView[] = []; - const base = new Date(); - base.setUTCDate(1); + const base = new Date(Date.UTC(startYear, startMonth, 1)); for (let i = 0; i < monthCount; i += 1) { const monthDate = new Date(base); @@ -40,25 +39,99 @@ function buildMonths(monthCount: number, blocked: Set): MonthView[] { return months; } -export default function AvailabilityCalendar({ - blockedDates, - months = 2, - disabled = false, -}: { - blockedDates: string[]; - months?: number; - disabled?: boolean; -}) { +type AvailabilityResponse = { blockedDates: string[] }; + +export default function AvailabilityCalendar({ listingId, hasCalendar, months = 2 }: { listingId: string; hasCalendar: boolean; months?: number }) { const { t } = useI18n(); + const today = useMemo(() => new Date(), []); + const [month, setMonth] = useState(today.getUTCMonth()); + const [year, setYear] = useState(today.getUTCFullYear()); + const [blockedDates, setBlockedDates] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); const blockedSet = useMemo(() => new Set(blockedDates), [blockedDates]); - const monthViews = useMemo(() => buildMonths(months, blockedSet), [months, blockedSet]); + const monthViews = useMemo(() => buildMonths(months, blockedSet, year, month), [months, 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(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 ( -
-
- {t('availabilityTitle')} - {t('availabilityLegendBooked')} +
+
+
+ {t('availabilityTitle')} + {t('availabilityLegendBooked')} +
+
+ + + + +
+ {error ?
{error}
: null}
{monthViews.map((month) => (
diff --git a/app/listings/[slug]/page.tsx b/app/listings/[slug]/page.tsx index c97aafd..831f85c 100644 --- a/app/listings/[slug]/page.tsx +++ b/app/listings/[slug]/page.tsx @@ -6,7 +6,7 @@ import { cookies, headers } from 'next/headers'; import { getListingBySlug, DEFAULT_LOCALE, withResolvedListingImages } from '../../../lib/listings'; import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing'; import { resolveLocale, t as translate } from '../../../lib/i18n'; -import { getCalendarRanges, expandBlockedDates } from '../../../lib/calendar'; +import AvailabilityCalendar from '../../components/AvailabilityCalendar'; import { verifyAccessToken } from '../../../lib/jwt'; import AvailabilityCalendar from '../../components/AvailabilityCalendar'; import { getSiteSettings } from '../../../lib/settings'; @@ -84,12 +84,6 @@ export default async function ListingPage({ params }: ListingPageProps) { } }); 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 = [ listing.hasSauna ? { icon: amenityIcons.sauna, label: t('amenitySauna') } : null, listing.hasFireplace ? { icon: amenityIcons.fireplace, label: t('amenityFireplace') } : null, @@ -192,7 +186,7 @@ export default async function ListingPage({ params }: ListingPageProps) {
- + {!hasCalendar ? (
{ +async function fetchCalendarUrl(url: string, forceRefresh = false): Promise { const cached = globalCache.get(url) as CacheEntry | undefined; const now = Date.now(); - if (cached && cached.expiresAt > now) { + if (!forceRefresh && cached && cached.expiresAt > now) { return cached.ranges; } @@ -97,10 +97,11 @@ async function fetchCalendarUrl(url: string): Promise { } } -export async function getCalendarRanges(urls: string[]): Promise { +export async function getCalendarRanges(urls: string[], opts?: { forceRefresh?: boolean }): Promise { + const forceRefresh = Boolean(opts?.forceRefresh); const unique = Array.from(new Set(urls.filter(Boolean))); if (!unique.length) return []; - const results = await Promise.all(unique.map(fetchCalendarUrl)); + const results = await Promise.all(unique.map((u) => fetchCalendarUrl(u, forceRefresh))); return results.flat(); } -- 2.45.3