Files
MarkusandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2b24334bfa 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>
2026-08-19 22:54:49 +02:00

393 lines
13 KiB
TypeScript

"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>
);
}