lomavuokraus/app/listings/[slug]/page.tsx

228 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
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 };
};
const amenityIcons: Record<string, string> = {
sauna: '🧖',
fireplace: '🔥',
wifi: '📶',
pets: '🐾',
lake: '🌊',
ac: '❄️',
ev: '⚡',
kitchen: '🍽️',
dishwasher: '🧼',
washer: '🧺',
barbecue: '🍖',
microwave: '🍲',
parking: '🅿️',
};
export async function generateMetadata({ params }: ListingPageProps): Promise<Metadata> {
const translation = await getListingBySlug({ slug: params.slug, locale: DEFAULT_LOCALE });
return {
title: translation ? `${translation.title} | Lomavuokraus.fi` : `${params.slug} | Lomavuokraus.fi`,
description: translation?.teaser ?? translation?.description?.slice(0, 140),
};
}
export default async function ListingPage({ params }: ListingPageProps) {
const cookieStore = cookies();
const locale = resolveLocale({ cookieLocale: cookieStore.get('locale')?.value, acceptLanguage: headers().get('accept-language') });
const t = (key: any, vars?: Record<string, string | number>) => translate(locale, key as any, vars);
const translationRaw = await getListingBySlug({ slug: params.slug, locale: locale ?? DEFAULT_LOCALE });
const translation = translationRaw ? withResolvedListingImages(translationRaw) : null;
if (!translation) {
notFound();
}
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,
listing.hasWifi ? { icon: amenityIcons.wifi, label: t('amenityWifi') } : null,
listing.petsAllowed ? { icon: amenityIcons.pets, label: t('amenityPets') } : null,
listing.byTheLake ? { icon: amenityIcons.lake, label: t('amenityLake') } : null,
listing.hasAirConditioning ? { icon: amenityIcons.ac, label: t('amenityAirConditioning') } : null,
listing.evCharging === 'FREE' ? { icon: amenityIcons.ev, label: t('amenityEvFree') } : null,
listing.evCharging === 'PAID' ? { icon: amenityIcons.ev, label: t('amenityEvPaid') } : null,
listing.hasKitchen ? { icon: amenityIcons.kitchen, label: t('amenityKitchen') } : null,
listing.hasDishwasher ? { icon: amenityIcons.dishwasher, label: t('amenityDishwasher') } : null,
listing.hasWashingMachine ? { icon: amenityIcons.washer, label: t('amenityWashingMachine') } : null,
listing.hasBarbecue ? { icon: amenityIcons.barbecue, label: t('amenityBarbecue') } : null,
listing.hasMicrowave ? { icon: amenityIcons.microwave, label: t('amenityMicrowave') } : null,
listing.hasFreeParking ? { icon: amenityIcons.parking, label: t('amenityFreeParking') } : null,
].filter(Boolean) as { icon: string; label: string }[];
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;
const priceLine =
listing.priceWeekdayEuros || listing.priceWeekendEuros
? [listing.priceWeekdayEuros ? t('priceWeekdayShort', { price: listing.priceWeekdayEuros }) : null, listing.priceWeekendEuros ? t('priceWeekendShort', { price: listing.priceWeekendEuros }) : null]
.filter(Boolean)
.join(' · ')
: t('priceNotSet');
return (
<main className="listing-shell">
<div className="breadcrumb">
<Link href="/">{t('homeCrumb')}</Link> / <span>{params.slug}</span>
</div>
<div className="listing-layout">
<div className="panel listing-main">
{isSample ? (
<div className="badge warning" style={{ marginBottom: 10, display: 'inline-block' }}>
{t('sampleBadge')}
</div>
) : null}
<h1>{title}</h1>
<p style={{ marginTop: 8 }}>{teaser ?? description}</p>
{listing.addressNote ? (
<div style={{ marginTop: 4, color: '#cbd5e1' }}>
<em>{listing.addressNote}</em>
</div>
) : null}
{listing.externalUrl ? (
<div style={{ marginTop: 12 }}>
<a href={listing.externalUrl} target="_blank" rel="noreferrer" className="button secondary">
{t('listingMoreInfo')}
</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={1} disabled={!listing.calendarUrls?.length} />
) : (
<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
.filter((img) => Boolean(img.url))
.map((img) => (
<figure key={img.id} style={{ border: '1px solid rgba(148, 163, 184, 0.25)', borderRadius: 12, overflow: 'hidden', background: 'rgba(255,255,255,0.03)' }}>
<a href={img.url || ''} target="_blank" rel="noreferrer" style={{ display: 'block', cursor: 'zoom-in' }}>
<img src={img.url || ''} alt={img.altText ?? title} style={{ width: '100%', height: '200px', objectFit: 'cover' }} />
</a>
{img.altText ? (
<figcaption style={{ padding: '10px 12px', fontSize: 14, color: '#cbd5e1' }}>{img.altText}</figcaption>
) : null}
</figure>
))}
</div>
) : null}
<div style={{ marginTop: 16, fontSize: 14, color: '#666' }}>
{t('localeLabel')}: <code>{translationLocale}</code>
</div>
</div>
<aside className="panel listing-aside">
<div className="fact-row">
<span aria-hidden className="amenity-icon">📍</span>
<div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingLocation')}</div>
<div>{addressLine}</div>
</div>
</div>
<div className="fact-row">
<span aria-hidden className="amenity-icon">👥</span>
<div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingCapacity')}</div>
<div>{capacityLine}</div>
</div>
</div>
<div className="fact-row">
<span aria-hidden className="amenity-icon">💶</span>
<div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingPrices')}</div>
<div>{priceLine}</div>
</div>
</div>
<div className="fact-row">
<span aria-hidden className="amenity-icon"></span>
<div>
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('listingContact')}</div>
<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 ? (
<div className="amenity-row" style={{ borderStyle: 'dashed' }}>
<span className="amenity-icon"></span>
<span>{t('listingNoAmenities')}</span>
</div>
) : (
amenities.map((item) => (
<div key={item.label} className="amenity-row">
<span className="amenity-icon" aria-hidden>
{item.icon}
</span>
<span>{item.label}</span>
</div>
))
)}
</div>
</aside>
</div>
</main>
);
}