lomavuokraus/app/listings/[slug]/page.tsx
Tero Halla-aho 0b5ca0a190
Some checks failed
CI / checks (push) Has been cancelled
CI / checks (pull_request) Has been cancelled
Fix draft listing save/reset and allow viewing drafts
2025-12-15 21:23:25 +02:00

276 lines
12 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: '⚡',
kitchen: '🍽️',
dishwasher: '🧼',
washer: '🧺',
barbecue: '🍖',
microwave: '🍲',
parking: '🅿️',
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.evChargingAvailable ? { icon: amenityIcons.ev, label: t('amenityEvNearby') } : 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>
);
}