Add calendar availability and amenity filters

This commit is contained in:
Tero Halla-aho 2025-11-27 12:44:42 +02:00
parent 2a835d9875
commit a3ac05bf08
10 changed files with 443 additions and 1 deletions

View file

@ -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`.

View file

@ -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<T extends { locale: string }>(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<string, boolean>();
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,

View file

@ -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<string>): 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 (
<div style={{ display: 'grid', gap: 16 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ fontWeight: 700 }}>{t('availabilityTitle')}</span>
<span className="badge secondary">{t('availabilityLegendBooked')}</span>
</div>
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: `repeat(${months}, minmax(180px, 1fr))` }}>
{monthViews.map((month) => (
<div key={month.label} className="panel" style={{ padding: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{month.label}</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>
))}
{month.days.map((day, idx) => (
<div
key={`${month.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>
);
}

View file

@ -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 (
<main className="listing-shell">
@ -91,6 +102,41 @@ export default async function ListingPage({ params }: ListingPageProps) {
</a>
</div>
) : null}
{(coverImage || hasCalendar) && (
<div
style={{
marginTop: 16,
display: 'grid',
gap: 12,
gridTemplateColumns: 'minmax(240px, 1.4fr) minmax(240px, 1fr)',
alignItems: 'stretch',
}}
>
<div className="panel" style={{ padding: 0, overflow: 'hidden' }}>
{coverImage ? (
<a href={coverImage.url || ''} target="_blank" rel="noreferrer" style={{ display: 'block', cursor: 'zoom-in' }}>
<img
src={coverImage.url || ''}
alt={coverImage.altText ?? title}
style={{ width: '100%', height: 280, objectFit: 'cover' }}
/>
</a>
) : (
<div style={{ width: '100%', height: 280, background: 'linear-gradient(120deg, rgba(14,165,233,0.15), rgba(30,64,175,0.2))' }} />
)}
</div>
<div className="panel" style={{ padding: 12 }}>
{hasCalendar ? (
<AvailabilityCalendar blockedDates={blockedDates} months={2} />
) : (
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontWeight: 700 }}>{t('availabilityTitle')}</div>
<p style={{ color: '#cbd5e1', margin: 0 }}>{t('availabilityMissing')}</p>
</div>
)}
</div>
</div>
)}
{listing.images.length > 0 ? (
<div style={{ marginTop: 12, display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
{listing.images
@ -133,6 +179,13 @@ export default async function ListingPage({ params }: ListingPageProps) {
<div>{contactLine}</div>
</div>
</div>
<div className="fact-row">
<span aria-hidden className="amenity-icon">📅</span>
<div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('searchAvailability')}</div>
<div>{hasCalendar ? t('calendarConnected') : t('availabilityMissing')}</div>
</div>
</div>
<div className="amenity-list">
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingAmenities')}</div>
{amenities.length === 0 ? (

View file

@ -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<SelectedImage[]>([]);
const [coverImageIndex, setCoverImageIndex] = useState(1);
const [message, setMessage] = useState<string | null>(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() {
/>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('priceHintHelp')}</div>
</label>
<label style={{ gridColumn: '1 / -1' }}>
{t('calendarUrlsLabel')}
<textarea
value={calendarUrls}
onChange={(e) => setCalendarUrls(e.target.value)}
placeholder="https://example.com/calendar.ics"
rows={3}
/>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('calendarUrlsHelp')}</div>
</label>
</div>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
<label>

View file

@ -36,6 +36,8 @@ type ListingResult = {
priceHintPerNightEuros: number | null;
coverImage: string | null;
isSample: boolean;
hasCalendar: boolean;
availableForDates?: boolean;
};
type LatLng = { lat: number; lon: number };
@ -171,6 +173,9 @@ export default function ListingsIndexPage() {
const [radiusKm, setRadiusKm] = useState(50);
const [geocoding, setGeocoding] = useState(false);
const [geoError, setGeoError] = useState<string | null>(null);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [amenities, setAmenities] = useState<string[]>([]);
const scrollRef = useRef<HTMLDivElement | null>(null);
const filteredByAddress = useMemo(() => {
@ -187,6 +192,19 @@ export default function ListingsIndexPage() {
return filteredByAddress.filter((l) => l.evCharging === evCharging);
}, [filteredByAddress, evCharging]);
const amenityOptions = [
{ key: 'sauna', label: t('amenitySauna') },
{ key: 'fireplace', label: t('amenityFireplace') },
{ key: 'wifi', label: t('amenityWifi') },
{ key: 'pets', label: t('amenityPets') },
{ key: 'lake', label: t('amenityLake') },
{ key: 'ac', label: t('amenityAirConditioning') },
{ key: 'kitchen', label: t('amenityKitchen') },
{ key: 'dishwasher', label: t('amenityDishwasher') },
{ key: 'washer', label: t('amenityWashingMachine') },
{ key: 'barbecue', label: t('amenityBarbecue') },
];
async function fetchListings() {
setLoading(true);
setError(null);
@ -196,6 +214,9 @@ export default function ListingsIndexPage() {
if (city) params.set('city', city);
if (region) params.set('region', region);
if (evCharging !== 'ALL') params.set('evCharging', evCharging);
if (startDate) params.set('availableStart', startDate);
if (endDate) params.set('availableEnd', endDate);
amenities.forEach((a) => params.append('amenity', a));
const res = await fetch(`/api/listings?${params.toString()}`, { cache: 'no-store' });
const data = await res.json();
if (!res.ok || data.error) {
@ -210,6 +231,10 @@ export default function ListingsIndexPage() {
}
}
function toggleAmenity(key: string) {
setAmenities((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
}
async function locateAddress() {
if (!addressQuery.trim()) return;
setGeocoding(true);
@ -283,6 +308,39 @@ export default function ListingsIndexPage() {
</select>
</label>
</div>
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', marginTop: 12 }}>
<label>
{t('startDate')}
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
</label>
<label>
{t('endDate')}
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</label>
</div>
<div style={{ marginTop: 8, color: '#cbd5e1', fontSize: 13 }}>{t('availabilityOnlyWithCalendar')}</div>
<div className="amenity-grid" style={{ marginTop: 12 }}>
<div style={{ gridColumn: '1 / -1', color: '#cbd5e1', fontWeight: 600 }}>{t('searchAmenities')}</div>
{amenityOptions.map((opt) => (
<button
key={opt.key}
type="button"
className={`amenity-option ${amenities.includes(opt.key) ? 'selected' : ''}`}
aria-pressed={amenities.includes(opt.key)}
onClick={() => toggleAmenity(opt.key)}
>
<div className="amenity-option-meta">
<span className="amenity-emoji" aria-hidden>
</span>
<span className="amenity-name">{opt.label}</span>
</div>
<span className="amenity-check" aria-hidden>
{amenities.includes(opt.key) ? '✓' : ''}
</span>
</button>
))}
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 12, flexWrap: 'wrap' }}>
<button className="button" onClick={fetchListings} disabled={loading}>
{loading ? t('loading') : t('searchButton')}
@ -296,6 +354,9 @@ export default function ListingsIndexPage() {
setEvCharging('ALL');
setAddressCenter(null);
setAddressQuery('');
setStartDate('');
setEndDate('');
setAmenities([]);
}}
>
{t('clearFilters')}
@ -395,6 +456,10 @@ export default function ListingsIndexPage() {
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', fontSize: 13 }}>
<span className="badge">{t('capacityGuests', { count: l.maxGuests })}</span>
<span className="badge">{t('capacityBedrooms', { count: l.bedrooms })}</span>
{l.hasCalendar ? <span className="badge secondary">{t('calendarConnected')}</span> : null}
{startDate && endDate && l.availableForDates ? (
<span className="badge">{t('availableForDates')}</span>
) : null}
{l.evCharging === 'FREE' ? <span className="badge">{t('amenityEvFree')}</span> : null}
{l.evCharging === 'PAID' ? <span className="badge">{t('amenityEvPaid')}</span> : null}
{l.hasAirConditioning ? <span className="badge">{t('amenityAirConditioning')}</span> : null}

128
lib/calendar.ts Normal file
View file

@ -0,0 +1,128 @@
type TimeRange = { start: Date; end: Date };
type CacheEntry = { expiresAt: number; ranges: TimeRange[] };
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const MAX_RANGE_DAYS = 365; // guard against unbounded events
const globalCache = (globalThis as any).__icalCache || new Map<string, CacheEntry>();
(globalThis as any).__icalCache = globalCache;
function parseDateValue(raw: string): Date | null {
if (!raw) return null;
// Format examples: 20250101, 20250101T120000Z, 20250101T120000
if (/^\d{8}$/.test(raw)) {
const year = Number(raw.slice(0, 4));
const month = Number(raw.slice(4, 6)) - 1;
const day = Number(raw.slice(6, 8));
return new Date(Date.UTC(year, month, day));
}
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function normalizeRange(startRaw: string | null, endRaw: string | null): TimeRange | null {
const start = startRaw ? parseDateValue(startRaw) : null;
let end = endRaw ? parseDateValue(endRaw) : null;
if (!start) return null;
if (!end) {
end = new Date(start);
end.setUTCDate(end.getUTCDate() + 1);
}
// Clamp absurdly long ranges
const maxEnd = new Date(start);
maxEnd.setUTCDate(maxEnd.getUTCDate() + MAX_RANGE_DAYS);
if (end > maxEnd) end = maxEnd;
if (end <= start) return null;
return { start, end };
}
function parseIcs(text: string): TimeRange[] {
const lines = text.split(/\r?\n/);
const ranges: TimeRange[] = [];
let current: { dtstart?: string; dtend?: string; status?: string } | null = null;
for (const rawLine of lines) {
const line = rawLine.trim();
if (line === 'BEGIN:VEVENT') {
current = {};
continue;
}
if (line === 'END:VEVENT') {
if (current && current.status !== 'CANCELLED') {
const range = normalizeRange(current.dtstart || null, current.dtend || null);
if (range) ranges.push(range);
}
current = null;
continue;
}
if (!current) continue;
if (line.startsWith('DTSTART')) {
const [, value] = line.split(':');
current.dtstart = value;
} else if (line.startsWith('DTEND')) {
const [, value] = line.split(':');
current.dtend = value;
} else if (line.startsWith('STATUS')) {
const [, value] = line.split(':');
current.status = value;
}
}
return ranges;
}
async function fetchCalendarUrl(url: string): Promise<TimeRange[]> {
const cached = globalCache.get(url) as CacheEntry | undefined;
const now = Date.now();
if (cached && cached.expiresAt > now) {
return cached.ranges;
}
try {
const res = await fetch(url, { headers: { 'User-Agent': 'lomavuokraus-ical/1.0' }, cache: 'no-store' });
if (!res.ok) throw new Error(`Fetch failed (${res.status})`);
const text = await res.text();
const ranges = parseIcs(text);
globalCache.set(url, { expiresAt: now + CACHE_TTL_MS, ranges });
return ranges;
} catch (err) {
console.error('Failed to fetch calendar', url, err);
globalCache.set(url, { expiresAt: now + CACHE_TTL_MS, ranges: [] });
return [];
}
}
export async function getCalendarRanges(urls: string[]): Promise<TimeRange[]> {
const unique = Array.from(new Set(urls.filter(Boolean)));
if (!unique.length) return [];
const results = await Promise.all(unique.map(fetchCalendarUrl));
return results.flat();
}
export function isRangeAvailable(ranges: TimeRange[], start: Date, end: Date): boolean {
for (const r of ranges) {
if (r.start < end && r.end > start) {
return false;
}
}
return true;
}
export function expandBlockedDates(ranges: TimeRange[], from: Date, to: Date): string[] {
const days: string[] = [];
const cursor = new Date(from);
while (cursor <= to) {
const next = new Date(cursor);
next.setUTCDate(next.getUTCDate() + 1);
if (!isRangeAvailable(ranges, cursor, next)) {
days.push(cursor.toISOString().slice(0, 10));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return days;
}

View file

@ -129,6 +129,9 @@ const allMessages = {
listingNoAmenities: 'No amenities listed yet.',
listingContact: 'Contact',
listingMoreInfo: 'More info',
availabilityTitle: 'Availability calendar',
availabilityLegendBooked: 'Booked',
availabilityMissing: 'Calendar not connected yet.',
localeLabel: 'Locale',
homeCrumb: 'Home',
createListingTitle: 'Create listing',
@ -154,6 +157,8 @@ const allMessages = {
bathroomsLabel: 'Bathrooms',
priceHintLabel: 'Price ballpark (€ / night)',
priceHintHelp: 'Rough nightly price in euros (not a binding offer).',
calendarUrlsLabel: 'Availability calendars (iCal URLs, one per line)',
calendarUrlsHelp: 'Paste iCal links from other platforms. We will merge them to show availability.',
imagesLabel: 'Images',
imagesHelp: 'Upload up to {count} images (max {sizeMb}MB each).',
imagesTooMany: 'Too many images (max {count}).',
@ -220,6 +225,14 @@ const allMessages = {
cityFilter: 'City',
regionFilter: 'Region',
searchButton: 'Search',
searchAmenities: 'Amenities',
searchAvailability: 'Availability',
startDate: 'Start date',
endDate: 'End date',
availabilityOnlyWithCalendar: 'Only listings with a connected calendar are shown when filtering by dates.',
availableForDates: 'Available for selected dates',
notAvailableForDates: 'Unavailable for selected dates',
calendarConnected: 'Calendar connected',
clearFilters: 'Clear filters',
addressSearchLabel: 'Find listings near an address',
addressSearchPlaceholder: 'Street, city, or place',
@ -363,6 +376,9 @@ const allMessages = {
listingNoAmenities: 'Varustelua ei ole listattu.',
listingContact: 'Yhteystiedot',
listingMoreInfo: 'Lisätietoja',
availabilityTitle: 'Saatavuuskalenteri',
availabilityLegendBooked: 'Varattu',
availabilityMissing: 'Kalenteria ei ole vielä yhdistetty.',
localeLabel: 'Kieli',
homeCrumb: 'Etusivu',
createListingTitle: 'Luo kohde',
@ -388,6 +404,8 @@ const allMessages = {
bathroomsLabel: 'Kylpyhuoneita',
priceHintLabel: 'Hinta-arvio (€ / yö)',
priceHintHelp: 'Suuntaa-antava hinta euroina per yö (ei sitova).',
calendarUrlsLabel: 'Saatavuuskalenterit (iCal-osoitteet, yksi per rivi)',
calendarUrlsHelp: 'Liitä iCal-linkit muilta alustoilta. Yhdistämme ne saatavuuden näyttämiseen.',
imagesLabel: 'Kuvat',
imagesHelp: 'Lataa enintään {count} kuvaa (max {sizeMb} Mt / kuva).',
imagesTooMany: 'Liikaa kuvia (enintään {count}).',
@ -454,6 +472,14 @@ const allMessages = {
cityFilter: 'Kaupunki/kunta',
regionFilter: 'Maakunta/alue',
searchButton: 'Hae',
searchAmenities: 'Varustelu',
searchAvailability: 'Saatavuus',
startDate: 'Alkupäivä',
endDate: 'Loppupäivä',
availabilityOnlyWithCalendar: 'Päiväsuodatus näyttää vain kohteet, joissa on kalenteri yhdistettynä.',
availableForDates: 'Vapaa valituille päiville',
notAvailableForDates: 'Ei vapaana valituille päiville',
calendarConnected: 'Kalenteri yhdistetty',
clearFilters: 'Tyhjennä suodattimet',
addressSearchLabel: 'Etsi kohteita osoitteen läheltä',
addressSearchPlaceholder: 'Katu, kaupunki tai paikka',

View file

@ -0,0 +1,2 @@
-- Add optional calendar URLs for availability sync (PostgreSQL text array)
ALTER TABLE "Listing" ADD COLUMN "calendarUrls" TEXT[];

View file

@ -93,6 +93,7 @@ model Listing {
hasWashingMachine Boolean @default(false)
hasBarbecue Boolean @default(false)
evCharging EvCharging @default(NONE)
calendarUrls String[]? @db.Text[]
priceHintPerNightEuros Int?
contactName String
contactEmail String