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
Alex van Andel 2022-06-15 21:54:31 +01:00 committed by GitHub
parent 6959eb2d27
commit e9f3248fc0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1052 additions and 573 deletions

View File

@ -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>
);

View File

@ -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"

View File

@ -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;

View File

@ -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,
}),
}));
};

View File

@ -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");

View File

@ -16,11 +16,3 @@ export type WorkingHours = {
startTime: number;
endTime: number;
};
export type CurrentSeats = {
uid: string;
startTime: string;
_count: {
attendees: number;
};
};

View File

@ -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,
},
};
};

View File

@ -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" };
};

View File

@ -159,6 +159,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
id: eventTypeId,
},
select: {
id: true,
users: userSelect,
team: {
select: {

View File

@ -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(),

View File

@ -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 */,

View File

@ -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,

View File

@ -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();

View File

@ -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,

View File

@ -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);

View File

@ -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,
};
},
});

View File

@ -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,

View File

@ -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();

View File

@ -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;

View File

@ -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;

View File

@ -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==