Initial commit: ANOUMA website with Payload CMS, WebRTC meetings, and booking system

- Next.js 16 App Router site with the ANOUMA design system
- Payload CMS (PostgreSQL) for offers, events, posts and page content
- WebRTC video-call system with custom signaling server
- SMTP email reminders and booking-request notifications
- Customer accounts, calendar-based availability, and booking workflow
This commit is contained in:
maro
2026-08-25 16:40:51 +02:00
commit 45261a0461
138 changed files with 23263 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
import { cache } from "react";
import { getCMS } from "@/lib/payload/getPayload";
import type { Event, Offer, Post } from "@/payload-types";
// Wrapped in React's `cache()` so multiple components reading the same data
// during one request (e.g. layout + page) only hit the database once.
//
// IMPORTANT: Payload's Local API defaults `overrideAccess` to `true`, which
// SKIPS each collection's `access.read` check entirely (see
// node_modules/payload/dist/collections/operations/find.js) — it does not
// automatically hide drafts. Every fetcher that feeds a *public* page must
// therefore pass `overrideAccess: false` so the collection's own
// `publishedOrAdmin` access rule actually filters out unpublished documents.
// ---- Angebote ---------------------------------------------------------
export const getOffers = cache(async (): Promise<Offer[]> => {
const payload = await getCMS();
const result = await payload.find({
collection: "offers",
sort: "order",
limit: 100,
depth: 1,
overrideAccess: false,
});
return result.docs;
});
export const getOfferBySlug = cache(async (slug: string): Promise<Offer | null> => {
const payload = await getCMS();
const result = await payload.find({
collection: "offers",
where: { slug: { equals: slug } },
limit: 1,
depth: 1,
overrideAccess: false,
});
return result.docs[0] ?? null;
});
// ---- Termine (Events) --------------------------------------------------
export const getUpcomingEvents = cache(async (limit = 6): Promise<Event[]> => {
const payload = await getCMS();
const today = new Date();
today.setHours(0, 0, 0, 0);
const result = await payload.find({
collection: "events",
where: { date: { greater_than_equal: today.toISOString() } },
sort: "date",
limit,
depth: 1,
overrideAccess: false,
});
return result.docs;
});
export const getAllEvents = cache(async (): Promise<Event[]> => {
const payload = await getCMS();
const result = await payload.find({
collection: "events",
sort: "-date",
limit: 200,
depth: 1,
overrideAccess: false,
});
return result.docs;
});
export const getEventBySlug = cache(async (slug: string): Promise<Event | null> => {
const payload = await getCMS();
const result = await payload.find({
collection: "events",
where: { slug: { equals: slug } },
limit: 1,
depth: 1,
overrideAccess: false,
});
return result.docs[0] ?? null;
});
/**
* Unlike `getEventBySlug`, this intentionally does NOT filter by publish
* status. Confirmed private bookings create an unpublished/private linked
* Event (see collections/BookingRequests.ts) that must still be reachable
* for the one customer who received its join link + password — "unlisted",
* not "hidden". Only used by the join/beitreten flow, never for listings.
*/
export const getEventForJoin = cache(async (slug: string): Promise<Event | null> => {
const payload = await getCMS();
const result = await payload.find({
collection: "events",
where: { slug: { equals: slug } },
limit: 1,
depth: 1,
});
return result.docs[0] ?? null;
});
// ---- Aktuelles (Posts) --------------------------------------------------
export const getPosts = cache(async (limit = 50): Promise<Post[]> => {
const payload = await getCMS();
const result = await payload.find({
collection: "posts",
sort: "-publishDate",
limit,
depth: 1,
overrideAccess: false,
});
return result.docs;
});
export const getPostBySlug = cache(async (slug: string): Promise<Post | null> => {
const payload = await getCMS();
const result = await payload.find({
collection: "posts",
where: { slug: { equals: slug } },
limit: 1,
depth: 1,
overrideAccess: false,
});
return result.docs[0] ?? null;
});
+11
View File
@@ -0,0 +1,11 @@
import { getPayload } from "payload";
import config from "@payload-config";
/**
* Thin wrapper around Payload's Local API client. `getPayload` already
* memoizes the instance internally (keyed by config), so this is safe to
* call from any Server Component without re-initializing on every request.
*/
export function getCMS() {
return getPayload({ config });
}
+33
View File
@@ -0,0 +1,33 @@
import { cache } from "react";
import { getCMS } from "@/lib/payload/getPayload";
import type { About, AktuellesIntro, AngeboteIntro, Booking, Contact, Home } from "@/payload-types";
export const getHomeGlobal = cache(async (): Promise<Home> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "home" });
});
export const getAboutGlobal = cache(async (): Promise<About> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "about" });
});
export const getAngeboteIntroGlobal = cache(async (): Promise<AngeboteIntro> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "angebote-intro" });
});
export const getAktuellesIntroGlobal = cache(async (): Promise<AktuellesIntro> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "aktuelles-intro" });
});
export const getContactGlobal = cache(async (): Promise<Contact> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "contact" });
});
export const getBookingGlobal = cache(async (): Promise<Booking> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "booking" });
});
+21
View File
@@ -0,0 +1,21 @@
import type { Media } from "@/payload-types";
type MediaSize = "thumbnail" | "card" | "hero";
/** Resolves a populated (depth >= 1) media relationship to a usable image URL. */
export function mediaUrl(
value: Media | number | null | undefined,
size?: MediaSize,
): string | undefined {
if (!value || typeof value === "number") return undefined;
if (size) {
const sized = value.sizes?.[size]?.url;
if (sized) return sized;
}
return value.url ?? undefined;
}
export function mediaAlt(value: Media | number | null | undefined): string | undefined {
if (!value || typeof value === "number") return undefined;
return value.alt;
}