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,128 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Home,
|
||||
MapPin,
|
||||
Mail,
|
||||
Phone,
|
||||
Globe,
|
||||
Shield,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
|
||||
export function Footer() {
|
||||
const t = useTranslations("footer");
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="relative bg-brand-grey-dark text-white overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-brand-green via-brand-green-light to-brand-green" />
|
||||
<div className="absolute -top-24 -right-24 w-64 h-64 bg-brand-green/10 rounded-full blur-3xl" />
|
||||
<div className="absolute -bottom-32 -left-32 w-80 h-80 bg-brand-green/10 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-brand-green rounded-xl">
|
||||
<Home className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">{t("brandTitle")}</h3>
|
||||
</div>
|
||||
<p className="text-brand-grey-light text-sm leading-relaxed">
|
||||
{t("welcome")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-brand-green-light mb-2">
|
||||
<MapPin className="w-5 h-5" />
|
||||
<h3 className="text-lg font-bold text-white">{t("locationsTitle")}</h3>
|
||||
</div>
|
||||
<div className="space-y-4 text-sm">
|
||||
<a
|
||||
href="https://maps.google.com/?q=Herdweg+71+Böblingen"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-brand-grey-light hover:text-brand-green-light transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-white">{t("herdwegName")}</span>
|
||||
<br />
|
||||
{t("herdwegAddress")}
|
||||
</a>
|
||||
<a
|
||||
href="https://maps.google.com/?q=Schönaicher+Straße+26+Böblingen"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-brand-grey-light hover:text-brand-green-light transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-white">{t("schoenaicherName")}</span>
|
||||
<br />
|
||||
{t("schoenaicherAddress")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-brand-green-light mb-2">
|
||||
<Phone className="w-5 h-5" />
|
||||
<h3 className="text-lg font-bold text-white">{t("contactTitle")}</h3>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm">
|
||||
<a
|
||||
href={`mailto:${t("email")}`}
|
||||
className="flex items-center gap-2 text-brand-grey-light hover:text-brand-green-light transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4 text-brand-green" />
|
||||
{t("email")}
|
||||
</a>
|
||||
<a
|
||||
href={`tel:${t("phone").replace(/\s/g, "")}`}
|
||||
className="flex items-center gap-2 text-brand-grey-light hover:text-brand-green-light transition-colors"
|
||||
>
|
||||
<Phone className="w-4 h-4 text-brand-green" />
|
||||
{t("phone")}
|
||||
</a>
|
||||
<a
|
||||
href={`https://${t("web")}`}
|
||||
className="flex items-center gap-2 text-brand-grey-light hover:text-brand-green-light transition-colors"
|
||||
>
|
||||
<Globe className="w-4 h-4 text-brand-green" />
|
||||
{t("web")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-brand-green-light mb-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
<h3 className="text-lg font-bold text-white">{t("legalTitle")}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link
|
||||
href="/imprint"
|
||||
className="flex items-center gap-2 text-brand-grey-light hover:text-brand-green-light hover:translate-x-1 transition-all text-sm"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4 text-brand-green" />
|
||||
{t("imprint")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="flex items-center gap-2 text-brand-grey-light hover:text-brand-green-light hover:translate-x-1 transition-all text-sm"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4 text-brand-green" />
|
||||
{t("privacy")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-14 pt-8 border-t border-brand-grey/30 text-center text-brand-grey-light text-sm">
|
||||
© {currentYear} BB-Apartment. {t("rights")}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function Header({ locale }: { locale: string }) {
|
||||
const t = useTranslations("navigation");
|
||||
const pathname = usePathname();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
|
||||
const showSolid = !isHome || scrolled;
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 20);
|
||||
};
|
||||
|
||||
handleScroll();
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const navLinks = [
|
||||
{ href: `/${locale}`, label: t("home") },
|
||||
{ href: `/${locale}/houses`, label: t("houses") },
|
||||
{ href: `/${locale}/contact`, label: t("contact") },
|
||||
];
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 border-b ${
|
||||
showSolid
|
||||
? "bg-white/95 backdrop-blur-md border-brand-grey-soft shadow-sm"
|
||||
: "bg-transparent border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div
|
||||
className={`flex items-center justify-between transition-all duration-300 ${
|
||||
showSolid ? "h-20" : "h-28"
|
||||
}`}
|
||||
>
|
||||
<Link href={`/${locale}`} className="flex items-center gap-3">
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-lg transition-all duration-300 ${
|
||||
showSolid ? "w-12 h-12" : "w-24 h-24"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src="/images/logo.jpg"
|
||||
alt="BB-Apartment"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xl font-bold transition-colors duration-300 ${
|
||||
showSolid ? "text-brand-grey-dark" : "text-white"
|
||||
}`}
|
||||
>
|
||||
BB-Apartment
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-8">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`font-medium transition-colors duration-300 ${
|
||||
showSolid
|
||||
? "text-brand-grey hover:text-brand-green"
|
||||
: "text-white/90 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<LanguageSwitcher locale={locale} scrolled={showSolid} />
|
||||
<Link
|
||||
href={`/${locale}/houses`}
|
||||
className={`font-semibold px-5 py-2.5 rounded-xl transition-all duration-300 ${
|
||||
showSolid
|
||||
? "bg-brand-green text-white hover:bg-brand-green-dark shadow-sm"
|
||||
: "bg-white text-brand-green-dark hover:bg-brand-grey-soft"
|
||||
}`}
|
||||
>
|
||||
{t("bookNow")}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="md:hidden flex items-center gap-4">
|
||||
<LanguageSwitcher locale={locale} scrolled={showSolid} />
|
||||
<Link
|
||||
href={`/${locale}/houses`}
|
||||
className="hidden sm:inline-flex font-semibold px-4 py-2 rounded-xl bg-brand-green text-white hover:bg-brand-green-dark transition-colors text-sm"
|
||||
>
|
||||
{t("bookNow")}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className={`p-2 transition-colors duration-300 ${
|
||||
showSolid ? "text-brand-grey-dark" : "text-white"
|
||||
}`}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden bg-white border-t border-brand-grey-soft">
|
||||
<nav className="flex flex-col px-4 py-4 gap-4">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="text-brand-grey hover:text-brand-green font-medium transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
href={`/${locale}/houses`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="inline-flex items-center justify-center gap-2 bg-brand-green text-white font-semibold px-4 py-3 rounded-xl hover:bg-brand-green-dark transition-colors"
|
||||
>
|
||||
{t("bookNow")}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { locales } from "@/i18n";
|
||||
|
||||
export function LanguageSwitcher({
|
||||
locale,
|
||||
scrolled = false,
|
||||
}: {
|
||||
locale: string;
|
||||
scrolled?: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const strippedPath = pathname.replace(`/${locale}`, "") || "/";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1 rounded-full p-1 transition-colors duration-300 ${
|
||||
scrolled ? "bg-brand-grey-soft" : "bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{locales.map((l) => (
|
||||
<Link
|
||||
key={l}
|
||||
href={`/${l}${strippedPath}`}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors duration-300 ${
|
||||
l === locale
|
||||
? "bg-brand-green text-white"
|
||||
: scrolled
|
||||
? "text-brand-grey hover:text-brand-green-dark"
|
||||
: "text-white/80 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{l.toUpperCase()}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user