Feature/booking page refactor (#3035)
* Extracted UI related logic on the DatePicker, stripped out all logic * wip * fixed small regression due to merge * Fix alignment of the chevrons * Added isToday dot, added onMonthChange so we can fetch this month slots * Added includedDates to inverse excludedDates * removed trpcState * Improvements to the state * All params are now dynamic * This builds the flat map so not all paths block on every new build * Added requiresConfirmation * Correctly take into account getFilteredTimes to make the calendar function * Rewritten team availability, seems to work * Circumvent i18n flicker by showing the loader instead * 'You can remove this code. Its not being used now' - Hariom * Nailed a persistent little bug, new Date() caused the current day to flicker on and off * TS fixes * Fix some eventType details in AvailableTimes * '5 / 6 Seats Available' instead of '6 / Seats Available' * More type fixes * Removed unrelated merge artifact * Use WEBAPP_URL instead of hardcoded * Next round of TS fixes * I believe this was mistyped * Temporarily disabled rescheduling 'this is when you originally scheduled', so removed dep * Sorting some dead code * This page has a lot of red, not all related to this PR * A PR to your PR (#3067) * Cleanup * Cleanup * Uses zod to parse params * Type fixes * Fixes ISR * E2E fixes * Disabled dynamic bookings until post v1.7 * More test fixes * Fixed border position (transparent border) to prevent dot from jumping - and possibly fix spacing * Disabled style nitpicks * Delete useSlots.ts Removed early design artifact * Unlock DatePicker locale * Adds mini spinner to DatePicker Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: zomars <zomars@me.com>pull/3068/head
parent
6959eb2d27
commit
e9f3248fc0
|
@ -1,66 +1,44 @@
|
|||
import { ExclamationIcon } from "@heroicons/react/solid";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useSlots } from "@lib/hooks/useSlots";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import type { Slot } from "@server/routers/viewer/slots";
|
||||
|
||||
type AvailableTimesProps = {
|
||||
timeFormat: string;
|
||||
minimumBookingNotice: number;
|
||||
beforeBufferTime: number;
|
||||
afterBufferTime: number;
|
||||
eventTypeId: number;
|
||||
eventLength: number;
|
||||
recurringCount: number | undefined;
|
||||
eventTypeSlug: string;
|
||||
slotInterval: number | null;
|
||||
date: Dayjs;
|
||||
users: {
|
||||
username: string | null;
|
||||
}[];
|
||||
schedulingType: SchedulingType | null;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
slots?: Slot[];
|
||||
};
|
||||
|
||||
const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
slots = [],
|
||||
date,
|
||||
eventLength,
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
slotInterval,
|
||||
minimumBookingNotice,
|
||||
recurringCount,
|
||||
timeFormat,
|
||||
users,
|
||||
schedulingType,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
seatsPerTimeSlot,
|
||||
}) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
const { slots, loading, error } = useSlots({
|
||||
date,
|
||||
slotInterval,
|
||||
eventLength,
|
||||
schedulingType,
|
||||
users,
|
||||
minimumBookingNotice,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
eventTypeId,
|
||||
});
|
||||
|
||||
const [brand, setBrand] = useState("#292929");
|
||||
|
||||
|
@ -80,8 +58,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||
</span>
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto md:h-[364px]">
|
||||
{!loading &&
|
||||
slots?.length > 0 &&
|
||||
{slots?.length > 0 &&
|
||||
slots.map((slot) => {
|
||||
type BookingURL = {
|
||||
pathname: string;
|
||||
|
@ -91,7 +68,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||
pathname: "book",
|
||||
query: {
|
||||
...router.query,
|
||||
date: slot.time.format(),
|
||||
date: dayjs(slot.time).format(),
|
||||
type: eventTypeId,
|
||||
slug: eventTypeSlug,
|
||||
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
|
||||
|
@ -113,7 +90,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div key={slot.time.format()}>
|
||||
<div key={dayjs(slot.time).format()}>
|
||||
{/* Current there is no way to disable Next.js Links */}
|
||||
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
|
||||
<div
|
||||
|
@ -121,7 +98,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||
"text-primary-500 mb-2 block rounded-sm border bg-white py-4 font-medium opacity-25 dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 ",
|
||||
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
||||
)}>
|
||||
{slot.time.format(timeFormat)}
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{!!seatsPerTimeSlot && <p className={`text-sm`}>{t("booking_full")}</p>}
|
||||
</div>
|
||||
) : (
|
||||
|
@ -132,7 +109,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
||||
)}
|
||||
data-testid="time">
|
||||
{dayjs.tz(slot.time, timeZone()).format(timeFormat)}
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{!!seatsPerTimeSlot && (
|
||||
<p
|
||||
className={`${
|
||||
|
@ -152,26 +129,11 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{!loading && !error && !slots.length && (
|
||||
{!slots.length && (
|
||||
<div className="-mt-4 flex h-full w-full flex-col content-center items-center justify-center">
|
||||
<h1 className="my-6 text-xl text-black dark:text-white">{t("all_booked_today")}</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <Loader />}
|
||||
|
||||
{error && (
|
||||
<div className="border-l-4 border-yellow-400 bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ltr:ml-3 rtl:mr-3">
|
||||
<p className="text-sm text-yellow-700">{t("slots_load_fail")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -249,15 +249,9 @@ function DatePicker({
|
|||
day.disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles }
|
||||
}
|
||||
className={classNames(
|
||||
"absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm text-center",
|
||||
"hover:border-brand hover:border dark:hover:border-white",
|
||||
day.disabled
|
||||
? "text-bookinglighter cursor-default font-light hover:border-0"
|
||||
: "font-medium",
|
||||
"hover:border-brand disabled:text-bookinglighter absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm text-center font-medium hover:border disabled:cursor-default disabled:font-light disabled:hover:border-0 dark:hover:border-white",
|
||||
date && date.isSame(browsingDate.date(day.date), "day")
|
||||
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
|
||||
: !day.disabled
|
||||
? " bg-gray-100 dark:bg-gray-600 dark:text-white"
|
||||
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast disabled:bg-gray-100 disabled:dark:bg-gray-600 disabled:dark:text-white"
|
||||
: ""
|
||||
)}
|
||||
data-testid="day"
|
||||
|
|
|
@ -1,66 +1,70 @@
|
|||
// Get router variables
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
ClipboardCheckIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
GlobeIcon,
|
||||
InformationCircleIcon,
|
||||
LocationMarkerIcon,
|
||||
ClipboardCheckIcon,
|
||||
RefreshIcon,
|
||||
VideoCameraIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { EventType } from "@prisma/client";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import dayjs from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { TFunction } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { AppStoreLocationType, LocationObject, LocationType } from "@calcom/app-store/locations";
|
||||
import {
|
||||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
useIsBackgroundTransparent,
|
||||
sdkActionManager,
|
||||
useEmbedNonStylesConfig,
|
||||
useEmbedStyles,
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { yyyymmdd } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import Loader from "@calcom/ui/Loader";
|
||||
import DatePicker from "@calcom/ui/booker/DatePicker";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import { parseDate } from "@lib/parseDate";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import AvailableTimes from "@components/booking/AvailableTimes";
|
||||
import DatePicker from "@components/booking/DatePicker";
|
||||
import TimeOptions from "@components/booking/TimeOptions";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import PoweredByCal from "@components/ui/PoweredByCal";
|
||||
|
||||
import { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||
import { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||
import type { Slot } from "@server/routers/viewer/slots";
|
||||
|
||||
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
||||
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
|
||||
type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||
|
||||
export const locationKeyToString = (location: LocationObject, t: TFunction) => {
|
||||
switch (location.type) {
|
||||
|
@ -91,27 +95,173 @@ export const locationKeyToString = (location: LocationObject, t: TFunction) => {
|
|||
}
|
||||
};
|
||||
|
||||
const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage, booking }: Props) => {
|
||||
const GoBackToPreviousPage = ({ slug }: { slug: string }) => {
|
||||
const router = useRouter();
|
||||
const isEmbed = useIsEmbed();
|
||||
const { rescheduleUid } = router.query;
|
||||
const { isReady, Theme } = useTheme(profile.theme);
|
||||
const { t, i18n } = useLocale();
|
||||
const { contracts } = useContracts();
|
||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
useExposePlanGlobally(plan);
|
||||
const [previousPage, setPreviousPage] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (eventType.metadata.smartContractAddress) {
|
||||
const eventOwner = eventType.users[0];
|
||||
if (!contracts[(eventType.metadata.smartContractAddress || null) as number])
|
||||
router.replace(`/${eventOwner.username}`);
|
||||
}
|
||||
}, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]);
|
||||
setPreviousPage(document.referrer);
|
||||
}, []);
|
||||
|
||||
const selectedDate = useMemo(() => {
|
||||
return previousPage === `${WEBAPP_URL}/${slug}` ? (
|
||||
<div className="flex h-full flex-col justify-end">
|
||||
<ArrowLeftIcon
|
||||
className="h-4 w-4 text-black transition-opacity hover:cursor-pointer dark:text-white"
|
||||
onClick={() => router.back()}
|
||||
/>
|
||||
<p className="sr-only">Go Back</p>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
const useSlots = ({
|
||||
eventTypeId,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
}) => {
|
||||
const { data, isLoading } = trpc.useQuery([
|
||||
"viewer.slots.getSchedule",
|
||||
{
|
||||
eventTypeId,
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
return { slots: data?.slots || {}, isLoading };
|
||||
};
|
||||
|
||||
const SlotPicker = ({
|
||||
eventType,
|
||||
timezoneDropdown,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
recurringEventCount,
|
||||
seatsPerTimeSlot,
|
||||
weekStart = 0,
|
||||
}: {
|
||||
eventType: Pick<EventType, "id" | "schedulingType" | "slug">;
|
||||
timezoneDropdown: JSX.Element;
|
||||
timeFormat: string;
|
||||
timeZone?: string;
|
||||
seatsPerTimeSlot?: number;
|
||||
recurringEventCount?: number;
|
||||
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
}) => {
|
||||
const { selectedDate, setSelectedDate } = useDateSelected({ timeZone });
|
||||
|
||||
const { i18n } = useLocale();
|
||||
const [startDate, setStartDate] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
if (dayjs(selectedDate).startOf("month").isAfter(dayjs())) {
|
||||
setStartDate(dayjs(selectedDate).startOf("month").toDate());
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
const { slots, isLoading } = useSlots({
|
||||
eventTypeId: eventType.id,
|
||||
startTime: startDate,
|
||||
endTime: dayjs(startDate).endOf("month").toDate(),
|
||||
});
|
||||
|
||||
const [times, setTimes] = useState<Slot[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && slots[yyyymmdd(selectedDate)]) {
|
||||
setTimes(slots[yyyymmdd(selectedDate)]);
|
||||
}
|
||||
}, [selectedDate, slots]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DatePicker
|
||||
isLoading={isLoading}
|
||||
className={
|
||||
"mt-8 w-full sm:mt-0 sm:min-w-[455px] " +
|
||||
(selectedDate
|
||||
? "sm:w-1/2 sm:border-r sm:pl-4 sm:pr-6 sm:dark:border-gray-700 md:w-1/3 "
|
||||
: "sm:pl-4")
|
||||
}
|
||||
locale={i18n.language}
|
||||
includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)}
|
||||
selected={selectedDate}
|
||||
onChange={setSelectedDate}
|
||||
onMonthChange={setStartDate}
|
||||
weekStart={weekStart}
|
||||
/>
|
||||
|
||||
<div className="mt-4 ml-1 block sm:hidden">{timezoneDropdown}</div>
|
||||
|
||||
{selectedDate && (
|
||||
<AvailableTimes
|
||||
slots={times}
|
||||
date={dayjs(selectedDate)}
|
||||
timeFormat={timeFormat}
|
||||
eventTypeId={eventType.id}
|
||||
eventTypeSlug={eventType.slug}
|
||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||
recurringCount={recurringEventCount}
|
||||
schedulingType={eventType.schedulingType}
|
||||
users={[]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function TimezoneDropdown({
|
||||
onChangeTimeFormat,
|
||||
onChangeTimeZone,
|
||||
}: {
|
||||
onChangeTimeFormat: (newTimeFormat: string) => void;
|
||||
onChangeTimeZone: (newTimeZone: string) => void;
|
||||
}) {
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSelectTimeZone = (newTimeZone: string) => {
|
||||
timeZone(newTimeZone);
|
||||
onChangeTimeZone(newTimeZone);
|
||||
setIsTimeOptionsOpen(false);
|
||||
};
|
||||
|
||||
const handleToggle24hClock = (is24hClock: boolean) => {
|
||||
onChangeTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||
<Collapsible.Trigger className="min-w-32 text-bookinglight mb-1 -ml-2 px-2 py-1 text-left dark:text-white">
|
||||
<GlobeIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{timeZone()}
|
||||
{isTimeOptionsOpen ? (
|
||||
<ChevronUpIcon className="ml-1 -mt-1 inline-block h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="ml-1 -mt-1 inline-block h-4 w-4" />
|
||||
)}
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} onToggle24hClock={handleToggle24hClock} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const useDateSelected = ({ timeZone }: { timeZone?: string }) => {
|
||||
const router = useRouter();
|
||||
const [selectedDate, _setSelectedDate] = useState<Date>();
|
||||
|
||||
useEffect(() => {
|
||||
const dateString = asStringOrNull(router.query.date);
|
||||
if (dateString) {
|
||||
const offsetString = dateString.substr(11, 14); // hhmm
|
||||
|
@ -126,24 +276,66 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
(offsetMinute !== "" ? parseInt(offsetMinute) : 0));
|
||||
|
||||
const date = dayjs(dateString.substr(0, 10)).utcOffset(utcOffsetInMinutes, true);
|
||||
return date.isValid() ? date : null;
|
||||
if (date.isValid()) {
|
||||
setSelectedDate(date.toDate());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const setSelectedDate = (newDate: Date) => {
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
...router.query,
|
||||
date: dayjs(newDate).tz(timeZone, true).format("YYYY-MM-DDZZ"),
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
_setSelectedDate(newDate);
|
||||
};
|
||||
|
||||
return { selectedDate, setSelectedDate };
|
||||
};
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
const router = useRouter();
|
||||
const isEmbed = useIsEmbed();
|
||||
const { rescheduleUid } = router.query;
|
||||
const { isReady, Theme } = useTheme(profile.theme);
|
||||
const { t, i18n } = useLocale();
|
||||
const { contracts } = useContracts();
|
||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
|
||||
const [timeZone, setTimeZone] = useState<string>();
|
||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||
const [isAvailableTimesVisible, setIsAvailableTimesVisible] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
setIsAvailableTimesVisible(!!router.query.date);
|
||||
}, [router.query.date]);
|
||||
|
||||
if (selectedDate) {
|
||||
// Let iframe take the width available due to increase in max-width
|
||||
sdkActionManager?.fire("__refreshWidth", {});
|
||||
}
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||
// TODO: Improve this;
|
||||
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
|
||||
|
||||
// TODO: this needs to be extracted elsewhere
|
||||
useEffect(() => {
|
||||
if (eventType.metadata.smartContractAddress) {
|
||||
const eventOwner = eventType.users[0];
|
||||
if (!contracts[(eventType.metadata.smartContractAddress || null) as number])
|
||||
router.replace(`/${eventOwner.username}`);
|
||||
}
|
||||
}, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]);
|
||||
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect(() => {
|
||||
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
|
||||
|
||||
if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
telemetry.event(
|
||||
|
@ -153,45 +345,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
}
|
||||
}, [telemetry]);
|
||||
|
||||
const changeDate = useCallback(
|
||||
(newDate: Dayjs) => {
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
...router.query,
|
||||
date: newDate.tz(timeZone(), true).format("YYYY-MM-DDZZ"),
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedDate != null &&
|
||||
selectedDate?.utcOffset() !== selectedDate.clone().utcOffset(0).tz(timeZone()).utcOffset()
|
||||
) {
|
||||
changeDate(selectedDate.tz(timeZone(), true));
|
||||
}
|
||||
}, [selectedDate, changeDate]);
|
||||
|
||||
const handleSelectTimeZone = (selectedTimeZone: string): void => {
|
||||
timeZone(selectedTimeZone);
|
||||
if (selectedDate) {
|
||||
changeDate(selectedDate.tz(selectedTimeZone, true));
|
||||
}
|
||||
setIsTimeOptionsOpen(false);
|
||||
};
|
||||
|
||||
const handleToggle24hClock = (is24hClock: boolean) => {
|
||||
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||
};
|
||||
|
||||
// Recurring event sidebar requires more space
|
||||
const maxWidth = selectedDate
|
||||
const maxWidth = isAvailableTimesVisible
|
||||
? recurringEventCount
|
||||
? "max-w-6xl"
|
||||
: "max-w-5xl"
|
||||
|
@ -199,6 +354,14 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
? "max-w-4xl"
|
||||
: "max-w-3xl";
|
||||
|
||||
if (Object.keys(i18n).length === 0) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const timezoneDropdown = (
|
||||
<TimezoneDropdown onChangeTimeFormat={setTimeFormat} onChangeTimeZone={setTimeZone} />
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Theme />
|
||||
|
@ -340,10 +503,10 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TimezoneDropdown />
|
||||
{timezoneDropdown}
|
||||
|
||||
<div className="md:hidden">
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
{/* Temp disabled booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-8 text-gray-600 dark:text-white"
|
||||
|
@ -356,7 +519,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -368,7 +531,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
<div
|
||||
className={
|
||||
"hidden overflow-hidden pr-8 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " +
|
||||
(selectedDate ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
|
||||
(isAvailableTimesVisible ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
|
||||
}>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
|
@ -483,19 +646,12 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
<TimezoneDropdown />
|
||||
{timezoneDropdown}
|
||||
</div>
|
||||
|
||||
{previousPage === `${WEBAPP_URL}/${profile.slug}` && (
|
||||
<div className="flex h-full flex-col justify-end">
|
||||
<ArrowLeftIcon
|
||||
className="h-4 w-4 text-black transition-opacity hover:cursor-pointer dark:text-white"
|
||||
onClick={() => router.back()}
|
||||
/>
|
||||
<p className="sr-only">Go Back</p>
|
||||
</div>
|
||||
)}
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<GoBackToPreviousPage slug={profile.slug || ""} />
|
||||
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-3 text-gray-600 dark:text-white"
|
||||
|
@ -507,44 +663,16 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)*/}
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
periodType={eventType?.periodType}
|
||||
periodStartDate={eventType?.periodStartDate}
|
||||
periodEndDate={eventType?.periodEndDate}
|
||||
periodDays={eventType?.periodDays}
|
||||
periodCountCalendarDays={eventType?.periodCountCalendarDays}
|
||||
onDatePicked={changeDate}
|
||||
workingHours={workingHours}
|
||||
weekStart={profile.weekStart || "Sunday"}
|
||||
eventLength={eventType.length}
|
||||
minimumBookingNotice={eventType.minimumBookingNotice}
|
||||
<SlotPicker
|
||||
eventType={eventType}
|
||||
timezoneDropdown={timezoneDropdown}
|
||||
timeZone={timeZone}
|
||||
timeFormat={timeFormat}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
/>
|
||||
|
||||
<div className="mt-4 ml-1 block sm:hidden">
|
||||
<TimezoneDropdown />
|
||||
</div>
|
||||
|
||||
{selectedDate && (
|
||||
<AvailableTimes
|
||||
timeFormat={timeFormat}
|
||||
minimumBookingNotice={eventType.minimumBookingNotice}
|
||||
eventTypeId={eventType.id}
|
||||
eventTypeSlug={eventType.slug}
|
||||
slotInterval={eventType.slotInterval}
|
||||
eventLength={eventType.length}
|
||||
recurringCount={recurringEventCount}
|
||||
date={selectedDate}
|
||||
users={eventType.users}
|
||||
schedulingType={eventType.schedulingType ?? null}
|
||||
beforeBufferTime={eventType.beforeEventBuffer}
|
||||
afterBufferTime={eventType.afterEventBuffer}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -553,25 +681,6 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
function TimezoneDropdown() {
|
||||
return (
|
||||
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||
<Collapsible.Trigger className="min-w-32 text-gray mb-1 -ml-2 px-2 py-1 text-left dark:text-white">
|
||||
<GlobeIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{timeZone()}
|
||||
{isTimeOptionsOpen ? (
|
||||
<ChevronUpIcon className="ml-1 -mt-1 inline-block h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="ml-1 -mt-1 inline-block h-4 w-4" />
|
||||
)}
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} onToggle24hClock={handleToggle24hClock} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default AvailabilityPage;
|
||||
|
|
|
@ -5,8 +5,10 @@ import utc from "dayjs/plugin/utc";
|
|||
import { stringify } from "querystring";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { CurrentSeats } from "@calcom/core/getUserAvailability";
|
||||
|
||||
import getSlots from "@lib/slots";
|
||||
import { CurrentSeats, TimeRange, WorkingHours } from "@lib/types/schedule";
|
||||
import type { TimeRange, WorkingHours } from "@lib/types/schedule";
|
||||
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(utc);
|
||||
|
@ -15,7 +17,7 @@ type AvailabilityUserResponse = {
|
|||
busy: TimeRange[];
|
||||
timeZone: string;
|
||||
workingHours: WorkingHours[];
|
||||
currentSeats?: CurrentSeats[];
|
||||
currentSeats?: CurrentSeats;
|
||||
};
|
||||
|
||||
type Slot = {
|
||||
|
@ -43,7 +45,7 @@ type getFilteredTimesProps = {
|
|||
eventLength: number;
|
||||
beforeBufferTime: number;
|
||||
afterBufferTime: number;
|
||||
currentSeats?: CurrentSeats[];
|
||||
currentSeats?: CurrentSeats;
|
||||
};
|
||||
|
||||
export const getFilteredTimes = (props: getFilteredTimesProps) => {
|
||||
|
@ -61,7 +63,7 @@ export const getFilteredTimes = (props: getFilteredTimesProps) => {
|
|||
const slotEndTime = times[i].add(eventLength, "minutes");
|
||||
const slotStartTimeWithBeforeBuffer = times[i].subtract(beforeBufferTime, "minutes");
|
||||
// If the event has seats then see if there is already a booking (want to show full bookings as well)
|
||||
if (currentSeats?.some((booking) => booking.startTime === slotStartTime.toISOString())) {
|
||||
if (currentSeats?.some((booking) => booking.startTime === slotStartTime.toDate())) {
|
||||
break;
|
||||
}
|
||||
busy.every((busyTime): boolean => {
|
||||
|
@ -155,12 +157,12 @@ export const useSlots = (props: UseSlotsProps) => {
|
|||
time,
|
||||
users: [user],
|
||||
// Conditionally add the attendees and booking id to slots object if there is already a booking during that time
|
||||
...(currentSeats?.some((booking) => booking.startTime === time.toISOString()) && {
|
||||
...(currentSeats?.some((booking) => booking.startTime === time.toDate()) && {
|
||||
attendees:
|
||||
currentSeats[currentSeats.findIndex((booking) => booking.startTime === time.toISOString())]._count
|
||||
currentSeats[currentSeats.findIndex((booking) => booking.startTime === time.toDate())]._count
|
||||
.attendees,
|
||||
bookingUid:
|
||||
currentSeats[currentSeats.findIndex((booking) => booking.startTime === time.toISOString())].uid,
|
||||
currentSeats[currentSeats.findIndex((booking) => booking.startTime === time.toDate())].uid,
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
|
|
@ -4,7 +4,7 @@ import isToday from "dayjs/plugin/isToday";
|
|||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { getWorkingHours } from "./availability";
|
||||
import { WorkingHours, CurrentSeats } from "./types/schedule";
|
||||
import { WorkingHours } from "./types/schedule";
|
||||
|
||||
dayjs.extend(isToday);
|
||||
dayjs.extend(utc);
|
||||
|
@ -16,7 +16,6 @@ export type GetSlots = {
|
|||
workingHours: WorkingHours[];
|
||||
minimumBookingNotice: number;
|
||||
eventLength: number;
|
||||
currentSeats?: CurrentSeats[];
|
||||
};
|
||||
export type WorkingHoursTimeFrame = { startTime: number; endTime: number };
|
||||
|
||||
|
@ -43,14 +42,7 @@ const splitAvailableTime = (
|
|||
return result;
|
||||
};
|
||||
|
||||
const getSlots = ({
|
||||
inviteeDate,
|
||||
frequency,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
eventLength,
|
||||
currentSeats,
|
||||
}: GetSlots) => {
|
||||
const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours, eventLength }: GetSlots) => {
|
||||
// current date in invitee tz
|
||||
const startDate = dayjs().add(minimumBookingNotice, "minute");
|
||||
const startOfDay = dayjs.utc().startOf("day");
|
||||
|
|
|
@ -16,11 +16,3 @@ export type WorkingHours = {
|
|||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
|
||||
export type CurrentSeats = {
|
||||
uid: string;
|
||||
startTime: string;
|
||||
_count: {
|
||||
attendees: number;
|
||||
};
|
||||
};
|
||||
|
|
|
@ -46,71 +46,53 @@ interface EvtsToVerify {
|
|||
}
|
||||
|
||||
export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { users, profile } = props;
|
||||
const { users, profile, eventTypes, isDynamicGroup, dynamicNames, dynamicUsernames, isSingleUser } = props;
|
||||
const [user] = users; //To be used when we only have a single user, not dynamic group
|
||||
const { Theme } = useTheme(user.theme);
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const isSingleUser = props.users.length === 1;
|
||||
const isDynamicGroup = props.users.length > 1;
|
||||
const dynamicNames = isDynamicGroup
|
||||
? props.users.map((user) => {
|
||||
return user.name || "";
|
||||
})
|
||||
: [];
|
||||
const dynamicUsernames = isDynamicGroup
|
||||
? props.users.map((user) => {
|
||||
return user.username || "";
|
||||
})
|
||||
: [];
|
||||
const eventTypes = isDynamicGroup
|
||||
? defaultEvents.map((event) => {
|
||||
event.description = getDynamicEventDescription(dynamicUsernames, event.slug);
|
||||
return event;
|
||||
})
|
||||
: props.eventTypes;
|
||||
const groupEventTypes = props.users.some((user) => {
|
||||
return !user.allowDynamicBooking;
|
||||
}) ? (
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="p-8 text-center text-gray-400 dark:text-white">
|
||||
<h2 className="font-cal mb-2 text-3xl text-gray-600 dark:text-white">{" " + t("unavailable")}</h2>
|
||||
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled") as string}</p>
|
||||
|
||||
const groupEventTypes =
|
||||
/* props.users.some((user) => !user.allowDynamicBooking) TODO: Re-enable after v1.7 launch */ true ? (
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="p-8 text-center text-gray-400 dark:text-white">
|
||||
<h2 className="font-cal mb-2 text-3xl text-gray-600 dark:text-white">{" " + t("unavailable")}</h2>
|
||||
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled") as string}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{eventTypes.map((type, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="hover:border-brand group relative rounded-sm border border-neutral-200 bg-white hover:bg-gray-50 dark:border-neutral-700 dark:bg-gray-800 dark:hover:border-neutral-600">
|
||||
<ArrowRightIcon className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
|
||||
<Link href={getUsernameSlugLink({ users: props.users, slug: type.slug })}>
|
||||
<a className="flex justify-between px-6 py-4" data-testid="event-type-link">
|
||||
<div className="flex-shrink">
|
||||
<h2 className="font-cal font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
|
||||
<EventTypeDescription className="text-sm" eventType={type} />
|
||||
</div>
|
||||
<div className="mt-1 self-center">
|
||||
<AvatarGroup
|
||||
border="border-2 border-white"
|
||||
truncateAfter={4}
|
||||
className="flex flex-shrink-0"
|
||||
size={10}
|
||||
items={props.users.map((user) => ({
|
||||
alt: user.name || "",
|
||||
image: user.avatar || "",
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{eventTypes.map((type, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="hover:border-brand group relative rounded-sm border border-neutral-200 bg-white hover:bg-gray-50 dark:border-neutral-700 dark:bg-gray-800 dark:hover:border-neutral-600">
|
||||
<ArrowRightIcon className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
|
||||
<Link href={getUsernameSlugLink({ users: props.users, slug: type.slug })}>
|
||||
<a className="flex justify-between px-6 py-4" data-testid="event-type-link">
|
||||
<div className="flex-shrink">
|
||||
<h2 className="font-cal font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
|
||||
<EventTypeDescription className="text-sm" eventType={type} />
|
||||
</div>
|
||||
<div className="mt-1 self-center">
|
||||
<AvatarGroup
|
||||
border="border-2 border-white"
|
||||
truncateAfter={4}
|
||||
className="flex flex-shrink-0"
|
||||
size={10}
|
||||
items={props.users.map((user) => ({
|
||||
alt: user.name || "",
|
||||
image: user.avatar || "",
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
const isEmbed = useIsEmbed();
|
||||
const eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem");
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
|
@ -376,6 +358,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
: false,
|
||||
}));
|
||||
|
||||
const isSingleUser = users.length === 1;
|
||||
const dynamicUsernames = isDynamicGroup
|
||||
? users.map((user) => {
|
||||
return user.username || "";
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
props: {
|
||||
users,
|
||||
|
@ -383,8 +372,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
user: {
|
||||
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
|
||||
},
|
||||
eventTypes,
|
||||
eventTypes: isDynamicGroup
|
||||
? defaultEvents.map((event) => {
|
||||
event.description = getDynamicEventDescription(dynamicUsernames, event.slug);
|
||||
return event;
|
||||
})
|
||||
: eventTypes,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isDynamicGroup,
|
||||
dynamicNames,
|
||||
dynamicUsernames,
|
||||
isSingleUser,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,27 +1,24 @@
|
|||
import { Prisma, UserPlan } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import { GetStaticPropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
import { z } from "zod";
|
||||
|
||||
import { locationHiddenFilter, LocationObject } from "@calcom/app-store/locations";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { availiblityPageEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
import getBooking, { GetBookingType } from "@lib/getBooking";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
export type AvailabilityPageProps = inferSSRProps<typeof getStaticProps>;
|
||||
|
||||
export default function Type(props: AvailabilityPageProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return props.away ? (
|
||||
<div className="h-screen dark:bg-neutral-900">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
|
@ -37,7 +34,7 @@ export default function Type(props: AvailabilityPageProps) {
|
|||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : props.isDynamicGroup && !props.profile.allowDynamicBooking ? (
|
||||
) : props.isDynamic /* && !props.profile.allowDynamicBooking TODO: Re-enable after v1.7 launch */ ? (
|
||||
<div className="h-screen dark:bg-neutral-900">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
|
@ -57,21 +54,115 @@ export default function Type(props: AvailabilityPageProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
// get query params and typecast them to string
|
||||
// (would be even better to assert them instead of typecasting)
|
||||
const usernameList = getUsernameList(context.query.user as string);
|
||||
async function getUserPageProps({ username, slug }: { username: string; slug: string }) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
away: true,
|
||||
plan: true,
|
||||
eventTypes: {
|
||||
// Order is important to ensure that given a slug if there are duplicates, we choose the same event type consistently when showing in event-types list UI(in terms of ordering and disabled event types)
|
||||
// TODO: If we can ensure that there are no duplicates for a [slug, userId] combination in existing data, this requirement might be avoided.
|
||||
orderBy: [
|
||||
{
|
||||
position: "desc",
|
||||
},
|
||||
{
|
||||
id: "asc",
|
||||
},
|
||||
],
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
recurringEvent: true,
|
||||
length: true,
|
||||
locations: true,
|
||||
id: true,
|
||||
description: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
requiresConfirmation: true,
|
||||
schedulingType: true,
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
users: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
plan: true,
|
||||
allowDynamicBooking: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userParam = asStringOrNull(context.query.user);
|
||||
const typeParam = asStringOrNull(context.query.type);
|
||||
const dateParam = asStringOrNull(context.query.date);
|
||||
const rescheduleUid = asStringOrNull(context.query.rescheduleUid);
|
||||
|
||||
if (!userParam || !typeParam) {
|
||||
throw new Error(`File is not named [type]/[user]`);
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const eventType = user.eventTypes.find((et, i) =>
|
||||
user.plan === UserPlan.FREE ? i === 0 && et.slug === slug : et.slug === slug
|
||||
);
|
||||
|
||||
if (!eventType) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
||||
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
metadata: (eventType.metadata || {}) as JSONObject,
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
locations: locationHiddenFilter(locations),
|
||||
users: eventType.users.map((user) => {
|
||||
return {
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
hideBranding: user.hideBranding,
|
||||
plan: user.plan,
|
||||
timeZone: user.timeZone,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
eventType: eventTypeObject,
|
||||
profile: {
|
||||
...eventType.users[0],
|
||||
slug: `${eventType.users[0].username}/${eventType.slug}`,
|
||||
image: `${WEBAPP_URL}/${eventType.users[0].username}/avatar.png`,
|
||||
},
|
||||
away: user?.away,
|
||||
isDynamic: false,
|
||||
},
|
||||
revalidate: 10, // seconds
|
||||
};
|
||||
}
|
||||
|
||||
async function getDynamicGroupPageProps({
|
||||
usernameList,
|
||||
length,
|
||||
}: {
|
||||
usernameList: string[];
|
||||
length: number;
|
||||
}) {
|
||||
const eventType = getDefaultEvent("" + length);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
|
@ -105,226 +196,95 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
},
|
||||
theme: true,
|
||||
plan: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
slug: typeParam,
|
||||
},
|
||||
{
|
||||
teamId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Order is important to ensure that given a slug if there are duplicates, we choose the same event type consistently when showing in event-types list UI(in terms of ordering and disabled event types)
|
||||
// TODO: If we can ensure that there are no duplicates for a [slug, userId] combination in existing data, this requirement might be avoided.
|
||||
orderBy: [
|
||||
{
|
||||
position: "desc",
|
||||
},
|
||||
{
|
||||
id: "asc",
|
||||
},
|
||||
],
|
||||
select: {
|
||||
...availiblityPageEventTypeSelect,
|
||||
users: {
|
||||
select: {
|
||||
id: false,
|
||||
avatar: true,
|
||||
name: true,
|
||||
username: true,
|
||||
hideBranding: true,
|
||||
plan: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!users || !users.length) {
|
||||
if (!users.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
const [user] = users; //to be used when dealing with single user, not dynamic group
|
||||
const isSingleUser = users.length === 1;
|
||||
const isDynamicGroup = users.length > 1;
|
||||
|
||||
if (isSingleUser && user.eventTypes.length !== 1) {
|
||||
const eventTypeBackwardsCompat = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
slug: typeParam,
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
...availiblityPageEventTypeSelect,
|
||||
users: {
|
||||
select: {
|
||||
id: false,
|
||||
avatar: true,
|
||||
name: true,
|
||||
username: true,
|
||||
hideBranding: true,
|
||||
plan: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventTypeBackwardsCompat) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
eventTypeBackwardsCompat.users.push({
|
||||
avatar: user.avatar,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
hideBranding: user.hideBranding,
|
||||
plan: user.plan,
|
||||
timeZone: user.timeZone,
|
||||
});
|
||||
|
||||
user.eventTypes.push(eventTypeBackwardsCompat);
|
||||
}
|
||||
let [eventType] = user.eventTypes;
|
||||
if (isDynamicGroup) {
|
||||
eventType = getDefaultEvent(typeParam);
|
||||
eventType["users"] = users.map((user) => {
|
||||
return {
|
||||
avatar: user.avatar as string,
|
||||
name: user.name as string,
|
||||
username: user.username as string,
|
||||
hideBranding: user.hideBranding,
|
||||
plan: user.plan,
|
||||
timeZone: user.timeZone as string,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// check this is the first event for free user
|
||||
if (isSingleUser && user.plan === UserPlan.FREE) {
|
||||
const firstEventType = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
users: {
|
||||
some: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
position: "desc",
|
||||
},
|
||||
{
|
||||
id: "asc",
|
||||
},
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (firstEventType?.id !== eventType.id) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
||||
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
metadata: (eventType.metadata || {}) as JSONObject,
|
||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
locations: locationHiddenFilter(locations),
|
||||
users: users.map((user) => {
|
||||
return {
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
hideBranding: user.hideBranding,
|
||||
plan: user.plan,
|
||||
timeZone: user.timeZone,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const schedule = eventType.schedule
|
||||
? { ...eventType.schedule }
|
||||
: {
|
||||
...user.schedules.filter(
|
||||
(schedule) => !user.defaultScheduleId || schedule.id === user.defaultScheduleId
|
||||
)[0],
|
||||
};
|
||||
const dynamicNames = users.map((user) => {
|
||||
return user.name || "";
|
||||
});
|
||||
|
||||
const timeZone = isDynamicGroup ? undefined : schedule.timeZone || eventType.timeZone || user.timeZone;
|
||||
|
||||
const workingHours = getWorkingHours(
|
||||
{
|
||||
timeZone,
|
||||
},
|
||||
isDynamicGroup
|
||||
? eventType.availability || undefined
|
||||
: schedule.availability || (eventType.availability.length ? eventType.availability : user.availability)
|
||||
);
|
||||
eventTypeObject.schedule = null;
|
||||
eventTypeObject.availability = [];
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBooking(prisma, rescheduleUid);
|
||||
}
|
||||
|
||||
const dynamicNames = isDynamicGroup
|
||||
? users.map((user) => {
|
||||
return user.name || "";
|
||||
})
|
||||
: [];
|
||||
|
||||
const profile = isDynamicGroup
|
||||
? {
|
||||
name: getGroupName(dynamicNames),
|
||||
image: null,
|
||||
slug: typeParam,
|
||||
theme: null,
|
||||
weekStart: "Sunday",
|
||||
brandColor: "",
|
||||
darkBrandColor: "",
|
||||
allowDynamicBooking: !users.some((user) => {
|
||||
return !user.allowDynamicBooking;
|
||||
}),
|
||||
}
|
||||
: {
|
||||
name: user.name || user.username,
|
||||
image: user.avatar,
|
||||
slug: user.username,
|
||||
theme: user.theme,
|
||||
weekStart: user.weekStart,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
};
|
||||
const profile = {
|
||||
name: getGroupName(dynamicNames),
|
||||
image: null,
|
||||
slug: "" + length,
|
||||
theme: null as string | null,
|
||||
weekStart: "Sunday",
|
||||
brandColor: "",
|
||||
darkBrandColor: "",
|
||||
allowDynamicBooking: !users.some((user) => {
|
||||
return !user.allowDynamicBooking;
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
away: user.away,
|
||||
isDynamicGroup,
|
||||
profile,
|
||||
plan: user.plan,
|
||||
date: dateParam,
|
||||
eventType: eventTypeObject,
|
||||
workingHours,
|
||||
trpcState: ssr.dehydrate(),
|
||||
previousPage: context.req.headers.referer ?? null,
|
||||
booking,
|
||||
profile,
|
||||
isDynamic: true,
|
||||
away: false,
|
||||
},
|
||||
revalidate: 10, // seconds
|
||||
};
|
||||
}
|
||||
|
||||
const paramsSchema = z.object({ type: z.string(), user: z.string() });
|
||||
|
||||
export const getStaticProps = async (context: GetStaticPropsContext) => {
|
||||
const { type: typeParam, user: userParam } = paramsSchema.parse(context.params);
|
||||
|
||||
// dynamic groups are not generated at build time, but otherwise are probably cached until infinity.
|
||||
const isDynamicGroup = userParam.includes("+");
|
||||
if (isDynamicGroup) {
|
||||
return await getDynamicGroupPageProps({
|
||||
usernameList: getUsernameList(userParam),
|
||||
length: parseInt(typeParam),
|
||||
});
|
||||
} else {
|
||||
return await getUserPageProps({ username: userParam, slug: typeParam });
|
||||
}
|
||||
};
|
||||
|
||||
export const getStaticPaths = async () => {
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
username: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
teamId: null,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const paths = users?.flatMap((user) =>
|
||||
user.eventTypes.flatMap((eventType) => `/${user.username}/${eventType.slug}`)
|
||||
);
|
||||
|
||||
return { paths, fallback: "blocking" };
|
||||
};
|
||||
|
|
|
@ -159,6 +159,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
|||
id: eventTypeId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
users: userSelect,
|
||||
team: {
|
||||
select: {
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
import { z } from "zod";
|
||||
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { availiblityPageEventTypeSelect } from "@calcom/prisma";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
import { GetBookingType } from "@lib/getBooking";
|
||||
import { locationHiddenFilter, LocationObject } from "@lib/location";
|
||||
|
@ -16,17 +15,21 @@ import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
|
|||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
export type DynamicAvailabilityPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Type(props: AvailabilityPageProps) {
|
||||
export default function Type(props: DynamicAvailabilityPageProps) {
|
||||
return <AvailabilityPage {...props} />;
|
||||
}
|
||||
|
||||
const querySchema = z.object({
|
||||
link: z.string().optional().default(""),
|
||||
slug: z.string().optional().default(""),
|
||||
date: z.union([z.string(), z.null()]).optional().default(null),
|
||||
});
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const link = asStringOrNull(context.query.link) || "";
|
||||
const slug = asStringOrNull(context.query.slug) || "";
|
||||
const dateParam = asStringOrNull(context.query.date);
|
||||
const { link, slug, date } = querySchema.parse(context.query);
|
||||
|
||||
const hashedLink = await prisma.hashedLink.findUnique({
|
||||
where: {
|
||||
|
@ -140,7 +143,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
isDynamicGroup: false,
|
||||
profile,
|
||||
plan: user.plan,
|
||||
date: dateParam,
|
||||
date,
|
||||
eventType: eventTypeObject,
|
||||
workingHours,
|
||||
trpcState: ssr.dehydrate(),
|
||||
|
|
|
@ -135,7 +135,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
name: team.name || team.slug,
|
||||
slug: team.slug,
|
||||
image: team.logo,
|
||||
theme: null,
|
||||
theme: null as string | null,
|
||||
weekStart: "Sunday",
|
||||
brandColor: "" /* TODO: Add a way to set a brand color for Teams */,
|
||||
darkBrandColor: "" /* TODO: Add a way to set a brand color for Teams */,
|
||||
|
|
|
@ -109,7 +109,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
// FIXME: This slug is used as username on success page which is wrong. This is correctly set as username for user booking.
|
||||
slug: "team/" + eventTypeObject.slug,
|
||||
image: eventTypeObject.team?.logo || null,
|
||||
theme: null /* Teams don't have a theme, and `BookingPage` uses it */,
|
||||
theme: null as string | null /* Teams don't have a theme, and `BookingPage` uses it */,
|
||||
brandColor: null /* Teams don't have a brandColor, and `BookingPage` uses it */,
|
||||
darkBrandColor: null /* Teams don't have a darkBrandColor, and `BookingPage` uses it */,
|
||||
eventName: null,
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("dynamic booking", () => {
|
||||
test.skip(true, "TODO: Re-enable after v1.7 launch");
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.login();
|
||||
|
|
|
@ -46,6 +46,7 @@ test.describe("Reschedule Tests", async () => {
|
|||
});
|
||||
|
||||
test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => {
|
||||
test.skip(true, "TODO: Re-enable after v1.7 launch");
|
||||
const user = await users.create();
|
||||
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
status: BookingStatus.CANCELLED,
|
||||
|
|
|
@ -31,6 +31,7 @@ import { apiKeysRouter } from "@server/routers/viewer/apiKeys";
|
|||
import { availabilityRouter } from "@server/routers/viewer/availability";
|
||||
import { bookingsRouter } from "@server/routers/viewer/bookings";
|
||||
import { eventTypesRouter } from "@server/routers/viewer/eventTypes";
|
||||
import { slotsRouter } from "@server/routers/viewer/slots";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { createProtectedRouter, createRouter } from "../createRouter";
|
||||
|
@ -950,4 +951,5 @@ export const viewerRouter = createRouter()
|
|||
.merge("availability.", availabilityRouter)
|
||||
.merge("teams.", viewerTeamsRouter)
|
||||
.merge("webhook.", webhookRouter)
|
||||
.merge("apiKeys.", apiKeysRouter);
|
||||
.merge("apiKeys.", apiKeysRouter)
|
||||
.merge("slots.", slotsRouter);
|
||||
|
|
|
@ -0,0 +1,256 @@
|
|||
import { SchedulingType } from "@prisma/client";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CurrentSeats } from "@calcom/core/getUserAvailability";
|
||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||
import { yyyymmdd } from "@calcom/lib/date-fns";
|
||||
import { availabilityUserSelect } from "@calcom/prisma";
|
||||
import { stringToDayjs } from "@calcom/prisma/zod-utils";
|
||||
import { TimeRange, WorkingHours } from "@calcom/types/schedule";
|
||||
|
||||
import getSlots from "@lib/slots";
|
||||
|
||||
import { createRouter } from "@server/createRouter";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const getScheduleSchema = z
|
||||
.object({
|
||||
// startTime ISOString
|
||||
startTime: stringToDayjs,
|
||||
// endTime ISOString
|
||||
endTime: stringToDayjs,
|
||||
// Event type ID
|
||||
eventTypeId: z.number().optional(),
|
||||
// or list of users (for dynamic events)
|
||||
usernameList: z.array(z.string()).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => !!data.eventTypeId || !!data.usernameList,
|
||||
"Either usernameList or eventTypeId should be filled in."
|
||||
);
|
||||
|
||||
export type Slot = {
|
||||
time: string;
|
||||
attendees?: number;
|
||||
bookingUid?: string;
|
||||
users?: string[];
|
||||
};
|
||||
|
||||
const checkForAvailability = ({
|
||||
time,
|
||||
busy,
|
||||
workingHours,
|
||||
eventLength,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
currentSeats,
|
||||
}: {
|
||||
time: Dayjs;
|
||||
busy: (TimeRange | { start: string; end: string })[];
|
||||
workingHours: WorkingHours[];
|
||||
eventLength: number;
|
||||
beforeBufferTime: number;
|
||||
afterBufferTime: number;
|
||||
currentSeats?: CurrentSeats;
|
||||
}) => {
|
||||
if (
|
||||
!workingHours.every((workingHour) => {
|
||||
if (!workingHour.days.includes(time.day())) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!time.isBetween(
|
||||
time.utc().startOf("day").add(workingHour.startTime, "minutes"),
|
||||
time.utc().startOf("day").add(workingHour.endTime, "minutes"),
|
||||
null,
|
||||
"[)"
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const slotEndTime = time.add(eventLength, "minutes");
|
||||
const slotStartTimeWithBeforeBuffer = time.subtract(beforeBufferTime, "minutes");
|
||||
const slotEndTimeWithAfterBuffer = time.add(eventLength + afterBufferTime, "minutes");
|
||||
|
||||
return busy.every((busyTime): boolean => {
|
||||
const startTime = dayjs(busyTime.start);
|
||||
const endTime = dayjs(busyTime.end);
|
||||
// Check if start times are the same
|
||||
if (time.isBetween(startTime, endTime, null, "[)")) {
|
||||
return false;
|
||||
}
|
||||
// Check if slot end time is between start and end time
|
||||
else if (slotEndTime.isBetween(startTime, endTime)) {
|
||||
return false;
|
||||
}
|
||||
// Check if startTime is between slot
|
||||
else if (startTime.isBetween(time, slotEndTime)) {
|
||||
return false;
|
||||
}
|
||||
// Check if timeslot has before buffer time space free
|
||||
else if (
|
||||
slotStartTimeWithBeforeBuffer.isBetween(
|
||||
startTime.subtract(beforeBufferTime, "minutes"),
|
||||
endTime.add(afterBufferTime, "minutes")
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Check if timeslot has after buffer time space free
|
||||
else if (
|
||||
slotEndTimeWithAfterBuffer.isBetween(
|
||||
startTime.subtract(beforeBufferTime, "minutes"),
|
||||
endTime.add(afterBufferTime, "minutes")
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const slotsRouter = createRouter().query("getSchedule", {
|
||||
input: getScheduleSchema,
|
||||
async resolve({ input, ctx }) {
|
||||
const eventType = await ctx.prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: input.eventTypeId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
minimumBookingNotice: true,
|
||||
length: true,
|
||||
seatsPerTimeSlot: true,
|
||||
timeZone: true,
|
||||
slotInterval: true,
|
||||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
schedulingType: true,
|
||||
schedule: {
|
||||
select: {
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
availability: {
|
||||
select: {
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
days: true,
|
||||
},
|
||||
},
|
||||
users: {
|
||||
select: {
|
||||
username: true,
|
||||
...availabilityUserSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
const { startTime, endTime } = input;
|
||||
if (!startTime.isValid() || !endTime.isValid()) {
|
||||
throw new TRPCError({ message: "Invalid time range given.", code: "BAD_REQUEST" });
|
||||
}
|
||||
|
||||
let currentSeats: CurrentSeats | undefined = undefined;
|
||||
|
||||
const userSchedules = await Promise.all(
|
||||
eventType.users.map(async (currentUser) => {
|
||||
const {
|
||||
busy,
|
||||
workingHours,
|
||||
currentSeats: _currentSeats,
|
||||
} = await getUserAvailability(
|
||||
{
|
||||
userId: currentUser.id,
|
||||
dateFrom: startTime.format(),
|
||||
dateTo: endTime.format(),
|
||||
eventTypeId: input.eventTypeId,
|
||||
},
|
||||
{ user: currentUser, eventType, currentSeats }
|
||||
);
|
||||
if (!currentSeats && _currentSeats) currentSeats = _currentSeats;
|
||||
|
||||
return {
|
||||
workingHours,
|
||||
busy,
|
||||
};
|
||||
})
|
||||
);
|
||||
const workingHours = userSchedules.flatMap((s) => s.workingHours);
|
||||
console.log("workingHours", workingHours);
|
||||
console.log("currentSeats", currentSeats);
|
||||
|
||||
const slots: Record<string, Slot[]> = {};
|
||||
|
||||
const availabilityCheckProps = {
|
||||
eventLength: eventType.length,
|
||||
beforeBufferTime: eventType.beforeEventBuffer,
|
||||
afterBufferTime: eventType.afterEventBuffer,
|
||||
currentSeats,
|
||||
};
|
||||
|
||||
let time = dayjs(startTime);
|
||||
do {
|
||||
// get slots retrieves the available times for a given day
|
||||
const times = getSlots({
|
||||
inviteeDate: time,
|
||||
eventLength: eventType.length,
|
||||
workingHours,
|
||||
minimumBookingNotice: eventType.minimumBookingNotice,
|
||||
frequency: eventType.slotInterval || eventType.length,
|
||||
});
|
||||
|
||||
// if ROUND_ROBIN - slots stay available on some() - if normal / COLLECTIVE - slots only stay available on every()
|
||||
const filteredTimes =
|
||||
!eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE
|
||||
? times.filter((time) =>
|
||||
userSchedules.every((schedule) =>
|
||||
checkForAvailability({ time, ...schedule, ...availabilityCheckProps })
|
||||
)
|
||||
)
|
||||
: times.filter((time) =>
|
||||
userSchedules.some((schedule) =>
|
||||
checkForAvailability({ time, ...schedule, ...availabilityCheckProps })
|
||||
)
|
||||
);
|
||||
|
||||
slots[yyyymmdd(time.toDate())] = filteredTimes.map((time) => ({
|
||||
time: time.toISOString(),
|
||||
users: eventType.users.map((user) => user.username || ""),
|
||||
// Conditionally add the attendees and booking id to slots object if there is already a booking during that time
|
||||
...(currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString()) && {
|
||||
attendees:
|
||||
currentSeats[
|
||||
currentSeats.findIndex((booking) => booking.startTime.toISOString() === time.toISOString())
|
||||
]._count.attendees,
|
||||
bookingUid:
|
||||
currentSeats[
|
||||
currentSeats.findIndex((booking) => booking.startTime.toISOString() === time.toISOString())
|
||||
].uid,
|
||||
}),
|
||||
}));
|
||||
time = time.add(1, "day");
|
||||
} while (time.isBefore(endTime));
|
||||
|
||||
return {
|
||||
slots,
|
||||
};
|
||||
},
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getWorkingHours } from "@calcom/lib/availability";
|
||||
|
@ -24,6 +24,7 @@ const getEventType = (id: number) =>
|
|||
prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
seatsPerTimeSlot: true,
|
||||
timeZone: true,
|
||||
schedule: {
|
||||
|
@ -52,6 +53,28 @@ const getUser = (where: Prisma.UserWhereUniqueInput) =>
|
|||
|
||||
type User = Awaited<ReturnType<typeof getUser>>;
|
||||
|
||||
export const getCurrentSeats = (eventTypeId: number, dateFrom: Dayjs, dateTo: Dayjs) =>
|
||||
prisma.booking.findMany({
|
||||
where: {
|
||||
eventTypeId,
|
||||
startTime: {
|
||||
gte: dateFrom.format(),
|
||||
lte: dateTo.format(),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
startTime: true,
|
||||
_count: {
|
||||
select: {
|
||||
attendees: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type CurrentSeats = Awaited<ReturnType<typeof getCurrentSeats>>;
|
||||
|
||||
export async function getUserAvailability(
|
||||
query: {
|
||||
username?: string;
|
||||
|
@ -64,6 +87,7 @@ export async function getUserAvailability(
|
|||
initialData?: {
|
||||
user?: User;
|
||||
eventType?: EventType;
|
||||
currentSeats?: CurrentSeats;
|
||||
}
|
||||
) {
|
||||
const { username, userId, dateFrom, dateTo, eventTypeId, timezone } = availabilitySchema.parse(query);
|
||||
|
@ -82,6 +106,12 @@ export async function getUserAvailability(
|
|||
let eventType: EventType | null = initialData?.eventType || null;
|
||||
if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId);
|
||||
|
||||
/* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab
|
||||
current bookings with a seats event type and display them on the calendar, even if they are full */
|
||||
let currentSeats: CurrentSeats | null = initialData?.currentSeats || null;
|
||||
if (!currentSeats && eventType?.seatsPerTimeSlot)
|
||||
currentSeats = await getCurrentSeats(eventType.id, dateFrom, dateTo);
|
||||
|
||||
const { selectedCalendars, ...currentUser } = user;
|
||||
|
||||
const busyTimes = await getBusyTimes({
|
||||
|
@ -98,8 +128,6 @@ export async function getUserAvailability(
|
|||
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toISOString(),
|
||||
}));
|
||||
|
||||
const timeZone = timezone || eventType?.timeZone || currentUser.timeZone;
|
||||
|
||||
const schedule = eventType?.schedule
|
||||
? { ...eventType?.schedule }
|
||||
: {
|
||||
|
@ -108,36 +136,14 @@ export async function getUserAvailability(
|
|||
)[0],
|
||||
};
|
||||
|
||||
const timeZone = timezone || schedule?.timeZone || eventType?.timeZone || currentUser.timeZone;
|
||||
|
||||
const workingHours = getWorkingHours(
|
||||
{ timeZone },
|
||||
schedule.availability ||
|
||||
(eventType?.availability.length ? eventType.availability : currentUser.availability)
|
||||
);
|
||||
|
||||
/* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab
|
||||
current bookings with a seats event type and display them on the calendar, even if they are full */
|
||||
let currentSeats;
|
||||
if (eventType?.seatsPerTimeSlot) {
|
||||
currentSeats = await prisma.booking.findMany({
|
||||
where: {
|
||||
eventTypeId: eventTypeId,
|
||||
startTime: {
|
||||
gte: dateFrom.format(),
|
||||
lte: dateTo.format(),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
startTime: true,
|
||||
_count: {
|
||||
select: {
|
||||
attendees: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
busy: bufferedBusyTimes,
|
||||
timeZone,
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import dayjs from "dayjs";
|
||||
|
||||
// converts a date to 2022-04-25 for example.
|
||||
export const yyyymmdd = (date: Date) => dayjs(date).format("YYYY-MM-DD");
|
||||
|
||||
export const daysInMonth = (date: Date) => dayjs(date).daysInMonth();
|
|
@ -154,29 +154,23 @@ export const getUsernameSlugLink = ({ users, slug }: UsernameSlugLinkProps): str
|
|||
return slugLink;
|
||||
};
|
||||
|
||||
const arrayCast = (value: unknown | unknown[]) => {
|
||||
return Array.isArray(value) ? value : value ? [value] : [];
|
||||
};
|
||||
|
||||
export const getUsernameList = (users: string | string[] | undefined): string[] => {
|
||||
if (!users) {
|
||||
return [];
|
||||
}
|
||||
if (!(users instanceof Array)) {
|
||||
users = [users];
|
||||
}
|
||||
const allUsers: string[] = [];
|
||||
// Multiple users can come in case of a team round-robin booking and in that case dynamic link won't be a user.
|
||||
// So, even though this code handles even if individual user is dynamic link, that isn't a possibility right now.
|
||||
users.forEach((user) => {
|
||||
allUsers.push(
|
||||
...user
|
||||
?.toLowerCase()
|
||||
.replace(/ /g, "+")
|
||||
.replace(/%20/g, "+")
|
||||
.split("+")
|
||||
.filter((el) => {
|
||||
return el.length != 0;
|
||||
})
|
||||
);
|
||||
});
|
||||
return allUsers;
|
||||
users = arrayCast(users);
|
||||
|
||||
const allUsers = users.map((user) =>
|
||||
user
|
||||
.toLowerCase()
|
||||
.replace(/( |%20)/g, "+")
|
||||
.split("+")
|
||||
);
|
||||
|
||||
return Array.prototype.concat(...allUsers);
|
||||
};
|
||||
|
||||
export default defaultEvents;
|
||||
|
|
|
@ -0,0 +1,200 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||
import dayjs from "dayjs";
|
||||
import isToday from "dayjs/plugin/isToday";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
|
||||
dayjs.extend(isToday);
|
||||
|
||||
export type DatePickerProps = {
|
||||
/** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */
|
||||
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
/** Fires whenever a selected date is changed. */
|
||||
onChange: (date: Date) => void;
|
||||
/** Fires when the month is changed. */
|
||||
onMonthChange?: (date: Date) => void;
|
||||
/** which date is currently selected (not tracked from here) */
|
||||
selected?: Date;
|
||||
/** defaults to current date. */
|
||||
minDate?: Date;
|
||||
/** Furthest date selectable in the future, default = UNLIMITED */
|
||||
maxDate?: Date;
|
||||
/** locale, any IETF language tag, e.g. "hu-HU" - defaults to Browser settings */
|
||||
locale: string;
|
||||
/** Defaults to [], which dates are not bookable. Array of valid dates like: ["2022-04-23", "2022-04-24"] */
|
||||
excludedDates?: string[];
|
||||
/** defaults to all, which dates are bookable (inverse of excludedDates) */
|
||||
includedDates?: string[];
|
||||
/** allows adding classes to the container */
|
||||
className?: string;
|
||||
/** Shows a small loading spinner next to the month name */
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
const Day = ({
|
||||
date,
|
||||
active,
|
||||
...props
|
||||
}: JSX.IntrinsicElements["button"] & { active: boolean; date: Date }) => {
|
||||
return (
|
||||
<button
|
||||
style={props.disabled ? {} : {}}
|
||||
className={classNames(
|
||||
"absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm border border-transparent text-center",
|
||||
props.disabled
|
||||
? "text-bookinglighter cursor-default font-light"
|
||||
: "hover:border-brand font-medium dark:hover:border-white",
|
||||
active
|
||||
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
|
||||
: !props.disabled
|
||||
? " bg-gray-100 dark:bg-gray-600 dark:text-white"
|
||||
: ""
|
||||
)}
|
||||
data-testid="day"
|
||||
data-disabled={props.disabled}
|
||||
{...props}>
|
||||
{date.getDate()}
|
||||
{dayjs(date).isToday() && <span className=" absolute left-0 bottom-1 mx-auto w-full text-4xl">.</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const Days = ({
|
||||
minDate,
|
||||
excludedDates = [],
|
||||
includedDates = [],
|
||||
browsingDate,
|
||||
weekStart,
|
||||
selected,
|
||||
...props
|
||||
}: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & {
|
||||
browsingDate: Date;
|
||||
weekStart: number;
|
||||
}) => {
|
||||
// Create placeholder elements for empty days in first week
|
||||
const weekdayOfFirst = new Date(new Date(browsingDate).setDate(1)).getDay();
|
||||
// memoize to prevent a flicker on redraw on the current day
|
||||
const minDateValueOf = useMemo(() => {
|
||||
return minDate?.valueOf() || new Date().valueOf();
|
||||
}, [minDate]);
|
||||
|
||||
const days: (Date | null)[] = Array((weekdayOfFirst - weekStart + 7) % 7).fill(null);
|
||||
for (let day = 1, dayCount = daysInMonth(browsingDate); day <= dayCount; day++) {
|
||||
const date = new Date(new Date(browsingDate).setDate(day));
|
||||
days.push(date);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{days.map((day, idx) => (
|
||||
<div
|
||||
key={day === null ? `e-${idx}` : `day-${day}`}
|
||||
style={{
|
||||
paddingTop: "100%",
|
||||
}}
|
||||
className="relative w-full">
|
||||
{day === null ? (
|
||||
<div key={`e-${idx}`} />
|
||||
) : (
|
||||
<Day
|
||||
date={day}
|
||||
onClick={() => props.onChange(day)}
|
||||
disabled={
|
||||
!includedDates.includes(yyyymmdd(day)) ||
|
||||
excludedDates.includes(yyyymmdd(day)) ||
|
||||
day.valueOf() < minDateValueOf
|
||||
}
|
||||
active={selected ? yyyymmdd(selected) === yyyymmdd(day) : false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Spinner = () => (
|
||||
<svg
|
||||
className="mt-[-9px] mr-1 inline h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx={12} cy={12} r={10} stroke="currentColor" strokeWidth={4} />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DatePicker = ({
|
||||
weekStart = 0,
|
||||
className,
|
||||
locale,
|
||||
selected,
|
||||
onMonthChange,
|
||||
isLoading = false,
|
||||
...passThroughProps
|
||||
}: DatePickerProps) => {
|
||||
const [month, setMonth] = useState(selected ? selected.getMonth() : new Date().getMonth());
|
||||
|
||||
const changeMonth = (newMonth: number) => {
|
||||
setMonth(newMonth);
|
||||
if (onMonthChange) {
|
||||
const d = new Date();
|
||||
d.setMonth(newMonth, 1);
|
||||
onMonthChange(d);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-4 flex justify-between text-xl font-light">
|
||||
<span className="w-1/2 dark:text-white">
|
||||
<strong className="text-bookingdarker dark:text-white">
|
||||
{new Date(new Date().setMonth(month)).toLocaleString(locale, { month: "long" })}
|
||||
</strong>{" "}
|
||||
<span className="text-bookinglight">{new Date(new Date().setMonth(month)).getFullYear()}</span>
|
||||
</span>
|
||||
<div>
|
||||
{isLoading && <Spinner />}
|
||||
<button
|
||||
onClick={() => changeMonth(month - 1)}
|
||||
className={classNames(
|
||||
"group p-1 hover:text-black ltr:mr-2 rtl:ml-2 dark:hover:text-white",
|
||||
month <= new Date().getMonth() &&
|
||||
"text-bookinglighter disabled:text-bookinglighter dark:text-gray-600"
|
||||
)}
|
||||
disabled={month <= new Date().getMonth()}
|
||||
data-testid="decrementMonth">
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button className="group p-1" onClick={() => changeMonth(month + 1)} data-testid="incrementMonth">
|
||||
<ChevronRightIcon className="h-5 w-5 group-hover:text-black dark:group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-bookinglightest grid grid-cols-7 gap-4 border-t border-b text-center dark:border-gray-800 sm:border-0">
|
||||
{weekdayNames(locale, weekStart, "short").map((weekDay) => (
|
||||
<div key={weekDay} className="text-bookinglight my-4 text-xs uppercase tracking-widest">
|
||||
{weekDay}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2 text-center">
|
||||
<Days
|
||||
browsingDate={new Date(new Date().setMonth(month))}
|
||||
weekStart={weekStart}
|
||||
selected={selected}
|
||||
{...passThroughProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatePicker;
|
|
@ -17625,4 +17625,4 @@ zwitch@^1.0.0:
|
|||
zwitch@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.2.tgz#91f8d0e901ffa3d66599756dde7f57b17c95dce1"
|
||||
integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA==
|
||||
integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA==
|
Loading…
Reference in New Issue