466 lines
15 KiB
TypeScript
466 lines
15 KiB
TypeScript
import type { Metadata } from "next/dist/types";
|
||
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 = await cookies();
|
||
const headerList = await headers();
|
||
const locale = resolveLocale({
|
||
cookieLocale: cookieStore.get("locale")?.value,
|
||
acceptLanguage: headerList.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>
|
||
);
|
||
}
|