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,206 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getHouseBySlug } from "@/src/config/apartments";
|
||||
import { ApartmentCard } from "@/components/ui/ApartmentCard";
|
||||
import { HouseHighlights } from "@/components/ui/HouseHighlights";
|
||||
import { MapPin, Home, Car, Clock, Navigation } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale, houseSlug },
|
||||
}: {
|
||||
params: { locale: string; houseSlug: string };
|
||||
}) {
|
||||
const house = getHouseBySlug(houseSlug);
|
||||
if (!house) return { title: "Not Found" };
|
||||
|
||||
const t = await getTranslations({ locale, namespace: "metadata" });
|
||||
return {
|
||||
title: `${house.nameEn} - ${t("title")}`,
|
||||
description: house.descriptionEn,
|
||||
};
|
||||
}
|
||||
|
||||
export default function HousePage({
|
||||
params: { locale, houseSlug },
|
||||
}: {
|
||||
params: { locale: string; houseSlug: string };
|
||||
}) {
|
||||
const t = useTranslations("houses");
|
||||
const house = getHouseBySlug(houseSlug);
|
||||
|
||||
if (!house) notFound();
|
||||
|
||||
const displayName = locale === "de" ? house.name : house.nameEn;
|
||||
const displayDescription =
|
||||
locale === "de" ? house.description : house.descriptionEn;
|
||||
const displayCountry = locale === "de" ? house.country : house.countryEn;
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="relative h-80 md:h-96 rounded-2xl overflow-hidden mb-12">
|
||||
<Image
|
||||
src={house.images[0] || "/images/placeholder.jpg"}
|
||||
alt={displayName}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 p-8 text-white">
|
||||
<h1 className="text-4xl font-bold mb-2 text-white">{displayName}</h1>
|
||||
<div className="flex items-center gap-2 text-white/90">
|
||||
<MapPin className="w-5 h-5" />
|
||||
<span>
|
||||
{house.address}, {house.zipCode} {house.city}, {displayCountry}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
<div className="lg:col-span-2">
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{t("overview")}
|
||||
</h2>
|
||||
<p className="text-brand-grey leading-relaxed mb-8">
|
||||
{displayDescription}
|
||||
</p>
|
||||
|
||||
<HouseHighlights house={house} locale={locale} />
|
||||
|
||||
<h2 className="text-2xl font-bold mb-6">
|
||||
{t("apartments")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{house.apartments.map((apartment) => (
|
||||
<ApartmentCard
|
||||
key={apartment.id}
|
||||
apartment={apartment}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8 h-fit">
|
||||
<div className="bg-brand-grey-soft rounded-2xl p-8 h-fit">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Home className="w-6 h-6 text-brand-green" />
|
||||
<h3 className="text-xl font-bold">{displayName}</h3>
|
||||
</div>
|
||||
<p className="text-brand-grey text-sm mb-4">
|
||||
{house.apartments.length} {locale === "de" ? "Apartments" : "Apartments"}
|
||||
</p>
|
||||
<div className="text-sm text-brand-grey">
|
||||
<p className="mb-2">
|
||||
<strong>{locale === "de" ? "Adresse:" : "Address:"}</strong> {house.address}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{locale === "de" ? "Stadt:" : "City:"}</strong> {house.zipCode} {house.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-brand-grey-soft shadow-sm p-6 h-fit">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Navigation className="w-5 h-5 text-brand-green" />
|
||||
<h2 className="text-lg font-bold">
|
||||
{locale === "de" ? "Lage & Entfernungen" : "Location & Distances"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
{house.id === "herdweg" ? (
|
||||
<>
|
||||
<a
|
||||
href="https://www.google.com/maps/dir/Herdweg+71,+71032+Böblingen/Panzer+Kaserne+Böblingen"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-brand-green text-white rounded-xl hover:bg-brand-green-dark transition-colors"
|
||||
>
|
||||
<Car className="w-5 h-5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-sm">
|
||||
{locale === "de" ? "Panzer Kaserne – gleiche Straße" : "Panzer Kaserne – Same Street"}
|
||||
</p>
|
||||
<p className="text-sm text-white/85">
|
||||
{locale === "de" ? "Kein Abbiegen nötig" : "Zero turns needed"}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.google.com/maps/dir/Herdweg+71,+71032+Böblingen/Böblingen+Zentrum"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-brand-green/10 rounded-xl hover:bg-brand-green/20 transition-colors"
|
||||
>
|
||||
<Clock className="w-5 h-5 text-brand-green shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-brand-grey-dark text-sm">
|
||||
{locale === "de" ? "Stadtzentrum Böblingen" : "Böblingen City Center"}
|
||||
</p>
|
||||
<p className="text-sm text-brand-grey">5–8 min</p>
|
||||
</div>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a
|
||||
href="https://www.google.com/maps/dir/Schönaicher+Straße+26,+71032+Böblingen/Daimler+Werk+Sindelfingen"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-brand-green/10 rounded-xl hover:bg-brand-green/20 transition-colors"
|
||||
>
|
||||
<Car className="w-5 h-5 text-brand-green shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-brand-grey-dark text-sm">
|
||||
{locale === "de" ? "Daimler Werk Sindelfingen" : "Daimler Plant Sindelfingen"}
|
||||
</p>
|
||||
<p className="text-sm text-brand-grey">10–15 min</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.google.com/maps/dir/Schönaicher+Straße+26,+71032+Böblingen/Böblingen+Zentrum"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-brand-green/10 rounded-xl hover:bg-brand-green/20 transition-colors"
|
||||
>
|
||||
<Clock className="w-5 h-5 text-brand-green shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-brand-grey-dark text-sm">
|
||||
{locale === "de" ? "Stadtzentrum Böblingen" : "Böblingen City Center"}
|
||||
</p>
|
||||
<p className="text-sm text-brand-grey">< 5 min</p>
|
||||
</div>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-48 rounded-xl overflow-hidden border border-brand-grey-soft">
|
||||
<iframe
|
||||
src={`https://maps.google.com/maps?q=${encodeURIComponent(`${house.address}, ${house.zipCode} ${house.city}`)}&z=15&output=embed`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
title={displayName}
|
||||
className="grayscale-[20%] hover:grayscale-0 transition-all duration-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-brand-grey mt-3 text-center">
|
||||
{locale === "de"
|
||||
? "Karte ist interaktiv – klicken Sie auf einen Ort, um die Route zu planen."
|
||||
: "Map is interactive – click a destination to plan the route."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { houses, allApartments, getHouseById, bookingConfig } from "@/src/config/apartments";
|
||||
import { getAvailability, getEffectiveMinStay } from "@/src/lib/beds24";
|
||||
import { calculatePrice } from "@/src/lib/pricing";
|
||||
import { nightsBetween, parseDateOnly } from "@/src/lib/date";
|
||||
import { HouseCard } from "@/components/ui/HouseCard";
|
||||
import { AvailabilitySearchBar } from "@/components/ui/AvailabilitySearchBar";
|
||||
import { AvailableApartmentCard } from "@/components/ui/AvailableApartmentCard";
|
||||
import { NoAvailabilityContact } from "@/components/ui/NoAvailabilityContact";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const t = await getTranslations({ locale, namespace: "home" });
|
||||
return {
|
||||
title: t("houses.title"),
|
||||
description: `${t("houses.title")} – ${t("houses.subtitle")}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HousesPage({
|
||||
params: { locale },
|
||||
searchParams,
|
||||
}: {
|
||||
params: { locale: string };
|
||||
searchParams: { arrival?: string; departure?: string; months?: string };
|
||||
}) {
|
||||
const t = await getTranslations({ locale, namespace: "home" });
|
||||
const tSearch = await getTranslations({ locale, namespace: "houses.search" });
|
||||
|
||||
const { arrival, departure } = searchParams;
|
||||
const hasSearch = Boolean(arrival && departure);
|
||||
|
||||
if (hasSearch && arrival && departure) {
|
||||
const nights = nightsBetween(arrival, departure);
|
||||
|
||||
const results = await Promise.all(
|
||||
allApartments.map(async (apartment) => {
|
||||
const house = getHouseById(apartment.houseId)!;
|
||||
try {
|
||||
const [availability, minStayNights] = await Promise.all([
|
||||
getAvailability({
|
||||
houseId: apartment.houseId,
|
||||
propertyId: apartment.beds24PropertyId,
|
||||
roomId: apartment.beds24RoomId,
|
||||
start: arrival,
|
||||
end: departure,
|
||||
}),
|
||||
getEffectiveMinStay(
|
||||
apartment.houseId,
|
||||
apartment.beds24PropertyId,
|
||||
apartment.beds24RoomId,
|
||||
arrival,
|
||||
bookingConfig.minStayNights
|
||||
),
|
||||
]);
|
||||
const minStayValid = nights > 0 && nights >= minStayNights;
|
||||
const price = calculatePrice(availability.price, nights, availability.currency);
|
||||
return {
|
||||
apartment,
|
||||
house,
|
||||
available: availability.available,
|
||||
minStayValid,
|
||||
pricePerNight: price.basePricePerNight,
|
||||
currency: price.currency,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
apartment,
|
||||
house,
|
||||
available: false,
|
||||
minStayValid: false,
|
||||
pricePerNight: undefined,
|
||||
currency: undefined,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
results.sort((a, b) => {
|
||||
const aOk = a.available && a.minStayValid;
|
||||
const bOk = b.available && b.minStayValid;
|
||||
return aOk === bOk ? 0 : aOk ? -1 : 1;
|
||||
});
|
||||
|
||||
const anyAvailable = results.some((r) => r.available && r.minStayValid);
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold mb-4">{tSearch("resultsTitle")}</h1>
|
||||
<p className="text-brand-grey text-lg">
|
||||
{tSearch("resultsSubtitle")}: {parseDateOnly(arrival).toLocaleDateString(locale === "de" ? "de-DE" : "en-US")} –{" "}
|
||||
{parseDateOnly(departure).toLocaleDateString(locale === "de" ? "de-DE" : "en-US")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AvailabilitySearchBar
|
||||
locale={locale}
|
||||
defaultArrival={arrival}
|
||||
defaultMonths={searchParams.months ? Number(searchParams.months) : undefined}
|
||||
/>
|
||||
|
||||
{!anyAvailable && (
|
||||
<div className="mb-12">
|
||||
<p className="text-center text-brand-grey mb-8">{tSearch("noResults")}</p>
|
||||
<NoAvailabilityContact
|
||||
locale={locale}
|
||||
context={`${locale === "de" ? "Gewünschter Zeitraum" : "Requested dates"}: ${arrival} – ${departure}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{anyAvailable && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 mb-12">
|
||||
{results.map(({ apartment, house, available, minStayValid, pricePerNight, currency }) => (
|
||||
<AvailableApartmentCard
|
||||
key={apartment.id}
|
||||
apartment={apartment}
|
||||
house={house}
|
||||
locale={locale}
|
||||
available={available}
|
||||
minStayValid={minStayValid}
|
||||
pricePerNight={pricePerNight}
|
||||
currency={currency}
|
||||
arrival={arrival}
|
||||
departure={departure}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={`/${locale}/houses`}
|
||||
className="text-brand-green-dark font-medium hover:text-brand-green"
|
||||
>
|
||||
{tSearch("showAllHouses")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold mb-4">{t("houses.title")}</h1>
|
||||
<p className="text-brand-grey text-lg">{t("houses.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<AvailabilitySearchBar locale={locale} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{houses.map((house) => (
|
||||
<HouseCard key={house.id} house={house} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user