lomavuokraus/app/listings/[slug]/page.tsx
Tero Halla-aho cb92a17f1d
Some checks failed
CI / checks (push) Has been cancelled
CI / checks (pull_request) Has been cancelled
Add on-site EV charging amenity
2025-12-17 13:40:47 +02:00

280 lines
13 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 { 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 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');
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>
);
}