Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite)
This commit is contained in:
87
components/apartment/AvailabilityCalendar.tsx
Normal file
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
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
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
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
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user