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:
Markus
2026-08-19 22:54:49 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 232770afe7
commit 2b24334bfa
153 changed files with 5621 additions and 176 deletions
+307
View File
@@ -0,0 +1,307 @@
export interface Apartment {
id: string;
slug: string;
name: string;
nameEn: string;
beds24RoomId: string;
beds24PropertyId: string;
houseId: string;
floor: string;
floorEn: string;
maxGuests: number;
sizeSqm?: number;
bedrooms?: number;
bathrooms?: number;
description?: string;
descriptionEn?: string;
amenities: string[];
amenitiesEn: string[];
tourUrl?: string;
tourProvider?: string;
images: string[];
}
export interface House {
id: string;
slug: string;
name: string;
nameEn: string;
address: string;
city: string;
zipCode: string;
country: string;
countryEn: string;
description: string;
descriptionEn: string;
stripeAccountId: string;
beds24PropertyId: string;
images: string[];
apartments: Apartment[];
}
export const houses: House[] = [
{
id: "herdweg",
slug: "bb-apartment-herdweg",
name: "BB-Apartment Herdweg",
nameEn: "BB-Apartment Herdweg",
address: "Herdweg 71",
city: "Böblingen",
zipCode: "71032",
country: "Deutschland",
countryEn: "Germany",
description:
"Willkommen in Ihrer ruhigen Premium-Home-Base in Böblingen. Das Haus wurde für längere Aufenthalte konzipiert und bietet vier vollmöblierte Apartments mit allem, was Sie für einen sofortigen Einzug benötigen. Ideal für Personal der USAG Stuttgart, das Komfort und einen völlig stressfreien Alltag sucht. Alle Apartments liegen in der Nähe einer Bushaltestelle.",
descriptionEn:
"Welcome to your quiet, premium home base in Böblingen. Designed for extended stays, this house offers four fully furnished apartments with everything you need to settle in immediately. Ideal for USAG Stuttgart personnel looking for comfort and a completely stress-free daily routine. All apartments are close to a bus stop.",
stripeAccountId: process.env.STRIPE_ACCOUNT_HERDWEWEG || "",
beds24PropertyId: "121184",
images: [
"/images/houses/herdweg/exterior/Main.png",
"/images/houses/herdweg/exterior/MKE_1955.JPG",
"/images/houses/herdweg/impressions/MKE_2027.jpg",
"/images/houses/herdweg/impressions/MKE_2030.jpg",
"/images/houses/herdweg/impressions/MKE_2034.JPG",
"/images/houses/herdweg/impressions/MKE_2037.jpg",
],
apartments: [
{
id: "herdweg-ug",
slug: "herdweg-ug",
name: "Herdweg UG",
nameEn: "Herdweg Ground Floor",
beds24RoomId: "272541",
beds24PropertyId: "121184",
houseId: "herdweg",
floor: "Untergeschoss",
floorEn: "Ground Floor",
maxGuests: 2,
amenities: ["Küche", "Bad", "WLAN", "TV", "Waschmaschine", "Vollmöbliert"],
amenitiesEn: ["Kitchen", "Bathroom", "WiFi", "TV", "Washing machine", "Fully furnished"],
images: [
"/images/houses/herdweg/apartments/ug/20210501_120501_001.jpg",
"/images/houses/herdweg/apartments/ug/20210501_120659.jpg",
"/images/houses/herdweg/apartments/ug/20210501_120720.jpg",
"/images/houses/herdweg/apartments/ug/20210501_120732.jpg",
"/images/houses/herdweg/apartments/ug/20210501_120740.jpg",
"/images/houses/herdweg/apartments/ug/20210501_120759.jpg",
"/images/houses/herdweg/apartments/ug/IMG-20201022-WA0012.jpg",
"/images/houses/herdweg/apartments/ug/IMG-20201022-WA0014.jpg",
"/images/houses/herdweg/apartments/ug/IMG-20201022-WA0015.jpg",
"/images/houses/herdweg/apartments/ug/IMG-20201022-WA0016.jpg",
"/images/houses/herdweg/apartments/ug/MKE_2572.JPG",
"/images/houses/herdweg/apartments/ug/MKE_2576.JPG",
"/images/houses/herdweg/apartments/ug/MKE_2577.JPG",
],
},
{
id: "herdweg-eg",
slug: "herdweg-eg",
name: "Herdweg EG",
nameEn: "Herdweg 1st Floor",
beds24RoomId: "278806",
beds24PropertyId: "121184",
houseId: "herdweg",
floor: "Erdgeschoss",
floorEn: "1st Floor",
maxGuests: 2,
amenities: ["Küche", "Bad", "WLAN", "TV", "Waschmaschine", "Klimaanlage", "Vollmöbliert"],
amenitiesEn: ["Kitchen", "Bathroom", "WiFi", "TV", "Washing machine", "Air conditioning", "Fully furnished"],
images: [
"/images/houses/herdweg/apartments/eg/MKE_1958.jpg",
"/images/houses/herdweg/apartments/eg/MKE_1960.JPG",
"/images/houses/herdweg/apartments/eg/MKE_1962.JPG",
"/images/houses/herdweg/apartments/eg/MKE_1965.jpg",
"/images/houses/herdweg/apartments/eg/MKE_1967.JPG",
"/images/houses/herdweg/apartments/eg/MKE_1974.jpg",
"/images/houses/herdweg/apartments/eg/MKE_1990.JPG",
],
},
{
id: "herdweg-1og",
slug: "herdweg-1og",
name: "Herdweg 1.OG",
nameEn: "Herdweg 2nd Floor",
beds24RoomId: "284177",
beds24PropertyId: "121184",
houseId: "herdweg",
floor: "1. Obergeschoss",
floorEn: "2nd Floor",
maxGuests: 2,
amenities: ["Küche", "Bad", "WLAN", "TV", "Waschmaschine", "Klimaanlage", "Vollmöbliert"],
amenitiesEn: ["Kitchen", "Bathroom", "WiFi", "TV", "Washing machine", "Air conditioning", "Fully furnished"],
images: [
"/images/houses/herdweg/apartments/1og/MKE_1958.jpg",
"/images/houses/herdweg/apartments/1og/MKE_1960.JPG",
"/images/houses/herdweg/apartments/1og/MKE_1962.JPG",
"/images/houses/herdweg/apartments/1og/MKE_1965.jpg",
"/images/houses/herdweg/apartments/1og/MKE_1967.JPG",
"/images/houses/herdweg/apartments/1og/MKE_1974.jpg",
"/images/houses/herdweg/apartments/1og/MKE_1990.JPG",
],
},
{
id: "herdweg-dg",
slug: "herdweg-dg",
name: "Herdweg DG",
nameEn: "Herdweg Top Floor",
beds24RoomId: "284178",
beds24PropertyId: "121184",
houseId: "herdweg",
floor: "Dachgeschoss",
floorEn: "Top Floor",
maxGuests: 2,
amenities: ["Küche", "Bad", "WLAN", "TV", "Waschmaschine", "Klimaanlage", "Vollmöbliert"],
amenitiesEn: ["Kitchen", "Bathroom", "WiFi", "TV", "Washing machine", "Air conditioning", "Fully furnished"],
images: [
"/images/houses/herdweg/apartments/dg/MKE_1994.jpg",
"/images/houses/herdweg/apartments/dg/MKE_1995.JPG",
"/images/houses/herdweg/apartments/dg/MKE_1996.JPG",
"/images/houses/herdweg/apartments/dg/MKE_1997.jpg",
"/images/houses/herdweg/apartments/dg/MKE_1998.jpg",
"/images/houses/herdweg/apartments/dg/MKE_1999.JPG",
"/images/houses/herdweg/apartments/dg/MKE_2000.jpg",
"/images/houses/herdweg/apartments/dg/MKE_2001.jpg",
"/images/houses/herdweg/apartments/dg/MKE_2002.jpg",
"/images/houses/herdweg/apartments/dg/MKE_2003.JPG",
"/images/houses/herdweg/apartments/dg/MKE_2004.jpg",
"/images/houses/herdweg/apartments/dg/MKE_2011.jpg",
"/images/houses/herdweg/apartments/dg/MKE_2013.JPG",
"/images/houses/herdweg/apartments/dg/MKE_2015.JPG",
],
},
],
},
{
id: "schoenaicher",
slug: "bb-apartment-schoenaicher-strasse",
name: "BB-Apartment Schönaicher Straße",
nameEn: "BB-Apartment Schönaicher Straße",
address: "Schönaicher Straße 26",
city: "Böblingen",
zipCode: "71032",
country: "Deutschland",
countryEn: "Germany",
description:
"Zentrales Haus mit vier charmanten Apartments für Geschäftsreisende und längere Aufenthalte. Nahe dem Stadtzentrum, das Daimler Werk in Sindelfingen ist leicht zu erreichen. Alle Apartments liegen in der Nähe einer Bushaltestelle.",
descriptionEn:
"Central house with four charming apartments for business travelers and longer stays. Close to the city center, the Daimler plant in Sindelfingen is easily reachable. All apartments are close to a bus stop.",
stripeAccountId: process.env.STRIPE_ACCOUNT_SCHOENAICHER || "",
beds24PropertyId: "36159",
images: ["/images/houses/schoenaicher/exterior/Main.jpg"],
apartments: [
{
id: "schoenaicher-dg-links",
slug: "schoenaicher-dg-links",
name: "DG links",
nameEn: "Top Floor Left",
beds24RoomId: "83525",
beds24PropertyId: "36159",
houseId: "schoenaicher",
floor: "Dachgeschoss links",
floorEn: "Top Floor Left",
maxGuests: 2,
amenities: ["Küche", "Bad", "WLAN", "TV", "Waschmaschine", "Klimaanlage", "Vollmöbliert"],
amenitiesEn: ["Kitchen", "Bathroom", "WiFi", "TV", "Washing machine", "Air conditioning", "Fully furnished"],
images: [
"/images/houses/schoenaicher/apartments/dg-links/01.png",
"/images/houses/schoenaicher/apartments/dg-links/11.png",
"/images/houses/schoenaicher/apartments/dg-links/12.png",
"/images/houses/schoenaicher/apartments/dg-links/13.png",
"/images/houses/schoenaicher/apartments/dg-links/30.png",
],
},
{
id: "schoenaicher-dg-rechts",
slug: "schoenaicher-dg-rechts",
name: "DG rechts",
nameEn: "Top Floor Right",
beds24RoomId: "83524",
beds24PropertyId: "36159",
houseId: "schoenaicher",
floor: "Dachgeschoss rechts",
floorEn: "Top Floor Right",
maxGuests: 2,
amenities: ["Küche", "Bad", "WLAN", "TV", "Waschmaschine", "Klimaanlage", "Vollmöbliert"],
amenitiesEn: ["Kitchen", "Bathroom", "WiFi", "TV", "Washing machine", "Air conditioning", "Fully furnished"],
images: [
"/images/houses/schoenaicher/apartments/dg-rechts/MKE_0081.JPG",
"/images/houses/schoenaicher/apartments/dg-rechts/MKE_0085.JPG",
"/images/houses/schoenaicher/apartments/dg-rechts/MKE_0088.JPG",
"/images/houses/schoenaicher/apartments/dg-rechts/MKE_0089.JPG",
"/images/houses/schoenaicher/apartments/dg-rechts/MKE_0092.JPG",
"/images/houses/schoenaicher/apartments/dg-rechts/MKE_0095.JPG",
"/images/houses/schoenaicher/apartments/dg-rechts/MKE_0096.JPG",
],
},
{
id: "schoenaicher-og-links",
slug: "schoenaicher-og-links",
name: "OG links",
nameEn: "Upper Floor Left",
beds24RoomId: "83660",
beds24PropertyId: "36159",
houseId: "schoenaicher",
floor: "Obergeschoss links",
floorEn: "Upper Floor Left",
maxGuests: 2,
amenities: ["Küche", "Bad", "WLAN", "TV", "Waschmaschine", "Klimaanlage", "Vollmöbliert"],
amenitiesEn: ["Kitchen", "Bathroom", "WiFi", "TV", "Washing machine", "Air conditioning", "Fully furnished"],
images: [
"/images/houses/schoenaicher/apartments/og-links/05.png",
"/images/houses/schoenaicher/apartments/og-links/10.png",
"/images/houses/schoenaicher/apartments/og-links/32.png",
"/images/houses/schoenaicher/apartments/og-links/33.png",
"/images/houses/schoenaicher/apartments/og-links/34.png",
],
},
{
id: "schoenaicher-og-rechts",
slug: "schoenaicher-og-rechts",
name: "OG rechts",
nameEn: "Upper Floor Right",
beds24RoomId: "83659",
beds24PropertyId: "36159",
houseId: "schoenaicher",
floor: "Obergeschoss rechts",
floorEn: "Upper Floor Right",
maxGuests: 2,
amenities: ["Küche", "Bad", "WLAN", "TV", "Waschmaschine", "Klimaanlage", "Vollmöbliert"],
amenitiesEn: ["Kitchen", "Bathroom", "WiFi", "TV", "Washing machine", "Air conditioning", "Fully furnished"],
images: [
"/images/houses/schoenaicher/apartments/og-rechts/302367ba-466f-45f5-b84c-e25dcc3b9117.jpg",
"/images/houses/schoenaicher/apartments/og-rechts/3b41e1cb-c192-409c-a10b-1aa6f71453f8.jpg",
"/images/houses/schoenaicher/apartments/og-rechts/432488b9-7208-4580-8651-386690303d8e.avif",
"/images/houses/schoenaicher/apartments/og-rechts/c34fcaf7-c9c3-4203-b794-d7afd898627b.jpg",
"/images/houses/schoenaicher/apartments/og-rechts/d9b463cc-f921-44cc-9d18-34c6c89d41d7.jpg",
"/images/houses/schoenaicher/apartments/og-rechts/e24cd0a0-d72d-409e-91b2-02355b630284.jpg",
],
},
],
},
];
export const allApartments = houses.flatMap((house) => house.apartments);
export const bookingConfig = {
minStayNights: 20,
depositPercent: 20,
// Reference stay length (nights) used purely to display a genuine, real
// "savings vs. short-term rate" comparison. This does NOT add any further
// discount on top of the Beds24 price — it only compares two real Beds24
// rates (the requested long stay vs. this short reference stay) so any
// savings shown are truthful and backed by Beds24's own pricing rules.
referenceStayNights: 7,
};
export function getHouseBySlug(slug: string): House | undefined {
return houses.find((house) => house.slug === slug);
}
export function getApartmentBySlug(slug: string): Apartment | undefined {
return allApartments.find((apartment) => apartment.slug === slug);
}
export function getHouseById(id: string): House | undefined {
return houses.find((house) => house.id === id);
}
+503
View File
@@ -0,0 +1,503 @@
/**
* Beds24 API v2 client.
*
* IMPORTANT: Each house (Herdweg, Schönaicher Straße) has its own, separate
* Beds24 account/login (not just a separate property within one account) —
* same pattern as the per-house Stripe accounts in `src/lib/stripe.ts`.
* That means every house needs its own invite code / refresh token.
*
* Auth flow per house (see https://wiki.beds24.com/index.php/OTAs:_How_to_connect_to_Beds24_using_API_V2):
* 1. Log into that house's Beds24 account: Settings > Account > Apps/API > "Generate invite code"
* (scopes: inventory, later also "bookings" for automated booking creation)
* 2. Exchange the invite code once for a long-life refresh token:
* curl -H "code: <invite-code>" https://api.beds24.com/v2/authentication/setup
* 3. Store the returned `refreshToken` as BEDS24_REFRESH_TOKEN_HERDWEG /
* BEDS24_REFRESH_TOKEN_SCHOENAICHER in .env.local (see .env.local.example).
*
* At runtime we exchange each house's refresh token for a short-lived access
* token (GET /authentication/token, header `refreshToken`) and cache it in
* memory per house until shortly before it expires.
*
* NOTE: The exact response shape of /inventory/rooms/offers and /properties below
* is modeled from Beds24's published API docs but has not been verified against a
* live account. Once the refresh tokens are configured, double check the response
* in the Swagger UI (https://beds24.com/api/v2/#/) and adjust the parsing below if
* field names differ.
*/
import { parseDateOnly, nightsBetween, addDays as addDaysToDateString } from "@/src/lib/date";
const BEDS24_API_BASE = "https://api.beds24.com/v2";
export interface Beds24AvailabilityParams {
houseId: string;
propertyId: string;
roomId: string;
start: string; // YYYY-MM-DD
end: string; // YYYY-MM-DD
numAdults?: number;
}
export interface Beds24AvailabilityResponse {
roomId: string;
available: boolean;
price: number; // per night
currency: string;
}
export interface Beds24RoomInfo {
roomId: string;
name: string;
maxGuests: number;
minStay: number;
currency: string;
}
function getRefreshTokenEnvVar(houseId: string): string {
if (houseId === "herdweg") return "BEDS24_REFRESH_TOKEN_HERDWEG";
if (houseId === "schoenaicher") return "BEDS24_REFRESH_TOKEN_SCHOENAICHER";
throw new Error(`No Beds24 refresh token configured for house: ${houseId}`);
}
function getRefreshToken(houseId: string): string | undefined {
return process.env[getRefreshTokenEnvVar(houseId)];
}
const accessTokenCache = new Map<string, { token: string; expiresAt: number }>();
async function getAccessToken(houseId: string): Promise<string> {
const refreshToken = getRefreshToken(houseId);
if (!refreshToken) {
throw new Error(
`${getRefreshTokenEnvVar(houseId)} is not configured for house: ${houseId}`
);
}
const cached = accessTokenCache.get(houseId);
if (cached && cached.expiresAt > Date.now()) {
return cached.token;
}
const response = await fetch(`${BEDS24_API_BASE}/authentication/token`, {
method: "GET",
headers: { refreshToken },
});
if (!response.ok) {
throw new Error(
`Beds24 authentication error for house ${houseId}: ${response.status} ${response.statusText}`
);
}
const data: { token: string; expiresIn: number } = await response.json();
// Refresh a minute early to avoid using a token that expires mid-request.
accessTokenCache.set(houseId, {
token: data.token,
expiresAt: Date.now() + Math.max(data.expiresIn - 60, 30) * 1000,
});
return data.token;
}
async function beds24Get<T>(
houseId: string,
path: string,
searchParams: Record<string, string | number | boolean | undefined>,
revalidateSeconds = 300
): Promise<T> {
const token = await getAccessToken(houseId);
const url = new URL(`${BEDS24_API_BASE}${path}`);
Object.entries(searchParams).forEach(([key, value]) => {
if (value !== undefined) url.searchParams.set(key, String(value));
});
const response = await fetch(url.toString(), {
method: "GET",
headers: { token },
// Beds24 explicitly asks integrators to cache rather than call in real time
// for every page view; 5 minutes keeps us well within their rate limits
// (longer for data that changes rarely, e.g. rate/min-stay lookups).
next: { revalidate: revalidateSeconds },
});
if (!response.ok) {
throw new Error(`Beds24 API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async function fetchOffer(
houseId: string,
propertyId: string,
roomId: string,
arrival: string,
departure: string,
numAdults: number,
revalidateSeconds?: number
): Promise<{ price: number; currency: string } | null> {
const data = await beds24Get<{
data: Array<{
roomId: number;
propertyId: number;
offers?: Array<{ offerId: number; price: number; currency: string }>;
}>;
}>(
houseId,
"/inventory/rooms/offers",
{
propertyId,
roomId,
arrival,
departure,
numAdults,
},
revalidateSeconds
);
const room = data.data?.find((r) => String(r.roomId) === String(roomId));
const offer = room?.offers?.[0];
return offer ? { price: offer.price, currency: offer.currency } : null;
}
// Beds24's /inventory/rooms/offers endpoint rejects any request spanning
// 100+ nights with a generic "Invalid departure" (code 2001) error — verified
// empirically (99 nights works, 100 doesn't; undocumented, likely an
// internal limitation on their end). Since this site's whole business model
// is long-term stays (1–12+ months), we work around it by splitting longer
// stays into consecutive chunks of at most this many nights each, querying
// each separately, and summing the results.
const MAX_NIGHTS_PER_OFFER_REQUEST = 90;
export async function getAvailability(
params: Beds24AvailabilityParams
): Promise<Beds24AvailabilityResponse> {
// Fallback: if no refresh token is configured for this house, return mock data
// for development.
if (!getRefreshToken(params.houseId)) {
return {
roomId: params.roomId,
available: true,
price: 85,
currency: "EUR",
};
}
const nights = Math.max(nightsBetween(params.start, params.end), 1);
if (nights > MAX_NIGHTS_PER_OFFER_REQUEST) {
return getAvailabilityInChunks(params, nights);
}
const offer = await fetchOffer(
params.houseId,
params.propertyId,
params.roomId,
params.start,
params.end,
params.numAdults ?? 1
);
if (!offer) {
// No offer can mean the room is genuinely booked/blocked, OR that this
// exact stay length doesn't satisfy the active rate's own minimum stay
// (which can be higher than the room's base minStay — see
// getEffectiveMinStay() below). Either way, it's not bookable as-is.
return {
roomId: params.roomId,
available: false,
price: 0,
currency: "EUR",
};
}
// The offers endpoint returns the total price for the requested date range,
// not a per-night rate — divide back down to per-night for our pricing logic.
return {
roomId: params.roomId,
available: true,
price: offer.price / nights,
currency: offer.currency,
};
}
async function getAvailabilityInChunks(
params: Beds24AvailabilityParams,
totalNights: number
): Promise<Beds24AvailabilityResponse> {
let cursor = params.start;
let remaining = totalNights;
let totalPrice = 0;
let currency = "EUR";
while (remaining > 0) {
const chunkNights = Math.min(remaining, MAX_NIGHTS_PER_OFFER_REQUEST);
const chunkEnd = addDaysToDateString(cursor, chunkNights);
const offer = await fetchOffer(
params.houseId,
params.propertyId,
params.roomId,
cursor,
chunkEnd,
params.numAdults ?? 1
);
if (!offer) {
// Any unavailable/unpriceable chunk makes the whole stay unbookable.
return {
roomId: params.roomId,
available: false,
price: 0,
currency: "EUR",
};
}
totalPrice += offer.price;
currency = offer.currency;
cursor = chunkEnd;
remaining -= chunkNights;
}
return {
roomId: params.roomId,
available: true,
price: totalPrice / totalNights,
currency,
};
}
/**
* Determines the REAL, effective minimum stay (in nights) for a booking
* starting on `arrival`, verified empirically against actual bookability.
*
* IMPORTANT LESSON LEARNED: Beds24's `/inventory/fixedPrices` only lists ONE
* rate row per property in practice, tied to a single roomId — but the same
* rate's restrictions (e.g. a 20-night minimum) can still apply to OTHER
* rooms in that property too (Beds24 shares/syncs rate rules across rooms
* internally without exposing that link via this endpoint). So
* `fixedPrices` alone is NOT a reliable source of truth for a given room's
* effective minimum stay — it can under-report it.
*
* The only reliable way to know for sure is to ask
* GET /inventory/rooms/offers whether a priced offer actually exists for a
* given stay length — that's the real, ground-truth answer a guest would
* get. We use `fixedPrices` (if any) purely as an informed first guess to
* minimize the number of probes, then verify/binary-search from there.
*/
export async function getEffectiveMinStay(
houseId: string,
propertyId: string,
roomId: string,
arrival: string,
fallback: number,
// Kept comfortably below Beds24's undocumented ~99-night ceiling on
// /inventory/rooms/offers (see MAX_NIGHTS_PER_OFFER_REQUEST) — realistic
// minimum-stay values are always far below this anyway.
maxNightsToProbe = 95
): Promise<number> {
if (!getRefreshToken(houseId)) {
return fallback;
}
try {
// NOTE: each specific "does an offer exist at N nights" probe below is
// cached independently by its own URL. A too-long cache window (we used
// to use 6 hours) can then make different probes go stale at different
// times, so if the host changes the minimum stay mid-window, some probes
// reflect the old value and some the new one — producing inconsistent,
// "flickering" results. A too-short window (we briefly tried 5 minutes)
// instead risks burning through Beds24's rate/credit limit under real
// traffic, since a single lookup can cost several requests. 60 minutes
// is the agreed middle ground: config changes show up within an hour,
// while normal browsing stays well within Beds24's limits.
const revalidateSeconds = 60 * 60; // 1 hour
const [roomInfo, fixedPrices] = await Promise.all([
getRoomInfo(houseId, propertyId, roomId, revalidateSeconds),
beds24Get<{
data: Array<{
roomId: number;
firstNight: string;
lastNight: string;
minNights?: number;
}>;
}>(houseId, "/inventory/fixedPrices", { propertyId }, revalidateSeconds),
]);
const floor = Math.max(roomInfo.minStay, 1);
// Any rate (on any room of this property) whose validity range covers
// `arrival` gives us an informed guess of what the real minimum might
// be — even though Beds24 doesn't reliably tell us it also applies to
// THIS room, it's usually right and saves most of the probing below.
const arrivalDate = parseDateOnly(arrival);
const candidateMinNights = (fixedPrices.data || [])
.filter((rate) => {
const first = parseDateOnly(rate.firstNight);
const last = parseDateOnly(rate.lastNight);
return arrivalDate >= first && arrivalDate <= last;
})
.map((rate) => rate.minNights ?? 0);
const informedGuess = Math.max(floor, ...candidateMinNights, 0);
const hasOfferAt = async (nights: number) =>
Boolean(
await fetchOffer(
houseId,
propertyId,
roomId,
arrival,
addDaysToDateString(arrival, nights),
1,
revalidateSeconds
)
);
// Fast path: the informed guess is correct (this covers the common case
// in just one extra call).
if (informedGuess > floor && (await hasOfferAt(informedGuess))) {
// Make sure it's really the minimum, not just *a* valid stay length —
// if the floor itself doesn't work but something between floor and
// the guess does, binary-search that narrower range.
if (!(await hasOfferAt(floor))) {
let low = floor + 1;
let high = informedGuess - 1;
let found = informedGuess;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (await hasOfferAt(mid)) {
found = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return found;
}
return floor;
}
// Otherwise fall back to a full binary search between the room's floor
// and a generous upper bound.
if (await hasOfferAt(floor)) {
return floor;
}
let low = floor + 1;
let high = maxNightsToProbe;
if (!(await hasOfferAt(high))) {
return Math.max(informedGuess, fallback);
}
let found: number | null = null;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (await hasOfferAt(mid)) {
found = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return found ?? fallback;
} catch (error) {
console.error("Failed to determine effective min stay from Beds24:", error);
return fallback;
}
}
export async function getRoomInfo(
houseId: string,
propertyId: string,
roomId: string,
revalidateSeconds = 300
): Promise<Beds24RoomInfo> {
if (!getRefreshToken(houseId)) {
return {
roomId,
name: "Apartment",
maxGuests: 2,
minStay: 20,
currency: "EUR",
};
}
const data = await beds24Get<{
data: Array<{
id: number;
currency: string;
roomTypes: Array<{
id: number;
name: string;
maxPeople?: number;
minStay?: number;
}>;
}>;
}>(
houseId,
"/properties",
{
propertyId,
includeAllRooms: true,
},
revalidateSeconds
);
const property = data.data?.find((p) => String(p.id) === String(propertyId));
const room = property?.roomTypes?.find((r) => String(r.id) === String(roomId));
if (!room) {
throw new Error("Room not found in Beds24");
}
return {
roomId,
name: room.name,
maxGuests: room.maxPeople ?? 2,
minStay: room.minStay ?? 20,
currency: property?.currency ?? "EUR",
};
}
/**
* Convenience wrapper around getRoomInfo() that only returns the minimum
* stay (in nights) as configured for this specific room in Beds24, falling
* back to `bookingConfig.minStayNights` if Beds24 is unreachable or the room
* can't be found — so the rest of the app never has to worry about errors
* here.
*/
export async function getMinStayNights(
houseId: string,
propertyId: string,
roomId: string,
fallback: number
): Promise<number> {
try {
const info = await getRoomInfo(houseId, propertyId, roomId);
return info.minStay;
} catch (error) {
console.error("Failed to fetch min stay from Beds24, using fallback:", error);
return fallback;
}
}
export async function getBeds24CancellationPolicy(
houseId: string,
propertyId: string
): Promise<string> {
if (!getRefreshToken(houseId)) {
return "Stornierungsbedingungen werden aus Beds24 übernommen.";
}
const data = await beds24Get<{
data: Array<{ id: number; policies?: { cancellation?: string } }>;
}>(houseId, "/properties", { propertyId });
const property = data.data?.find((p) => String(p.id) === String(propertyId));
return property?.policies?.cancellation || "";
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Date-only ("YYYY-MM-DD") helpers.
*
* IMPORTANT: Never use `date.toISOString().split("T")[0]` to format a
* date-only string — `toISOString()` converts to UTC first, which silently
* shifts the calendar day backward by one for anyone in a positive UTC
* offset timezone (all of Central Europe, i.e. our actual guests/hosts).
* E.g. local midnight Sept 21 in Germany (UTC+2) becomes "2026-09-20" once
* converted to UTC. Always use the local-time helpers below instead.
*/
/** Parses a "YYYY-MM-DD" string as local midnight (not UTC midnight). */
export function parseDateOnly(dateStr: string): Date {
const [year, month, day] = dateStr.split("-").map(Number);
return new Date(year, month - 1, day);
}
/** Formats a Date using its LOCAL date components (no UTC conversion). */
export function formatDateOnly(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function addDays(dateStr: string, days: number): string {
const date = parseDateOnly(dateStr);
date.setDate(date.getDate() + days);
return formatDateOnly(date);
}
export function addMonths(dateStr: string, months: number): string {
const date = parseDateOnly(dateStr);
date.setMonth(date.getMonth() + months);
return formatDateOnly(date);
}
/** Tomorrow's date (local), as a "YYYY-MM-DD" string. */
export function tomorrowDateString(): string {
const date = new Date();
date.setDate(date.getDate() + 1);
return formatDateOnly(date);
}
/** Number of nights between two "YYYY-MM-DD" strings. */
export function nightsBetween(startStr: string, endStr: string): number {
const start = parseDateOnly(startStr);
const end = parseDateOnly(endStr);
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
}
+63
View File
@@ -0,0 +1,63 @@
import nodemailer from "nodemailer";
export interface ContactMessage {
email: string;
message: string;
name?: string;
/** Optional context, e.g. the search dates that had no availability. */
context?: string;
}
function getTransport() {
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD } = process.env;
if (!SMTP_HOST || !SMTP_USER || !SMTP_PASSWORD) {
return null;
}
return nodemailer.createTransport({
host: SMTP_HOST,
port: Number(SMTP_PORT) || 587,
secure: Number(SMTP_PORT) === 465,
auth: {
user: SMTP_USER,
pass: SMTP_PASSWORD,
},
});
}
export async function sendContactMessage(data: ContactMessage): Promise<void> {
const transport = getTransport();
const to = process.env.CONTACT_INBOX_EMAIL || "info@bb-apartment.de";
const from = process.env.SMTP_FROM || "noreply@bb-apartment.de";
const subject = `Neue Kontaktanfrage von ${data.name || data.email}`;
const text = [
data.context ? `Kontext: ${data.context}` : null,
`Name: ${data.name || "-"}`,
`E-Mail: ${data.email}`,
"",
data.message,
]
.filter(Boolean)
.join("\n");
if (!transport) {
// No SMTP configured yet (development) — log instead of failing so the
// form still "works" locally.
console.log("[contact] SMTP not configured, would have sent:", {
to,
from,
subject,
text,
});
return;
}
await transport.sendMail({
to,
from,
replyTo: data.email,
subject,
text,
});
}
+67
View File
@@ -0,0 +1,67 @@
import { bookingConfig } from "@/src/config/apartments";
export interface PriceCalculation {
nights: number;
basePricePerNight: number;
baseTotal: number;
/** Genuine savings vs. a short-term Beds24 rate, for display only — NOT an
* additional deduction. The charged amount (`totalAfterDiscount`) always
* equals `baseTotal`, i.e. exactly what Beds24 quoted for this stay. */
discountPercent: number;
discountAmount: number;
totalAfterDiscount: number;
depositAmount: number;
currency: string;
}
/**
* Calculates the real price to charge for a stay, taken as-is from Beds24
* (`basePricePerNight` already reflects Beds24's own length-of-stay rates —
* we no longer apply any extra discount on our side).
*
* If `referenceNightlyRate` is provided (the real Beds24 rate for a short
* reference stay, see `bookingConfig.referenceStayNights`), we additionally
* compute a truthful "you save X% vs. short-term rate" figure purely for
* display — this never changes the actual charged total.
*/
export function calculatePrice(
basePricePerNight: number,
nights: number,
currency: string = "EUR",
referenceNightlyRate?: number
): PriceCalculation {
const baseTotal = basePricePerNight * nights;
const totalAfterDiscount = baseTotal; // this IS the real amount to charge
const depositAmount = (totalAfterDiscount * bookingConfig.depositPercent) / 100;
let discountPercent = 0;
let discountAmount = 0;
if (referenceNightlyRate && referenceNightlyRate > basePricePerNight) {
discountPercent = Math.round(
(1 - basePricePerNight / referenceNightlyRate) * 100
);
discountAmount = (referenceNightlyRate - basePricePerNight) * nights;
}
return {
nights,
basePricePerNight,
baseTotal,
discountPercent,
discountAmount,
totalAfterDiscount,
depositAmount,
currency,
};
}
export function validateMinStay(nights: number): boolean {
return nights >= bookingConfig.minStayNights;
}
export function formatPrice(amount: number, currency: string = "EUR"): string {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency,
}).format(amount);
}
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+43
View File
@@ -0,0 +1,43 @@
import Stripe from "stripe";
import { getHouseById } from "@/src/config/apartments";
export function getStripe(houseId: string): Stripe {
const house = getHouseById(houseId);
if (!house) {
throw new Error(`House not found: ${houseId}`);
}
const accountKey =
houseId === "herdweg"
? process.env.STRIPE_SECRET_KEY_HERDWEWEG
: process.env.STRIPE_SECRET_KEY_SCHOENAICHER;
if (!accountKey) {
throw new Error(`Stripe secret key not configured for house: ${houseId}`);
}
return new Stripe(accountKey, {
apiVersion: "2026-07-29.dahlia",
});
}
export function getStripeWebhookSecret(houseId: string): string {
const secret =
houseId === "herdweg"
? process.env.STRIPE_WEBHOOK_SECRET_HERDWEWEG
: process.env.STRIPE_WEBHOOK_SECRET_SCHOENAICHER;
if (!secret) {
throw new Error(`Stripe webhook secret not configured for house: ${houseId}`);
}
return secret;
}
export function getStripeAccountId(houseId: string): string {
const house = getHouseById(houseId);
if (!house) {
throw new Error(`House not found: ${houseId}`);
}
return house.stripeAccountId;
}