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>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
232770afe7
commit
2b24334bfa
@@ -0,0 +1,209 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user