Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite)

This commit is contained in:
2026-06-03 14:08:48 +02:00
committed by Ihor_Zhekov
commit bf5d79a919
94 changed files with 12480 additions and 0 deletions

27
.dockerignore Normal file
View File

@@ -0,0 +1,27 @@
node_modules
.next
.git
.gitignore
.claude
.DS_Store
*.log
# secrets — provide via Coolify env vars instead
.env
.env.local
.env.*.local
# dev DB — production DB lives on the persistent volume
prisma/dev.db
prisma/dev.db-journal
prisma/*.db
# build artefacts
tsconfig.tsbuildinfo
dist
out
# docker meta
Dockerfile
.dockerignore
README.md

28
.env.example Normal file
View File

@@ -0,0 +1,28 @@
# -------------------------------------------------------------
# Spreewaldzeit Environment Variables
# Kopiere diese Datei nach `.env` und trage echte Werte ein.
# -------------------------------------------------------------
# Datenbank (SQLite lokal; später z. B. Postgres)
DATABASE_URL="file:./dev.db"
# Basis-URL der Website (für Links in Mails)
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
# Admin-Login (einmaliger Seed-Account; Passwort wird gehasht gespeichert)
ADMIN_EMAIL="admin@spreewaldzeit.de"
ADMIN_PASSWORD="bitte-sofort-aendern"
# Secret für signierte Session-Cookies (mind. 32 Zeichen, zufällig!)
# Generieren: openssl rand -base64 48
AUTH_SECRET="change-me-to-a-long-random-string-min-32-chars"
# E-Mail-Versand (SMTP). Ohne diese Werte werden Mails nur ins Terminal geloggt.
SMTP_HOST=""
SMTP_PORT="587"
SMTP_USER=""
SMTP_PASSWORD=""
SMTP_FROM="Spreewaldzeit <noreply@spreewaldzeit.de>"
# Empfänger für eingehende Anfragen (Vermieter)
OWNER_EMAIL="vermieter@spreewaldzeit.de"

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
node_modules
.next
.env
.env.local
.env.*.local
*.log
.DS_Store
.claude
# Prisma
prisma/dev.db
prisma/dev.db-journal
prisma/*.db
# Build
dist
out
tsconfig.tsbuildinfo

58
Dockerfile Normal file
View File

@@ -0,0 +1,58 @@
# syntax=docker/dockerfile:1.7
# -------- build stage --------
FROM node:20-alpine AS build
WORKDIR /app
# Prisma needs openssl; sharp prefers libc6-compat on alpine
RUN apk add --no-cache openssl libc6-compat
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Generate Prisma client + build Next.js.
# We intentionally skip `prisma db push` here (it runs at container start
# against the persistent volume — see CMD below).
RUN npx prisma generate \
&& SKIP_ENV_VALIDATION=1 NEXT_TELEMETRY_DISABLED=1 npx next build
# Drop dev dependencies for a leaner runtime layer
RUN npm prune --omit=dev
# -------- runtime stage --------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0 \
DATABASE_URL=file:/app/data/prod.db
RUN apk add --no-cache openssl libc6-compat wget \
&& addgroup -g 1001 -S nodejs \
&& adduser -S nextjs -u 1001 \
&& mkdir -p /app/data \
&& chown -R nextjs:nodejs /app
COPY --from=build --chown=nextjs:nodejs /app/package.json /app/package-lock.json ./
COPY --from=build --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=build --chown=nextjs:nodejs /app/.next ./.next
COPY --from=build --chown=nextjs:nodejs /app/public ./public
COPY --from=build --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=build --chown=nextjs:nodejs /app/next.config.js ./
COPY --from=build --chown=nextjs:nodejs /app/middleware.ts ./middleware.ts
USER nextjs
VOLUME ["/app/data"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/ >/dev/null || exit 1
# Apply schema to the SQLite file on the persistent volume, then start Next.
CMD ["sh", "-c", "npx prisma db push --accept-data-loss --skip-generate && npx next start -H 0.0.0.0 -p 3000"]

191
README.md Normal file
View File

@@ -0,0 +1,191 @@
# Spreewaldzeit — MVP
Private Ferienwohnungs-Website mit zwei Wohnungen. Ruhig, editorial, wartungsarm.
Technik-Stack bewusst schlank gehalten — kein Hotel-PMS-Overhead, aber vorbereitet
für spätere Erweiterungen (iCal-Sync, Direktbuchung, Zahlungsabwicklung).
## Tech-Stack
- **Next.js 14** (App Router, Server Components)
- **TypeScript**, strict
- **Prisma** + **SQLite** (einfach portierbar nach Postgres/MySQL)
- **Tailwind CSS** mit eigener Naturpalette
- **React Hook Form** + **Zod** für Formulare & Validierung
- **jose** für signierte Session-Cookies (kein NextAuth-Overhead)
- **nodemailer** für Mailversand (mit Dev-Fallback ins Terminal)
- **react-day-picker** für den Verfügbarkeitskalender
## Projektstruktur
```
spreewaldzeit/
├── app/
│ ├── layout.tsx # Root-Layout + Google Fonts
│ ├── globals.css # Tailwind-Layer + globale Styles
│ ├── page.tsx # Startseite
│ ├── not-found.tsx
│ ├── wohnungen/[slug]/
│ │ └── page.tsx # Wohnungsdetail
│ ├── anfrage/
│ │ └── page.tsx # Anfrageformular
│ ├── datenschutz/page.tsx
│ ├── impressum/page.tsx
│ ├── admin/
│ │ ├── layout.tsx
│ │ ├── page.tsx # Redirect → Anfragen
│ │ ├── login/page.tsx
│ │ ├── anfragen/page.tsx # Anfragen-Liste
│ │ ├── kalender/page.tsx # Blocks sperren/freigeben
│ │ └── wohnungen/page.tsx # Wohnungen pflegen
│ └── api/
│ ├── inquiries/route.ts # POST (öffentlich)
│ ├── availability/[slug]/route.ts # GET (öffentlich)
│ └── admin/
│ ├── login/route.ts
│ ├── logout/route.ts
│ ├── inquiries/[id]/route.ts # PATCH, DELETE
│ ├── blocks/route.ts # POST
│ ├── blocks/[id]/route.ts # DELETE
│ └── apartments/[id]/route.ts # PATCH
├── components/
│ ├── layout/ (Header, Footer)
│ ├── home/ (Hero, About, ApartmentPreview, Location)
│ ├── apartment/ (Gallery, Features, AvailabilityCalendar)
│ ├── inquiry/ (InquiryForm)
│ ├── admin/ (AdminNav, InquiryRow, CalendarManager, ApartmentEditor)
│ └── ui/ (Button, Input, Label, Textarea, FieldError)
├── lib/
│ ├── db.ts # Prisma-Singleton
│ ├── auth.ts # JWT-Session via jose
│ ├── email.ts # Nodemailer + Mail-Templates
│ ├── validations.ts # Zod-Schemas
│ └── utils.ts # Preis-/Datumsformatierung
├── prisma/
│ ├── schema.prisma # Apartment, Inquiry, Block, Admin
│ └── seed.ts # 2 Wohnungen + Admin + 1 Beispiel-Block
├── types/
├── middleware.ts # schützt /admin und /api/admin
├── tailwind.config.ts
├── next.config.js
├── tsconfig.json
├── .env.example
└── package.json
```
## Setup
### 1. Abhängigkeiten installieren
```bash
npm install
```
### 2. Environment vorbereiten
```bash
cp .env.example .env
```
Dann `.env` öffnen und **mindestens** setzen:
- `AUTH_SECRET` — zufällige, mind. 32 Zeichen lange Zeichenkette
(z. B. `openssl rand -base64 48`)
- `ADMIN_EMAIL` und `ADMIN_PASSWORD` — initialer Admin-Account
- `OWNER_EMAIL` — wohin sollen neue Anfragen gehen?
Die SMTP-Variablen sind **optional**. Ohne sie werden Mails ins Terminal geloggt
(praktisch fürs lokale Entwickeln).
### 3. Datenbank + Seed
```bash
npm run setup
```
Das führt die Prisma-Migration aus und legt die zwei Beispiel-Wohnungen samt
Admin-Account an.
### 4. Dev-Server
```bash
npm run dev
```
→ [http://localhost:3000](http://localhost:3000)
→ Admin: [http://localhost:3000/admin/login](http://localhost:3000/admin/login)
## Nützliche Scripts
| Script | Zweck |
|---------------------|----------------------------------------------------|
| `npm run dev` | Dev-Server mit Hot-Reload |
| `npm run build` | Production-Build (inkl. `prisma generate + migrate`)|
| `npm run start` | Production-Server |
| `npm run db:push` | Schema ohne Migration in DB pushen (prototyping) |
| `npm run db:migrate`| Neue Migration erzeugen |
| `npm run db:seed` | Seed erneut laufen lassen |
| `npm run db:studio` | Prisma Studio öffnen (DB-Browser) |
## Design-System
- **Farben:** `parchment` (Hintergrund), `ink` (Text), `moss` & `sand` (Akzente)
- **Typografie:** *Fraunces* (Display) + *Figtree* (Body), geladen via `next/font`
- **Layout:** Container mit großzügigem Whitespace, asymmetrische Grids
- **Bildsprache:** Große Bilder, ruhige Kompositionen, dezente Hover-Zooms
Alle Tokens in `tailwind.config.ts`.
## Wichtige Designentscheidungen
### Warum SQLite?
Ein Reiseobjekt mit zwei Wohnungen erzeugt wenig Schreiblast. SQLite ist schnell,
wartungsarm und das Schema ist 1:1 auf Postgres/MySQL portierbar, sobald gebraucht.
### Warum JWT-Cookie statt NextAuth?
Für einen Admin-User + zwei Gastgeber ist NextAuth Overkill. `jose` liefert
signierte Cookies in ~80 Zeilen Code, die Middleware-kompatibel (Edge) funktionieren.
### Warum `Block.source` trotz MVP?
Damit der spätere iCal-Importer einfach zusätzliche Einträge mit
`source: "airbnb"` / `source: "booking"` schreiben kann — ohne Schema-Änderung.
### Honeypot statt Captcha
Das Anfrageformular hat ein verstecktes `website`-Feld als Bot-Falle.
Für den MVP ausreichend; falls Spam zunimmt, lässt sich hCaptcha o. ä. ergänzen.
## Erweiterungspfad
Der Code ist bewusst so strukturiert, dass die folgenden Features **additiv**
eingebaut werden können — ohne Refactoring:
### iCal-Sync (Airbnb / Booking.com)
- Cron-Job (z. B. via Vercel Cron oder externer Scheduler), der pro Wohnung
eine `ics_import_url` lädt, parst und Blocks mit `source: "airbnb"` /
`source: "booking"` anlegt. Schema muss ggf. um `Apartment.icalUrls` erweitert werden.
- Umgekehrt: GET-Endpoint `/api/ical/[slug].ics`, der alle Blocks als ICS ausliefert,
damit Airbnb/Booking wiederum eure Belegung sehen.
### Direktbuchung
- Stripe/Mollie-Integration im `/api/bookings/route.ts`.
- `Booking`-Tabelle einführen; erfolgreiche Zahlungen legen automatisch einen
`Block` mit `source: "direct"` an.
### Mehr Inhalte
- Tagespreise / Saisonpreise (neues `PriceRule`-Modell).
- Blog/Storys (neues `Post`-Modell + `/journal`-Route).
### Bildupload
- Aktuell pflegt der Editor Bilder als URLs. Für echtes Hochladen:
S3/R2/Cloudinary-Adapter in `lib/storage.ts` und Drag-and-Drop-Komponente
im `ApartmentEditor`.
## DSGVO-Notizen
- Keine Marketing-Cookies, kein Tracking.
- Nur technisch notwendige Session-Cookies (httpOnly, SameSite=Lax, Secure in Prod).
- Anfragen werden mit ausdrücklicher Einwilligung verarbeitet (Checkbox im Formular).
- Datenschutz- und Impressums-Seite als Platzhalter angelegt — **vor Go-Live rechtlich prüfen lassen**.
## Lizenz
Privat / intern. Anpassen wie benötigt.

View File

@@ -0,0 +1,99 @@
import { prisma } from "@/lib/db";
import { InquiryRow, type InquiryRowData } from "@/components/admin/InquiryRow";
import type { InquiryStatus } from "@/types";
import { INQUIRY_STATUS_LABELS } from "@/types";
import Link from "next/link";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic";
interface PageProps {
searchParams: { status?: string };
}
const FILTERS: Array<{ key: InquiryStatus | "all"; label: string }> = [
{ key: "all", label: "Alle" },
{ key: "new", label: "Neu" },
{ key: "read", label: "Gelesen" },
{ key: "confirmed", label: "Bestätigt" },
{ key: "declined", label: "Abgelehnt" },
{ key: "archived", label: "Archiviert" },
];
export default async function AdminInquiriesPage({ searchParams }: PageProps) {
const filter = (searchParams.status ?? "all") as InquiryStatus | "all";
const inquiries = await prisma.inquiry.findMany({
where: filter === "all" ? {} : { status: filter },
include: { apartment: { select: { name: true } } },
orderBy: { createdAt: "desc" },
});
const newCount = await prisma.inquiry.count({ where: { status: "new" } });
const rows: InquiryRowData[] = inquiries.map((i) => ({
id: i.id,
apartmentName: i.apartment.name,
arrival: i.arrival.toISOString(),
departure: i.departure.toISOString(),
guests: i.guests,
name: i.name,
email: i.email,
phone: i.phone,
message: i.message,
status: i.status as InquiryStatus,
createdAt: i.createdAt.toISOString(),
}));
return (
<div className="container py-10 md:py-14">
<div className="flex items-end justify-between gap-6 mb-8">
<div>
<div className="eyebrow mb-2">Admin</div>
<h1 className="font-display text-3xl md:text-4xl leading-tight">
Anfragen
</h1>
<p className="text-ink/60 text-sm mt-2">
{newCount > 0
? `${newCount} neue Anfrage${newCount === 1 ? "" : "n"} wartet auf Ihre Rückmeldung.`
: "Alle Anfragen sind bearbeitet."}
</p>
</div>
</div>
{/* Filter */}
<div className="flex flex-wrap gap-2 mb-6">
{FILTERS.map((f) => {
const active = filter === f.key;
const href = f.key === "all" ? "/admin/anfragen" : `/admin/anfragen?status=${f.key}`;
return (
<Link
key={f.key}
href={href}
className={cn(
"px-3.5 py-1.5 rounded-full text-xs uppercase tracking-wider transition",
active
? "bg-ink text-parchment"
: "bg-cream border border-ink/15 text-ink/70 hover:border-ink/30"
)}
>
{f.label}
</Link>
);
})}
</div>
{rows.length === 0 ? (
<div className="bg-cream border border-ink/10 rounded-sm p-12 text-center text-ink/55">
Keine Anfragen in dieser Ansicht.
</div>
) : (
<ul className="space-y-3">
{rows.map((row) => (
<InquiryRow key={row.id} inquiry={row} />
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { prisma } from "@/lib/db";
import { CalendarManager, type BlockRow } from "@/components/admin/CalendarManager";
export const dynamic = "force-dynamic";
export default async function AdminKalenderPage() {
const apartments = await prisma.apartment.findMany({
orderBy: { createdAt: "asc" },
select: { id: true, slug: true, name: true },
});
const blocks = await prisma.block.findMany({
include: { apartment: { select: { name: true } } },
orderBy: { startDate: "asc" },
});
const rows: BlockRow[] = blocks.map((b) => ({
id: b.id,
apartmentId: b.apartmentId,
apartmentName: b.apartment.name,
startDate: b.startDate.toISOString(),
endDate: b.endDate.toISOString(),
reason: b.reason,
source: b.source,
note: b.note,
}));
return (
<div className="container py-10 md:py-14">
<div className="mb-10">
<div className="eyebrow mb-2">Admin</div>
<h1 className="font-display text-3xl md:text-4xl leading-tight">Kalender</h1>
<p className="text-ink/60 text-sm mt-2">
Zeiträume sperren oder freigeben. Für jede Wohnung getrennt.
</p>
</div>
<CalendarManager apartments={apartments} blocks={rows} />
</div>
);
}

22
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { getSession } from "@/lib/auth";
import { AdminNav } from "@/components/admin/AdminNav";
export const dynamic = "force-dynamic";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
// Die Middleware (middleware.ts) schützt alle /admin-Routen außer /admin/login.
// Hier holen wir nur die Session, um bei eingeloggten Admins die Nav anzuzeigen.
// Auf der Login-Seite gibt es (noch) keine Session → Nav wird nicht gerendert.
const session = await getSession();
return (
<div className="min-h-screen bg-parchment flex flex-col">
{session && <AdminNav email={session.email} />}
<div className="flex-1">{children}</div>
</div>
);
}

82
app/admin/login/page.tsx Normal file
View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Input, Label, FieldError } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const next = searchParams.get("next") ?? "/admin/anfragen";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Login fehlgeschlagen.");
}
router.push(next);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Unbekannter Fehler.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-parchment">
<div className="w-full max-w-sm bg-cream border border-ink/10 rounded-sm p-8 shadow-card">
<div className="eyebrow mb-3">Admin</div>
<h1 className="font-display text-3xl mb-8 leading-tight">
Willkommen zurück.
</h1>
<form onSubmit={onSubmit} className="space-y-6">
<div>
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
</div>
<div>
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<FieldError message={error ?? undefined} />
<Button type="submit" size="lg" className="w-full" disabled={loading}>
{loading ? "Anmelden…" : "Anmelden"}
</Button>
</form>
</div>
</div>
);
}

5
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminIndex() {
redirect("/admin/anfragen");
}

View File

