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:
Markus
2026-08-19 22:54:49 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 232770afe7
commit 2b24334bfa
153 changed files with 5621 additions and 176 deletions
+70
View File
@@ -0,0 +1,70 @@
import Link from "next/link";
import Image from "next/image";
import { Apartment } from "@/src/config/apartments";
import { Users, ArrowRight, Wind } from "lucide-react";
interface ApartmentCardProps {
apartment: Apartment;
locale: string;
}
export function ApartmentCard({ apartment, locale }: ApartmentCardProps) {
const displayName = locale === "de" ? apartment.name : apartment.nameEn;
const displayFloor = locale === "de" ? apartment.floor : apartment.floorEn;
const hasAc = apartment.amenities.some((a) => a.toLowerCase().includes("klimaanlage"));
return (
<div className="bg-white rounded-2xl overflow-hidden shadow-sm border border-brand-grey-soft hover:shadow-lg transition-shadow">
<div className="relative h-48">
<Image
src={apartment.images[0] || "/images/placeholder.jpg"}
alt={displayName}
fill
className="object-cover"
/>
<div
className={`absolute top-3 right-3 flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1.5 rounded-full backdrop-blur-sm ${
hasAc ? "bg-brand-green/90 text-white" : "bg-white/90 text-brand-grey"
}`}
title={
hasAc
? locale === "de"
? "Klimaanlage vorhanden"
: "Air conditioning available"
: locale === "de"
? "Keine Klimaanlage"
: "No air conditioning"
}
>
<Wind className={`w-3.5 h-3.5 ${hasAc ? "" : "opacity-40"}`} />
{hasAc
? locale === "de"
? "Klimaanlage"
: "AC"
: locale === "de"
? "Keine Klimaanlage"
: "No AC"}
</div>
</div>
<div className="p-5">
<h3 className="text-xl font-bold mb-1 text-brand-grey-dark">
{displayName}
</h3>
<p className="text-brand-grey-light text-sm mb-4">{displayFloor}</p>
<div className="flex items-center gap-2 text-sm text-brand-grey mb-4">
<Users className="w-4 h-4 text-brand-green" />
<span>
{apartment.maxGuests} {locale === "de" ? "Gäste" : "guests"}
</span>
</div>
<Link
href={`/${locale}/apartments/${apartment.slug}`}
className="inline-flex items-center gap-2 text-brand-green-dark font-medium hover:text-brand-green transition-colors"
>
{locale === "de" ? "Details ansehen" : "View details"}
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Calendar, ArrowRight } from "lucide-react";
import { tomorrowDateString, addMonths } from "@/src/lib/date";
interface AvailabilitySearchBarProps {
locale: string;
defaultArrival?: string;
defaultMonths?: number;
}
export function AvailabilitySearchBar({
locale,
defaultArrival,
defaultMonths,
}: AvailabilitySearchBarProps) {
const t = useTranslations("houses.search");
const router = useRouter();
const [arrival, setArrival] = useState(defaultArrival || tomorrowDateString());
const [months, setMonths] = useState(defaultMonths || 3);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const departure = addMonths(arrival, months);
const searchParams = new URLSearchParams({
arrival,
departure,
months: String(months),
});
router.push(`/${locale}/houses?${searchParams.toString()}`);
};
return (
<form
onSubmit={handleSubmit}
className="bg-white rounded-2xl shadow-lg border border-brand-grey-soft/80 p-6 md:p-8 mb-12"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-end">
<div className="space-y-2">
<label
htmlFor="search-arrival"
className="flex items-center gap-2 text-sm font-bold text-brand-grey-dark"
>
<Calendar className="w-4 h-4 text-brand-green" />
{t("arrival")}
</label>
<input
id="search-arrival"
type="date"
required
lang={locale === "de" ? "de-DE" : "en-US"}
value={arrival}
min={tomorrowDateString()}
onChange={(e) => setArrival(e.target.value)}
className="w-full px-4 py-3 rounded-xl border-2 border-brand-grey-soft/60 bg-brand-grey-soft/20 text-brand-grey-dark focus:outline-none focus:ring-2 focus:ring-brand-green focus:border-transparent transition-all"
/>
</div>
<div className="space-y-2">
<label htmlFor="search-months" className="block text-sm font-bold text-brand-grey-dark">
{t("stayMonths")}
</label>
<input
id="search-months"
type="number"
min={1}
max={24}
required
value={months}
onChange={(e) => setMonths(Number(e.target.value))}
className="w-full px-4 py-3 text-center rounded-xl border-2 border-brand-grey-soft/60 bg-brand-grey-soft/20 text-brand-grey-dark focus:outline-none focus:ring-2 focus:ring-brand-green focus:border-transparent transition-all"
/>
</div>
<button
type="submit"
className="inline-flex items-center justify-center gap-2 bg-brand-green text-white font-bold px-6 py-3 rounded-xl hover:bg-brand-green-dark hover:shadow-lg transition-all"
>
{t("checkAvailability")}
<ArrowRight className="w-5 h-5" />
</button>
</div>
</form>
);
}
+102
View File
@@ -0,0 +1,102 @@
import Link from "next/link";
import Image from "next/image";
import { Apartment, House } from "@/src/config/apartments";
import { formatPrice } from "@/src/lib/pricing";
import { CheckCircle2, XCircle, Users, MapPin, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
interface AvailableApartmentCardProps {
apartment: Apartment;
house: House;
locale: string;
available: boolean;
minStayValid: boolean;
pricePerNight?: number;
currency?: string;
arrival: string;
departure: string;
}
export function AvailableApartmentCard({
apartment,
house,
locale,
available,
minStayValid,
pricePerNight,
currency,
arrival,
departure,
}: AvailableApartmentCardProps) {
const t = useTranslations("houses.search");
const displayName = locale === "de" ? apartment.name : apartment.nameEn;
const houseName = locale === "de" ? house.name : house.nameEn;
const canBook = available && minStayValid;
return (
<div
className={`bg-white rounded-2xl overflow-hidden shadow-sm border transition-shadow ${
canBook
? "border-brand-grey-soft hover:shadow-lg"
: "border-brand-grey-soft opacity-70"
}`}
>
<div className="relative h-48">
<Image
src={apartment.images[0] || "/images/placeholder.jpg"}
alt={displayName}
fill
className={`object-cover ${canBook ? "" : "grayscale"}`}
/>
<div
className={`absolute top-3 right-3 flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1.5 rounded-full backdrop-blur-sm ${
canBook ? "bg-brand-green/90 text-white" : "bg-white/90 text-brand-grey"
}`}
>
{canBook ? (
<CheckCircle2 className="w-3.5 h-3.5" />
) : (
<XCircle className="w-3.5 h-3.5" />
)}
{canBook ? t("available") : !minStayValid ? t("minStayNotMet") : t("notAvailable")}
</div>
</div>
<div className="p-5">
<h3 className="text-lg font-bold mb-1 text-brand-grey-dark">{displayName}</h3>
<div className="flex items-center gap-1.5 text-sm text-brand-grey mb-3">
<MapPin className="w-4 h-4 text-brand-green" />
<span>{houseName}</span>
</div>
<div className="flex items-center gap-2 text-sm text-brand-grey mb-1">
<Users className="w-4 h-4 text-brand-green" />
<span>
{apartment.maxGuests} {locale === "de" ? "Gäste" : "guests"}
</span>
</div>
{canBook && pricePerNight !== undefined && (
<p className="text-lg font-bold text-brand-green-dark mb-4">
{formatPrice(pricePerNight, currency)}{" "}
<span className="text-sm font-normal text-brand-grey">/ {t("perNight")}</span>
</p>
)}
{canBook ? (
<Link
href={`/${locale}/apartments/${apartment.slug}?checkin=${arrival}&checkout=${departure}`}
className="inline-flex items-center justify-center gap-2 w-full bg-brand-green text-white font-semibold px-4 py-3 rounded-xl hover:bg-brand-green-dark transition-colors"
>
{t("bookThis")}
<ArrowRight className="w-4 h-4" />
</Link>
) : (
<Link
href={`/${locale}/apartments/${apartment.slug}`}
className="inline-flex items-center justify-center gap-2 w-full bg-brand-grey-soft text-brand-grey-dark font-semibold px-4 py-3 rounded-xl hover:bg-brand-grey-soft/70 transition-colors"
>
{locale === "de" ? "Details ansehen" : "View details"}
</Link>
)}
</div>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Calendar, ArrowRight } from "lucide-react";
import { tomorrowDateString, addMonths } from "@/src/lib/date";
interface BookingBannerProps {
locale: string;
}
export function BookingBanner({ locale }: BookingBannerProps) {
const t = useTranslations("home.bookingBanner");
const router = useRouter();
const [arrival, setArrival] = useState(tomorrowDateString());
const [months, setMonths] = useState(3);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const departure = addMonths(arrival, months);
const searchParams = new URLSearchParams({
arrival,
departure,
months: String(months),
});
router.push(`/${locale}/houses?${searchParams.toString()}`);
};
return (
<section className="relative z-20 -mt-16 mb-24">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<form
onSubmit={handleSubmit}
className="bg-white rounded-3xl shadow-2xl shadow-brand-grey-dark/15 border border-brand-grey-soft/80 overflow-hidden"
>
<div className="h-1.5 w-full bg-gradient-to-r from-brand-green via-brand-green-light to-brand-green" />
<div className="p-8 md:p-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-end">
<div className="space-y-3">
<label
htmlFor="booking-arrival"
className="flex items-center gap-2 text-base font-bold text-brand-grey-dark"
>
<Calendar className="w-5 h-5 text-brand-green" />
{t("arrival")}
</label>
<div className="relative">
<input
id="booking-arrival"
type="date"
required
value={arrival}
min={tomorrowDateString()}
onChange={(e) => setArrival(e.target.value)}
className="w-full pl-4 pr-4 py-4 text-lg rounded-xl border-2 border-brand-grey-soft/60 bg-brand-grey-soft/20 text-brand-grey-dark focus:outline-none focus:ring-2 focus:ring-brand-green focus:border-transparent transition-all"
/>
</div>
</div>
<div className="space-y-3">
<label
htmlFor="booking-months"
className="block text-base font-bold text-brand-grey-dark"
>
{t("stayMonths")}
</label>
<div className="relative">
<input
id="booking-months"
type="number"
min={1}
max={24}
required
value={months}
onChange={(e) => setMonths(Number(e.target.value))}
className="w-full px-4 py-4 text-lg text-center rounded-xl border-2 border-brand-grey-soft/60 bg-brand-grey-soft/20 text-brand-grey-dark focus:outline-none focus:ring-2 focus:ring-brand-green focus:border-transparent transition-all"
/>
</div>
</div>
<button
type="submit"
className="inline-flex items-center justify-center gap-3 bg-brand-green text-white text-lg font-bold px-8 py-4 rounded-xl hover:bg-brand-green-dark hover:shadow-xl hover:-translate-y-1 transition-all"
>
{t("checkAvailability")}
<ArrowRight className="w-6 h-6" />
</button>
</div>
</div>
</form>
</div>
</section>
);
}
+392
View File
@@ -0,0 +1,392 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Apartment } from "@/src/config/apartments";
import { addDays } from "@/src/lib/date";
import { Calendar, Users } from "lucide-react";
interface BookingFormProps {
apartment: Apartment;
locale: string;
/** Initial value (probed server-side for "tomorrow") — kept up to date
* client-side via /api/min-stay whenever the guest changes the check-in
* date, since the effective minimum stay can vary by season/rate. */
minStayNights: number;
}
export function BookingForm({ apartment, locale, minStayNights: initialMinStayNights }: BookingFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [start, setStart] = useState(searchParams.get("checkin") || "");
const [end, setEnd] = useState(searchParams.get("checkout") || "");
const [guestName, setGuestName] = useState("");
const [guestEmail, setGuestEmail] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [attemptedSubmit, setAttemptedSubmit] = useState(false);
const [minStayNights, setMinStayNights] = useState(initialMinStayNights);
const [minStayLoading, setMinStayLoading] = useState(false);
const [price, setPrice] = useState<{
nights: number;
basePricePerNight: number;
totalAfterDiscount: number;
depositAmount: number;
discountPercent: number;
minStayValid: boolean;
available: boolean;
} | null>(null);
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
de: {
checkIn: "Anreise",
checkOut: "Abreise",
guests: "Gäste",
name: "Name",
email: "E-Mail",
calculate: "Preis berechnen",
bookNow: "Jetzt anzahlungspflichtig buchen",
minStay: "Mindestaufenthalt",
nights: "Nächte",
discount: "Ersparnis ggü. Kurzzeitpreis (bereits enthalten)",
total: "Gesamt",
deposit: "Anzahlung",
unavailable: "Nicht verfügbar",
loading: "Wird geladen...",
needsCalculation: "Bitte zuerst den Preis berechnen.",
needsNameAndEmail: "Bitte Name und E-Mail-Adresse eingeben, um fortzufahren.",
needsName: "Bitte Namen eingeben, um fortzufahren.",
needsEmail: "Bitte E-Mail-Adresse eingeben, um fortzufahren.",
},
en: {
checkIn: "Check-in",
checkOut: "Check-out",
guests: "Guests",
name: "Name",
email: "Email",
calculate: "Calculate price",
bookNow: "Book with deposit",
minStay: "Minimum stay",
nights: "nights",
discount: "Savings vs. short-term rate (already included)",
total: "Total",
deposit: "Deposit",
unavailable: "Not available",
loading: "Loading...",
needsCalculation: "Please calculate the price first.",
needsNameAndEmail: "Please enter your name and email address to continue.",
needsName: "Please enter your name to continue.",
needsEmail: "Please enter your email address to continue.",
},
};
return translations[locale]?.[key] || key;
};
// Re-fetch the real, effective minimum stay whenever the check-in date
// changes — Beds24's active rate (which can override the room's base
// minimum) may require a different length of stay depending on the season.
useEffect(() => {
if (!start) return;
let cancelled = false;
setMinStayLoading(true);
fetch(`/api/min-stay?apartmentSlug=${apartment.slug}&arrival=${start}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled && typeof data.minStayNights === "number") {
setMinStayNights(data.minStayNights);
setEnd((currentEnd) =>
currentEnd && currentEnd < addDays(start, data.minStayNights) ? "" : currentEnd
);
}
})
.catch(() => {
/* keep previous value on error */
})
.finally(() => {
if (!cancelled) setMinStayLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [start, apartment.slug]);
async function calculatePrice() {
if (!start || !end) return;
setLoading(true);
setError("");
try {
const res = await fetch(
`/api/availability?apartmentSlug=${apartment.slug}&start=${start}&end=${end}`
);
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to calculate price");
}
setPrice(data.price);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
setPrice(null);
} finally {
setLoading(false);
}
}
async function handleBooking() {
if (!price || !guestName || !guestEmail) {
setAttemptedSubmit(true);
return;
}
setAttemptedSubmit(false);
setLoading(true);
setError("");
try {
const res = await fetch("/api/checkout-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apartmentSlug: apartment.slug,
start,
end,
guestName,
guestEmail,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to create checkout session");
}
if (data.url) {
router.push(data.url);
} else {
throw new Error("No checkout URL returned");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}
return (
<div className="bg-white rounded-2xl p-6 border border-brand-grey-soft shadow-sm">
<h3 className="text-xl font-bold mb-1">
{locale === "de" ? "Buchungsanfrage" : "Booking request"}
</h3>
<p className="text-sm text-brand-grey mb-4">
{t("minStay")}:{" "}
<span className="font-semibold text-brand-grey-dark">
{minStayLoading
? locale === "de"
? "wird geprüft..."
: "checking..."
: `${minStayNights} ${t("nights")}`}
</span>
</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("checkIn")}
</label>
<div className="relative">
<Calendar className="absolute left-3 top-3 w-4 h-4 text-brand-grey-light" />
<input
type="date"
lang={locale === "de" ? "de-DE" : "en-US"}
value={start}
onChange={(e) => {
setStart(e.target.value);
// The checkout date's validity (and the min-stay value shown
// below) is re-checked by the useEffect above once we know
// the real minimum stay for this new check-in date.
setPrice(null);
}}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
<p className="text-xs text-brand-grey-light mt-1">
{start
? new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", {
dateStyle: "medium",
}).format(new Date(`${start}T00:00:00`))
: locale === "de"
? "Format: TT.MM.JJJJ"
: "Format: MM/DD/YYYY"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("checkOut")}
</label>
<div className="relative">
<Calendar className="absolute left-3 top-3 w-4 h-4 text-brand-grey-light" />
<input
type="date"
lang={locale === "de" ? "de-DE" : "en-US"}
value={end}
min={start ? addDays(start, minStayNights) : undefined}
onChange={(e) => {
setEnd(e.target.value);
setPrice(null);
}}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
<p className="text-xs text-brand-grey-light mt-1">
{end
? new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", {
dateStyle: "medium",
}).format(new Date(`${end}T00:00:00`))
: locale === "de"
? "Format: TT.MM.JJJJ"
: "Format: MM/DD/YYYY"}
</p>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("guests")}
</label>
<div className="relative">
<Users className="absolute left-3 top-3 w-4 h-4 text-brand-grey-light" />
<input
type="number"
min={1}
max={apartment.maxGuests}
defaultValue={1}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("name")}
</label>
<input
type="text"
value={guestName}
onChange={(e) => {
setGuestName(e.target.value);
if (e.target.value) setAttemptedSubmit(false);
}}
className={`w-full px-3 py-2 rounded-lg border focus:ring-1 outline-none ${
attemptedSubmit && !guestName
? "border-red-400 focus:border-red-500 focus:ring-red-400"
: "border-brand-grey-soft focus:border-brand-green focus:ring-brand-green"
}`}
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("email")}
</label>
<input
type="email"
value={guestEmail}
onChange={(e) => {
setGuestEmail(e.target.value);
if (e.target.value) setAttemptedSubmit(false);
}}
className={`w-full px-3 py-2 rounded-lg border focus:ring-1 outline-none ${
attemptedSubmit && !guestEmail
? "border-red-400 focus:border-red-500 focus:ring-red-400"
: "border-brand-grey-soft focus:border-brand-green focus:ring-brand-green"
}`}
/>
</div>
<button
onClick={calculatePrice}
disabled={!start || !end || loading}
className={`w-full mb-4 text-white font-semibold py-3 rounded-full transition-colors disabled:opacity-50 ${
attemptedSubmit && !price
? "bg-red-500 hover:bg-red-600 ring-2 ring-red-300"
: "bg-brand-grey hover:bg-brand-grey-dark"
}`}
>
{loading ? t("loading") : t("calculate")}
</button>
{price && (
<div className="bg-brand-grey-soft rounded-xl p-4 mb-4">
<div className="flex justify-between mb-2">
<span className="text-brand-grey">
{price.nights} {t("nights")}
</span>
<span className="font-medium">
{new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(price.totalAfterDiscount)}
</span>
</div>
{price.discountPercent > 0 && (
<div className="flex justify-between mb-2 text-brand-green">
<span>{t("discount")}</span>
<span>{price.discountPercent}%</span>
</div>
)}
<div className="flex justify-between text-lg font-bold text-brand-grey-dark">
<span>{t("deposit")}</span>
<span>
{new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(price.depositAmount)}
</span>
</div>
</div>
)}
{error && (
<div className="bg-red-50 text-red-600 rounded-lg p-3 mb-4 text-sm">
{error}
</div>
)}
<button
onClick={handleBooking}
disabled={loading}
className={`w-full font-semibold py-3 rounded-full transition-colors disabled:opacity-50 ${
price && guestName && guestEmail
? "bg-brand-green text-white hover:bg-brand-green-dark"
: "bg-brand-grey-soft text-brand-grey-dark hover:bg-brand-grey-soft/70"
}`}
>
{loading ? t("loading") : t("bookNow")}
</button>
{!loading && (!price || !guestName || !guestEmail) && (
<p
className={`text-lg font-semibold rounded-lg px-4 py-3 mt-3 text-center ${
attemptedSubmit
? "bg-red-50 text-red-600 border-2 border-red-300"
: "bg-brand-green/10 text-brand-green-dark"
}`}
>
{!price
? t("needsCalculation")
: !guestName && !guestEmail
? t("needsNameAndEmail")
: !guestName
? t("needsName")
: t("needsEmail")}
</p>
)}
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import Image from "next/image";
interface HeroProps {
title: string;
subtitle: string;
description: string;
cta: string;
ctaHref: string;
}
const heroImages = [
{ src: "/images/heroshots/MKE_1996.JPG", alt: "BB-Apartment Hero 1" },
{ src: "/images/heroshots/MKE_1959.JPG", alt: "BB-Apartment Hero 2" },
{ src: "/images/heroshots/MKE_1974.jpg", alt: "BB-Apartment Hero 3" },
];
export function Hero({ title, subtitle, description, cta, ctaHref }: HeroProps) {
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setActiveIndex((prev) => (prev + 1) % heroImages.length);
}, 5000);
return () => clearInterval(interval);
}, []);
return (
<section className="relative -mt-20 pt-20 h-screen flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 z-0">
{heroImages.map((image, index) => (
<div
key={image.src}
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
index === activeIndex ? "opacity-100" : "opacity-0"
}`}
>
<Image
src={image.src}
alt={image.alt}
fill
className="object-cover"
priority={index === 0}
/>
</div>
))}
<div className="absolute inset-0 bg-black/20" />
</div>
<div className="relative z-10 max-w-5xl mx-auto px-4 sm:px-6 text-center text-white">
<p className="text-lg md:text-xl text-brand-green-light mb-4 font-medium tracking-wide uppercase">
{subtitle}
</p>
<h1 className="text-5xl md:text-7xl font-bold mb-10 text-white tracking-tight">
{title}
</h1>
{description && (
<p className="text-lg md:text-2xl text-white/90 mb-10 max-w-3xl mx-auto leading-relaxed">
{description}
</p>
)}
<Link
href={ctaHref}
className="inline-flex items-center gap-2 bg-brand-green text-white font-semibold px-8 py-4 rounded-2xl text-lg hover:bg-brand-green-dark hover:shadow-lg hover:-translate-y-0.5 transition-all"
>
{cta}
</Link>
</div>
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-20 flex gap-2">
{heroImages.map((_, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={`w-2.5 h-2.5 rounded-full transition-all ${
index === activeIndex
? "bg-white scale-110"
: "bg-white/50 hover:bg-white/80"
}`}
aria-label={`Zum Hero-Bild ${index + 1} wechseln`}
/>
))}
</div>
</section>
);
}
+98
View File
@@ -0,0 +1,98 @@
import Link from "next/link";
import Image from "next/image";
import { House } from "@/src/config/apartments";
import { MapPin, Home, ArrowRight, Wind, Car, Bus, Sofa } from "lucide-react";
interface HouseCardProps {
house: House;
locale: string;
}
export function HouseCard({ house, locale }: HouseCardProps) {
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="group h-full bg-white rounded-3xl overflow-hidden shadow-sm border border-brand-grey-soft hover:shadow-2xl hover:shadow-brand-grey-dark/10 hover:-translate-y-2 transition-all duration-300 flex flex-col">
<div className="relative h-72 overflow-hidden flex-shrink-0">
<Image
src={house.images[0] || "/images/placeholder.jpg"}
alt={displayName}
fill
className="object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute top-4 left-4">
<span className="inline-flex items-center gap-1.5 bg-white/90 backdrop-blur-sm text-brand-green-dark text-sm font-semibold px-3 py-1.5 rounded-full">
<Home className="w-4 h-4" />
{house.apartments.length} {locale === "de" ? "Apartments" : "Apartments"}
</span>
</div>
</div>
<div className="p-7 flex flex-col flex-grow">
<h3 className="text-2xl font-bold mb-3 text-brand-grey-dark group-hover:text-brand-green transition-colors">
{displayName}
</h3>
<div className="flex flex-wrap gap-2 mb-4">
{house.id === "herdweg" ? (
<>
<span className="inline-flex items-center gap-2 text-sm font-bold text-white bg-brand-green px-3 py-1.5 rounded-full shadow-sm">
<Car className="w-5 h-5 flex-shrink-0" />
<div className="flex flex-col leading-tight">
<span>
{locale === "de" ? "Gleiche Straße wie Panzer Kaserne" : "Same Street as Panzer Kaserne"}
</span>
<span className="text-xs font-medium text-white/85">
{locale === "de" ? "Kein Abbiegen nötig" : "Zero turns needed"}
</span>
</div>
</span>
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-brand-green-dark bg-brand-green/10 px-3 py-1.5 rounded-full">
<Wind className="w-4 h-4" />
{locale === "de" ? "Klimaanlage" : "Air conditioning"}
</span>
</>
) : (
<>
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-brand-green-dark bg-brand-green/10 px-3 py-1.5 rounded-full">
<MapPin className="w-4 h-4" />
{locale === "de" ? "Nahe Stadtzentrum" : "Close to city center"}
</span>
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-brand-green-dark bg-brand-green/10 px-3 py-1.5 rounded-full">
<Wind className="w-4 h-4" />
{locale === "de" ? "Klimaanlage" : "Air conditioning"}
</span>
</>
)}
{house.id === "herdweg" && (
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-brand-green-dark bg-brand-green/10 px-3 py-1.5 rounded-full">
<Bus className="w-4 h-4" />
{locale === "de" ? "Bus in der Nähe" : "Bus nearby"}
</span>
)}
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-brand-green-dark bg-brand-green/10 px-3 py-1.5 rounded-full">
<Sofa className="w-4 h-4" />
{locale === "de" ? "Vollmöbliert" : "Fully furnished"}
</span>
</div>
<p className="text-brand-grey mb-5 leading-relaxed flex-grow">
{displayDescription}
</p>
<div className="flex items-center gap-2 text-sm text-brand-grey mb-6">
<MapPin className="w-4 h-4 text-brand-green" />
<span>
{house.city}, {displayCountry}
</span>
</div>
<Link
href={`/${locale}/houses/${house.slug}`}
className="mt-auto inline-flex items-center justify-center gap-2 w-full bg-brand-green text-white font-semibold px-6 py-3.5 rounded-2xl hover:bg-brand-green-dark hover:shadow-lg transition-all"
>
{locale === "de" ? "Mehr erfahren" : "Learn more"}
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { Wind, Car, Bus, MapPin, Sofa } from "lucide-react";
import { House } from "@/src/config/apartments";
interface HouseHighlightsProps {
house: House;
locale: string;
}
export function HouseHighlights({ house, locale }: HouseHighlightsProps) {
const isHerdweg = house.id === "herdweg";
const highlights = isHerdweg
? [
{
icon: Car,
title: locale === "de" ? "Gleiche Straße wie Panzer Kaserne" : "Same Street as Panzer Kaserne",
text: locale === "de" ? "Kein Abbiegen nötig" : "Zero turns needed",
highlight: true,
},
{
icon: Bus,
title: locale === "de" ? "Bushaltestelle" : "Bus stop",
text: locale === "de" ? "In der Nähe" : "Nearby",
},
{
icon: Wind,
title: locale === "de" ? "Klimaanlage" : "Air conditioning",
text: locale === "de" ? "In den meisten Apartments" : "In most apartments",
},
{
icon: Sofa,
title: locale === "de" ? "Vollmöbliert" : "Fully furnished",
text: locale === "de" ? "Alle Apartments" : "All apartments",
},
]
: [
{
icon: MapPin,
title: locale === "de" ? "Stadtzentrum" : "City center",
text: locale === "de" ? "Nahe gelegen" : "Close by",
},
{
icon: Car,
title: locale === "de" ? "Daimler Werk Sindelfingen" : "Daimler plant Sindelfingen",
text: locale === "de" ? "Leicht erreichbar" : "Easy to reach",
},
{
icon: Wind,
title: locale === "de" ? "Klimaanlage" : "Air conditioning",
text: locale === "de" ? "In allen Apartments" : "In all apartments",
},
{
icon: Sofa,
title: locale === "de" ? "Vollmöbliert" : "Fully furnished",
text: locale === "de" ? "Alle Apartments" : "All apartments",
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5 mb-12">
{highlights.map((highlight, index) => {
const Icon = highlight.icon;
return (
<div
key={index}
className={`flex flex-col items-center text-center gap-4 p-6 rounded-2xl shadow-md hover:shadow-xl hover:-translate-y-1 transition-all ${
highlight.highlight
? "bg-brand-green border-2 border-brand-green text-white"
: "bg-white border-2 border-brand-green/20"
}`}
>
<div
className={`p-4 rounded-2xl shadow-sm ${
highlight.highlight ? "bg-white text-brand-green" : "bg-brand-green text-white"
}`}
>
<Icon className="w-9 h-9" />
</div>
<div>
<p
className={`font-bold text-xl leading-tight mb-1 ${
highlight.highlight ? "text-white" : "text-brand-grey-dark"
}`}
>
{highlight.title}
</p>
<p className={`text-base font-medium ${highlight.highlight ? "text-white/90" : "text-brand-grey"}`}>
{highlight.text}
</p>
</div>
</div>
);
})}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface ImageGalleryProps {
images: string[];
alt: string;
}
export function ImageGallery({ images, alt }: ImageGalleryProps) {
const [activeIndex, setActiveIndex] = useState(0);
const hasImages = images.length > 0;
const handlePrevious = () => {
setActiveIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
};
const handleNext = () => {
setActiveIndex((prev) => (prev + 1) % images.length);
};
return (
<div className="space-y-4">
<div className="relative h-80 md:h-[500px] rounded-2xl overflow-hidden bg-brand-grey-soft">
{hasImages && (
<Image
src={images[activeIndex]}
alt={`${alt} - ${activeIndex + 1}`}
fill
className="object-cover"
/>
)}
{images.length > 1 && (
<>
<button
onClick={handlePrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-2 bg-white/80 rounded-full hover:bg-white transition-colors"
aria-label="Vorheriges Bild"
>
<ChevronLeft className="w-5 h-5 text-brand-grey-dark" />
</button>
<button
onClick={handleNext}
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-2 bg-white/80 rounded-full hover:bg-white transition-colors"
aria-label="Nächstes Bild"
>
<ChevronRight className="w-5 h-5 text-brand-grey-dark" />
</button>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex gap-1.5">
{images.map((_, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={`w-2 h-2 rounded-full transition-all ${
index === activeIndex
? "bg-white scale-110"
: "bg-white/50 hover:bg-white/80"
}`}
aria-label={`Zu Bild ${index + 1} wechseln`}
/>
))}
</div>
</>
)}
</div>
{images.length > 1 && (
<div className="flex gap-3 overflow-x-auto pb-2">
{images.map((image, index) => (
<button
key={image}
onClick={() => setActiveIndex(index)}
className={`relative w-20 h-20 flex-shrink-0 rounded-xl overflow-hidden ${
index === activeIndex
? "ring-2 ring-brand-green"
: "ring-2 ring-transparent opacity-70 hover:opacity-100"
}`}
>
<Image
src={image}
alt={`${alt} - Vorschau ${index + 1}`}
fill
className="object-cover"
/>
</button>
))}
</div>
)}
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Image from "next/image";
const tilePools = [
[
"/images/houses/herdweg/impressions/11.png",
"/images/houses/herdweg/impressions/MKE_1986.JPG",
"/images/houses/herdweg/impressions/MKE_2042.JPG",
],
[
"/images/houses/herdweg/impressions/MKE_1974.jpg",
"/images/houses/herdweg/impressions/MKE_2027.jpg",
],
[
"/images/houses/herdweg/impressions/MKE_2030.jpg",
"/images/houses/herdweg/impressions/MKE_2034.JPG",
],
[
"/images/houses/herdweg/impressions/MKE_2037.jpg",
"/images/houses/herdweg/impressions/MKE_2038.JPG",
],
];
function nextFromPool(pool: string[], exclude: string) {
const filtered = pool.filter((src) => src !== exclude);
if (filtered.length === 0) return pool[0];
return filtered[Math.floor(Math.random() * filtered.length)];
}
interface ImpressionsTileProps {
pool: string[];
initialSrc: string;
className?: string;
}
function ImpressionsTile({ pool, initialSrc, className = "" }: ImpressionsTileProps) {
const [current, setCurrent] = useState(initialSrc);
const [visible, setVisible] = useState(true);
const rotate = useCallback(() => {
setVisible(false);
setTimeout(() => {
setCurrent((prev) => nextFromPool(pool, prev));
setVisible(true);
}, 700);
}, [pool]);
useEffect(() => {
let timeout: NodeJS.Timeout;
const schedule = () => {
const delay = 6000 + Math.random() * 6000;
timeout = setTimeout(() => {
rotate();
schedule();
}, delay);
};
schedule();
return () => clearTimeout(timeout);
}, [rotate]);
return (
<div
className={`relative overflow-hidden rounded-2xl shadow-md hover:shadow-xl transition-shadow ${className}`}
>
<Image
src={current}
alt="Impression"
fill
className={`object-cover transition-all duration-700 ease-in-out ${
visible
? "opacity-100 translate-x-0"
: "opacity-0 -translate-x-8"
}`}
sizes="(max-width: 768px) 100vw, 33vw"
/>
</div>
);
}
interface ImpressionsProps {
title: string;
subtitle: string;
}
export function Impressions({ title, subtitle }: ImpressionsProps) {
return (
<section className="py-24 bg-brand-grey-soft/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<p className="text-brand-green font-semibold mb-2 uppercase tracking-wider text-sm">
{subtitle}
</p>
<h2 className="text-4xl font-bold mb-4 text-brand-grey-dark">
{title}
</h2>
<div className="w-20 h-1 bg-brand-green mx-auto rounded-full" />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 auto-rows-[220px]">
<ImpressionsTile
pool={tilePools[0]}
initialSrc={tilePools[0][0]}
className="md:col-span-2 md:row-span-2"
/>
<ImpressionsTile pool={tilePools[1]} initialSrc={tilePools[1][0]} />
<ImpressionsTile pool={tilePools[2]} initialSrc={tilePools[2][0]} />
<ImpressionsTile
pool={tilePools[3]}
initialSrc={tilePools[3][0]}
className="md:col-span-3"
/>
</div>
</div>
</section>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import { Mail, Send, CheckCircle2 } from "lucide-react";
interface NoAvailabilityContactProps {
locale: string;
/** e.g. the searched arrival/departure dates, sent along for context. */
context?: string;
}
export function NoAvailabilityContact({ locale, context }: NoAvailabilityContactProps) {
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [sent, setSent] = useState(false);
const isDe = locale === "de";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || !message) return;
setLoading(true);
setError("");
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, message, context }),
});
if (!res.ok) {
throw new Error("Failed to send message");
}
setSent(true);
} catch {
setError(
isDe
? "Ihre Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es erneut oder schreiben Sie uns direkt an info@bb-apartment.de."
: "Your message could not be sent. Please try again or email us directly at info@bb-apartment.de."
);
} finally {
setLoading(false);
}
}
if (sent) {
return (
<div className="bg-brand-green/10 border-2 border-brand-green/30 rounded-2xl p-8 text-center max-w-xl mx-auto">
<CheckCircle2 className="w-10 h-10 text-brand-green mx-auto mb-3" />
<h3 className="text-xl font-bold text-brand-grey-dark mb-2">
{isDe ? "Nachricht gesendet!" : "Message sent!"}
</h3>
<p className="text-brand-grey">
{isDe
? "Danke für Ihre Anfrage. Wir melden uns so schnell wie möglich bei Ihnen."
: "Thank you for reaching out. We'll get back to you as soon as possible."}
</p>
</div>
);
}
return (
<div className="bg-white border-2 border-brand-grey-soft rounded-2xl p-8 max-w-xl mx-auto">
<div className="flex items-center gap-3 mb-2">
<Mail className="w-6 h-6 text-brand-green" />
<h3 className="text-xl font-bold text-brand-grey-dark">
{isDe ? "Kein passendes Apartment gefunden?" : "Can't find what you're looking for?"}
</h3>
</div>
<p className="text-brand-grey mb-6">
{isDe
? "Schreiben Sie uns kurz Ihre Wunschtermine und Anforderungen — wir melden uns persönlich bei Ihnen, auch für individuelle Anfragen oder Wartelisten."
: "Tell us your preferred dates and requirements — we'll get back to you personally, including for custom requests or waitlists."}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{isDe ? "E-Mail" : "Email"}
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{isDe ? "Ihre Nachricht" : "Your message"}
</label>
<textarea
required
rows={5}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={
isDe
? "z.B. gewünschter Zeitraum, Anzahl Personen, besondere Wünsche..."
: "e.g. preferred dates, number of guests, special requests..."
}
className="w-full px-4 py-3 rounded-xl border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
{error && (
<div className="bg-red-50 text-red-600 rounded-lg p-3 text-sm">{error}</div>
)}
<button
type="submit"
disabled={loading}
className="inline-flex items-center justify-center gap-2 w-full bg-brand-green text-white font-semibold px-8 py-4 rounded-full hover:bg-brand-green-dark transition-colors disabled:opacity-50"
>
{loading ? (isDe ? "Wird gesendet..." : "Sending...") : isDe ? "Nachricht senden" : "Send message"}
<Send className="w-4 h-4" />
</button>
</form>
</div>
);
}