- 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
133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
|
||
export type CalendarMarker = {
|
||
date: Date;
|
||
status: "pending" | "confirmed" | "rejected" | "cancelled" | "past" | "public" | "private";
|
||
};
|
||
|
||
const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||
|
||
const markerColor: Record<CalendarMarker["status"], string> = {
|
||
pending: "bg-anouma-sand",
|
||
confirmed: "bg-anouma-sage",
|
||
rejected: "bg-red-400",
|
||
cancelled: "bg-anouma-taupe",
|
||
past: "bg-anouma-taupe/50",
|
||
public: "bg-anouma-rose",
|
||
private: "bg-anouma-mauve-dark",
|
||
};
|
||
|
||
function startOfMonth(d: Date) {
|
||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||
}
|
||
function dateKey(d: Date) {
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
export function MonthCalendar({
|
||
markers,
|
||
onSelectDay,
|
||
selectedDay,
|
||
}: {
|
||
markers: CalendarMarker[];
|
||
onSelectDay?: (day: Date) => void;
|
||
selectedDay?: Date | null;
|
||
}) {
|
||
const [month, setMonth] = useState(() => startOfMonth(new Date()));
|
||
|
||
const markersByDay = useMemo(() => {
|
||
const map = new Map<string, CalendarMarker[]>();
|
||
for (const marker of markers) {
|
||
const key = dateKey(marker.date);
|
||
map.set(key, [...(map.get(key) ?? []), marker]);
|
||
}
|
||
return map;
|
||
}, [markers]);
|
||
|
||
const weeks = useMemo(() => {
|
||
const first = startOfMonth(month);
|
||
const firstWeekday = (first.getDay() + 6) % 7; // Monday = 0
|
||
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
|
||
|
||
const cells: (Date | null)[] = Array(firstWeekday).fill(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 result: (Date | null)[][] = [];
|
||
for (let i = 0; i < cells.length; i += 7) result.push(cells.slice(i, i + 7));
|
||
return result;
|
||
}, [month]);
|
||
|
||
const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
|
||
const today = dateKey(new Date());
|
||
|
||
return (
|
||
<div className="rounded-3xl bg-white p-6">
|
||
<div className="flex items-center justify-between">
|
||
<button
|
||
type="button"
|
||
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}
|
||
aria-label="Vorheriger Monat"
|
||
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
|
||
>
|
||
‹
|
||
</button>
|
||
<p className="font-serif text-lg font-medium capitalize text-anouma-plum">{monthLabel}</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}
|
||
aria-label="Nächster Monat"
|
||
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs font-medium uppercase tracking-wide text-anouma-plum/50">
|
||
{WEEKDAY_LABELS.map((d) => (
|
||
<div key={d}>{d}</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-1 space-y-1">
|
||
{weeks.map((week, i) => (
|
||
<div key={i} className="grid grid-cols-7 gap-1">
|
||
{week.map((day, j) => {
|
||
if (!day) return <div key={j} />;
|
||
const key = dateKey(day);
|
||
const dayMarkers = markersByDay.get(key) ?? [];
|
||
const isSelected = selectedDay && dateKey(selectedDay) === key;
|
||
return (
|
||
<button
|
||
key={j}
|
||
type="button"
|
||
onClick={() => onSelectDay?.(day)}
|
||
className={`flex aspect-square flex-col items-center justify-center rounded-xl text-sm transition-colors ${
|
||
isSelected ? "bg-anouma-mauve-dark text-white" : "text-anouma-plum hover:bg-anouma-cream-light"
|
||
} ${key === today && !isSelected ? "font-semibold" : ""}`}
|
||
>
|
||
<span>{day.getDate()}</span>
|
||
{dayMarkers.length > 0 && (
|
||
<span className="mt-0.5 flex gap-0.5">
|
||
{dayMarkers.slice(0, 3).map((m, k) => (
|
||
<span
|
||
key={k}
|
||
className={`h-1.5 w-1.5 rounded-full ${isSelected ? "bg-white" : markerColor[m.status]}`}
|
||
/>
|
||
))}
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|