Files
webseite_BB-Apartment2026/app/[locale]/apartments/[apartmentSlug]/page.tsx
T
MarkusandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2b24334bfa Initial upload of BB-Apartment 2026 website.
Adds the complete Next.js website including i18n routing, house and
apartment pages, Beds24 v2 integration for live availability/pricing,
Stripe checkout, contact forms, Docker setup and deployment docs.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-19 22:54:49 +02:00

210 lines
8.6 KiB
TypeScript

import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getApartmentBySlug, getHouseById, bookingConfig } from "@/src/config/apartments";
import { getEffectiveMinStay } from "@/src/lib/beds24";
import { tomorrowDateString } from "@/src/lib/date";
import { Bed, Bath, Users, Calendar, Check, Wind, Sofa, Wifi, WashingMachine, Tv } from "lucide-react";
import { BookingForm } from "@/components/ui/BookingForm";
import { ImageGallery } from "@/components/ui/ImageGallery";
import Link from "next/link";
export async function generateMetadata({
params: { locale, apartmentSlug },
}: {
params: { locale: string; apartmentSlug: string };
}) {
const apartment = getApartmentBySlug(apartmentSlug);
if (!apartment) return { title: "Not Found" };
const t = await getTranslations({ locale, namespace: "metadata" });
return {
title: `${apartment.nameEn} - ${t("title")}`,
description: apartment.descriptionEn || t("description"),
};
}
export default async function ApartmentPage({
params: { locale, apartmentSlug },
}: {
params: { locale: string; apartmentSlug: string };
}) {
const t = await getTranslations({ locale, namespace: "apartment" });
const apartment = getApartmentBySlug(apartmentSlug);
const house = apartment ? getHouseById(apartment.houseId) : undefined;
if (!apartment || !house) notFound();
// Real, effective minimum stay for this apartment as of "tomorrow" — this
// combines Beds24's room-level minimum with whatever the currently active
// rate/price plan requires (the higher of the two wins), so it reflects
// what a guest could actually book, not just the room's base setting.
// The BookingForm re-fetches this whenever the guest picks a different
// check-in date, since it can vary by season.
const minStayNights = await getEffectiveMinStay(
apartment.houseId,
apartment.beds24PropertyId,
apartment.beds24RoomId,
tomorrowDateString(),
bookingConfig.minStayNights
);
const displayName = locale === "de" ? apartment.name : apartment.nameEn;
const displayDescription =
locale === "de" ? apartment.description : apartment.descriptionEn;
const displayFloor = locale === "de" ? apartment.floor : apartment.floorEn;
const amenities =
locale === "de" ? apartment.amenities : apartment.amenitiesEn;
const houseName = locale === "de" ? house.name : house.nameEn;
const hasAmenity = (keys: string[]) =>
amenities.some((a) => keys.some((k) => a.toLowerCase().includes(k.toLowerCase())));
// Apartment-specific highlights (excludes "Fully furnished", already shown elsewhere)
const specificHighlights = [
hasAmenity(["Klimaanlage", "Air conditioning"]) && {
icon: Wind,
title: locale === "de" ? "Klimaanlage" : "Air conditioning",
},
hasAmenity(["Waschmaschine", "Washing machine"]) && {
icon: WashingMachine,
title: locale === "de" ? "Eigene Waschmaschine" : "In-unit Washer",
},
hasAmenity(["WLAN", "WiFi"]) && {
icon: Wifi,
title: locale === "de" ? "High-Speed WLAN" : "High-Speed WiFi",
},
hasAmenity(["TV"]) && {
icon: Tv,
title: "Smart TV",
},
].filter(Boolean) as { icon: typeof Wind; title: string }[];
return (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-6">
<Link
href={`/${locale}/houses/${house.slug}`}
className="text-brand-green-dark hover:text-brand-green"
>
← {houseName}
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-12">
<ImageGallery
images={apartment.images.length > 0 ? apartment.images : ["/images/placeholder.jpg"]}
alt={displayName}
/>
<div>
<h1 className="text-4xl font-bold mb-2">{displayName}</h1>
<p className="text-brand-grey mb-6">{houseName} • {displayFloor}</p>
{displayDescription && (
<p className="text-brand-grey leading-relaxed mb-6">
{displayDescription}
</p>
)}
<div className="grid grid-cols-2 gap-4 mb-8">
<div className="flex items-center gap-3 p-4 bg-brand-grey-soft rounded-xl">
<Users className="w-5 h-5 text-brand-green" />
<div>
<p className="text-sm text-brand-grey">{t("guests")}</p>
<p className="font-semibold">{apartment.maxGuests}</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-brand-grey-soft rounded-xl">
<Calendar className="w-5 h-5 text-brand-green" />
<div>
<p className="text-sm text-brand-grey">{t("minStay")}</p>
<p className="font-semibold">{minStayNights} {t("nights")}</p>
</div>
</div>
{amenities.some((a) =>
["Klimaanlage", "Air conditioning"].includes(a)
) && (
<div className="flex items-center gap-3 p-4 bg-brand-green/10 rounded-xl border border-brand-green/20 col-span-2 sm:col-span-1">
<Wind className="w-5 h-5 text-brand-green" />
<div>
<p className="text-sm text-brand-grey">
{locale === "de" ? "Klimaanlage" : "Air conditioning"}
</p>
<p className="font-semibold text-brand-green-dark">
{locale === "de" ? "Vorhanden" : "Available"}
</p>
</div>
</div>
)}
<div className="flex items-center gap-3 p-4 bg-brand-green/10 rounded-xl border border-brand-green/20">
<Sofa className="w-5 h-5 text-brand-green" />
<div>
<p className="text-sm text-brand-grey">
{locale === "de" ? "Vollmöbliert" : "Fully furnished"}
</p>
<p className="font-semibold text-brand-green-dark">
{locale === "de" ? "Ja" : "Yes"}
</p>
</div>
</div>
{apartment.bedrooms && (
<div className="flex items-center gap-3 p-4 bg-brand-grey-soft rounded-xl">
<Bed className="w-5 h-5 text-brand-green" />
<div>
<p className="text-sm text-brand-grey">{locale === "de" ? "Schlafzimmer" : "Bedrooms"}</p>
<p className="font-semibold">{apartment.bedrooms}</p>
</div>
</div>
)}
{apartment.bathrooms && (
<div className="flex items-center gap-3 p-4 bg-brand-grey-soft rounded-xl">
<Bath className="w-5 h-5 text-brand-green" />
<div>
<p className="text-sm text-brand-grey">{locale === "de" ? "Bäder" : "Bathrooms"}</p>
<p className="font-semibold">{apartment.bathrooms}</p>
</div>
</div>
)}
</div>
<BookingForm apartment={apartment} locale={locale} minStayNights={minStayNights} />
</div>
</div>
{specificHighlights.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-5 mb-12">
{specificHighlights.map((highlight, index) => {
const Icon = highlight.icon;
return (
<div
key={index}
className="flex flex-col items-center text-center gap-4 p-6 bg-white rounded-2xl border-2 border-brand-green/20 shadow-md hover:shadow-xl hover:-translate-y-1 transition-all"
>
<div className="p-4 bg-brand-green rounded-2xl text-white shadow-sm">
<Icon className="w-9 h-9" />
</div>
<p className="font-bold text-brand-grey-dark text-lg leading-tight">
{highlight.title}
</p>
</div>
);
})}
</div>
)}
<div className="bg-brand-grey-soft rounded-2xl p-8">
<h2 className="text-2xl font-bold mb-6">{t("features")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{amenities.map((amenity, index) => (
<div key={index} className="flex items-center gap-3">
<Check className="w-5 h-5 text-brand-green flex-shrink-0" />
<span className="text-brand-grey-dark">{amenity}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}