@@ -0,0 +1,50 @@
import { prisma } from "@/lib/db";
import { parseJsonArray } from "@/lib/utils";
import {
ApartmentEditor,
type EditorApartment,
} from "@/components/admin/ApartmentEditor";
export const dynamic = "force-dynamic";
export default async function AdminApartmentsPage() {
const rows = await prisma.apartment.findMany({
orderBy: { createdAt: "asc" },
});
const apartments: EditorApartment[] = rows.map((r) => ({
id: r.id,
slug: r.slug,
name: r.name,
tagline: r.tagline,
shortDescription: r.shortDescription,
description: r.description,
priceFrom: r.priceFrom,
maxGuests: r.maxGuests,
bedrooms: r.bedrooms,
sizeSqm: r.sizeSqm,
features: parseJsonArray<string>(r.features),
images: parseJsonArray<string>(r.images),
airbnbUrl: r.airbnbUrl ?? null,
bookingUrl: r.bookingUrl ?? null,
published: r.published,
}));
return (
<div className="container py-10 md:py-14">
<div className="mb-10">
<div className="eyebrow mb-2">Admin</div>
<h1 className="font-display text-3xl md:text-4xl leading-tight">Wohnungen</h1>
<p className="text-ink/60 text-sm mt-2">
Basisdaten, Ausstattung und Bilder pflegen.
</p>
</div>
<div className="space-y-8">
{apartments.map((apt) => (
<ApartmentEditor key={apt.id} apartment={apt} />
))}
</div>
</div>
);
}

47
app/anfrage/page.tsx Normal file
View File

@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { InquiryForm } from "@/components/inquiry/InquiryForm";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Anfrage senden",
description: "Senden Sie eine unverbindliche Anfrage für eine unserer Ferienwohnungen.",
};
export default async function InquiryPage({
searchParams,
}: {
searchParams: { wohnung?: string; arrival?: string; departure?: string };
}) {
const apartments = await prisma.apartment.findMany({
where: { published: true },
select: { slug: true, name: true },
orderBy: { createdAt: "asc" },
});
return (
<div className="py-12 md:py-20">
<div className="container max-w-3xl">
<div className="mb-14">
<div className="eyebrow mb-4">Anfrage</div>
<h1 className="font-display text-display-lg leading-[0.98]">
Erzählen Sie uns,<br />
<span className="italic text-moss-600">wann Sie kommen möchten.</span>
</h1>
<p className="mt-6 text-ink/70 max-w-xl leading-relaxed">
Ein kurzes Formular den Rest klären wir persönlich. Wir antworten
in der Regel innerhalb von 24 Stunden.
</p>
</div>
<InquiryForm
apartments={apartments}
defaultSlug={searchParams.wohnung}
defaultArrival={searchParams.arrival}
defaultDeparture={searchParams.departure}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { apartmentUpdateSchema } from "@/lib/validations";
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json().catch(() => null);
const parsed = apartmentUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Ungültige Eingabe.", issues: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const data = parsed.data;
try {
const updated = await prisma.apartment.update({
where: { id: params.id },
data: {
name: data.name,
tagline: data.tagline,
shortDescription: data.shortDescription,
description: data.description,
priceFrom: data.priceFrom,
maxGuests: data.maxGuests,
bedrooms: data.bedrooms,
sizeSqm: data.sizeSqm,
features: JSON.stringify(data.features),
images: JSON.stringify(data.images),
airbnbUrl: data.airbnbUrl || null,
bookingUrl: data.bookingUrl || null,
published: data.published,
},
});
return NextResponse.json({ ok: true, apartment: updated });
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
}

View File

@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function DELETE(
_req: Request,
{ params }: { params: { id: string } }
) {
try {
await prisma.block.delete({ where: { id: params.id } });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { blockSchema } from "@/lib/validations";
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = blockSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Ungültige Eingabe.", issues: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const { apartmentId, startDate, endDate, note, reason } = parsed.data;
const apt = await prisma.apartment.findUnique({ where: { id: apartmentId } });
if (!apt) {
return NextResponse.json({ error: "Wohnung nicht gefunden." }, { status: 404 });
}
const block = await prisma.block.create({
data: {
apartmentId,
startDate: new Date(startDate),
endDate: new Date(endDate),
note: note || null,
reason,
source: "manual",
},
});
return NextResponse.json({ ok: true, block }, { status: 201 });
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
const patchSchema = z.object({
status: z.enum(["new", "read", "confirmed", "declined", "archived"]),
});
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
}
try {
const updated = await prisma.inquiry.update({
where: { id: params.id },
data: { status: parsed.data.status },
});
return NextResponse.json({ ok: true, inquiry: updated });
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
}
export async function DELETE(
_req: Request,
{ params }: { params: { id: string } }
) {
try {
await prisma.inquiry.delete({ where: { id: params.id } });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth";
import { loginSchema } from "@/lib/validations";
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
}
const { email, password } = parsed.data;
const admin = await prisma.admin.findUnique({ where: { email } });
if (!admin) {
return NextResponse.json({ error: "Zugangsdaten falsch." }, { status: 401 });
}
const ok = await bcrypt.compare(password, admin.passwordHash);
if (!ok) {
return NextResponse.json({ error: "Zugangsdaten falsch." }, { status: 401 });
}
await createSession({ sub: admin.id, email: admin.email });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { clearSession } from "@/lib/auth";
export async function POST() {
clearSession();
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,130 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { z } from "zod";
const bodySchema = z.object({
apartmentId: z.string().min(1),
icalUrl: z.string().url(),
source: z.enum(["airbnb", "booking"]),
});
/** Minimal iCal parser for Airbnb / Booking.com VCALENDAR feeds. */
function parseIcal(text: string) {
// Unfold continuation lines (RFC 5545 §3.1)
const unfolded = text.replace(/\r?\n[ \t]/g, "");
const lines = unfolded.split(/\r?\n/);
const events: Array<{ uid: string; start: Date; end: Date }> = [];
let inEvent = false;
let uid = "";
let start: Date | null = null;
let end: Date | null = null;
for (const line of lines) {
if (line === "BEGIN:VEVENT") {
inEvent = true;
uid = "";
start = null;
end = null;
continue;
}
if (line === "END:VEVENT") {
inEvent = false;
if (uid && start && end && end > start) {
events.push({ uid, start, end });
}
continue;
}
if (!inEvent) continue;
// Split on first colon, ignoring property params like ;VALUE=DATE
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
const propFull = line.slice(0, colonIdx);
const value = line.slice(colonIdx + 1).trim();
const prop = propFull.split(";")[0].toUpperCase();
if (prop === "DTSTART" || prop === "DTEND") {
const parsed = parseIcalDate(value);
if (parsed) {
if (prop === "DTSTART") start = parsed;
else end = parsed;
}
} else if (prop === "UID") {
uid = value;
}
}
return events;
}
function parseIcalDate(value: string): Date | null {
// DATE: YYYYMMDD
// DATETIME: YYYYMMDDTHHMMSSz or YYYYMMDDTHHMMSSZz
const digits = value.replace(/[TZ]/g, "").slice(0, 8);
if (digits.length < 8) return null;
const y = parseInt(digits.slice(0, 4));
const m = parseInt(digits.slice(4, 6)) - 1;
const d = parseInt(digits.slice(6, 8));
if (isNaN(y) || isNaN(m) || isNaN(d)) return null;
return new Date(Date.UTC(y, m, d));
}
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
}
const { apartmentId, icalUrl, source } = parsed.data;
// Verify apartment exists
const apartment = await prisma.apartment.findUnique({ where: { id: apartmentId } });
if (!apartment) {
return NextResponse.json({ error: "Wohnung nicht gefunden." }, { status: 404 });
}
// Fetch the iCal feed
let icalText: string;
try {
const res = await fetch(icalUrl, {
headers: { "User-Agent": "Spreewaldzeit-CalSync/1.0" },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
icalText = await res.text();
} catch (err) {
return NextResponse.json(
{ error: `Kalender konnte nicht geladen werden: ${err instanceof Error ? err.message : "Unbekannt"}` },
{ status: 502 }
);
}
const events = parseIcal(icalText);
// Delete all previous blocks from this source for this apartment
const deleted = await prisma.block.deleteMany({
where: { apartmentId, source },
});
// Insert new blocks
if (events.length > 0) {
await prisma.block.createMany({
data: events.map((e) => ({
apartmentId,
startDate: e.start,
endDate: e.end,
reason: "booking",
source,
note: `iCal-Import (${source}) · UID: ${e.uid.slice(0, 40)}`,
})),
});
}
return NextResponse.json({
ok: true,
deleted: deleted.count,
imported: events.length,
});
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(
_req: Request,
{ params }: { params: { slug: string } }
) {
const apartment = await prisma.apartment.findUnique({
where: { slug: params.slug },
select: { id: true },
});
if (!apartment) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
// Wir geben nur zukünftige & heute aktive Blöcke zurück.
const today = new Date();
today.setHours(0, 0, 0, 0);
const blocks = await prisma.block.findMany({
where: {
apartmentId: apartment.id,
endDate: { gte: today },
},
select: { startDate: true, endDate: true },
orderBy: { startDate: "asc" },
});
return NextResponse.json(
{
blocks: blocks.map((b) => ({
start: b.startDate.toISOString(),
end: b.endDate.toISOString(),
})),
},
{
headers: { "Cache-Control": "no-store" },
}
);
}

View File

@@ -0,0 +1,81 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
interface Period {
start: string;
end: string;
nights: number;
}
export async function GET(
_req: Request,
{ params }: { params: { slug: string } }
) {
const apartment = await prisma.apartment.findUnique({
where: { slug: params.slug },
select: { id: true },
});
if (!apartment) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const horizon = new Date(today);
horizon.setDate(horizon.getDate() + 180);
// Fetch all blocks that overlap the window
const rows = await prisma.block.findMany({
where: {
apartmentId: apartment.id,
startDate: { lt: horizon },
endDate: { gt: today },
},
select: { startDate: true, endDate: true },
orderBy: { startDate: "asc" },
});
const blocks = rows.map((b) => ({
from: new Date(b.startDate),
to: new Date(b.endDate),
}));
// Walk forward from tomorrow, find free 7-night windows
const DEFAULT_NIGHTS = 7;
const MAX_SUGGESTIONS = 8;
const suggestions: Period[] = [];
const cursor = new Date(today);
cursor.setDate(cursor.getDate() + 1); // start from tomorrow
while (cursor < horizon && suggestions.length < MAX_SUGGESTIONS) {
const tripEnd = new Date(cursor);
tripEnd.setDate(tripEnd.getDate() + DEFAULT_NIGHTS);
if (tripEnd > horizon) break;
// Find any block that overlaps [cursor, tripEnd)
const overlap = blocks.find((b) => cursor < b.to && tripEnd > b.from);
if (!overlap) {
suggestions.push({
start: cursor.toISOString().slice(0, 10),
end: tripEnd.toISOString().slice(0, 10),
nights: DEFAULT_NIGHTS,
});
// Jump forward: end of this trip + small gap
cursor.setDate(cursor.getDate() + DEFAULT_NIGHTS + 7);
} else {
// Jump past the blocking block
cursor.setTime(overlap.to.getTime());
cursor.setDate(cursor.getDate() + 1);
}
}
return NextResponse.json({ periods: suggestions }, {
headers: { "Cache-Control": "no-store" },
});
}

View File

@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { inquirySchema } from "@/lib/validations";
import { sendInquiryMails } from "@/lib/email";
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const parsed = inquirySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: "Bitte prüfen Sie Ihre Eingaben.",
issues: parsed.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
// Honeypot: wenn `website` gefüllt ist, ist es vermutlich ein Bot.
if (parsed.data.website) {
return NextResponse.json({ ok: true }, { status: 200 });
}
const { apartmentSlug, arrival, departure, guests, name, email, phone, message } =
parsed.data;
const apartment = await prisma.apartment.findUnique({
where: { slug: apartmentSlug },
});
if (!apartment) {
return NextResponse.json({ error: "Wohnung nicht gefunden." }, { status: 404 });
}
const arrivalDate = new Date(arrival);
const departureDate = new Date(departure);
if (guests > apartment.maxGuests) {
return NextResponse.json(
{ error: `Für diese Wohnung sind maximal ${apartment.maxGuests} Personen möglich.` },
{ status: 400 }
);
}
// Hinweis: wir blockieren eine Anfrage NICHT, nur weil die Zeit schon belegt ist —
// der Gast bekommt ggf. eine Absage, und der Vermieter sieht alles im Admin.
// Aber wir markieren im Memo-Feld nichts — Vermieter entscheidet.
const inquiry = await prisma.inquiry.create({
data: {
apartmentId: apartment.id,
arrival: arrivalDate,
departure: departureDate,
guests,
name,
email,
phone: phone || null,
message: message || "",
status: "new",
},
});
// Mails senden — Fehler dürfen den Erfolg nicht verhindern
try {
await sendInquiryMails({
apartmentName: apartment.name,
arrival: arrivalDate,
departure: departureDate,
guests,
name,
email,
phone,
message,
inquiryId: inquiry.id,
});
} catch (err) {
console.error("Mailversand fehlgeschlagen:", err);
}
return NextResponse.json({ ok: true, id: inquiry.id }, { status: 201 });
}

69
app/datenschutz/page.tsx Normal file
View File

@@ -0,0 +1,69 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Datenschutz",
robots: { index: false, follow: true },
};
export default function DatenschutzPage() {
return (
<div className="py-16 md:py-24">
<div className="container max-w-3xl">
<div className="eyebrow mb-4">Rechtliches</div>
<h1 className="font-display text-display-lg mb-10 leading-[1.02]">
Datenschutz
</h1>
<div className="max-w-none text-ink/85 space-y-6 leading-relaxed">
<p>
Der Schutz Ihrer personenbezogenen Daten ist uns wichtig. Nachfolgend
informieren wir Sie über die Verarbeitung Ihrer Daten auf dieser Website.
</p>
<h2 className="font-display text-2xl mt-10 mb-3">1. Verantwortlich</h2>
<p>
Familie Musterfrau<br />
Hauptstraße 12, 03222 Lübbenau/Spreewald<br />
E-Mail: <a href="mailto:hallo@spreewaldzeit.de" className="link-underline">hallo@spreewaldzeit.de</a>
</p>
<h2 className="font-display text-2xl mt-10 mb-3">2. Anfrageformular</h2>
<p>
Wenn Sie uns über das Formular eine Anfrage senden, verarbeiten wir die von
Ihnen angegebenen Daten (Name, E-Mail, optional Telefon, Reisedaten,
Nachricht) zum Zweck der Bearbeitung Ihrer Anfrage auf Grundlage von
Art. 6 Abs. 1 lit. b DSGVO. Die Daten werden bei uns gespeichert und nach
Abschluss der Anfrage bzw. nach den steuerrechtlichen Aufbewahrungsfristen
gelöscht.
</p>
<h2 className="font-display text-2xl mt-10 mb-3">3. Server-Logs</h2>
<p>
Beim Aufruf der Website werden technisch notwendige Daten (IP-Adresse,
Zeitpunkt, Browser) temporär verarbeitet (Art. 6 Abs. 1 lit. f DSGVO).
Diese Daten werden nicht mit anderen Datenquellen zusammengeführt.
</p>
<h2 className="font-display text-2xl mt-10 mb-3">4. Cookies & Tracking</h2>
<p>
Diese Website setzt keine Marketing- oder Tracking-Cookies ein. Es werden
lediglich technisch notwendige Cookies (Session) genutzt, soweit Sie sich
als Administrator anmelden.
</p>
<h2 className="font-display text-2xl mt-10 mb-3">5. Ihre Rechte</h2>
<p>
Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung
der Verarbeitung, Widerspruch und Datenübertragbarkeit. Wenden Sie sich
hierfür an die oben genannte Adresse. Zudem können Sie sich bei einer
Datenschutzaufsichtsbehörde beschweren.
</p>
<p className="text-xs text-ink/50 pt-10">
Stand: {new Date().toLocaleDateString("de-DE")}. Diese Datenschutzerklärung
ist ein Platzhalter bitte vor Veröffentlichung rechtlich prüfen lassen.
</p>
</div>
</div>
</div>
);
}

146
app/globals.css Normal file
View File

@@ -0,0 +1,146 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* -----------------------------------------------------------
Basis-Typografie & Grundfarben
----------------------------------------------------------- */
@layer base {
:root {
--color-bg: #f5f1e8;
--color-bg-soft: #fbf9f4;
--color-ink: #1c2620;
--color-moss: #5a6b4f;
}
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
background-color: var(--color-bg);
color: var(--color-ink);
font-family: var(--font-figtree), ui-sans-serif, system-ui, sans-serif;
font-feature-settings: "ss01", "ss02";
font-size: 1.0625rem; /* 17px base */
}
/* Subtle paper texture — desktop only (fixed bg repaints on mobile scroll) */
@media (min-width: 768px) {
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.35;
background-image: radial-gradient(
rgba(28, 38, 32, 0.025) 1px,
transparent 1px
);
background-size: 3px 3px;
}
}
h1, h2, h3, h4 {
font-family: var(--font-fraunces), Georgia, serif;
font-weight: 400;
letter-spacing: -0.02em;
}
::selection {
background: #5a6b4f;
color: #f5f1e8;
}
:focus-visible {
outline: 2px solid #5a6b4f;
outline-offset: 2px;
border-radius: 2px;
}
}
/* -----------------------------------------------------------
Utility-Klassen
----------------------------------------------------------- */
@layer components {
.eyebrow {
@apply text-xs uppercase tracking-[0.22em] text-moss-600 font-medium;
}
.link-underline {
@apply relative inline-block;
background-image: linear-gradient(currentColor, currentColor);
background-position: 0% 100%;
background-repeat: no-repeat;
background-size: 100% 1px;
transition: background-size 0.3s ease;
}
.link-underline:hover {
background-size: 0% 1px;
}
.hairline {
@apply block h-px w-full bg-ink/10;
}
/* Platform external link button */
.platform-link {
@apply flex items-center justify-between w-full border border-ink/12 bg-parchment/60 px-4 py-3 rounded-sm text-sm transition-all duration-200;
}
.platform-link:hover {
@apply border-ink/25 bg-parchment;
}
}
/* -----------------------------------------------------------
react-day-picker v9 — palette overrides
----------------------------------------------------------- */
.rdp-root {
--rdp-accent-color: #5a6b4f;
--rdp-accent-background-color: rgba(90, 107, 79, 0.1);
--rdp-today-color: #5a6b4f;
--rdp-range_start-date-background-color: #5a6b4f;
--rdp-range_end-date-background-color: #5a6b4f;
--rdp-range_middle-background-color: rgba(90, 107, 79, 0.1);
--rdp-range_start-color: #fbf9f4;
--rdp-range_end-color: #fbf9f4;
--rdp-disabled-opacity: 0.45;
--rdp-day-height: 44px;
--rdp-day-width: 44px;
--rdp-day_button-height: 40px;
--rdp-day_button-width: 40px;
margin: 0;
font-family: var(--font-figtree), ui-sans-serif, sans-serif;
font-size: 0.9rem;
}
.rdp-selected .rdp-day_button {
background-color: #5a6b4f;
color: #fbf9f4;
border-color: #5a6b4f;
}
.rdp-disabled:not(.rdp-selected) .rdp-day_button {
text-decoration: line-through;
color: #a9a391;
}
.rdp-caption_label {
font-family: var(--font-fraunces), Georgia, serif;
font-weight: 400;
font-size: 1.1rem;
letter-spacing: -0.01em;
}
.rdp-button_previous,
.rdp-button_next {
transition: background-color 0.2s ease, opacity 0.2s ease;
border-radius: 6px;
}
.rdp-button_previous:hover,
.rdp-button_next:hover {
background-color: rgba(90, 107, 79, 0.1);
}
.rdp-day_button:hover:not(:disabled) {
background-color: rgba(90, 107, 79, 0.08);
}

60
app/impressum/page.tsx Normal file
View File

@@ -0,0 +1,60 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Impressum",
robots: { index: false, follow: true },
};
export default function ImpressumPage() {
return (
<div className="py-16 md:py-24">
<div className="container max-w-3xl">
<div className="eyebrow mb-4">Rechtliches</div>
<h1 className="font-display text-display-lg mb-10 leading-[1.02]">
Impressum
</h1>
<div className="text-ink/85 space-y-6 leading-relaxed">
<h2 className="font-display text-2xl mt-6 mb-2">Angaben gemäß § 5 TMG</h2>
<p>
Familie Musterfrau<br />
Hauptstraße 12<br />
03222 Lübbenau/Spreewald<br />
Deutschland
</p>
<h2 className="font-display text-2xl mt-8 mb-2">Kontakt</h2>
<p>
Telefon: +49 (0)3542 000000<br />
E-Mail: <a href="mailto:hallo@spreewaldzeit.de" className="link-underline">hallo@spreewaldzeit.de</a>
</p>
<h2 className="font-display text-2xl mt-8 mb-2">Umsatzsteuer-ID</h2>
<p>
Gemäß §27 a Umsatzsteuergesetz:<br />
DE000000000
</p>
<h2 className="font-display text-2xl mt-8 mb-2">Streitschlichtung</h2>
<p>
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS)
bereit:{" "}
<a
href="https://ec.europa.eu/consumers/odr/"
target="_blank"
rel="noopener noreferrer"
className="link-underline"
>
https://ec.europa.eu/consumers/odr/
</a>
. Wir sind nicht bereit oder verpflichtet, an einem Streitbeilegungsverfahren
vor einer Verbraucherschlichtungsstelle teilzunehmen.
</p>
<p className="text-xs text-ink/50 pt-10">
Platzhalter bitte vor Veröffentlichung anpassen.
</p>
</div>
</div>
</div>
);
}

