Integrate LiveKit SFU and harden booking workflow
This commit is contained in:
@@ -155,13 +155,21 @@
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f7f4f1;
|
||||
text-decoration: none;
|
||||
color: #1f1f1f;
|
||||
font-size: 13px;
|
||||
}
|
||||
.entry:hover {
|
||||
background: #efe7e1;
|
||||
}
|
||||
.entryLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AdminViewServerProps } from "payload";
|
||||
import { getAdminCalendarItems, type AdminCalendarItem } from "@/lib/booking/adminCalendar";
|
||||
import { berlinParts } from "@/lib/calendar/timezone";
|
||||
import { CalendarFeedPanel } from "./CalendarFeedPanel";
|
||||
import { AdminCallButton } from "./AdminCallButton";
|
||||
import styles from "./AdminCalendarView.module.css";
|
||||
|
||||
const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
@@ -27,7 +29,8 @@ const BADGE_CLASS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function dateKey(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
const { year, month, day } = berlinParts(d);
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
function parseMonthParam(value: string | null): Date {
|
||||
if (value && /^\d{4}-\d{2}$/.test(value)) {
|
||||
@@ -40,7 +43,11 @@ 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" });
|
||||
return new Date(iso).toLocaleTimeString("de-DE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export async function AdminCalendarView({ initPageResult }: AdminViewServerProps) {
|
||||
@@ -48,7 +55,9 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
|
||||
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;
|
||||
// Noon UTC stays on the same Berlin calendar day even when the server runs
|
||||
// in UTC and the selected date is around a DST transition.
|
||||
const selectedDay = selectedDayParam ? new Date(`${selectedDayParam}T12:00:00Z`) : null;
|
||||
|
||||
const gridStart = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const firstWeekday = (gridStart.getDay() + 6) % 7;
|
||||
@@ -82,7 +91,10 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
|
||||
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
|
||||
const startHours = dayItems.length ? dayItems.map((item) => berlinParts(new Date(item.startTime)).hour) : [];
|
||||
const firstHour = Math.min(8, ...startHours);
|
||||
const lastHour = Math.max(20, ...startHours);
|
||||
const hours = Array.from({ length: lastHour - firstHour + 1 }, (_, i) => i + firstHour);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
@@ -156,23 +168,26 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
|
||||
{selectedDay &&
|
||||
dayItems.length > 0 &&
|
||||
hours.map((hour) => {
|
||||
const hourEntries = dayItems.filter((item) => new Date(item.startTime).getHours() === hour);
|
||||
const hourEntries = dayItems.filter((item) => berlinParts(new Date(item.startTime)).hour === 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 key={entry.id} className={styles.entry}>
|
||||
<a href={entry.href} className={styles.entryLink}>
|
||||
<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>
|
||||
{entry.callEventSlug && <AdminCallButton eventSlug={entry.callEventSlug} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function AdminCallButton({ eventSlug }: { eventSlug: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
async function startCall() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
try {
|
||||
const response = await fetch("/api/admin/meetings/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ eventSlug }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setError(data.error || "Der Video-Call konnte nicht gestartet werden.");
|
||||
return;
|
||||
}
|
||||
setUrl(data.testUrl);
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.testUrl);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setError("Der Link wurde erzeugt. Bitte kopiere ihn aus dem Feld.");
|
||||
}
|
||||
} catch {
|
||||
setError("Verbindung fehlgeschlagen.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={startCall}
|
||||
disabled={loading}
|
||||
className="rounded-full bg-anouma-mauve-dark px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-anouma-plum disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Test-Link wird erzeugt …" : copied ? "Test-Link kopiert" : "Call testen"}
|
||||
</button>
|
||||
{url && !copied && (
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={url}
|
||||
aria-label="Test-Link"
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
className="w-56 rounded border border-anouma-taupe/30 bg-white px-2 py-1 text-xs text-anouma-plum"
|
||||
/>
|
||||
)}
|
||||
{error && <span className="text-xs text-red-700">{error}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user