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>
393 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|