Add calendar-based booking system: accounts, availability, admin calendar
- Customer accounts (separate auth collection) with /konto area - Availability + AvailabilityOverrides collections driving real slot calculation - BookingRequests with race-safe confirmation (Postgres advisory lock + transaction) - Booking emails (request received, admin notify, confirmed, rejected, alternative proposed/accepted, cancelled) - Confirmed online bookings auto-create a private linked video-call Event - Custom Payload admin calendar view (month grid + day schedule) - Wired booking widget into offer pages and /termin-buchen
This commit is contained in:
+14
-2
@@ -8,7 +8,7 @@ import { groupOffersByCategory } from "@/lib/angebote";
|
||||
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
|
||||
import type { Offer } from "@/payload-types";
|
||||
|
||||
export function Navbar({ offers }: { offers: Offer[] }) {
|
||||
export function Navbar({ offers, customerName }: { offers: Offer[]; customerName?: string | null }) {
|
||||
const pathname = usePathname();
|
||||
const groups = groupOffersByCategory(offers);
|
||||
const [megaOpen, setMegaOpen] = useState(false);
|
||||
@@ -173,7 +173,13 @@ export function Navbar({ offers }: { offers: Offer[] }) {
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<div className="hidden items-center gap-5 lg:flex">
|
||||
<Link
|
||||
href={customerName ? "/konto" : "/login"}
|
||||
className="text-[15px] font-medium text-anouma-plum/90 transition-colors hover:text-anouma-mauve-dark"
|
||||
>
|
||||
{customerName ? "Mein Konto" : "Anmelden"}
|
||||
</Link>
|
||||
<Link
|
||||
href={ctaNav.href}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum"
|
||||
@@ -300,6 +306,12 @@ export function Navbar({ offers }: { offers: Offer[] }) {
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
<Link
|
||||
href={customerName ? "/konto" : "/login"}
|
||||
className="border-b border-anouma-taupe/10 py-4 text-lg font-medium text-anouma-plum"
|
||||
>
|
||||
{customerName ? "Mein Konto" : "Anmelden"}
|
||||
</Link>
|
||||
<Link
|
||||
href={ctaNav.href}
|
||||
className="mt-6 rounded-full bg-anouma-mauve-dark px-6 py-4 text-center text-base font-medium text-white"
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
.page {
|
||||
padding: 24px;
|
||||
max-width: 1100px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 460px) 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.monthNav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.monthNav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
border: 1px solid #e2e2e2;
|
||||
}
|
||||
.monthNav a:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.monthLabel {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.weekdayLabel {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.dayCell {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
color: #1f1f1f;
|
||||
font-size: 13px;
|
||||
border: 1px solid transparent;
|
||||
gap: 3px;
|
||||
}
|
||||
.dayCell:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.dayCellSelected {
|
||||
background: #8b616d;
|
||||
color: #fff;
|
||||
}
|
||||
.dayCellEmpty {
|
||||
visibility: hidden;
|
||||
}
|
||||
.dayCellToday {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dotPending { background: #d4a04c; }
|
||||
.dotConfirmed { background: #5c7a4f; }
|
||||
.dotRejected { background: #c14b4b; }
|
||||
.dotCancelled { background: #999; }
|
||||
.dotPublic { background: #b3717a; }
|
||||
|
||||
.schedule {
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scheduleHeader {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hourRow {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.hourLabel {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.hourContent {
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f7f4f1;
|
||||
text-decoration: none;
|
||||
color: #1f1f1f;
|
||||
font-size: 13px;
|
||||
}
|
||||
.entry:hover {
|
||||
background: #efe7e1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badgePending { background: #f7e6c4; color: #6b4f14; }
|
||||
.badgeConfirmed { background: #dbe7d5; color: #3a4f30; }
|
||||
.badgeRejected { background: #f6d4d4; color: #7a2020; }
|
||||
.badgeCancelled { background: #e8e8e8; color: #555; }
|
||||
.badgePublic { background: #f0dde0; color: #6d3540; }
|
||||
|
||||
.empty {
|
||||
padding: 24px 18px;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
}
|
||||
.legendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { AdminViewServerProps } from "payload";
|
||||
import { getAdminCalendarItems, type AdminCalendarItem } from "@/lib/booking/adminCalendar";
|
||||
import styles from "./AdminCalendarView.module.css";
|
||||
|
||||
const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: "Anfrage",
|
||||
confirmed: "Bestätigt",
|
||||
rejected: "Abgelehnt",
|
||||
cancelled: "Storniert",
|
||||
public: "Öffentlich",
|
||||
};
|
||||
const DOT_CLASS: Record<string, string> = {
|
||||
pending: styles.dotPending,
|
||||
confirmed: styles.dotConfirmed,
|
||||
rejected: styles.dotRejected,
|
||||
cancelled: styles.dotCancelled,
|
||||
public: styles.dotPublic,
|
||||
};
|
||||
const BADGE_CLASS: Record<string, string> = {
|
||||
pending: styles.badgePending,
|
||||
confirmed: styles.badgeConfirmed,
|
||||
rejected: styles.badgeRejected,
|
||||
cancelled: styles.badgeCancelled,
|
||||
public: styles.badgePublic,
|
||||
};
|
||||
|
||||
function dateKey(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
function parseMonthParam(value: string | null): Date {
|
||||
if (value && /^\d{4}-\d{2}$/.test(value)) {
|
||||
const [y, m] = value.split("-").map(Number);
|
||||
return new Date(y, m - 1, 1);
|
||||
}
|
||||
return new Date(new Date().getFullYear(), new Date().getMonth(), 1);
|
||||
}
|
||||
function monthParam(d: Date) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
function fmtTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export async function AdminCalendarView({ initPageResult }: AdminViewServerProps) {
|
||||
const req = initPageResult.req;
|
||||
const searchParams = req.searchParams;
|
||||
const month = parseMonthParam(searchParams.get("month"));
|
||||
const selectedDayParam = searchParams.get("day");
|
||||
const selectedDay = selectedDayParam ? new Date(`${selectedDayParam}T00:00:00`) : null;
|
||||
|
||||
const gridStart = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const firstWeekday = (gridStart.getDay() + 6) % 7;
|
||||
const rangeStart = new Date(gridStart);
|
||||
rangeStart.setDate(rangeStart.getDate() - firstWeekday);
|
||||
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
|
||||
const rangeEnd = new Date(rangeStart);
|
||||
rangeEnd.setDate(rangeEnd.getDate() + 41); // 6 full weeks
|
||||
|
||||
const items = await getAdminCalendarItems(req.payload, { from: rangeStart, to: rangeEnd });
|
||||
const itemsByDay = new Map<string, AdminCalendarItem[]>();
|
||||
for (const item of items) {
|
||||
const key = dateKey(new Date(item.date));
|
||||
itemsByDay.set(key, [...(itemsByDay.get(key) ?? []), item]);
|
||||
}
|
||||
|
||||
const cells: (Date | null)[] = [];
|
||||
for (let i = 0; i < firstWeekday; i++) cells.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(month.getFullYear(), month.getMonth(), d));
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
|
||||
const prevMonth = new Date(month.getFullYear(), month.getMonth() - 1, 1);
|
||||
const nextMonth = new Date(month.getFullYear(), month.getMonth() + 1, 1);
|
||||
const today = dateKey(new Date());
|
||||
const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
|
||||
|
||||
const dayItems = selectedDay ? (itemsByDay.get(dateKey(selectedDay)) ?? []) : [];
|
||||
const hours = Array.from({ length: 13 }, (_, i) => i + 8); // 08:00–20:00
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.title}>Kalender</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Alle bestätigten und vorgeschlagenen Termine, private Einzelbuchungen und öffentliche Veranstaltungen.
|
||||
</p>
|
||||
|
||||
<div className={styles.layout}>
|
||||
<div>
|
||||
<div className={styles.monthNav}>
|
||||
<a href={`?month=${monthParam(prevMonth)}`} aria-label="Vorheriger Monat">
|
||||
‹
|
||||
</a>
|
||||
<span className={styles.monthLabel}>{monthLabel}</span>
|
||||
<a href={`?month=${monthParam(nextMonth)}`} aria-label="Nächster Monat">
|
||||
›
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{WEEKDAY_LABELS.map((d) => (
|
||||
<div key={d} className={styles.weekdayLabel}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
{cells.map((day, i) => {
|
||||
if (!day) return <div key={i} className={`${styles.dayCell} ${styles.dayCellEmpty}`} />;
|
||||
const key = dateKey(day);
|
||||
const dayEntries = itemsByDay.get(key) ?? [];
|
||||
const isSelected = selectedDayParam === key;
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={`?month=${monthParam(month)}&day=${key}`}
|
||||
className={`${styles.dayCell} ${isSelected ? styles.dayCellSelected : ""} ${key === today ? styles.dayCellToday : ""}`}
|
||||
>
|
||||
<span>{day.getDate()}</span>
|
||||
{dayEntries.length > 0 && (
|
||||
<span className={styles.dots}>
|
||||
{dayEntries.slice(0, 4).map((entry, j) => (
|
||||
<span key={j} className={`${styles.dot} ${DOT_CLASS[entry.status] ?? ""}`} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.legend}>
|
||||
{Object.entries(STATUS_LABEL).map(([status, label]) => (
|
||||
<span key={status} className={styles.legendItem}>
|
||||
<span className={`${styles.dot} ${DOT_CLASS[status]}`} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.schedule}>
|
||||
<div className={styles.scheduleHeader}>
|
||||
{selectedDay
|
||||
? selectedDay.toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long", year: "numeric" })
|
||||
: "Wähle einen Tag im Kalender"}
|
||||
</div>
|
||||
{!selectedDay && <p className={styles.empty}>Klicke auf einen Tag, um die Tagesübersicht zu sehen.</p>}
|
||||
{selectedDay && dayItems.length === 0 && <p className={styles.empty}>An diesem Tag ist nichts eingetragen.</p>}
|
||||
{selectedDay &&
|
||||
dayItems.length > 0 &&
|
||||
hours.map((hour) => {
|
||||
const hourEntries = dayItems.filter((item) => new Date(item.startTime).getHours() === hour);
|
||||
return (
|
||||
<div key={hour} className={styles.hourRow}>
|
||||
<div className={styles.hourLabel}>{String(hour).padStart(2, "0")}:00</div>
|
||||
<div className={styles.hourContent}>
|
||||
{hourEntries.map((entry) => (
|
||||
<a key={entry.id} href={entry.href} className={styles.entry}>
|
||||
<span className={`${styles.badge} ${BADGE_CLASS[entry.status] ?? ""}`}>
|
||||
{STATUS_LABEL[entry.status] ?? entry.status}
|
||||
</span>
|
||||
<strong>{entry.title}</strong>
|
||||
<span>
|
||||
{fmtTime(entry.startTime)}–{fmtTime(entry.endTime)}
|
||||
</span>
|
||||
{entry.subtitle && <span>· {entry.subtitle}</span>}
|
||||
{entry.appointmentType && <span>· {entry.appointmentType === "online" ? "Online" : "Vor Ort"}</span>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function CalendarNavLink() {
|
||||
return (
|
||||
<div style={{ padding: "0 8px 8px" }}>
|
||||
<Link
|
||||
href="/admin/kalender"
|
||||
style={{
|
||||
display: "block",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#8b616d",
|
||||
textDecoration: "none",
|
||||
border: "1px solid #e2e2e2",
|
||||
}}
|
||||
>
|
||||
📅 Kalender
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user