53
app/layout.tsx Normal file
View File

@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import { Fraunces, Figtree } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
const fraunces = Fraunces({
subsets: ["latin"],
display: "swap",
variable: "--font-fraunces",
});
const figtree = Figtree({
subsets: ["latin"],
display: "swap",
variable: "--font-figtree",
});
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"),
title: {
default: "Spreewaldzeit Zwei Ferienwohnungen am Fließ",
template: "%s · Spreewaldzeit",
},
description:
"Zwei private Ferienwohnungen im Spreewald — ruhig, mit viel Holz, Wasser vor der Tür und Platz zum Durchatmen.",
openGraph: {
title: "Spreewaldzeit",
description:
"Zwei private Ferienwohnungen im Spreewald — ruhig, mit viel Holz, Wasser vor der Tür und Platz zum Durchatmen.",
locale: "de_DE",
type: "website",
},
robots: { index: true, follow: true },
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de" className={`${fraunces.variable} ${figtree.variable}`}>
<body className="min-h-screen flex flex-col">
<div className="relative z-10 flex flex-col flex-1">
<Header />
<main className="flex-1 pt-[72px] md:pt-[80px]">{children}</main>
<Footer />
</div>
</body>
</html>
);
}

21
app/not-found.tsx Normal file
View File

@@ -0,0 +1,21 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="container py-24 md:py-32 text-center">
<div className="eyebrow mb-4">404</div>
<h1 className="font-display text-display-lg mb-6 leading-[1.02]">
Hier ist nur <span className="italic text-moss-600">Nebel.</span>
</h1>
<p className="text-ink/70 max-w-md mx-auto">
Die Seite, die Sie suchen, gibt es nicht (mehr).
</p>
<Link
href="/"
className="inline-flex items-center gap-2 mt-10 bg-ink text-parchment px-7 py-3.5 rounded-full text-sm hover:bg-moss-700 transition-colors"
>
Zurück zur Startseite
</Link>
</div>
);
}

36
app/page.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { prisma } from "@/lib/db";
import { parseJsonArray } from "@/lib/utils";
import type { Apartment } from "@/types";
import { Hero } from "@/components/home/Hero";
import { About } from "@/components/home/About";
import { ApartmentPreview } from "@/components/home/ApartmentPreview";
import { Location } from "@/components/home/Location";
import { PlacesToVisit } from "@/components/home/PlacesToVisit";
// Daten immer frisch (SQLite, lokal unproblematisch; für Produktion ggf. ISR)
export const dynamic = "force-dynamic";
async function getApartments(): Promise<Apartment[]> {
const rows = await prisma.apartment.findMany({
where: { published: true },
orderBy: { createdAt: "asc" },
});
return rows.map((r) => ({
...r,
features: parseJsonArray<string>(r.features),
images: parseJsonArray<string>(r.images),
}));
}
export default async function HomePage() {
const apartments = await getApartments();
return (
<>
<Hero />
<About />
<ApartmentPreview apartments={apartments} />
<Location />
<PlacesToVisit />
</>
);
}

View File

@@ -0,0 +1,145 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { formatPrice, parseJsonArray } from "@/lib/utils";
import { Gallery } from "@/components/apartment/Gallery";
import { Features } from "@/components/apartment/Features";
import { AvailabilityCalendar } from "@/components/apartment/AvailabilityCalendar";
import { BookingPlatforms } from "@/components/apartment/BookingPlatforms";
import { AvailablePeriods } from "@/components/apartment/AvailablePeriods";
export const dynamic = "force-dynamic";
interface PageProps {
params: { slug: string };
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const apt = await prisma.apartment.findUnique({ where: { slug: params.slug } });
if (!apt) return { title: "Wohnung nicht gefunden" };
return {
title: `${apt.name}${apt.tagline}`,
description: apt.shortDescription,
};
}
export default async function ApartmentDetailPage({ params }: PageProps) {
const row = await prisma.apartment.findUnique({ where: { slug: params.slug } });
if (!row || !row.published) notFound();
const apartment = {
...row,
features: parseJsonArray<string>(row.features),
images: parseJsonArray<string>(row.images),
};
return (
<article className="pt-4 md:pt-8 pb-20">
<div className="container">
{/* Brotkrümel */}
<nav className="mb-8 text-xs text-ink/60">
<Link href="/" className="link-underline">Start</Link>
<span className="mx-2 text-ink/30">/</span>
<Link href="/#wohnungen" className="link-underline">Wohnungen</Link>
<span className="mx-2 text-ink/30">/</span>
<span className="text-ink/80">{apartment.name}</span>
</nav>
{/* Titelblock */}
<header className="grid md:grid-cols-12 gap-8 mb-10 md:mb-16 items-end">
<div className="md:col-span-7">
<div className="eyebrow mb-3">{apartment.tagline}</div>
<h1 className="font-display text-display-lg leading-[0.98]">
{apartment.name}
</h1>
</div>
<dl className="md:col-span-5 grid grid-cols-3 gap-4 border-t md:border-t-0 md:border-l border-ink/10 md:pl-8 pt-6 md:pt-0">
<div>
<dt className="eyebrow mb-1">Gäste</dt>
<dd className="font-display text-xl">bis {apartment.maxGuests}</dd>
</div>
<div>
<dt className="eyebrow mb-1">Größe</dt>
<dd className="font-display text-xl">{apartment.sizeSqm} m²</dd>
</div>
<div>
<dt className="eyebrow mb-1">ab</dt>
<dd className="font-display text-xl">
{formatPrice(apartment.priceFrom)}
<span className="text-xs text-ink/50">/Nacht</span>
</dd>
</div>
</dl>
</header>
{/* Galerie */}
<Gallery images={apartment.images} alt={apartment.name} />
{/* Beschreibung + Sticky-CTA */}
<div className="mt-16 md:mt-20 grid md:grid-cols-12 gap-10 md:gap-16">
<div className="md:col-span-7">
<div className="eyebrow mb-4">Die Wohnung</div>
<h2 className="font-display text-3xl md:text-4xl leading-tight mb-6">
{apartment.shortDescription}
</h2>
<p className="text-ink/80 leading-relaxed whitespace-pre-line">
{apartment.description}
</p>
<Features features={apartment.features} />
<AvailablePeriods slug={apartment.slug} />
<AvailabilityCalendar slug={apartment.slug} />
</div>
{/* Sticky Anfrage-Box */}
<aside className="md:col-span-5 md:col-start-9">
<div className="md:sticky md:top-28 bg-cream rounded-sm p-7 md:p-8 border border-ink/10">
<div className="flex items-baseline justify-between gap-2 mb-5">
<div>
<div className="eyebrow">Ab</div>
<div className="font-display text-3xl">
{formatPrice(apartment.priceFrom)}
</div>
</div>
<div className="text-right text-xs text-ink/55">
pro Nacht<br />inkl. Nebenkosten
</div>
</div>
<BookingPlatforms
airbnbUrl={apartment.airbnbUrl}
bookingUrl={apartment.bookingUrl}
className="mb-5"
/>
{(apartment.airbnbUrl || apartment.bookingUrl) && (
<div className="flex items-center gap-3 my-5">
<span className="flex-1 h-px bg-ink/10" />
<span className="text-[11px] text-ink/40 uppercase tracking-widest">oder direkt</span>
<span className="flex-1 h-px bg-ink/10" />
</div>
)}
<p className="text-sm text-ink/70 leading-relaxed mb-4">
Senden Sie uns Ihren Wunsch­zeitraum wir melden uns in der Regel
innerhalb von 24 Stunden. Ohne Provision.
</p>
<Link
href={`/anfrage?wohnung=${apartment.slug}`}
className="flex items-center justify-center w-full bg-ink text-parchment px-6 py-4 rounded-full text-sm hover:bg-moss-700 transition-colors"
>
Anfrage senden
</Link>
<p className="mt-4 text-[11px] text-ink/50 text-center">
Unverbindlich · keine Vorabzahlung · keine Provision
</p>
</div>
</aside>
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
const nav = [
{ href: "/admin/anfragen", label: "Anfragen" },
{ href: "/admin/kalender", label: "Kalender" },
{ href: "/admin/wohnungen", label: "Wohnungen" },
];
export function AdminNav({ email }: { email: string }) {
const pathname = usePathname();
const router = useRouter();
async function logout() {
await fetch("/api/admin/logout", { method: "POST" });
router.push("/admin/login");
router.refresh();
}
return (
<header className="bg-ink text-parchment">
<div className="container flex flex-col md:flex-row md:items-center justify-between gap-4 py-5">
<div className="flex items-center gap-8">
<Link href="/admin/anfragen" className="font-display text-xl tracking-tight">
Spreewaldzeit{" "}
<span className="text-parchment/50 text-sm ml-1">· Admin</span>
</Link>
<nav className="flex gap-1 md:gap-2">
{nav.map((item) => {
const active = pathname?.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"px-3 py-1.5 rounded-full text-sm transition",
active
? "bg-parchment text-ink"
: "text-parchment/70 hover:text-parchment hover:bg-parchment/10"
)}
>
{item.label}
</Link>
);
})}
</nav>
</div>
<div className="flex items-center gap-4 text-sm">
<Link href="/" target="_blank" className="text-parchment/60 hover:text-parchment">
Website ansehen
</Link>
<span className="hidden md:inline text-parchment/40">·</span>
<span className="hidden md:inline text-parchment/70">{email}</span>
<button
onClick={logout}
className="px-3 py-1.5 rounded-full bg-parchment/10 hover:bg-parchment/20 text-sm transition"
>
Abmelden
</button>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,321 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Input, Label, Textarea, FieldError } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { formatPrice } from "@/lib/utils";
export interface EditorApartment {
id: string;
slug: string;
name: string;
tagline: string;
shortDescription: string;
description: string;
priceFrom: number; // Cent
maxGuests: number;
bedrooms: number;
sizeSqm: number;
features: string[];
images: string[];
airbnbUrl: string | null;
bookingUrl: string | null;
published: boolean;
}
export function ApartmentEditor({ apartment }: { apartment: EditorApartment }) {
const router = useRouter();
const [form, setForm] = useState(apartment);
const [priceEuro, setPriceEuro] = useState(String(Math.round(apartment.priceFrom / 100)));
const [airbnbUrl, setAirbnbUrl] = useState(apartment.airbnbUrl ?? "");
const [bookingUrl, setBookingUrl] = useState(apartment.bookingUrl ?? "");
const [featureInput, setFeatureInput] = useState("");
const [imageInput, setImageInput] = useState("");
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
function update<K extends keyof EditorApartment>(key: K, value: EditorApartment[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
function addFeature() {
const v = featureInput.trim();
if (!v) return;
update("features", [...form.features, v]);
setFeatureInput("");
}
function removeFeature(i: number) {
update("features", form.features.filter((_, idx) => idx !== i));
}
function addImage() {
const v = imageInput.trim();
if (!v) return;
try {
new URL(v);
} catch {
setMessage({ kind: "err", text: "Bild-URL ist ungültig." });
return;
}
update("images", [...form.images, v]);
setImageInput("");
setMessage(null);
}
function removeImage(i: number) {
update("images", form.images.filter((_, idx) => idx !== i));
}
async function save() {
setSaving(true);
setMessage(null);
try {
const res = await fetch(`/api/admin/apartments/${form.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name,
tagline: form.tagline,
shortDescription: form.shortDescription,
description: form.description,
priceFrom: Math.max(0, Math.round(Number(priceEuro) * 100)),
maxGuests: Number(form.maxGuests),
bedrooms: Number(form.bedrooms),
sizeSqm: Number(form.sizeSqm),
features: form.features,
images: form.images,
airbnbUrl: airbnbUrl.trim() || "",
bookingUrl: bookingUrl.trim() || "",
published: form.published,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Speichern fehlgeschlagen.");
}
setMessage({ kind: "ok", text: "Gespeichert." });
router.refresh();
} catch (err) {
setMessage({
kind: "err",
text: err instanceof Error ? err.message : "Unbekannter Fehler.",
});
} finally {
setSaving(false);
}
}
return (
<div className="bg-cream border border-ink/10 rounded-sm p-6 md:p-8">
<div className="flex items-start justify-between gap-4 mb-8">
<div>
<div className="eyebrow mb-1">/{form.slug}</div>
<h2 className="font-display text-3xl leading-tight">{form.name}</h2>
<div className="mt-2 text-sm text-ink/60">
ab {formatPrice(form.priceFrom)} · bis {form.maxGuests} Gäste · {form.sizeSqm} m²
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer shrink-0">
<input
type="checkbox"
checked={form.published}
onChange={(e) => update("published", e.target.checked)}
className="accent-moss-600 h-4 w-4"
/>
Veröffentlicht
</label>
</div>
<div className="grid md:grid-cols-2 gap-6">
<div>
<Label>Name</Label>
<Input value={form.name} onChange={(e) => update("name", e.target.value)} />
</div>
<div>
<Label>Kurztitel / Tagline</Label>
<Input value={form.tagline} onChange={(e) => update("tagline", e.target.value)} />
</div>
<div className="md:col-span-2">
<Label>Kurzbeschreibung</Label>
<Textarea
rows={2}
value={form.shortDescription}
onChange={(e) => update("shortDescription", e.target.value)}
/>
</div>
<div className="md:col-span-2">
<Label>Beschreibung</Label>
<Textarea
rows={6}
value={form.description}
onChange={(e) => update("description", e.target.value)}
/>
</div>
<div>
<Label>Preis ab (/Nacht)</Label>
<Input
type="number"
min={0}
value={priceEuro}
onChange={(e) => setPriceEuro(e.target.value)}
/>
</div>
<div>
<Label>Max. Gäste</Label>
<Input
type="number"
min={1}
value={form.maxGuests}
onChange={(e) => update("maxGuests", Number(e.target.value))}
/>
</div>
<div>
<Label>Schlafzimmer</Label>
<Input
type="number"
min={0}
value={form.bedrooms}
onChange={(e) => update("bedrooms", Number(e.target.value))}
/>
</div>
<div>
<Label>Größe (m²)</Label>
<Input
type="number"
min={1}
value={form.sizeSqm}
onChange={(e) => update("sizeSqm", Number(e.target.value))}
/>
</div>
</div>
{/* Features */}
<div className="mt-10">
<Label>Ausstattung</Label>
<div className="flex flex-wrap gap-2 mt-3 mb-3">
{form.features.map((f, i) => (
<span
key={`${f}-${i}`}
className="inline-flex items-center gap-2 bg-parchment border border-ink/15 px-3 py-1 rounded-full text-sm"
>
{f}
<button
onClick={() => removeFeature(i)}
aria-label="Entfernen"
className="text-ink/40 hover:text-red-700 text-lg leading-none"
>
×
</button>
</span>
))}
</div>
<div className="flex gap-2">
<Input
placeholder="z. B. Kachelofen"
value={featureInput}
onChange={(e) => setFeatureInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addFeature();
}
}}
/>
<Button type="button" variant="outline" onClick={addFeature}>
Hinzufügen
</Button>
</div>
</div>
{/* Bilder */}
<div className="mt-10">
<Label>Bilder (URLs)</Label>
{form.images.length > 0 && (
<ul className="grid grid-cols-3 md:grid-cols-5 gap-3 mt-3 mb-4">
{form.images.map((src, i) => (
<li key={`${src}-${i}`} className="relative aspect-square rounded-sm overflow-hidden bg-ink/5 group">
<Image
src={src}
alt=""
fill
sizes="200px"
className="object-cover"
/>
<button
onClick={() => removeImage(i)}
className="absolute top-1 right-1 bg-ink/80 text-parchment text-xs w-6 h-6 rounded-full opacity-0 group-hover:opacity-100 transition"
aria-label="Bild entfernen"
>
×
</button>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<Input
placeholder="https://…"
value={imageInput}
onChange={(e) => setImageInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addImage();
}
}}
/>
<Button type="button" variant="outline" onClick={addImage}>
Hinzufügen
</Button>
</div>
<p className="mt-2 text-xs text-ink/50">
Für den MVP werden Bilder als URLs gepflegt. Später per Upload über S3/Cloudinary erweiterbar.
</p>
</div>
{/* Buchungsplattformen */}
<div className="mt-10">
<Label>Buchungsplattformen</Label>
<p className="mt-1 mb-4 text-xs text-ink/50">
Wenn Airbnb- oder Booking.com-Links gesetzt sind, werden sie auf der Wohnungsseite als externe Buchungsoption angezeigt.
</p>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label>Airbnb-Link</Label>
<Input
placeholder="https://www.airbnb.de/rooms/…"
value={airbnbUrl}
onChange={(e) => setAirbnbUrl(e.target.value)}
/>
</div>
<div>
<Label>Booking.com-Link</Label>
<Input
placeholder="https://www.booking.com/hotel/…"
value={bookingUrl}
onChange={(e) => setBookingUrl(e.target.value)}
/>
</div>
</div>
</div>
{/* Aktionen */}
<div className="mt-10 pt-6 border-t border-ink/10 flex items-center justify-between gap-4 flex-wrap">
<div className="text-sm">
{message && (
<span className={message.kind === "ok" ? "text-moss-700" : "text-red-700"}>
{message.text}
</span>
)}
</div>
<Button onClick={save} disabled={saving} size="lg">
{saving ? "Speichert…" : "Änderungen speichern"}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,288 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/utils";
import { Input, Label, Textarea, FieldError } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
interface ApartmentLite {
id: string;
slug: string;
name: string;
}
export interface BlockRow {
id: string;
apartmentId: string;
apartmentName: string;
startDate: string;
endDate: string;
reason: string;
source: string;
note: string | null;
}
export function CalendarManager({
apartments,
blocks,
}: {
apartments: ApartmentLite[];
blocks: BlockRow[];
}) {
const router = useRouter();
const [apartmentId, setApartmentId] = useState(apartments[0]?.id ?? "");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [note, setNote] = useState("");
const [reason, setReason] = useState<"manual" | "maintenance" | "booking">("manual");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// iCal sync state
const [icalApartmentId, setIcalApartmentId] = useState(apartments[0]?.id ?? "");
const [icalUrl, setIcalUrl] = useState("");
const [icalSource, setIcalSource] = useState<"airbnb" | "booking">("airbnb");
const [icalSyncing, setIcalSyncing] = useState(false);
const [icalMessage, setIcalMessage] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const res = await fetch("/api/admin/blocks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apartmentId, startDate, endDate, note, reason }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Konnte Zeitraum nicht sperren.");
}
setStartDate("");
setEndDate("");
setNote("");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Unbekannter Fehler.");
} finally {
setSaving(false);
}
}
async function removeBlock(id: string) {
if (!confirm("Zeitraum freigeben?")) return;
const res = await fetch(`/api/admin/blocks/${id}`, { method: "DELETE" });
if (res.ok) router.refresh();
}
async function syncIcal(e: React.FormEvent) {
e.preventDefault();
if (!icalUrl.trim()) return;
setIcalSyncing(true);
setIcalMessage(null);
try {
const res = await fetch("/api/admin/sync-ical", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apartmentId: icalApartmentId, icalUrl: icalUrl.trim(), source: icalSource }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error ?? "Synchronisation fehlgeschlagen.");
setIcalMessage({
kind: "ok",
text: `${data.imported} Buchungen importiert, ${data.deleted} alte entfernt.`,
});
setIcalUrl("");
router.refresh();
} catch (err) {
setIcalMessage({ kind: "err", text: err instanceof Error ? err.message : "Fehler." });
} finally {
setIcalSyncing(false);
}
}
return (
<div className="grid md:grid-cols-12 gap-10">
{/* Formular */}
<form onSubmit={onSubmit} className="md:col-span-5 bg-cream border border-ink/10 rounded-sm p-6 md:p-8 space-y-6 h-fit">
<div>
<h2 className="font-display text-2xl">Zeitraum sperren</h2>
<p className="text-sm text-ink/60 mt-1">
Für externe Buchungen, eigene Nutzung oder Wartung.
</p>
</div>
<div>
<Label htmlFor="apartment">Wohnung</Label>
<select
id="apartment"
value={apartmentId}
onChange={(e) => setApartmentId(e.target.value)}
className="w-full bg-transparent border-b border-ink/25 focus:border-ink py-2.5 focus:outline-none"
>
{apartments.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="start">Von</Label>
<Input id="start" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} required />
</div>
<div>
<Label htmlFor="end">Bis</Label>
<Input id="end" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} required />
</div>
</div>
<div>
<Label htmlFor="reason">Grund</Label>
<select
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value as typeof reason)}
className="w-full bg-transparent border-b border-ink/25 focus:border-ink py-2.5 focus:outline-none"
>
<option value="manual">Manuell gesperrt</option>
<option value="booking">Externe Buchung</option>
<option value="maintenance">Wartung / Pflege</option>
</select>
</div>
<div>
<Label htmlFor="note">Notiz (optional)</Label>
<Textarea id="note" rows={3} value={note} onChange={(e) => setNote(e.target.value)} />
</div>
<FieldError message={error ?? undefined} />
<Button type="submit" size="lg" disabled={saving} className="w-full">
{saving ? "Wird gespeichert…" : "Zeitraum sperren"}
</Button>
</form>
{/* Liste */}
<div className="md:col-span-7">
<div className="flex items-center justify-between mb-4">
<h2 className="font-display text-2xl">Gesperrte Zeiträume</h2>
<span className="text-xs text-ink/50">{blocks.length} gesamt</span>
</div>
{blocks.length === 0 ? (
<div className="bg-cream border border-ink/10 rounded-sm p-10 text-center text-ink/55">
Keine gesperrten Zeiträume.
</div>
) : (
<ul className="space-y-2">
{blocks.map((b) => (
<li
key={b.id}
className="grid grid-cols-[1fr_auto] items-center gap-4 border border-ink/10 bg-cream rounded-sm px-5 py-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{b.apartmentName}</span>
<span className="text-xs uppercase tracking-wider text-ink/50 bg-ink/5 px-2 py-0.5 rounded">
{b.reason === "booking"
? "Buchung"
: b.reason === "maintenance"
? "Wartung"
: "Manuell"}
</span>
{b.source !== "manual" && (
<span className="text-xs uppercase tracking-wider text-moss-700 bg-moss-50 px-2 py-0.5 rounded">
{b.source}
</span>
)}
</div>
<div className="text-sm text-ink/70 mt-1">
{formatDate(b.startDate)} {formatDate(b.endDate)}
{b.note && <span className="text-ink/50"> · {b.note}</span>}
</div>
</div>
<button
onClick={() => removeBlock(b.id)}
className="text-sm px-3 py-1.5 rounded-full hover:bg-red-50 text-red-700 shrink-0"
>
Freigeben
</button>
</li>
))}
</ul>
)}
</div>
{/* iCal Sync */}
<div className="md:col-span-12 mt-2">
<div className="bg-moss-50 border border-moss-200 rounded-sm p-6 md:p-8">
<h2 className="font-display text-2xl mb-1">iCal-Synchronisation</h2>
<p className="text-sm text-ink/60 mb-6">
Fügen Sie den iCal-Link Ihres Airbnb- oder Booking.com-Inserats ein. Alle bisherigen
Buchungen dieser Quelle werden ersetzt. Den Link finden Sie in Airbnb unter{" "}
<strong>Kalender Verfügbarkeit exportieren</strong> und in Booking.com unter{" "}
<strong>Kalender iCal-Export</strong>.
</p>
<form onSubmit={syncIcal} className="grid md:grid-cols-12 gap-4 items-end">
<div className="md:col-span-3">
<Label htmlFor="ical-apartment">Wohnung</Label>
<select
id="ical-apartment"
value={icalApartmentId}
onChange={(e) => setIcalApartmentId(e.target.value)}
className="w-full bg-transparent border-b border-ink/25 focus:border-ink py-2.5 focus:outline-none"
>
{apartments.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<div className="md:col-span-2">
<Label htmlFor="ical-source">Plattform</Label>
<select
id="ical-source"
value={icalSource}
onChange={(e) => setIcalSource(e.target.value as "airbnb" | "booking")}
className="w-full bg-transparent border-b border-ink/25 focus:border-ink py-2.5 focus:outline-none"
>
<option value="airbnb">Airbnb</option>
<option value="booking">Booking.com</option>
</select>
</div>
<div className="md:col-span-5">
<Label htmlFor="ical-url">iCal-URL</Label>
<Input
id="ical-url"
type="url"
placeholder="https://www.airbnb.de/calendar/ical/…"
value={icalUrl}
onChange={(e) => setIcalUrl(e.target.value)}
required
/>
</div>
<div className="md:col-span-2">
<Button type="submit" disabled={icalSyncing} className="w-full">
{icalSyncing ? "Synchronisiert…" : "Jetzt synchronisieren"}
</Button>
</div>
</form>
{icalMessage && (
<p className={`mt-3 text-sm ${icalMessage.kind === "ok" ? "text-moss-700" : "text-red-700"}`}>
{icalMessage.text}
</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { formatDate, nightsBetween } from "@/lib/utils";
import type { InquiryStatus } from "@/types";
import { INQUIRY_STATUS_LABELS } from "@/types";
export interface InquiryRowData {
id: string;
apartmentName: string;
arrival: string;
departure: string;
guests: number;
name: string;
email: string;
phone: string | null;
message: string;
status: InquiryStatus;
createdAt: string;
}
const statusClasses: Record<InquiryStatus, string> = {
new: "bg-moss-500 text-parchment",
read: "bg-ink/10 text-ink",
confirmed: "bg-moss-700 text-parchment",
declined: "bg-red-100 text-red-800",
archived: "bg-ink/5 text-ink/60",
};
export function InquiryRow({ inquiry }: { inquiry: InquiryRowData }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<InquiryStatus>(inquiry.status);
const [pending, startTransition] = useTransition();
const [saving, setSaving] = useState(false);
async function updateStatus(next: InquiryStatus) {
setSaving(true);
try {
const res = await fetch(`/api/admin/inquiries/${inquiry.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: next }),
});
if (!res.ok) throw new Error();
setStatus(next);
startTransition(() => router.refresh());
} catch {
alert("Konnte Status nicht speichern.");
} finally {
setSaving(false);
}
}
async function remove() {
if (!confirm("Anfrage wirklich löschen? Das kann nicht rückgängig gemacht werden.")) return;
const res = await fetch(`/api/admin/inquiries/${inquiry.id}`, { method: "DELETE" });
if (res.ok) router.refresh();
}
const nights = nightsBetween(inquiry.arrival, inquiry.departure);
return (
<li className="border border-ink/10 rounded-sm bg-cream overflow-hidden">
<button
onClick={() => setOpen((v) => !v)}
className="w-full grid md:grid-cols-[auto_1fr_auto_auto_auto] items-center gap-4 px-5 py-4 text-left hover:bg-ink/[0.02] transition"
>
<span
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-full font-medium ${statusClasses[status]}`}
>
{INQUIRY_STATUS_LABELS[status]}
</span>
<div className="min-w-0">
<div className="font-medium truncate">{inquiry.name}</div>
<div className="text-xs text-ink/60 truncate">
{inquiry.apartmentName} · {inquiry.guests} Gäste · {nights} Nächte
</div>
</div>
<div className="hidden md:block text-sm text-ink/70">
{formatDate(inquiry.arrival)} {formatDate(inquiry.departure)}
</div>
<div className="hidden md:block text-xs text-ink/50 tabular-nums">
{formatDate(inquiry.createdAt)}
</div>
<span className="text-ink/40" aria-hidden>{open ? "▴" : "▾"}</span>
</button>
{open && (
<div className="border-t border-ink/10 px-5 py-5 bg-parchment/50 grid md:grid-cols-2 gap-6">
<dl className="space-y-3 text-sm">
<div>
<dt className="eyebrow mb-0.5">E-Mail</dt>
<dd>
<a href={`mailto:${inquiry.email}`} className="link-underline">
{inquiry.email}
</a>
</dd>
</div>
{inquiry.phone && (
<div>
<dt className="eyebrow mb-0.5">Telefon</dt>
<dd>{inquiry.phone}</dd>
</div>
)}
<div>
<dt className="eyebrow mb-0.5">Zeitraum</dt>
<dd>
{formatDate(inquiry.arrival)} {formatDate(inquiry.departure)} ({nights} N)
</dd>
</div>
<div>
<dt className="eyebrow mb-0.5">Eingegangen</dt>
<dd>{formatDate(inquiry.createdAt)}</dd>
</div>
</dl>
<div>
<div className="eyebrow mb-2">Nachricht</div>
<p className="whitespace-pre-line text-sm text-ink/85 leading-relaxed">
{inquiry.message || <span className="text-ink/40">(keine Nachricht)</span>}
</p>
</div>
<div className="md:col-span-2 flex flex-wrap items-center justify-between gap-4 pt-4 border-t border-ink/10">
<div className="flex items-center gap-3">
<label className="eyebrow">Status</label>
<select
value={status}
disabled={saving || pending}
onChange={(e) => updateStatus(e.target.value as InquiryStatus)}
className="bg-transparent border-b border-ink/25 focus:border-ink py-1.5 text-sm focus:outline-none"
>
{(Object.keys(INQUIRY_STATUS_LABELS) as InquiryStatus[]).map((s) => (
<option key={s} value={s}>{INQUIRY_STATUS_LABELS[s]}</option>
))}
</select>
</div>
<div className="flex items-center gap-3">
<a
href={`mailto:${inquiry.email}?subject=${encodeURIComponent("Ihre Anfrage — " + inquiry.apartmentName)}`}
className="px-4 py-2 text-sm rounded-full border border-ink/20 hover:bg-ink/5"
>
Antworten
</a>
<button
onClick={remove}
className="px-4 py-2 text-sm rounded-full text-red-700 hover:bg-red-50"
>
Löschen
</button>
</div>
</div>
</div>
)}
</li>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useEffect, useState } from "react";
import { DayPicker } from "react-day-picker";
import "react-day-picker/src/style.css";
import { de } from "date-fns/locale";
interface BlockRange {
from: Date;
to: Date;
}
export function AvailabilityCalendar({ slug }: { slug: string }) {
const [blocks, setBlocks] = useState<BlockRange[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/availability/${slug}`, { cache: "no-store" });
if (!res.ok) throw new Error();
const data = (await res.json()) as { blocks: { start: string; end: string }[] };
if (cancelled) return;
setBlocks(
data.blocks.map((b) => ({
from: new Date(b.start),
to: new Date(b.end),
}))
);
} catch {
if (!cancelled) setBlocks([]);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [slug]);
const today = new Date();
today.setHours(0, 0, 0, 0);
return (
<section className="mt-16 md:mt-20">
<div className="eyebrow mb-4">Verfügbarkeit</div>
<h2 className="font-display text-3xl md:text-4xl mb-3 leading-tight">
Wann darf es losgehen?
</h2>
<p className="text-ink/65 max-w-xl mb-8">
Durchgestrichene Tage sind bereits belegt. Die Belegung berücksichtigt
auch Buchungen über Airbnb und Booking.com.
</p>
<div className="bg-cream border border-ink/8 rounded-sm p-4 md:p-8 overflow-x-auto shadow-[0_1px_3px_rgba(28,38,32,0.04),0_4px_16px_rgba(28,38,32,0.05)]">
{loading ? (
<div className="h-[320px] flex items-center justify-center text-ink/40 text-sm">
Belegung wird geladen
</div>
) : (
<DayPicker
mode="range"
locale={de}
numberOfMonths={2}
weekStartsOn={1}
disabled={[{ before: today }, ...blocks]}
fromMonth={today}
showOutsideDays={false}
className="mx-auto"
/>
)}
</div>
<div className="mt-4 flex flex-wrap gap-5 text-xs text-ink/55">
<span className="inline-flex items-center gap-2">
<span className="inline-block h-3 w-3 bg-moss-500 rounded-sm" />
ausgewählter Zeitraum
</span>
<span className="inline-flex items-center gap-2">
<span className="inline-block h-3 w-3 bg-stone-soft rounded-sm" style={{ textDecoration: "line-through" }} />
belegt
</span>
</div>
</section>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { format, parseISO } from "date-fns";
import { de } from "date-fns/locale";
interface Period {
start: string;
end: string;
nights: number;
}
function formatRange(start: string, end: string) {
const s = parseISO(start);
const e = parseISO(end);
return {
startLabel: format(s, "EEE, d. MMM", { locale: de }),
endLabel: format(e, "EEE, d. MMM yyyy", { locale: de }),
};
}
export function AvailablePeriods({ slug }: { slug: string }) {
const [periods, setPeriods] = useState<Period[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetch(`/api/available-periods/${slug}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) setPeriods(data.periods ?? []);
})
.catch(() => {
if (!cancelled) setPeriods([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [slug]);
if (loading) {
return (
<section className="mt-16 md:mt-20">
<div className="eyebrow mb-4">Mögliche Reisen</div>
<div className="h-40 flex items-center justify-center text-ink/40 text-sm">
Wird geladen
</div>
</section>
);
}
if (periods.length === 0) {
return (
<section className="mt-16 md:mt-20">
<div className="eyebrow mb-4">Mögliche Reisen</div>
<p className="text-ink/60 text-sm">
Im Moment keine vorberechneten Zeiträume verfügbar. Bitte direkt anfragen.
</p>
</section>
);
}
return (
<section className="mt-16 md:mt-20">
<div className="eyebrow mb-4">Mögliche Reisen</div>
<h2 className="font-display text-3xl md:text-4xl mb-3 leading-tight">
Diese Zeiträume sind noch frei.
</h2>
<p className="text-ink/65 max-w-xl mb-8">
Alle Vorschläge sind 7 Nächte klicken Sie auf einen, um das Anfrageformular direkt
mit diesem Zeitraum zu befüllen.
</p>
<ul className="grid sm:grid-cols-2 gap-3">
{periods.map((p) => {
const { startLabel, endLabel } = formatRange(p.start, p.end);
const href = `/anfrage?wohnung=${slug}&arrival=${p.start}&departure=${p.end}`;
return (
<li key={p.start}>
<Link
href={href}
className="group flex items-center justify-between border border-ink/12 bg-cream/60 hover:bg-cream hover:border-ink/25 hover:shadow-card rounded-sm px-5 py-4 transition-all duration-200"
>
<div>
<div className="font-display text-xl leading-tight">
{startLabel}
<span className="text-ink/35 mx-2 font-sans text-base"></span>
{endLabel}
</div>
<div className="text-xs text-ink/50 mt-1">{p.nights} Nächte</div>
</div>
<span className="text-xs text-moss-600 font-medium opacity-0 group-hover:opacity-100 translate-x-1 group-hover:translate-x-0 transition-all duration-200 shrink-0 ml-4">
Anfragen
</span>
</Link>
</li>
);
})}
</ul>
</section>
);
}

View File

@@ -0,0 +1,67 @@
import { cn } from "@/lib/utils";
interface Props {
airbnbUrl?: string | null;
bookingUrl?: string | null;
className?: string;
}
function AirbnbLogo() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M11.996 0C5.372 0 0 5.372 0 12s5.372 12 11.996 12C18.627 24 24 18.628 24 12S18.627 0 11.996 0zm.004 4.29c.907 0 1.642.735 1.642 1.641 0 .907-.735 1.643-1.642 1.643-.906 0-1.641-.736-1.641-1.643 0-.906.735-1.641 1.641-1.641zm4.372 11.653c-.195.43-.482.808-.84 1.1-.36.29-.784.492-1.236.584-.227.047-.458.07-.69.07-.43 0-.857-.083-1.26-.243l-.346-.134-.346.134c-.403.16-.83.243-1.26.243-.232 0-.463-.023-.69-.07-.452-.092-.876-.294-1.236-.584-.358-.292-.645-.67-.84-1.1-.474-1.054-.192-2.237.28-3.337l2.092-4.738c.09-.203.29-.335.517-.335h.966c.227 0 .427.132.517.335l2.092 4.738c.472 1.1.754 2.283.28 3.337z" />
</svg>
);
}
function BookingLogo() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M19.5 0h-15A4.5 4.5 0 0 0 0 4.5v15A4.5 4.5 0 0 0 4.5 24h15a4.5 4.5 0 0 0 4.5-4.5v-15A4.5 4.5 0 0 0 19.5 0zM9.4 16.9H7V7.1h2.4v9.8zm4.8 0h-2.4V7.1H14c2.2 0 3.6 1.3 3.6 3.3 0 1.4-.7 2.5-1.9 3l2.2 3.5h-2.7l-1.9-3.2h-.1v3.2zm0-5h-.1V9h.2c.9 0 1.4.5 1.4 1.4 0 1-.5 1.5-1.5 1.5z" />
</svg>
);
}
export function BookingPlatforms({ airbnbUrl, bookingUrl, className }: Props) {
if (!airbnbUrl && !bookingUrl) return null;
return (
<div className={cn("space-y-2.5", className)}>
<p className="text-[11px] uppercase tracking-[0.18em] text-ink/45 font-medium mb-3">
Auch buchbar auf
</p>
{airbnbUrl && (
<a
href={airbnbUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between w-full border border-ink/12 bg-parchment/60 px-4 py-3 rounded-sm hover:border-ink/25 hover:bg-parchment transition-all duration-200 group text-sm"
>
<span className="flex items-center gap-3 text-[#FF5A5F]">
<AirbnbLogo />
<span className="font-medium text-ink">Airbnb</span>
</span>
<span className="text-ink/30 group-hover:text-ink/60 group-hover:translate-x-0.5 transition-all duration-200 text-xs">
</span>
</a>
)}
{bookingUrl && (
<a
href={bookingUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between w-full border border-ink/12 bg-parchment/60 px-4 py-3 rounded-sm hover:border-ink/25 hover:bg-parchment transition-all duration-200 group text-sm"
>
<span className="flex items-center gap-3 text-[#003580]">
<BookingLogo />
<span className="font-medium text-ink">Booking.com</span>
</span>
<span className="text-ink/30 group-hover:text-ink/60 group-hover:translate-x-0.5 transition-all duration-200 text-xs">
</span>
</a>
)}
</div>
);
}

View File

@@ -0,0 +1,26 @@
export function Features({ features }: { features: string[] }) {
if (features.length === 0) return null;
return (
<section className="mt-16 md:mt-20">
<div className="eyebrow mb-4">Ausstattung</div>
<h2 className="font-display text-3xl md:text-4xl mb-8 leading-tight">
Alles da, nichts zu viel.
</h2>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-x-10 gap-y-3">
{features.map((f) => (
<li
key={f}
className="flex items-start gap-3 py-2 border-b border-ink/10 text-ink/80"
>
<span
aria-hidden
className="mt-2 h-1.5 w-1.5 rounded-full bg-moss-500 shrink-0"
/>
<span>{f}</span>
</li>
))}
</ul>
</section>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
export function Gallery({ images, alt }: { images: string[]; alt: string }) {
const [open, setOpen] = useState<number | null>(null);
const touchStartX = useRef<number | null>(null);
const thumbsRef = useRef<HTMLDivElement>(null);
// Keyboard + scroll lock
useEffect(() => {
if (open === null) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(null);
if (e.key === "ArrowRight") setOpen((i) => (i === null ? null : (i + 1) % images.length));
if (e.key === "ArrowLeft") setOpen((i) => (i === null ? null : (i - 1 + images.length) % images.length));
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, images.length]);
// Scroll active thumbnail into view
useEffect(() => {
if (open === null || !thumbsRef.current) return;
const btn = thumbsRef.current.children[open] as HTMLElement | undefined;
btn?.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
}, [open]);
if (images.length === 0) return null;
const [hero, ...rest] = images;
const go = (dir: 1 | -1) =>
setOpen((i) => (i === null ? null : (i + dir + images.length) % images.length));
return (
<>
{/* Grid */}
<div className="grid md:grid-cols-5 gap-2 md:gap-3">
<button
onClick={() => setOpen(0)}
className="md:col-span-3 relative aspect-[4/3] overflow-hidden rounded-sm group"
>
<Image
src={hero}
alt={`${alt} — Ansicht 1`}
fill
priority
sizes="(max-width: 768px) 100vw, 60vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.02]"
/>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span className="bg-ink/70 text-parchment text-xs px-3 py-1.5 rounded-full backdrop-blur-sm">
Alle {images.length} Fotos
</span>
</div>
</button>
<div className="md:col-span-2 grid grid-cols-2 gap-2 md:gap-3">
{rest.slice(0, 4).map((src, idx) => (
<button
key={src}
onClick={() => setOpen(idx + 1)}
className="relative aspect-[4/3] overflow-hidden rounded-sm group"
>
<Image
src={src}
alt={`${alt} — Ansicht ${idx + 2}`}
fill
sizes="(max-width: 768px) 50vw, 25vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
/>
{idx === 3 && images.length > 5 && (
<div className="absolute inset-0 bg-ink/55 text-parchment flex items-center justify-center text-sm font-medium">
+{images.length - 5}
</div>
)}
</button>
))}
</div>
</div>
{/* Lightbox */}
{open !== null && (
<div
className="fixed inset-0 z-50 bg-ink/95 flex flex-col"
onClick={() => setOpen(null)}
role="dialog"
aria-modal="true"
onTouchStart={(e) => { touchStartX.current = e.touches[0].clientX; }}
onTouchEnd={(e) => {
if (touchStartX.current === null) return;
const dx = e.changedTouches[0].clientX - (touchStartX.current as number);
if (Math.abs(dx) > 50) go(dx < 0 ? 1 : -1);
touchStartX.current = null;
}}
>
{/* Top bar */}
<div className="shrink-0 flex items-center justify-between px-5 py-4" onClick={(e) => e.stopPropagation()}>
<span className="text-parchment/50 text-sm tabular-nums">{open + 1} / {images.length}</span>
<button
onClick={() => setOpen(null)}
className="text-parchment/70 hover:text-parchment transition-colors text-xl leading-none px-2 py-1"
aria-label="Galerie schließen"
>
</button>
</div>
{/* Image */}
<div
className="flex-1 flex items-center justify-center relative px-14 md:px-20 min-h-0"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={(e) => { e.stopPropagation(); go(-1); }}
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 text-parchment/60 hover:text-parchment text-5xl px-3 py-4 transition-colors select-none z-10"
aria-label="Vorheriges Bild"
></button>
<div className="relative w-full max-w-5xl aspect-[4/3]">
<Image
key={open}
src={images[open]}
alt={`${alt} — Ansicht ${open + 1}`}
fill
sizes="90vw"
className="object-contain"
/>
</div>
<button
onClick={(e) => { e.stopPropagation(); go(1); }}
className="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 text-parchment/60 hover:text-parchment text-5xl px-3 py-4 transition-colors select-none z-10"
aria-label="Nächstes Bild"
></button>
</div>
{/* Thumbnail strip */}
<div
className="shrink-0 py-3 px-4"
onClick={(e) => e.stopPropagation()}
>
<div ref={thumbsRef} className="flex gap-2 overflow-x-auto" style={{ scrollbarWidth: "none" }}>
{images.map((src, idx) => (
<button
key={src}
onClick={() => setOpen(idx)}
className={`relative shrink-0 w-14 h-10 rounded overflow-hidden transition-all duration-200 ${
idx === open ? "ring-2 ring-parchment opacity-100" : "opacity-35 hover:opacity-65"
}`}
>
<Image src={src} alt="" fill sizes="56px" className="object-cover" />
</button>
))}
</div>
</div>
</div>
)}
</>
);
}

53
components/home/About.tsx Normal file
View File

@@ -0,0 +1,53 @@
export function About() {
return (
<section className="py-20 md:py-28 border-t border-ink/10">
<div className="container">
{/* Stats row */}
<div className="grid grid-cols-3 gap-4 md:gap-0 md:divide-x divide-ink/10 border border-ink/10 rounded-sm mb-16 md:mb-20">
{[
{ value: "2", label: "Ferienwohnungen" },
{ value: "400 km", label: "Radwege ringsum" },
{ value: "90 min", label: "ab Berlin" },
].map((s) => (
<div key={s.label} className="px-4 py-5 md:px-10 md:py-7 text-center">
<div className="font-display text-2xl md:text-4xl leading-none text-moss-700 mb-1">{s.value}</div>
<div className="text-[10px] md:text-xs text-ink/50 uppercase tracking-[0.12em] md:tracking-[0.15em]">{s.label}</div>
</div>
))}
</div>
{/* Editorial text block */}
<div className="grid md:grid-cols-12 gap-10 md:gap-16">
<div className="md:col-span-4">
<div className="eyebrow mb-4">Über uns</div>
<h2 className="font-display text-display-md">
Zwei Häuser,<br />
<span className="italic text-moss-600">eine Haltung.</span>
</h2>
</div>
<div className="md:col-span-7 md:col-start-6 space-y-6 text-ink/80 leading-relaxed">
<p className="text-lg">
Wir vermieten keine Hotelzimmer und führen keine Rezeption. Was wir anbieten,
sind zwei Wohnungen, die wir selbst bewohnen würden und manchmal tun.
</p>
<p>
Kahnblick liegt am Hafen von Lübbenau, Erlenhof steht unter den alten Bäumen
in Burg. Beide sind verschieden, beide sind still. Beide sind gedacht für
Menschen, die lieber einen Tag länger bleiben als einen schneller weiter.
</p>
<figure className="pt-6 border-t border-ink/10 mt-10">
<blockquote className="font-display text-2xl md:text-3xl leading-tight italic text-moss-700">
Wir glauben, dass ein gutes Frühstück länger dauern darf als eine
Vorstandssitzung."
</blockquote>
<figcaption className="mt-4 text-sm text-ink/55">
Familie Musterfrau, Gastgeber
</figcaption>
</figure>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,138 @@
import Image from "next/image";
import Link from "next/link";
import type { Apartment } from "@/types";
import { formatPrice } from "@/lib/utils";
export function ApartmentPreview({
apartments,
}: {
apartments: Apartment[];
}) {
return (
<section id="wohnungen" className="py-20 md:py-28 border-t border-ink/10 scroll-mt-24">
<div className="container">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-14 md:mb-20">
<div>
<div className="eyebrow mb-4">Unsere Wohnungen</div>
<h2 className="font-display text-display-lg max-w-2xl leading-[1.02]">
Zwei Orte, an die man<br className="hidden md:inline" />{" "}
<span className="italic text-moss-600">gerne zurückkehrt</span>.
</h2>
</div>
<p className="text-ink/65 max-w-sm text-sm md:text-base">
Jedes Haus hat seine eigene Geschichte wählen Sie, was für Ihre
nächste Auszeit passt.
</p>
</div>
<div className="space-y-20 md:space-y-28">
{apartments.map((apt, i) => {
const isEven = i % 2 === 0;
const hasPlatforms = apt.airbnbUrl || apt.bookingUrl;
return (
<article
key={apt.id}
className="grid md:grid-cols-12 gap-8 md:gap-14 items-center"
>
<Link
href={`/wohnungen/${apt.slug}`}
className={`md:col-span-7 relative aspect-[3/2] md:aspect-[4/3] overflow-hidden rounded-sm group ${
isEven ? "" : "md:col-start-6"
}`}
>
<Image
src={apt.images[0] ?? ""}
alt={apt.name}
fill
sizes="(max-width: 768px) 100vw, 58vw"
className="object-cover transition-transform duration-[1200ms] ease-out group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-ink/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="absolute top-5 left-5 bg-ink/60 backdrop-blur-sm text-parchment px-3 py-1 text-xs font-medium rounded-full tracking-widest">
0{i + 1}
</div>
</Link>
<div
className={`md:col-span-5 ${
isEven ? "md:col-start-8" : "md:col-start-1 md:row-start-1"
}`}
>
<div className="eyebrow mb-3">{apt.tagline}</div>
<h3 className="font-display text-4xl md:text-5xl mb-5 leading-none">
{apt.name}
</h3>
<p className="text-ink/75 leading-relaxed mb-6">
{apt.shortDescription}
</p>
<dl className="grid grid-cols-3 gap-4 py-5 border-y border-ink/10 text-sm">
<div>
<dt className="eyebrow mb-1.5">Gäste</dt>
<dd className="font-display text-2xl leading-none">
bis {apt.maxGuests}
</dd>
</div>
<div>
<dt className="eyebrow mb-1.5">Größe</dt>
<dd className="font-display text-2xl leading-none">{apt.sizeSqm} m²</dd>
</div>
<div>
<dt className="eyebrow mb-1.5">ab / Nacht</dt>
<dd className="font-display text-2xl leading-none text-moss-700">
{formatPrice(apt.priceFrom)}
</dd>
</div>
</dl>
<div className="mt-7 flex flex-wrap gap-3">
<Link
href={`/wohnungen/${apt.slug}`}
className="inline-flex items-center gap-2 bg-ink text-parchment px-6 py-3 rounded-full text-sm hover:bg-moss-700 transition-colors"
>
Wohnung ansehen
<span aria-hidden></span>
</Link>
<Link
href={`/anfrage?wohnung=${apt.slug}`}
className="inline-flex items-center gap-2 px-6 py-3 rounded-full text-sm border border-ink/15 hover:border-ink/30 hover:bg-ink/5 transition"
>
Direkt anfragen
</Link>
</div>
{hasPlatforms && (
<div className="mt-5 flex items-center gap-3 text-xs text-ink/45">
<span>Auch auf</span>
{apt.airbnbUrl && (
<a
href={apt.airbnbUrl}
target="_blank"
rel="noopener noreferrer"
className="text-ink/60 hover:text-ink transition underline underline-offset-2"
>
Airbnb
</a>
)}
{apt.airbnbUrl && apt.bookingUrl && <span className="text-ink/25">·</span>}
{apt.bookingUrl && (
<a
href={apt.bookingUrl}
target="_blank"
rel="noopener noreferrer"
className="text-ink/60 hover:text-ink transition underline underline-offset-2"
>
Booking.com
</a>
)}
</div>
)}
</div>
</article>
);
})}
</div>
</div>
</section>
);
}

78
components/home/Hero.tsx Normal file
View File

@@ -0,0 +1,78 @@
import Image from "next/image";
import Link from "next/link";
export function Hero() {
return (
<section className="-mt-[72px] md:-mt-[80px] relative min-h-[100vh] flex flex-col overflow-hidden">
{/* Full-bleed background image */}
<div className="absolute inset-0 z-0">
<Image
src="/images/b6ca2c_a39e632a73b944dbbd6887cdb627223d~mv2.avif"
alt="Nebel über dem Spreewald"
fill
priority
sizes="100vw"
className="object-cover"
/>
{/* Base dark veil */}
<div className="absolute inset-0 bg-ink/40" />
{/* Strong bottom-to-top gradient for text area */}
<div className="absolute inset-0 bg-gradient-to-t from-ink/85 via-ink/50 to-ink/15" />
{/* Left-side reinforcement for text column */}
<div className="absolute inset-0 bg-gradient-to-r from-ink/50 via-ink/20 to-transparent" />
</div>
{/* Content — pinned to bottom */}
<div className="relative z-10 mt-auto container pb-16 md:pb-24 pt-32 md:pt-40">
<div className="max-w-4xl">
<div className="eyebrow !text-parchment/60 mb-6">Ferienwohnungen · Spreewald</div>
<h1 className="font-display text-display-xl text-parchment leading-[0.96]" style={{ textShadow: "0 2px 24px rgba(28,38,32,0.5)" }}>
Hier hat der<br />
Morgen noch{" "}
<span className="italic text-moss-300">Nebel</span>,<br />
und die Nacht<br />
noch{" "}
<span className="italic text-moss-300">Sterne</span>.
</h1>
<div className="mt-10 flex flex-col sm:flex-row gap-3">
<Link
href="#wohnungen"
className="inline-flex items-center justify-center gap-2 bg-parchment text-ink px-7 py-3.5 rounded-full text-sm font-medium hover:bg-cream transition-colors"
>
Die Wohnungen ansehen
<span aria-hidden></span>
</Link>
<Link
href="/anfrage"
className="inline-flex items-center justify-center gap-2 border border-parchment/30 text-parchment px-7 py-3.5 rounded-full text-sm hover:border-parchment/60 hover:bg-parchment/10 transition"
>
Anfrage senden
</Link>
</div>
{/* Trust signals */}
<div className="mt-10 pt-8 border-t border-parchment/15 flex flex-wrap gap-6 text-xs text-parchment/50">
<span className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-moss-400 inline-block" aria-hidden />
Keine Provision
</span>
<span className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-moss-400 inline-block" aria-hidden />
Direkt beim Gastgeber
</span>
<span className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-moss-400 inline-block" aria-hidden />
Antwort in 24 Stunden
</span>
</div>
</div>
</div>
{/* Bottom scroll hint */}
<div className="relative z-10 container pb-8 flex items-center justify-end">
<span className="text-xs text-parchment/35 tracking-[0.2em] uppercase">Scrollen</span>
</div>
</section>
);
}

View File

@@ -0,0 +1,136 @@
import Image from "next/image";
import Link from "next/link";
import { SpreeMap } from "./SpreeMap";
const highlights = [
{
title: "Die Kähne",
text: "Vom Hafen Lübbenau starten täglich die traditionellen Spreewaldkähne. Drei Stunden durch das UNESCO-Biosphärenreservat — ohne Motor, mit Schilf und Stille.",
image: "https://images.unsplash.com/photo-1528181304800-259b08848526?auto=format&fit=crop&w=800&q=75",
},
{
title: "Die Wege",
text: "Über 400 km Radwege führen durch die Region. Unsere Fahrräder stehen für Sie bereit, Tourentipps liegen in jeder Wohnung aus.",
image: "https://images.unsplash.com/photo-1517649763962-0c623066013b?auto=format&fit=crop&w=800&q=75",
},
{
title: "Der Tisch",
text: "Gurken, Leinöl, frischer Fisch, sorbische Hochzeitssuppe — die Spreewaldküche ist bodenständig und überraschend. Wir haben unsere Lieblingsadressen notiert.",
image: "https://images.unsplash.com/photo-1476224203421-9ac39bcb3327?auto=format&fit=crop&w=800&q=75",
},
];
export function Location() {
return (
<section id="umgebung" className="py-20 md:py-28 border-t border-ink/10 scroll-mt-24">
<div className="container">
<div className="max-w-3xl mb-14 md:mb-20">
<div className="eyebrow mb-4">Die Umgebung</div>
<h2 className="font-display text-display-lg leading-[1.02]">
Zwischen Wasser,<br />
<span className="italic text-moss-600">Wald und Wegen.</span>
</h2>
<p className="mt-6 text-ink/70 max-w-xl leading-relaxed">
Der Spreewald ist ein UNESCO-Biosphärenreservat. Weniger als 90 Minuten
von Berlin entfernt und doch eine andere Welt.
</p>
</div>
{/* Highlight cards */}
<div className="grid md:grid-cols-3 gap-6 md:gap-8">
{highlights.map((h) => (
<article
key={h.title}
className="group bg-cream/60 rounded-sm overflow-hidden"
>
<div className="relative aspect-[5/4] overflow-hidden">
<Image
src={h.image}
alt={h.title}
fill
sizes="(max-width: 768px) 100vw, 33vw"
className="object-cover transition-transform duration-[1200ms] ease-out group-hover:scale-[1.04]"
/>
</div>
<div className="p-6 md:p-7">
<h3 className="font-display text-2xl mb-3">{h.title}</h3>
<p className="text-sm text-ink/75 leading-relaxed">{h.text}</p>
</div>
</article>
))}
</div>
{/* Map */}
<div className="mt-16 md:mt-20">
<div className="eyebrow mb-4">Wo wir sind</div>
<h3 className="font-display text-2xl md:text-3xl mb-3 leading-tight">
Vetschau/Spreewald {" "}
<span className="italic text-moss-600">mitten im Biosphärenreservat</span>.
</h3>
<p className="text-ink/65 text-sm mb-6 max-w-xl">
Kraftwerkstraße 10 · 03226 Vetschau. Klicken Sie auf den Marker für
die Routenplanung.
</p>
<div className="relative overflow-hidden rounded-sm border border-ink/10 shadow-[0_1px_3px_rgba(28,38,32,0.04),0_4px_16px_rgba(28,38,32,0.05)]">
<SpreeMap />
<div className="absolute inset-0 pointer-events-none ring-1 ring-inset ring-ink/8 rounded-sm" />
</div>
{/* Address cards */}
<div className="mt-5 grid sm:grid-cols-2 gap-4">
<div className="flex items-start justify-between gap-4 bg-cream/60 border border-ink/10 rounded-sm px-5 py-4">
<div>
<div className="eyebrow mb-1">Spreewaldzeit</div>
<div className="font-display text-lg leading-tight">Kraftwerkstraße 10</div>
<div className="text-sm text-ink/65 mt-1">03226 Vetschau/Spreewald</div>
</div>
<a
href="https://www.google.com/maps/dir/?api=1&destination=51.7753,14.0966"
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-xs text-moss-700 border border-moss-300 px-3 py-1.5 rounded-full hover:bg-moss-50 transition-colors mt-1"
>
Route
</a>
</div>
<div className="flex items-start justify-between gap-4 bg-cream/60 border border-ink/10 rounded-sm px-5 py-4">
<div>
<div className="eyebrow mb-1">Anfahrt</div>
<div className="font-display text-lg leading-tight">Berlin Vetschau</div>
<div className="text-sm text-ink/65 mt-1">ca. 90 Min · A13 Richtung Cottbus</div>
</div>
<a
href="https://www.google.com/maps/dir/?api=1&destination=51.7753,14.0966"
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-xs text-moss-700 border border-moss-300 px-3 py-1.5 rounded-full hover:bg-moss-50 transition-colors mt-1"
>
Route
</a>
</div>
</div>
</div>
{/* CTA */}
<div className="mt-16 md:mt-20 flex flex-col md:flex-row items-start md:items-center justify-between gap-6 p-8 md:p-10 bg-moss-800 text-parchment rounded-sm">
<div>
<div className="eyebrow !text-parchment/60 mb-2">Bereit?</div>
<h3 className="font-display text-2xl md:text-3xl leading-tight">
Fragen Sie unverbindlich an <br className="hidden md:block" />
wir melden uns innerhalb von 24 Stunden.
</h3>
</div>
<Link
href="/anfrage"
className="inline-flex items-center gap-2 bg-parchment text-ink px-7 py-3.5 rounded-full text-sm hover:bg-cream transition-colors shrink-0"
>
Zur Anfrage
<span aria-hidden></span>
</Link>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,228 @@
"use client";
import { useRef } from "react";
type Category = "Natur" | "Museum" | "Wellness" | "Erlebnis" | "Restaurant" | "Ausflug";
interface Place {
name: string;
category: Category;
distance: string;
description: string;
mapsQuery: string;
rating?: string;
}
const PLACES: Place[] = [
{
name: "Spreewaldfahrt Familie Goertz",
category: "Ausflug",
distance: "12 km",
description:
"Traditionelle Kahnfahrt durch das UNESCO-Biosphärenreservat. Stille Fließe, Schilf und die Geschichte des Spreewalds — erzählt vom Kahnführer.",
mapsQuery: "Spreewaldfahrt+Familie+Goertz+Lübbenau",
rating: "4.7",
},
{
name: "Spreewald Therme",
category: "Wellness",
distance: "15 km",
description:
"Entspannung nach einem Wandertag: große Saunalandschaft, Außenbecken und Ruhebereich mitten im Biosphärenreservat. Burg (Spreewald).",
mapsQuery: "Spreewald+Therme+Burg",
rating: "4.2",
},
{
name: "Slawischer Burgwall Raddusch",
category: "Museum",
distance: "5 km",
description:
"Rekonstruierter Ringwall aus dem 9. Jahrhundert — das älteste sichtbare Baudenkmal der Region. Mit Aussichtsplattform über die Teiche.",
mapsQuery: "Slawischer+Burgwall+Raddusch",
rating: "4.4",
},
{
name: "Freilandmuseum Lehde",
category: "Museum",
distance: "20 km",
description:
"Vier original erhaltene Spreewaldgehöfte aus dem 19. Jahrhundert — Einblick in das Leben der sorbischen Bevölkerung. Im Dorf Lehde, nur per Kahn oder Rad erreichbar.",
mapsQuery: "Freilandmuseum+Lehde+Spreewald",
rating: "4.5",
},
{
name: "Gurkenradweg",
category: "Natur",
distance: "direkt",
description:
"250 km Radwegenetz durch Gurkenfelder, Wasserwege und Dörfer — das Herzstück des Spreewalds. Flach, gut beschildert, für jedes Tempo geeignet.",
mapsQuery: "Gurkenradweg+Spreewald",
},
{
name: "Tropical Islands",
category: "Erlebnis",
distance: "35 km",
description:
"Die größte Indoortropenwelt der Welt in einer umgebauten Luftschiffhalle. Strand, Wasserrutschen, Regenwald und Sauna unter einem Dach.",
mapsQuery: "Tropical+Islands+Brand",
rating: "4.3",
},
{
name: "Schloss & Park Branitz",
category: "Natur",
distance: "28 km",
description:
"Das Lebenswerk des exzentrischen Fürsten Pückler-Muskau: ein englischer Landschaftspark mit einzigartigen Erdpyramiden und barockem Schloss in Cottbus.",
mapsQuery: "Schloss+Branitz+Cottbus",
rating: "4.6",
},
{
name: "Spreewood Distillers",
category: "Erlebnis",
distance: "18 km",
description:
"Whisky, Gin und Liköre aus dem Biosphärenreservat. Führungen durch die Destillerie, Tastings und ein kleiner Shop direkt vor Ort in Schlepzig.",
mapsQuery: "Spreewood+Distillers+Schlepzig",
rating: "4.2",
},
{
name: "Biberhof & Aquarium",
category: "Natur",
distance: "15 km",
description:
"Biber, Fischotter, Fischadler — die typischen Bewohner des Spreewalds zum Anfassen nah. Beliebtes Ausflugsziel für Familien in Raddusch.",
mapsQuery: "Biberhof+Raddusch+Spreewald",
rating: "4.2",
},
{
name: "Bismarckturm Burg",
category: "Ausflug",
distance: "17 km",
description:
"Historischer Aussichtsturm auf einem der wenigen Hügel des Spreewalds. Bei klarem Wetter Panoramablick über das gesamte Biosphärenreservat.",
mapsQuery: "Bismarckturm+Burg+Spreewald",
rating: "3.6",
},
];
const categoryStyles: Record<Category, { bg: string; text: string; dot: string }> = {
Natur: { bg: "bg-moss-50", text: "text-moss-700", dot: "bg-moss-500" },
Museum: { bg: "bg-sand-50", text: "text-sand-700", dot: "bg-sand-500" },
Wellness: { bg: "bg-blue-50", text: "text-blue-700", dot: "bg-blue-400" },
Erlebnis: { bg: "bg-rose-50", text: "text-rose-700", dot: "bg-rose-400" },
Restaurant:{ bg: "bg-orange-50", text: "text-orange-700", dot: "bg-orange-400" },
Ausflug: { bg: "bg-teal-50", text: "text-teal-700", dot: "bg-teal-500" },
};
export function PlacesToVisit() {
const scrollRef = useRef<HTMLDivElement>(null);
const scroll = (dir: "left" | "right") => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: dir === "right" ? 320 : -320, behavior: "smooth" });
};
return (
<section id="ausflugsziele" className="py-20 md:py-28 border-t border-ink/10 scroll-mt-24">
<div className="container">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-10 md:mb-14">
<div>
<div className="eyebrow mb-4">Die Region</div>
<h2 className="font-display text-display-lg max-w-2xl leading-[1.02]">
Was Sie nicht verpassen{" "}
<span className="italic text-moss-600">sollten.</span>
</h2>
</div>
<div className="flex items-center gap-4">
<p className="text-ink/60 max-w-xs text-sm md:text-base shrink-0 hidden md:block">
Ausgewählte Highlights im Umkreis von 50 km.
</p>
{/* Arrow buttons */}
<div className="flex gap-2 shrink-0">
<button
onClick={() => scroll("left")}
aria-label="Zurück"
className="h-9 w-9 rounded-full border border-ink/15 bg-cream flex items-center justify-center text-ink/50 hover:border-ink/30 hover:text-ink/80 transition-colors"
>
</button>
<button
onClick={() => scroll("right")}
aria-label="Weiter"
className="h-9 w-9 rounded-full border border-ink/15 bg-cream flex items-center justify-center text-ink/50 hover:border-ink/30 hover:text-ink/80 transition-colors"
>
</button>
</div>
</div>
</div>
{/* Horizontal scroll container */}
<div className="relative">
{/* Fade edges */}
<div className="pointer-events-none absolute left-0 top-0 bottom-4 w-8 bg-gradient-to-r from-parchment to-transparent z-10" />
<div className="pointer-events-none absolute right-0 top-0 bottom-4 w-16 bg-gradient-to-l from-parchment to-transparent z-10" />
<div
ref={scrollRef}
className="flex gap-4 overflow-x-auto pb-4 scroll-smooth snap-x snap-mandatory"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
{PLACES.map((place) => {
const style = categoryStyles[place.category];
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${place.mapsQuery}`;
return (
<a
key={place.name}
href={mapsUrl}
target="_blank"
rel="noopener noreferrer"
className="group snap-start shrink-0 w-[280px] md:w-[300px] bg-cream border border-ink/10 rounded-sm p-5 md:p-6 flex flex-col hover:border-ink/25 hover:shadow-card transition-all duration-200"
>
{/* Top row: category + distance */}
<div className="flex items-center justify-between mb-4">
<span className={`inline-flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-[0.15em] px-2.5 py-1 rounded-full ${style.bg} ${style.text}`}>
<span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} aria-hidden />
{place.category}
</span>
<span className="text-xs text-ink/45 tabular-nums">{place.distance}</span>
</div>
{/* Name */}
<h3 className="font-display text-xl leading-tight mb-3 group-hover:text-moss-700 transition-colors">
{place.name}
</h3>
{/* Description */}
<p className="text-sm text-ink/70 leading-relaxed flex-1">
{place.description}
</p>
{/* Footer: rating + link hint */}
<div className="mt-4 pt-4 border-t border-ink/8 flex items-center justify-between">
{place.rating ? (
<span className="text-xs text-ink/50 flex items-center gap-1">
<span className="text-sand-500"></span>
{place.rating} auf TripAdvisor
</span>
) : (
<span />
)}
<span className="text-xs text-ink/40 group-hover:text-moss-600 transition-colors">
Maps
</span>
</div>
</a>
);
})}
</div>
</div>
<p className="mt-3 text-xs text-ink/40">
Alle Entfernungen ab Vetschau/Spreewald. Bewertungen via TripAdvisor.
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,15 @@
export function SpreeMap() {
return (
<iframe
src="https://maps.google.com/maps?q=Kraftwerkstra%C3%9Fe+10%2C+03226+Vetschau%2FSpreewald&z=15&output=embed"
width="100%"
height="300"
className="md:!h-[440px]"
style={{ border: 0, display: "block" }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="Spreewaldzeit Kraftwerkstraße 10, Vetschau/Spreewald"
/>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import Link from "next/link";
import { inquirySchema, type InquiryInput } from "@/lib/validations";
import { Input, Textarea, Label, FieldError } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { cn } from "@/lib/utils";
interface ApartmentOption {
slug: string;
name: string;
}
interface Props {
apartments: ApartmentOption[];
defaultSlug?: string;
defaultArrival?: string;
defaultDeparture?: string;
}
export function InquiryForm({ apartments, defaultSlug, defaultArrival, defaultDeparture }: Props) {
const [state, setState] = useState<"idle" | "loading" | "success" | "error">("idle");
const [serverError, setServerError] = useState<string | null>(null);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<InquiryInput>({
resolver: zodResolver(inquirySchema),
defaultValues: {
apartmentSlug: defaultSlug && apartments.some((a) => a.slug === defaultSlug) ? defaultSlug : apartments[0]?.slug,
arrival: defaultArrival ?? "",
departure: defaultDeparture ?? "",
guests: 2,
gdpr: false as unknown as true,
website: "",
},
});
async function onSubmit(values: InquiryInput) {
setState("loading");
setServerError(null);
try {
const res = await fetch("/api/inquiries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Anfrage konnte nicht gesendet werden.");
}
reset();
setState("success");
window.scrollTo({ top: 0, behavior: "smooth" });
} catch (err) {
setState("error");
setServerError(err instanceof Error ? err.message : "Unbekannter Fehler.");
}
}
if (state === "success") {
return (
<div className="bg-moss-50 border border-moss-200 rounded-sm p-8 md:p-12 animate-fade-up">
<div className="eyebrow mb-3">Danke!</div>
<h2 className="font-display text-3xl md:text-4xl leading-tight mb-4">
Ihre Anfrage ist bei uns.
</h2>
<p className="text-ink/75 max-w-xl leading-relaxed">
Wir haben Ihnen eine Bestätigung per E-Mail geschickt und melden uns in
der Regel innerhalb von 24 Stunden.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/" className="inline-flex items-center gap-2 bg-ink text-parchment px-6 py-3 rounded-full text-sm hover:bg-moss-700 transition-colors">
Zur Startseite
</Link>
<button
onClick={() => setState("idle")}
className="inline-flex items-center gap-2 px-6 py-3 rounded-full text-sm border border-ink/20 hover:bg-ink/5"
>
Weitere Anfrage senden
</button>
</div>
</div>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-10" noValidate>
{/* Honeypot */}
<div className="absolute left-[-9999px] w-0 h-0 overflow-hidden" aria-hidden="true">
<label>
Website (bitte leer lassen)
<input type="text" tabIndex={-1} autoComplete="off" {...register("website")} />
</label>
</div>
{/* Wohnung */}
<div>
<Label htmlFor="apartmentSlug">Wohnung</Label>
<div className="relative mt-2">
<select
id="apartmentSlug"
{...register("apartmentSlug")}
className={cn(
"w-full appearance-none bg-transparent border-b py-2.5 text-base pr-8",
errors.apartmentSlug ? "border-red-600" : "border-ink/25 focus:border-ink",
"focus:outline-none transition-colors"
)}
>
{apartments.map((a) => (
<option key={a.slug} value={a.slug}>{a.name}</option>
))}
</select>
<span className="pointer-events-none absolute right-1 top-1/2 -translate-y-1/2 text-ink/40" aria-hidden></span>
</div>
<FieldError message={errors.apartmentSlug?.message} />
</div>
{/* Datum + Gäste */}
<div className="grid md:grid-cols-3 gap-8">
<div>
<Label htmlFor="arrival">Anreise</Label>
<Input id="arrival" type="date" error={errors.arrival?.message} {...register("arrival")} />
<FieldError message={errors.arrival?.message} />
</div>
<div>
<Label htmlFor="departure">Abreise</Label>
<Input id="departure" type="date" error={errors.departure?.message} {...register("departure")} />
<FieldError message={errors.departure?.message} />
</div>
<div>
<Label htmlFor="guests">Personen</Label>
<Input id="guests" type="number" min={1} max={20} error={errors.guests?.message} {...register("guests")} />
<FieldError message={errors.guests?.message} />
</div>
</div>
<hr className="border-ink/10" />
{/* Kontakt */}
<div className="grid md:grid-cols-2 gap-8">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" type="text" placeholder="Ihr Name" autoComplete="name" error={errors.name?.message} {...register("name")} />
<FieldError message={errors.name?.message} />
</div>
<div>
<Label htmlFor="email">E-Mail</Label>
<Input id="email" type="email" placeholder="name@example.com" autoComplete="email" error={errors.email?.message} {...register("email")} />
<FieldError message={errors.email?.message} />
</div>
</div>
<div>
<Label htmlFor="phone">
Telefon <span className="text-ink/40 normal-case tracking-normal">(optional)</span>
</Label>
<Input id="phone" type="tel" placeholder="+49 …" autoComplete="tel" {...register("phone")} />
</div>
{/* Nachricht */}
<div>
<Label htmlFor="message">
Nachricht <span className="text-ink/40 normal-case tracking-normal">(optional)</span>
</Label>
<Textarea
id="message"
placeholder="Reisen Sie mit Hund? Gibt es Wünsche, die wir wissen sollten?"
className="mt-2"
rows={5}
{...register("message")}
/>
</div>
{/* DSGVO */}
<div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
{...register("gdpr")}
className="mt-1 h-4 w-4 accent-moss-600 shrink-0"
/>
<span className="text-sm text-ink/75 leading-relaxed">
Ich habe die{" "}
<Link href="/datenschutz" className="link-underline text-ink">
Datenschutzerklärung
</Link>{" "}
gelesen und stimme der Verarbeitung meiner Daten zur Bearbeitung dieser Anfrage zu.
</span>
</label>
<FieldError message={errors.gdpr?.message as string | undefined} />
</div>
{serverError && (
<div className="border border-red-200 bg-red-50 text-red-800 px-4 py-3 rounded-sm text-sm">
{serverError}
</div>
)}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 pt-2">
<Button type="submit" size="lg" disabled={state === "loading"}>
{state === "loading" ? "Wird gesendet…" : "Anfrage senden →"}
</Button>
<span className="text-xs text-ink/50">
Unverbindlich · Sie erhalten eine Bestätigung per E-Mail.
</span>
</div>
</form>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function Footer() {
const pathname = usePathname();
if (pathname?.startsWith("/admin")) return null;
const year = new Date().getFullYear();
return (
<footer className="mt-24 md:mt-32 border-t border-ink/10">
{/* Big editorial tagline band */}
<div className="bg-moss-800 text-parchment overflow-hidden">
<div className="container py-16 md:py-24">
<p className="font-display text-[clamp(2.5rem,8vw,6rem)] leading-[0.95] tracking-tight">
Zeit haben.<br />
Luft holen.<br />
<span className="italic text-moss-300">Bleiben.</span>
</p>
<div className="mt-10 flex flex-wrap gap-3">
<Link
href="/anfrage"
className="inline-flex items-center gap-2 bg-parchment text-ink px-7 py-3.5 rounded-full text-sm font-medium hover:bg-cream transition-colors"
>
Jetzt anfragen
</Link>
</div>
</div>
</div>
{/* Links & info row */}
<div className="bg-cream/60">
<div className="container py-12 md:py-16 grid grid-cols-2 md:grid-cols-12 gap-10 md:gap-12">
<div className="col-span-2 md:col-span-4">
<div className="font-display text-2xl mb-3">Spreewaldzeit</div>
<p className="text-ink/65 text-sm leading-relaxed max-w-xs">
Zwei private Ferienwohnungen im Spreewald. Keine Rezeption, keine Masse
nur Sie, das Wasser und die Bäume.
</p>
</div>
<div className="col-span-2 md:col-span-3 md:col-start-6">
<div className="eyebrow mb-4">Kontakt</div>
<address className="not-italic text-sm leading-relaxed text-ink/75">
Spreewaldzeit<br />
Familie Musterfrau<br />
Kraftwerkstraße 10<br />
03226 Vetschau/Spreewald
</address>
<div className="mt-4 text-sm text-ink/75">
<a href="mailto:hallo@spreewaldzeit.de" className="link-underline">
hallo@spreewaldzeit.de
</a>
</div>
</div>
<div className="md:col-span-2 md:col-start-9 col-span-1">
<div className="eyebrow mb-4">Navigation</div>
<ul className="space-y-2 text-sm text-ink/75">
<li><Link href="/#wohnungen" className="link-underline">Die Wohnungen</Link></li>
<li><Link href="/#umgebung" className="link-underline">Umgebung</Link></li>
<li><Link href="/anfrage" className="link-underline">Anfrage senden</Link></li>
<li><Link href="/datenschutz" className="link-underline">Datenschutz</Link></li>
<li><Link href="/impressum" className="link-underline">Impressum</Link></li>
</ul>
</div>
<div className="md:col-span-2 md:col-start-11 col-span-1">
<div className="eyebrow mb-4">Plattformen</div>
<ul className="space-y-2 text-sm text-ink/75">
<li>
<a href="https://www.airbnb.de" target="_blank" rel="noopener noreferrer" className="link-underline">
Airbnb
</a>
</li>
<li>
<a href="https://www.booking.com" target="_blank" rel="noopener noreferrer" className="link-underline">
Booking.com
</a>
</li>
</ul>
</div>
</div>
<div className="border-t border-ink/10">
<div className="container py-5 flex flex-col md:flex-row justify-between gap-2 text-xs text-ink/40">
<span>© {year} Spreewaldzeit. Alle Rechte vorbehalten.</span>
<span>Mit Sorgfalt gemacht im Spreewald.</span>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const navItems = [
{ href: "/#wohnungen", label: "Wohnungen" },
{ href: "/#umgebung", label: "Umgebung" },
{ href: "/anfrage", label: "Anfrage" },
];
export function Header() {
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();
const isHome = pathname === "/";
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 12);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
// On non-home pages the header is always opaque
const transparent = isHome && !scrolled;
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
if (pathname?.startsWith("/admin")) return null;
return (
<header
className={cn(
"fixed top-0 left-0 right-0 z-40 transition-all duration-300",
transparent
? "bg-transparent"
: "bg-parchment/95 backdrop-blur-md border-b border-ink/8 shadow-[0_1px_12px_rgba(28,38,32,0.06)]"
)}
>
<div className="container flex items-center justify-between py-5 md:py-6">
<Link href="/" className="flex items-baseline gap-2.5 group">
<span className={cn(
"font-display text-2xl md:text-[1.7rem] tracking-tight leading-none transition-colors duration-200",
transparent ? "text-parchment group-hover:text-moss-200" : "text-ink group-hover:text-moss-700"
)}>
Spreewaldzeit
</span>
<span
className="hidden sm:inline-block h-1.5 w-1.5 rounded-full bg-moss-400 opacity-70 group-hover:opacity-100 group-hover:scale-110 transition-all duration-200"
aria-hidden="true"
/>
</Link>
<nav className="hidden md:flex items-center gap-9 text-sm">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"link-underline transition-colors duration-200",
transparent ? "text-parchment/70 hover:text-parchment" : "text-ink/70 hover:text-ink"
)}
>
{item.label}
</Link>
))}
<Link
href="/anfrage"
className={cn(
"inline-flex items-center gap-2 px-5 py-2.5 rounded-full text-sm transition-colors",
transparent
? "bg-parchment/15 text-parchment border border-parchment/25 hover:bg-parchment/25"
: "bg-ink text-parchment hover:bg-moss-700"
)}
>
Jetzt anfragen
<span aria-hidden="true"></span>
</Link>
</nav>
<button
className="md:hidden p-2 -mr-2 rounded-sm transition"
onClick={() => setMobileOpen((v) => !v)}
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
aria-expanded={mobileOpen}
>
<span className="relative block w-6 h-[2px]">
<span
className={cn(
"absolute inset-x-0 h-[2px] transition-all duration-300",
transparent ? "bg-parchment" : "bg-ink",
mobileOpen ? "top-0 rotate-45" : "-top-2"
)}
/>
<span
className={cn(
"absolute inset-x-0 h-[2px] top-0 transition-opacity",
transparent ? "bg-parchment" : "bg-ink",
mobileOpen && "opacity-0"
)}
/>
<span
className={cn(
"absolute inset-x-0 h-[2px] transition-all duration-300",
transparent ? "bg-parchment" : "bg-ink",
mobileOpen ? "top-0 -rotate-45" : "top-2"
)}
/>
</span>
</button>
</div>
{/* Mobile menu */}
<div
className={cn(
"md:hidden overflow-hidden border-t border-ink/8 bg-parchment/95 backdrop-blur-md transition-[max-height,opacity] duration-300",
mobileOpen ? "max-h-[400px] opacity-100" : "max-h-0 opacity-0"
)}
>
<nav className="container flex flex-col py-5 gap-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="py-3 text-lg font-display hover:text-moss-600 transition-colors"
>
{item.label}
</Link>
))}
<Link
href="/anfrage"
className="mt-3 inline-flex items-center justify-center bg-ink text-parchment px-5 py-3 rounded-full text-sm hover:bg-moss-700 transition-colors"
>
Jetzt anfragen
</Link>
</nav>
</div>
</header>
);
}

43
components/ui/Button.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { cn } from "@/lib/utils";
import { forwardRef, type ButtonHTMLAttributes } from "react";
type Variant = "primary" | "secondary" | "ghost" | "outline";
type Size = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
const variantClasses: Record<Variant, string> = {
primary:
"bg-ink text-parchment hover:bg-moss-700 disabled:bg-ink/40",
secondary:
"bg-moss-500 text-parchment hover:bg-moss-600 disabled:bg-moss-500/40",
outline:
"border border-ink/20 text-ink hover:border-ink/40 hover:bg-ink/5",
ghost:
"text-ink hover:bg-ink/5",
};
const sizeClasses: Record<Size, string> = {
sm: "px-3.5 py-2 text-xs",
md: "px-5 py-2.5 text-sm",
lg: "px-7 py-3.5 text-base",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "primary", size = "md", ...props }, ref) => (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-full font-medium transition-colors disabled:cursor-not-allowed",
variantClasses[variant],
sizeClasses[size],
className
)}
{...props}
/>
)
);
Button.displayName = "Button";

80
components/ui/Input.tsx Normal file
View File

@@ -0,0 +1,80 @@
import { cn } from "@/lib/utils";
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes, type LabelHTMLAttributes } from "react";
// -----------------------------------------------------------------
// Label
// -----------------------------------------------------------------
export function Label({
className,
...props
}: LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label
className={cn(
"text-xs uppercase tracking-[0.18em] text-ink/70 font-medium",
className
)}
{...props}
/>
);
}
// -----------------------------------------------------------------
// Input
// -----------------------------------------------------------------
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
error?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, error, ...props }, ref) => (
<input
ref={ref}
className={cn(
"w-full bg-transparent border-b py-2.5 text-base",
"placeholder:text-ink/40 focus:outline-none",
error
? "border-red-600 focus:border-red-700"
: "border-ink/25 focus:border-ink",
"transition-colors",
className
)}
{...props}
/>
)
);
Input.displayName = "Input";
// -----------------------------------------------------------------
// Textarea
// -----------------------------------------------------------------
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
error?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, error, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"w-full bg-transparent border py-3 px-3 text-base rounded-md",
"placeholder:text-ink/40 focus:outline-none resize-y min-h-[120px]",
error
? "border-red-600 focus:border-red-700"
: "border-ink/25 focus:border-ink",
"transition-colors",
className
)}
{...props}
/>
)
);
Textarea.displayName = "Textarea";
// -----------------------------------------------------------------
// FieldError ein einheitlicher Fehler-Text
// -----------------------------------------------------------------
export function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p className="mt-1.5 text-xs text-red-700">{message}</p>;
}

