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
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.env
.env.local
.env.*.local
coverage
.DS_Store
*.log
npm-debug.log*
.prisma
+39
View File
@@ -0,0 +1,39 @@
# Beds24 API (v2) — each house has its own separate Beds24 account/login,
# so each needs its own invite code exchanged for its own refresh token:
# 1. Log into that house's Beds24 account: Settings > Account > Apps/API >
# "Generate invite code" (scopes: inventory, later also "bookings")
# 2. Exchange once: curl -H "code: <invite-code>" https://api.beds24.com/v2/authentication/setup
# 3. Paste the returned "refreshToken" value below.
BEDS24_REFRESH_TOKEN_HERDWEG=your_beds24_refresh_token_for_herdweg
BEDS24_REFRESH_TOKEN_SCHOENAICHER=your_beds24_refresh_token_for_schoenaicher
# Stripe (one account per house)
NEXT_PUBLIC_BASE_URL=https://www.BB-Apartment.de
STRIPE_ACCOUNT_HERDWEWEG=acct_xxxxxxxxxxxxxxxx
STRIPE_ACCOUNT_SCHOENAICHER=acct_xxxxxxxxxxxxxxxx
STRIPE_SECRET_KEY_HERDWEWEG=sk_test_xxxxxxxxxxxxxxxx
STRIPE_SECRET_KEY_SCHOENAICHER=sk_test_xxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET_HERDWEWEG=whsec_xxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET_SCHOENAICHER=whsec_xxxxxxxxxxxxxxxx
# Database (used by Next.js and n8n)
DATABASE_URL=postgresql://bbapartment:changeme@postgres:5432/bb_apartment
POSTGRES_USER=bbapartment
POSTGRES_PASSWORD=changeme
POSTGRES_DB=bb_apartment
# n8n
N8N_BASIC_AUTH_USER=admin
N8N_BASIC_AUTH_PASSWORD=changeme
N8N_WEBHOOK_URL=https://n8n.bb-apartment.de/
# SMTP (own server) — used by the contact form (/api/contact) and, later,
# booking confirmation emails. Without these set, contact messages are just
# logged to the server console instead of sent (useful for local dev).
SMTP_HOST=mail.your-server.de
SMTP_PORT=587
SMTP_USER=your_smtp_user
SMTP_PASSWORD=your_smtp_password
SMTP_FROM=noreply@bb-apartment.de
# Inbox that receives contact form submissions (defaults to info@bb-apartment.de)
CONTACT_INBOX_EMAIL=info@bb-apartment.de
+157
View File
@@ -0,0 +1,157 @@
# Deployment-Anleitung (Docker)
Diese Anleitung beschreibt, wie die BB-Apartment-Website auf einem eigenen Server per Docker deployt wird. Das Projekt bringt bereits ein fertiges `Dockerfile` und `docker-compose.yml` mit.
## 1. Voraussetzungen auf dem Server
Docker & Docker Compose installieren (falls noch nicht vorhanden), z. B. auf Debian/Ubuntu:
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
```
## 2. Code auf den Server bringen
Git ist **nicht zwingend erforderlich** – es gibt mehrere gleichwertige Wege:
### Option A: Mit Git (am einfachsten für spätere Updates)
```bash
git clone <dein-repo-url> bb-apartment-website
cd bb-apartment-website
```
Updates später einfach per `git pull` (siehe Abschnitt "Updates einspielen").
### Option B: Ohne Git – Dateien direkt kopieren (rsync/scp)
Von deinem lokalen Rechner aus, z. B. per `rsync`:
```bash
rsync -avz --exclude 'node_modules' --exclude '.next' --exclude '.git' \
./bb-apartment-website/ \
user@dein-server:/pfad/zu/bb-apartment-website/
```
Alternativ per `scp` oder einem SFTP-Programm (z. B. FileZilla) den kompletten Ordner hochladen. `.env.local` am besten separat/sicher übertragen oder direkt auf dem Server neu anlegen (Schritt 3), statt sie mitzukopieren.
### Option C: Docker-Image lokal bauen und übertragen
Falls der Server gar keinen Zugriff auf den Quellcode/Registry haben soll:
```bash
# Lokal bauen und als Datei exportieren
docker build -t bb-apartment-website .
docker save bb-apartment-website | gzip > bb-apartment.tar.gz
# Auf den Server kopieren und laden
scp bb-apartment.tar.gz user@dein-server:/tmp/
ssh user@dein-server "gunzip -c /tmp/bb-apartment.tar.gz | docker load"
```
Auf dem Server dann in Schritt 4 `docker compose up -d` **ohne** `--build` verwenden, da das Image schon vorhanden ist (ggf. `image: bb-apartment-website` statt `build:` im `docker-compose.yml` für den `nextjs`-Service eintragen).
### Option D: Docker Registry (Docker Hub, GitHub Container Registry, ...)
Lokal bauen → Image in eine Registry pushen → auf dem Server nur pullen. Praktisch bei häufigeren Updates über mehrere Server hinweg, aber etwas mehr initiales Setup (Registry-Zugang einrichten).
---
Für die restliche Anleitung wird der einfachste Fall (Git) angenommen – bei den anderen Optionen einfach `git pull` in Schritt "Updates einspielen" durch dein jeweiliges Kopier-/Pull-Verfahren ersetzen.
## 3. `.env.local` mit echten Werten anlegen
Diese Datei liegt **nicht** im Git-Repo (enthält Secrets). Auf dem Server einmalig erstellen:
```bash
cp .env.local.example .env.local
nano .env.local # alle Platzhalter durch echte Werte ersetzen
```
Wichtig einzutragen:
- **Beds24**: `BEDS24_REFRESH_TOKEN_HERDWEG` und `BEDS24_REFRESH_TOKEN_SCHOENAICHER` (je Haus ein eigener Zugang, siehe README für die Anleitung zum Beschaffen des Tokens)
- **Stripe**: Account-IDs, Secret Keys und Webhook-Secrets für **beide** Häuser
- **Datenbank**: `DATABASE_URL` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`
- **SMTP**: Zugangsdaten für den Versand der Kontaktformular-Mails
- **`NEXT_PUBLIC_BASE_URL`**: die echte Domain, z. B. `https://www.bb-apartment.de`
- **`N8N_*`**: Zugangsdaten für die n8n-Automatisierung (Buchungsbestätigungen etc.)
## 4. Container bauen und starten
```bash
docker compose up -d --build
```
Das startet automatisch:
- **Next.js** (Port 3000) – die eigentliche Website
- **PostgreSQL** – Datenbank für Buchungen
- **n8n** (Port 5678) – Automatisierungen
### Falls Port 3000 (oder 5678) auf dem Server schon belegt ist
Der Container selbst lauscht intern immer auf Port 3000 (bzw. 5678 bei n8n) — nur der **Host-Port** (die linke Seite von `"3000:3000"` in `docker-compose.yml`) muss angepasst werden. Das ist bereits über Umgebungsvariablen konfigurierbar.
Wichtig: Docker Compose liest diese Variablen für die `ports:`-Zuordnung aus einer Datei namens **`.env`** (nicht `.env.local`!) im selben Ordner wie `docker-compose.yml`, oder aus der Shell-Umgebung. Eine Datei `.env` mit folgendem Inhalt anlegen:
```bash
cat > .env <<'EOF'
NEXTJS_HOST_PORT=8080
N8N_HOST_PORT=8679
EOF
```
Dann wie gewohnt starten:
```bash
docker compose up -d --build
```
Die Website ist danach unter `http://localhost:8080` erreichbar (bzw. dem von dir gewählten Port). Der Reverse Proxy (siehe Schritt 7) muss dann auf diesen Port statt auf 3000 zeigen.
Alternativ – ohne `.env`-Datei – geht es auch einmalig inline:
```bash
NEXTJS_HOST_PORT=8080 docker compose up -d --build
```
## 5. Datenbank-Migration einmalig ausführen
```bash
docker compose exec nextjs npx prisma migrate deploy
```
## 6. Prüfen, ob alles läuft
```bash
docker compose ps
docker compose logs -f nextjs
curl http://localhost:3000 # bzw. der Port aus deiner .env, falls angepasst
```
## 7. Reverse Proxy (Domain + HTTPS)
Das Compose-Setup exponiert Next.js nur auf einem HTTP-Port (Standard 3000) ohne SSL. Für die echte Domain braucht es einen Reverse Proxy (nginx, Caddy o. ä.) davor, der z. B. `www.bb-apartment.de` → `localhost:3000` (bzw. den gewählten Host-Port) weiterleitet und das SSL-Zertifikat (Let's Encrypt) terminiert.
## Updates einspielen
```bash
git pull
docker compose up -d --build # bei Bedarf gezielt nur einen Service: docker compose up -d --build nextjs
docker compose exec nextjs npx prisma migrate deploy # nur falls sich das DB-Schema geändert hat
```
## Fehlersuche
```bash
docker compose logs -f nextjs # Next.js-Logs live verfolgen
docker compose logs -f postgres # Datenbank-Logs
docker compose restart nextjs # Einzelnen Service neu starten
docker compose down # Alles stoppen (Daten in Volumes bleiben erhalten)
```
## Hinweise zu Beds24-Rate-Limits
Die Website cached Beds24-Anfragen serverseitig (5 Minuten bis 1 Stunde, je nach Endpunkt), um innerhalb der Beds24-Anfragelimits zu bleiben. Nach Änderungen in Beds24 (z. B. Mindestaufenthalt, Preise) kann es daher bis zu einer Stunde dauern, bis diese auf der Website sichtbar sind. Details siehe Kommentare in `src/lib/beds24.ts`.
+42
View File
@@ -0,0 +1,42 @@
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --legacy-peer-deps
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npx prisma generate
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+131 -22
View File
@@ -1,36 +1,145 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# BB-Apartment Website
## Getting Started
Zweisprachige (DE/EN), buchungsfähige Website für BB-Apartment mit Next.js, Tailwind CSS, Beds24-Integration, Stripe-Zahlung und n8n-Automatisierung.
First, run the development server:
## Technologie-Stack
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
- **Framework:** Next.js 14 (App Router, TypeScript)
- **Styling:** Tailwind CSS
- **Internationalisierung:** next-intl (Deutsch/Englisch)
- **Zahlungen:** Stripe Checkout (Anzahlung) + Stripe Billing (monatliche Rechnungen) + Stripe Customer Portal
- **PMS:** Beds24 API
- **Workflows:** n8n (Self-Hosted)
- **Datenbank:** PostgreSQL (via Prisma)
- **Deployment:** Docker Compose auf eigenem Server
## Projektstruktur
```
app/[locale]/ # Öffentliche Seiten (i18n-Routing)
app/api/ # API-Endpunkte (Beds24, Stripe, Portal)
components/ # React-Komponenten
messages/ # Übersetzungsdateien (de.json, en.json)
prisma/ # Prisma Schema
public/images/ # Bilder & Logo
src/config/ # Apartment-Konfiguration & Preislogik
src/lib/ # API-Clients (Beds24, Stripe, Prisma)
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Lokal entwickeln
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
1. Abhängigkeiten installieren:
```bash
npm install
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
2. Umgebungsvariablen kopieren und ausfüllen:
```bash
cp .env.local.example .env.local
```
## Learn More
3. Datenbank migrieren (lokale Postgres oder Docker):
```bash
npx prisma migrate dev
```
To learn more about Next.js, take a look at the following resources:
4. Dev-Server starten:
```bash
npm run dev
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
Die Seite ist unter `http://localhost:3000/de` bzw. `http://localhost:3000/en` erreichbar.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Produktiv-Deployment (Docker Compose)
## Deploy on Vercel
1. `.env.local` auf dem Server mit echten Werten befüllen.
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
2. Container starten:
```bash
docker compose up -d --build
```
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Dies startet:
- Next.js App auf Port 3000
- PostgreSQL
- n8n auf Port 5678
3. Erste Datenbank-Migration im Container ausführen:
```bash
docker compose exec nextjs npx prisma migrate deploy
```
## Wichtige API-Endpunkte
- `GET /api/availability?apartmentSlug=...&start=YYYY-MM-DD&end=YYYY-MM-DD`
Verfügbarkeit und Preis (inkl. Rabatt/Anzahlung)
- `POST /api/checkout-session`
Stripe Checkout für Anzahlung erstellen
- `POST /api/portal-session`
Stripe Customer Portal für Rechnungen/Adresse
- `POST /api/webhooks/stripe`
Stripe Webhook Empfänger
- `GET /api/rooms`
Apartment-Informationen aus Beds24
## Konfiguration
### Apartments
Die 8 Apartments mit Beds24 Property-/Room-IDs sind in `src/config/apartments.ts` hinterlegt:
- **Herdweg** (Property-ID `121184`): 4 Apartments
- **Schönaicher Straße** (Property-ID `36159`): 4 Apartments
### Preise & Anzahlung
Rabattstaffel und Anzahlungshöhe sind in `src/config/apartments.ts` (`bookingConfig`) zentral konfigurierbar:
- Mindestaufenthalt: 20 Tage
- Standard-Rabatte: 30 Nächte = 5%, 60 Nächte = 10%, 90 Nächte = 15%
- Anzahlung: 20% der Gesamtkosten
### Stripe
Pro Haus wird ein separates Stripe-Konto verwendet. Die API-Keys und Webhook-Secrets müssen in `.env.local` hinterlegt werden.
### Beds24
Die Anbindung nutzt **Beds24 API v2** (Token-Auth), siehe Kommentare in `src/lib/beds24.ts`.
**Jedes Haus hat einen eigenen, separaten Beds24-Zugang** (eigener Login, nicht nur eine eigene Unterkunft innerhalb eines Accounts) — analog zu den pro Haus getrennten Stripe-Konten. Es werden daher zwei Invite-Codes/Refresh-Tokens benötigt:
1. In **jedem** der beiden Beds24-Konten: Settings → Account → Apps/API → "Generate invite code" (Scope `inventory`, später auch `bookings` für automatische Buchungsanlage).
2. Jeden Code einmalig gegen einen Refresh-Token tauschen:
```bash
curl -H "code: <invite-code>" https://api.beds24.com/v2/authentication/setup
```
3. Die zurückgegebenen `refreshToken`-Werte als `BEDS24_REFRESH_TOKEN_HERDWEG` bzw. `BEDS24_REFRESH_TOKEN_SCHOENAICHER` in `.env.local` hinterlegen.
Jeder Refresh-Token bleibt gültig, solange er mindestens alle 30 Tage einmal genutzt wird (passiert automatisch bei jedem Seitenaufruf mit Verfügbarkeitsprüfung für das jeweilige Haus). Ohne konfigurierten Token liefert der jeweilige Endpunkt Mock-Daten für die Entwicklung.
**Wichtig:** Die genaue Feldstruktur der Antwort von `/inventory/rooms/offers` und `/properties` wurde anhand der offiziellen Beds24-Dokumentation modelliert, aber noch nicht gegen einen echten Account verifiziert. Nach Eintragen der echten Tokens unbedingt einmal `/api/rooms` aufrufen und mit der Swagger-UI (https://beds24.com/api/v2/#/) abgleichen, ob Feldnamen wie `maxPeople`/`minNights` exakt passen.
## Nächste Schritte / Offene Punkte
- Für beide Häuser je einen echten Beds24-Refresh-Token hinterlegen und Antwortstruktur gegen Swagger-UI verifizieren
- Stripe API-Keys & Webhooks für beide Häuser konfigurieren
- n8n-Workflows für Buchungsbestätigung, Beds24-Buchungsanlage und monatliche Rechnungen bauen
- E-Mail-Vorlagen (Deutsch/Englisch) finalisieren
- Haus-/Apartment-Adressen vervollständigen
- Optional: 3D-Rundgänge (Insta360) und KI-Assistent (OpenRouter) nach Go-Live
### Geplant: Monatliche Zahlweise (zurückgestellt)
Entscheidung: **Option A – automatisch per Stripe Subscription Schedule**, Monatsrate = (Gesamtpreis − Anzahlung) ÷ Anzahl Monate, gleichmäßig verteilt.
Umsetzungsschritte, sobald wir weitermachen:
1. Bei der Anzahlungs-Checkout-Session `payment_intent_data.setup_future_usage: "off_session"` setzen, um die Karte zu speichern (inkl. klarer Einwilligungs-Formulierung für den Gast).
2. Nach erfolgreicher Anzahlung (Webhook `checkout.session.completed`) automatisch einen Stripe **Subscription Schedule** mit fester Phasenzahl (= Aufenthaltsmonate) und `end_behavior: "cancel"` anlegen.
3. Prisma-Schema um `stripeSubscriptionScheduleId` / `stripeSubscriptionId` auf `Booking` erweitern.
4. Webhook um `invoice.paid` / `invoice.payment_failed` erweitern (Status-Update + Benachrichtigung bei Fehlschlag).
5. Optional später: SEPA-Lastschrift als zusätzliche Zahlmethode ergänzen.
## Lizenz
Privat – alle Rechte vorbehalten.
@@ -0,0 +1,209 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getApartmentBySlug, getHouseById, bookingConfig } from "@/src/config/apartments";
import { getEffectiveMinStay } from "@/src/lib/beds24";
import { tomorrowDateString } from "@/src/lib/date";
import { Bed, Bath, Users, Calendar, Check, Wind, Sofa, Wifi, WashingMachine, Tv } from "lucide-react";
import { BookingForm } from "@/components/ui/BookingForm";
import { ImageGallery } from "@/components/ui/ImageGallery";
import Link from "next/link";
export async function generateMetadata({
params: { locale, apartmentSlug },
}: {
params: { locale: string; apartmentSlug: string };
}) {
const apartment = getApartmentBySlug(apartmentSlug);
if (!apartment) return { title: "Not Found" };
const t = await getTranslations({ locale, namespace: "metadata" });
return {
title: `${apartment.nameEn} - ${t("title")}`,
description: apartment.descriptionEn || t("description"),
};
}
export default async function ApartmentPage({
params: { locale, apartmentSlug },
}: {
params: { locale: string; apartmentSlug: string };
}) {
const t = await getTranslations({ locale, namespace: "apartment" });
const apartment = getApartmentBySlug(apartmentSlug);
const house = apartment ? getHouseById(apartment.houseId) : undefined;
if (!apartment || !house) notFound();
// Real, effective minimum stay for this apartment as of "tomorrow" — this
// combines Beds24's room-level minimum with whatever the currently active
// rate/price plan requires (the higher of the two wins), so it reflects
// what a guest could actually book, not just the room's base setting.
// The BookingForm re-fetches this whenever the guest picks a different
// check-in date, since it can vary by season.
const minStayNights = await getEffectiveMinStay(
apartment.houseId,
apartment.beds24PropertyId,
apartment.beds24RoomId,
tomorrowDateString(),
bookingConfig.minStayNights
);
const displayName = locale === "de" ? apartment.name : apartment.nameEn;
const displayDescription =
locale === "de" ? apartment.description : apartment.descriptionEn;
const displayFloor = locale === "de" ? apartment.floor : apartment.floorEn;
const amenities =
locale === "de" ? apartment.amenities : apartment.amenitiesEn;
const houseName = locale === "de" ? house.name : house.nameEn;
const hasAmenity = (keys: string[]) =>
amenities.some((a) => keys.some((k) => a.toLowerCase().includes(k.toLowerCase())));
// Apartment-specific highlights (excludes "Fully furnished", already shown elsewhere)
const specificHighlights = [
hasAmenity(["Klimaanlage", "Air conditioning"]) && {
icon: Wind,
title: locale === "de" ? "Klimaanlage" : "Air conditioning",
},
hasAmenity(["Waschmaschine", "Washing machine"]) && {
icon: WashingMachine,
title: locale === "de" ? "Eigene Waschmaschine" : "In-unit Washer",
},
hasAmenity(["WLAN", "WiFi"]) && {
icon: Wifi,
title: locale === "de" ? "High-Speed WLAN" : "High-Speed WiFi",
},
hasAmenity(["TV"]) && {
icon: Tv,
title: "Smart TV",
},
].filter(Boolean) as { icon: typeof Wind; title: string }[];
return (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-6">
<Link
href={`/${locale}/houses/${house.slug}`}
className="text-brand-green-dark hover:text-brand-green"
>
← {houseName}
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-12">
<ImageGallery
images={apartment.images.length > 0 ? apartment.images : ["/images/placeholder.jpg"]}
alt={displayName}
/>
<div>
<h1 className="text-4xl font-bold mb-2">{displayName}</h1>
<p className="text-brand-grey mb-6">{houseName} • {displayFloor}</p>
{displayDescription && (
<p className="text-brand-grey leading-relaxed mb-6">
{displayDescription}
</p>
)}
<div className="grid grid-cols-2 gap-4 mb-8">
<div className="flex items-center gap-3 p-4 bg-brand-grey-soft rounded-xl">
<Users className="w-5 h-5 text-brand-green" />
<div>
<p className="text-sm text-brand-grey">{t("guests")}</p>
<p className="font-semibold">{apartment.maxGuests}</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-brand-grey-soft rounded-xl">
<Calendar className="w-5 h-5 text-brand-green" />
<div>
<p className="text-sm text-brand-grey">{t("minStay")}</p>
<p className="font-semibold">{minStayNights} {t("nights")}</p>
</div>
</div>
{amenities.some((a) =>
["Klimaanlage", "Air conditioning"].includes(a)
) && (
<div className="flex items-center gap-3 p-4 bg-brand-green/10 rounded-xl border border-brand-green/20 col-span-2 sm:col-span-1">
<Wind className="w-5 h-5 text-brand-green" />
<div>
<p className="text-sm text-brand-grey">
{locale === "de" ? "Klimaanlage" : "Air conditioning"}
</p>
<p className="font-semibold text-brand-green-dark">
{locale === "de" ? "Vorhanden" : "Available"}
</p>
</div>
</div>
)}
<div className="flex items-center gap-3 p-4 bg-brand-green/10 rounded-xl border border-brand-green/20">
<Sofa className="w-5 h-5 text-brand-green" />
<div>
<p className="text-sm text-brand-grey">
{locale === "de" ? "Vollmöbliert" : "Fully furnished"}
</p>
<p className="font-semibold text-brand-green-dark">
{locale === "de" ? "Ja" : "Yes"}
</p>
</div>
</div>
{apartment.bedrooms && (
<div className="flex items-center gap-3 p-4 bg-brand-grey-soft rounded-xl">
<Bed className="w-5 h-5 text-brand-green" />
<div>
<p className="text-sm text-brand-grey">{locale === "de" ? "Schlafzimmer" : "Bedrooms"}</p>
<p className="font-semibold">{apartment.bedrooms}</p>
</div>
</div>
)}
{apartment.bathrooms && (
<div className="flex items-center gap-3 p-4 bg-brand-grey-soft rounded-xl">
<Bath className="w-5 h-5 text-brand-green" />
<div>
<p className="text-sm text-brand-grey">{locale === "de" ? "Bäder" : "Bathrooms"}</p>
<p className="font-semibold">{apartment.bathrooms}</p>
</div>
</div>
)}
</div>
<BookingForm apartment={apartment} locale={locale} minStayNights={minStayNights} />
</div>
</div>
{specificHighlights.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-5 mb-12">
{specificHighlights.map((highlight, index) => {
const Icon = highlight.icon;
return (
<div
key={index}
className="flex flex-col items-center text-center gap-4 p-6 bg-white rounded-2xl border-2 border-brand-green/20 shadow-md hover:shadow-xl hover:-translate-y-1 transition-all"
>
<div className="p-4 bg-brand-green rounded-2xl text-white shadow-sm">
<Icon className="w-9 h-9" />
</div>
<p className="font-bold text-brand-grey-dark text-lg leading-tight">
{highlight.title}
</p>
</div>
);
})}
</div>
)}
<div className="bg-brand-grey-soft rounded-2xl p-8">
<h2 className="text-2xl font-bold mb-6">{t("features")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{amenities.map((amenity, index) => (
<div key={index} className="flex items-center gap-3">
<Check className="w-5 h-5 text-brand-green flex-shrink-0" />
<span className="text-brand-grey-dark">{amenity}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { getTranslations } from "next-intl/server";
import { CheckCircle } from "lucide-react";
import Link from "next/link";
export async function generateMetadata({
params: { locale },
}: {
params: { locale: string };
}) {
const t = await getTranslations({ locale, namespace: "metadata" });
return {
title: `${t("title")} - Buchung erfolgreich`,
description: t("description"),
};
}
export default function BookingSuccessPage({
params: { locale },
}: {
params: { locale: string };
}) {
const isDe = locale === "de";
return (
<div className="py-16">
<div className="max-w-2xl mx-auto px-4 text-center">
<CheckCircle className="w-16 h-16 text-brand-green mx-auto mb-6" />
<h1 className="text-4xl font-bold mb-4">
{isDe ? "Anzahlung erfolgreich!" : "Deposit successful!"}
</h1>
<p className="text-brand-grey text-lg mb-8">
{isDe
? "Vielen Dank für Ihre Buchung. Sie erhalten in Kürze eine Bestätigungs-E-Mail. Die monatlichen Rechnungen werden Ihnen über das Kundenportal zur Verfügung gestellt."
: "Thank you for your booking. You will receive a confirmation email shortly. Monthly invoices will be available through the customer portal."}
</p>
<Link
href={`/${locale}`}
className="inline-block bg-brand-green text-white font-semibold px-8 py-4 rounded-full hover:bg-brand-green-dark transition-colors"
>
{isDe ? "Zurück zur Startseite" : "Back to home"}
</Link>
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { Mail, Phone, MapPin } from "lucide-react";
export async function generateMetadata({
params: { locale },
}: {
params: { locale: string };
}) {
const t = await getTranslations({ locale, namespace: "contact" });
return {
title: t("title"),
description: t("description"),
};
}
export default function ContactPage({
params: { locale },
}: {
params: { locale: string };
}) {
const t = useTranslations("contact");
return (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">{t("title")}</h1>
<p className="text-brand-grey text-lg max-w-2xl mx-auto">
{t("description")}
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div className="bg-brand-grey-soft rounded-2xl p-8">
<h2 className="text-2xl font-bold mb-6">
{locale === "de" ? "Kontaktdaten" : "Contact Details"}
</h2>
<div className="space-y-6">
<div className="flex items-start gap-4">
<Mail className="w-6 h-6 text-brand-green mt-1" />
<div>
<p className="font-semibold">{t("email")}</p>
<a href="mailto:info@bb-apartment.de" className="text-brand-grey">
info@bb-apartment.de
</a>
</div>
</div>
<div className="flex items-start gap-4">
<Phone className="w-6 h-6 text-brand-green mt-1" />
<div>
<p className="font-semibold">{t("phone")}</p>
<p className="text-brand-grey">+49 (0) 7031 7631132</p>
</div>
</div>
<div className="flex items-start gap-4">
<MapPin className="w-6 h-6 text-brand-green mt-1" />
<div>
<p className="font-semibold">{locale === "de" ? "Adresse" : "Address"}</p>
<p className="text-brand-grey">
BB-Apartment<br />
Böblingen<br />
Deutschland
</p>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-2xl p-8 border border-brand-grey-soft shadow-sm">
<h2 className="text-2xl font-bold mb-6">
{locale === "de" ? "Nachricht senden" : "Send a message"}
</h2>
<form className="space-y-4">
<div>
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("name")}
</label>
<input
type="text"
className="w-full px-4 py-3 rounded-xl border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("email")}
</label>
<input
type="email"
className="w-full px-4 py-3 rounded-xl border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("message")}
</label>
<textarea
rows={5}
className="w-full px-4 py-3 rounded-xl border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
<button
type="submit"
className="w-full bg-brand-green text-white font-semibold px-8 py-4 rounded-full hover:bg-brand-green-dark transition-colors"
>
{t("send")}
</button>
</form>
</div>
</div>
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="min-h-screen flex flex-col items-center justify-center px-4 text-center">
<h2 className="text-2xl font-bold text-brand-grey-dark mb-4">
Etwas ist schiefgelaufen.
</h2>
<p className="text-brand-grey mb-8 max-w-md">
{error.message ||
"Beim Laden der Seite ist ein Fehler aufgetreten. Bitte versuche es erneut."}
</p>
<button
onClick={reset}
className="px-6 py-3 bg-brand-green text-white rounded-xl font-medium hover:bg-brand-green-dark transition-colors"
>
Erneut versuchen
</button>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
import { notFound } from "next/navigation";
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { getHouseBySlug } from "@/src/config/apartments";
import { ApartmentCard } from "@/components/ui/ApartmentCard";
import { HouseHighlights } from "@/components/ui/HouseHighlights";
import { MapPin, Home, Car, Clock, Navigation } from "lucide-react";
import Image from "next/image";
export async function generateMetadata({
params: { locale, houseSlug },
}: {
params: { locale: string; houseSlug: string };
}) {
const house = getHouseBySlug(houseSlug);
if (!house) return { title: "Not Found" };
const t = await getTranslations({ locale, namespace: "metadata" });
return {
title: `${house.nameEn} - ${t("title")}`,
description: house.descriptionEn,
};
}
export default function HousePage({
params: { locale, houseSlug },
}: {
params: { locale: string; houseSlug: string };
}) {
const t = useTranslations("houses");
const house = getHouseBySlug(houseSlug);
if (!house) notFound();
const displayName = locale === "de" ? house.name : house.nameEn;
const displayDescription =
locale === "de" ? house.description : house.descriptionEn;
const displayCountry = locale === "de" ? house.country : house.countryEn;
return (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="relative h-80 md:h-96 rounded-2xl overflow-hidden mb-12">
<Image
src={house.images[0] || "/images/placeholder.jpg"}
alt={displayName}
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-0 left-0 p-8 text-white">
<h1 className="text-4xl font-bold mb-2 text-white">{displayName}</h1>
<div className="flex items-center gap-2 text-white/90">
<MapPin className="w-5 h-5" />
<span>
{house.address}, {house.zipCode} {house.city}, {displayCountry}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
<div className="lg:col-span-2">
<h2 className="text-2xl font-bold mb-4">
{t("overview")}
</h2>
<p className="text-brand-grey leading-relaxed mb-8">
{displayDescription}
</p>
<HouseHighlights house={house} locale={locale} />
<h2 className="text-2xl font-bold mb-6">
{t("apartments")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{house.apartments.map((apartment) => (
<ApartmentCard
key={apartment.id}
apartment={apartment}
locale={locale}
/>
))}
</div>
</div>
<div className="flex flex-col gap-8 h-fit">
<div className="bg-brand-grey-soft rounded-2xl p-8 h-fit">
<div className="flex items-center gap-3 mb-4">
<Home className="w-6 h-6 text-brand-green" />
<h3 className="text-xl font-bold">{displayName}</h3>
</div>
<p className="text-brand-grey text-sm mb-4">
{house.apartments.length} {locale === "de" ? "Apartments" : "Apartments"}
</p>
<div className="text-sm text-brand-grey">
<p className="mb-2">
<strong>{locale === "de" ? "Adresse:" : "Address:"}</strong> {house.address}
</p>
<p>
<strong>{locale === "de" ? "Stadt:" : "City:"}</strong> {house.zipCode} {house.city}
</p>
</div>
</div>
<div className="bg-white rounded-2xl border border-brand-grey-soft shadow-sm p-6 h-fit">
<div className="flex items-center gap-2 mb-4">
<Navigation className="w-5 h-5 text-brand-green" />
<h2 className="text-lg font-bold">
{locale === "de" ? "Lage & Entfernungen" : "Location & Distances"}
</h2>
</div>
<div className="flex flex-col gap-3 mb-4">
{house.id === "herdweg" ? (
<>
<a
href="https://www.google.com/maps/dir/Herdweg+71,+71032+Böblingen/Panzer+Kaserne+Böblingen"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 bg-brand-green text-white rounded-xl hover:bg-brand-green-dark transition-colors"
>
<Car className="w-5 h-5 shrink-0" />
<div>
<p className="font-bold text-sm">
{locale === "de" ? "Panzer Kaserne – gleiche Straße" : "Panzer Kaserne – Same Street"}
</p>
<p className="text-sm text-white/85">
{locale === "de" ? "Kein Abbiegen nötig" : "Zero turns needed"}
</p>
</div>
</a>
<a
href="https://www.google.com/maps/dir/Herdweg+71,+71032+Böblingen/Böblingen+Zentrum"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 bg-brand-green/10 rounded-xl hover:bg-brand-green/20 transition-colors"
>
<Clock className="w-5 h-5 text-brand-green shrink-0" />
<div>
<p className="font-bold text-brand-grey-dark text-sm">
{locale === "de" ? "Stadtzentrum Böblingen" : "Böblingen City Center"}
</p>
<p className="text-sm text-brand-grey">5–8 min</p>
</div>
</a>
</>
) : (
<>
<a
href="https://www.google.com/maps/dir/Schönaicher+Straße+26,+71032+Böblingen/Daimler+Werk+Sindelfingen"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 bg-brand-green/10 rounded-xl hover:bg-brand-green/20 transition-colors"
>
<Car className="w-5 h-5 text-brand-green shrink-0" />
<div>
<p className="font-bold text-brand-grey-dark text-sm">
{locale === "de" ? "Daimler Werk Sindelfingen" : "Daimler Plant Sindelfingen"}
</p>
<p className="text-sm text-brand-grey">10–15 min</p>
</div>
</a>
<a
href="https://www.google.com/maps/dir/Schönaicher+Straße+26,+71032+Böblingen/Böblingen+Zentrum"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 bg-brand-green/10 rounded-xl hover:bg-brand-green/20 transition-colors"
>
<Clock className="w-5 h-5 text-brand-green shrink-0" />
<div>
<p className="font-bold text-brand-grey-dark text-sm">
{locale === "de" ? "Stadtzentrum Böblingen" : "Böblingen City Center"}
</p>
<p className="text-sm text-brand-grey">&lt; 5 min</p>
</div>
</a>
</>
)}
</div>
<div className="relative w-full h-48 rounded-xl overflow-hidden border border-brand-grey-soft">
<iframe
src={`https://maps.google.com/maps?q=${encodeURIComponent(`${house.address}, ${house.zipCode} ${house.city}`)}&z=15&output=embed`}
width="100%"
height="100%"
style={{ border: 0 }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title={displayName}
className="grayscale-[20%] hover:grayscale-0 transition-all duration-500"
/>
</div>
<p className="text-xs text-brand-grey mt-3 text-center">
{locale === "de"
? "Karte ist interaktiv – klicken Sie auf einen Ort, um die Route zu planen."
: "Map is interactive – click a destination to plan the route."}
</p>
</div>
</div>
</div>
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { getTranslations } from "next-intl/server";
import { houses, allApartments, getHouseById, bookingConfig } from "@/src/config/apartments";
import { getAvailability, getEffectiveMinStay } from "@/src/lib/beds24";
import { calculatePrice } from "@/src/lib/pricing";
import { nightsBetween, parseDateOnly } from "@/src/lib/date";
import { HouseCard } from "@/components/ui/HouseCard";
import { AvailabilitySearchBar } from "@/components/ui/AvailabilitySearchBar";
import { AvailableApartmentCard } from "@/components/ui/AvailableApartmentCard";
import { NoAvailabilityContact } from "@/components/ui/NoAvailabilityContact";
import Link from "next/link";
export async function generateMetadata({
params: { locale },
}: {
params: { locale: string };
}) {
const t = await getTranslations({ locale, namespace: "home" });
return {
title: t("houses.title"),
description: `${t("houses.title")} – ${t("houses.subtitle")}`,
};
}
export default async function HousesPage({
params: { locale },
searchParams,
}: {
params: { locale: string };
searchParams: { arrival?: string; departure?: string; months?: string };
}) {
const t = await getTranslations({ locale, namespace: "home" });
const tSearch = await getTranslations({ locale, namespace: "houses.search" });
const { arrival, departure } = searchParams;
const hasSearch = Boolean(arrival && departure);
if (hasSearch && arrival && departure) {
const nights = nightsBetween(arrival, departure);
const results = await Promise.all(
allApartments.map(async (apartment) => {
const house = getHouseById(apartment.houseId)!;
try {
const [availability, minStayNights] = await Promise.all([
getAvailability({
houseId: apartment.houseId,
propertyId: apartment.beds24PropertyId,
roomId: apartment.beds24RoomId,
start: arrival,
end: departure,
}),
getEffectiveMinStay(
apartment.houseId,
apartment.beds24PropertyId,
apartment.beds24RoomId,
arrival,
bookingConfig.minStayNights
),
]);
const minStayValid = nights > 0 && nights >= minStayNights;
const price = calculatePrice(availability.price, nights, availability.currency);
return {
apartment,
house,
available: availability.available,
minStayValid,
pricePerNight: price.basePricePerNight,
currency: price.currency,
};
} catch {
return {
apartment,
house,
available: false,
minStayValid: false,
pricePerNight: undefined,
currency: undefined,
};
}
})
);
results.sort((a, b) => {
const aOk = a.available && a.minStayValid;
const bOk = b.available && b.minStayValid;
return aOk === bOk ? 0 : aOk ? -1 : 1;
});
const anyAvailable = results.some((r) => r.available && r.minStayValid);
return (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold mb-4">{tSearch("resultsTitle")}</h1>
<p className="text-brand-grey text-lg">
{tSearch("resultsSubtitle")}: {parseDateOnly(arrival).toLocaleDateString(locale === "de" ? "de-DE" : "en-US")} –{" "}
{parseDateOnly(departure).toLocaleDateString(locale === "de" ? "de-DE" : "en-US")}
</p>
</div>
<AvailabilitySearchBar
locale={locale}
defaultArrival={arrival}
defaultMonths={searchParams.months ? Number(searchParams.months) : undefined}
/>
{!anyAvailable && (
<div className="mb-12">
<p className="text-center text-brand-grey mb-8">{tSearch("noResults")}</p>
<NoAvailabilityContact
locale={locale}
context={`${locale === "de" ? "Gewünschter Zeitraum" : "Requested dates"}: ${arrival} – ${departure}`}
/>
</div>
)}
{anyAvailable && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 mb-12">
{results.map(({ apartment, house, available, minStayValid, pricePerNight, currency }) => (
<AvailableApartmentCard
key={apartment.id}
apartment={apartment}
house={house}
locale={locale}
available={available}
minStayValid={minStayValid}
pricePerNight={pricePerNight}
currency={currency}
arrival={arrival}
departure={departure}
/>
))}
</div>
)}
<div className="text-center">
<Link
href={`/${locale}/houses`}
className="text-brand-green-dark font-medium hover:text-brand-green"
>
{tSearch("showAllHouses")}
</Link>
</div>
</div>
</div>
);
}
return (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">{t("houses.title")}</h1>
<p className="text-brand-grey text-lg">{t("houses.subtitle")}</p>
</div>
<AvailabilitySearchBar locale={locale} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{houses.map((house) => (
<HouseCard key={house.id} house={house} locale={locale} />
))}
</div>
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { getTranslations } from "next-intl/server";
export async function generateMetadata({
params: { locale },
}: {
params: { locale: string };
}) {
const t = await getTranslations({ locale, namespace: "footer" });
return {
title: t("imprint"),
};
}
export default function ImprintPage({
params: { locale },
}: {
params: { locale: string };
}) {
const isDe = locale === "de";
return (
<div className="py-16">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-4xl font-bold mb-8">
{isDe ? "Impressum" : "Imprint"}
</h1>
<div className="prose prose-lg max-w-none">
<p className="text-brand-grey mb-8">
{isDe
? "Angaben gemäß § 5 TMG"
: "Information according to § 5 TMG"}
</p>
<h2 className="text-2xl font-bold mb-4">
{isDe ? "Angaben zum Anbieter" : "Provider Information"}
</h2>
<p className="text-brand-grey mb-6">
<strong>
{isDe ? "Grundstücksgemeinschaft Allami / Allawy" : "Allami / Allawy Property Community"}
</strong>
<br />
Forchenweg 1
<br />
71063 Sindelfingen
<br />
{isDe ? "Deutschland" : "Germany"}
</p>
<h2 className="text-2xl font-bold mb-4">
{isDe ? "Vertreten durch" : "Represented by"}
</h2>
<p className="text-brand-grey mb-6">
Ali Allawy
</p>
<h2 className="text-2xl font-bold mb-4">
{isDe ? "Kontakt" : "Contact"}
</h2>
<p className="text-brand-grey mb-6">
E-Mail: info@bb-apartment.de
<br />
{isDe ? "Telefon:" : "Phone:"} 07031 7631132
<br />
{isDe ? "Web:" : "Web:"} www.BB-Apartment.de
</p>
<h2 className="text-2xl font-bold mb-4">
{isDe ? "Verantwortlich für den Inhalt" : "Responsible for Content"}
</h2>
<p className="text-brand-grey mb-6">
Ali Allawy
<br />
Forchenweg 1
<br />
71063 Sindelfingen
</p>
<h2 className="text-2xl font-bold mb-4">
{isDe ? "Hinweis" : "Note"}
</h2>
<p className="text-brand-grey mb-6">
{isDe
? "EU-Streitschlichtung: Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit. Unsere E-Mail-Adresse finden Sie oben im Impressum. Wir sind nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen."
: "EU Dispute Resolution: The European Commission provides a platform for online dispute resolution (ODR). Our email address can be found above in the imprint. We are not obligated to participate in dispute resolution proceedings before a consumer arbitration board."}
</p>
</div>
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { ReactNode } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { locales, Locale } from "@/i18n";
import "../globals.css";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import localFont from "next/font/local";
export async function generateMetadata({
params: { locale },
}: {
params: { locale: string };
}) {
const t = await getTranslations({ locale, namespace: "metadata" });
return {
metadataBase: new URL("https://bb-apartment.de"),
title: {
template: `%s | ${t("title")}`,
default: t("title"),
},
description: t("description"),
alternates: {
canonical: `/${locale}`,
languages: {
"de-DE": "/de",
"en-US": "/en",
"x-default": "/de",
},
},
openGraph: {
type: "website",
locale: locale === "de" ? "de_DE" : "en_US",
url: `https://bb-apartment.de/${locale}`,
siteName: "BB-Apartment",
title: t("title"),
description: t("description"),
},
twitter: {
card: "summary_large_image",
title: t("title"),
description: t("description"),
},
robots: {
index: true,
follow: true,
},
};
}
const geistSans = localFont({
src: "../fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
export default async function LocaleLayout({
children,
params: { locale },
}: {
children: ReactNode;
params: { locale: string };
}) {
if (!locales.includes(locale as Locale)) notFound();
const messages = await getMessages();
return (
<html lang={locale} className={geistSans.variable}>
<body className="antialiased min-h-screen flex flex-col bg-white text-brand-grey-dark">
<NextIntlClientProvider messages={messages} locale={locale}>
<Header locale={locale} />
<main className="flex-grow pt-20">{children}</main>
<Footer />
</NextIntlClientProvider>
</body>
</html>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { houses } from "@/src/config/apartments";
import { Hero } from "@/components/ui/Hero";
import { HouseCard } from "@/components/ui/HouseCard";
import { BookingBanner } from "@/components/ui/BookingBanner";
import { Impressions } from "@/components/ui/Impressions";
import { ArrowRight, MapPin, Shield, Clock, Wind, Home, CreditCard } from "lucide-react";
import Link from "next/link";
export async function generateMetadata({
params: { locale },
}: {
params: { locale: string };
}) {
const t = await getTranslations({ locale, namespace: "metadata" });
return {
title: t("title"),
description: t("description"),
};
}
export default function HomePage({
params: { locale },
}: {
params: { locale: string };
}) {
const t = useTranslations("home");
const features = [
{
icon: Clock,
text: t("features.longStay"),
},
{
icon: MapPin,
text: t("features.location"),
},
{
icon: Wind,
text: t("features.airConditioning"),
},
{
icon: Home,
text: t("features.furnished"),
},
{
icon: CreditCard,
text: t("features.payment"),
},
];
return (
<div>
<Hero
title={t("hero.title")}
subtitle={t("hero.subtitle")}
description={t("hero.description")}
cta={t("hero.cta")}
ctaHref={`/${locale}/houses`}
/>
<BookingBanner locale={locale} />
<section className="py-20 bg-brand-grey-soft/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<p className="text-brand-green font-semibold mb-2 uppercase tracking-wider text-sm">{t("houses.subtitle")}</p>
<h2 className="text-4xl font-bold mb-4 text-brand-grey-dark">{t("houses.title")}</h2>
<div className="w-20 h-1 bg-brand-green mx-auto rounded-full" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
{houses.map((house) => (
<HouseCard key={house.id} house={house} locale={locale} />
))}
</div>
</div>
</section>
<Impressions
title={t("impressions.title")}
subtitle={t("impressions.subtitle")}
/>
<section className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<p className="text-brand-green font-semibold mb-2 uppercase tracking-wider text-sm">{t("features.title")}</p>
<h2 className="text-4xl font-bold mb-4 text-brand-grey-dark">{t("features.title")}</h2>
<div className="w-20 h-1 bg-brand-green mx-auto rounded-full" />
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
{features.map((feature, index) => {
const Icon = feature.icon;
return (
<div
key={index}
className="group p-6 md:p-8 bg-white rounded-2xl shadow-md border-2 border-brand-green/20 hover:bg-brand-green hover:border-brand-green hover:-translate-y-2 transition-all duration-300 flex flex-col items-center text-center"
>
<div className="p-4 bg-brand-green/10 rounded-2xl mb-5 group-hover:bg-white/20 transition-colors">
<Icon className="w-8 h-8 text-brand-green group-hover:text-white transition-colors" />
</div>
<p className="text-brand-grey-dark font-bold text-lg leading-snug group-hover:text-white transition-colors">{feature.text}</p>
</div>
);
})}
</div>
</div>
</section>
<section className="relative py-24 overflow-hidden">
<div className="absolute inset-0 bg-brand-green" />
<div className="absolute inset-0 bg-[url('/images/cta-bg.jpg')] opacity-10 bg-cover bg-center" />
<div className="relative z-10 max-w-4xl mx-auto px-4 text-center">
<Shield className="w-12 h-12 text-white/80 mx-auto mb-6" />
<h2 className="text-4xl font-bold mb-6 text-white">
{t("ctaSection.title")}
</h2>
<p className="text-xl mb-10 text-white/90 max-w-2xl mx-auto">
{t("ctaSection.description")}
</p>
<Link
href={`/${locale}/houses`}
className="inline-flex items-center gap-2 bg-white text-brand-green-dark font-semibold px-8 py-4 rounded-2xl hover:bg-brand-grey-soft hover:shadow-lg hover:-translate-y-0.5 transition-all"
>
{t("ctaSection.cta")}
<ArrowRight className="w-5 h-5" />
</Link>
</div>
</section>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { getTranslations } from "next-intl/server";
export async function generateMetadata({
params: { locale },
}: {
params: { locale: string };
}) {
const t = await getTranslations({ locale, namespace: "footer" });
return {
title: t("privacy"),
};
}
export default function PrivacyPage({
params: { locale },
}: {
params: { locale: string };
}) {
const isDe = locale === "de";
return (
<div className="py-16">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-4xl font-bold mb-8">
{isDe ? "Datenschutzerklärung" : "Privacy Policy"}
</h1>
<div className="prose prose-lg max-w-none">
<p className="text-brand-grey mb-8">
{isDe
? "Der Schutz Ihrer personenbezogenen Daten ist uns wichtig. Wir verarbeiten Ihre Daten nur, wenn dies technisch oder zur Vertragsabwicklung erforderlich ist."
: "The protection of your personal data is important to us. We only process your data when this is technically necessary or required for contract processing."}
</p>
<h2 className="text-2xl font-bold mb-4">
{isDe ? "Verantwortlicher" : "Controller"}
</h2>
<p className="text-brand-grey mb-6">
<strong>
{isDe ? "Grundstücksgemeinschaft Allami / Allawy" : "Allami / Allawy Property Community"}
</strong>
<br />
Forchenweg 1
<br />
71063 Sindelfingen
<br />
{isDe ? "Deutschland" : "Germany"}
<br />
<br />
{isDe ? "Vertreten durch:" : "Represented by:"} Ali Allawy
<br />
E-Mail: info@bb-apartment.de
<br />
{isDe ? "Telefon:" : "Phone:"} 07031 7631132
</p>
<h2 className="text-2xl font-bold mb-4">
{isDe ? "Keine Cookies & kein Tracking" : "No Cookies & No Tracking"}
</h2>
<p className="text-brand-grey mb-6">
{isDe
? "Diese Website verwendet keine Cookies, Tracking-Tools, Analyse-Dienste oder Werbe-Technologien. Wir speichern keine personenbezogenen Daten zu Marketing- oder Profiling-Zwecken."
: "This website does not use cookies, tracking tools, analytics services or advertising technologies. We do not store personal data for marketing or profiling purposes."}
</p>
<h2 className="text-2xl font-bold mb-4">
{isDe ? "Server-Logfiles" : "Server Log Files"}
</h2>
<p className="text-brand-grey mb-6">
{isDe
? "Unser Hosting-Anbieter erhebt automatisch technische Daten (z. B. Browsertyp, IP-Adresse, Uhrzeit der Anfrage), die für die technische Bereitstellung und Sicherheit der Website erforderlich sind. Diese Daten werden automatisch gelöscht und nicht mit anderen Datenquellen zusammengeführt."
: "Our hosting provider automatically collects technical data (e.g. browser type, IP address, time of request) required for the technical provision and security of the website. This data is automatically deleted and not merged with other data sources."}
</p>
<h2 className="text-2xl font-bold mb-4">
{isDe ? "Kontaktanfragen" : "Contact Inquiries"}
</h2>
<p className="text-brand-grey mb-6">
{isDe
? "Wenn Sie uns per E-Mail oder Kontaktformular kontaktieren, speichern wir die von Ihnen freiwillig übermittelten Daten ausschließlich zur Bearbeitung Ihrer Anfrage. Eine Weitergabe an Dritte erfolgt nicht."
: "If you contact us by email or contact form, we store the data you voluntarily provide solely for the purpose of processing your inquiry. This data is not passed on to third parties."}
</p>
<h2 className="text-2xl font-bold mb-4">
{isDe ? "Ihre Rechte" : "Your Rights"}
</h2>
<p className="text-brand-grey mb-6">
{isDe
? "Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung sowie Widerspruch gegen die Verarbeitung Ihrer personenbezogenen Daten. Bitte wenden Sie sich dazu an info@bb-apartment.de."
: "You have the right to access, rectification, erasure, restriction of processing and objection to the processing of your personal data. Please contact info@bb-apartment.de for this."}
</p>
</div>
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from "next/server";
import { getAvailability, getEffectiveMinStay } from "@/src/lib/beds24";
import { calculatePrice } from "@/src/lib/pricing";
import { getApartmentBySlug, bookingConfig } from "@/src/config/apartments";
import { nightsBetween, addDays } from "@/src/lib/date";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const apartmentSlug = searchParams.get("apartmentSlug");
const start = searchParams.get("start");
const end = searchParams.get("end");
if (!apartmentSlug || !start || !end) {
return NextResponse.json(
{ error: "Missing required parameters: apartmentSlug, start, end" },
{ status: 400 }
);
}
const apartment = getApartmentBySlug(apartmentSlug);
if (!apartment) {
return NextResponse.json(
{ error: "Apartment not found" },
{ status: 404 }
);
}
const nights = nightsBetween(start, end);
if (nights <= 0) {
return NextResponse.json(
{ error: "End date must be after start date" },
{ status: 400 }
);
}
const minStayNights = await getEffectiveMinStay(
apartment.houseId,
apartment.beds24PropertyId,
apartment.beds24RoomId,
start,
bookingConfig.minStayNights
);
const minStayValid = nights >= minStayNights;
const availability = await getAvailability({
houseId: apartment.houseId,
propertyId: apartment.beds24PropertyId,
roomId: apartment.beds24RoomId,
start,
end,
});
// Fetch Beds24's own short-stay rate for the same arrival date, purely to
// display a genuine "savings vs. short-term rate" figure — this never
// changes the actual price charged (see calculatePrice()).
let referenceNightlyRate: number | undefined;
if (availability.available && nights > bookingConfig.referenceStayNights) {
try {
const reference = await getAvailability({
houseId: apartment.houseId,
propertyId: apartment.beds24PropertyId,
roomId: apartment.beds24RoomId,
start,
end: addDays(start, bookingConfig.referenceStayNights),
});
if (reference.available) {
referenceNightlyRate = reference.price;
}
} catch (referenceError) {
console.error("Reference price lookup failed:", referenceError);
}
}
const price = calculatePrice(
availability.price,
nights,
availability.currency,
referenceNightlyRate
);
return NextResponse.json({
apartmentSlug,
start,
end,
nights,
available: availability.available,
minStayValid,
minStayNights,
maxGuests: apartment.maxGuests,
price,
});
} catch (error) {
console.error("Availability API error:", error);
return NextResponse.json(
{ error: "Failed to fetch availability" },
{ status: 500 }
);
}
}
+162
View File
@@ -0,0 +1,162 @@
import { NextRequest, NextResponse } from "next/server";
import { getStripe, getStripeAccountId } from "@/src/lib/stripe";
import { prisma } from "@/src/lib/prisma";
import { getApartmentBySlug, bookingConfig } from "@/src/config/apartments";
import { getAvailability, getEffectiveMinStay } from "@/src/lib/beds24";
import { calculatePrice } from "@/src/lib/pricing";
import { nightsBetween } from "@/src/lib/date";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
apartmentSlug,
start,
end,
guestEmail,
guestName,
successUrl,
cancelUrl,
} = body;
if (!apartmentSlug || !start || !end || !guestEmail || !guestName) {
return NextResponse.json(
{ error: "Missing required booking parameters" },
{ status: 400 }
);
}
const apartment = getApartmentBySlug(apartmentSlug);
if (!apartment) {
return NextResponse.json(
{ error: "Apartment not found" },
{ status: 404 }
);
}
const startDate = new Date(start);
const endDate = new Date(end);
const nights = nightsBetween(start, end);
const minStayNights = await getEffectiveMinStay(
apartment.houseId,
apartment.beds24PropertyId,
apartment.beds24RoomId,
start,
bookingConfig.minStayNights
);
if (nights < minStayNights) {
return NextResponse.json(
{ error: `Minimum stay is ${minStayNights} nights` },
{ status: 400 }
);
}
const availability = await getAvailability({
houseId: apartment.houseId,
propertyId: apartment.beds24PropertyId,
roomId: apartment.beds24RoomId,
start,
end,
});
if (!availability.available) {
return NextResponse.json(
{ error: "Apartment not available for selected dates" },
{ status: 409 }
);
}
const price = calculatePrice(availability.price, nights, availability.currency);
const depositAmount = Math.round(price.depositAmount * 100); // cents
const stripe = getStripe(apartment.houseId);
const accountId = getStripeAccountId(apartment.houseId);
// Create or update Stripe customer
let customer;
const existingCustomers = await stripe.customers.list({
email: guestEmail,
limit: 1,
});
if (existingCustomers.data.length > 0) {
customer = existingCustomers.data[0];
} else {
customer = await stripe.customers.create({
email: guestEmail,
name: guestName,
});
}
const bookingReference = `BB-${Date.now()}-${Math.random().toString(36).substring(2, 7).toUpperCase()}`;
// Create pending booking in database
await prisma.booking.create({
data: {
bookingReference,
apartmentSlug,
houseId: apartment.houseId,
beds24PropertyId: apartment.beds24PropertyId,
beds24RoomId: apartment.beds24RoomId,
guestEmail,
guestName,
checkIn: startDate,
checkOut: endDate,
nights,
totalAmount: price.totalAfterDiscount,
depositAmount: price.depositAmount,
currency: price.currency,
status: "pending",
stripeAccountId: accountId,
stripeCustomerId: customer.id,
},
});
const session = await stripe.checkout.sessions.create({
customer: customer.id,
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency: price.currency.toLowerCase(),
product_data: {
name: `Anzahlung Apartment ${apartment.name}`,
description: `Aufenthalt: ${start} bis ${end} (${nights} Nächte)`,
},
unit_amount: depositAmount,
},
quantity: 1,
},
],
mode: "payment",
success_url: successUrl || `${process.env.NEXT_PUBLIC_BASE_URL}/de/booking-success?reference=${bookingReference}`,
cancel_url: cancelUrl || `${process.env.NEXT_PUBLIC_BASE_URL}/de/apartments/${apartmentSlug}`,
metadata: {
bookingReference,
apartmentSlug,
houseId: apartment.houseId,
},
});
await prisma.booking.update({
where: { bookingReference },
data: { stripeCheckoutSessionId: session.id },
});
return NextResponse.json({
sessionId: session.id,
bookingReference,
url: session.url,
});
} catch (error) {
console.error("Checkout session error:", error);
return NextResponse.json(
{ error: "Failed to create checkout session" },
{ status: 500 }
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { sendContactMessage } from "@/src/lib/mail";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
try {
const { name, email, message, context } = await request.json();
if (!email || !message) {
return NextResponse.json(
{ error: "Missing required fields: email, message" },
{ status: 400 }
);
}
await sendContactMessage({ name, email, message, context });
return NextResponse.json({ success: true });
} catch (error) {
console.error("Contact API error:", error);
return NextResponse.json(
{ error: "Failed to send message" },
{ status: 500 }
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { getEffectiveMinStay } from "@/src/lib/beds24";
import { getApartmentBySlug, bookingConfig } from "@/src/config/apartments";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const apartmentSlug = searchParams.get("apartmentSlug");
const arrival = searchParams.get("arrival");
if (!apartmentSlug || !arrival) {
return NextResponse.json(
{ error: "Missing required parameters: apartmentSlug, arrival" },
{ status: 400 }
);
}
const apartment = getApartmentBySlug(apartmentSlug);
if (!apartment) {
return NextResponse.json(
{ error: "Apartment not found" },
{ status: 404 }
);
}
const minStayNights = await getEffectiveMinStay(
apartment.houseId,
apartment.beds24PropertyId,
apartment.beds24RoomId,
arrival,
bookingConfig.minStayNights
);
return NextResponse.json({ apartmentSlug, arrival, minStayNights });
} catch (error) {
console.error("Min-stay API error:", error);
return NextResponse.json(
{ error: "Failed to determine minimum stay" },
{ status: 500 }
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { getStripe } from "@/src/lib/stripe";
import { prisma } from "@/src/lib/prisma";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
try {
const { bookingReference, email } = await request.json();
if (!bookingReference || !email) {
return NextResponse.json(
{ error: "Missing bookingReference or email" },
{ status: 400 }
);
}
const booking = await prisma.booking.findUnique({
where: { bookingReference },
});
if (!booking || booking.guestEmail !== email || !booking.stripeCustomerId) {
return NextResponse.json(
{ error: "Booking not found" },
{ status: 404 }
);
}
const stripe = getStripe(booking.houseId);
const session = await stripe.billingPortal.sessions.create({
customer: booking.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/de`,
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error("Portal session error:", error);
return NextResponse.json(
{ error: "Failed to create portal session" },
{ status: 500 }
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { getRoomInfo } from "@/src/lib/beds24";
import { allApartments } from "@/src/config/apartments";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const rooms = await Promise.all(
allApartments.map(async (apartment) => {
const info = await getRoomInfo(
apartment.houseId,
apartment.beds24PropertyId,
apartment.beds24RoomId
);
return {
slug: apartment.slug,
beds24RoomId: apartment.beds24RoomId,
beds24PropertyId: apartment.beds24PropertyId,
name: apartment.name,
nameEn: apartment.nameEn,
beds24Name: info.name,
maxGuests: info.maxGuests,
minStay: info.minStay,
currency: info.currency,
};
})
);
return NextResponse.json({ rooms });
} catch (error) {
console.error("Rooms API error:", error);
return NextResponse.json(
{ error: "Failed to fetch rooms" },
{ status: 500 }
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import Stripe from "stripe";
import { getStripe, getStripeWebhookSecret } from "@/src/lib/stripe";
import { prisma } from "@/src/lib/prisma";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
const payload = await request.text();
const headerPayload = await headers();
const signature = headerPayload.get("stripe-signature") || "";
// Determine which Stripe account this webhook belongs to
// Stripe sends the account ID in the Stripe-Account header for Connect
const accountId = headerPayload.get("stripe-account") || "";
let houseId: string;
if (process.env.STRIPE_ACCOUNT_HERDWEWEG && accountId === process.env.STRIPE_ACCOUNT_HERDWEWEG) {
houseId = "herdweg";
} else if (process.env.STRIPE_ACCOUNT_SCHOENAICHER && accountId === process.env.STRIPE_ACCOUNT_SCHOENAICHER) {
houseId = "schoenaicher";
} else {
// If no account header, try to infer from event metadata (not available in signature verification)
// For now, we'll try both secrets. In production, use separate webhook endpoints or account headers.
return NextResponse.json(
{ error: "Could not determine Stripe account" },
{ status: 400 }
);
}
try {
const stripe = getStripe(houseId);
const webhookSecret = getStripeWebhookSecret(houseId);
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
// Deduplicate events
const existingEvent = await prisma.stripeEvent.findUnique({
where: { eventId: event.id },
});
if (existingEvent) {
return NextResponse.json({ received: true });
}
await prisma.stripeEvent.create({
data: {
eventId: event.id,
accountId,
type: event.type,
payload: JSON.stringify(event),
},
});
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const bookingReference = session.metadata?.bookingReference;
if (bookingReference) {
await prisma.booking.update({
where: { bookingReference },
data: {
status: "deposit_paid",
stripePaymentIntentId: session.payment_intent as string,
},
});
// TODO: Trigger n8n workflow to create booking in Beds24
console.log(`Deposit paid for booking ${bookingReference}`);
}
}
return NextResponse.json({ received: true });
} catch (error) {
console.error("Stripe webhook error:", error);
return NextResponse.json(
{ error: "Webhook verification failed" },
{ status: 400 }
);
}
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body className="min-h-screen flex flex-col items-center justify-center px-4 text-center bg-white text-brand-grey-dark">
<h2 className="text-2xl font-bold mb-4">Ein unerwarteter Fehler ist aufgetreten.</h2>
<p className="text-brand-grey mb-8">{error.message}</p>
<button
onClick={reset}
className="px-6 py-3 bg-brand-green text-white rounded-xl font-medium hover:bg-brand-green-dark transition-colors"
>
Erneut versuchen
</button>
</body>
</html>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body className="min-h-screen flex flex-col items-center justify-center px-4 text-center bg-white text-brand-grey-dark">
<h2 className="text-2xl font-bold mb-4">Ein kritischer Fehler ist aufgetreten.</h2>
<p className="text-brand-grey mb-8">{error.message}</p>
<button
onClick={() => reset()}
className="px-6 py-3 bg-brand-green text-white rounded-xl font-medium hover:bg-brand-green-dark transition-colors"
>
Erneut versuchen
</button>
</body>
</html>
);
}
+13 -9
View File
@@ -4,20 +4,15 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--foreground: #2c2c2c;
--brand-green: #7cb342;
--brand-green-dark: #558b2f;
--brand-grey: #4a4a4a;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
@@ -25,3 +20,12 @@ body {
text-wrap: balance;
}
}
@layer base {
h1, h2, h3, h4, h5, h6 {
@apply text-brand-grey-dark font-semibold;
}
a {
@apply text-brand-green-dark hover:text-brand-green transition-colors;
}
}
+3 -33
View File
@@ -1,35 +1,5 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { ReactNode } from "react";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
export default function RootLayout({ children }: { children: ReactNode }) {
return children;
}
-101
View File
@@ -1,101 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="https://nextjs.org/icons/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="https://nextjs.org/icons/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org →
</a>
</footer>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: "https://bb-apartment.de/sitemap.xml",
};
}
+50
View File
@@ -0,0 +1,50 @@
import { MetadataRoute } from "next";
import { houses } from "@/src/config/apartments";
const baseUrl = "https://bb-apartment.de";
export default function sitemap(): MetadataRoute.Sitemap {
const locales = ["de", "en"];
const staticRoutes: MetadataRoute.Sitemap = [
{ url: baseUrl, lastModified: new Date() },
...locales.map((locale) => ({
url: `${baseUrl}/${locale}`,
lastModified: new Date(),
})),
...locales.map((locale) => ({
url: `${baseUrl}/${locale}/houses`,
lastModified: new Date(),
})),
...locales.map((locale) => ({
url: `${baseUrl}/${locale}/contact`,
lastModified: new Date(),
})),
...locales.map((locale) => ({
url: `${baseUrl}/${locale}/imprint`,
lastModified: new Date(),
})),
...locales.map((locale) => ({
url: `${baseUrl}/${locale}/privacy`,
lastModified: new Date(),
})),
];
const houseRoutes: MetadataRoute.Sitemap = houses.flatMap((house) =>
locales.map((locale) => ({
url: `${baseUrl}/${locale}/houses/${house.slug}`,
lastModified: new Date(),
}))
);
const apartmentRoutes: MetadataRoute.Sitemap = houses.flatMap((house) =>
house.apartments.flatMap((apartment) =>
locales.map((locale) => ({
url: `${baseUrl}/${locale}/apartments/${apartment.slug}`,
lastModified: new Date(),
}))
)
);
return [...staticRoutes, ...houseRoutes, ...apartmentRoutes];
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import {
Home,
MapPin,
Mail,
Phone,
Globe,
Shield,
ArrowRight,
} from "lucide-react";
export function Footer() {
const t = useTranslations("footer");
const currentYear = new Date().getFullYear();
return (
<footer className="relative bg-brand-grey-dark text-white overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-brand-green via-brand-green-light to-brand-green" />
<div className="absolute -top-24 -right-24 w-64 h-64 bg-brand-green/10 rounded-full blur-3xl" />
<div className="absolute -bottom-32 -left-32 w-80 h-80 bg-brand-green/10 rounded-full blur-3xl" />
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12">
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-brand-green rounded-xl">
<Home className="w-6 h-6 text-white" />
</div>
<h3 className="text-xl font-bold">{t("brandTitle")}</h3>
</div>
<p className="text-brand-grey-light text-sm leading-relaxed">
{t("welcome")}
</p>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 text-brand-green-light mb-2">
<MapPin className="w-5 h-5" />
<h3 className="text-lg font-bold text-white">{t("locationsTitle")}</h3>
</div>
<div className="space-y-4 text-sm">
<a
href="https://maps.google.com/?q=Herdweg+71+Böblingen"
target="_blank"
rel="noopener noreferrer"
className="block text-brand-grey-light hover:text-brand-green-light transition-colors"
>
<span className="font-semibold text-white">{t("herdwegName")}</span>
<br />
{t("herdwegAddress")}
</a>
<a
href="https://maps.google.com/?q=Schönaicher+Straße+26+Böblingen"
target="_blank"
rel="noopener noreferrer"
className="block text-brand-grey-light hover:text-brand-green-light transition-colors"
>
<span className="font-semibold text-white">{t("schoenaicherName")}</span>
<br />
{t("schoenaicherAddress")}
</a>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 text-brand-green-light mb-2">
<Phone className="w-5 h-5" />
<h3 className="text-lg font-bold text-white">{t("contactTitle")}</h3>
</div>
<div className="space-y-3 text-sm">
<a
href={`mailto:${t("email")}`}
className="flex items-center gap-2 text-brand-grey-light hover:text-brand-green-light transition-colors"
>
<Mail className="w-4 h-4 text-brand-green" />
{t("email")}
</a>
<a
href={`tel:${t("phone").replace(/\s/g, "")}`}
className="flex items-center gap-2 text-brand-grey-light hover:text-brand-green-light transition-colors"
>
<Phone className="w-4 h-4 text-brand-green" />
{t("phone")}
</a>
<a
href={`https://${t("web")}`}
className="flex items-center gap-2 text-brand-grey-light hover:text-brand-green-light transition-colors"
>
<Globe className="w-4 h-4 text-brand-green" />
{t("web")}
</a>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 text-brand-green-light mb-2">
<Shield className="w-5 h-5" />
<h3 className="text-lg font-bold text-white">{t("legalTitle")}</h3>
</div>
<div className="flex flex-col gap-3">
<Link
href="/imprint"
className="flex items-center gap-2 text-brand-grey-light hover:text-brand-green-light hover:translate-x-1 transition-all text-sm"
>
<ArrowRight className="w-4 h-4 text-brand-green" />
{t("imprint")}
</Link>
<Link
href="/privacy"
className="flex items-center gap-2 text-brand-grey-light hover:text-brand-green-light hover:translate-x-1 transition-all text-sm"
>
<ArrowRight className="w-4 h-4 text-brand-green" />
{t("privacy")}
</Link>
</div>
</div>
</div>
<div className="relative mt-14 pt-8 border-t border-brand-grey/30 text-center text-brand-grey-light text-sm">
© {currentYear} BB-Apartment. {t("rights")}
</div>
</div>
</footer>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { usePathname } from "next/navigation";
import { LanguageSwitcher } from "./LanguageSwitcher";
import { Menu, X } from "lucide-react";
import { useState, useEffect } from "react";
export function Header({ locale }: { locale: string }) {
const t = useTranslations("navigation");
const pathname = usePathname();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
const showSolid = !isHome || scrolled;
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 20);
};
handleScroll();
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const navLinks = [
{ href: `/${locale}`, label: t("home") },
{ href: `/${locale}/houses`, label: t("houses") },
{ href: `/${locale}/contact`, label: t("contact") },
];
return (
<header
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 border-b ${
showSolid
? "bg-white/95 backdrop-blur-md border-brand-grey-soft shadow-sm"
: "bg-transparent border-transparent"
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div
className={`flex items-center justify-between transition-all duration-300 ${
showSolid ? "h-20" : "h-28"
}`}
>
<Link href={`/${locale}`} className="flex items-center gap-3">
<div
className={`relative overflow-hidden rounded-lg transition-all duration-300 ${
showSolid ? "w-12 h-12" : "w-24 h-24"
}`}
>
<Image
src="/images/logo.jpg"
alt="BB-Apartment"
fill
className="object-cover"
/>
</div>
<span
className={`text-xl font-bold transition-colors duration-300 ${
showSolid ? "text-brand-grey-dark" : "text-white"
}`}
>
BB-Apartment
</span>
</Link>
<nav className="hidden md:flex items-center gap-8">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={`font-medium transition-colors duration-300 ${
showSolid
? "text-brand-grey hover:text-brand-green"
: "text-white/90 hover:text-white"
}`}
>
{link.label}
</Link>
))}
<LanguageSwitcher locale={locale} scrolled={showSolid} />
<Link
href={`/${locale}/houses`}
className={`font-semibold px-5 py-2.5 rounded-xl transition-all duration-300 ${
showSolid
? "bg-brand-green text-white hover:bg-brand-green-dark shadow-sm"
: "bg-white text-brand-green-dark hover:bg-brand-grey-soft"
}`}
>
{t("bookNow")}
</Link>
</nav>
<div className="md:hidden flex items-center gap-4">
<LanguageSwitcher locale={locale} scrolled={showSolid} />
<Link
href={`/${locale}/houses`}
className="hidden sm:inline-flex font-semibold px-4 py-2 rounded-xl bg-brand-green text-white hover:bg-brand-green-dark transition-colors text-sm"
>
{t("bookNow")}
</Link>
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className={`p-2 transition-colors duration-300 ${
showSolid ? "text-brand-grey-dark" : "text-white"
}`}
aria-label="Toggle menu"
>
{mobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
</div>
</div>
{mobileMenuOpen && (
<div className="md:hidden bg-white border-t border-brand-grey-soft">
<nav className="flex flex-col px-4 py-4 gap-4">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileMenuOpen(false)}
className="text-brand-grey hover:text-brand-green font-medium transition-colors"
>
{link.label}
</Link>
))}
<Link
href={`/${locale}/houses`}
onClick={() => setMobileMenuOpen(false)}
className="inline-flex items-center justify-center gap-2 bg-brand-green text-white font-semibold px-4 py-3 rounded-xl hover:bg-brand-green-dark transition-colors"
>
{t("bookNow")}
</Link>
</nav>
</div>
)}
</header>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { locales } from "@/i18n";
export function LanguageSwitcher({
locale,
scrolled = false,
}: {
locale: string;
scrolled?: boolean;
}) {
const pathname = usePathname();
const strippedPath = pathname.replace(`/${locale}`, "") || "/";
return (
<div
className={`flex items-center gap-1 rounded-full p-1 transition-colors duration-300 ${
scrolled ? "bg-brand-grey-soft" : "bg-white/10"
}`}
>
{locales.map((l) => (
<Link
key={l}
href={`/${l}${strippedPath}`}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors duration-300 ${
l === locale
? "bg-brand-green text-white"
: scrolled
? "text-brand-grey hover:text-brand-green-dark"
: "text-white/80 hover:text-white"
}`}
>
{l.toUpperCase()}
</Link>
))}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import Link from "next/link";
import Image from "next/image";
import { Apartment } from "@/src/config/apartments";
import { Users, ArrowRight, Wind } from "lucide-react";
interface ApartmentCardProps {
apartment: Apartment;
locale: string;
}
export function ApartmentCard({ apartment, locale }: ApartmentCardProps) {
const displayName = locale === "de" ? apartment.name : apartment.nameEn;
const displayFloor = locale === "de" ? apartment.floor : apartment.floorEn;
const hasAc = apartment.amenities.some((a) => a.toLowerCase().includes("klimaanlage"));
return (
<div className="bg-white rounded-2xl overflow-hidden shadow-sm border border-brand-grey-soft hover:shadow-lg transition-shadow">
<div className="relative h-48">
<Image
src={apartment.images[0] || "/images/placeholder.jpg"}
alt={displayName}
fill
className="object-cover"
/>
<div
className={`absolute top-3 right-3 flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1.5 rounded-full backdrop-blur-sm ${
hasAc ? "bg-brand-green/90 text-white" : "bg-white/90 text-brand-grey"
}`}
title={
hasAc
? locale === "de"
? "Klimaanlage vorhanden"
: "Air conditioning available"
: locale === "de"
? "Keine Klimaanlage"
: "No air conditioning"
}
>
<Wind className={`w-3.5 h-3.5 ${hasAc ? "" : "opacity-40"}`} />
{hasAc
? locale === "de"
? "Klimaanlage"
: "AC"
: locale === "de"
? "Keine Klimaanlage"
: "No AC"}
</div>
</div>
<div className="p-5">
<h3 className="text-xl font-bold mb-1 text-brand-grey-dark">
{displayName}
</h3>
<p className="text-brand-grey-light text-sm mb-4">{displayFloor}</p>
<div className="flex items-center gap-2 text-sm text-brand-grey mb-4">
<Users className="w-4 h-4 text-brand-green" />
<span>
{apartment.maxGuests} {locale === "de" ? "Gäste" : "guests"}
</span>
</div>
<Link
href={`/${locale}/apartments/${apartment.slug}`}
className="inline-flex items-center gap-2 text-brand-green-dark font-medium hover:text-brand-green transition-colors"
>
{locale === "de" ? "Details ansehen" : "View details"}
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Calendar, ArrowRight } from "lucide-react";
import { tomorrowDateString, addMonths } from "@/src/lib/date";
interface AvailabilitySearchBarProps {
locale: string;
defaultArrival?: string;
defaultMonths?: number;
}
export function AvailabilitySearchBar({
locale,
defaultArrival,
defaultMonths,
}: AvailabilitySearchBarProps) {
const t = useTranslations("houses.search");
const router = useRouter();
const [arrival, setArrival] = useState(defaultArrival || tomorrowDateString());
const [months, setMonths] = useState(defaultMonths || 3);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const departure = addMonths(arrival, months);
const searchParams = new URLSearchParams({
arrival,
departure,
months: String(months),
});
router.push(`/${locale}/houses?${searchParams.toString()}`);
};
return (
<form
onSubmit={handleSubmit}
className="bg-white rounded-2xl shadow-lg border border-brand-grey-soft/80 p-6 md:p-8 mb-12"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-end">
<div className="space-y-2">
<label
htmlFor="search-arrival"
className="flex items-center gap-2 text-sm font-bold text-brand-grey-dark"
>
<Calendar className="w-4 h-4 text-brand-green" />
{t("arrival")}
</label>
<input
id="search-arrival"
type="date"
required
lang={locale === "de" ? "de-DE" : "en-US"}
value={arrival}
min={tomorrowDateString()}
onChange={(e) => setArrival(e.target.value)}
className="w-full px-4 py-3 rounded-xl border-2 border-brand-grey-soft/60 bg-brand-grey-soft/20 text-brand-grey-dark focus:outline-none focus:ring-2 focus:ring-brand-green focus:border-transparent transition-all"
/>
</div>
<div className="space-y-2">
<label htmlFor="search-months" className="block text-sm font-bold text-brand-grey-dark">
{t("stayMonths")}
</label>
<input
id="search-months"
type="number"
min={1}
max={24}
required
value={months}
onChange={(e) => setMonths(Number(e.target.value))}
className="w-full px-4 py-3 text-center rounded-xl border-2 border-brand-grey-soft/60 bg-brand-grey-soft/20 text-brand-grey-dark focus:outline-none focus:ring-2 focus:ring-brand-green focus:border-transparent transition-all"
/>
</div>
<button
type="submit"
className="inline-flex items-center justify-center gap-2 bg-brand-green text-white font-bold px-6 py-3 rounded-xl hover:bg-brand-green-dark hover:shadow-lg transition-all"
>
{t("checkAvailability")}
<ArrowRight className="w-5 h-5" />
</button>
</div>
</form>
);
}
+102
View File
@@ -0,0 +1,102 @@
import Link from "next/link";
import Image from "next/image";
import { Apartment, House } from "@/src/config/apartments";
import { formatPrice } from "@/src/lib/pricing";
import { CheckCircle2, XCircle, Users, MapPin, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
interface AvailableApartmentCardProps {
apartment: Apartment;
house: House;
locale: string;
available: boolean;
minStayValid: boolean;
pricePerNight?: number;
currency?: string;
arrival: string;
departure: string;
}
export function AvailableApartmentCard({
apartment,
house,
locale,
available,
minStayValid,
pricePerNight,
currency,
arrival,
departure,
}: AvailableApartmentCardProps) {
const t = useTranslations("houses.search");
const displayName = locale === "de" ? apartment.name : apartment.nameEn;
const houseName = locale === "de" ? house.name : house.nameEn;
const canBook = available && minStayValid;
return (
<div
className={`bg-white rounded-2xl overflow-hidden shadow-sm border transition-shadow ${
canBook
? "border-brand-grey-soft hover:shadow-lg"
: "border-brand-grey-soft opacity-70"
}`}
>
<div className="relative h-48">
<Image
src={apartment.images[0] || "/images/placeholder.jpg"}
alt={displayName}
fill
className={`object-cover ${canBook ? "" : "grayscale"}`}
/>
<div
className={`absolute top-3 right-3 flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1.5 rounded-full backdrop-blur-sm ${
canBook ? "bg-brand-green/90 text-white" : "bg-white/90 text-brand-grey"
}`}
>
{canBook ? (
<CheckCircle2 className="w-3.5 h-3.5" />
) : (
<XCircle className="w-3.5 h-3.5" />
)}
{canBook ? t("available") : !minStayValid ? t("minStayNotMet") : t("notAvailable")}
</div>
</div>
<div className="p-5">
<h3 className="text-lg font-bold mb-1 text-brand-grey-dark">{displayName}</h3>
<div className="flex items-center gap-1.5 text-sm text-brand-grey mb-3">
<MapPin className="w-4 h-4 text-brand-green" />
<span>{houseName}</span>
</div>
<div className="flex items-center gap-2 text-sm text-brand-grey mb-1">
<Users className="w-4 h-4 text-brand-green" />
<span>
{apartment.maxGuests} {locale === "de" ? "Gäste" : "guests"}
</span>
</div>
{canBook && pricePerNight !== undefined && (
<p className="text-lg font-bold text-brand-green-dark mb-4">
{formatPrice(pricePerNight, currency)}{" "}
<span className="text-sm font-normal text-brand-grey">/ {t("perNight")}</span>
</p>
)}
{canBook ? (
<Link
href={`/${locale}/apartments/${apartment.slug}?checkin=${arrival}&checkout=${departure}`}
className="inline-flex items-center justify-center gap-2 w-full bg-brand-green text-white font-semibold px-4 py-3 rounded-xl hover:bg-brand-green-dark transition-colors"
>
{t("bookThis")}
<ArrowRight className="w-4 h-4" />
</Link>
) : (
<Link
href={`/${locale}/apartments/${apartment.slug}`}
className="inline-flex items-center justify-center gap-2 w-full bg-brand-grey-soft text-brand-grey-dark font-semibold px-4 py-3 rounded-xl hover:bg-brand-grey-soft/70 transition-colors"
>
{locale === "de" ? "Details ansehen" : "View details"}
</Link>
)}
</div>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Calendar, ArrowRight } from "lucide-react";
import { tomorrowDateString, addMonths } from "@/src/lib/date";
interface BookingBannerProps {
locale: string;
}
export function BookingBanner({ locale }: BookingBannerProps) {
const t = useTranslations("home.bookingBanner");
const router = useRouter();
const [arrival, setArrival] = useState(tomorrowDateString());
const [months, setMonths] = useState(3);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const departure = addMonths(arrival, months);
const searchParams = new URLSearchParams({
arrival,
departure,
months: String(months),
});
router.push(`/${locale}/houses?${searchParams.toString()}`);
};
return (
<section className="relative z-20 -mt-16 mb-24">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<form
onSubmit={handleSubmit}
className="bg-white rounded-3xl shadow-2xl shadow-brand-grey-dark/15 border border-brand-grey-soft/80 overflow-hidden"
>
<div className="h-1.5 w-full bg-gradient-to-r from-brand-green via-brand-green-light to-brand-green" />
<div className="p-8 md:p-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-end">
<div className="space-y-3">
<label
htmlFor="booking-arrival"
className="flex items-center gap-2 text-base font-bold text-brand-grey-dark"
>
<Calendar className="w-5 h-5 text-brand-green" />
{t("arrival")}
</label>
<div className="relative">
<input
id="booking-arrival"
type="date"
required
value={arrival}
min={tomorrowDateString()}
onChange={(e) => setArrival(e.target.value)}
className="w-full pl-4 pr-4 py-4 text-lg rounded-xl border-2 border-brand-grey-soft/60 bg-brand-grey-soft/20 text-brand-grey-dark focus:outline-none focus:ring-2 focus:ring-brand-green focus:border-transparent transition-all"
/>
</div>
</div>
<div className="space-y-3">
<label
htmlFor="booking-months"
className="block text-base font-bold text-brand-grey-dark"
>
{t("stayMonths")}
</label>
<div className="relative">
<input
id="booking-months"
type="number"
min={1}
max={24}
required
value={months}
onChange={(e) => setMonths(Number(e.target.value))}
className="w-full px-4 py-4 text-lg text-center rounded-xl border-2 border-brand-grey-soft/60 bg-brand-grey-soft/20 text-brand-grey-dark focus:outline-none focus:ring-2 focus:ring-brand-green focus:border-transparent transition-all"
/>
</div>
</div>
<button
type="submit"
className="inline-flex items-center justify-center gap-3 bg-brand-green text-white text-lg font-bold px-8 py-4 rounded-xl hover:bg-brand-green-dark hover:shadow-xl hover:-translate-y-1 transition-all"
>
{t("checkAvailability")}
<ArrowRight className="w-6 h-6" />
</button>
</div>
</div>
</form>
</div>
</section>
);
}
+392
View File
@@ -0,0 +1,392 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Apartment } from "@/src/config/apartments";
import { addDays } from "@/src/lib/date";
import { Calendar, Users } from "lucide-react";
interface BookingFormProps {
apartment: Apartment;
locale: string;
/** Initial value (probed server-side for "tomorrow") — kept up to date
* client-side via /api/min-stay whenever the guest changes the check-in
* date, since the effective minimum stay can vary by season/rate. */
minStayNights: number;
}
export function BookingForm({ apartment, locale, minStayNights: initialMinStayNights }: BookingFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [start, setStart] = useState(searchParams.get("checkin") || "");
const [end, setEnd] = useState(searchParams.get("checkout") || "");
const [guestName, setGuestName] = useState("");
const [guestEmail, setGuestEmail] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [attemptedSubmit, setAttemptedSubmit] = useState(false);
const [minStayNights, setMinStayNights] = useState(initialMinStayNights);
const [minStayLoading, setMinStayLoading] = useState(false);
const [price, setPrice] = useState<{
nights: number;
basePricePerNight: number;
totalAfterDiscount: number;
depositAmount: number;
discountPercent: number;
minStayValid: boolean;
available: boolean;
} | null>(null);
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
de: {
checkIn: "Anreise",
checkOut: "Abreise",
guests: "Gäste",
name: "Name",
email: "E-Mail",
calculate: "Preis berechnen",
bookNow: "Jetzt anzahlungspflichtig buchen",
minStay: "Mindestaufenthalt",
nights: "Nächte",
discount: "Ersparnis ggü. Kurzzeitpreis (bereits enthalten)",
total: "Gesamt",
deposit: "Anzahlung",
unavailable: "Nicht verfügbar",
loading: "Wird geladen...",
needsCalculation: "Bitte zuerst den Preis berechnen.",
needsNameAndEmail: "Bitte Name und E-Mail-Adresse eingeben, um fortzufahren.",
needsName: "Bitte Namen eingeben, um fortzufahren.",
needsEmail: "Bitte E-Mail-Adresse eingeben, um fortzufahren.",
},
en: {
checkIn: "Check-in",
checkOut: "Check-out",
guests: "Guests",
name: "Name",
email: "Email",
calculate: "Calculate price",
bookNow: "Book with deposit",
minStay: "Minimum stay",
nights: "nights",
discount: "Savings vs. short-term rate (already included)",
total: "Total",
deposit: "Deposit",
unavailable: "Not available",
loading: "Loading...",
needsCalculation: "Please calculate the price first.",
needsNameAndEmail: "Please enter your name and email address to continue.",
needsName: "Please enter your name to continue.",
needsEmail: "Please enter your email address to continue.",
},
};
return translations[locale]?.[key] || key;
};
// Re-fetch the real, effective minimum stay whenever the check-in date
// changes — Beds24's active rate (which can override the room's base
// minimum) may require a different length of stay depending on the season.
useEffect(() => {
if (!start) return;
let cancelled = false;
setMinStayLoading(true);
fetch(`/api/min-stay?apartmentSlug=${apartment.slug}&arrival=${start}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled && typeof data.minStayNights === "number") {
setMinStayNights(data.minStayNights);
setEnd((currentEnd) =>
currentEnd && currentEnd < addDays(start, data.minStayNights) ? "" : currentEnd
);
}
})
.catch(() => {
/* keep previous value on error */
})
.finally(() => {
if (!cancelled) setMinStayLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [start, apartment.slug]);
async function calculatePrice() {
if (!start || !end) return;
setLoading(true);
setError("");
try {
const res = await fetch(
`/api/availability?apartmentSlug=${apartment.slug}&start=${start}&end=${end}`
);
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to calculate price");
}
setPrice(data.price);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
setPrice(null);
} finally {
setLoading(false);
}
}
async function handleBooking() {
if (!price || !guestName || !guestEmail) {
setAttemptedSubmit(true);
return;
}
setAttemptedSubmit(false);
setLoading(true);
setError("");
try {
const res = await fetch("/api/checkout-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apartmentSlug: apartment.slug,
start,
end,
guestName,
guestEmail,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to create checkout session");
}
if (data.url) {
router.push(data.url);
} else {
throw new Error("No checkout URL returned");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}
return (
<div className="bg-white rounded-2xl p-6 border border-brand-grey-soft shadow-sm">
<h3 className="text-xl font-bold mb-1">
{locale === "de" ? "Buchungsanfrage" : "Booking request"}
</h3>
<p className="text-sm text-brand-grey mb-4">
{t("minStay")}:{" "}
<span className="font-semibold text-brand-grey-dark">
{minStayLoading
? locale === "de"
? "wird geprüft..."
: "checking..."
: `${minStayNights} ${t("nights")}`}
</span>
</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("checkIn")}
</label>
<div className="relative">
<Calendar className="absolute left-3 top-3 w-4 h-4 text-brand-grey-light" />
<input
type="date"
lang={locale === "de" ? "de-DE" : "en-US"}
value={start}
onChange={(e) => {
setStart(e.target.value);
// The checkout date's validity (and the min-stay value shown
// below) is re-checked by the useEffect above once we know
// the real minimum stay for this new check-in date.
setPrice(null);
}}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
<p className="text-xs text-brand-grey-light mt-1">
{start
? new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", {
dateStyle: "medium",
}).format(new Date(`${start}T00:00:00`))
: locale === "de"
? "Format: TT.MM.JJJJ"
: "Format: MM/DD/YYYY"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("checkOut")}
</label>
<div className="relative">
<Calendar className="absolute left-3 top-3 w-4 h-4 text-brand-grey-light" />
<input
type="date"
lang={locale === "de" ? "de-DE" : "en-US"}
value={end}
min={start ? addDays(start, minStayNights) : undefined}
onChange={(e) => {
setEnd(e.target.value);
setPrice(null);
}}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
<p className="text-xs text-brand-grey-light mt-1">
{end
? new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", {
dateStyle: "medium",
}).format(new Date(`${end}T00:00:00`))
: locale === "de"
? "Format: TT.MM.JJJJ"
: "Format: MM/DD/YYYY"}
</p>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("guests")}
</label>
<div className="relative">
<Users className="absolute left-3 top-3 w-4 h-4 text-brand-grey-light" />
<input
type="number"
min={1}
max={apartment.maxGuests}
defaultValue={1}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("name")}
</label>
<input
type="text"
value={guestName}
onChange={(e) => {
setGuestName(e.target.value);
if (e.target.value) setAttemptedSubmit(false);
}}
className={`w-full px-3 py-2 rounded-lg border focus:ring-1 outline-none ${
attemptedSubmit && !guestName
? "border-red-400 focus:border-red-500 focus:ring-red-400"
: "border-brand-grey-soft focus:border-brand-green focus:ring-brand-green"
}`}
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{t("email")}
</label>
<input
type="email"
value={guestEmail}
onChange={(e) => {
setGuestEmail(e.target.value);
if (e.target.value) setAttemptedSubmit(false);
}}
className={`w-full px-3 py-2 rounded-lg border focus:ring-1 outline-none ${
attemptedSubmit && !guestEmail
? "border-red-400 focus:border-red-500 focus:ring-red-400"
: "border-brand-grey-soft focus:border-brand-green focus:ring-brand-green"
}`}
/>
</div>
<button
onClick={calculatePrice}
disabled={!start || !end || loading}
className={`w-full mb-4 text-white font-semibold py-3 rounded-full transition-colors disabled:opacity-50 ${
attemptedSubmit && !price
? "bg-red-500 hover:bg-red-600 ring-2 ring-red-300"
: "bg-brand-grey hover:bg-brand-grey-dark"
}`}
>
{loading ? t("loading") : t("calculate")}
</button>
{price && (
<div className="bg-brand-grey-soft rounded-xl p-4 mb-4">
<div className="flex justify-between mb-2">
<span className="text-brand-grey">
{price.nights} {t("nights")}
</span>
<span className="font-medium">
{new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(price.totalAfterDiscount)}
</span>
</div>
{price.discountPercent > 0 && (
<div className="flex justify-between mb-2 text-brand-green">
<span>{t("discount")}</span>
<span>{price.discountPercent}%</span>
</div>
)}
<div className="flex justify-between text-lg font-bold text-brand-grey-dark">
<span>{t("deposit")}</span>
<span>
{new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(price.depositAmount)}
</span>
</div>
</div>
)}
{error && (
<div className="bg-red-50 text-red-600 rounded-lg p-3 mb-4 text-sm">
{error}
</div>
)}
<button
onClick={handleBooking}
disabled={loading}
className={`w-full font-semibold py-3 rounded-full transition-colors disabled:opacity-50 ${
price && guestName && guestEmail
? "bg-brand-green text-white hover:bg-brand-green-dark"
: "bg-brand-grey-soft text-brand-grey-dark hover:bg-brand-grey-soft/70"
}`}
>
{loading ? t("loading") : t("bookNow")}
</button>
{!loading && (!price || !guestName || !guestEmail) && (
<p
className={`text-lg font-semibold rounded-lg px-4 py-3 mt-3 text-center ${
attemptedSubmit
? "bg-red-50 text-red-600 border-2 border-red-300"
: "bg-brand-green/10 text-brand-green-dark"
}`}
>
{!price
? t("needsCalculation")
: !guestName && !guestEmail
? t("needsNameAndEmail")
: !guestName
? t("needsName")
: t("needsEmail")}
</p>
)}
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import Image from "next/image";
interface HeroProps {
title: string;
subtitle: string;
description: string;
cta: string;
ctaHref: string;
}
const heroImages = [
{ src: "/images/heroshots/MKE_1996.JPG", alt: "BB-Apartment Hero 1" },
{ src: "/images/heroshots/MKE_1959.JPG", alt: "BB-Apartment Hero 2" },
{ src: "/images/heroshots/MKE_1974.jpg", alt: "BB-Apartment Hero 3" },
];
export function Hero({ title, subtitle, description, cta, ctaHref }: HeroProps) {
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setActiveIndex((prev) => (prev + 1) % heroImages.length);
}, 5000);
return () => clearInterval(interval);
}, []);
return (
<section className="relative -mt-20 pt-20 h-screen flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 z-0">
{heroImages.map((image, index) => (
<div
key={image.src}
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
index === activeIndex ? "opacity-100" : "opacity-0"
}`}
>
<Image
src={image.src}
alt={image.alt}
fill
className="object-cover"
priority={index === 0}
/>
</div>
))}
<div className="absolute inset-0 bg-black/20" />
</div>
<div className="relative z-10 max-w-5xl mx-auto px-4 sm:px-6 text-center text-white">
<p className="text-lg md:text-xl text-brand-green-light mb-4 font-medium tracking-wide uppercase">
{subtitle}
</p>
<h1 className="text-5xl md:text-7xl font-bold mb-10 text-white tracking-tight">
{title}
</h1>
{description && (
<p className="text-lg md:text-2xl text-white/90 mb-10 max-w-3xl mx-auto leading-relaxed">
{description}
</p>
)}
<Link
href={ctaHref}
className="inline-flex items-center gap-2 bg-brand-green text-white font-semibold px-8 py-4 rounded-2xl text-lg hover:bg-brand-green-dark hover:shadow-lg hover:-translate-y-0.5 transition-all"
>
{cta}
</Link>
</div>
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-20 flex gap-2">
{heroImages.map((_, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={`w-2.5 h-2.5 rounded-full transition-all ${
index === activeIndex
? "bg-white scale-110"
: "bg-white/50 hover:bg-white/80"
}`}
aria-label={`Zum Hero-Bild ${index + 1} wechseln`}
/>
))}
</div>
</section>
);
}
+98
View File
@@ -0,0 +1,98 @@
import Link from "next/link";
import Image from "next/image";
import { House } from "@/src/config/apartments";
import { MapPin, Home, ArrowRight, Wind, Car, Bus, Sofa } from "lucide-react";
interface HouseCardProps {
house: House;
locale: string;
}
export function HouseCard({ house, locale }: HouseCardProps) {
const displayName = locale === "de" ? house.name : house.nameEn;
const displayDescription =
locale === "de" ? house.description : house.descriptionEn;
const displayCountry = locale === "de" ? house.country : house.countryEn;
return (
<div className="group h-full bg-white rounded-3xl overflow-hidden shadow-sm border border-brand-grey-soft hover:shadow-2xl hover:shadow-brand-grey-dark/10 hover:-translate-y-2 transition-all duration-300 flex flex-col">
<div className="relative h-72 overflow-hidden flex-shrink-0">
<Image
src={house.images[0] || "/images/placeholder.jpg"}
alt={displayName}
fill
className="object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute top-4 left-4">
<span className="inline-flex items-center gap-1.5 bg-white/90 backdrop-blur-sm text-brand-green-dark text-sm font-semibold px-3 py-1.5 rounded-full">
<Home className="w-4 h-4" />
{house.apartments.length} {locale === "de" ? "Apartments" : "Apartments"}
</span>
</div>
</div>
<div className="p-7 flex flex-col flex-grow">
<h3 className="text-2xl font-bold mb-3 text-brand-grey-dark group-hover:text-brand-green transition-colors">
{displayName}
</h3>
<div className="flex flex-wrap gap-2 mb-4">
{house.id === "herdweg" ? (
<>
<span className="inline-flex items-center gap-2 text-sm font-bold text-white bg-brand-green px-3 py-1.5 rounded-full shadow-sm">
<Car className="w-5 h-5 flex-shrink-0" />
<div className="flex flex-col leading-tight">
<span>
{locale === "de" ? "Gleiche Straße wie Panzer Kaserne" : "Same Street as Panzer Kaserne"}
</span>
<span className="text-xs font-medium text-white/85">
{locale === "de" ? "Kein Abbiegen nötig" : "Zero turns needed"}
</span>
</div>
</span>
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-brand-green-dark bg-brand-green/10 px-3 py-1.5 rounded-full">
<Wind className="w-4 h-4" />
{locale === "de" ? "Klimaanlage" : "Air conditioning"}
</span>
</>
) : (
<>
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-brand-green-dark bg-brand-green/10 px-3 py-1.5 rounded-full">
<MapPin className="w-4 h-4" />
{locale === "de" ? "Nahe Stadtzentrum" : "Close to city center"}
</span>
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-brand-green-dark bg-brand-green/10 px-3 py-1.5 rounded-full">
<Wind className="w-4 h-4" />
{locale === "de" ? "Klimaanlage" : "Air conditioning"}
</span>
</>
)}
{house.id === "herdweg" && (
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-brand-green-dark bg-brand-green/10 px-3 py-1.5 rounded-full">
<Bus className="w-4 h-4" />
{locale === "de" ? "Bus in der Nähe" : "Bus nearby"}
</span>
)}
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-brand-green-dark bg-brand-green/10 px-3 py-1.5 rounded-full">
<Sofa className="w-4 h-4" />
{locale === "de" ? "Vollmöbliert" : "Fully furnished"}
</span>
</div>
<p className="text-brand-grey mb-5 leading-relaxed flex-grow">
{displayDescription}
</p>
<div className="flex items-center gap-2 text-sm text-brand-grey mb-6">
<MapPin className="w-4 h-4 text-brand-green" />
<span>
{house.city}, {displayCountry}
</span>
</div>
<Link
href={`/${locale}/houses/${house.slug}`}
className="mt-auto inline-flex items-center justify-center gap-2 w-full bg-brand-green text-white font-semibold px-6 py-3.5 rounded-2xl hover:bg-brand-green-dark hover:shadow-lg transition-all"
>
{locale === "de" ? "Mehr erfahren" : "Learn more"}
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { Wind, Car, Bus, MapPin, Sofa } from "lucide-react";
import { House } from "@/src/config/apartments";
interface HouseHighlightsProps {
house: House;
locale: string;
}
export function HouseHighlights({ house, locale }: HouseHighlightsProps) {
const isHerdweg = house.id === "herdweg";
const highlights = isHerdweg
? [
{
icon: Car,
title: locale === "de" ? "Gleiche Straße wie Panzer Kaserne" : "Same Street as Panzer Kaserne",
text: locale === "de" ? "Kein Abbiegen nötig" : "Zero turns needed",
highlight: true,
},
{
icon: Bus,
title: locale === "de" ? "Bushaltestelle" : "Bus stop",
text: locale === "de" ? "In der Nähe" : "Nearby",
},
{
icon: Wind,
title: locale === "de" ? "Klimaanlage" : "Air conditioning",
text: locale === "de" ? "In den meisten Apartments" : "In most apartments",
},
{
icon: Sofa,
title: locale === "de" ? "Vollmöbliert" : "Fully furnished",
text: locale === "de" ? "Alle Apartments" : "All apartments",
},
]
: [
{
icon: MapPin,
title: locale === "de" ? "Stadtzentrum" : "City center",
text: locale === "de" ? "Nahe gelegen" : "Close by",
},
{
icon: Car,
title: locale === "de" ? "Daimler Werk Sindelfingen" : "Daimler plant Sindelfingen",
text: locale === "de" ? "Leicht erreichbar" : "Easy to reach",
},
{
icon: Wind,
title: locale === "de" ? "Klimaanlage" : "Air conditioning",
text: locale === "de" ? "In allen Apartments" : "In all apartments",
},
{
icon: Sofa,
title: locale === "de" ? "Vollmöbliert" : "Fully furnished",
text: locale === "de" ? "Alle Apartments" : "All apartments",
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5 mb-12">
{highlights.map((highlight, index) => {
const Icon = highlight.icon;
return (
<div
key={index}
className={`flex flex-col items-center text-center gap-4 p-6 rounded-2xl shadow-md hover:shadow-xl hover:-translate-y-1 transition-all ${
highlight.highlight
? "bg-brand-green border-2 border-brand-green text-white"
: "bg-white border-2 border-brand-green/20"
}`}
>
<div
className={`p-4 rounded-2xl shadow-sm ${
highlight.highlight ? "bg-white text-brand-green" : "bg-brand-green text-white"
}`}
>
<Icon className="w-9 h-9" />
</div>
<div>
<p
className={`font-bold text-xl leading-tight mb-1 ${
highlight.highlight ? "text-white" : "text-brand-grey-dark"
}`}
>
{highlight.title}
</p>
<p className={`text-base font-medium ${highlight.highlight ? "text-white/90" : "text-brand-grey"}`}>
{highlight.text}
</p>
</div>
</div>
);
})}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface ImageGalleryProps {
images: string[];
alt: string;
}
export function ImageGallery({ images, alt }: ImageGalleryProps) {
const [activeIndex, setActiveIndex] = useState(0);
const hasImages = images.length > 0;
const handlePrevious = () => {
setActiveIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
};
const handleNext = () => {
setActiveIndex((prev) => (prev + 1) % images.length);
};
return (
<div className="space-y-4">
<div className="relative h-80 md:h-[500px] rounded-2xl overflow-hidden bg-brand-grey-soft">
{hasImages && (
<Image
src={images[activeIndex]}
alt={`${alt} - ${activeIndex + 1}`}
fill
className="object-cover"
/>
)}
{images.length > 1 && (
<>
<button
onClick={handlePrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-2 bg-white/80 rounded-full hover:bg-white transition-colors"
aria-label="Vorheriges Bild"
>
<ChevronLeft className="w-5 h-5 text-brand-grey-dark" />
</button>
<button
onClick={handleNext}
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-2 bg-white/80 rounded-full hover:bg-white transition-colors"
aria-label="Nächstes Bild"
>
<ChevronRight className="w-5 h-5 text-brand-grey-dark" />
</button>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex gap-1.5">
{images.map((_, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={`w-2 h-2 rounded-full transition-all ${
index === activeIndex
? "bg-white scale-110"
: "bg-white/50 hover:bg-white/80"
}`}
aria-label={`Zu Bild ${index + 1} wechseln`}
/>
))}
</div>
</>
)}
</div>
{images.length > 1 && (
<div className="flex gap-3 overflow-x-auto pb-2">
{images.map((image, index) => (
<button
key={image}
onClick={() => setActiveIndex(index)}
className={`relative w-20 h-20 flex-shrink-0 rounded-xl overflow-hidden ${
index === activeIndex
? "ring-2 ring-brand-green"
: "ring-2 ring-transparent opacity-70 hover:opacity-100"
}`}
>
<Image
src={image}
alt={`${alt} - Vorschau ${index + 1}`}
fill
className="object-cover"
/>
</button>
))}
</div>
)}
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Image from "next/image";
const tilePools = [
[
"/images/houses/herdweg/impressions/11.png",
"/images/houses/herdweg/impressions/MKE_1986.JPG",
"/images/houses/herdweg/impressions/MKE_2042.JPG",
],
[
"/images/houses/herdweg/impressions/MKE_1974.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",
"/images/houses/herdweg/impressions/MKE_2038.JPG",
],
];
function nextFromPool(pool: string[], exclude: string) {
const filtered = pool.filter((src) => src !== exclude);
if (filtered.length === 0) return pool[0];
return filtered[Math.floor(Math.random() * filtered.length)];
}
interface ImpressionsTileProps {
pool: string[];
initialSrc: string;
className?: string;
}
function ImpressionsTile({ pool, initialSrc, className = "" }: ImpressionsTileProps) {
const [current, setCurrent] = useState(initialSrc);
const [visible, setVisible] = useState(true);
const rotate = useCallback(() => {
setVisible(false);
setTimeout(() => {
setCurrent((prev) => nextFromPool(pool, prev));
setVisible(true);
}, 700);
}, [pool]);
useEffect(() => {
let timeout: NodeJS.Timeout;
const schedule = () => {
const delay = 6000 + Math.random() * 6000;
timeout = setTimeout(() => {
rotate();
schedule();
}, delay);
};
schedule();
return () => clearTimeout(timeout);
}, [rotate]);
return (
<div
className={`relative overflow-hidden rounded-2xl shadow-md hover:shadow-xl transition-shadow ${className}`}
>
<Image
src={current}
alt="Impression"
fill
className={`object-cover transition-all duration-700 ease-in-out ${
visible
? "opacity-100 translate-x-0"
: "opacity-0 -translate-x-8"
}`}
sizes="(max-width: 768px) 100vw, 33vw"
/>
</div>
);
}
interface ImpressionsProps {
title: string;
subtitle: string;
}
export function Impressions({ title, subtitle }: ImpressionsProps) {
return (
<section className="py-24 bg-brand-grey-soft/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<p className="text-brand-green font-semibold mb-2 uppercase tracking-wider text-sm">
{subtitle}
</p>
<h2 className="text-4xl font-bold mb-4 text-brand-grey-dark">
{title}
</h2>
<div className="w-20 h-1 bg-brand-green mx-auto rounded-full" />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 auto-rows-[220px]">
<ImpressionsTile
pool={tilePools[0]}
initialSrc={tilePools[0][0]}
className="md:col-span-2 md:row-span-2"
/>
<ImpressionsTile pool={tilePools[1]} initialSrc={tilePools[1][0]} />
<ImpressionsTile pool={tilePools[2]} initialSrc={tilePools[2][0]} />
<ImpressionsTile
pool={tilePools[3]}
initialSrc={tilePools[3][0]}
className="md:col-span-3"
/>
</div>
</div>
</section>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import { Mail, Send, CheckCircle2 } from "lucide-react";
interface NoAvailabilityContactProps {
locale: string;
/** e.g. the searched arrival/departure dates, sent along for context. */
context?: string;
}
export function NoAvailabilityContact({ locale, context }: NoAvailabilityContactProps) {
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [sent, setSent] = useState(false);
const isDe = locale === "de";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || !message) return;
setLoading(true);
setError("");
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, message, context }),
});
if (!res.ok) {
throw new Error("Failed to send message");
}
setSent(true);
} catch {
setError(
isDe
? "Ihre Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es erneut oder schreiben Sie uns direkt an info@bb-apartment.de."
: "Your message could not be sent. Please try again or email us directly at info@bb-apartment.de."
);
} finally {
setLoading(false);
}
}
if (sent) {
return (
<div className="bg-brand-green/10 border-2 border-brand-green/30 rounded-2xl p-8 text-center max-w-xl mx-auto">
<CheckCircle2 className="w-10 h-10 text-brand-green mx-auto mb-3" />
<h3 className="text-xl font-bold text-brand-grey-dark mb-2">
{isDe ? "Nachricht gesendet!" : "Message sent!"}
</h3>
<p className="text-brand-grey">
{isDe
? "Danke für Ihre Anfrage. Wir melden uns so schnell wie möglich bei Ihnen."
: "Thank you for reaching out. We'll get back to you as soon as possible."}
</p>
</div>
);
}
return (
<div className="bg-white border-2 border-brand-grey-soft rounded-2xl p-8 max-w-xl mx-auto">
<div className="flex items-center gap-3 mb-2">
<Mail className="w-6 h-6 text-brand-green" />
<h3 className="text-xl font-bold text-brand-grey-dark">
{isDe ? "Kein passendes Apartment gefunden?" : "Can't find what you're looking for?"}
</h3>
</div>
<p className="text-brand-grey mb-6">
{isDe
? "Schreiben Sie uns kurz Ihre Wunschtermine und Anforderungen — wir melden uns persönlich bei Ihnen, auch für individuelle Anfragen oder Wartelisten."
: "Tell us your preferred dates and requirements — we'll get back to you personally, including for custom requests or waitlists."}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{isDe ? "E-Mail" : "Email"}
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-brand-grey-dark mb-1">
{isDe ? "Ihre Nachricht" : "Your message"}
</label>
<textarea
required
rows={5}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={
isDe
? "z.B. gewünschter Zeitraum, Anzahl Personen, besondere Wünsche..."
: "e.g. preferred dates, number of guests, special requests..."
}
className="w-full px-4 py-3 rounded-xl border border-brand-grey-soft focus:border-brand-green focus:ring-1 focus:ring-brand-green outline-none"
/>
</div>
{error && (
<div className="bg-red-50 text-red-600 rounded-lg p-3 text-sm">{error}</div>
)}
<button
type="submit"
disabled={loading}
className="inline-flex items-center justify-center gap-2 w-full bg-brand-green text-white font-semibold px-8 py-4 rounded-full hover:bg-brand-green-dark transition-colors disabled:opacity-50"
>
{loading ? (isDe ? "Wird gesendet..." : "Sending...") : isDe ? "Nachricht senden" : "Send message"}
<Send className="w-4 h-4" />
</button>
</form>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
version: "3.8"
services:
postgres:
image: postgres:16-alpine
container_name: bb-apartment-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-bbapartment}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
POSTGRES_DB: ${POSTGRES_DB:-bb_apartment}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- bb-apartment-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-bbapartment} -d ${POSTGRES_DB:-bb_apartment}"]
interval: 10s
timeout: 5s
retries: 5
nextjs:
build:
context: .
dockerfile: Dockerfile
container_name: bb-apartment-nextjs
env_file:
- .env.local
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-bbapartment}:${POSTGRES_PASSWORD:-changeme}@postgres:5432/${POSTGRES_DB:-bb_apartment}
ports:
- "${NEXTJS_HOST_PORT:-3000}:3000"
depends_on:
postgres:
condition: service_healthy
networks:
- bb-apartment-network
n8n:
image: n8nio/n8n:latest
container_name: bb-apartment-n8n
env_file:
- .env.local
environment:
- N8N_BASIC_AUTH_ACTIVE=true
- N8N_BASIC_AUTH_USER=${N8N_BASIC_AUTH_USER:-admin}
- N8N_BASIC_AUTH_PASSWORD=${N8N_BASIC_AUTH_PASSWORD:-changeme}
- WEBHOOK_URL=${N8N_WEBHOOK_URL:-https://n8n.bb-apartment.de/}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB:-bb_apartment}
- DB_POSTGRESDB_USER=${POSTGRES_USER:-bbapartment}
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD:-changeme}
volumes:
- n8n-data:/home/node/.n8n
ports:
- "${N8N_HOST_PORT:-5678}:5678"
depends_on:
postgres:
condition: service_healthy
networks:
- bb-apartment-network
volumes:
postgres-data:
n8n-data:
networks:
bb-apartment-network:
driver: bridge
Vendored
+29
View File
@@ -0,0 +1,29 @@
declare module "*.css" {
const content: { [className: string]: string };
export default content;
}
declare module "*.jpg" {
const content: string;
export default content;
}
declare module "*.jpeg" {
const content: string;
export default content;
}
declare module "*.png" {
const content: string;
export default content;
}
declare module "*.svg" {
const content: string;
export default content;
}
declare module "*.webp" {
const content: string;
export default content;
}
+17
View File
@@ -0,0 +1,17 @@
import { getRequestConfig } from "next-intl/server";
import { notFound } from "next/navigation";
export const locales = ["de", "en"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "de";
export default getRequestConfig(async ({ requestLocale }) => {
const locale = await requestLocale;
if (!locale || !locales.includes(locale as Locale)) notFound();
return {
locale: locale as string,
messages: (await import(`./messages/${locale}.json`)).default,
};
});
+115
View File
@@ -0,0 +1,115 @@
{
"metadata": {
"title": "BB-Apartment – Willkommen zu Hause",
"description": "Moderne Apartments für längere Aufenthalte in Böblingen. 8 Apartments in 2 Häusern. Ideal für Ingenieure und Angehörige der US Army."
},
"navigation": {
"home": "Startseite",
"houses": "Häuser",
"contact": "Kontakt",
"bookNow": "Jetzt buchen"
},
"home": {
"hero": {
"title": "BB-Apartment",
"subtitle": "Willkommen zu Hause",
"description": "",
"cta": "Apartments entdecken"
},
"houses": {
"title": "Unsere Häuser",
"subtitle": "Wählen Sie zwischen zwei Standorten in Böblingen"
},
"impressions": {
"title": "Impressionen",
"subtitle": "Einblicke in BB-Apartment"
},
"features": {
"title": "Warum BB-Apartment?",
"longStay": "Langzeitaufenthalte (1–12+ Monate)",
"location": "US-Militärbasen & Firmen",
"airConditioning": "Klimaanlage",
"furnished": "Vollmöbliert & sofort bezugsfertig",
"payment": "Flexible Zahlung per Karte oder Rechnung"
},
"quickStats": {
"apartments": {
"title": "8 Apartments",
"subtitle": "in 2 Häusern in Böblingen"
},
"stay": {
"title": "Langzeitaufenthalte",
"subtitle": "ab 20 Nächten buchbar"
}
},
"ctaSection": {
"title": "Steht Ihre Versetzung nach Stuttgart an oder planen Sie einen längeren Geschäftsaufenthalt?",
"description": "Finden Sie Ihre Premium-Home-Base in Böblingen. Fordern Sie noch heute Ihr maßgeschneidertes Angebot an.",
"cta": "Angebot anfordern"
},
"bookingBanner": {
"arrival": "Anreise",
"stayMonths": "Aufenthaltsmonate",
"checkAvailability": "Verfügbarkeit prüfen",
"bookNow": "Jetzt buchen"
}
},
"houses": {
"overview": "Übersicht",
"apartments": "Apartments",
"viewApartment": "Apartment ansehen",
"book": "Buchen",
"search": {
"arrival": "Anreise",
"stayMonths": "Aufenthaltsmonate",
"checkAvailability": "Verfügbarkeit prüfen",
"resultsTitle": "Verfügbare Apartments",
"resultsSubtitle": "Ergebnisse für Ihre Anreise",
"available": "Verfügbar",
"notAvailable": "Nicht verfügbar",
"minStayNotMet": "Mindestaufenthalt nicht erreicht",
"bookThis": "Jetzt anfragen",
"perNight": "pro Nacht",
"showAllHouses": "Alle Häuser ansehen",
"noResults": "Für diesen Zeitraum ist derzeit kein Apartment verfügbar. Bitte versuchen Sie andere Daten."
}
},
"apartment": {
"from": "ab",
"perNight": "pro Nacht",
"minStay": "Mindestaufenthalt",
"nights": "Nächte",
"guests": "Gäste",
"features": "Ausstattung",
"gallery": "Galerie",
"location": "Lage",
"bookNow": "Jetzt anfragen",
"notFound": "Apartment nicht gefunden"
},
"contact": {
"title": "Kontakt",
"description": "Schreiben Sie uns – wir melden uns schnellstmöglich.",
"name": "Name",
"email": "E-Mail",
"message": "Nachricht",
"send": "Senden",
"phone": "Telefon"
},
"footer": {
"rights": "Alle Rechte vorbehalten.",
"imprint": "Impressum",
"privacy": "Datenschutz",
"welcome": "Willkommen zu Hause. Komfortable Apartments für längere Aufenthalte in Böblingen.",
"brandTitle": "BB-Apartment",
"locationsTitle": "Standorte",
"herdwegName": "Herdweg",
"herdwegAddress": "Herdweg 71, 71032 Böblingen",
"schoenaicherName": "Schönaicher Straße",
"schoenaicherAddress": "Schönaicher Straße 26, 71032 Böblingen",
"contactTitle": "Kontakt",
"email": "info@bb-apartment.de",
"phone": "+49 (0) 7031 7631132",
"web": "www.BB-Apartment.de",
"legalTitle": "Rechtliches"
}
}
+115
View File
@@ -0,0 +1,115 @@
{
"metadata": {
"title": "BB-Apartment – Welcome Home",
"description": "Modern apartments for longer stays in Böblingen. 8 apartments in 2 houses. Ideal for engineers and US Army affiliates."
},
"navigation": {
"home": "Home",
"houses": "Houses",
"contact": "Contact",
"bookNow": "Book now"
},
"home": {
"hero": {
"title": "BB-Apartment",
"subtitle": "Welcome Home",
"description": "",
"cta": "Discover apartments"
},
"houses": {
"title": "Our Houses",
"subtitle": "Choose between two locations in Böblingen"
},
"impressions": {
"title": "Impressions",
"subtitle": "A glimpse of BB-Apartment"
},
"features": {
"title": "Why BB-Apartment?",
"longStay": "Extended Stays (1–12+ Months)",
"location": "US Military Bases & Corporate",
"airConditioning": "Air conditioning",
"furnished": "Fully furnished & ready to move in",
"payment": "Flexible Payment by Card or invoice"
},
"quickStats": {
"apartments": {
"title": "8 Apartments",
"subtitle": "in 2 houses in Böblingen"
},
"stay": {
"title": "Long-term stays",
"subtitle": "bookable from 20 nights"
}
},
"ctaSection": {
"title": "PCSing to Stuttgart or traveling for business?",
"description": "Find your premium home base in Böblingen. Request your custom quote today.",
"cta": "Request a Quote"
},
"bookingBanner": {
"arrival": "Arrival",
"stayMonths": "Stay months",
"checkAvailability": "Check availability",
"bookNow": "Book now"
}
},
"houses": {
"overview": "Overview",
"apartments": "Apartments",
"viewApartment": "View apartment",
"book": "Book",
"search": {
"arrival": "Arrival",
"stayMonths": "Stay months",
"checkAvailability": "Check availability",
"resultsTitle": "Available Apartments",
"resultsSubtitle": "Results for your arrival",
"available": "Available",
"notAvailable": "Not available",
"minStayNotMet": "Minimum stay not met",
"bookThis": "Request now",
"perNight": "per night",
"showAllHouses": "View all houses",
"noResults": "No apartments are available for this period right now. Please try different dates."
}
},
"apartment": {
"from": "from",
"perNight": "per night",
"minStay": "Minimum stay",
"nights": "nights",
"guests": "guests",
"features": "Amenities",
"gallery": "Gallery",
"location": "Location",
"bookNow": "Request now",
"notFound": "Apartment not found"
},
"contact": {
"title": "Contact",
"description": "Write us – we will get back to you as soon as possible.",
"name": "Name",
"email": "Email",
"message": "Message",
"send": "Send",
"phone": "Phone"
},
"footer": {
"rights": "All rights reserved.",
"imprint": "Imprint",
"privacy": "Privacy",
"welcome": "Welcome home. Comfortable apartments for longer stays in Böblingen.",
"brandTitle": "BB-Apartment",
"locationsTitle": "Locations",
"herdwegName": "Herdweg",
"herdwegAddress": "Herdweg 71, 71032 Böblingen",
"schoenaicherName": "Schönaicher Straße",
"schoenaicherAddress": "Schönaicher Straße 26, 71032 Böblingen",
"contactTitle": "Contact",
"email": "info@bb-apartment.de",
"phone": "+49 (0)7031 7631132",
"web": "www.BB-Apartment.de",
"legalTitle": "Legal"
}
}
+12
View File
@@ -0,0 +1,12 @@
import createMiddleware from "next-intl/middleware";
import { locales, defaultLocale } from "./i18n";
export default createMiddleware({
locales,
defaultLocale,
localePrefix: "always",
});
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)"],
};
+13 -3
View File
@@ -1,4 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
import createNextIntlPlugin from "next-intl/plugin";
export default nextConfig;
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
images: {
unoptimized: true,
},
allowedDevOrigins: ["127.0.0.1"],
};
const withNextIntl = createNextIntlPlugin("./i18n.ts");
export default withNextIntl(nextConfig);
+258 -4
View File
@@ -8,12 +8,19 @@
"name": "bb-apartment-website",
"version": "0.1.0",
"dependencies": {
"@prisma/client": "^5.22.0",
"lucide-react": "^0.460.0",
"next": "14.2.35",
"next-intl": "^3.26.5",
"nodemailer": "^9.0.5",
"prisma": "^5.22.0",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"stripe": "^22.4.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
@@ -133,6 +140,66 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@formatjs/ecma402-abstract": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
"integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "2.2.7",
"@formatjs/intl-localematcher": "0.6.2",
"decimal.js": "^10.4.3",
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
"integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/fast-memoize": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/icu-messageformat-parser": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
"integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
"license": "MIT",
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"@formatjs/icu-skeleton-parser": "1.8.16",
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/icu-skeleton-parser": {
"version": "1.8.16",
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
"integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
"license": "MIT",
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz",
"integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==",
"license": "MIT",
"dependencies": {
"tslib": "2"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -498,6 +565,69 @@
"node": ">=14"
}
},
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.13"
},
"peerDependencies": {
"prisma": "*"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
}
}
},
"node_modules/@prisma/debug": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -550,12 +680,22 @@
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1867,6 +2007,12 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2778,7 +2924,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -3200,6 +3345,18 @@
"node": ">= 0.4"
}
},
"node_modules/intl-messageformat": {
"version": "10.7.18",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",
"integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==",
"license": "BSD-3-Clause",
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"@formatjs/fast-memoize": "2.2.7",
"@formatjs/icu-messageformat-parser": "2.11.4",
"tslib": "^2.8.0"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -3897,6 +4054,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/lucide-react": {
"version": "0.460.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz",
"integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4024,6 +4190,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/next": {
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
@@ -4074,6 +4249,27 @@
}
}
},
"node_modules/next-intl": {
"version": "3.26.5",
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-3.26.5.tgz",
"integrity": "sha512-EQlCIfY0jOhRldiFxwSXG+ImwkQtDEfQeSOEQp6ieAGSLWGlgjdb/Ck/O7wMfC430ZHGeUKVKax8KGusTPKCgg==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"negotiator": "^1.0.0",
"use-intl": "^3.26.5"
},
"peerDependencies": {
"next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -4131,6 +4327,15 @@
"semver": "bin/semver.js"
}
},
"node_modules/nodemailer": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -4664,6 +4869,25 @@
"node": ">= 0.8.0"
}
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.22.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=16.13"
},
"optionalDependencies": {
"fsevents": "2.3.3"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5434,6 +5658,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "22.4.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.4.0.tgz",
"integrity": "sha512-LVJ+tcSYeqOSnXr3i+Kz2tZ7y0crLLdP2uwD/4wccrmEVyn0g/Heo0pF7as7rxS/sOjJzrb1lxgZY0Y0Dx1pSA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
@@ -5837,7 +6078,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -5888,6 +6129,19 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-intl": {
"version": "3.26.5",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.26.5.tgz",
"integrity": "sha512-OdsJnC/znPvHCHLQH/duvQNXnP1w0hPfS+tkSi3mAbfjYBGh4JnyfdwkQBfIVf7t8gs9eSX/CntxUMvtKdG2MQ==",
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "^2.2.0",
"intl-messageformat": "^10.5.14"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+11 -4
View File
@@ -9,18 +9,25 @@
"lint": "next lint"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"lucide-react": "^0.460.0",
"next": "14.2.35",
"next-intl": "^3.26.5",
"nodemailer": "^9.0.5",
"prisma": "^5.22.0",
"react": "^18",
"react-dom": "^18",
"next": "14.2.35"
"stripe": "^22.4.0"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^8",
"eslint-config-next": "14.2.35"
"typescript": "^5"
}
}
+65
View File
@@ -0,0 +1,65 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Booking {
id String @id @default(cuid())
bookingReference String @unique
apartmentSlug String
houseId String
beds24PropertyId String
beds24RoomId String
guestEmail String
guestName String
checkIn DateTime
checkOut DateTime
nights Int
totalAmount Decimal @db.Decimal(10, 2)
depositAmount Decimal @db.Decimal(10, 2)
currency String @default("EUR")
status String @default("pending") // pending, deposit_paid, confirmed, cancelled
// Stripe mapping (per house account)
stripeAccountId String
stripeCustomerId String?
stripeCheckoutSessionId String?
stripePaymentIntentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
invoices Invoice[]
}
model Invoice {
id String @id @default(cuid())
bookingId String
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
invoiceNumber String @unique
amount Decimal @db.Decimal(10, 2)
currency String @default("EUR")
dueDate DateTime
status String @default("pending") // pending, paid, overdue, cancelled
// Stripe invoice mapping
stripeInvoiceId String?
stripeInvoiceUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model StripeEvent {
id String @id @default(cuid())
eventId String @unique
accountId String
type String
payload String
processedAt DateTime @default(now())
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Some files were not shown because too many files have changed in this diff Show More