diff --git a/PROGRESS.md b/PROGRESS.md index e1dcdc0..04dcc0d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -62,6 +62,7 @@ - Mermaid docs fixed: all sequence diagrams declare their participants and avoid “->” inside message text; the listing creation diagram message was rewritten to prevent parse errors. Use mermaid.live or browser console to debug future syntax issues (errors flag the offending line/column). - New amenities added: kitchen, dishwasher, washing machine, barbecue; API/UI/i18n updated and seeds randomized to populate missing prices/amenities. Prisma migration `20250210_more_amenities` applied to shared DB; registry pull secret added to k8s Deployment to avoid image pull errors in prod. - Added About and Pricing pages (FI/EN), moved highlights/runtime config to About, and linked footer navigation. +- Availability calendars: listings can store iCal URLs, merged into a combined availability calendar on detail pages; availability filtering added to search along with amenity filters; new migration `20251127_calendar_urls`. To resume: 1) If desired, render diagrams locally: PlantUML in `docs/plantuml`, draw.io in `docs/drawio`. diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index 6cd9494..feabad1 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -4,6 +4,7 @@ import { prisma } from '../../../lib/prisma'; import { requireAuth } from '../../../lib/jwt'; import { resolveLocale } from '../../../lib/i18n'; import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing'; +import { getCalendarRanges, isRangeAvailable } from '../../../lib/calendar'; const MAX_IMAGES = 6; const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image @@ -32,6 +33,23 @@ function pickTranslation(translations: T[], locale return translations[0]; } +function normalizeCalendarUrls(input: unknown): string[] { + if (Array.isArray(input)) { + return input + .map((u) => (typeof u === 'string' ? u.trim() : '')) + .filter(Boolean) + .slice(0, 5); + } + if (typeof input === 'string') { + return input + .split(/\n|,/) + .map((u) => u.trim()) + .filter(Boolean) + .slice(0, 5); + } + return []; +} + export async function GET(req: Request) { const url = new URL(req.url); const searchParams = url.searchParams; @@ -40,15 +58,34 @@ export async function GET(req: Request) { const region = searchParams.get('region')?.trim(); const evChargingParam = searchParams.get('evCharging'); const evCharging = evChargingParam ? normalizeEvCharging(evChargingParam) : null; + const startDateParam = searchParams.get('availableStart'); + const endDateParam = searchParams.get('availableEnd'); + const startDate = startDateParam ? new Date(startDateParam) : null; + const endDate = endDateParam ? new Date(endDateParam) : null; + const availabilityFilterActive = Boolean(startDate && endDate && startDate < endDate); + const amenityFilters = searchParams.getAll('amenity').map((a) => a.trim().toLowerCase()); const locale = resolveLocale({ cookieLocale: null, acceptLanguage: req.headers.get('accept-language') }); const limit = Math.min(Number(searchParams.get('limit') ?? 40), 100); + const amenityWhere: Prisma.ListingWhereInput = {}; + if (amenityFilters.includes('sauna')) amenityWhere.hasSauna = true; + if (amenityFilters.includes('fireplace')) amenityWhere.hasFireplace = true; + if (amenityFilters.includes('wifi')) amenityWhere.hasWifi = true; + if (amenityFilters.includes('pets')) amenityWhere.petsAllowed = true; + if (amenityFilters.includes('lake')) amenityWhere.byTheLake = true; + if (amenityFilters.includes('ac')) amenityWhere.hasAirConditioning = true; + if (amenityFilters.includes('kitchen')) amenityWhere.hasKitchen = true; + if (amenityFilters.includes('dishwasher')) amenityWhere.hasDishwasher = true; + if (amenityFilters.includes('washer')) amenityWhere.hasWashingMachine = true; + if (amenityFilters.includes('barbecue')) amenityWhere.hasBarbecue = true; + const where: Prisma.ListingWhereInput = { status: ListingStatus.PUBLISHED, removedAt: null, city: city ? { contains: city, mode: 'insensitive' } : undefined, region: region ? { contains: region, mode: 'insensitive' } : undefined, evCharging: evCharging ?? undefined, + ...amenityWhere, translations: q ? { some: { @@ -72,7 +109,24 @@ export async function GET(req: Request) { take: Number.isNaN(limit) ? 40 : limit, }); - const payload = listings.map((listing) => { + let filteredListings = listings; + let availabilityMap = new Map(); + + if (availabilityFilterActive) { + const checks = await Promise.all( + listings.map(async (listing) => { + const urls = listing.calendarUrls ?? []; + if (!urls.length) return { id: listing.id, available: false }; + const ranges = await getCalendarRanges(urls); + const available = isRangeAvailable(ranges, startDate!, endDate!); + return { id: listing.id, available }; + }), + ); + availabilityMap = new Map(checks.map((c) => [c.id, c.available])); + filteredListings = listings.filter((l) => availabilityMap.get(l.id)); + } + + const payload = filteredListings.map((listing) => { const isSample = listing.isSample || listing.contactEmail === SAMPLE_EMAIL || @@ -112,6 +166,8 @@ export async function GET(req: Request) { priceHintPerNightEuros: listing.priceHintPerNightEuros, coverImage: resolveImageUrl(listing.images.find((img) => img.isCover) ?? listing.images[0] ?? { id: '', url: null, size: null }), isSample, + hasCalendar: Boolean(listing.calendarUrls?.length), + availableForDates: availabilityFilterActive ? Boolean(availabilityMap.get(listing.id)) : undefined, }; }); @@ -147,6 +203,7 @@ export async function POST(req: Request) { const beds = Number(body.beds ?? 1); const bathrooms = Number(body.bathrooms ?? 1); const priceHintPerNightEuros = body.priceHintPerNightEuros !== undefined && body.priceHintPerNightEuros !== null && body.priceHintPerNightEuros !== '' ? Math.round(Number(body.priceHintPerNightEuros)) : null; + const calendarUrls = normalizeCalendarUrls(body.calendarUrls); const images = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : []; if (Array.isArray(body.images) && body.images.length > MAX_IMAGES) { @@ -240,6 +297,7 @@ export async function POST(req: Request) { hasBarbecue: Boolean(body.hasBarbecue), evCharging: normalizeEvCharging(body.evCharging), priceHintPerNightEuros, + calendarUrls: calendarUrls.length ? calendarUrls : null, contactName, contactEmail, contactPhone: body.contactPhone ?? null, diff --git a/app/components/AvailabilityCalendar.tsx b/app/components/AvailabilityCalendar.tsx new file mode 100644 index 0000000..a011d58 --- /dev/null +++ b/app/components/AvailabilityCalendar.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useMemo } 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): MonthView[] { + const months: MonthView[] = []; + const base = new Date(); + base.setUTCDate(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; +} + +export default function AvailabilityCalendar({ blockedDates, months = 2 }: { blockedDates: string[]; months?: number }) { + const { t } = useI18n(); + const blockedSet = useMemo(() => new Set(blockedDates), [blockedDates]); + const monthViews = useMemo(() => buildMonths(months, blockedSet), [months, blockedSet]); + + return ( +
+
+ {t('availabilityTitle')} + {t('availabilityLegendBooked')} +
+
+ {monthViews.map((month) => ( +
+
{month.label}
+
+ {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d) => ( +
+ {d} +
+ ))} + {month.days.map((day, idx) => ( +
+ {day.label} +
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/app/listings/[slug]/page.tsx b/app/listings/[slug]/page.tsx index c587fd6..2e54ac4 100644 --- a/app/listings/[slug]/page.tsx +++ b/app/listings/[slug]/page.tsx @@ -5,6 +5,8 @@ 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'; type ListingPageProps = { params: { slug: string }; @@ -47,6 +49,14 @@ export default async function ListingPage({ params }: ListingPageProps) { const { listing, title, description, teaser, locale: translationLocale } = translation; const isSample = listing.isSample || listing.contactEmail === 'host@lomavuokraus.fi' || SAMPLE_LISTING_SLUGS.includes(params.slug); + const calendarUrls = listing.calendarUrls ?? []; + 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, @@ -64,6 +74,7 @@ export default async function ListingPage({ params }: ListingPageProps) { const addressLine = `${listing.streetAddress ? `${listing.streetAddress}, ` : ''}${listing.city}, ${listing.region}, ${listing.country}`; const capacityLine = `${t('capacityGuests', { count: listing.maxGuests })} · ${t('capacityBedrooms', { count: listing.bedrooms })} · ${t('capacityBeds', { count: listing.beds })} · ${t('capacityBathrooms', { count: listing.bathrooms })}`; const contactLine = `${listing.contactName} · ${listing.contactEmail}${listing.contactPhone ? ` · ${listing.contactPhone}` : ''}`; + const coverImage = listing.images.find((img) => img.isCover) ?? listing.images[0] ?? null; return (
@@ -91,6 +102,41 @@ export default async function ListingPage({ params }: ListingPageProps) { ) : null} + {(coverImage || hasCalendar) && ( +
+
+ {coverImage ? ( + + {coverImage.altText + + ) : ( +
+ )} +
+
+ {hasCalendar ? ( + + ) : ( +
+
{t('availabilityTitle')}
+

{t('availabilityMissing')}

+
+ )} +
+
+ )} {listing.images.length > 0 ? (
{listing.images @@ -133,6 +179,13 @@ export default async function ListingPage({ params }: ListingPageProps) {
{contactLine}
+
+ 📅 +
+
{t('searchAvailability')}
+
{hasCalendar ? t('calendarConnected') : t('availabilityMissing')}
+
+
{t('listingAmenities')}
{amenities.length === 0 ? ( diff --git a/app/listings/new/page.tsx b/app/listings/new/page.tsx index 7ab675f..8eeb208 100644 --- a/app/listings/new/page.tsx +++ b/app/listings/new/page.tsx @@ -47,6 +47,7 @@ export default function NewListingPage() { const [hasWashingMachine, setHasWashingMachine] = useState(false); const [hasBarbecue, setHasBarbecue] = useState(false); const [evCharging, setEvCharging] = useState<'NONE' | 'FREE' | 'PAID'>('NONE'); + const [calendarUrls, setCalendarUrls] = useState(''); const [selectedImages, setSelectedImages] = useState([]); const [coverImageIndex, setCoverImageIndex] = useState(1); const [message, setMessage] = useState(null); @@ -177,6 +178,7 @@ export default function NewListingPage() { evCharging, coverImageIndex, images: parseImages(), + calendarUrls, }), }); const data = await res.json(); @@ -196,6 +198,7 @@ export default function NewListingPage() { setLongitude(''); setContactName(''); setContactEmail(''); + setCalendarUrls(''); setSelectedImages([]); setCoverImageIndex(1); } @@ -322,6 +325,16 @@ export default function NewListingPage() { />
{t('priceHintHelp')}
+