55
lib/auth.ts Normal file
View File

@@ -0,0 +1,55 @@
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
const COOKIE_NAME = "sz_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 Tage
function getSecret(): Uint8Array {
const secret = process.env.AUTH_SECRET;
if (!secret || secret.length < 32) {
throw new Error(
"AUTH_SECRET fehlt oder ist zu kurz (min. 32 Zeichen). Bitte .env prüfen."
);
}
return new TextEncoder().encode(secret);
}
export interface SessionPayload {
sub: string; // Admin-ID
email: string;
iat?: number;
exp?: number;
}
export async function createSession(payload: Omit<SessionPayload, "iat" | "exp">) {
const token = await new SignJWT({ ...payload })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${COOKIE_MAX_AGE}s`)
.sign(getSecret());
cookies().set(COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: COOKIE_MAX_AGE,
});
}
export async function getSession(): Promise<SessionPayload | null> {
const token = cookies().get(COOKIE_NAME)?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, getSecret());
return payload as unknown as SessionPayload;
} catch {
return null;
}
}
export function clearSession() {
cookies().delete(COOKIE_NAME);
}
export const SESSION_COOKIE = COOKIE_NAME;

12
lib/db.ts Normal file
View File

@@ -0,0 +1,12 @@
import { PrismaClient } from "@prisma/client";
// Prisma-Singleton: verhindert im Dev-Modus zu viele Verbindungen durch Hot-Reload.
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

128
lib/email.ts Normal file
View File

@@ -0,0 +1,128 @@
import nodemailer, { type Transporter } from "nodemailer";
import { formatDate, nightsBetween } from "./utils";
// --------------------------------------------------------------------
// Transporter
// --------------------------------------------------------------------
// Wenn kein SMTP-Host konfiguriert ist, fällt der Versand auf "Console-Log"
// zurück. So funktioniert die Entwicklung ohne echten Mailserver.
// --------------------------------------------------------------------
let cachedTransporter: Transporter | null = null;
function getTransporter(): Transporter | null {
if (cachedTransporter) return cachedTransporter;
const host = process.env.SMTP_HOST;
if (!host) return null;
cachedTransporter = nodemailer.createTransport({
host,
port: Number(process.env.SMTP_PORT ?? 587),
secure: Number(process.env.SMTP_PORT ?? 587) === 465,
auth:
process.env.SMTP_USER && process.env.SMTP_PASSWORD
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
: undefined,
});
return cachedTransporter;
}
interface SendMailParams {
to: string;
subject: string;
text: string;
html?: string;
replyTo?: string;
}
export async function sendMail({ to, subject, text, html, replyTo }: SendMailParams) {
const from = process.env.SMTP_FROM ?? "Spreewaldzeit <noreply@spreewaldzeit.de>";
const transporter = getTransporter();
if (!transporter) {
// Dev-Fallback: loggen
console.log(
"\n─────── 📬 MAIL (Dev-Fallback, kein SMTP gesetzt) ───────\n" +
`An: ${to}\n` +
`Von: ${from}\n` +
`Betreff: ${subject}\n` +
(replyTo ? `Reply-To: ${replyTo}\n` : "") +
`\n${text}\n` +
"──────────────────────────────────────────────────────────\n"
);
return { devFallback: true };
}
await transporter.sendMail({ from, to, subject, text, html, replyTo });
return { devFallback: false };
}
// --------------------------------------------------------------------
// Vorgefertigte Templates
// --------------------------------------------------------------------
interface InquiryMailData {
apartmentName: string;
arrival: Date;
departure: Date;
guests: number;
name: string;
email: string;
phone?: string | null;
message?: string | null;
inquiryId: string;
}
export async function sendInquiryMails(data: InquiryMailData) {
const nights = nightsBetween(data.arrival, data.departure);
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
// 1) Mail an Vermieter
const ownerEmail = process.env.OWNER_EMAIL;
if (ownerEmail) {
await sendMail({
to: ownerEmail,
replyTo: data.email,
subject: `Neue Anfrage: ${data.apartmentName} (${formatDate(data.arrival)} ${formatDate(
data.departure
)})`,
text: [
`Neue Anfrage über Spreewaldzeit`,
``,
`Wohnung: ${data.apartmentName}`,
`Zeitraum: ${formatDate(data.arrival)} ${formatDate(data.departure)} (${nights} Nächte)`,
`Gäste: ${data.guests}`,
``,
`Name: ${data.name}`,
`E-Mail: ${data.email}`,
`Telefon: ${data.phone || "—"}`,
``,
`Nachricht:`,
data.message?.trim() || "(keine)",
``,
`Im Admin öffnen: ${siteUrl}/admin/anfragen`,
].join("\n"),
});
}
// 2) Bestätigung an Gast
await sendMail({
to: data.email,
subject: `Ihre Anfrage bei Spreewaldzeit ${data.apartmentName}`,
text: [
`Hallo ${data.name},`,
``,
`vielen Dank für Ihre Anfrage. Wir haben Ihre Daten erhalten und melden uns`,
`in der Regel innerhalb von 24 Stunden.`,
``,
`Ihre Anfrage im Überblick:`,
` Wohnung: ${data.apartmentName}`,
` Zeitraum: ${formatDate(data.arrival)} ${formatDate(data.departure)} (${nights} Nächte)`,
` Gäste: ${data.guests}`,
``,
`Herzliche Grüße`,
`Ihr Spreewaldzeit-Team`,
``,
``,
`Diese Mail wurde automatisch versendet.`,
].join("\n"),
});
}

48
lib/utils.ts Normal file
View File

@@ -0,0 +1,48 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatPrice(cents: number): string {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
}).format(cents / 100);
}
export function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(d);
}
export function formatDateShort(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
}).format(d);
}
export function nightsBetween(arrival: Date | string, departure: Date | string): number {
const a = typeof arrival === "string" ? new Date(arrival) : arrival;
const d = typeof departure === "string" ? new Date(departure) : departure;
return Math.round((d.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
}
export function parseJsonArray<T = string>(raw: string | null | undefined): T[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch {
return [];
}
}

82
lib/validations.ts Normal file
View File

@@ -0,0 +1,82 @@
import { z } from "zod";
// --------------------------------------------------
// Anfrageformular (öffentlich)
// --------------------------------------------------
export const inquirySchema = z
.object({
apartmentSlug: z.string().min(1, "Bitte eine Wohnung wählen."),
arrival: z
.string()
.min(1, "Bitte Anreisedatum angeben.")
.refine((s) => !Number.isNaN(Date.parse(s)), "Ungültiges Datum."),
departure: z
.string()
.min(1, "Bitte Abreisedatum angeben.")
.refine((s) => !Number.isNaN(Date.parse(s)), "Ungültiges Datum."),
guests: z.coerce.number().int().min(1, "Mindestens 1 Gast.").max(20),
name: z.string().trim().min(2, "Bitte Ihren Namen angeben."),
email: z.string().trim().email("Bitte eine gültige E-Mail-Adresse angeben."),
phone: z.string().trim().max(40).optional().or(z.literal("")),
message: z.string().trim().max(2000).optional().or(z.literal("")),
gdpr: z.literal(true, {
errorMap: () => ({ message: "Bitte der Datenschutzerklärung zustimmen." }),
}),
// Honeypot-Feld gegen Bots — muss leer bleiben
website: z.string().max(0).optional().or(z.literal("")),
})
.refine(
(d) => new Date(d.departure).getTime() > new Date(d.arrival).getTime(),
{ message: "Abreise muss nach Anreise liegen.", path: ["departure"] }
);
export type InquiryInput = z.infer<typeof inquirySchema>;
// --------------------------------------------------
// Admin: Zeitraum sperren
// --------------------------------------------------
export const blockSchema = z
.object({
apartmentId: z.string().min(1),
startDate: z.string().refine((s) => !Number.isNaN(Date.parse(s)), "Ungültiges Datum."),
endDate: z.string().refine((s) => !Number.isNaN(Date.parse(s)), "Ungültiges Datum."),
note: z.string().max(500).optional().or(z.literal("")),
reason: z.enum(["manual", "maintenance", "booking"]).default("manual"),
})
.refine((d) => new Date(d.endDate).getTime() > new Date(d.startDate).getTime(), {
message: "Enddatum muss nach Startdatum liegen.",
path: ["endDate"],
});
export type BlockInput = z.infer<typeof blockSchema>;
// --------------------------------------------------
// Admin: Wohnungsdaten
// --------------------------------------------------
export const apartmentUpdateSchema = z.object({
name: z.string().min(2).max(120),
tagline: z.string().min(2).max(200),
shortDescription: z.string().min(10).max(500),
description: z.string().min(10).max(5000),
priceFrom: z.coerce.number().int().min(0), // in Cent
maxGuests: z.coerce.number().int().min(1).max(20),
bedrooms: z.coerce.number().int().min(0).max(20),
sizeSqm: z.coerce.number().int().min(1).max(2000),
features: z.array(z.string().min(1)).default([]),
images: z.array(z.string().url()).default([]),
airbnbUrl: z.string().url().optional().or(z.literal("")),
bookingUrl: z.string().url().optional().or(z.literal("")),
published: z.boolean().default(true),
});
export type ApartmentUpdateInput = z.infer<typeof apartmentUpdateSchema>;
// --------------------------------------------------
// Login
// --------------------------------------------------
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export type LoginInput = z.infer<typeof loginSchema>;

46
middleware.ts Normal file
View File

@@ -0,0 +1,46 @@
import { NextResponse, type NextRequest } from "next/server";
import { jwtVerify } from "jose";
const COOKIE_NAME = "sz_session";
async function isValid(token: string | undefined): Promise<boolean> {
if (!token) return false;
const secret = process.env.AUTH_SECRET;
if (!secret || secret.length < 32) return false;
try {
await jwtVerify(token, new TextEncoder().encode(secret));
return true;
} catch {
return false;
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Nur geschützte Admin-Routen: /admin/... außer /admin/login und /api/admin/login
const isAdminPage =
pathname.startsWith("/admin") && !pathname.startsWith("/admin/login");
const isAdminApi =
pathname.startsWith("/api/admin") &&
!pathname.startsWith("/api/admin/login");
if (!isAdminPage && !isAdminApi) return NextResponse.next();
const token = request.cookies.get(COOKIE_NAME)?.value;
const authed = await isValid(token);
if (authed) return NextResponse.next();
if (isAdminApi) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const loginUrl = new URL("/admin/login", request.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
export const config = {
matcher: ["/admin/:path*", "/api/admin/:path*"],
};

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

18
next.config.js Normal file
View File

@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
formats: ["image/avif", "image/webp"],
// Only generate these widths — fewer variants = less processing
deviceSizes: [640, 1080, 1920],
imageSizes: [256, 512],
// Cache optimised images for 30 days
minimumCacheTTL: 60 * 60 * 24 * 30,
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com" },
{ protocol: "https", hostname: "plus.unsplash.com" },
],
},
};
module.exports = nextConfig;

7451
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "spreewaldzeit",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && prisma db push --accept-data-loss && next build",
"start": "next start",
"lint": "next lint",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"setup": "prisma migrate dev --name init && tsx prisma/seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"jose": "^5.9.6",
"next": "14.2.15",
"nodemailer": "^6.9.16",
"react": "^18.3.1",
"react-day-picker": "^9.3.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.2",
"sharp": "^0.34.5",
"tailwind-merge": "^2.5.4",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.8.6",
"@types/nodemailer": "^6.4.16",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.15",
"postcss": "^8.4.47",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.14",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

82
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,82 @@
// -------------------------------------------------------------
// Spreewaldzeit Datenbankschema
// SQLite für den MVP. Das Schema ist portabel (Postgres/MySQL).
//
// Block-Modell ist bewusst generisch gehalten: `source` speichert,
// woher ein Block stammt ("manual", "airbnb", "booking", "direct").
// So kann später ein iCal-Importer die Tabelle befüllen, ohne dass
// das Schema sich ändern muss.
// -------------------------------------------------------------
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Apartment {
id String @id @default(cuid())
slug String @unique
name String
tagline String
shortDescription String
description String
priceFrom Int // in Cent, z. B. 9500 = 95,00 €
maxGuests Int
bedrooms Int
sizeSqm Int
features String // JSON-Array als String
images String // JSON-Array URLs
airbnbUrl String?
bookingUrl String?
published Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inquiries Inquiry[]
blocks Block[]
}
model Inquiry {
id String @id @default(cuid())
apartmentId String
apartment Apartment @relation(fields: [apartmentId], references: [id])
arrival DateTime
departure DateTime
guests Int
name String
email String
phone String?
message String
// status: new | read | confirmed | declined | archived
status String @default("new")
createdAt DateTime @default(now())
@@index([apartmentId, createdAt])
}
model Block {
id String @id @default(cuid())
apartmentId String
apartment Apartment @relation(fields: [apartmentId], references: [id])
startDate DateTime
endDate DateTime
// reason: manual | maintenance | booking
reason String @default("manual")
// source: manual | airbnb | booking | direct (für späteren iCal-Sync)
source String @default("manual")
note String?
createdAt DateTime @default(now())
@@index([apartmentId, startDate, endDate])
}
model Admin {
id String @id @default(cuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
}

141
prisma/seed.ts Normal file
View File

@@ -0,0 +1,141 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
// ------------------------------------------------------------------
// Admin-Account anlegen (aus ENV; Passwort wird gehasht gespeichert)
// ------------------------------------------------------------------
const adminEmail = process.env.ADMIN_EMAIL ?? "admin@spreewaldzeit.de";
const adminPassword = process.env.ADMIN_PASSWORD ?? "bitte-sofort-aendern";
const passwordHash = await bcrypt.hash(adminPassword, 10);
await prisma.admin.upsert({
where: { email: adminEmail },
update: { passwordHash },
create: { email: adminEmail, passwordHash },
});
console.log(`✓ Admin: ${adminEmail}`);
// ------------------------------------------------------------------
// Wohnungen
// ------------------------------------------------------------------
const apartments = [
{
slug: "kahnblick",
name: "Kahnblick",
tagline: "Wohnen direkt am Fließ",
shortDescription:
"Helles Refugium mit Blick aufs Wasser — für alle, die Ruhe, Holz und Morgennebel mögen.",
description:
"Die Ferienwohnung Kahnblick liegt am Rand von Lübbenau, nur wenige Schritte vom Hafen entfernt. Große Fenster holen das Licht des Fließes ins Zimmer, geölte Eichendielen tragen durch alle Räume. Die Küche ist vollständig ausgestattet, der private Garten lädt zum Frühstück unter alten Obstbäumen. Der Kahnhafen, von dem die traditionellen Kahnfahrten starten, ist in fünf Gehminuten erreichbar.",
priceFrom: 9500, // 95,00 €
maxGuests: 4,
bedrooms: 2,
sizeSqm: 68,
features: [
"WLAN (kostenfrei)",
"Voll ausgestattete Küche",
"Kaffeemaschine & Siebträger",
"Waschmaschine",
"Privater Garten",
"Fahrrad-Verleih vor Ort",
"Nichtraucher",
"Haustiere auf Anfrage",
],
images: [
"https://images.unsplash.com/photo-1540541338287-41700207dee6?auto=format&fit=crop&w=1800&q=80",
"https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1800&q=80",
"https://images.unsplash.com/photo-1522771739844-6a9f6d5f14af?auto=format&fit=crop&w=1800&q=80",
"https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=1800&q=80",
"https://images.unsplash.com/photo-1505692433770-36f19f51681d?auto=format&fit=crop&w=1800&q=80",
],
},
{
slug: "erlenhof",
name: "Erlenhof",
tagline: "Stille unter alten Bäumen",
shortDescription:
"Traditionelles Spreewaldhaus, behutsam renoviert — mit Kachelofen, Leseecke und tiefem Garten.",
description:
"Der Erlenhof ist ein über hundert Jahre altes Spreewaldhaus in Burg, Ortsteil Kauper. Der Kachelofen im Wohnzimmer wärmt im Winter, im Sommer zieht es Sie in den weitläufigen Garten mit Wiese, Hängematte und Feuerstelle. Zwei Schlafzimmer, ein Bad, eine offene Küche — alles in natürlichen Materialien, mit Leinen, Keramik und viel Tageslicht. Die Kahnfahrten-Anlegestelle Hauptspreewehr ist in drei Gehminuten erreicht.",
priceFrom: 11500, // 115,00 €
maxGuests: 5,
bedrooms: 2,
sizeSqm: 82,
features: [
"WLAN (kostenfrei)",
"Kachelofen",
"Voll ausgestattete Küche",
"Großer Garten mit Feuerstelle",
"Hängematte & Liegen",
"Waschmaschine & Trockner",
"Fahrradunterstand",
"Nichtraucher",
"Hund auf Anfrage willkommen",
],
images: [
"https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?auto=format&fit=crop&w=1800&q=80",
"https://images.unsplash.com/photo-1484154218962-a197022b5858?auto=format&fit=crop&w=1800&q=80",
"https://images.unsplash.com/photo-1556020685-ae41abfc9365?auto=format&fit=crop&w=1800&q=80",
"https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?auto=format&fit=crop&w=1800&q=80",
"https://images.unsplash.com/photo-1513694203232-719a280e022f?auto=format&fit=crop&w=1800&q=80",
],
},
];
for (const apt of apartments) {
await prisma.apartment.upsert({
where: { slug: apt.slug },
update: {
...apt,
features: JSON.stringify(apt.features),
images: JSON.stringify(apt.images),
},
create: {
...apt,
features: JSON.stringify(apt.features),
images: JSON.stringify(apt.images),
},
});
console.log(`✓ Wohnung: ${apt.name}`);
}
// ------------------------------------------------------------------
// Ein Beispiel-Block (belegter Zeitraum) zum Testen der Anzeige
// ------------------------------------------------------------------
const kahnblick = await prisma.apartment.findUnique({ where: { slug: "kahnblick" } });
if (kahnblick) {
const blockExists = await prisma.block.findFirst({
where: { apartmentId: kahnblick.id, reason: "manual" },
});
if (!blockExists) {
const today = new Date();
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 10);
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 17);
await prisma.block.create({
data: {
apartmentId: kahnblick.id,
startDate: start,
endDate: end,
reason: "manual",
source: "manual",
note: "Familientreffen der Eigentümer",
},
});
console.log("✓ Beispiel-Block: Kahnblick, +10 bis +17 Tage");
}
}
console.log("\nFertig. Admin-Login: " + adminEmail);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

92
tailwind.config.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
],
theme: {
container: {
center: true,
padding: {
DEFAULT: "1.25rem",
md: "2rem",
lg: "3rem",
},
screens: {
sm: "640px",
md: "768px",
lg: "1024px",
xl: "1200px",
"2xl": "1320px",
},
},
extend: {
colors: {
// Warme Naturpalette — ruhiger Spreewald-Ton
parchment: "#F5F1E8", // Hintergrund
cream: "#FBF9F4", // heller Hintergrund
ink: "#1C2620", // Haupttext (tiefes Waldgrün-Schwarz)
moss: {
50: "#EEF0EA",
100: "#D9DECF",
200: "#B4BFA3",
300: "#8A9876",
400: "#6B7D58",
500: "#5A6B4F", // Akzent
600: "#48593F",
700: "#3A4734",
800: "#2C3A2A",
900: "#1F2A1D",
},
sand: {
50: "#FAF5EB",
100: "#F0E6D2",
200: "#E2D3B0",
300: "#CDB582",
400: "#B6955A",
500: "#8B6F47", // warme Akzentfarbe
600: "#6E5836",
700: "#544229",
},
stone: {
muted: "#A9A391",
soft: "#DCD6C6",
},
},
fontFamily: {
display: ["var(--font-fraunces)", "Georgia", "serif"],
sans: ["var(--font-figtree)", "ui-sans-serif", "system-ui", "sans-serif"],
},
fontSize: {
"display-xl": ["clamp(3rem, 8vw, 6rem)", { lineHeight: "1.02", letterSpacing: "-0.03em" }],
"display-lg": ["clamp(2.25rem, 5vw, 4rem)", { lineHeight: "1.05", letterSpacing: "-0.025em" }],
"display-md": ["clamp(1.75rem, 3.5vw, 2.75rem)", { lineHeight: "1.1", letterSpacing: "-0.02em" }],
},
letterSpacing: {
tightest: "-0.04em",
},
boxShadow: {
soft: "0 2px 10px rgba(28, 38, 32, 0.04), 0 12px 30px rgba(28, 38, 32, 0.06)",
card: "0 1px 2px rgba(28, 38, 32, 0.04), 0 8px 24px rgba(28, 38, 32, 0.06)",
},
animation: {
"fade-up": "fadeUp 0.8s ease-out both",
"fade-in": "fadeIn 1s ease-out both",
},
keyframes: {
fadeUp: {
"0%": { opacity: "0", transform: "translateY(16px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
},
},
},
plugins: [],
};
export default config;

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

19
types/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { Apartment as PrismaApartment, Inquiry, Block } from "@prisma/client";
/** Apartment mit geparsten Feldern (features + images als Arrays statt JSON-String). */
export interface Apartment extends Omit<PrismaApartment, "features" | "images"> {
features: string[];
images: string[];
}
export type { Inquiry, Block };
export type InquiryStatus = "new" | "read" | "confirmed" | "declined" | "archived";
export const INQUIRY_STATUS_LABELS: Record<InquiryStatus, string> = {
new: "Neu",
read: "Gelesen",
confirmed: "Bestätigt",
declined: "Abgelehnt",
archived: "Archiviert",
};