lomavuokraus/app/listings/[slug]/page.tsx
Tero Halla-aho 0286c7f5be
Some checks failed
CI / checks (push) Has been cancelled
CI / checks (pull_request) Has been cancelled
Fix duplicate AvailabilityCalendar import in listing page
2025-12-21 22:07:03 +02:00

294 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 AvailabilityCalendar from '../../components/AvailabilityCalendar';
import { verifyAccessToken } from '../../../lib/jwt';
import { getSiteSettings } from '../../../lib/settings';
import type { UrlObject } from 'url';
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 siteSettings = await getSiteSettings();
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 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 contactParts = [listing.contactName, listing.contactEmail, listing.contactPhone].filter(Boolean) as string[];
const contactLine = contactParts.length ? contactParts.join(' · ') : '—';
const canViewContact = !siteSettings.requireLoginForContactDetails || Boolean(viewerId);
const loginRedirectUrl: UrlObject = { pathname: '/auth/login', query: { redirect: `/listings/${params.slug}` } };
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 listingId={listing.id} hasCalendar={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>
{canViewContact ? (
<div>{contactLine}</div>
) : (
<div style={{ marginTop: 4 }}>
<Link href={loginRedirectUrl} className="button secondary">
{t('contactLoginToView')}
</Link>
</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>
);
}