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