From bf5d79a919fda5f42872d8da905f1e8b2c51a31e Mon Sep 17 00:00:00 2001 From: Ihor Date: Wed, 3 Jun 2026 14:08:48 +0200 Subject: [PATCH] Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite) --- .dockerignore | 27 + .env.example | 28 + .gitignore | 18 + Dockerfile | 58 + README.md | 191 + app/admin/anfragen/page.tsx | 99 + app/admin/kalender/page.tsx | 41 + app/admin/layout.tsx | 22 + app/admin/login/page.tsx | 82 + app/admin/page.tsx | 5 + app/admin/wohnungen/page.tsx | 50 + app/anfrage/page.tsx | 47 + app/api/admin/apartments/[id]/route.ts | 43 + app/api/admin/blocks/[id]/route.ts | 14 + app/api/admin/blocks/route.ts | 34 + app/api/admin/inquiries/[id]/route.ts | 40 + app/api/admin/login/route.ts | 33 + app/api/admin/logout/route.ts | 7 + app/api/admin/sync-ical/route.ts | 130 + app/api/availability/[slug]/route.ts | 40 + app/api/available-periods/[slug]/route.ts | 81 + app/api/inquiries/route.ts | 86 + app/datenschutz/page.tsx | 69 + app/globals.css | 146 + app/impressum/page.tsx | 60 + app/layout.tsx | 53 + app/not-found.tsx | 21 + app/page.tsx | 36 + app/wohnungen/[slug]/page.tsx | 145 + components/admin/AdminNav.tsx | 68 + components/admin/ApartmentEditor.tsx | 321 + components/admin/CalendarManager.tsx | 288 + components/admin/InquiryRow.tsx | 159 + components/apartment/AvailabilityCalendar.tsx | 87 + components/apartment/AvailablePeriods.tsx | 104 + components/apartment/BookingPlatforms.tsx | 67 + components/apartment/Features.tsx | 26 + components/apartment/Gallery.tsx | 168 + components/home/About.tsx | 53 + components/home/ApartmentPreview.tsx | 138 + components/home/Hero.tsx | 78 + components/home/Location.tsx | 136 + components/home/PlacesToVisit.tsx | 228 + components/home/SpreeMap.tsx | 15 + components/inquiry/InquiryForm.tsx | 216 + components/layout/Footer.tsx | 95 + components/layout/Header.tsx | 145 + components/ui/Button.tsx | 43 + components/ui/Input.tsx | 80 + lib/auth.ts | 55 + lib/db.ts | 12 + lib/email.ts | 128 + lib/utils.ts | 48 + lib/validations.ts | 82 + middleware.ts | 46 + next-env.d.ts | 5 + next.config.js | 18 + package-lock.json | 7451 +++++++++++++++++ package.json | 51 + postcss.config.js | 6 + prisma/schema.prisma | 82 + prisma/seed.ts | 141 + ..._10e2cf7652bb403596887173a9068ee6~mv2.avif | Bin 0 -> 220015 bytes ..._2333c981b3e74d38a00cef6a8545bfab~mv2.avif | Bin 0 -> 98169 bytes ...83951332446b39d66c9871858ce74~mv2 (1).avif | Bin 0 -> 103509 bytes ..._25083951332446b39d66c9871858ce74~mv2.avif | Bin 0 -> 88648 bytes ..._2c5e325a5ae0438dbe37977a5d6b7cfb~mv2.avif | Bin 0 -> 141948 bytes ..._401c6eeb5f1147ac8ad1fa9593787ffc~mv2.avif | Bin 0 -> 36280 bytes ..._408ec5ff2a8e431c998eb456080e395a~mv2.avif | Bin 0 -> 110655 bytes ..._7018a67b781545eb954455fe5b9e69f9~mv2.avif | Bin 0 -> 209231 bytes ..._782ab1a331ee4608aae26a284a9f1f7c~mv2.avif | Bin 0 -> 162065 bytes ..._80b509f0f9fb473bae4de89f56b2f8c7~mv2.avif | Bin 0 -> 86620 bytes ..._c28ca5d63f5b4d2da9d07a394922b1d9~mv2.avif | Bin 0 -> 96854 bytes ..._d9a20c4576204ab786988810ef298f29~mv2.avif | Bin 0 -> 96812 bytes ..._20d01fdca6c14e58ad5aa1f026232ab0~mv2.avif | Bin 0 -> 40083 bytes ..._39011a1294af4b10985d866408297055~mv2.avif | Bin 0 -> 17070 bytes ..._54c4b3103096470a95831608bc427a26~mv2.avif | Bin 0 -> 37587 bytes ..._564f4b31003f4da2bc1e7b01fbb427ea~mv2.avif | Bin 0 -> 33759 bytes ...68f193862594c1a89aaefc9b2c4ef0a~mv2-2.avif | Bin 0 -> 38488 bytes ..._768f193862594c1a89aaefc9b2c4ef0a~mv2.avif | Bin 0 -> 42013 bytes ..._795fdee57db54005a65c3b5b1b9d6c6c~mv2.avif | Bin 0 -> 50707 bytes ..._7a230d514259432ea5a4c4006d5b02b1~mv2.avif | Bin 0 -> 40524 bytes ..._83c0003c5bab40b18332d8901474225b~mv2.avif | Bin 0 -> 39703 bytes ..._90181292c0bb451fb75b1f74bffe08cc~mv2.avif | Bin 0 -> 37662 bytes ..._9208a85c294d4514b12f398eb36df784~mv2.avif | Bin 0 -> 67875 bytes ..._9460e79ad0ca4d64b1b6a54002414f30~mv2.avif | Bin 0 -> 39962 bytes ..._a14e0942a7904727a6d84074f2318a36~mv2.avif | Bin 0 -> 42157 bytes ..._a39e632a73b944dbbd6887cdb627223d~mv2.avif | Bin 0 -> 539904 bytes ..._b64889da769f44259271038842c0ffb8~mv2.avif | Bin 0 -> 30338 bytes ..._c3115464a9d54acfa05e69aaca0b60f7~mv2.avif | Bin 0 -> 44673 bytes ..._c41f10b96d5e49ec821ef6a9f0d0fa05~mv2.avif | Bin 0 -> 52202 bytes tailwind.config.ts | 92 + tsconfig.json | 23 + types/index.ts | 19 + 94 files changed, 12480 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/admin/anfragen/page.tsx create mode 100644 app/admin/kalender/page.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/login/page.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/admin/wohnungen/page.tsx create mode 100644 app/anfrage/page.tsx create mode 100644 app/api/admin/apartments/[id]/route.ts create mode 100644 app/api/admin/blocks/[id]/route.ts create mode 100644 app/api/admin/blocks/route.ts create mode 100644 app/api/admin/inquiries/[id]/route.ts create mode 100644 app/api/admin/login/route.ts create mode 100644 app/api/admin/logout/route.ts create mode 100644 app/api/admin/sync-ical/route.ts create mode 100644 app/api/availability/[slug]/route.ts create mode 100644 app/api/available-periods/[slug]/route.ts create mode 100644 app/api/inquiries/route.ts create mode 100644 app/datenschutz/page.tsx create mode 100644 app/globals.css create mode 100644 app/impressum/page.tsx create mode 100644 app/layout.tsx create mode 100644 app/not-found.tsx create mode 100644 app/page.tsx create mode 100644 app/wohnungen/[slug]/page.tsx create mode 100644 components/admin/AdminNav.tsx create mode 100644 components/admin/ApartmentEditor.tsx create mode 100644 components/admin/CalendarManager.tsx create mode 100644 components/admin/InquiryRow.tsx create mode 100644 components/apartment/AvailabilityCalendar.tsx create mode 100644 components/apartment/AvailablePeriods.tsx create mode 100644 components/apartment/BookingPlatforms.tsx create mode 100644 components/apartment/Features.tsx create mode 100644 components/apartment/Gallery.tsx create mode 100644 components/home/About.tsx create mode 100644 components/home/ApartmentPreview.tsx create mode 100644 components/home/Hero.tsx create mode 100644 components/home/Location.tsx create mode 100644 components/home/PlacesToVisit.tsx create mode 100644 components/home/SpreeMap.tsx create mode 100644 components/inquiry/InquiryForm.tsx create mode 100644 components/layout/Footer.tsx create mode 100644 components/layout/Header.tsx create mode 100644 components/ui/Button.tsx create mode 100644 components/ui/Input.tsx create mode 100644 lib/auth.ts create mode 100644 lib/db.ts create mode 100644 lib/email.ts create mode 100644 lib/utils.ts create mode 100644 lib/validations.ts create mode 100644 middleware.ts create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 public/images/ae336d_10e2cf7652bb403596887173a9068ee6~mv2.avif create mode 100644 public/images/ae336d_2333c981b3e74d38a00cef6a8545bfab~mv2.avif create mode 100644 public/images/ae336d_25083951332446b39d66c9871858ce74~mv2 (1).avif create mode 100644 public/images/ae336d_25083951332446b39d66c9871858ce74~mv2.avif create mode 100644 public/images/ae336d_2c5e325a5ae0438dbe37977a5d6b7cfb~mv2.avif create mode 100644 public/images/ae336d_401c6eeb5f1147ac8ad1fa9593787ffc~mv2.avif create mode 100644 public/images/ae336d_408ec5ff2a8e431c998eb456080e395a~mv2.avif create mode 100644 public/images/ae336d_7018a67b781545eb954455fe5b9e69f9~mv2.avif create mode 100644 public/images/ae336d_782ab1a331ee4608aae26a284a9f1f7c~mv2.avif create mode 100644 public/images/ae336d_80b509f0f9fb473bae4de89f56b2f8c7~mv2.avif create mode 100644 public/images/ae336d_c28ca5d63f5b4d2da9d07a394922b1d9~mv2.avif create mode 100644 public/images/ae336d_d9a20c4576204ab786988810ef298f29~mv2.avif create mode 100644 public/images/b6ca2c_20d01fdca6c14e58ad5aa1f026232ab0~mv2.avif create mode 100644 public/images/b6ca2c_39011a1294af4b10985d866408297055~mv2.avif create mode 100644 public/images/b6ca2c_54c4b3103096470a95831608bc427a26~mv2.avif create mode 100644 public/images/b6ca2c_564f4b31003f4da2bc1e7b01fbb427ea~mv2.avif create mode 100644 public/images/b6ca2c_768f193862594c1a89aaefc9b2c4ef0a~mv2-2.avif create mode 100644 public/images/b6ca2c_768f193862594c1a89aaefc9b2c4ef0a~mv2.avif create mode 100644 public/images/b6ca2c_795fdee57db54005a65c3b5b1b9d6c6c~mv2.avif create mode 100644 public/images/b6ca2c_7a230d514259432ea5a4c4006d5b02b1~mv2.avif create mode 100644 public/images/b6ca2c_83c0003c5bab40b18332d8901474225b~mv2.avif create mode 100644 public/images/b6ca2c_90181292c0bb451fb75b1f74bffe08cc~mv2.avif create mode 100644 public/images/b6ca2c_9208a85c294d4514b12f398eb36df784~mv2.avif create mode 100644 public/images/b6ca2c_9460e79ad0ca4d64b1b6a54002414f30~mv2.avif create mode 100644 public/images/b6ca2c_a14e0942a7904727a6d84074f2318a36~mv2.avif create mode 100644 public/images/b6ca2c_a39e632a73b944dbbd6887cdb627223d~mv2.avif create mode 100644 public/images/b6ca2c_b64889da769f44259271038842c0ffb8~mv2.avif create mode 100644 public/images/b6ca2c_c3115464a9d54acfa05e69aaca0b60f7~mv2.avif create mode 100644 public/images/b6ca2c_c41f10b96d5e49ec821ef6a9f0d0fa05~mv2.avif create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 types/index.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8b7ab74 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fd8a5ba --- /dev/null +++ b/.env.example @@ -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 " + +# Empfänger für eingehende Anfragen (Vermieter) +OWNER_EMAIL="vermieter@spreewaldzeit.de" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..111ef9c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2124a94 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca9f957 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/admin/anfragen/page.tsx b/app/admin/anfragen/page.tsx new file mode 100644 index 0000000..9f7b886 --- /dev/null +++ b/app/admin/anfragen/page.tsx @@ -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 ( +
+
+
+
Admin
+

+ Anfragen +

+

+ {newCount > 0 + ? `${newCount} neue Anfrage${newCount === 1 ? "" : "n"} wartet auf Ihre Rückmeldung.` + : "Alle Anfragen sind bearbeitet."} +

+
+
+ + {/* Filter */} +
+ {FILTERS.map((f) => { + const active = filter === f.key; + const href = f.key === "all" ? "/admin/anfragen" : `/admin/anfragen?status=${f.key}`; + return ( + + {f.label} + + ); + })} +
+ + {rows.length === 0 ? ( +
+ Keine Anfragen in dieser Ansicht. +
+ ) : ( +
    + {rows.map((row) => ( + + ))} +
+ )} +
+ ); +} diff --git a/app/admin/kalender/page.tsx b/app/admin/kalender/page.tsx new file mode 100644 index 0000000..7d50c10 --- /dev/null +++ b/app/admin/kalender/page.tsx @@ -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 ( +
+
+
Admin
+

Kalender

+

+ Zeiträume sperren oder freigeben. Für jede Wohnung getrennt. +

+
+ + +
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..0ec9e60 --- /dev/null +++ b/app/admin/layout.tsx @@ -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 ( +
+ {session && } +
{children}
+
+ ); +} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..7c5ca42 --- /dev/null +++ b/app/admin/login/page.tsx @@ -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(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 ( +
+
+
Admin
+

+ Willkommen zurück. +

+ +
+
+ + setEmail(e.target.value)} + autoComplete="email" + required + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+ + + + + +
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..d9046c8 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AdminIndex() { + redirect("/admin/anfragen"); +} diff --git a/app/admin/wohnungen/page.tsx b/app/admin/wohnungen/page.tsx new file mode 100644 index 0000000..61ddd4b --- /dev/null +++ b/app/admin/wohnungen/page.tsx @@ -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(r.features), + images: parseJsonArray(r.images), + airbnbUrl: r.airbnbUrl ?? null, + bookingUrl: r.bookingUrl ?? null, + published: r.published, + })); + + return ( +
+
+
Admin
+

Wohnungen

+

+ Basisdaten, Ausstattung und Bilder pflegen. +

+
+ +
+ {apartments.map((apt) => ( + + ))} +
+
+ ); +} diff --git a/app/anfrage/page.tsx b/app/anfrage/page.tsx new file mode 100644 index 0000000..28afb56 --- /dev/null +++ b/app/anfrage/page.tsx @@ -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 ( +
+
+
+
Anfrage
+

+ Erzählen Sie uns,
+ wann Sie kommen möchten. +

+

+ Ein kurzes Formular — den Rest klären wir persönlich. Wir antworten + in der Regel innerhalb von 24 Stunden. +

+
+ + +
+
+ ); +} diff --git a/app/api/admin/apartments/[id]/route.ts b/app/api/admin/apartments/[id]/route.ts new file mode 100644 index 0000000..a076995 --- /dev/null +++ b/app/api/admin/apartments/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/blocks/[id]/route.ts b/app/api/admin/blocks/[id]/route.ts new file mode 100644 index 0000000..29efa52 --- /dev/null +++ b/app/api/admin/blocks/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/blocks/route.ts b/app/api/admin/blocks/route.ts new file mode 100644 index 0000000..9c227b7 --- /dev/null +++ b/app/api/admin/blocks/route.ts @@ -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 }); +} diff --git a/app/api/admin/inquiries/[id]/route.ts b/app/api/admin/inquiries/[id]/route.ts new file mode 100644 index 0000000..aa9830f --- /dev/null +++ b/app/api/admin/inquiries/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/login/route.ts b/app/api/admin/login/route.ts new file mode 100644 index 0000000..a5c06c3 --- /dev/null +++ b/app/api/admin/login/route.ts @@ -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 }); +} diff --git a/app/api/admin/logout/route.ts b/app/api/admin/logout/route.ts new file mode 100644 index 0000000..58ad922 --- /dev/null +++ b/app/api/admin/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { clearSession } from "@/lib/auth"; + +export async function POST() { + clearSession(); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/admin/sync-ical/route.ts b/app/api/admin/sync-ical/route.ts new file mode 100644 index 0000000..13fb6e5 --- /dev/null +++ b/app/api/admin/sync-ical/route.ts @@ -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, + }); +} diff --git a/app/api/availability/[slug]/route.ts b/app/api/availability/[slug]/route.ts new file mode 100644 index 0000000..bfcac62 --- /dev/null +++ b/app/api/availability/[slug]/route.ts @@ -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" }, + } + ); +} diff --git a/app/api/available-periods/[slug]/route.ts b/app/api/available-periods/[slug]/route.ts new file mode 100644 index 0000000..841ed03 --- /dev/null +++ b/app/api/available-periods/[slug]/route.ts @@ -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" }, + }); +} diff --git a/app/api/inquiries/route.ts b/app/api/inquiries/route.ts new file mode 100644 index 0000000..4accf95 --- /dev/null +++ b/app/api/inquiries/route.ts @@ -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 }); +} diff --git a/app/datenschutz/page.tsx b/app/datenschutz/page.tsx new file mode 100644 index 0000000..57f5b0f --- /dev/null +++ b/app/datenschutz/page.tsx @@ -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 ( +
+
+
Rechtliches
+

+ Datenschutz +

+
+

+ Der Schutz Ihrer personenbezogenen Daten ist uns wichtig. Nachfolgend + informieren wir Sie über die Verarbeitung Ihrer Daten auf dieser Website. +

+ +

1. Verantwortlich

+

+ Familie Musterfrau
+ Hauptstraße 12, 03222 Lübbenau/Spreewald
+ E-Mail: hallo@spreewaldzeit.de +

+ +

2. Anfrageformular

+

+ 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. +

+ +

3. Server-Logs

+

+ 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. +

+ +

4. Cookies & Tracking

+

+ Diese Website setzt keine Marketing- oder Tracking-Cookies ein. Es werden + lediglich technisch notwendige Cookies (Session) genutzt, soweit Sie sich + als Administrator anmelden. +

+ +

5. Ihre Rechte

+

+ 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. +

+ +

+ Stand: {new Date().toLocaleDateString("de-DE")}. Diese Datenschutzerklärung + ist ein Platzhalter — bitte vor Veröffentlichung rechtlich prüfen lassen. +

+
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..dd95cea --- /dev/null +++ b/app/globals.css @@ -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); +} diff --git a/app/impressum/page.tsx b/app/impressum/page.tsx new file mode 100644 index 0000000..8e25432 --- /dev/null +++ b/app/impressum/page.tsx @@ -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 ( +
+
+
Rechtliches
+

+ Impressum +

+
+

Angaben gemäß § 5 TMG

+

+ Familie Musterfrau
+ Hauptstraße 12
+ 03222 Lübbenau/Spreewald
+ Deutschland +

+ +

Kontakt

+

+ Telefon: +49 (0)3542 000000
+ E-Mail: hallo@spreewaldzeit.de +

+ +

Umsatzsteuer-ID

+

+ Gemäß §27 a Umsatzsteuergesetz:
+ DE000000000 +

+ +

Streitschlichtung

+

+ Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) + bereit:{" "} + + https://ec.europa.eu/consumers/odr/ + + . Wir sind nicht bereit oder verpflichtet, an einem Streitbeilegungsverfahren + vor einer Verbraucherschlichtungsstelle teilzunehmen. +

+ +

+ Platzhalter — bitte vor Veröffentlichung anpassen. +

+
+
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..ecc6427 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + +
+
+
{children}
+
+
+ + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..395d101 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
+
404
+

+ Hier ist nur Nebel. +

+

+ Die Seite, die Sie suchen, gibt es nicht (mehr). +

+ + Zurück zur Startseite → + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..f08e20c --- /dev/null +++ b/app/page.tsx @@ -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 { + const rows = await prisma.apartment.findMany({ + where: { published: true }, + orderBy: { createdAt: "asc" }, + }); + return rows.map((r) => ({ + ...r, + features: parseJsonArray(r.features), + images: parseJsonArray(r.images), + })); +} + +export default async function HomePage() { + const apartments = await getApartments(); + return ( + <> + + + + + + + ); +} diff --git a/app/wohnungen/[slug]/page.tsx b/app/wohnungen/[slug]/page.tsx new file mode 100644 index 0000000..54e5800 --- /dev/null +++ b/app/wohnungen/[slug]/page.tsx @@ -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 { + 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(row.features), + images: parseJsonArray(row.images), + }; + + return ( +
+
+ {/* Brotkrümel */} + + + {/* Titelblock */} +
+
+
{apartment.tagline}
+

+ {apartment.name} +

+
+
+
+
Gäste
+
bis {apartment.maxGuests}
+
+
+
Größe
+
{apartment.sizeSqm} m²
+
+
+
ab
+
+ {formatPrice(apartment.priceFrom)} + /Nacht +
+
+
+
+ + {/* Galerie */} + + + {/* Beschreibung + Sticky-CTA */} +
+
+
Die Wohnung
+

+ {apartment.shortDescription} +

+

+ {apartment.description} +

+ + + + +
+ + {/* Sticky Anfrage-Box */} + +
+
+
+ ); +} diff --git a/components/admin/AdminNav.tsx b/components/admin/AdminNav.tsx new file mode 100644 index 0000000..bbce431 --- /dev/null +++ b/components/admin/AdminNav.tsx @@ -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 ( +
+
+
+ + Spreewaldzeit{" "} + · Admin + + +
+ +
+ + Website ansehen ↗ + + · + {email} + +
+
+
+ ); +} diff --git a/components/admin/ApartmentEditor.tsx b/components/admin/ApartmentEditor.tsx new file mode 100644 index 0000000..233eea8 --- /dev/null +++ b/components/admin/ApartmentEditor.tsx @@ -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(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 ( +
+
+
+
/{form.slug}
+

{form.name}

+
+ ab {formatPrice(form.priceFrom)} · bis {form.maxGuests} Gäste · {form.sizeSqm} m² +
+
+ +
+ +
+
+ + update("name", e.target.value)} /> +
+
+ + update("tagline", e.target.value)} /> +
+ +
+ +