Initial upload of BB-Apartment 2026 website.
Adds the complete Next.js website including i18n routing, house and apartment pages, Beds24 v2 integration for live availability/pricing, Stripe checkout, contact forms, Docker setup and deployment docs. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
232770afe7
commit
2b24334bfa
@@ -0,0 +1,209 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getApartmentBySlug, getHouseById, bookingConfig } from "@/src/config/apartments";
|
||||
import { getEffectiveMinStay } from "@/src/lib/beds24";
|
||||
import { tomorrowDateString } from "@/src/lib/date";
|
||||
import { Bed, Bath, Users, Calendar, Check, Wind, Sofa, Wifi, WashingMachine, Tv } from "lucide-react";
|
||||
import { BookingForm } from "@/components/ui/BookingForm";
|
||||
import { ImageGallery } from "@/components/ui/ImageGallery";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale, apartmentSlug },
|
||||
}: {
|
||||
params: { locale: string; apartmentSlug: string };
|
||||
}) {
|
||||
const apartment = getApartmentBySlug(apartmentSlug);
|
||||
if (!apartment) return { title: "Not Found" };
|
||||
|
||||
const t = await getTranslations({ locale, namespace: "metadata" });
|
||||
return {
|
||||
title: `${apartment.nameEn} - ${t("title")}`,
|
||||
description: apartment.descriptionEn || t("description"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ApartmentPage({
|
||||
params: { locale, apartmentSlug },
|
||||
}: {
|
||||
params: { locale: string; apartmentSlug: string };
|
||||
}) {
|
||||
const t = await getTranslations({ locale, namespace: "apartment" });
|
||||
const apartment = getApartmentBySlug(apartmentSlug);
|
||||
const house = apartment ? getHouseById(apartment.houseId) : undefined;
|
||||
|
||||
if (!apartment || !house) notFound();
|
||||
|
||||
// Real, effective minimum stay for this apartment as of "tomorrow" — this
|
||||
// combines Beds24's room-level minimum with whatever the currently active
|
||||
// rate/price plan requires (the higher of the two wins), so it reflects
|
||||
// what a guest could actually book, not just the room's base setting.
|
||||
// The BookingForm re-fetches this whenever the guest picks a different
|
||||
// check-in date, since it can vary by season.
|
||||
const minStayNights = await getEffectiveMinStay(
|
||||
apartment.houseId,
|
||||
apartment.beds24PropertyId,
|
||||
apartment.beds24RoomId,
|
||||
tomorrowDateString(),
|
||||
bookingConfig.minStayNights
|
||||
);
|
||||
|
||||
const displayName = locale === "de" ? apartment.name : apartment.nameEn;
|
||||
const displayDescription =
|
||||
locale === "de" ? apartment.description : apartment.descriptionEn;
|
||||
const displayFloor = locale === "de" ? apartment.floor : apartment.floorEn;
|
||||
const amenities =
|
||||
locale === "de" ? apartment.amenities : apartment.amenitiesEn;
|
||||
const houseName = locale === "de" ? house.name : house.nameEn;
|
||||
|
||||
const hasAmenity = (keys: string[]) =>
|
||||
amenities.some((a) => keys.some((k) => a.toLowerCase().includes(k.toLowerCase())));
|
||||
|
||||
// Apartment-specific highlights (excludes "Fully furnished", already shown elsewhere)
|
||||
const specificHighlights = [
|
||||
hasAmenity(["Klimaanlage", "Air conditioning"]) && {
|
||||
icon: Wind,
|
||||
title: locale === "de" ? "Klimaanlage" : "Air conditioning",
|
||||
},
|
||||
hasAmenity(["Waschmaschine", "Washing machine"]) && {
|
||||
icon: WashingMachine,
|
||||
title: locale === "de" ? "Eigene Waschmaschine" : "In-unit Washer",
|
||||
},
|
||||
hasAmenity(["WLAN", "WiFi"]) && {
|
||||
icon: Wifi,
|
||||
title: locale === "de" ? "High-Speed WLAN" : "High-Speed WiFi",
|
||||
},
|
||||
hasAmenity(["TV"]) && {
|
||||
icon: Tv,
|
||||
title: "Smart TV",
|
||||
},
|
||||
].filter(Boolean) as { icon: typeof Wind; title: string }[];
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href={`/${locale}/houses/${house.slug}`}
|
||||
className="text-brand-green-dark hover:text-brand-green"
|
||||
>
|
||||
← {houseName}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-12">
|
||||
<ImageGallery
|
||||
images={apartment.images.length > 0 ? apartment.images : ["/images/placeholder.jpg"]}
|
||||
alt={displayName}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">{displayName}</h1>
|
||||
<p className="text-brand-grey mb-6">{houseName} • {displayFloor}</p>
|
||||
|
||||
{displayDescription && (
|
||||
<p className="text-brand-grey leading-relaxed mb-6">
|
||||
{displayDescription}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||
<div className="flex items-center gap-3 p-4 bg-brand-grey-soft rounded-xl">
|
||||
<Users className="w-5 h-5 text-brand-green" />
|
||||
<div>
|
||||
<p className="text-sm text-brand-grey">{t("guests")}</p>
|
||||
<p className="font-semibold">{apartment.maxGuests}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-4 bg-brand-grey-soft rounded-xl">
|
||||
<Calendar className="w-5 h-5 text-brand-green" />
|
||||
<div>
|
||||
<p className="text-sm text-brand-grey">{t("minStay")}</p>
|
||||
<p className="font-semibold">{minStayNights} {t("nights")}</p>
|
||||
</div>
|
||||
</div>
|
||||
{amenities.some((a) =>
|
||||
["Klimaanlage", "Air conditioning"].includes(a)
|
||||
) && (
|
||||
<div className="flex items-center gap-3 p-4 bg-brand-green/10 rounded-xl border border-brand-green/20 col-span-2 sm:col-span-1">
|
||||
<Wind className="w-5 h-5 text-brand-green" />
|
||||
<div>
|
||||
<p className="text-sm text-brand-grey">
|
||||
{locale === "de" ? "Klimaanlage" : "Air conditioning"}
|
||||
</p>
|
||||
<p className="font-semibold text-brand-green-dark">
|
||||
{locale === "de" ? "Vorhanden" : "Available"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 p-4 bg-brand-green/10 rounded-xl border border-brand-green/20">
|
||||
<Sofa className="w-5 h-5 text-brand-green" />
|
||||
<div>
|
||||
<p className="text-sm text-brand-grey">
|
||||
{locale === "de" ? "Vollmöbliert" : "Fully furnished"}
|
||||
</p>
|
||||
<p className="font-semibold text-brand-green-dark">
|
||||
{locale === "de" ? "Ja" : "Yes"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{apartment.bedrooms && (
|
||||
<div className="flex items-center gap-3 p-4 bg-brand-grey-soft rounded-xl">
|
||||
<Bed className="w-5 h-5 text-brand-green" />
|
||||
<div>
|
||||
<p className="text-sm text-brand-grey">{locale === "de" ? "Schlafzimmer" : "Bedrooms"}</p>
|
||||
<p className="font-semibold">{apartment.bedrooms}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{apartment.bathrooms && (
|
||||
<div className="flex items-center gap-3 p-4 bg-brand-grey-soft rounded-xl">
|
||||
<Bath className="w-5 h-5 text-brand-green" />
|
||||
<div>
|
||||
<p className="text-sm text-brand-grey">{locale === "de" ? "Bäder" : "Bathrooms"}</p>
|
||||
<p className="font-semibold">{apartment.bathrooms}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BookingForm apartment={apartment} locale={locale} minStayNights={minStayNights} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{specificHighlights.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-5 mb-12">
|
||||
{specificHighlights.map((highlight, index) => {
|
||||
const Icon = highlight.icon;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center text-center gap-4 p-6 bg-white rounded-2xl border-2 border-brand-green/20 shadow-md hover:shadow-xl hover:-translate-y-1 transition-all"
|
||||
>
|
||||
<div className="p-4 bg-brand-green rounded-2xl text-white shadow-sm">
|
||||
<Icon className="w-9 h-9" />
|
||||
</div>
|
||||
<p className="font-bold text-brand-grey-dark text-lg leading-tight">
|
||||
{highlight.title}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-brand-grey-soft rounded-2xl p-8">
|
||||
<h2 className="text-2xl font-bold mb-6">{t("features")}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{amenities.map((amenity, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<Check className="w-5 h-5 text-brand-green flex-shrink-0" />
|
||||
<span className="text-brand-grey-dark">{amenity}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { CheckCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const t = await getTranslations({ locale, namespace: "metadata" });
|
||||
return {
|
||||
title: `${t("title")} - Buchung erfolgreich`,
|
||||
description: t("description"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function BookingSuccessPage({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const isDe = locale === "de";
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-2xl mx-auto px-4 text-center">
|
||||
<CheckCircle className="w-16 h-16 text-brand-green mx-auto mb-6" />
|
||||
<h1 className="text-4xl font-bold mb-4">
|
||||
{isDe ? "Anzahlung erfolgreich!" : "Deposit successful!"}
|
||||
</h1>
|
||||
<p className="text-brand-grey text-lg mb-8">
|
||||
{isDe
|
||||
? "Vielen Dank für Ihre Buchung. Sie erhalten in Kürze eine Bestätigungs-E-Mail. Die monatlichen Rechnungen werden Ihnen über das Kundenportal zur Verfügung gestellt."
|
||||
: "Thank you for your booking. You will receive a confirmation email shortly. Monthly invoices will be available through the customer portal."}
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="inline-block bg-brand-green text-white font-semibold px-8 py-4 rounded-full hover:bg-brand-green-dark transition-colors"
|
||||
>
|
||||
{isDe ? "Zurück zur Startseite" : "Back to home"}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Mail, Phone, MapPin } from "lucide-react";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const t = await getTranslations({ locale, namespace: "contact" });
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ContactPage({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const t = useTranslations("contact");
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold mb-4">{t("title")}</h1>
|
||||
<p className="text-brand-grey text-lg max-w-2xl mx-auto">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<div className="bg-brand-grey-soft rounded-2xl p-8">
|
||||
<h2 className="text-2xl font-bold mb-6">
|
||||
{locale === "de" ? "Kontaktdaten" : "Contact Details"}
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<Mail className="w-6 h-6 text-brand-green mt-1" />
|
||||
<div>
|
||||
<p className="font-semibold">{t("email")}</p>
|
||||
<a href="mailto:info@bb-apartment.de" className="text-brand-grey">
|
||||
info@bb-apartment.de
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<Phone className="w-6 h-6 text-brand-green mt-1" />
|
||||
<div>
|
||||
<p className="font-semibold">{t("phone")}</p>
|
||||
<p className="text-brand-grey">+49 (0) 7031 7631132</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<MapPin className="w-6 h-6 text-brand-green mt-1" />
|
||||
<div>
|
||||
<p className="font-semibold">{locale === "de" ? "Adresse" : "Address"}</p>
|
||||
<p className="text-brand-grey">
|
||||
BB-Apartment<br />
|
||||
Böblingen<br />
|
||||
Deutschland
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-8 border border-brand-grey-soft shadow-sm">
|
||||
<h2 className="text-2xl font-bold mb-6">
|
||||
{locale === "de" ? "Nachricht senden" : "Send a message"}
|
||||
</h2>
|
||||
<form className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
|
||||
{t("name")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
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">
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
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">
|
||||
{t("message")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
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>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-brand-green text-white font-semibold px-8 py-4 rounded-full hover:bg-brand-green-dark transition-colors"
|
||||
>
|
||||
{t("send")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center px-4 text-center">
|
||||
<h2 className="text-2xl font-bold text-brand-grey-dark mb-4">
|
||||
Etwas ist schiefgelaufen.
|
||||
</h2>
|
||||
<p className="text-brand-grey mb-8 max-w-md">
|
||||
{error.message ||
|
||||
"Beim Laden der Seite ist ein Fehler aufgetreten. Bitte versuche es erneut."}
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="px-6 py-3 bg-brand-green text-white rounded-xl font-medium hover:bg-brand-green-dark transition-colors"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getHouseBySlug } from "@/src/config/apartments";
|
||||
import { ApartmentCard } from "@/components/ui/ApartmentCard";
|
||||
import { HouseHighlights } from "@/components/ui/HouseHighlights";
|
||||
import { MapPin, Home, Car, Clock, Navigation } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale, houseSlug },
|
||||
}: {
|
||||
params: { locale: string; houseSlug: string };
|
||||
}) {
|
||||
const house = getHouseBySlug(houseSlug);
|
||||
if (!house) return { title: "Not Found" };
|
||||
|
||||
const t = await getTranslations({ locale, namespace: "metadata" });
|
||||
return {
|
||||
title: `${house.nameEn} - ${t("title")}`,
|
||||
description: house.descriptionEn,
|
||||
};
|
||||
}
|
||||
|
||||
export default function HousePage({
|
||||
params: { locale, houseSlug },
|
||||
}: {
|
||||
params: { locale: string; houseSlug: string };
|
||||
}) {
|
||||
const t = useTranslations("houses");
|
||||
const house = getHouseBySlug(houseSlug);
|
||||
|
||||
if (!house) notFound();
|
||||
|
||||
const displayName = locale === "de" ? house.name : house.nameEn;
|
||||
const displayDescription =
|
||||
locale === "de" ? house.description : house.descriptionEn;
|
||||
const displayCountry = locale === "de" ? house.country : house.countryEn;
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="relative h-80 md:h-96 rounded-2xl overflow-hidden mb-12">
|
||||
<Image
|
||||
src={house.images[0] || "/images/placeholder.jpg"}
|
||||
alt={displayName}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 p-8 text-white">
|
||||
<h1 className="text-4xl font-bold mb-2 text-white">{displayName}</h1>
|
||||
<div className="flex items-center gap-2 text-white/90">
|
||||
<MapPin className="w-5 h-5" />
|
||||
<span>
|
||||
{house.address}, {house.zipCode} {house.city}, {displayCountry}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
<div className="lg:col-span-2">
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{t("overview")}
|
||||
</h2>
|
||||
<p className="text-brand-grey leading-relaxed mb-8">
|
||||
{displayDescription}
|
||||
</p>
|
||||
|
||||
<HouseHighlights house={house} locale={locale} />
|
||||
|
||||
<h2 className="text-2xl font-bold mb-6">
|
||||
{t("apartments")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{house.apartments.map((apartment) => (
|
||||
<ApartmentCard
|
||||
key={apartment.id}
|
||||
apartment={apartment}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8 h-fit">
|
||||
<div className="bg-brand-grey-soft rounded-2xl p-8 h-fit">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Home className="w-6 h-6 text-brand-green" />
|
||||
<h3 className="text-xl font-bold">{displayName}</h3>
|
||||
</div>
|
||||
<p className="text-brand-grey text-sm mb-4">
|
||||
{house.apartments.length} {locale === "de" ? "Apartments" : "Apartments"}
|
||||
</p>
|
||||
<div className="text-sm text-brand-grey">
|
||||
<p className="mb-2">
|
||||
<strong>{locale === "de" ? "Adresse:" : "Address:"}</strong> {house.address}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{locale === "de" ? "Stadt:" : "City:"}</strong> {house.zipCode} {house.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-brand-grey-soft shadow-sm p-6 h-fit">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Navigation className="w-5 h-5 text-brand-green" />
|
||||
<h2 className="text-lg font-bold">
|
||||
{locale === "de" ? "Lage & Entfernungen" : "Location & Distances"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
{house.id === "herdweg" ? (
|
||||
<>
|
||||
<a
|
||||
href="https://www.google.com/maps/dir/Herdweg+71,+71032+Böblingen/Panzer+Kaserne+Böblingen"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-brand-green text-white rounded-xl hover:bg-brand-green-dark transition-colors"
|
||||
>
|
||||
<Car className="w-5 h-5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-sm">
|
||||
{locale === "de" ? "Panzer Kaserne – gleiche Straße" : "Panzer Kaserne – Same Street"}
|
||||
</p>
|
||||
<p className="text-sm text-white/85">
|
||||
{locale === "de" ? "Kein Abbiegen nötig" : "Zero turns needed"}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.google.com/maps/dir/Herdweg+71,+71032+Böblingen/Böblingen+Zentrum"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-brand-green/10 rounded-xl hover:bg-brand-green/20 transition-colors"
|
||||
>
|
||||
<Clock className="w-5 h-5 text-brand-green shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-brand-grey-dark text-sm">
|
||||
{locale === "de" ? "Stadtzentrum Böblingen" : "Böblingen City Center"}
|
||||
</p>
|
||||
<p className="text-sm text-brand-grey">5–8 min</p>
|
||||
</div>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a
|
||||
href="https://www.google.com/maps/dir/Schönaicher+Straße+26,+71032+Böblingen/Daimler+Werk+Sindelfingen"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-brand-green/10 rounded-xl hover:bg-brand-green/20 transition-colors"
|
||||
>
|
||||
<Car className="w-5 h-5 text-brand-green shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-brand-grey-dark text-sm">
|
||||
{locale === "de" ? "Daimler Werk Sindelfingen" : "Daimler Plant Sindelfingen"}
|
||||
</p>
|
||||
<p className="text-sm text-brand-grey">10–15 min</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.google.com/maps/dir/Schönaicher+Straße+26,+71032+Böblingen/Böblingen+Zentrum"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-brand-green/10 rounded-xl hover:bg-brand-green/20 transition-colors"
|
||||
>
|
||||
<Clock className="w-5 h-5 text-brand-green shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-brand-grey-dark text-sm">
|
||||
{locale === "de" ? "Stadtzentrum Böblingen" : "Böblingen City Center"}
|
||||
</p>
|
||||
<p className="text-sm text-brand-grey">< 5 min</p>
|
||||
</div>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-48 rounded-xl overflow-hidden border border-brand-grey-soft">
|
||||
<iframe
|
||||
src={`https://maps.google.com/maps?q=${encodeURIComponent(`${house.address}, ${house.zipCode} ${house.city}`)}&z=15&output=embed`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
title={displayName}
|
||||
className="grayscale-[20%] hover:grayscale-0 transition-all duration-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-brand-grey mt-3 text-center">
|
||||
{locale === "de"
|
||||
? "Karte ist interaktiv – klicken Sie auf einen Ort, um die Route zu planen."
|
||||
: "Map is interactive – click a destination to plan the route."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { houses, allApartments, getHouseById, bookingConfig } from "@/src/config/apartments";
|
||||
import { getAvailability, getEffectiveMinStay } from "@/src/lib/beds24";
|
||||
import { calculatePrice } from "@/src/lib/pricing";
|
||||
import { nightsBetween, parseDateOnly } from "@/src/lib/date";
|
||||
import { HouseCard } from "@/components/ui/HouseCard";
|
||||
import { AvailabilitySearchBar } from "@/components/ui/AvailabilitySearchBar";
|
||||
import { AvailableApartmentCard } from "@/components/ui/AvailableApartmentCard";
|
||||
import { NoAvailabilityContact } from "@/components/ui/NoAvailabilityContact";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const t = await getTranslations({ locale, namespace: "home" });
|
||||
return {
|
||||
title: t("houses.title"),
|
||||
description: `${t("houses.title")} – ${t("houses.subtitle")}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HousesPage({
|
||||
params: { locale },
|
||||
searchParams,
|
||||
}: {
|
||||
params: { locale: string };
|
||||
searchParams: { arrival?: string; departure?: string; months?: string };
|
||||
}) {
|
||||
const t = await getTranslations({ locale, namespace: "home" });
|
||||
const tSearch = await getTranslations({ locale, namespace: "houses.search" });
|
||||
|
||||
const { arrival, departure } = searchParams;
|
||||
const hasSearch = Boolean(arrival && departure);
|
||||
|
||||
if (hasSearch && arrival && departure) {
|
||||
const nights = nightsBetween(arrival, departure);
|
||||
|
||||
const results = await Promise.all(
|
||||
allApartments.map(async (apartment) => {
|
||||
const house = getHouseById(apartment.houseId)!;
|
||||
try {
|
||||
const [availability, minStayNights] = await Promise.all([
|
||||
getAvailability({
|
||||
houseId: apartment.houseId,
|
||||
propertyId: apartment.beds24PropertyId,
|
||||
roomId: apartment.beds24RoomId,
|
||||
start: arrival,
|
||||
end: departure,
|
||||
}),
|
||||
getEffectiveMinStay(
|
||||
apartment.houseId,
|
||||
apartment.beds24PropertyId,
|
||||
apartment.beds24RoomId,
|
||||
arrival,
|
||||
bookingConfig.minStayNights
|
||||
),
|
||||
]);
|
||||
const minStayValid = nights > 0 && nights >= minStayNights;
|
||||
const price = calculatePrice(availability.price, nights, availability.currency);
|
||||
return {
|
||||
apartment,
|
||||
house,
|
||||
available: availability.available,
|
||||
minStayValid,
|
||||
pricePerNight: price.basePricePerNight,
|
||||
currency: price.currency,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
apartment,
|
||||
house,
|
||||
available: false,
|
||||
minStayValid: false,
|
||||
pricePerNight: undefined,
|
||||
currency: undefined,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
results.sort((a, b) => {
|
||||
const aOk = a.available && a.minStayValid;
|
||||
const bOk = b.available && b.minStayValid;
|
||||
return aOk === bOk ? 0 : aOk ? -1 : 1;
|
||||
});
|
||||
|
||||
const anyAvailable = results.some((r) => r.available && r.minStayValid);
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold mb-4">{tSearch("resultsTitle")}</h1>
|
||||
<p className="text-brand-grey text-lg">
|
||||
{tSearch("resultsSubtitle")}: {parseDateOnly(arrival).toLocaleDateString(locale === "de" ? "de-DE" : "en-US")} –{" "}
|
||||
{parseDateOnly(departure).toLocaleDateString(locale === "de" ? "de-DE" : "en-US")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AvailabilitySearchBar
|
||||
locale={locale}
|
||||
defaultArrival={arrival}
|
||||
defaultMonths={searchParams.months ? Number(searchParams.months) : undefined}
|
||||
/>
|
||||
|
||||
{!anyAvailable && (
|
||||
<div className="mb-12">
|
||||
<p className="text-center text-brand-grey mb-8">{tSearch("noResults")}</p>
|
||||
<NoAvailabilityContact
|
||||
locale={locale}
|
||||
context={`${locale === "de" ? "Gewünschter Zeitraum" : "Requested dates"}: ${arrival} – ${departure}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{anyAvailable && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 mb-12">
|
||||
{results.map(({ apartment, house, available, minStayValid, pricePerNight, currency }) => (
|
||||
<AvailableApartmentCard
|
||||
key={apartment.id}
|
||||
apartment={apartment}
|
||||
house={house}
|
||||
locale={locale}
|
||||
available={available}
|
||||
minStayValid={minStayValid}
|
||||
pricePerNight={pricePerNight}
|
||||
currency={currency}
|
||||
arrival={arrival}
|
||||
departure={departure}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={`/${locale}/houses`}
|
||||
className="text-brand-green-dark font-medium hover:text-brand-green"
|
||||
>
|
||||
{tSearch("showAllHouses")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold mb-4">{t("houses.title")}</h1>
|
||||
<p className="text-brand-grey text-lg">{t("houses.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<AvailabilitySearchBar locale={locale} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{houses.map((house) => (
|
||||
<HouseCard key={house.id} house={house} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const t = await getTranslations({ locale, namespace: "footer" });
|
||||
return {
|
||||
title: t("imprint"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ImprintPage({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const isDe = locale === "de";
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h1 className="text-4xl font-bold mb-8">
|
||||
{isDe ? "Impressum" : "Imprint"}
|
||||
</h1>
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<p className="text-brand-grey mb-8">
|
||||
{isDe
|
||||
? "Angaben gemäß § 5 TMG"
|
||||
: "Information according to § 5 TMG"}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{isDe ? "Angaben zum Anbieter" : "Provider Information"}
|
||||
</h2>
|
||||
<p className="text-brand-grey mb-6">
|
||||
<strong>
|
||||
{isDe ? "Grundstücksgemeinschaft Allami / Allawy" : "Allami / Allawy Property Community"}
|
||||
</strong>
|
||||
<br />
|
||||
Forchenweg 1
|
||||
<br />
|
||||
71063 Sindelfingen
|
||||
<br />
|
||||
{isDe ? "Deutschland" : "Germany"}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{isDe ? "Vertreten durch" : "Represented by"}
|
||||
</h2>
|
||||
<p className="text-brand-grey mb-6">
|
||||
Ali Allawy
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{isDe ? "Kontakt" : "Contact"}
|
||||
</h2>
|
||||
<p className="text-brand-grey mb-6">
|
||||
E-Mail: info@bb-apartment.de
|
||||
<br />
|
||||
{isDe ? "Telefon:" : "Phone:"} 07031 7631132
|
||||
<br />
|
||||
{isDe ? "Web:" : "Web:"} www.BB-Apartment.de
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{isDe ? "Verantwortlich für den Inhalt" : "Responsible for Content"}
|
||||
</h2>
|
||||
<p className="text-brand-grey mb-6">
|
||||
Ali Allawy
|
||||
<br />
|
||||
Forchenweg 1
|
||||
<br />
|
||||
71063 Sindelfingen
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{isDe ? "Hinweis" : "Note"}
|
||||
</h2>
|
||||
<p className="text-brand-grey mb-6">
|
||||
{isDe
|
||||
? "EU-Streitschlichtung: Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit. Unsere E-Mail-Adresse finden Sie oben im Impressum. Wir sind nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen."
|
||||
: "EU Dispute Resolution: The European Commission provides a platform for online dispute resolution (ODR). Our email address can be found above in the imprint. We are not obligated to participate in dispute resolution proceedings before a consumer arbitration board."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ReactNode } from "react";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, getTranslations } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { locales, Locale } from "@/i18n";
|
||||
import "../globals.css";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import localFont from "next/font/local";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const t = await getTranslations({ locale, namespace: "metadata" });
|
||||
|
||||
return {
|
||||
metadataBase: new URL("https://bb-apartment.de"),
|
||||
title: {
|
||||
template: `%s | ${t("title")}`,
|
||||
default: t("title"),
|
||||
},
|
||||
description: t("description"),
|
||||
alternates: {
|
||||
canonical: `/${locale}`,
|
||||
languages: {
|
||||
"de-DE": "/de",
|
||||
"en-US": "/en",
|
||||
"x-default": "/de",
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: locale === "de" ? "de_DE" : "en_US",
|
||||
url: `https://bb-apartment.de/${locale}`,
|
||||
siteName: "BB-Apartment",
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "../fonts/GeistVF.woff",
|
||||
variable: "--font-geist-sans",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params: { locale },
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: { locale: string };
|
||||
}) {
|
||||
if (!locales.includes(locale as Locale)) notFound();
|
||||
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang={locale} className={geistSans.variable}>
|
||||
<body className="antialiased min-h-screen flex flex-col bg-white text-brand-grey-dark">
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
<Header locale={locale} />
|
||||
<main className="flex-grow pt-20">{children}</main>
|
||||
<Footer />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { houses } from "@/src/config/apartments";
|
||||
import { Hero } from "@/components/ui/Hero";
|
||||
import { HouseCard } from "@/components/ui/HouseCard";
|
||||
import { BookingBanner } from "@/components/ui/BookingBanner";
|
||||
import { Impressions } from "@/components/ui/Impressions";
|
||||
import { ArrowRight, MapPin, Shield, Clock, Wind, Home, CreditCard } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const t = await getTranslations({ locale, namespace: "metadata" });
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function HomePage({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const t = useTranslations("home");
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Clock,
|
||||
text: t("features.longStay"),
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
text: t("features.location"),
|
||||
},
|
||||
{
|
||||
icon: Wind,
|
||||
text: t("features.airConditioning"),
|
||||
},
|
||||
{
|
||||
icon: Home,
|
||||
text: t("features.furnished"),
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
text: t("features.payment"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Hero
|
||||
title={t("hero.title")}
|
||||
subtitle={t("hero.subtitle")}
|
||||
description={t("hero.description")}
|
||||
cta={t("hero.cta")}
|
||||
ctaHref={`/${locale}/houses`}
|
||||
/>
|
||||
|
||||
<BookingBanner locale={locale} />
|
||||
|
||||
<section className="py-20 bg-brand-grey-soft/50">
|
||||
<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">{t("houses.subtitle")}</p>
|
||||
<h2 className="text-4xl font-bold mb-4 text-brand-grey-dark">{t("houses.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-2 gap-10">
|
||||
{houses.map((house) => (
|
||||
<HouseCard key={house.id} house={house} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Impressions
|
||||
title={t("impressions.title")}
|
||||
subtitle={t("impressions.subtitle")}
|
||||
/>
|
||||
|
||||
<section className="py-20">
|
||||
<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">{t("features.title")}</p>
|
||||
<h2 className="text-4xl font-bold mb-4 text-brand-grey-dark">{t("features.title")}</h2>
|
||||
<div className="w-20 h-1 bg-brand-green mx-auto rounded-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||
{features.map((feature, index) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="group p-6 md:p-8 bg-white rounded-2xl shadow-md border-2 border-brand-green/20 hover:bg-brand-green hover:border-brand-green hover:-translate-y-2 transition-all duration-300 flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="p-4 bg-brand-green/10 rounded-2xl mb-5 group-hover:bg-white/20 transition-colors">
|
||||
<Icon className="w-8 h-8 text-brand-green group-hover:text-white transition-colors" />
|
||||
</div>
|
||||
<p className="text-brand-grey-dark font-bold text-lg leading-snug group-hover:text-white transition-colors">{feature.text}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="relative py-24 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-brand-green" />
|
||||
<div className="absolute inset-0 bg-[url('/images/cta-bg.jpg')] opacity-10 bg-cover bg-center" />
|
||||
<div className="relative z-10 max-w-4xl mx-auto px-4 text-center">
|
||||
<Shield className="w-12 h-12 text-white/80 mx-auto mb-6" />
|
||||
<h2 className="text-4xl font-bold mb-6 text-white">
|
||||
{t("ctaSection.title")}
|
||||
</h2>
|
||||
<p className="text-xl mb-10 text-white/90 max-w-2xl mx-auto">
|
||||
{t("ctaSection.description")}
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}/houses`}
|
||||
className="inline-flex items-center gap-2 bg-white text-brand-green-dark font-semibold px-8 py-4 rounded-2xl hover:bg-brand-grey-soft hover:shadow-lg hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
{t("ctaSection.cta")}
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const t = await getTranslations({ locale, namespace: "footer" });
|
||||
return {
|
||||
title: t("privacy"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function PrivacyPage({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const isDe = locale === "de";
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h1 className="text-4xl font-bold mb-8">
|
||||
{isDe ? "Datenschutzerklärung" : "Privacy Policy"}
|
||||
</h1>
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<p className="text-brand-grey mb-8">
|
||||
{isDe
|
||||
? "Der Schutz Ihrer personenbezogenen Daten ist uns wichtig. Wir verarbeiten Ihre Daten nur, wenn dies technisch oder zur Vertragsabwicklung erforderlich ist."
|
||||
: "The protection of your personal data is important to us. We only process your data when this is technically necessary or required for contract processing."}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{isDe ? "Verantwortlicher" : "Controller"}
|
||||
</h2>
|
||||
<p className="text-brand-grey mb-6">
|
||||
<strong>
|
||||
{isDe ? "Grundstücksgemeinschaft Allami / Allawy" : "Allami / Allawy Property Community"}
|
||||
</strong>
|
||||
<br />
|
||||
Forchenweg 1
|
||||
<br />
|
||||
71063 Sindelfingen
|
||||
<br />
|
||||
{isDe ? "Deutschland" : "Germany"}
|
||||
<br />
|
||||
<br />
|
||||
{isDe ? "Vertreten durch:" : "Represented by:"} Ali Allawy
|
||||
<br />
|
||||
E-Mail: info@bb-apartment.de
|
||||
<br />
|
||||
{isDe ? "Telefon:" : "Phone:"} 07031 7631132
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{isDe ? "Keine Cookies & kein Tracking" : "No Cookies & No Tracking"}
|
||||
</h2>
|
||||
<p className="text-brand-grey mb-6">
|
||||
{isDe
|
||||
? "Diese Website verwendet keine Cookies, Tracking-Tools, Analyse-Dienste oder Werbe-Technologien. Wir speichern keine personenbezogenen Daten zu Marketing- oder Profiling-Zwecken."
|
||||
: "This website does not use cookies, tracking tools, analytics services or advertising technologies. We do not store personal data for marketing or profiling purposes."}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{isDe ? "Server-Logfiles" : "Server Log Files"}
|
||||
</h2>
|
||||
<p className="text-brand-grey mb-6">
|
||||
{isDe
|
||||
? "Unser Hosting-Anbieter erhebt automatisch technische Daten (z. B. Browsertyp, IP-Adresse, Uhrzeit der Anfrage), die für die technische Bereitstellung und Sicherheit der Website erforderlich sind. Diese Daten werden automatisch gelöscht und nicht mit anderen Datenquellen zusammengeführt."
|
||||
: "Our hosting provider automatically collects technical data (e.g. browser type, IP address, time of request) required for the technical provision and security of the website. This data is automatically deleted and not merged with other data sources."}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{isDe ? "Kontaktanfragen" : "Contact Inquiries"}
|
||||
</h2>
|
||||
<p className="text-brand-grey mb-6">
|
||||
{isDe
|
||||
? "Wenn Sie uns per E-Mail oder Kontaktformular kontaktieren, speichern wir die von Ihnen freiwillig übermittelten Daten ausschließlich zur Bearbeitung Ihrer Anfrage. Eine Weitergabe an Dritte erfolgt nicht."
|
||||
: "If you contact us by email or contact form, we store the data you voluntarily provide solely for the purpose of processing your inquiry. This data is not passed on to third parties."}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{isDe ? "Ihre Rechte" : "Your Rights"}
|
||||
</h2>
|
||||
<p className="text-brand-grey mb-6">
|
||||
{isDe
|
||||
? "Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung sowie Widerspruch gegen die Verarbeitung Ihrer personenbezogenen Daten. Bitte wenden Sie sich dazu an info@bb-apartment.de."
|
||||
: "You have the right to access, rectification, erasure, restriction of processing and objection to the processing of your personal data. Please contact info@bb-apartment.de for this."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAvailability, getEffectiveMinStay } from "@/src/lib/beds24";
|
||||
import { calculatePrice } from "@/src/lib/pricing";
|
||||
import { getApartmentBySlug, bookingConfig } from "@/src/config/apartments";
|
||||
import { nightsBetween, addDays } from "@/src/lib/date";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const apartmentSlug = searchParams.get("apartmentSlug");
|
||||
const start = searchParams.get("start");
|
||||
const end = searchParams.get("end");
|
||||
|
||||
if (!apartmentSlug || !start || !end) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required parameters: apartmentSlug, start, end" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const apartment = getApartmentBySlug(apartmentSlug);
|
||||
if (!apartment) {
|
||||
return NextResponse.json(
|
||||
{ error: "Apartment not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const nights = nightsBetween(start, end);
|
||||
|
||||
if (nights <= 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "End date must be after start date" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const minStayNights = await getEffectiveMinStay(
|
||||
apartment.houseId,
|
||||
apartment.beds24PropertyId,
|
||||
apartment.beds24RoomId,
|
||||
start,
|
||||
bookingConfig.minStayNights
|
||||
);
|
||||
const minStayValid = nights >= minStayNights;
|
||||
|
||||
const availability = await getAvailability({
|
||||
houseId: apartment.houseId,
|
||||
propertyId: apartment.beds24PropertyId,
|
||||
roomId: apartment.beds24RoomId,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
// Fetch Beds24's own short-stay rate for the same arrival date, purely to
|
||||
// display a genuine "savings vs. short-term rate" figure — this never
|
||||
// changes the actual price charged (see calculatePrice()).
|
||||
let referenceNightlyRate: number | undefined;
|
||||
if (availability.available && nights > bookingConfig.referenceStayNights) {
|
||||
try {
|
||||
const reference = await getAvailability({
|
||||
houseId: apartment.houseId,
|
||||
propertyId: apartment.beds24PropertyId,
|
||||
roomId: apartment.beds24RoomId,
|
||||
start,
|
||||
end: addDays(start, bookingConfig.referenceStayNights),
|
||||
});
|
||||
if (reference.available) {
|
||||
referenceNightlyRate = reference.price;
|
||||
}
|
||||
} catch (referenceError) {
|
||||
console.error("Reference price lookup failed:", referenceError);
|
||||
}
|
||||
}
|
||||
|
||||
const price = calculatePrice(
|
||||
availability.price,
|
||||
nights,
|
||||
availability.currency,
|
||||
referenceNightlyRate
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
apartmentSlug,
|
||||
start,
|
||||
end,
|
||||
nights,
|
||||
available: availability.available,
|
||||
minStayValid,
|
||||
minStayNights,
|
||||
maxGuests: apartment.maxGuests,
|
||||
price,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Availability API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch availability" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getStripe, getStripeAccountId } from "@/src/lib/stripe";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getApartmentBySlug, bookingConfig } from "@/src/config/apartments";
|
||||
import { getAvailability, getEffectiveMinStay } from "@/src/lib/beds24";
|
||||
import { calculatePrice } from "@/src/lib/pricing";
|
||||
import { nightsBetween } from "@/src/lib/date";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
apartmentSlug,
|
||||
start,
|
||||
end,
|
||||
guestEmail,
|
||||
guestName,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
} = body;
|
||||
|
||||
if (!apartmentSlug || !start || !end || !guestEmail || !guestName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required booking parameters" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const apartment = getApartmentBySlug(apartmentSlug);
|
||||
if (!apartment) {
|
||||
return NextResponse.json(
|
||||
{ error: "Apartment not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
const nights = nightsBetween(start, end);
|
||||
|
||||
const minStayNights = await getEffectiveMinStay(
|
||||
apartment.houseId,
|
||||
apartment.beds24PropertyId,
|
||||
apartment.beds24RoomId,
|
||||
start,
|
||||
bookingConfig.minStayNights
|
||||
);
|
||||
|
||||
if (nights < minStayNights) {
|
||||
return NextResponse.json(
|
||||
{ error: `Minimum stay is ${minStayNights} nights` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const availability = await getAvailability({
|
||||
houseId: apartment.houseId,
|
||||
propertyId: apartment.beds24PropertyId,
|
||||
roomId: apartment.beds24RoomId,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
if (!availability.available) {
|
||||
return NextResponse.json(
|
||||
{ error: "Apartment not available for selected dates" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const price = calculatePrice(availability.price, nights, availability.currency);
|
||||
const depositAmount = Math.round(price.depositAmount * 100); // cents
|
||||
|
||||
const stripe = getStripe(apartment.houseId);
|
||||
const accountId = getStripeAccountId(apartment.houseId);
|
||||
|
||||
// Create or update Stripe customer
|
||||
let customer;
|
||||
const existingCustomers = await stripe.customers.list({
|
||||
email: guestEmail,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (existingCustomers.data.length > 0) {
|
||||
customer = existingCustomers.data[0];
|
||||
} else {
|
||||
customer = await stripe.customers.create({
|
||||
email: guestEmail,
|
||||
name: guestName,
|
||||
});
|
||||
}
|
||||
|
||||
const bookingReference = `BB-${Date.now()}-${Math.random().toString(36).substring(2, 7).toUpperCase()}`;
|
||||
|
||||
// Create pending booking in database
|
||||
await prisma.booking.create({
|
||||
data: {
|
||||
bookingReference,
|
||||
apartmentSlug,
|
||||
houseId: apartment.houseId,
|
||||
beds24PropertyId: apartment.beds24PropertyId,
|
||||
beds24RoomId: apartment.beds24RoomId,
|
||||
guestEmail,
|
||||
guestName,
|
||||
checkIn: startDate,
|
||||
checkOut: endDate,
|
||||
nights,
|
||||
totalAmount: price.totalAfterDiscount,
|
||||
depositAmount: price.depositAmount,
|
||||
currency: price.currency,
|
||||
status: "pending",
|
||||
stripeAccountId: accountId,
|
||||
stripeCustomerId: customer.id,
|
||||
},
|
||||
});
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customer.id,
|
||||
payment_method_types: ["card"],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: price.currency.toLowerCase(),
|
||||
product_data: {
|
||||
name: `Anzahlung Apartment ${apartment.name}`,
|
||||
description: `Aufenthalt: ${start} bis ${end} (${nights} Nächte)`,
|
||||
},
|
||||
unit_amount: depositAmount,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: "payment",
|
||||
success_url: successUrl || `${process.env.NEXT_PUBLIC_BASE_URL}/de/booking-success?reference=${bookingReference}`,
|
||||
cancel_url: cancelUrl || `${process.env.NEXT_PUBLIC_BASE_URL}/de/apartments/${apartmentSlug}`,
|
||||
metadata: {
|
||||
bookingReference,
|
||||
apartmentSlug,
|
||||
houseId: apartment.houseId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.booking.update({
|
||||
where: { bookingReference },
|
||||
data: { stripeCheckoutSessionId: session.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
sessionId: session.id,
|
||||
bookingReference,
|
||||
url: session.url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Checkout session error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create checkout session" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sendContactMessage } from "@/src/lib/mail";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { name, email, message, context } = await request.json();
|
||||
|
||||
if (!email || !message) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: email, message" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await sendContactMessage({ name, email, message, context });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Contact API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to send message" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getEffectiveMinStay } from "@/src/lib/beds24";
|
||||
import { getApartmentBySlug, bookingConfig } from "@/src/config/apartments";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const apartmentSlug = searchParams.get("apartmentSlug");
|
||||
const arrival = searchParams.get("arrival");
|
||||
|
||||
if (!apartmentSlug || !arrival) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required parameters: apartmentSlug, arrival" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const apartment = getApartmentBySlug(apartmentSlug);
|
||||
if (!apartment) {
|
||||
return NextResponse.json(
|
||||
{ error: "Apartment not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const minStayNights = await getEffectiveMinStay(
|
||||
apartment.houseId,
|
||||
apartment.beds24PropertyId,
|
||||
apartment.beds24RoomId,
|
||||
arrival,
|
||||
bookingConfig.minStayNights
|
||||
);
|
||||
|
||||
return NextResponse.json({ apartmentSlug, arrival, minStayNights });
|
||||
} catch (error) {
|
||||
console.error("Min-stay API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to determine minimum stay" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getStripe } from "@/src/lib/stripe";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { bookingReference, email } = await request.json();
|
||||
|
||||
if (!bookingReference || !email) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing bookingReference or email" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { bookingReference },
|
||||
});
|
||||
|
||||
if (!booking || booking.guestEmail !== email || !booking.stripeCustomerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Booking not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripe(booking.houseId);
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: booking.stripeCustomerId,
|
||||
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/de`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (error) {
|
||||
console.error("Portal session error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create portal session" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRoomInfo } from "@/src/lib/beds24";
|
||||
import { allApartments } from "@/src/config/apartments";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const rooms = await Promise.all(
|
||||
allApartments.map(async (apartment) => {
|
||||
const info = await getRoomInfo(
|
||||
apartment.houseId,
|
||||
apartment.beds24PropertyId,
|
||||
apartment.beds24RoomId
|
||||
);
|
||||
return {
|
||||
slug: apartment.slug,
|
||||
beds24RoomId: apartment.beds24RoomId,
|
||||
beds24PropertyId: apartment.beds24PropertyId,
|
||||
name: apartment.name,
|
||||
nameEn: apartment.nameEn,
|
||||
beds24Name: info.name,
|
||||
maxGuests: info.maxGuests,
|
||||
minStay: info.minStay,
|
||||
currency: info.currency,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ rooms });
|
||||
} catch (error) {
|
||||
console.error("Rooms API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch rooms" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import Stripe from "stripe";
|
||||
import { getStripe, getStripeWebhookSecret } from "@/src/lib/stripe";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const payload = await request.text();
|
||||
const headerPayload = await headers();
|
||||
const signature = headerPayload.get("stripe-signature") || "";
|
||||
|
||||
// Determine which Stripe account this webhook belongs to
|
||||
// Stripe sends the account ID in the Stripe-Account header for Connect
|
||||
const accountId = headerPayload.get("stripe-account") || "";
|
||||
let houseId: string;
|
||||
|
||||
if (process.env.STRIPE_ACCOUNT_HERDWEWEG && accountId === process.env.STRIPE_ACCOUNT_HERDWEWEG) {
|
||||
houseId = "herdweg";
|
||||
} else if (process.env.STRIPE_ACCOUNT_SCHOENAICHER && accountId === process.env.STRIPE_ACCOUNT_SCHOENAICHER) {
|
||||
houseId = "schoenaicher";
|
||||
} else {
|
||||
// If no account header, try to infer from event metadata (not available in signature verification)
|
||||
// For now, we'll try both secrets. In production, use separate webhook endpoints or account headers.
|
||||
return NextResponse.json(
|
||||
{ error: "Could not determine Stripe account" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = getStripe(houseId);
|
||||
const webhookSecret = getStripeWebhookSecret(houseId);
|
||||
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
|
||||
// Deduplicate events
|
||||
const existingEvent = await prisma.stripeEvent.findUnique({
|
||||
where: { eventId: event.id },
|
||||
});
|
||||
if (existingEvent) {
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
|
||||
await prisma.stripeEvent.create({
|
||||
data: {
|
||||
eventId: event.id,
|
||||
accountId,
|
||||
type: event.type,
|
||||
payload: JSON.stringify(event),
|
||||
},
|
||||
});
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const bookingReference = session.metadata?.bookingReference;
|
||||
|
||||
if (bookingReference) {
|
||||
await prisma.booking.update({
|
||||
where: { bookingReference },
|
||||
data: {
|
||||
status: "deposit_paid",
|
||||
stripePaymentIntentId: session.payment_intent as string,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Trigger n8n workflow to create booking in Beds24
|
||||
console.log(`Deposit paid for booking ${bookingReference}`);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
} catch (error) {
|
||||
console.error("Stripe webhook error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Webhook verification failed" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<html>
|
||||
<body className="min-h-screen flex flex-col items-center justify-center px-4 text-center bg-white text-brand-grey-dark">
|
||||
<h2 className="text-2xl font-bold mb-4">Ein unerwarteter Fehler ist aufgetreten.</h2>
|
||||
<p className="text-brand-grey mb-8">{error.message}</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="px-6 py-3 bg-brand-green text-white rounded-xl font-medium hover:bg-brand-green-dark transition-colors"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<html>
|
||||
<body className="min-h-screen flex flex-col items-center justify-center px-4 text-center bg-white text-brand-grey-dark">
|
||||
<h2 className="text-2xl font-bold mb-4">Ein kritischer Fehler ist aufgetreten.</h2>
|
||||
<p className="text-brand-grey mb-8">{error.message}</p>
|
||||
<button
|
||||
onClick={() => reset()}
|
||||
className="px-6 py-3 bg-brand-green text-white rounded-xl font-medium hover:bg-brand-green-dark transition-colors"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+13
-9
@@ -4,20 +4,15 @@
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
--foreground: #2c2c2c;
|
||||
--brand-green: #7cb342;
|
||||
--brand-green-dark: #558b2f;
|
||||
--brand-grey: #4a4a4a;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@@ -25,3 +20,12 @@ body {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply text-brand-grey-dark font-semibold;
|
||||
}
|
||||
a {
|
||||
@apply text-brand-green-dark hover:text-brand-green transition-colors;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-33
@@ -1,35 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
variable: "--font-geist-sans",
|
||||
weight: "100 900",
|
||||
});
|
||||
const geistMono = localFont({
|
||||
src: "./fonts/GeistMonoVF.woff",
|
||||
variable: "--font-geist-mono",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="https://nextjs.org/icons/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
||||
app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="https://nextjs.org/icons/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="https://nextjs.org/icons/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="https://nextjs.org/icons/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="https://nextjs.org/icons/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
sitemap: "https://bb-apartment.de/sitemap.xml",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { houses } from "@/src/config/apartments";
|
||||
|
||||
const baseUrl = "https://bb-apartment.de";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const locales = ["de", "en"];
|
||||
|
||||
const staticRoutes: MetadataRoute.Sitemap = [
|
||||
{ url: baseUrl, lastModified: new Date() },
|
||||
...locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}`,
|
||||
lastModified: new Date(),
|
||||
})),
|
||||
...locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}/houses`,
|
||||
lastModified: new Date(),
|
||||
})),
|
||||
...locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}/contact`,
|
||||
lastModified: new Date(),
|
||||
})),
|
||||
...locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}/imprint`,
|
||||
lastModified: new Date(),
|
||||
})),
|
||||
...locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}/privacy`,
|
||||
lastModified: new Date(),
|
||||
})),
|
||||
];
|
||||
|
||||
const houseRoutes: MetadataRoute.Sitemap = houses.flatMap((house) =>
|
||||
locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}/houses/${house.slug}`,
|
||||
lastModified: new Date(),
|
||||
}))
|
||||
);
|
||||
|
||||
const apartmentRoutes: MetadataRoute.Sitemap = houses.flatMap((house) =>
|
||||
house.apartments.flatMap((apartment) =>
|
||||
locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}/apartments/${apartment.slug}`,
|
||||
lastModified: new Date(),
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
return [...staticRoutes, ...houseRoutes, ...apartmentRoutes];
|
||||
}
|
||||
Reference in New Issue
Block a user