Add calendar availability and amenity filters
This commit is contained in:
parent
2a835d9875
commit
a3ac05bf08
10 changed files with 443 additions and 1 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
95
app/components/AvailabilityCalendar.tsx
Normal file
95
app/components/AvailabilityCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
128
lib/calendar.ts
Normal 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;
|
||||
}
|
||||
26
lib/i18n.ts
26
lib/i18n.ts
|
|
@ -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',
|
||||
|
|
|
|||
2
prisma/migrations/20251127_calendar_urls/migration.sql
Normal file
2
prisma/migrations/20251127_calendar_urls/migration.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-- Add optional calendar URLs for availability sync (PostgreSQL text array)
|
||||
ALTER TABLE "Listing" ADD COLUMN "calendarUrls" TEXT[];
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue