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

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

View File

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

View File

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

View File

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

View File

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

View File

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