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:
@@ -5,10 +5,12 @@ import { Section } from "@/components/Section";
|
|||||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||||
import { RichText } from "@/components/RichText";
|
import { RichText } from "@/components/RichText";
|
||||||
import { CTA } from "@/components/CTA";
|
import { CTA } from "@/components/CTA";
|
||||||
|
import { BookingWidget } from "@/components/booking/BookingWidget";
|
||||||
import { getOfferBySlug } from "@/lib/payload/content";
|
import { getOfferBySlug } from "@/lib/payload/content";
|
||||||
import { moodForOffer } from "@/lib/angebote";
|
import { moodForOffer } from "@/lib/angebote";
|
||||||
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
|
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
|
||||||
import { OFFER_CATEGORIES } from "@/collections/Offers";
|
import { OFFER_CATEGORIES } from "@/collections/Offers";
|
||||||
|
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ export default async function OfferDetailPage({ params }: Args) {
|
|||||||
if (!offer) notFound();
|
if (!offer) notFound();
|
||||||
|
|
||||||
const categoryLabel = OFFER_CATEGORIES.find((c) => c.value === offer.category)?.label;
|
const categoryLabel = OFFER_CATEGORIES.find((c) => c.value === offer.category)?.label;
|
||||||
|
const customer = offer.bookable ? await getCurrentCustomer() : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -54,13 +57,39 @@ export default async function OfferDetailPage({ params }: Args) {
|
|||||||
</div>
|
</div>
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
<div className="mx-auto max-w-2xl">
|
<div className="mx-auto max-w-2xl">
|
||||||
|
{(offer.price || offer.durationMinutes) && (
|
||||||
|
<div className="mb-8 flex flex-wrap gap-6 rounded-2xl bg-anouma-cream-light p-5 text-sm">
|
||||||
|
{offer.price && (
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs font-medium uppercase tracking-wide text-anouma-plum/60">Preis</span>
|
||||||
|
<span className="text-base text-anouma-plum">{offer.price}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{offer.durationMinutes && (
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs font-medium uppercase tracking-wide text-anouma-plum/60">Dauer</span>
|
||||||
|
<span className="text-base text-anouma-plum">{offer.durationMinutes} Minuten</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<RichText data={offer.description} />
|
<RichText data={offer.description} />
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
<CTA
|
|
||||||
title="Interesse geweckt?"
|
{offer.bookable ? (
|
||||||
lead={`Melde dich gerne für ein unverbindliches Gespräch zu „${offer.title}“.`}
|
<Section tone="cream">
|
||||||
/>
|
<div className="mx-auto max-w-3xl">
|
||||||
|
<h2 className="mb-6 text-center font-serif text-3xl font-medium text-anouma-plum">Termin auswählen</h2>
|
||||||
|
<BookingWidget offerSlug={offer.slug!} isLoggedIn={Boolean(customer)} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
) : (
|
||||||
|
<CTA
|
||||||
|
title="Interesse geweckt?"
|
||||||
|
lead={`Melde dich gerne für ein unverbindliches Gespräch zu „${offer.title}“.`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Navbar } from "@/components/Navbar";
|
|||||||
import { Footer } from "@/components/Footer";
|
import { Footer } from "@/components/Footer";
|
||||||
import { siteConfig } from "@/lib/site";
|
import { siteConfig } from "@/lib/site";
|
||||||
import { getOffers } from "@/lib/payload/content";
|
import { getOffers } from "@/lib/payload/content";
|
||||||
|
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
// Every page under this layout can read live content from Payload, so the
|
// Every page under this layout can read live content from Payload, so the
|
||||||
@@ -45,7 +46,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
const offers = await getOffers();
|
const [offers, customer] = await Promise.all([getOffers(), getCurrentCustomer()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
@@ -60,7 +61,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
>
|
>
|
||||||
Zum Inhalt springen
|
Zum Inhalt springen
|
||||||
</a>
|
</a>
|
||||||
<Navbar offers={offers} />
|
<Navbar offers={offers} customerName={customer?.name ?? null} />
|
||||||
<main id="main-content" className="flex-1">
|
<main id="main-content" className="flex-1">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import type { Metadata } from "next";
|
|||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
import { Section, SectionHeading } from "@/components/Section";
|
import { Section, SectionHeading } from "@/components/Section";
|
||||||
import { ContactForm } from "@/components/ContactForm";
|
import { ContactForm } from "@/components/ContactForm";
|
||||||
|
import { AngebotCard } from "@/components/AngebotCard";
|
||||||
import { Reveal } from "@/components/Reveal";
|
import { Reveal } from "@/components/Reveal";
|
||||||
import { getBookingGlobal, getContactGlobal } from "@/lib/payload/globals";
|
import { getBookingGlobal, getContactGlobal } from "@/lib/payload/globals";
|
||||||
|
import { getOffers } from "@/lib/payload/content";
|
||||||
|
import { moodForOffer } from "@/lib/angebote";
|
||||||
|
import { mediaUrl } from "@/lib/payload/media";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -16,7 +20,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function TerminBuchenPage() {
|
export default async function TerminBuchenPage() {
|
||||||
const [booking, contact] = await Promise.all([getBookingGlobal(), getContactGlobal()]);
|
const [booking, contact, offers] = await Promise.all([getBookingGlobal(), getContactGlobal(), getOffers()]);
|
||||||
|
const bookableOffers = offers.filter((offer) => offer.bookable);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -27,6 +32,25 @@ export default async function TerminBuchenPage() {
|
|||||||
crumbs={[{ title: "Termin buchen" }]}
|
crumbs={[{ title: "Termin buchen" }]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{bookableOffers.length > 0 && (
|
||||||
|
<Section tone="plain">
|
||||||
|
<SectionHeading eyebrow="Angebot auswählen" title="Wofür möchtest du einen Termin?" />
|
||||||
|
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{bookableOffers.map((offer, i) => (
|
||||||
|
<Reveal key={offer.slug} delay={i * 0.08}>
|
||||||
|
<AngebotCard
|
||||||
|
href={`/angebote/${offer.slug}`}
|
||||||
|
title={offer.title}
|
||||||
|
tagline={offer.shortDescription}
|
||||||
|
mood={moodForOffer(offer)}
|
||||||
|
imageSrc={mediaUrl(offer.image, "card")}
|
||||||
|
/>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{booking.steps && booking.steps.length > 0 && (
|
{booking.steps && booking.steps.length > 0 && (
|
||||||
<Section tone="cream">
|
<Section tone="cream">
|
||||||
<SectionHeading eyebrow="Ablauf" title="So findest du zu deinem Termin" />
|
<SectionHeading eyebrow="Ablauf" title="So findest du zu deinem Termin" />
|
||||||
@@ -46,7 +70,15 @@ export default async function TerminBuchenPage() {
|
|||||||
|
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
<div className="mx-auto max-w-xl">
|
<div className="mx-auto max-w-xl">
|
||||||
<SectionHeading title="Nachricht senden" align="left" />
|
<SectionHeading
|
||||||
|
title="Allgemeine Anfrage"
|
||||||
|
lead={
|
||||||
|
bookableOffers.length > 0
|
||||||
|
? "Für einen konkreten Termin wähle oben ein Angebot — hier kannst du auch einfach eine allgemeine Nachricht schreiben."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
align="left"
|
||||||
|
/>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<ContactForm
|
<ContactForm
|
||||||
toEmail={contact.email}
|
toEmail={contact.email}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93
|
|||||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { CalendarNavLink as CalendarNavLink_4d06fac03127b339306d5f5ecb073251 } from '../../../components/admin/CalendarNavLink'
|
||||||
|
import { AdminCalendarView as AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a } from '../../../components/admin/AdminCalendarView'
|
||||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||||
|
|
||||||
/** @type import('payload').ImportMap */
|
/** @type import('payload').ImportMap */
|
||||||
@@ -48,5 +50,7 @@ export const importMap = {
|
|||||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"/components/admin/CalendarNavLink#CalendarNavLink": CalendarNavLink_4d06fac03127b339306d5f5ecb073251,
|
||||||
|
"/components/admin/AdminCalendarView#AdminCalendarView": AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a,
|
||||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { APIError } from "payload";
|
import { APIError } from "payload";
|
||||||
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } from "payload";
|
||||||
import { isAdmin, isAdminFieldLevel } from "@/access";
|
import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||||
import { acquireBookingDayLock } from "@/lib/booking/lock";
|
import { acquireBookingDayLock } from "@/lib/booking/lock";
|
||||||
import {
|
import {
|
||||||
@@ -53,28 +53,35 @@ async function notifyAdmins(
|
|||||||
// inside that same lock/transaction, and — for online appointments — creates
|
// inside that same lock/transaction, and — for online appointments — creates
|
||||||
// the linked video-call Event (reusing the existing meeting/password system)
|
// the linked video-call Event (reusing the existing meeting/password system)
|
||||||
// before the booking itself is written.
|
// before the booking itself is written.
|
||||||
const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req, operation }) => {
|
const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => {
|
||||||
if (operation !== "update" || data.status !== "confirmed" || originalDoc?.status === "confirmed") {
|
// Covers both paths: an existing request being confirmed, AND Anna
|
||||||
|
// manually creating a booking that's already "confirmed" from the start
|
||||||
|
// (e.g. a phone booking) — both must go through the same conflict check.
|
||||||
|
const wasAlreadyConfirmed = originalDoc?.status === "confirmed";
|
||||||
|
if (data.status !== "confirmed" || wasAlreadyConfirmed) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = data.date ?? originalDoc.date;
|
const date = data.date ?? originalDoc?.date;
|
||||||
const startTime = data.startTime ?? originalDoc.startTime;
|
const startTime = data.startTime ?? originalDoc?.startTime;
|
||||||
const endTime = data.endTime ?? originalDoc.endTime;
|
const endTime = data.endTime ?? originalDoc?.endTime;
|
||||||
const appointmentType = data.appointmentType ?? originalDoc.appointmentType;
|
const appointmentType = data.appointmentType ?? originalDoc?.appointmentType;
|
||||||
|
if (!date || !startTime || !endTime) return data; // Field-level validation will reject this anyway.
|
||||||
|
|
||||||
await acquireBookingDayLock(req, dateKey(date));
|
await acquireBookingDayLock(req, dateKey(date));
|
||||||
|
|
||||||
|
const dayStart = new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString();
|
||||||
|
const dayEnd = new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString();
|
||||||
|
const conflictClauses: Where[] = [
|
||||||
|
{ status: { equals: "confirmed" } },
|
||||||
|
{ date: { greater_than_equal: dayStart } },
|
||||||
|
{ date: { less_than_equal: dayEnd } },
|
||||||
|
];
|
||||||
|
if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
|
||||||
|
|
||||||
const { docs: sameDayConfirmed } = await req.payload.find({
|
const { docs: sameDayConfirmed } = await req.payload.find({
|
||||||
collection: "booking-requests",
|
collection: "booking-requests",
|
||||||
where: {
|
where: { and: conflictClauses },
|
||||||
and: [
|
|
||||||
{ status: { equals: "confirmed" } },
|
|
||||||
{ id: { not_equals: originalDoc.id } },
|
|
||||||
{ date: { greater_than_equal: new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString() } },
|
|
||||||
{ date: { less_than_equal: new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString() } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
req,
|
req,
|
||||||
limit: 200,
|
limit: 200,
|
||||||
});
|
});
|
||||||
@@ -96,8 +103,8 @@ const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appointmentType === "online" && !data.linkedEvent && !originalDoc.linkedEvent) {
|
if (appointmentType === "online" && !data.linkedEvent && !originalDoc?.linkedEvent) {
|
||||||
const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc.offer, req })) as Offer;
|
const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc?.offer, req })) as Offer;
|
||||||
const event = (await req.payload.create({
|
const event = (await req.payload.create({
|
||||||
collection: "events",
|
collection: "events",
|
||||||
req,
|
req,
|
||||||
@@ -109,7 +116,10 @@ const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDo
|
|||||||
category: "sonstiges",
|
category: "sonstiges",
|
||||||
isOnline: true, // Events' own beforeChange hook auto-generates the password.
|
isOnline: true, // Events' own beforeChange hook auto-generates the password.
|
||||||
isPrivateBooking: true,
|
isPrivateBooking: true,
|
||||||
bookingRequest: originalDoc.id,
|
// Only set on the update-to-confirmed path — on create-as-confirmed
|
||||||
|
// this booking doesn't have an id yet (assigned after insert), so
|
||||||
|
// the back-reference is simply left blank in that one case.
|
||||||
|
...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}),
|
||||||
},
|
},
|
||||||
// Created as a draft so it never appears in public /termine listings
|
// Created as a draft so it never appears in public /termine listings
|
||||||
// (see lib/payload/content.ts) — it's still reachable via its direct
|
// (see lib/payload/content.ts) — it's still reachable via its direct
|
||||||
@@ -139,7 +149,11 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
|||||||
const adminUrl = `${serverUrl}/admin/collections/booking-requests/${doc.id}`;
|
const adminUrl = `${serverUrl}/admin/collections/booking-requests/${doc.id}`;
|
||||||
const accountUrl = `${serverUrl}/konto/termine`;
|
const accountUrl = `${serverUrl}/konto/termine`;
|
||||||
|
|
||||||
if (operation === "create") {
|
// A brand-new request (the normal customer-initiated path) always gets
|
||||||
|
// the "received" + admin-notification pair. A booking Anna creates
|
||||||
|
// manually as already-confirmed/rejected (operation 26: manual bookings)
|
||||||
|
// skips this and falls through to the same status-based emails below.
|
||||||
|
if (operation === "create" && doc.status === "pending") {
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
user.email,
|
user.email,
|
||||||
bookingRequestReceivedEmail({
|
bookingRequestReceivedEmail({
|
||||||
@@ -165,7 +179,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusChanged = previousDoc?.status !== doc.status;
|
const statusChanged = operation === "create" || previousDoc?.status !== doc.status;
|
||||||
|
|
||||||
if (statusChanged && doc.status === "confirmed") {
|
if (statusChanged && doc.status === "confirmed") {
|
||||||
let online: { joinUrl: string; password: string } | undefined;
|
let online: { joinUrl: string; password: string } | undefined;
|
||||||
|
|||||||
+14
-2
@@ -8,7 +8,7 @@ import { groupOffersByCategory } from "@/lib/angebote";
|
|||||||
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
|
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
|
||||||
import type { Offer } from "@/payload-types";
|
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 pathname = usePathname();
|
||||||
const groups = groupOffersByCategory(offers);
|
const groups = groupOffersByCategory(offers);
|
||||||
const [megaOpen, setMegaOpen] = useState(false);
|
const [megaOpen, setMegaOpen] = useState(false);
|
||||||
@@ -173,7 +173,13 @@ export function Navbar({ offers }: { offers: Offer[] }) {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</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
|
<Link
|
||||||
href={ctaNav.href}
|
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"
|
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>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
<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
|
<Link
|
||||||
href={ctaNav.href}
|
href={ctaNav.href}
|
||||||
className="mt-6 rounded-full bg-anouma-mauve-dark px-6 py-4 text-center text-base font-medium text-white"
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { Payload } from "payload";
|
||||||
|
import type { BookingRequest, Customer, Event, Offer } from "@/payload-types";
|
||||||
|
|
||||||
|
export type AdminCalendarItem = {
|
||||||
|
id: string;
|
||||||
|
kind: "booking" | "event";
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
status: string; // booking status, or "public" for a standalone Event
|
||||||
|
appointmentType?: "onsite" | "online";
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines private BookingRequests with standalone public Events (excluding
|
||||||
|
* the auto-generated Events already represented by a confirmed online
|
||||||
|
* booking, so Anna never sees the same appointment listed twice) into one
|
||||||
|
* unified calendar feed for the admin view.
|
||||||
|
*/
|
||||||
|
export async function getAdminCalendarItems(
|
||||||
|
payload: Payload,
|
||||||
|
range: { from: Date; to: Date },
|
||||||
|
): Promise<AdminCalendarItem[]> {
|
||||||
|
const fromISO = range.from.toISOString();
|
||||||
|
const toISO = range.to.toISOString();
|
||||||
|
|
||||||
|
const [{ docs: bookings }, { docs: events }] = await Promise.all([
|
||||||
|
payload.find({
|
||||||
|
collection: "booking-requests",
|
||||||
|
where: { and: [{ date: { greater_than_equal: fromISO } }, { date: { less_than_equal: toISO } }] },
|
||||||
|
depth: 2,
|
||||||
|
limit: 500,
|
||||||
|
}),
|
||||||
|
payload.find({
|
||||||
|
collection: "events",
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ date: { greater_than_equal: fromISO } },
|
||||||
|
{ date: { less_than_equal: toISO } },
|
||||||
|
{ isPrivateBooking: { not_equals: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
depth: 0,
|
||||||
|
limit: 500,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bookingItems: AdminCalendarItem[] = (bookings as BookingRequest[]).map((b) => {
|
||||||
|
const offer = typeof b.offer === "object" ? (b.offer as Offer) : null;
|
||||||
|
const customer = typeof b.user === "object" ? (b.user as Customer) : null;
|
||||||
|
return {
|
||||||
|
id: `booking-${b.id}`,
|
||||||
|
kind: "booking",
|
||||||
|
title: offer?.title ?? "Buchung",
|
||||||
|
subtitle: customer?.name,
|
||||||
|
date: b.date,
|
||||||
|
startTime: b.startTime,
|
||||||
|
endTime: b.endTime,
|
||||||
|
status: b.status,
|
||||||
|
appointmentType: b.appointmentType,
|
||||||
|
href: `/admin/collections/booking-requests/${b.id}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventItems: AdminCalendarItem[] = (events as Event[]).map((e) => ({
|
||||||
|
id: `event-${e.id}`,
|
||||||
|
kind: "event",
|
||||||
|
title: e.title,
|
||||||
|
date: e.date,
|
||||||
|
startTime: e.startTime ?? e.date,
|
||||||
|
endTime: e.endTime ?? e.date,
|
||||||
|
status: "public",
|
||||||
|
appointmentType: e.isOnline ? "online" : "onsite",
|
||||||
|
href: `/admin/collections/events/${e.id}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...bookingItems, ...eventItems].sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
||||||
|
}
|
||||||
@@ -38,6 +38,17 @@ export default buildConfig({
|
|||||||
meta: {
|
meta: {
|
||||||
titleSuffix: "— Anouma Admin",
|
titleSuffix: "— Anouma Admin",
|
||||||
},
|
},
|
||||||
|
components: {
|
||||||
|
afterNavLinks: ["/components/admin/CalendarNavLink#CalendarNavLink"],
|
||||||
|
views: {
|
||||||
|
kalender: {
|
||||||
|
Component: "/components/admin/AdminCalendarView#AdminCalendarView",
|
||||||
|
path: "/kalender",
|
||||||
|
exact: true,
|
||||||
|
meta: { title: "Kalender" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
collections: [
|
collections: [
|
||||||
Users,
|
Users,
|
||||||
|
|||||||
Reference in New Issue
Block a user