feat: build Vynte scheduler screens
Add Calendar (week grid + meeting composer), Availability (weekly hours + date overrides), and Connections screens, plus server-side viewer resolution for Server Components. Teammate busy blocks never reveal titles, demo-mode auth bypass is refused in production, and the visible week range is shared from the server so the first client fetch matches server-rendered data.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { AppShell } from "@scheduler/components/AppShell";
|
||||
import { AvailabilityEditor } from "@scheduler/components/AvailabilityEditor";
|
||||
import { getServerViewerId } from "@scheduler/lib/scheduler/auth";
|
||||
import { getSchedule, getTeamContext } from "@scheduler/lib/scheduler/service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AvailabilityPage() {
|
||||
const viewerId = await getServerViewerId();
|
||||
if (viewerId === null) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const [context, schedule] = await Promise.all([getTeamContext(viewerId), getSchedule(viewerId)]);
|
||||
|
||||
return (
|
||||
<AppShell active="availability" user={{ name: context.viewer.name, timeZone: context.viewer.timeZone }}>
|
||||
<AvailabilityEditor schedule={schedule} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { AppShell } from "@scheduler/components/AppShell";
|
||||
import { CalendarWorkspace } from "@scheduler/components/CalendarWorkspace";
|
||||
import { getServerViewerId } from "@scheduler/lib/scheduler/auth";
|
||||
import { getAvailability, getTeamContext } from "@scheduler/lib/scheduler/service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DEFAULT_DURATION_MINUTES = 30;
|
||||
const VISIBLE_DAYS = 5;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Monday 00:00 (server local) of the current week, and Monday + visible days. */
|
||||
function currentWeekRange(): { rangeStart: string; rangeEnd: string } {
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const weekday = start.getDay();
|
||||
const mondayOffset = weekday === 0 ? -6 : 1 - weekday;
|
||||
start.setDate(start.getDate() + mondayOffset);
|
||||
return {
|
||||
rangeStart: start.toISOString(),
|
||||
rangeEnd: new Date(start.getTime() + VISIBLE_DAYS * DAY_MS).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CalendarPage() {
|
||||
const viewerId = await getServerViewerId();
|
||||
if (viewerId === null) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const { rangeStart, rangeEnd } = currentWeekRange();
|
||||
const [context, availability] = await Promise.all([
|
||||
getTeamContext(viewerId),
|
||||
getAvailability(viewerId, [viewerId], rangeStart, rangeEnd, DEFAULT_DURATION_MINUTES),
|
||||
]);
|
||||
|
||||
return (
|
||||
<AppShell active="calendar" user={{ name: context.viewer.name, timeZone: context.viewer.timeZone }}>
|
||||
<CalendarWorkspace
|
||||
viewerId={context.viewer.id}
|
||||
team={context.team}
|
||||
initialBusy={availability.busy}
|
||||
initialSlots={availability.mutualSlots}
|
||||
weekStartIso={rangeStart}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { AppShell } from "@scheduler/components/AppShell";
|
||||
import { ConnectionsView } from "@scheduler/components/ConnectionsView";
|
||||
import { getServerViewerId } from "@scheduler/lib/scheduler/auth";
|
||||
import { getConnections, getTeamContext } from "@scheduler/lib/scheduler/service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ConnectionsPage() {
|
||||
const viewerId = await getServerViewerId();
|
||||
if (viewerId === null) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const [context, { connections }] = await Promise.all([getTeamContext(viewerId), getConnections(viewerId)]);
|
||||
|
||||
return (
|
||||
<AppShell active="connections" user={{ name: context.viewer.name, timeZone: context.viewer.timeZone }}>
|
||||
<ConnectionsView connections={connections} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -13,3 +13,67 @@ a { color: inherit; text-decoration: none; }
|
||||
.profile-block span { color: #777c83; }
|
||||
.main-surface { padding: 20px; min-width: 0; }
|
||||
@media (max-width: 860px) { .app-frame { grid-template-columns: 1fr; } .sidebar { min-height: auto; border-right: 0; border-bottom: 1px solid #e3e5e8; } }
|
||||
|
||||
/* Shared primitives */
|
||||
.panel-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
|
||||
.panel-header h1 { font-size: 20px; font-weight: 700; margin: 0; }
|
||||
.panel-header h2, .panel-header h1 + * { margin: 0; }
|
||||
.muted { color: #777c83; font-size: 13px; }
|
||||
.primary-button { background: #246b48; color: #fff; border: 1px solid #1f5c3e; border-radius: 6px; padding: 9px 16px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.primary-button:disabled { opacity: 0.6; cursor: default; }
|
||||
.secondary-button { background: #fff; color: #333; border: 1px solid #d7dadf; border-radius: 6px; padding: 7px 12px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.secondary-button[data-active="true"] { border-color: #246b48; color: #204d39; background: #e9efec; }
|
||||
.notice { font-size: 13px; margin: 8px 0 0; }
|
||||
.notice-error { color: #a23434; }
|
||||
.notice-warn { color: #8a6320; }
|
||||
.notice-ok { color: #246b48; }
|
||||
.field { display: grid; gap: 4px; margin: 12px 0; font-size: 13px; }
|
||||
.field input { border: 1px solid #d7dadf; border-radius: 6px; padding: 8px 10px; }
|
||||
.field-inline { display: flex; align-items: center; gap: 8px; font-size: 13px; margin: 12px 0; }
|
||||
|
||||
/* Calendar */
|
||||
.calendar-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 18px; align-items: start; }
|
||||
.calendar-panel { background: #fff; border: 1px solid #e3e5e8; border-radius: 8px; padding: 16px; }
|
||||
.week-grid { display: grid; grid-template-columns: 56px repeat(5, minmax(0, 1fr)); gap: 0; }
|
||||
.time-gutter { padding-top: 26px; }
|
||||
.hour-label { font-size: 11px; color: #9aa0a6; border-top: 1px solid #f0f1f3; padding: 2px 4px; }
|
||||
.day-column { border-left: 1px solid #f0f1f3; min-width: 0; }
|
||||
.day-heading { font-size: 12px; font-weight: 600; text-align: center; padding: 6px 0; color: #555a61; }
|
||||
.day-body { position: relative; background: #fff; }
|
||||
.busy-block { position: absolute; left: 3px; right: 3px; background: #e7e9ec; border: 1px solid #d3d6db; border-radius: 4px; font-size: 11px; padding: 2px 4px; overflow: hidden; color: #555a61; }
|
||||
.busy-block[data-own="true"] { background: #dce6e0; border-color: #b9cdc2; color: #2d5a42; }
|
||||
.mutual-slot { position: absolute; left: 3px; right: 3px; background: rgba(36, 107, 72, 0.12); border: 1px solid #246b48; border-radius: 4px; font-size: 11px; color: #204d39; cursor: pointer; padding: 2px 4px; }
|
||||
.mutual-slot[data-selected="true"] { background: #246b48; color: #fff; }
|
||||
|
||||
/* Composer */
|
||||
.composer { background: #fff; border: 1px solid #e3e5e8; border-radius: 8px; padding: 16px; position: sticky; top: 20px; }
|
||||
.composer h2 { font-size: 16px; margin: 0 0 12px; }
|
||||
.composer-group { border: 1px solid #eceef0; border-radius: 6px; padding: 10px 12px; margin: 0 0 12px; }
|
||||
.composer-group legend { font-size: 12px; font-weight: 600; color: #555a61; padding: 0 4px; }
|
||||
.attendee-row { display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 3px 0; }
|
||||
.duration-presets { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.duration-custom { width: 64px; border: 1px solid #d7dadf; border-radius: 6px; padding: 7px 8px; }
|
||||
.selected-slot { font-size: 13px; margin: 8px 0 12px; }
|
||||
|
||||
/* Availability + settings cards */
|
||||
.availability-layout, .connections-layout { display: grid; gap: 16px; max-width: 640px; }
|
||||
.settings-card { background: #fff; border: 1px solid #e3e5e8; border-radius: 8px; padding: 16px; }
|
||||
.settings-card h2 { font-size: 16px; margin: 0 0 12px; }
|
||||
.weekly-rows { display: grid; gap: 6px; }
|
||||
.weekly-row { display: flex; align-items: center; gap: 16px; padding: 6px 0; border-top: 1px solid #f0f1f3; }
|
||||
.weekly-row[data-enabled="false"] { opacity: 0.7; }
|
||||
.weekly-toggle { min-width: 130px; }
|
||||
.time-range { display: flex; align-items: center; gap: 8px; }
|
||||
.time-range input { border: 1px solid #d7dadf; border-radius: 6px; padding: 6px 8px; }
|
||||
.dash { color: #9aa0a6; }
|
||||
.unavailable-label { font-style: italic; }
|
||||
.override-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||
.override-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; border-top: 1px solid #f0f1f3; font-size: 13px; }
|
||||
.save-bar { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
/* Connections */
|
||||
.provider-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid #f0f1f3; }
|
||||
.provider-name { font-size: 13px; font-weight: 600; }
|
||||
.provider-status.connected { font-size: 12px; color: #246b48; font-weight: 600; }
|
||||
.browse-button { display: inline-block; margin-top: 12px; }
|
||||
.privacy-note { font-size: 12px; color: #777c83; }
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { DateOverride, SchedulerSchedule, WeeklyRange } from "@scheduler/lib/scheduler/types";
|
||||
|
||||
type AvailabilityEditorProps = {
|
||||
schedule: SchedulerSchedule;
|
||||
};
|
||||
|
||||
const DAY_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
|
||||
const DEFAULT_START = "09:00";
|
||||
const DEFAULT_END = "17:00";
|
||||
|
||||
type SaveState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "saving" }
|
||||
| { kind: "saved" }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
/** Builds one editable row per weekday, seeded from the schedule's first range for that day. */
|
||||
function buildRows(weekly: WeeklyRange[]): WeeklyRange[] {
|
||||
return DAY_LABELS.map((_, day) => {
|
||||
const existing = weekly.find((range) => range.day === day && range.enabled);
|
||||
return {
|
||||
day,
|
||||
enabled: Boolean(existing),
|
||||
startTime: existing?.startTime ?? DEFAULT_START,
|
||||
endTime: existing?.endTime ?? DEFAULT_END,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function AvailabilityEditor({ schedule }: AvailabilityEditorProps) {
|
||||
const [rows, setRows] = useState<WeeklyRange[]>(() => buildRows(schedule.weekly));
|
||||
const [overrides, setOverrides] = useState<DateOverride[]>(schedule.overrides);
|
||||
const [saveState, setSaveState] = useState<SaveState>({ kind: "idle" });
|
||||
const timeZone = schedule.timeZone;
|
||||
|
||||
const enabledCount = useMemo(() => rows.filter((row) => row.enabled).length, [rows]);
|
||||
|
||||
const updateRow = (day: number, patch: Partial<WeeklyRange>) => {
|
||||
setRows((current) => current.map((row) => (row.day === day ? { ...row, ...patch } : row)));
|
||||
};
|
||||
|
||||
const removeOverride = (date: string) => {
|
||||
setOverrides((current) => current.filter((override) => override.date !== date));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaveState({ kind: "saving" });
|
||||
try {
|
||||
const response = await fetch("/api/scheduler/schedule", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
timeZone,
|
||||
weekly: rows.filter((row) => row.enabled),
|
||||
overrides,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("save failed");
|
||||
const updated = (await response.json()) as SchedulerSchedule;
|
||||
setRows(buildRows(updated.weekly));
|
||||
setOverrides(updated.overrides);
|
||||
setSaveState({ kind: "saved" });
|
||||
} catch {
|
||||
setSaveState({ kind: "error", message: "Could not save your schedule." });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="availability-layout">
|
||||
<header className="panel-header">
|
||||
<h1>Availability</h1>
|
||||
<span className="muted">Timezone: {timeZone}</span>
|
||||
</header>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Weekly hours</h2>
|
||||
<p className="muted">
|
||||
{enabledCount} day{enabledCount === 1 ? "" : "s"} available
|
||||
</p>
|
||||
<div className="weekly-rows">
|
||||
{rows.map((row) => (
|
||||
<div key={row.day} className="weekly-row" data-enabled={row.enabled}>
|
||||
<label className="field-inline weekly-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.enabled}
|
||||
onChange={(event) => updateRow(row.day, { enabled: event.target.checked })}
|
||||
/>
|
||||
<span>{DAY_LABELS[row.day]}</span>
|
||||
</label>
|
||||
{row.enabled ? (
|
||||
<div className="time-range">
|
||||
<input
|
||||
type="time"
|
||||
value={row.startTime}
|
||||
onChange={(event) => updateRow(row.day, { startTime: event.target.value })}
|
||||
aria-label={`${DAY_LABELS[row.day]} start`}
|
||||
/>
|
||||
<span className="dash">–</span>
|
||||
<input
|
||||
type="time"
|
||||
value={row.endTime}
|
||||
onChange={(event) => updateRow(row.day, { endTime: event.target.value })}
|
||||
aria-label={`${DAY_LABELS[row.day]} end`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted unavailable-label">Unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Date overrides</h2>
|
||||
{overrides.length === 0 ? (
|
||||
<p className="muted">No date overrides.</p>
|
||||
) : (
|
||||
<ul className="override-list">
|
||||
{overrides.map((override) => (
|
||||
<li key={override.date} className="override-row">
|
||||
<span>{override.date}</span>
|
||||
<span className="muted">
|
||||
{override.unavailable
|
||||
? "Unavailable all day"
|
||||
: `${override.startTime ?? DEFAULT_START}–${override.endTime ?? DEFAULT_END}`}
|
||||
</span>
|
||||
<button type="button" className="secondary-button" onClick={() => removeOverride(override.date)}>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="save-bar">
|
||||
<button type="button" className="primary-button" onClick={save} disabled={saveState.kind === "saving"}>
|
||||
{saveState.kind === "saving" ? "Saving…" : "Save schedule"}
|
||||
</button>
|
||||
{saveState.kind === "saved" && <span className="notice notice-ok">Saved.</span>}
|
||||
{saveState.kind === "error" && <span className="notice notice-error">{saveState.message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { MutualSlot, PublicBusyBlock, SchedulerUser } from "@scheduler/lib/scheduler/types";
|
||||
|
||||
type CalendarWorkspaceProps = {
|
||||
viewerId: number;
|
||||
team: SchedulerUser[];
|
||||
initialBusy: PublicBusyBlock[];
|
||||
initialSlots: MutualSlot[];
|
||||
/** Server-computed Monday 00:00 ISO of the visible week — shared so the first
|
||||
* client fetch range matches the data rendered on the server. */
|
||||
weekStartIso: string;
|
||||
};
|
||||
|
||||
const DURATION_PRESETS = [15, 30, 45, 60] as const;
|
||||
const DAY_START_HOUR = 7;
|
||||
const DAY_END_HOUR = 19;
|
||||
const PX_PER_HOUR = 48;
|
||||
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"] as const;
|
||||
const REFETCH_DEBOUNCE_MS = 300;
|
||||
|
||||
type CreateState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "saving" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "unavailable"; message: string }
|
||||
| { kind: "created" };
|
||||
|
||||
/** Monday 00:00 (local) of the week containing `from`. */
|
||||
function startOfWeek(from: Date): Date {
|
||||
const date = new Date(from);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
const weekday = date.getDay(); // 0 = Sun
|
||||
const mondayOffset = weekday === 0 ? -6 : 1 - weekday;
|
||||
date.setDate(date.getDate() + mondayOffset);
|
||||
return date;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function minutesIntoDay(iso: string): number {
|
||||
const date = new Date(iso);
|
||||
return date.getHours() * 60 + date.getMinutes();
|
||||
}
|
||||
|
||||
function sameDay(iso: string, day: Date): boolean {
|
||||
const date = new Date(iso);
|
||||
return (
|
||||
date.getFullYear() === day.getFullYear() &&
|
||||
date.getMonth() === day.getMonth() &&
|
||||
date.getDate() === day.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function offsetTop(iso: string): number {
|
||||
const minutes = minutesIntoDay(iso) - DAY_START_HOUR * 60;
|
||||
return (minutes / 60) * PX_PER_HOUR;
|
||||
}
|
||||
|
||||
function blockHeight(startIso: string, endIso: string): number {
|
||||
const minutes = (new Date(endIso).getTime() - new Date(startIso).getTime()) / 60_000;
|
||||
return Math.max((minutes / 60) * PX_PER_HOUR, 14);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function CalendarWorkspace({
|
||||
viewerId,
|
||||
team,
|
||||
initialBusy,
|
||||
initialSlots,
|
||||
weekStartIso,
|
||||
}: CalendarWorkspaceProps) {
|
||||
const weekStart = useMemo(() => startOfWeek(new Date(weekStartIso)), [weekStartIso]);
|
||||
const days = useMemo(() => WEEKDAYS.map((_, index) => addDays(weekStart, index)), [weekStart]);
|
||||
const hours = useMemo(() => {
|
||||
const list: number[] = [];
|
||||
for (let hour = DAY_START_HOUR; hour < DAY_END_HOUR; hour += 1) list.push(hour);
|
||||
return list;
|
||||
}, []);
|
||||
|
||||
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<number[]>([viewerId]);
|
||||
const [busy, setBusy] = useState<PublicBusyBlock[]>(initialBusy);
|
||||
const [slots, setSlots] = useState<MutualSlot[]>(initialSlots);
|
||||
const [durationMinutes, setDurationMinutes] = useState<number>(30);
|
||||
const [title, setTitle] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [conferencing, setConferencing] = useState(false);
|
||||
const [selectedSlotStart, setSelectedSlotStart] = useState<string | null>(null);
|
||||
const [createState, setCreateState] = useState<CreateState>({ kind: "idle" });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
const rangeStart = days[0].toISOString();
|
||||
const rangeEnd = addDays(weekStart, WEEKDAYS.length).toISOString();
|
||||
|
||||
const toggleAttendee = useCallback(
|
||||
(id: number) => {
|
||||
if (id === viewerId) return; // organizer always attends
|
||||
setSelectedAttendeeIds((current) =>
|
||||
current.includes(id) ? current.filter((value) => value !== id) : [...current, id]
|
||||
);
|
||||
},
|
||||
[viewerId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
attendeeIds: selectedAttendeeIds.join(","),
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
durationMinutes: String(durationMinutes),
|
||||
});
|
||||
const response = await fetch(`/api/scheduler/availability?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error("availability request failed");
|
||||
const data = (await response.json()) as { busy: PublicBusyBlock[]; mutualSlots: MutualSlot[] };
|
||||
setBusy(data.busy);
|
||||
setSlots(data.mutualSlots);
|
||||
setSelectedSlotStart(null);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setCreateState({ kind: "error", message: "Could not refresh availability." });
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
}, REFETCH_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [selectedAttendeeIds, durationMinutes, rangeStart, rangeEnd]);
|
||||
|
||||
const createMeeting = useCallback(async () => {
|
||||
if (!selectedSlotStart || !title.trim()) {
|
||||
setCreateState({ kind: "error", message: "Pick a slot and enter a title first." });
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateState({ kind: "saving" });
|
||||
try {
|
||||
const response = await fetch("/api/scheduler/meetings", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
attendeeIds: selectedAttendeeIds,
|
||||
start: selectedSlotStart,
|
||||
durationMinutes,
|
||||
location: location.trim() || undefined,
|
||||
conferencing: conferencing ? "provider" : "none",
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 501) {
|
||||
setCreateState({
|
||||
kind: "unavailable",
|
||||
message: "Meeting booking is not wired up yet (CAL_BOOKING_SERVICE_BOUNDARY).",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (response.status === 409) {
|
||||
setCreateState({ kind: "unavailable", message: "That slot is no longer available." });
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("create failed");
|
||||
setCreateState({ kind: "created" });
|
||||
} catch {
|
||||
setCreateState({ kind: "error", message: "Could not create the meeting." });
|
||||
}
|
||||
}, [selectedSlotStart, title, selectedAttendeeIds, durationMinutes, location, conferencing]);
|
||||
|
||||
return (
|
||||
<div className="calendar-layout">
|
||||
<section className="calendar-panel" aria-label="Team week">
|
||||
<header className="panel-header">
|
||||
<h1>Calendar</h1>
|
||||
<span className="muted">{loading ? "Updating availability…" : "Mutual free slots outlined"}</span>
|
||||
</header>
|
||||
<div className="week-grid">
|
||||
<div className="time-gutter">
|
||||
{hours.map((hour) => (
|
||||
<div key={hour} className="hour-label" style={{ height: PX_PER_HOUR }}>
|
||||
{hour}:00
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{days.map((day, index) => (
|
||||
<div key={day.toISOString()} className="day-column">
|
||||
<div className="day-heading">
|
||||
{WEEKDAYS[index]} {day.getMonth() + 1}/{day.getDate()}
|
||||
</div>
|
||||
<div className="day-body" style={{ height: (DAY_END_HOUR - DAY_START_HOUR) * PX_PER_HOUR }}>
|
||||
{busy
|
||||
.filter((block) => sameDay(block.start, day))
|
||||
.map((block) => {
|
||||
// Teammate blocks never reveal a title, even if one leaks
|
||||
// past the server-side privacy filter.
|
||||
const label = block.userId === viewerId ? block.title ?? "Busy" : "Busy";
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
className="busy-block"
|
||||
role="img"
|
||||
aria-label={`${WEEKDAYS[index]} ${formatTime(block.start)} ${label}`}
|
||||
data-own={block.userId === viewerId}
|
||||
style={{ top: offsetTop(block.start), height: blockHeight(block.start, block.end) }}
|
||||
title={label}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{slots
|
||||
.filter((slot) => sameDay(slot.start, day))
|
||||
.map((slot) => (
|
||||
<button
|
||||
key={slot.start}
|
||||
type="button"
|
||||
className="mutual-slot"
|
||||
data-selected={slot.start === selectedSlotStart}
|
||||
aria-label={`Schedule ${WEEKDAYS[index]} at ${formatTime(slot.start)}`}
|
||||
aria-pressed={slot.start === selectedSlotStart}
|
||||
style={{ top: offsetTop(slot.start), height: blockHeight(slot.start, slot.end) }}
|
||||
onClick={() => setSelectedSlotStart(slot.start)}>
|
||||
{formatTime(slot.start)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="composer" aria-label="Meeting composer">
|
||||
<h2>Schedule meeting</h2>
|
||||
|
||||
<fieldset className="composer-group">
|
||||
<legend>Attendees</legend>
|
||||
{team.map((member) => (
|
||||
<label key={member.id} className="attendee-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAttendeeIds.includes(member.id)}
|
||||
disabled={member.id === viewerId}
|
||||
onChange={() => toggleAttendee(member.id)}
|
||||
/>
|
||||
<span>
|
||||
{member.name}
|
||||
{member.id === viewerId ? " (you)" : ""}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<label className="field">
|
||||
<span>Title</span>
|
||||
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Sync" />
|
||||
</label>
|
||||
|
||||
<fieldset className="composer-group">
|
||||
<legend>Duration</legend>
|
||||
<div className="duration-presets">
|
||||
{DURATION_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
data-active={durationMinutes === preset}
|
||||
onClick={() => setDurationMinutes(preset)}>
|
||||
{preset}m
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="number"
|
||||
min={5}
|
||||
step={5}
|
||||
className="duration-custom"
|
||||
value={durationMinutes}
|
||||
onChange={(event) => setDurationMinutes(Math.max(5, Number(event.target.value) || 5))}
|
||||
aria-label="Custom duration in minutes"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label className="field">
|
||||
<span>Location (optional)</span>
|
||||
<input value={location} onChange={(event) => setLocation(event.target.value)} placeholder="Room or address" />
|
||||
</label>
|
||||
|
||||
<label className="field-inline">
|
||||
<input type="checkbox" checked={conferencing} onChange={(event) => setConferencing(event.target.checked)} />
|
||||
<span>Add conferencing link (optional)</span>
|
||||
</label>
|
||||
|
||||
<div className="selected-slot">
|
||||
{selectedSlotStart ? (
|
||||
<span>
|
||||
Selected:{" "}
|
||||
{new Date(selectedSlotStart).toLocaleString([], {
|
||||
weekday: "short",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted">Pick a mutual slot on the calendar.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={createMeeting}
|
||||
disabled={createState.kind === "saving"}>
|
||||
{createState.kind === "saving" ? "Creating…" : "Create meeting"}
|
||||
</button>
|
||||
|
||||
{createState.kind === "error" && <p className="notice notice-error">{createState.message}</p>}
|
||||
{createState.kind === "unavailable" && <p className="notice notice-warn">{createState.message}</p>}
|
||||
{createState.kind === "created" && <p className="notice notice-ok">Meeting created.</p>}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { SchedulerConnection } from "@scheduler/lib/scheduler/service";
|
||||
|
||||
type ConnectionsViewProps = {
|
||||
connections: SchedulerConnection[];
|
||||
};
|
||||
|
||||
const CONFERENCING_CATEGORIES = new Set(["conferencing", "video"]);
|
||||
|
||||
function providerLabel(connection: SchedulerConnection): string {
|
||||
return connection.appId ?? connection.type;
|
||||
}
|
||||
|
||||
function ProviderRow({ connection }: { connection: SchedulerConnection }) {
|
||||
return (
|
||||
<div className="provider-row">
|
||||
<span className="provider-name">{providerLabel(connection)}</span>
|
||||
{connection.connected ? (
|
||||
<span className="provider-status connected">Connected</span>
|
||||
) : (
|
||||
<a className="secondary-button" href="/apps/installed">
|
||||
Connect
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionsView({ connections }: ConnectionsViewProps) {
|
||||
const calendars = connections.filter((connection) => connection.category === "calendar");
|
||||
const conferencing = connections.filter((connection) => CONFERENCING_CATEGORIES.has(connection.category));
|
||||
|
||||
return (
|
||||
<div className="connections-layout">
|
||||
<header className="panel-header">
|
||||
<h1>Connections</h1>
|
||||
<span className="muted">Reuses every provider supported by Cal.</span>
|
||||
</header>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Calendars</h2>
|
||||
{calendars.length === 0 ? (
|
||||
<p className="muted">No calendars connected yet.</p>
|
||||
) : (
|
||||
calendars.map((connection) => (
|
||||
<ProviderRow key={`${connection.type}-${connection.appId}`} connection={connection} />
|
||||
))
|
||||
)}
|
||||
<a className="primary-button browse-button" href="/apps/categories/calendar">
|
||||
Browse calendar providers
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Conferencing</h2>
|
||||
{conferencing.length === 0 ? (
|
||||
<p className="muted">No conferencing providers connected yet.</p>
|
||||
) : (
|
||||
conferencing.map((connection) => (
|
||||
<ProviderRow key={`${connection.type}-${connection.appId}`} connection={connection} />
|
||||
))
|
||||
)}
|
||||
<a className="primary-button browse-button" href="/apps/categories/conferencing">
|
||||
Browse conferencing providers
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<p className="privacy-note">Only availability blocks are visible to teammates.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,47 @@
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { buildLegacyRequest } from "./legacy-request";
|
||||
|
||||
/** Viewer id used for the demo team's organizer when SCHEDULER_DEMO_MODE=1. */
|
||||
const DEMO_VIEWER_ID = 1;
|
||||
|
||||
/**
|
||||
* Demo mode bypasses Authentik and impersonates the organizer, so it must never
|
||||
* be honored in production even if the env var is accidentally left set.
|
||||
*/
|
||||
function isDemoMode(): boolean {
|
||||
return process.env.SCHEDULER_DEMO_MODE === "1" && process.env.NODE_ENV !== "production";
|
||||
}
|
||||
|
||||
export async function getSchedulerSession(request: NextRequest) {
|
||||
return getServerSession({ req: buildLegacyRequest(request) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current viewer id inside a Server Component (which has no
|
||||
* `NextRequest`). Returns the demo organizer in demo mode, otherwise reads the
|
||||
* Authentik session from request cookies/headers. Returns null when there is no
|
||||
* valid session so the caller can redirect to login.
|
||||
*/
|
||||
export async function getServerViewerId(): Promise<number | null> {
|
||||
if (isDemoMode()) {
|
||||
return DEMO_VIEWER_ID;
|
||||
}
|
||||
|
||||
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
|
||||
const req = {
|
||||
cookies: Object.fromEntries(cookieStore.getAll().map((cookie) => [cookie.name, cookie.value])),
|
||||
headers: Object.fromEntries(headerStore.entries()),
|
||||
} as unknown as Parameters<typeof getServerSession>[0]["req"];
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
const viewerId = Number(session?.user?.id);
|
||||
return Number.isInteger(viewerId) && viewerId > 0 ? viewerId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns either `{ session, userId }` for an authenticated viewer, or `{ response }`
|
||||
* carrying a 401 the route handler should return directly. The `userId` is the
|
||||
|
||||
Reference in New Issue
Block a user