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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user