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
+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;
}