286 lines
13 KiB
TypeScript
286 lines
13 KiB
TypeScript
import type { Metadata } from 'next';
|
||
import { ListingStatus } from '@prisma/client';
|
||
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 { verifyAccessToken } from '../../../lib/jwt';
|
||
import AvailabilityCalendar from '../../components/AvailabilityCalendar';
|
||
|
||
type ListingPageProps = {
|
||
params: { slug: string };
|
||
};
|
||
|
||
const amenityIcons: Record<string, string> = {
|
||
sauna: '🧖',
|
||
fireplace: '🔥',
|
||
wifi: '📶',
|
||
pets: '🐾',
|
||
lake: '🌊',
|
||
ac: '❄️',
|
||
ev: '⚡',
|
||
evOnSite: '🔌',
|
||
kitchen: '🍽️',
|
||
dishwasher: '🧼',
|
||
washer: '🧺',
|
||
barbecue: '🍖',
|
||
microwave: '🍲',
|
||
parking: '🅿️',
|
||
accessible: '♿',
|
||
ski: '⛷️',
|
||
};
|
||
|
||
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 sessionToken = cookieStore.get('session_token')?.value;
|
||
let viewerId: string | null = null;
|
||
if (sessionToken) {
|
||
try {
|
||
const payload = await verifyAccessToken(sessionToken);
|
||
viewerId = payload.userId;
|
||
} catch {
|
||
viewerId = null;
|
||
}
|
||
}
|
||
|
||
const translationRaw = await getListingBySlug({
|
||
slug: params.slug,
|
||
locale: locale ?? DEFAULT_LOCALE,
|
||
includeOwnerDraftsForUserId: viewerId ?? undefined,
|
||
});
|
||
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 ?? []).filter((url) => {
|
||
if (!url) return false;
|
||
try {
|
||
const parsed = new URL(url);
|
||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
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.evChargingOnSite ? { icon: amenityIcons.evOnSite, label: t('amenityEvOnSite') } : null,
|
||
listing.evChargingAvailable && !listing.evChargingOnSite ? { icon: amenityIcons.ev, label: t('amenityEvNearby') } : null,
|
||
listing.wheelchairAccessible ? { icon: amenityIcons.accessible, label: t('amenityWheelchairAccessible') } : null,
|
||
listing.hasSkiPass ? { icon: amenityIcons.ski, label: t('amenitySkiPass') } : 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 capacityParts = [
|
||
listing.maxGuests ? t('capacityGuests', { count: listing.maxGuests }) : null,
|
||
listing.bedrooms ? t('capacityBedrooms', { count: listing.bedrooms }) : null,
|
||
listing.beds ? t('capacityBeds', { count: listing.beds }) : null,
|
||
listing.bathrooms ? t('capacityBathrooms', { count: listing.bathrooms }) : null,
|
||
].filter(Boolean) as string[];
|
||
const capacityLine = capacityParts.length ? capacityParts.join(' · ') : t('capacityUnknown');
|
||
const contactLine = `${listing.contactName} · ${listing.contactEmail}${listing.contactPhone ? ` · ${listing.contactPhone}` : ''}`;
|
||
const coverImage = listing.images.find((img) => img.isCover) ?? listing.images[0] ?? null;
|
||
const priceCandidates = [listing.priceWeekdayEuros, listing.priceWeekendEuros].filter((p): p is number => typeof p === 'number');
|
||
const startingFromEuros = priceCandidates.length ? Math.min(...priceCandidates) : null;
|
||
const priceLine =
|
||
listing.priceWeekdayEuros || listing.priceWeekendEuros
|
||
? `${startingFromEuros !== null ? t('priceStartingFromShort', { price: startingFromEuros }) : ''}${
|
||
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');
|
||
const isDraftOrPending = listing.status !== ListingStatus.PUBLISHED;
|
||
const isOwnerView = viewerId && listing.ownerId === viewerId;
|
||
|
||
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">
|
||
{isDraftOrPending ? (
|
||
<div className="badge warning" style={{ marginBottom: 10, display: 'inline-block' }}>
|
||
{isOwnerView ? t('statusLabel') : 'Status'}: {listing.status}
|
||
</div>
|
||
) : null}
|
||
{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 }}>
|
||
<div style={{ position: 'relative' }}>
|
||
<AvailabilityCalendar blockedDates={blockedDates} months={1} disabled={!hasCalendar} />
|
||
{!hasCalendar ? (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: '#cbd5e1',
|
||
fontWeight: 600,
|
||
textAlign: 'center',
|
||
background: 'linear-gradient(135deg, rgba(15,23,42,0.55), rgba(15,23,42,0.65))',
|
||
borderRadius: 12,
|
||
}}
|
||
>
|
||
{t('availabilityMissing')}
|
||
</div>
|
||
) : null}
|
||
</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>
|
||
</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>
|
||
);
|
||
}
|