Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite)
27
.dockerignore
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
99
app/admin/anfragen/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
app/admin/kalender/page.tsx
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function AdminIndex() {
|
||||||
|
redirect("/admin/anfragen");
|
||||||
|
}
|
||||||
50
app/admin/wohnungen/page.tsx
Normal 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
app/api/admin/apartments/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/api/admin/blocks/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/api/admin/blocks/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
40
app/api/admin/inquiries/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/api/admin/login/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
7
app/api/admin/logout/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
130
app/api/admin/sync-ical/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
40
app/api/availability/[slug]/route.ts
Normal 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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
81
app/api/available-periods/[slug]/route.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
86
app/api/inquiries/route.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
app/wohnungen/[slug]/page.tsx
Normal 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 Wunschzeitraum — 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
components/admin/AdminNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
321
components/admin/ApartmentEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
components/admin/CalendarManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
components/admin/InquiryRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
components/apartment/AvailabilityCalendar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
components/apartment/AvailablePeriods.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
components/apartment/BookingPlatforms.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
components/apartment/Features.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
components/apartment/Gallery.tsx
Normal 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
components/home/ApartmentPreview.tsx
Normal 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
components/home/Location.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
components/home/PlacesToVisit.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
components/home/SpreeMap.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
components/inquiry/InquiryForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
components/layout/Footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
components/layout/Header.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
51
package.json
Normal 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
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
82
prisma/schema.prisma
Normal 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
@@ -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();
|
||||||
|
});
|
||||||
BIN
public/images/ae336d_10e2cf7652bb403596887173a9068ee6~mv2.avif
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
public/images/ae336d_2333c981b3e74d38a00cef6a8545bfab~mv2.avif
Normal file
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 101 KiB |
BIN
public/images/ae336d_25083951332446b39d66c9871858ce74~mv2.avif
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
public/images/ae336d_2c5e325a5ae0438dbe37977a5d6b7cfb~mv2.avif
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
public/images/ae336d_401c6eeb5f1147ac8ad1fa9593787ffc~mv2.avif
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/images/ae336d_408ec5ff2a8e431c998eb456080e395a~mv2.avif
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
public/images/ae336d_7018a67b781545eb954455fe5b9e69f9~mv2.avif
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
public/images/ae336d_782ab1a331ee4608aae26a284a9f1f7c~mv2.avif
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
public/images/ae336d_80b509f0f9fb473bae4de89f56b2f8c7~mv2.avif
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
public/images/ae336d_c28ca5d63f5b4d2da9d07a394922b1d9~mv2.avif
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
public/images/ae336d_d9a20c4576204ab786988810ef298f29~mv2.avif
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
public/images/b6ca2c_20d01fdca6c14e58ad5aa1f026232ab0~mv2.avif
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/images/b6ca2c_39011a1294af4b10985d866408297055~mv2.avif
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/images/b6ca2c_54c4b3103096470a95831608bc427a26~mv2.avif
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/images/b6ca2c_564f4b31003f4da2bc1e7b01fbb427ea~mv2.avif
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/images/b6ca2c_768f193862594c1a89aaefc9b2c4ef0a~mv2-2.avif
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/images/b6ca2c_768f193862594c1a89aaefc9b2c4ef0a~mv2.avif
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/images/b6ca2c_795fdee57db54005a65c3b5b1b9d6c6c~mv2.avif
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/images/b6ca2c_7a230d514259432ea5a4c4006d5b02b1~mv2.avif
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/images/b6ca2c_83c0003c5bab40b18332d8901474225b~mv2.avif
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/images/b6ca2c_90181292c0bb451fb75b1f74bffe08cc~mv2.avif
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/images/b6ca2c_9208a85c294d4514b12f398eb36df784~mv2.avif
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
public/images/b6ca2c_9460e79ad0ca4d64b1b6a54002414f30~mv2.avif
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/images/b6ca2c_a14e0942a7904727a6d84074f2318a36~mv2.avif
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/images/b6ca2c_a39e632a73b944dbbd6887cdb627223d~mv2.avif
Normal file
|
After Width: | Height: | Size: 527 KiB |
BIN
public/images/b6ca2c_b64889da769f44259271038842c0ffb8~mv2.avif
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/images/b6ca2c_c3115464a9d54acfa05e69aaca0b60f7~mv2.avif
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
public/images/b6ca2c_c41f10b96d5e49ec821ef6a9f0d0fa05~mv2.avif
Normal file
|
After Width: | Height: | Size: 51 KiB |
92
tailwind.config.ts
Normal 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
@@ -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
@@ -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",
|
||||||
|
};
|
||||||