New Booker Component (preparations for booker atom) (#6792)

* Wip on booker atom

* Wip on booker atom

* Added correct icon imports

* Fixed build

* Responsive improvements

* Removed package lock

* Responsive tweaks

* Animation improvements and cleanup

* Animation improvements and event meta layout improvements.

* Tweaked margins.

* Added more event meta blocks

* Layout tweaks

* Converted booker layout to css grid and implemented multiple layout options

* cleanup

* Fixed build

* Fixed build

* Added temporary api route to enable/disable new booker

* Added sticky behavior

* Reverted yarn.lock and reinstalled new packages to see if this fixes build on vercel.

* Ensure divider lines always have 100% height.

* Improved animation config + initial load

* Ensure to pass eventid to getschedule, otherwise custom availability schedule wont work and wont return any availability

* Fixed divider line heights in booker

* Fixed timezone select positioning

* Added ability to view multiple days of timeslots

* Added icons to booker toggle

* Always show timeslots in timeslots view, also if no date is selected yet. In that case we show upcoming 5 days.

* Fixed timeslots in small calendar view

* Show selected day in calendar

* Fixed booker timeslots view

* Wip in making booking form work

* Moved most of the booker atom stuff to features, since it belongs there. Atom should be a rather small wrapper.

* Added create event functionality to booker form.

* Added guests toggle to booker form and styled input addons in dark mode.

* Added dynamic weekstart to booker

* Added seats limit feature to timeslots.

* Removed todo

* Added correct event avatars

* Added correct event name and icons

* Added correct translation for minutes text in multi duration

* Add rescheduling functionality to new booker.

* Added selected booking time to booking meta in sidebar.

* Abstracted away timeformat to custom hook

* Added correct key props to all components in booker.

* Fix build

* Create some new custom hooks to have a lot less repitition in code.

* Moved bookerform component inside booker directory since it is tied to it.

* Added error messages to booker form, plus fixed bug in recurring events.

* Added some comments <3

* Fixed todos in booker form.

* Added loading state for timeslot selector, and added prefetching of next month, in case of multi day view showing 2 months at the same time.

* Fixed import paths

* Added away view

* Validate uniqueness of event attendees.

* Tweaked comment

* #5798 added correct date format and style for selected date in booker.

* UI improvements

* Enable possibility to add booking values via query params.

* Added functionality to update query params when user selects date/duration etc in booker

* First steps in adding e2e test.

* Fixes after merge with main, and added new form builder.

* Implemented new form types and validation to booker, confirming new form builder. Validation still throwing wrong error keys though.

* Added search to timezone dropdown

* Added e2e test for booker (copy of current booker tests, only enabling cookie), plus fixed reschedule view.

* Updated yarn.lock

* Added new booker for team pages.

* Fixed input addon (hover) styles.

* Added dynamic booking.

* Hide timeformat  select for multi day view for now.

* Cleanup and ui tweaks

* removed log

* Mobile improvements

* Cleanup

* Small design tweaks after talking to ciaran.

* Text color and weight tweaks in booker

* Added rainbow gates to new booker.

* Added in default values which fixes form vallidation (???).

* Added empty defaults for name and email

* Added metadata

* Reset yarn.lock

* Fixed booker zod validation after change in main.

* Icon tweak

* Fixed timezone select styles after new classnames have been merged.

* Updated seat availability styles.

* Update yarn.lock

* Added explanation for alchemy key to .env.example

* Added tooltip to booker month/week/multiday toggle

* Fixed timezoneselect styles in booker after select updates.

* Updates bookingfields component by taking changes from current booker component

* Removed remaining booker todos

* Fix bookeventform

* Fix for recurring event meta

* Type fixes

* Typefixes

* Team event fixes

* Avoid hydration errors by only rendering date picker client side. Remove web3 gates since we dont offer them anymore. Prevent timeslot select from staying open when switching to a different month.

* Don't show calendar on mobile booker during booking.

* Always align booker buttons to bottom

* Don't show backend messages in error, rather show a helpful text like the current booker does as well.

* Do invisible next rewrite based on cookie from next.config.js (#7949)

* Do invisible next rewrite based on cookie from next.config.js

* Name embed link instead of bookerPath

* Rewrites only dynamic user pages

---------

Co-authored-by: zomars <zomars@me.com>

* Don't allow change of timezone when bookerform is visible

* Don't add duration to query param if the event is not a multi duration event.

* Update next.config.js

* Added correct timezone formatting to event meta when timeslot is selected.

* removed .env variable that isn't needed anymore.

* Update Gates.tsx

* Type fixes

* Allows to run all tests with the new booker

* Fixed timezone select styles after merge.

* Don't throw error when event doesn't have hosts, rather return no users, which will result in no availability in UI.

* Make booker errors of severity info instead of warning.

* Ensure team avatars are shown, as well as filter on uniqueness of avatars.

* Added all booked today message to timeslots.

* Added cal.com logo to booker.

* Fixed fragment classname error, minor mobile animation tweaks plus make all booked today text smaller for multi day layout.

* Improved timezone select styles, and updated arguments of getbooking function after updates in main.

* Prevent infinite loop in rewriting new booker.

* Prevent infinite loop in rewriting new booker.

* Moved new-booker pages to their own directory to prevent regexes confusing next and thus nut running getserversideprops after rewrite. Also adding clearing of old date in booker store, that could stick around when user immediately navigates back to the same page after booking.

* Fixed cal logo color in darkmode for new booker.

* Implemented new color tokens and theme variables. Also small design tweaks after merge with main.

* Minor style tweaks

* Show multiple locations in tooltip on booker #8222

* Radio button style tweaks

* Fixed build

* Updated calendar imports to new lucide names

* Removed resetting of selected times logic, because otherwise url params wouldnt be taken into account which is actually what we want. So old values sticking around when navigating back is actually the desired behavior.

* Updated tests to instead of always run the new booker in tests, have a utility to run both the new and old booker for specified tests.

* Added comment and eslint disable for if statement in booker test.

* Update packages/features/bookings/components/event-meta/Details.tsx

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Fix badge types

* Lazy loaded timezone select to save 85kb in bundle size.

* Upgraded framer to latest. Als moved framer and react sticky deps to features instead of atoms.

* Added new pagewrapper logic

* Simplified rescheduling ssr fetches, this now also supports multi seat rescheduling.

* Unset selected time when user is rescheduling directly after a new booking, otherwise it would show the form instead of new time selection.

* Updated form builder logic as per form builder in current booker.

* Updated form builder prefill logic as per logic in current booker.

* Updated getbooking function to fetch correct details when a reschedule uid is used

* Fixed booking questions test by NOT waiting for /book page because the new booker doesnt have this.

* Added former meeting time to reschedule view.

* Fixed types

* Undo playwright config update by mistake.

* Fixed event types test by only waiting for /book page in old booker

* Set new booker cookie to one year in the future instead of 2050

* added reset mockdate to test

* Temporary disabled test to see if this solves the out of memory error.

* Deleted test to see if that fixes the memory error

* Select first day when switching months in booker

---------

Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Jeroen Reumkens 2023-04-24 16:32:30 +02:00 committed by GitHub
parent f838e8bd53
commit 6d02ac6729
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
110 changed files with 4723 additions and 299 deletions

View File

@ -178,4 +178,4 @@ CSP_POLICY=
# Vercel Edge Config
EDGE_CONFIG=
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes

View File

@ -4,6 +4,7 @@ module.exports = {
stories: [
"../intro.stories.mdx",
"../../../packages/ui/components/**/*.stories.mdx",
"../../../packages/atoms/**/*.stories.mdx",
"../../../packages/features/**/*.stories.mdx",
"../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)",
],
@ -70,4 +71,5 @@ module.exports = {
return config;
},
typescript: { reactDocgen: 'react-docgen' }
};

View File

@ -21,6 +21,7 @@ import {
useIsBackgroundTransparent,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { createBooking, createRecurringBooking } from "@calcom/features/bookings/lib";
import {
getBookingFieldsWithSystemFields,
SystemField,
@ -38,6 +39,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import { parseDate, parseRecurringDates } from "@calcom/lib/parse-dates";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { TimeFormat } from "@calcom/lib/timeFormat";
@ -47,9 +49,6 @@ import { AlertTriangle, Calendar, RefreshCw, User } from "@calcom/ui/components/
import { timeZone } from "@lib/clock";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import createBooking from "@lib/mutations/bookings/create-booking";
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
import { parseRecurringDates, parseDate } from "@lib/parseDate";
import type { Gate, GateState } from "@components/Gates";
import Gates from "@components/Gates";
@ -433,7 +432,6 @@ const BookingPage = ({
// Calculate the booking date(s)
let recurringStrings: string[] = [],
recurringDates: Date[] = [];
if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
[recurringStrings, recurringDates] = parseRecurringDates(
{
@ -443,7 +441,7 @@ const BookingPage = ({
recurringCount: parseInt(recurringEventCount.toString()),
selectedTimeFormat: timeFormat,
},
i18n
i18n.language
);
}
@ -572,7 +570,7 @@ const BookingPage = ({
<div className="text-sm font-medium">
{isClientTimezoneAvailable &&
(rescheduleUid || !eventType.recurringEvent?.freq) &&
`${parseDate(date, i18n, timeFormat)}`}
`${parseDate(date, i18n.language, { selectedTimeFormat: timeFormat })}`}
{isClientTimezoneAvailable &&
!rescheduleUid &&
eventType.recurringEvent?.freq &&
@ -602,7 +600,9 @@ const BookingPage = ({
<Calendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
{isClientTimezoneAvailable &&
typeof booking.startTime === "string" &&
parseDate(dayjs(booking.startTime), i18n, timeFormat)}
parseDate(dayjs(booking.startTime), i18n.language, {
selectedTimeFormat: timeFormat,
})}
</p>
</div>
)}

View File

@ -1,6 +1,6 @@
import React from "react";
import { HttpError } from "@lib/core/http/error";
import { HttpError } from "@calcom/lib/http-error";
type Props = {
statusCode?: number | null;

View File

@ -1,13 +0,0 @@
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
import * as fetch from "@lib/core/http/fetch-wrapper";
import type { BookingResponse } from "@lib/types/booking";
type BookingCreateBodyForMutation = Omit<BookingCreateBody, "location">;
const createBooking = async (data: BookingCreateBodyForMutation) => {
const response = await fetch.post<BookingCreateBodyForMutation, BookingResponse>("/api/book/event", data);
return response;
};
export default createBooking;

View File

@ -1,6 +1,7 @@
require("dotenv").config({ path: "../../.env" });
const CopyWebpackPlugin = require("copy-webpack-plugin");
const os = require("os");
const glob = require("glob");
const { withAxiom } = require("next-axiom");
const { i18n } = require("./next-i18next.config");
@ -66,6 +67,18 @@ if (process.env.ANALYZE === "true") {
}
plugins.push(withAxiom);
/** Needed to rewrite public booking page, gets all static pages but [user] */
const pages = glob
.sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname })
.map((filename) =>
filename
.substr(6)
.replace(/(\.tsx|\.js|\.ts)/, "")
.replace(/\/.*/, "")
)
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]"));
/** @type {import("next").NextConfig} */
const nextConfig = {
i18n,
@ -198,6 +211,16 @@ const nextConfig = {
source: "/embed/embed.js",
destination: process.env.NEXT_PUBLIC_EMBED_LIB_URL?,
}, */
{
source: `/:user((?!${pages.join("|")}).*)/:type`,
destination: "/new-booker/:user/:type",
has: [{ type: "cookie", key: "new-booker-enabled" }],
},
{
source: "/team/:slug/:type",
destination: "/new-booker/team/:slug/:type",
has: [{ type: "cookie", key: "new-booker-enabled" }],
},
];
},
async headers() {

View File

@ -5,6 +5,8 @@ import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs";
import getBooking from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import {
@ -13,8 +15,6 @@ import {
getGroupName,
getUsernameList,
} from "@calcom/lib/defaultEvents";
import getBooking from "@calcom/lib/getBooking";
import type { GetBookingType } from "@calcom/lib/getBooking";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma, { bookEventTypeSelect } from "@calcom/prisma";

View File

@ -8,10 +8,9 @@ import NextError from "next/error";
import React from "react";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { HttpError } from "@lib/core/http/error";
import { ErrorPage } from "@components/error/error-page";
// Adds HttpException to the list of possible error types.

View File

@ -1,8 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { HttpError } from "@lib/core/http/error";
import { HttpError } from "@calcom/lib/http-error";
// This is the callback endpoint for the OIDC provider
// A team must set this endpoint in the OIDC provider's configuration

View File

@ -2,8 +2,7 @@ import type { OAuthReq } from "@boxyhq/saml-jackson";
import type { NextApiRequest, NextApiResponse } from "next";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import type { HttpError } from "@lib/core/http/error";
import type { HttpError } from "@calcom/lib/http-error";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { oauthController } = await jackson();

View File

@ -4,12 +4,11 @@ import type { Session } from "next-auth";
import getInstalledAppPath from "@calcom/app-store/_utils/getInstalledAppPath";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
import { HttpError } from "@calcom/lib/http-error";
import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache";
import prisma from "@calcom/prisma";
import type { AppDeclarativeHandler, AppHandler } from "@calcom/types/AppHandler";
import { HttpError } from "@lib/core/http/error";
const defaultIntegrationAddHandler = async ({
slug,
supportsMultipleInstalls,

View File

@ -0,0 +1,26 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { defaultResponder } from "@calcom/lib/server";
const newBookerSchema = z.object({
status: z.enum(["enable", "disable"]),
});
/**
* Very basic temporary api route to enable/disable new booker access.
*/
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { status } = newBookerSchema.parse(req.query);
if (status === "enable") {
const expires = new Date();
expires.setFullYear(expires.getFullYear() + 1);
res.setHeader("Set-Cookie", `new-booker-enabled=true; path=/; expires=${expires.toUTCString()}`);
} else {
res.setHeader("Set-Cookie", "new-booker-enabled=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
}
res.send({ status: 200, body: `Done ${status}` });
}
export default defaultResponder(handler);

View File

@ -10,6 +10,7 @@ import { availabilityAsString } from "@calcom/lib/availability";
import { yyyymmdd } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { Schedule as ScheduleType, TimeRange, WorkingHours } from "@calcom/types/schedule";
@ -35,8 +36,6 @@ import {
} from "@calcom/ui";
import { Info, Plus, Trash, MoreHorizontal } from "@calcom/ui/components/icon";
import { HttpError } from "@lib/core/http/error";
import PageWrapper from "@components/PageWrapper";
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
import EditableHeading from "@components/ui/EditableHeading";

View File

@ -3,13 +3,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import Shell from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { EmptyScreen, showToast } from "@calcom/ui";
import { Clock } from "@calcom/ui/components/icon";
import { withQuery } from "@lib/QueryCell";
import { HttpError } from "@lib/core/http/error";
import PageWrapper from "@components/PageWrapper";
import SkeletonLoader from "@components/availability/SkeletonLoader";

View File

@ -24,6 +24,7 @@ import {
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
import {
SystemField,
getBookingFieldsWithSystemFields,
@ -36,7 +37,6 @@ import {
formatToLocalizedTimezone,
} from "@calcom/lib/date-fns";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { getBookingWithResponses } from "@calcom/lib/getBooking";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";

View File

@ -3,9 +3,9 @@ import { z } from "zod";
import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import type { GetBookingType } from "@calcom/lib/getBooking";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { availiblityPageEventTypeSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma";

View File

@ -17,6 +17,7 @@ import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc, TRPCClientError } from "@calcom/trpc/react";
import {
@ -59,7 +60,6 @@ import {
} from "@calcom/ui/components/icon";
import { withQuery } from "@lib/QueryCell";
import { HttpError } from "@lib/core/http/error";
import { EmbedButton, EmbedDialog } from "@components/Embed";
import PageWrapper from "@components/PageWrapper";

View File

@ -0,0 +1,112 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { Booker } from "@calcom/atoms";
import { getBookingByUidOrRescheduleUid } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import prisma from "@calcom/prisma";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away }: PageProps) {
return (
<main className="flex justify-center">
<Booker username={user} eventSlug={slug} rescheduleBooking={booking} isAway={away} />
</main>
);
}
Type.PageWrapper = PageWrapper;
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const { user, type: slug } = paramsSchema.parse(context.params);
const { rescheduleUid } = context.query;
const { ssgInit } = await import("@server/lib/ssg");
const ssg = await ssgInit(context);
const usernameList = getUsernameList(user);
const users = await prisma.user.findMany({
where: {
username: {
in: usernameList,
},
},
select: {
allowDynamicBooking: true,
},
});
if (!users.length) {
return {
notFound: true,
};
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
}
return {
props: {
booking,
user,
slug,
away: false,
trpcState: ssg.dehydrate(),
},
};
}
async function getUserPageProps(context: GetServerSidePropsContext) {
const { user: username, type: slug } = paramsSchema.parse(context.params);
const { rescheduleUid } = context.query;
const { ssgInit } = await import("@server/lib/ssg");
const ssg = await ssgInit(context);
const user = await prisma.user.findUnique({
where: {
username,
},
select: {
away: true,
},
});
if (!user) {
return {
notFound: true,
};
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
}
return {
props: {
booking,
away: user?.away,
user: username,
slug,
trpcState: ssg.dehydrate(),
},
};
}
const paramsSchema = z.object({ type: z.string(), user: z.string() });
// Booker page fetches a tiny bit of data server side, to determine early
// whether the page should show an away state or dynamic booking not allowed.
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { user } = paramsSchema.parse(context.params);
const isDynamicGroup = user.includes("+");
return isDynamicGroup ? await getDynamicGroupPageProps(context) : await getUserPageProps(context);
};

View File

@ -0,0 +1,65 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { Booker } from "@calcom/atoms";
import { getBookingByUidOrRescheduleUid } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import prisma from "@calcom/prisma";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away }: PageProps) {
return (
<main className="flex justify-center">
<Booker username={user} eventSlug={slug} rescheduleBooking={booking} isAway={away} />
</main>
);
}
Type.PageWrapper = PageWrapper;
const paramsSchema = z.object({ type: z.string(), slug: z.string() });
// Booker page fetches a tiny bit of data server side:
// 1. Check if team exists, to show 404
// 2. If rescheduling, get the booking details
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
const { rescheduleUid } = context.query;
const { ssgInit } = await import("@server/lib/ssg");
const ssg = await ssgInit(context);
const team = await prisma.team.findFirst({
where: {
slug: teamSlug,
},
select: {
id: true,
},
});
if (!team) {
return {
notFound: true,
};
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
}
return {
props: {
booking,
away: false,
user: teamSlug,
slug: meetingSlug,
trpcState: ssg.dehydrate(),
},
};
};

View File

@ -2,10 +2,10 @@ import type { GetServerSidePropsContext } from "next";
import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import getBooking from "@calcom/features/bookings/lib/get-booking";
import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import getBooking from "@calcom/lib/getBooking";
import type { GetBookingType } from "@calcom/lib/getBooking";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";

View File

@ -3,10 +3,10 @@ import { z } from "zod";
import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
import getBooking from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import type { GetBookingType } from "@calcom/lib/getBooking";
import getBooking from "@calcom/lib/getBooking";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import prisma from "@calcom/prisma";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";

View File

@ -1,6 +1,7 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
import { testBothBookers } from "./lib/new-booker";
import {
bookFirstEvent,
bookOptinEvent,
@ -12,7 +13,7 @@ import {
test.describe.configure({ mode: "parallel" });
test.afterEach(async ({ users }) => users.deleteAll());
test.describe("free user", () => {
testBothBookers.describe("free user", (bookerVariant) => {
test.beforeEach(async ({ page, users }) => {
const free = await users.create();
await page.goto(`/${free.username}`);
@ -24,12 +25,18 @@ test.describe("free user", () => {
await selectFirstAvailableTimeSlotNextMonth(page);
// Navigate to book page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/book");
},
});
// Kept in if statement here, since it's only temporary
// until the old booker isn't used anymore, and I wanted
// to change the test as little as possible.
// eslint-disable-next-line playwright/no-conditional-in-test
if (bookerVariant !== "new-booker") {
// Navigate to book page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/book");
},
});
}
// save booking url
const bookingUrl: string = page.url();
@ -51,7 +58,7 @@ test.describe("free user", () => {
});
});
test.describe("pro user", () => {
testBothBookers.describe("pro user", () => {
test.beforeEach(async ({ page, users }) => {
const pro = await users.create();
await page.goto(`/${pro.username}`);

View File

@ -7,6 +7,7 @@ import prisma from "@calcom/prisma";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
import { testBothBookers } from "./lib/new-booker";
import {
bookTimeSlot,
createNewSeatedEventType,
@ -46,7 +47,7 @@ async function createUserWithSeatedEventAndAttendees(
return { user, eventType, booking };
}
test.describe("Booking with Seats", () => {
testBothBookers.describe("Booking with Seats", (bookerVariant) => {
test("User can create a seated event (2 seats as example)", async ({ users, page }) => {
const user = await users.create({ name: "Seated event" });
await user.login();
@ -64,11 +65,19 @@ test.describe("Booking with Seats", () => {
});
await page.goto(`/${user.username}/${slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/book");
},
});
// Kept in if statement here, since it's only temporary
// until the old booker isn't used anymore, and I wanted
// to change the test as little as possible.
// eslint-disable-next-line playwright/no-conditional-in-test
if (bookerVariant === "old-booker") {
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/book");
},
});
}
const bookingUrl = page.url();
await test.step("Attendee #1 can book a seated event time slot", async () => {
await page.goto(bookingUrl);
@ -93,7 +102,7 @@ test.describe("Booking with Seats", () => {
// TODO: Make E2E test: All attendees canceling should delete the booking for the User
// todo("All attendees canceling should delete the booking for the User");
test.describe("Reschedule for booking with seats", () => {
testBothBookers.describe("Reschedule for booking with seats", () => {
test("Should reschedule booking with seats", async ({ page, users, bookings }) => {
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },

View File

@ -4,12 +4,13 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { randomString } from "@calcom/lib/random";
import { test } from "./lib/fixtures";
import { testBothBookers } from "./lib/new-booker";
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("Event Types tests", () => {
test.describe("user", () => {
testBothBookers.describe("user", (bookerVariant) => {
test.beforeEach(async ({ page, users }) => {
const user = await users.create();
await user.login();
@ -147,11 +148,17 @@ test.describe("Event Types tests", () => {
await selectFirstAvailableTimeSlotNextMonth(page);
// Navigate to book page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/book");
},
});
// Kept in if statement here, since it's only temporary
// until the old booker isn't used anymore, and I wanted
// to change the test as little as possible.
// eslint-disable-next-line playwright/no-conditional-in-test
if (bookerVariant === "old-booker") {
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/book");
},
});
}
for (const location of locationData) {
await page.locator(`span:has-text("${location}")`).click();

View File

@ -1,3 +1,4 @@
import type { Page } from "@playwright/test";
import { test as base } from "@playwright/test";
import prisma from "@calcom/prisma";
@ -10,6 +11,7 @@ import { createServersFixture } from "../fixtures/servers";
import { createUsersFixture } from "../fixtures/users";
export interface Fixtures {
page: Page;
users: ReturnType<typeof createUsersFixture>;
bookings: ReturnType<typeof createBookingsFixture>;
payments: ReturnType<typeof createPaymentsFixture>;

View File

@ -0,0 +1,31 @@
import { test } from "./fixtures";
export type BookerVariants = "new-booker" | "old-booker";
const bookerVariants = ["new-booker", "old-booker"];
/**
* Small wrapper around test.describe().
* When using testbothBookers.describe() instead of test.describe(), this will run the specified
* tests twice. One with the old booker, and one with the new booker. It will also add the booker variant
* name to the test name for easier debugging.
* Finally it also adds a parameter bookerVariant to your testBothBooker.describe() callback, which
* can be used to do any conditional rendering in the test for a specific booker variant (should be as little
* as possible).
*
* See apps/web/playwright/booking-pages.e2e.ts for an example.
*/
export const testBothBookers = {
describe: (testName: string, testFn: (bookerVariant: BookerVariants) => void) => {
bookerVariants.forEach((bookerVariant) => {
test.describe(`${testName} -- ${bookerVariant}`, () => {
if (bookerVariant === "new-booker") {
test.beforeEach(({ context }) => {
context.addCookies([{ name: "new-booker-enabled", value: "true", url: "http://localhost:3000" }]);
});
}
testFn(bookerVariant as BookerVariants);
});
});
},
};

View File

@ -7,6 +7,8 @@ import { uuid } from "short-uuid";
import prisma from "@calcom/prisma";
import { test } from "./lib/fixtures";
import { testBothBookers } from "./lib/new-booker";
import type { BookerVariants } from "./lib/new-booker";
import { createHttpServer, waitFor, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
async function getLabelText(field: Locator) {
@ -18,7 +20,7 @@ test.describe("Manage Booking Questions", () => {
await users.deleteAll();
});
test.describe("For User EventType", () => {
testBothBookers.describe("For User EventType", (bookerVariant) => {
test("Do a booking with a user added question and verify a few thing in b/w", async ({
page,
users,
@ -37,11 +39,11 @@ test.describe("Manage Booking Questions", () => {
await firstEventTypeElement.click();
});
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver, bookerVariant);
});
});
test.describe("For Team EventType", () => {
testBothBookers.describe("For Team EventType", (bookerVariant) => {
test("Do a booking with a user added question and verify a few thing in b/w", async ({
page,
users,
@ -60,7 +62,7 @@ test.describe("Manage Booking Questions", () => {
await firstEventTypeElement.click();
});
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver, bookerVariant);
});
});
});
@ -73,7 +75,8 @@ async function runTestStepsCommonForTeamAndUserEventType(
close: () => import("http").Server;
requestList: (import("http").IncomingMessage & { body?: unknown })[];
url: string;
}
},
bookerVariant: BookerVariants
) {
await page.click('[href$="tabName=advanced"]');
@ -89,7 +92,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
},
});
await doOnFreshPreview(page, context, async (page) => {
await doOnFreshPreview(page, context, bookerVariant, async (page) => {
const allFieldsLocator = await expectSystemFieldsToBeThere(page);
const userFieldLocator = allFieldsLocator.nth(5);
@ -105,7 +108,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
name: "how_are_you",
page,
});
await doOnFreshPreview(page, context, async (page) => {
await doOnFreshPreview(page, context, bookerVariant, async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeHidden();
});
@ -119,7 +122,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
});
await test.step('Try to book without providing "How are you?" response', async () => {
await doOnFreshPreview(page, context, async (page) => {
await doOnFreshPreview(page, context, bookerVariant, async (page) => {
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
await expectErrorToBeThereFor({ page, name: "how_are_you" });
});
@ -138,6 +141,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
return await doOnFreshPreview(
page,
context,
bookerVariant,
async (page) => {
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
await expect(formBuilderFieldLocator).toBeVisible();
@ -196,8 +200,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
await test.step("Do a reschedule and notice that we can't book without giving a value for rescheduleReason", async () => {
const page = previewTabPage;
await rescheduleFromTheLinkOnPage({ page });
// await page.pause();
await rescheduleFromTheLinkOnPage({ page, bookerVariant });
await expectErrorToBeThereFor({ page, name: "rescheduleReason" });
});
}
@ -304,10 +307,11 @@ async function expectErrorToBeThereFor({ page, name }: { page: Page; name: strin
async function doOnFreshPreview(
page: Page,
context: PlaywrightTestArgs["context"],
bookerVariant: BookerVariants,
callback: (page: Page) => Promise<void>,
persistTab = false
) {
const previewTabPage = await openBookingFormInPreviewTab(context, page);
const previewTabPage = await openBookingFormInPreviewTab(context, page, bookerVariant);
await callback(previewTabPage);
if (!persistTab) {
await previewTabPage.close();
@ -347,25 +351,39 @@ async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType<t
return user;
}
async function rescheduleFromTheLinkOnPage({ page }: { page: Page }) {
async function rescheduleFromTheLinkOnPage({
page,
bookerVariant,
}: {
page: Page;
bookerVariant: BookerVariants;
}) {
await page.locator('[data-testid="reschedule-link"]').click();
await page.waitForLoadState();
await selectFirstAvailableTimeSlotNextMonth(page);
await page.waitForNavigation({
url: (url) => url.pathname.endsWith("/book"),
});
if (bookerVariant === "old-booker") {
await page.waitForNavigation({
url: (url) => url.pathname.endsWith("/book"),
});
}
await page.click('[data-testid="confirm-reschedule-button"]');
}
async function openBookingFormInPreviewTab(context: PlaywrightTestArgs["context"], page: Page) {
async function openBookingFormInPreviewTab(
context: PlaywrightTestArgs["context"],
page: Page,
bookerVariant: BookerVariants
) {
const previewTabPromise = context.waitForEvent("page");
await page.locator('[data-testid="preview-button"]').click();
const previewTabPage = await previewTabPromise;
await previewTabPage.waitForLoadState();
await selectFirstAvailableTimeSlotNextMonth(previewTabPage);
await previewTabPage.waitForNavigation({
url: (url) => url.pathname.endsWith("/book"),
});
if (bookerVariant === "old-booker") {
await previewTabPage.waitForNavigation({
url: (url) => url.pathname.endsWith("/book"),
});
}
return previewTabPage;
}

View File

@ -4,6 +4,7 @@ import { BookingStatus } from "@prisma/client";
import prisma from "@calcom/prisma";
import { test } from "./lib/fixtures";
import { testBothBookers } from "./lib/new-booker";
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
const IS_STRIPE_ENABLED = !!(
@ -16,7 +17,7 @@ test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Reschedule Tests", async () => {
testBothBookers.describe("Reschedule Tests", async () => {
test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion

View File

@ -1697,6 +1697,15 @@
"spot_popular_event_types_description": "See which of your event types are receiving the most clicks and bookings",
"no_responses_yet": "No responses yet",
"this_will_be_the_placeholder": "This will be the placeholder",
"error_booking_event": "An error occured when booking the event, please refresh the page and try again",
"timeslot_missing_title": "No timeslot selected",
"timeslot_missing_description": "Please select a timeslot to book the event.",
"timeslot_missing_cta": "Select timeslot",
"switch_monthly": "Switch to monthly view",
"switch_weekly": "Switch to weekly view",
"switch_multiday": "Switch to day view",
"num_locations": "{{num}} location options",
"select_on_next_step": "Select on the next step",
"this_meeting_has_not_started_yet": "This meeting has not started yet",
"this_app_requires_connected_account": "{{appName}} requires a connected {{dependencyName}} account",
"connect_app": "Connect {{dependencyName}}",

View File

@ -1,4 +1,4 @@
import { parseZone } from "@lib/parseZone";
import { parseZone } from "@calcom/lib/parse-zone";
const EXPECTED_DATE_STRING = "2021-06-20T11:59:59+02:00";

View File

@ -1,5 +1,8 @@
import type { Config } from "jest";
// Added +2 to ensure we need to do some conversions in our tests
process.env.TZ = "GMT+2";
const config: Config = {
preset: "ts-jest",
verbose: true,
@ -66,6 +69,22 @@ const config: Config = {
transformIgnorePatterns: ["/node_modules/", "^.+\\.module\\.(css|sass|scss)$"],
testEnvironment: "jsdom",
},
{
displayName: "@calcom/features",
roots: ["<rootDir>/packages/features"],
testMatch: ["**/*.(spec|test).(ts|tsx|js)"],
transform: {
"^.+\\.ts?$": "ts-jest",
},
transformIgnorePatterns: ["/node_modules/", "^.+\\.module\\.(css|sass|scss)$"],
testEnvironment: "jsdom",
moduleDirectories: ["node_modules", "<rootDir>"],
globals: {
"ts-jest": {
tsconfig: "<rootDir>/packages/features/tsconfig.json",
},
},
},
// FIXME: Prevent this breaking Jest when API module is missing
// {
// displayName: "@calcom/api",

View File

@ -0,0 +1,13 @@
import type { BookerProps } from "@calcom/features/bookings/Booker";
import { Booker as BookerComponent } from "@calcom/features/bookings/Booker";
import type { AtomsGlobalConfigProps } from "../types";
type BookerAtomProps = BookerProps & AtomsGlobalConfigProps;
/**
* @TODO Before we can turn this into a reusable atom
* * Use the webAppUrl coming from AtomsGlobalConfigProps to make url dynamic
* * Find a solution for translations
*/
export const Booker = (props: BookerAtomProps) => <BookerComponent {...props} />;

View File

@ -0,0 +1,14 @@
import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs';
import { Title } from '@calcom/storybook/components'
import { Icon } from "@calcom/ui";
import { Booker } from './Booker';
<Meta title="Atoms/Booker" component={Booker} />
<Title title="Booker"/>
<Canvas>
<Story name="Booker">
<Booker username="pro" />
</Story>
</Canvas>

View File

@ -0,0 +1,5 @@
/** Export file is only used for building the dist version of this Atom. */
// import "../globals.css";
export { Booker } from "./Booker";
export * from "../types";

View File

@ -0,0 +1 @@
export { Booker } from "./Booker";

31
packages/atoms/build.mjs Normal file
View File

@ -0,0 +1,31 @@
import path from "path";
import { fileURLToPath } from "url";
import { build } from "vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// @TODO: Do we want to automate this by checking all dirs for export.ts?
const libraries = [
{
entry: path.resolve(__dirname, "./booker/export.ts"),
fileName: "booker",
},
];
libraries.forEach(async (lib) => {
await build({
build: {
outDir: `./dist/${lib.fileName}`,
lib: {
...lib,
formats: ["es", "cjs"],
},
emptyOutDir: false,
},
resolve: {
alias: {
crypto: require.resolve("rollup-plugin-node-builtins"),
},
},
});
});

View File

@ -0,0 +1,10 @@
/*
* @NOTE: This file is only imported when building the component's CSS file
* When using this component in any Cal project, the globals are automatically imported
* in that project.
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "../ui/styles/shared-globals.css";

1
packages/atoms/index.ts Normal file
View File

@ -0,0 +1 @@
export { Booker } from "./booker/Booker";

View File

@ -0,0 +1,22 @@
{
"name": "@calcom/atoms",
"private": true,
"sideEffects": false,
"type": "module",
"description": "Cal.com Atoms",
"authors": "Cal.com, Inc.",
"version": "1.0.0",
"scripts": {
"build": "node build.mjs"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.0.1",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^2.2.0",
"rollup-plugin-node-builtins": "^2.1.2",
"typescript": "^4.9.3",
"vite": "^3.2.4"
},
"main": "./index"
}

View File

@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@ -0,0 +1,7 @@
const base = require("@calcom/config/tailwind-preset");
/** @type {import('tailwindcss').Config} */
module.exports = {
...base,
content: ["./bookings/**/*.tsx"],
};

View File

@ -0,0 +1,12 @@
{
"extends": "@calcom/tsconfig/react-library.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["/*"]
},
"resolveJsonModule": true
},
"include": [".", "../types/next-auth.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

7
packages/atoms/types.ts Normal file
View File

@ -0,0 +1,7 @@
export interface AtomsGlobalConfigProps {
/**
* API endpoint for the Booker component to fetch data from,
* defaults to https://cal.com
*/
webAppUrl?: string;
}

View File

@ -0,0 +1,28 @@
import { resolve } from "path";
import { defineConfig } from "vite";
export default defineConfig({
build: {
lib: {
entry: [resolve(__dirname, "booker/export.ts")],
name: "CalAtoms",
fileName: "cal-atoms",
},
rollupOptions: {
external: ["react", "fs", "path", "os", "react-dom"],
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
},
},
},
},
resolve: {
alias: {
fs: resolve("../../node_modules/rollup-plugin-node-builtins"),
path: resolve("../../node_modules/rollup-plugin-node-builtins"),
os: resolve("../../node_modules/rollup-plugin-node-builtins"),
},
},
});

View File

@ -17,6 +17,7 @@
"eslint-plugin-prettier": "^4.2.1"
},
"devDependencies": {
"@savvywombat/tailwindcss-grid-areas": "^3.0.0",
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/line-clamp": "^0.4.0",
"@tailwindcss/typography": "^0.5.4",

View File

@ -10,6 +10,7 @@ module.exports = {
"../../packages/app-store/**/*{components,pages}/**/*.{js,ts,jsx,tsx}",
"../../packages/features/**/*.{js,ts,jsx,tsx}",
"../../packages/ui/**/*.{js,ts,jsx,tsx}",
"../../packages/atoms/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
theme: {
@ -92,18 +93,12 @@ module.exports = {
},
keyframes: {
"fade-in-up": {
"0%": {
opacity: 0.75,
transform: "translateY(20px)",
},
"100%": {
opacity: 1,
transform: "translateY(0)",
},
from: { opacity: 0, transform: "translateY(10px)" },
to: { opacity: 1, transform: "none" },
},
},
animation: {
"fade-in-up": "fade-in-up 0.35s cubic-bezier(.21,1.02,.73,1)",
"fade-in-up": "fade-in-up 600ms var(--animation-delay, 0ms) cubic-bezier(.21,1.02,.73,1) forwards",
},
boxShadow: {
dropdown: "0px 2px 6px -1px rgba(0, 0, 0, 0.08)",
@ -152,6 +147,7 @@ module.exports = {
require("@tailwindcss/typography"),
require("tailwind-scrollbar"),
require("tailwindcss-radix")(),
require("@savvywombat/tailwindcss-grid-areas"),
plugin(({ addVariant }) => {
addVariant("mac", ".mac &");
addVariant("windows", ".windows &");

View File

@ -0,0 +1,213 @@
import type { MotionStyle } from "framer-motion";
import { LazyMotion, domAnimation, m, AnimatePresence } from "framer-motion";
import { Fragment, useEffect, useRef } from "react";
import StickyBox from "react-sticky-box";
import { shallow } from "zustand/shallow";
import classNames from "@calcom/lib/classNames";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { Logo, ToggleGroup, useCalcomTheme } from "@calcom/ui";
import { Calendar, Columns, Grid } from "@calcom/ui/components/icon";
import { AvailableTimeSlots } from "./components/AvailableTimeSlots";
import { Away } from "./components/Away";
import { BookEventForm } from "./components/BookEventForm";
import { DatePicker } from "./components/DatePicker";
import { EventMeta } from "./components/EventMeta";
import { LargeCalendar } from "./components/LargeCalendar";
import { BookerSection } from "./components/Section";
import { fadeInUp, fadeInLeft, resizeAnimationConfig } from "./config";
import { useBookerStore, useInitializeBookerStore } from "./store";
import type { BookerLayout, BookerProps } from "./types";
import { useEvent } from "./utils/event";
const useBrandColors = ({ brandColor, darkBrandColor }: { brandColor?: string; darkBrandColor?: string }) => {
const brandTheme = useGetBrandingColours({
lightVal: brandColor,
darkVal: darkBrandColor,
});
useCalcomTheme(brandTheme);
};
const BookerComponent = ({ username, eventSlug, month, rescheduleBooking }: BookerProps) => {
const { t } = useLocale();
const isMobile = useMediaQuery("(max-width: 768px)");
const isTablet = useMediaQuery("(max-width: 1024px)");
const timeslotsRef = useRef<HTMLDivElement>(null);
const StickyOnDesktop = isMobile ? "div" : StickyBox;
const rescheduleUid =
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduleUid") : null;
const event = useEvent();
const [layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
const selectedDate = useBookerStore((state) => state.selectedDate);
const [selectedTimeslot, setSelectedTimeslot] = useBookerStore(
(state) => [state.selectedTimeslot, state.setSelectedTimeslot],
shallow
);
useBrandColors({
brandColor: event.data?.profile.brandColor,
darkBrandColor: event.data?.profile.darkBrandColor,
});
useInitializeBookerStore({
username,
eventSlug,
month,
eventId: event?.data?.id,
rescheduleUid,
rescheduleBooking,
});
useEffect(() => {
setLayout(isMobile ? "mobile" : "small_calendar");
}, [isMobile, setLayout]);
useEffect(() => {
if (event.isLoading) return setBookerState("loading");
if (!selectedDate) return setBookerState("selecting_date");
if (!selectedTimeslot) return setBookerState("selecting_time");
return setBookerState("booking");
}, [event, selectedDate, selectedTimeslot, setBookerState]);
useEffect(() => {
if (layout === "mobile") {
timeslotsRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [layout, selectedDate]);
return (
<>
{/*
If we would render this on mobile, it would unset the mobile variant,
since that's not a valid option, so it would set the layout to null.
*/}
{!isMobile && (
<div className="[&>div]:bg-muted fixed top-2 right-3 z-10">
<ToggleGroup
onValueChange={(layout) => setLayout(layout as BookerLayout)}
defaultValue="small_calendar"
options={[
{
value: "small_calendar",
label: <Calendar width="16" height="16" />,
tooltip: t("switch_monthly"),
},
{
value: "large_calendar",
label: <Grid width="16" height="16" />,
tooltip: t("switch_weekly"),
},
{
value: "large_timeslots",
label: <Columns width="16" height="16" />,
tooltip: t("switch_multiday"),
},
]}
/>
</div>
)}
<div className="flex h-full w-full flex-col items-center">
<m.div
layout
// Passing the default animation styles here as the styles, makes sure that there's no initial loading state
// where there's no styles applied yet (meaning there wouldn't be a grid + widths), which would cause
// the layout to jump around on load.
style={resizeAnimationConfig.small_calendar.default as MotionStyle}
animate={resizeAnimationConfig[layout]?.[bookerState] || resizeAnimationConfig[layout].default}
transition={{ ease: "easeInOut", duration: 0.4 }}
className={classNames(
"[--booker-meta-width:280px] [--booker-main-width:480px] [--booker-timeslots-width:240px] lg:[--booker-timeslots-width:280px]",
"bg-muted grid max-w-full items-start overflow-clip dark:[color-scheme:dark] md:flex-row",
layout === "small_calendar" &&
"border-subtle mt-20 min-h-[450px] w-[calc(var(--booker-meta-width)+var(--booker-main-width))] rounded-md border",
layout !== "small_calendar" && "h-auto min-h-screen w-screen"
)}>
<AnimatePresence>
<StickyOnDesktop key="meta" className="relative z-10">
<BookerSection area="meta" className="max-w-screen w-full md:w-[var(--booker-meta-width)]">
<EventMeta />
{layout !== "small_calendar" && !(layout === "mobile" && bookerState === "booking") && (
<div className=" mt-auto p-6">
<DatePicker />
</div>
)}
</BookerSection>
</StickyOnDesktop>
<BookerSection
key="book-event-form"
area="main"
className="border-subtle sticky top-0 ml-[-1px] h-full p-6 md:w-[var(--booker-main-width)] md:border-l"
{...fadeInUp}
visible={bookerState === "booking"}>
<BookEventForm onCancel={() => setSelectedTimeslot(null)} />
</BookerSection>
<BookerSection
key="datepicker"
area="main"
visible={bookerState !== "booking" && layout === "small_calendar"}
{...fadeInUp}
initial="visible"
className="md:border-subtle ml-[-1px] h-full flex-shrink p-6 md:border-l lg:w-[var(--booker-main-width)]">
<DatePicker />
</BookerSection>
<BookerSection
key="large-calendar"
area="main"
visible={
layout === "large_calendar" &&
(bookerState === "selecting_date" || bookerState === "selecting_time")
}
className="border-muted sticky top-0 ml-[-1px] h-full md:border-l"
{...fadeInUp}>
<LargeCalendar />
</BookerSection>
<BookerSection
key="timeslots"
area={{ default: "main", small_calendar: "timeslots" }}
visible={
(layout !== "large_calendar" && bookerState === "selecting_time") ||
(layout === "large_timeslots" && bookerState !== "booking")
}
className={classNames(
"border-subtle flex h-full w-full flex-row p-6 pb-0 md:border-l",
layout === "small_calendar" && "h-full overflow-auto md:w-[var(--booker-timeslots-width)]",
layout !== "small_calendar" && "sticky top-0"
)}
ref={timeslotsRef}
{...fadeInLeft}>
<AvailableTimeSlots
extraDays={layout === "large_timeslots" ? (isTablet ? 2 : 4) : 0}
limitHeight={layout === "small_calendar"}
seatsPerTimeslot={event.data?.seatsPerTimeSlot}
/>
</BookerSection>
</AnimatePresence>
</m.div>
<m.span
key="logo"
className={classNames("mt-auto mb-6 pt-6", layout === "small_calendar" ? "block" : "hidden")}>
<Logo small />
</m.span>
</div>
</>
);
};
export const Booker = (props: BookerProps) => {
if (props.isAway) return <Away />;
return (
<LazyMotion features={domAnimation}>
<BookerComponent {...props} />
</LazyMotion>
);
};

View File

@ -0,0 +1,78 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { AvailableTimes, AvailableTimesSkeleton } from "@calcom/features/bookings";
import { useSlotsForMultipleDates } from "@calcom/features/schedules/lib/use-schedule/useSlotsForDate";
import { classNames } from "@calcom/lib";
import { useBookerStore } from "../store";
import { useScheduleForEvent } from "../utils/event";
type AvailableTimeSlotsProps = {
extraDays?: number;
limitHeight?: boolean;
seatsPerTimeslot?: number | null;
};
/**
* Renders available time slots for a given date.
* It will extract the date from the booker store.
* Next to that you can also pass in the `extraDays` prop, this
* will also fetch the next `extraDays` days and show multiple days
* in columns next to each other.
*/
export const AvailableTimeSlots = ({ extraDays, limitHeight, seatsPerTimeslot }: AvailableTimeSlotsProps) => {
const selectedDate = useBookerStore((state) => state.selectedDate);
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
const date = selectedDate || dayjs().format("YYYY-MM-DD");
const schedule = useScheduleForEvent({
prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(),
});
// Creates an array of dates to fetch slots for.
// If `extraDays` is passed in, we will extend the array with the next `extraDays` days.
const dates = useMemo(
() =>
!extraDays
? [date]
: [
// If NO date is selected yet, we show by default the upcomming `nextDays` days.
date,
...Array.from({ length: extraDays }).map((_, index) =>
dayjs(date)
.add(index + 1, "day")
.format("YYYY-MM-DD")
),
],
[date, extraDays]
);
const isMultipleDates = dates.length > 1;
const slotsPerDay = useSlotsForMultipleDates(dates, schedule?.data?.slots);
return (
<div
className={classNames(
limitHeight && "flex-grow md:h-[400px]",
!limitHeight &&
"flex w-full flex-row gap-4 [&_header]:top-4 md:[&_header]:top-12 [&_header:before]:h-20"
)}>
{schedule.isLoading
? // Shows exact amount of days as skeleton.
Array.from({ length: 1 + (extraDays ?? 0) }).map((_, i) => <AvailableTimesSkeleton key={i} />)
: slotsPerDay.length > 0 &&
slotsPerDay.map((slots) => (
<AvailableTimes
className="w-full"
key={slots.date}
showTimeformatToggle={!isMultipleDates}
onTimeSelect={setSelectedTimeslot}
date={dayjs(slots.date)}
slots={slots.slots}
seatsPerTimeslot={seatsPerTimeslot}
/>
))}
</div>
);
};

View File

@ -0,0 +1,20 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
export const Away = () => {
const { t } = useLocale();
return (
<div className="h-screen">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="space-y-6" data-testid="event-types">
<div className="border-brand overflow-hidden rounded-sm border">
<div className="text-subtle p-8 text-center">
<h2 className="font-cal text-subtle mb-2 text-3xl">😴{" " + t("user_away")}</h2>
<p className="mx-auto max-w-md">{t("user_away_description")}</p>
</div>
</div>
</div>
</main>
</div>
);
};

View File

@ -0,0 +1,342 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { UseMutationResult } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/router";
import { useMemo } from "react";
import type { FieldError } from "react-hook-form";
import { useForm } from "react-hook-form";
import type { TFunction } from "react-i18next";
import { z } from "zod";
import type { EventLocationType } from "@calcom/app-store/locations";
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
import dayjs from "@calcom/dayjs";
import {
useTimePreferences,
mapBookingToMutationInput,
createBooking,
createRecurringBooking,
mapRecurringBookingToMutationInput,
} from "@calcom/features/bookings/lib";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { Form, Button, Alert, EmptyScreen } from "@calcom/ui";
import { Calendar } from "@calcom/ui/components/icon";
import { useBookerStore } from "../../store";
import { useEvent } from "../../utils/event";
import { getQueryParam } from "../../utils/query-param";
import { BookingFields } from "./BookingFields";
import { FormSkeleton } from "./Skeleton";
type BookEventFormProps = {
onCancel?: () => void;
};
const getSuccessPath = ({
uid,
email,
slug,
formerTime,
isRecurring,
}: {
uid: string;
email: string;
slug: string;
formerTime?: string;
isRecurring: boolean;
}) => ({
pathname: `/booking/${uid}`,
query: {
[isRecurring ? "allRemainingBookings" : "isSuccessBookingPage"]: true,
email: email,
eventTypeSlug: slug,
formerTime: formerTime,
},
});
export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
const router = useRouter();
const { t, i18n } = useLocale();
const { timezone } = useTimePreferences();
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
const rescheduleBooking = useBookerStore((state) => state.rescheduleBooking);
const eventSlug = useBookerStore((state) => state.eventSlug);
const duration = useBookerStore((state) => state.selectedDuration);
const timeslot = useBookerStore((state) => state.selectedTimeslot);
const recurringEventCount = useBookerStore((state) => state.recurringEventCount);
const username = useBookerStore((state) => state.username);
const isRescheduling = !!rescheduleUid && !!rescheduleBooking;
const event = useEvent();
const eventType = event.data;
const defaultValues = useMemo(() => {
if (!eventType?.bookingFields) {
return {};
}
const defaultUserValues = {
email: rescheduleUid ? rescheduleBooking?.attendees[0].email : getQueryParam("email") || "",
name: rescheduleUid ? rescheduleBooking?.attendees[0].name : getQueryParam("name") || "",
};
if (!isRescheduling) {
const defaults = {
responses: {} as Partial<z.infer<typeof bookingFormSchema>["responses"]>,
};
const responses = eventType.bookingFields.reduce((responses, field) => {
return {
...responses,
[field.name]: getQueryParam(field.name) || undefined,
};
}, {});
defaults.responses = {
...responses,
name: defaultUserValues.name,
email: defaultUserValues.email,
};
return defaults;
}
if (!rescheduleBooking || !rescheduleBooking.attendees.length) {
return {};
}
const primaryAttendee = rescheduleBooking.attendees[0];
if (!primaryAttendee) {
return {};
}
const defaults = {
responses: {} as Partial<z.infer<typeof bookingFormSchema>["responses"]>,
};
const responses = eventType.bookingFields.reduce((responses, field) => {
return {
...responses,
[field.name]: rescheduleBooking.responses[field.name],
};
}, {});
defaults.responses = {
...responses,
name: defaultUserValues.name,
email: defaultUserValues.email,
};
return defaults;
}, [eventType?.bookingFields, isRescheduling, rescheduleBooking, rescheduleUid]);
const bookingFormSchema = z
.object({
responses: event?.data
? getBookingResponsesSchema({
eventType: { bookingFields: getBookingFieldsWithSystemFields(event.data) },
view: rescheduleUid ? "reschedule" : "booking",
})
: // Fallback until event is loaded.
z.object({}),
})
.passthrough();
type BookingFormValues = {
locationType?: EventLocationType["type"];
responses: z.infer<typeof bookingFormSchema>["responses"];
// Key is not really part of form values, but only used to have a key
// to set generic error messages on. Needed until RHF has implemented root error keys.
globalError: undefined;
};
const bookingForm = useForm<BookingFormValues>({
defaultValues,
resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema
});
const createBookingMutation = useMutation(createBooking, {
onSuccess: async (responseData) => {
const { uid, paymentUid } = responseData;
if (paymentUid) {
return await router.push(
createPaymentLink({
paymentUid,
date: timeslot,
name: bookingForm.getValues("responses.name"),
email: bookingForm.getValues("responses.email"),
absolute: false,
})
);
}
if (!uid) {
console.error("No uid returned from createBookingMutation");
return;
}
return await router.push(
getSuccessPath({
uid,
email: bookingForm.getValues("responses.email"),
formerTime: rescheduleBooking?.startTime
? dayjs(rescheduleBooking?.startTime).toISOString()
: undefined,
slug: `${eventSlug}`,
isRecurring: false,
})
);
},
});
const createRecurringBookingMutation = useMutation(createRecurringBooking, {
onSuccess: async (responseData) => {
const { uid } = responseData[0] || {};
if (!uid) {
console.error("No uid returned from createRecurringBookingMutation");
return;
}
return await router.push(
getSuccessPath({
uid,
email: bookingForm.getValues("responses.email"),
slug: `${eventSlug}`,
isRecurring: true,
})
);
},
});
if (event.isError) return <Alert severity="warning" message={t("error_booking_event")} />;
if (event.isLoading || !event.data) return <FormSkeleton />;
if (!timeslot)
return (
<EmptyScreen
headline={t("timeslot_missing_title")}
description={t("timeslot_missing_description")}
Icon={Calendar}
buttonText={t("timeslot_missing_cta")}
buttonOnClick={onCancel}
/>
);
const bookEvent = (values: BookingFormValues) => {
bookingForm.clearErrors();
// It shouldn't be possible that this method is fired without having event data,
// but since in theory (looking at the types) it is possible, we still handle that case.
if (!event?.data) {
bookingForm.setError("globalError", { message: t("error_booking_event") });
return;
}
// Ensures that duration is an allowed value, if not it defaults to the
// default event duration.
const validDuration =
duration &&
event.data.metadata?.multipleDuration &&
event.data.metadata?.multipleDuration.includes(duration)
? duration
: event.data.length;
const bookingInput = {
values,
duration: validDuration,
event: event.data,
date: timeslot,
timeZone: timezone,
language: i18n.language,
rescheduleUid: rescheduleUid || undefined,
username: username || "",
metadata: Object.keys(router.query)
.filter((key) => key.startsWith("metadata"))
.reduce(
(metadata, key) => ({
...metadata,
[key.substring("metadata[".length, key.length - 1)]: router.query[key],
}),
{}
),
};
if (event.data?.recurringEvent?.freq && recurringEventCount) {
createRecurringBookingMutation.mutate(
mapRecurringBookingToMutationInput(bookingInput, recurringEventCount)
);
} else {
createBookingMutation.mutate(mapBookingToMutationInput(bookingInput));
}
};
if (!eventType) {
console.warn("No event type found for event", router.query);
return <Alert severity="warning" message={t("error_booking_event")} />;
}
return (
<div className="flex h-full flex-col">
<Form className="flex h-full flex-col" form={bookingForm} handleSubmit={bookEvent} noValidate>
<BookingFields
isDynamicGroupBooking={!!(username && username.indexOf("+") > -1)}
fields={eventType.bookingFields}
locations={eventType.locations}
rescheduleUid={rescheduleUid || undefined}
/>
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
{!!onCancel && (
<Button color="minimal" type="button" onClick={onCancel}>
{t("back")}
</Button>
)}
<Button
type="submit"
color="primary"
loading={createBookingMutation.isLoading || createRecurringBookingMutation.isLoading}
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}>
{rescheduleUid ? t("reschedule") : t("confirm")}
</Button>
</div>
</Form>
{(createBookingMutation.isError ||
createRecurringBookingMutation.isError ||
bookingForm.formState.errors["globalError"]) && (
<div data-testid="booking-fail">
<Alert
className="mt-2"
severity="info"
title={rescheduleUid ? t("reschedule_fail") : t("booking_fail")}
message={getError(
bookingForm.formState.errors["globalError"],
createBookingMutation,
createRecurringBookingMutation,
t
)}
/>
</div>
)}
</div>
);
};
const getError = (
globalError: FieldError | undefined,
// It feels like an implementation detail to reimplement the types of useMutation here.
// Since they don't matter for this function, I'd rather disable them then giving you
// the cognitive overload of thinking to update them here when anything changes.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bookingMutation: UseMutationResult<any, any, any, any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
recurringBookingMutation: UseMutationResult<any, any, any, any>,
t: TFunction
) => {
if (globalError) return globalError.message;
const error = bookingMutation.error || recurringBookingMutation.error;
return error instanceof HttpError || error instanceof Error ? (
<>{t("can_you_try_again")}</>
) : (
"Unknown error"
);
};

View File

@ -0,0 +1,119 @@
import { useFormContext } from "react-hook-form";
import type { LocationObject } from "@calcom/app-store/locations";
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
import { FormBuilderField } from "@calcom/features/form-builder";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { SystemField } from "../../../lib/getBookingFields";
export const BookingFields = ({
fields,
locations,
rescheduleUid,
isDynamicGroupBooking,
}: {
fields: NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"];
locations: LocationObject[];
rescheduleUid?: string;
isDynamicGroupBooking: boolean;
}) => {
const { t } = useLocale();
const { watch, setValue } = useFormContext();
const locationResponse = watch("responses.location");
const currentView = rescheduleUid ? "reschedule" : "";
return (
// TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view
<div>
{fields.map((field, index) => {
// During reschedule by default all system fields are readOnly. Make them editable on case by case basis.
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
let readOnly =
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
let noLabel = false;
let hidden = !!field.hidden;
const fieldViews = field.views;
if (fieldViews && !fieldViews.find((view) => view.id === currentView)) {
return null;
}
if (field.name === SystemField.Enum.rescheduleReason) {
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule
readOnly = false;
}
if (field.name === SystemField.Enum.smsReminderNumber) {
// `smsReminderNumber` and location.optionValue when location.value===phone are the same data point. We should solve it in a better way in the Form Builder itself.
// I think we should have a way to connect 2 fields together and have them share the same value in Form Builder
if (locationResponse?.value === "phone") {
setValue(`responses.${SystemField.Enum.smsReminderNumber}`, locationResponse?.optionValue);
// Just don't render the field now, as the value is already connected to attendee phone location
return null;
}
// `smsReminderNumber` can be edited during reschedule even though it's a system field
readOnly = false;
}
if (field.name === SystemField.Enum.guests) {
// No matter what user configured for Guests field, we don't show it for dynamic group booking as that doesn't support guests
hidden = isDynamicGroupBooking ? true : !!field.hidden;
}
// We don't show `notes` field during reschedule
if (
(field.name === SystemField.Enum.notes || field.name === SystemField.Enum.guests) &&
!!rescheduleUid
) {
return null;
}
// Dynamically populate location field options
if (field.name === SystemField.Enum.location && field.type === "radioInput") {
if (!field.optionsInputs) {
throw new Error("radioInput must have optionsInputs");
}
const optionsInputs = field.optionsInputs;
// TODO: Instead of `getLocationOptionsForSelect` options should be retrieved from dataStore[field.getOptionsAt]. It would make it agnostic of the `name` of the field.
const options = getLocationOptionsForSelect(locations, t);
options.forEach((option) => {
const optionInput = optionsInputs[option.value as keyof typeof optionsInputs];
if (optionInput) {
optionInput.placeholder = option.inputPlaceholder;
}
});
field.options = options.filter(
(location): location is NonNullable<(typeof options)[number]> => !!location
);
// If we have only one option and it has an input, we don't show the field label because Option name acts as label.
// e.g. If it's just Attendee Phone Number option then we don't show `Location` label
if (field.options.length === 1) {
if (field.optionsInputs[field.options[0].value]) {
noLabel = true;
} else {
// If there's only one option and it doesn't have an input, we don't show the field at all because it's visible in the left side bar
hidden = true;
}
}
}
const label = noLabel ? "" : field.label || t(field.defaultLabel || "");
const placeholder = field.placeholder || t(field.defaultPlaceholder || "");
return (
<FormBuilderField
className="mb-4"
field={{ ...field, label, placeholder, hidden }}
readOnly={readOnly}
key={index}
/>
);
})}
</div>
);
};

View File

@ -0,0 +1,29 @@
import { SkeletonText } from "@calcom/ui";
export const FormSkeleton = () => (
<div className="flex flex-col">
<SkeletonText className="h-7 w-32" />
<SkeletonText className="mt-2 h-7 w-full" />
<SkeletonText className="mt-4 h-7 w-28" />
<SkeletonText className="mt-2 h-7 w-full" />
<div className="mt-12 flex h-7 w-full flex-row items-center gap-4">
<SkeletonText className="inline h-4 w-4 rounded-full" />
<SkeletonText className="inline h-7 w-32" />
</div>
<div className="mt-2 flex h-7 w-full flex-row items-center gap-4">
<SkeletonText className="inline h-4 w-4 rounded-full" />
<SkeletonText className="inline h-7 w-28" />
</div>
<SkeletonText className="mt-8 h-7 w-32" />
<SkeletonText className="mt-2 h-7 w-full" />
<SkeletonText className="mt-4 h-7 w-28" />
<SkeletonText className="mt-2 h-7 w-full" />
<div className="mt-6 flex flex-row gap-3">
<SkeletonText className="ml-auto h-8 w-20" />
<SkeletonText className="h-8 w-20" />
</div>
</div>
);

View File

@ -0,0 +1 @@
export { BookEventForm } from "./BookEventForm";

View File

@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { default as DatePickerComponent } from "@calcom/features/calendars/DatePicker";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules";
import { weekdayToWeekIndex } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useBookerStore } from "../store";
import { useEvent, useScheduleForEvent } from "../utils/event";
export const DatePicker = () => {
const [isLoadedClientSide, setIsLoadedClientSide] = useState(false);
const { i18n } = useLocale();
const [month, selectedDate] = useBookerStore((state) => [state.month, state.selectedDate], shallow);
const [setSelectedDate, setMonth] = useBookerStore(
(state) => [state.setSelectedDate, state.setMonth],
shallow
);
const event = useEvent();
const schedule = useScheduleForEvent();
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots);
// Not rendering the component on the server side to avoid hydration issues
// @TODO: We should update the datepicker component as soon as the current booker isn't
// used anymore, so we don't need to have this check.
useEffect(() => {
setIsLoadedClientSide(true);
}, []);
if (!isLoadedClientSide) return null;
return (
<div className="mt-1">
<DatePickerComponent
isLoading={schedule.isLoading}
onChange={(date: Dayjs) => {
setSelectedDate(date.format("YYYY-MM-DD"));
}}
onMonthChange={(date: Dayjs) => {
setMonth(date.format("YYYY-MM"));
setSelectedDate(date.format("YYYY-MM-DD"));
}}
includedDates={nonEmptyScheduleDays}
locale={i18n.language}
browsingDate={month ? dayjs(month) : undefined}
selected={dayjs(selectedDate)}
weekStart={weekdayToWeekIndex(event?.data?.users?.[0]?.weekStart)}
/>
</div>
);
};

View File

@ -0,0 +1,93 @@
import { m } from "framer-motion";
import dynamic from "next/dynamic";
import { EventDetails, EventMembers, EventMetaSkeleton, EventTitle } from "@calcom/features/bookings";
import { EventMetaBlock } from "@calcom/features/bookings/components/event-meta/Details";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Calendar, Globe } from "@calcom/ui/components/icon";
import { fadeInUp } from "../config";
import { useBookerStore } from "../store";
import { formatEventFromToTime } from "../utils/dates";
import { useEvent } from "../utils/event";
const TimezoneSelect = dynamic(() => import("@calcom/ui").then((mod) => mod.TimezoneSelect), {
ssr: false,
});
export const EventMeta = () => {
const { timezone, setTimezone, timeFormat } = useTimePreferences();
const selectedDuration = useBookerStore((state) => state.selectedDuration);
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
const bookerState = useBookerStore((state) => state.state);
const rescheduleBooking = useBookerStore((state) => state.rescheduleBooking);
const { i18n, t } = useLocale();
const { data: event, isLoading } = useEvent();
return (
<div className="relative z-10 p-6">
{isLoading && (
<m.div {...fadeInUp} initial="visible" layout>
<EventMetaSkeleton />
</m.div>
)}
{!isLoading && !!event && (
<m.div {...fadeInUp} layout transition={{ ...fadeInUp.transition, delay: 0.3 }}>
<EventMembers schedulingType={event.schedulingType} users={event.users} profile={event.profile} />
<EventTitle className="mt-2 mb-8">{event?.title}</EventTitle>
<div className="space-y-4">
{rescheduleBooking && (
<EventMetaBlock icon={Calendar}>
{t("former_time")}
<br />
<span className="line-through" data-testid="former_time_p">
{formatEventFromToTime(
rescheduleBooking.startTime.toString(),
null,
timeFormat,
timezone,
i18n.language
)}
</span>
</EventMetaBlock>
)}
{selectedTimeslot && (
<EventMetaBlock icon={Calendar}>
{formatEventFromToTime(
selectedTimeslot,
selectedDuration,
timeFormat,
timezone,
i18n.language
)}
</EventMetaBlock>
)}
<EventDetails event={event} />
<EventMetaBlock
className="cursor-pointer [&_.current-timezone:before]:focus-within:opacity-100 [&_.current-timezone:before]:hover:opacity-100 [&_>svg]:mt-[4px]"
contentClassName="relative"
icon={Globe}>
{bookerState === "booking" ? (
<>{timezone}</>
) : (
<span className="current-timezone before:bg-subtle flex items-center justify-center before:absolute before:inset-0 before:left-[-30px] before:top-[-3px] before:bottom-[-3px] before:w-[calc(100%_+_35px)] before:rounded-md before:py-3 before:opacity-0 before:transition-opacity">
<TimezoneSelect
menuPosition="fixed"
classNames={{
control: () => "!min-h-0 p-0 border-0 bg-transparent focus-within:ring-0",
menu: () => "!w-64 max-w-[90vw]",
singleValue: () => "text-text py-1",
}}
value={timezone}
onChange={(tz) => setTimezone(tz.value)}
/>
</span>
)}
</EventMetaBlock>
</div>
</m.div>
)}
</div>
);
};

View File

@ -0,0 +1,30 @@
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import { useBookerStore } from "../store";
export const LargeCalendar = () => {
const [setSelectedDate, setSelectedTimeslot] = useBookerStore(
(state) => [state.setSelectedDate, state.setSelectedTimeslot],
shallow
);
return (
<div className="bg-muted flex h-full w-full flex-col items-center justify-center">
Something big is coming...
<br />
<button
className="max-w-[300px] underline"
type="button"
onClick={(ev) => {
ev.preventDefault();
setSelectedDate(dayjs().format("YYYY-MM-DD"));
setSelectedTimeslot(dayjs().format());
}}>
Click this button to set date + time in one go just like the big thing that is coming here would do.
:)
</button>
</div>
);
};

View File

@ -0,0 +1,62 @@
import type { MotionProps } from "framer-motion";
import { m } from "framer-motion";
import { forwardRef } from "react";
import { classNames } from "@calcom/lib";
import { useBookerStore } from "../store";
import type { BookerAreas, BookerLayout } from "../types";
/**
* Define what grid area a section should be in.
* Value is either a string (in case it's always the same area), or an object
* looking like:
* {
* // Where default is the required default area.
* default: "calendar",
* // Any optional overrides for different layouts by their layout name.
* large_calendar: "main",
* }
*/
type GridArea = BookerAreas | ({ [key in BookerLayout]?: BookerAreas } & { default: BookerAreas });
type BookerSectionProps = {
children: React.ReactNode;
area: GridArea;
visible?: boolean;
className?: string;
} & MotionProps;
// This map with strings is needed so Tailwind generates all classnames,
// If we would concatenate them with JS, Tailwind would not generate them.
const gridAreaClassNameMap: { [key in BookerAreas]: string } = {
calendar: "[grid-area:calendar]",
main: "[grid-area:main]",
meta: "[grid-area:meta]",
timeslots: "[grid-area:timeslots]",
};
/**
* Small helper compnent that renders a booker section in a specific grid area.
*/
export const BookerSection = forwardRef<HTMLDivElement, BookerSectionProps>(function BookerSection(
{ children, area, visible, className, ...props },
ref
) {
const layout = useBookerStore((state) => state.layout);
let gridClassName: string;
if (typeof area === "string") {
gridClassName = gridAreaClassNameMap[area];
} else {
gridClassName = gridAreaClassNameMap[area[layout] || area.default];
}
if (!visible && typeof visible !== "undefined") return null;
return (
<m.div ref={ref} className={classNames(gridClassName, className)} layout {...props}>
{children}
</m.div>
);
});

View File

@ -0,0 +1,82 @@
import type { TargetAndTransition } from "framer-motion";
import type { BookerLayout, BookerState } from "./types";
// Framer motion fade in animation configs.
export const fadeInLeft = {
variants: {
visible: { opacity: 1, x: 0 },
hidden: { opacity: 0, x: 20 },
},
initial: "hidden",
exit: "hidden",
animate: "visible",
transition: { ease: "easeInOut", delay: 0.1 },
};
export const fadeInUp = {
variants: {
visible: { opacity: 1, y: 0 },
hidden: { opacity: 0, y: 20 },
},
initial: "hidden",
exit: "hidden",
animate: "visible",
transition: { ease: "easeInOut", delay: 0.1 },
};
type ResizeAnimationConfig = {
[key in BookerLayout]: {
[key in BookerState | "default"]?: TargetAndTransition;
};
};
/**
* This configuration is used to animate the grid container for the booker.
* The object is structured as following:
*
* The root property of the object: is the name of the layout
* (mobile, small_calendar, large_calendar, large_timeslots)
*
* The values of these properties are objects that define the animation for each state of the booker.
* The animation have the same properties as you could pass to the animate prop of framer-motion:
* @see: https://www.framer.com/motion/animation/
*/
export const resizeAnimationConfig: ResizeAnimationConfig = {
mobile: {
default: {
width: "100%",
gridTemplateAreas: `
"meta"
"main"
"timeslots"
`,
gridTemplateColumns: "100%",
},
},
small_calendar: {
default: {
width: "calc(var(--booker-meta-width) + var(--booker-main-width))",
gridTemplateAreas: `"meta main"`,
gridTemplateColumns: "var(--booker-meta-width) var(--booker-main-width)",
},
selecting_time: {
width: "calc(var(--booker-meta-width) + var(--booker-main-width) + var(--booker-timeslots-width))",
gridTemplateAreas: `"meta main timeslots"`,
gridTemplateColumns: "var(--booker-meta-width) var(--booker-main-width) var(--booker-timeslots-width)",
},
},
large_calendar: {
default: {
width: "100%",
gridTemplateAreas: `"meta main"`,
gridTemplateColumns: "var(--booker-meta-width) 1fr",
},
},
large_timeslots: {
default: {
width: "100%",
gridTemplateAreas: `"meta main"`,
gridTemplateColumns: "var(--booker-meta-width) 1fr",
},
},
};

View File

@ -0,0 +1,2 @@
export { Booker } from "./Booker";
export type { BookerProps } from "./types";

View File

@ -0,0 +1,168 @@
import { useEffect } from "react";
import { create } from "zustand";
import dayjs from "@calcom/dayjs";
import type { GetBookingType } from "../lib/get-booking";
import type { BookerState, BookerLayout } from "./types";
import { updateQueryParam, getQueryParam } from "./utils/query-param";
/**
* Arguments passed into store initializer, containing
* the event data.
*/
type StoreInitializeType = {
username: string;
eventSlug: string;
// Month can be undefined if it's not passed in as a prop.
month?: string;
eventId: number | undefined;
rescheduleUid: string | null;
rescheduleBooking: GetBookingType | null | undefined;
};
type BookerStore = {
/**
* Event details. These are stored in store for easier
* access in child components.
*/
username: string | null;
eventSlug: string | null;
eventId: number | null;
/**
* Current month being viewed. Format is YYYY-MM.
*/
month: string | null;
setMonth: (month: string | null) => void;
/**
* Current state of the booking process
* the user is currently in. See enum for possible values.
*/
state: BookerState;
setState: (state: BookerState) => void;
/**
* The booker component supports different layouts,
* this value tracks the current layout.
*/
layout: BookerLayout;
setLayout: (layout: BookerLayout) => void;
/**
* Date selected by user (exact day). Format is YYYY-MM-DD.
*/
selectedDate: string | null;
setSelectedDate: (date: string | null) => void;
/**
* Selected event duration in minutes.
*/
selectedDuration: number | null;
setSelectedDuration: (duration: number | null) => void;
/**
* Selected timeslot user has chosen. This is a date string
* containing both the date + time.
*/
selectedTimeslot: string | null;
setSelectedTimeslot: (timeslot: string | null) => void;
/**
* Number of recurring events to create.
*/
recurringEventCount: number | null;
setRecurringEventCount(count: number | null): void;
/**
* If booking is being rescheduled, both the ID as well as
* the current booking details are passed in. The `rescheduleBooking`
* object is something that's fetched server side.
*/
rescheduleUid: string | null;
rescheduleBooking: GetBookingType | null;
/**
* Method called by booker component to set initial data.
*/
initialize: (data: StoreInitializeType) => void;
};
/**
* The booker store contains the data of the component's
* current state. This data can be reused within child components
* by importing this hook.
*
* See comments in interface above for more information on it's specific values.
*/
export const useBookerStore = create<BookerStore>((set, get) => ({
state: "loading",
setState: (state: BookerState) => set({ state }),
layout: "small_calendar",
setLayout: (layout: BookerLayout) => set({ layout }),
selectedDate: getQueryParam("date") || null,
setSelectedDate: (selectedDate: string | null) => {
set({ selectedDate });
updateQueryParam("date", selectedDate ?? "");
},
username: null,
eventSlug: null,
eventId: null,
month: getQueryParam("month") || getQueryParam("date") || dayjs().format("YYYY-MM"),
setMonth: (month: string | null) => {
set({ month, selectedTimeslot: null });
updateQueryParam("month", month ?? "");
get().setSelectedDate(null);
},
initialize: ({
username,
eventSlug,
month,
eventId,
rescheduleUid = null,
rescheduleBooking = null,
}: StoreInitializeType) => {
if (
get().username === username &&
get().eventSlug === eventSlug &&
get().month === month &&
get().eventId === eventId &&
get().rescheduleUid === rescheduleUid &&
get().rescheduleBooking?.responses.email === rescheduleBooking?.responses.email
)
return;
set({
username,
eventSlug,
eventId,
rescheduleUid,
rescheduleBooking,
});
// Unset selected timeslot if user is rescheduling. This could happen
// if the user reschedules a booking right after the confirmation page.
// In that case the time would still be store in the store, this way we
// force clear this.
if (rescheduleBooking) set({ selectedTimeslot: null });
if (month) set({ month });
},
selectedDuration: Number(getQueryParam("duration")) || null,
setSelectedDuration: (selectedDuration: number | null) => {
set({ selectedDuration });
updateQueryParam("duration", selectedDuration ?? "");
},
recurringEventCount: null,
setRecurringEventCount: (recurringEventCount: number | null) => set({ recurringEventCount }),
rescheduleBooking: null,
rescheduleUid: null,
selectedTimeslot: getQueryParam("slot") || null,
setSelectedTimeslot: (selectedTimeslot: string | null) => {
set({ selectedTimeslot });
updateQueryParam("slot", selectedTimeslot ?? "");
},
}));
export const useInitializeBookerStore = ({
username,
eventSlug,
month,
eventId,
rescheduleUid = null,
rescheduleBooking = null,
}: StoreInitializeType) => {
const initializeStore = useBookerStore((state) => state.initialize);
useEffect(() => {
initializeStore({ username, eventSlug, month, eventId, rescheduleUid, rescheduleBooking });
}, [initializeStore, username, eventSlug, month, eventId, rescheduleUid, rescheduleBooking]);
};

View File

@ -0,0 +1,46 @@
import type { GetBookingType } from "../lib/get-booking";
export interface BookerProps {
eventSlug: string;
username: string;
/**
* If month is NOT set as a prop on the component, we expect a query parameter
* called `month` to be present on the url. If that is missing, the component will
* default to the current month.
* @note In case you're using a client side router, please pass the value in as a prop,
* since the component will leverage window.location, which might not have the query param yet.
* @format YYYY-MM.
* @optional
*/
month?: string;
/**
* Default selected date for with the slotpicker will already open.
* @optional
*/
selectedDate?: Date;
hideBranding?: boolean;
/**
* Sets the Booker component to the away state.
* This is NOT revalidated by calling the API.
*/
isAway?: boolean;
/**
* If false and the current username indicates a dynamic booking,
* the Booker will immediately show an error.
* This is NOT revalidated by calling the API.
*/
allowsDynamicBooking?: boolean;
/**
* When rescheduling a booking, the current' bookings data is passed in via this prop.
* The component itself won't fetch booking data based on the ID, since there is not public
* api to fetch this data. Therefore rescheduling a booking currently is not possible
* within the atom (i.e. without a server side component).
*/
rescheduleBooking?: GetBookingType;
}
export type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking";
export type BookerLayout = "small_calendar" | "large_timeslots" | "large_calendar" | "mobile";
export type BookerAreas = "calendar" | "timeslots" | "main" | "meta";

View File

@ -0,0 +1,18 @@
import dayjs from "@calcom/dayjs";
import type { TimeFormat } from "@calcom/lib/timeFormat";
export const formatEventFromToTime = (
date: string,
duration: number | null,
timeFormat: TimeFormat,
timeZone: string,
language: string
) => {
const start = dayjs(date).tz(timeZone);
const end = duration ? start.add(duration, "minute") : null;
return `${start.format("dddd")}, ${start
.toDate()
.toLocaleDateString(language, { dateStyle: "long" })} ${start.format(timeFormat)} ${
end ? ` ${end.format(timeFormat)}` : ``
}`;
};

View File

@ -0,0 +1,53 @@
import { shallow } from "zustand/shallow";
import { useSchedule } from "@calcom/features/schedules";
import { trpc } from "@calcom/trpc/react";
import { useTimePreferences } from "../../lib/timePreferences";
import { useBookerStore } from "../store";
/**
* Wrapper hook around the trpc query that fetches
* the event curently viewed in the booker. It will get
* the current event slug and username from the booker store.
*
* Using this hook means you only need to use one hook, instead
* of combining multiple conditional hooks.
*/
export const useEvent = () => {
const [username, eventSlug] = useBookerStore((state) => [state.username, state.eventSlug], shallow);
return trpc.viewer.public.event.useQuery(
{ username: username ?? "", eventSlug: eventSlug ?? "" },
{ refetchOnWindowFocus: false, enabled: Boolean(username) && Boolean(eventSlug) }
);
};
/**
* Gets schedule for the current event and current month.
* Gets all values from the booker store.
*
* Using this hook means you only need to use one hook, instead
* of combining multiple conditional hooks.
*
* The prefetchNextMonth argument can be used to prefetch two months at once,
* useful when the user is viewing dates near the end of the month,
* this way the multi day view will show data of both months.
*/
export const useScheduleForEvent = ({ prefetchNextMonth }: { prefetchNextMonth?: boolean } = {}) => {
const { timezone } = useTimePreferences();
const event = useEvent();
const [username, eventSlug, month] = useBookerStore(
(state) => [state.username, state.eventSlug, state.month],
shallow
);
return useSchedule({
username,
eventSlug,
eventId: event.data?.id,
month,
timezone,
prefetchNextMonth,
});
};

View File

@ -0,0 +1,13 @@
export const updateQueryParam = (param: string, value: string | number) => {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(param, `${value}`);
window.history.pushState({}, "", url.href);
};
export const getQueryParam = (param: string) => {
if (typeof window === "undefined") return;
return new URLSearchParams(window.location.search).get(param);
};

View File

@ -0,0 +1,104 @@
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import type { Slots } from "@calcom/features/schedules";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { nameOfDay } from "@calcom/lib/weekday";
import { Button, SkeletonText } from "@calcom/ui";
import { useTimePreferences } from "../lib";
import { TimeFormatToggle } from "./TimeFormatToggle";
type AvailableTimesProps = {
date: Dayjs;
slots: Slots[string];
onTimeSelect: (time: string) => void;
seatsPerTimeslot?: number | null;
showTimeformatToggle?: boolean;
className?: string;
};
export const AvailableTimes = ({
date,
slots,
onTimeSelect,
seatsPerTimeslot,
showTimeformatToggle = true,
className,
}: AvailableTimesProps) => {
const { t, i18n } = useLocale();
const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]);
const hasTimeSlots = !!seatsPerTimeslot;
return (
<div className={classNames("dark:text-white", className)}>
<header className="bg-muted before:bg-muted sticky top-0 left-0 z-10 mb-8 flex w-full flex-row items-center before:absolute before:-top-12 before:h-24 before:w-full md:flex-col md:items-start lg:flex-row lg:items-center">
<span className="relative z-10">
<span className="text-text font-semibold">
{nameOfDay(i18n.language, Number(date.format("d")), "short")},
</span>
<span className="dark:text-darkgray-500 text-gray-500">
{" "}
{date.toDate().toLocaleString(i18n.language, { month: "short" })} {date.format(" D ")}
</span>
</span>
{showTimeformatToggle && (
<div className="ml-auto md:ml-0 lg:ml-auto">
<TimeFormatToggle />
</div>
)}
</header>
<div className="pb-4">
{!slots.length && (
<p className={classNames("text-emphasis", showTimeformatToggle ? "-mt-1 text-lg" : "text-sm")}>
{t("all_booked_today")}
</p>
)}
{slots.map((slot) => {
const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeslot);
return (
<Button
key={slot.time}
disabled={bookingFull}
data-testid="time"
data-time={slot.time}
onClick={() => onTimeSelect(slot.time)}
className="mb-3 flex h-auto min-h-[44px] w-full flex-col items-center justify-center py-2"
color="secondary">
{dayjs.utc(slot.time).tz(timezone).format(timeFormat)}
{bookingFull && <p className="text-sm">{t("booking_full")}</p>}
{hasTimeSlots && !bookingFull && (
<p className="flex items-center text-sm lowercase">
<span
className={classNames(
slot.attendees && slot.attendees / seatsPerTimeslot >= 0.8
? "bg-rose-600"
: slot.attendees && slot.attendees / seatsPerTimeslot >= 0.33
? "bg-yellow-500"
: "bg-emerald-400",
"mr-1 inline-block h-2 w-2 rounded-full"
)}
aria-hidden
/>
{slot.attendees ? seatsPerTimeslot - slot.attendees : seatsPerTimeslot}{" "}
{t("seats_available")}
</p>
)}
</Button>
);
})}
</div>
</div>
);
};
export const AvailableTimesSkeleton = () => (
<div className="mt-8 flex h-full w-[20%] flex-col only:w-full">
{/* Random number of elements between 1 and 10. */}
{Array.from({ length: Math.floor(Math.random() * 10) + 1 }).map((_, i) => (
<SkeletonText className="mb-4 h-6 w-full" key={i} />
))}
</div>
);

View File

@ -0,0 +1,25 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TimeFormat } from "@calcom/lib/timeFormat";
import { ToggleGroup } from "@calcom/ui";
import { useTimePreferences } from "../lib";
export const TimeFormatToggle = () => {
const timeFormat = useTimePreferences((state) => state.timeFormat);
const setTimeFormat = useTimePreferences((state) => state.setTimeFormat);
const { t } = useLocale();
return (
<ToggleGroup
onValueChange={(newFormat) => {
if (newFormat !== timeFormat) setTimeFormat(newFormat as TimeFormat);
}}
defaultValue={timeFormat}
value={timeFormat}
options={[
{ value: TimeFormat.TWELVE_HOUR, label: t("12_hour_short") },
{ value: TimeFormat.TWENTY_FOUR_HOUR, label: t("24_hour_short") },
]}
/>
);
};

View File

@ -0,0 +1,168 @@
import { Fragment } from "react";
import React from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Info, Clock, CheckSquare, RefreshCcw, CreditCard } from "@calcom/ui/components/icon";
import type { PublicEvent } from "../../types";
import { EventDetailBlocks } from "../../types";
import { EventDuration } from "./Duration";
import { EventLocations } from "./Locations";
import { EventOccurences } from "./Occurences";
import { EventPrice } from "./Price";
type EventDetailsPropsBase = {
event: PublicEvent;
className?: string;
};
type EventDetailDefaultBlock = {
blocks?: EventDetailBlocks[];
};
// Rendering a custom block requires passing a name prop,
// which is used as a key for the block.
type EventDetailCustomBlock = {
blocks?: React.FC[];
name: string;
};
type EventDetailsProps = EventDetailsPropsBase & (EventDetailDefaultBlock | EventDetailCustomBlock);
interface EventMetaProps {
icon: React.FC<{ className: string }> | string;
children: React.ReactNode;
// Emphasises the text in the block. For now only
// applying in dark mode.
highlight?: boolean;
contentClassName?: string;
className?: string;
}
/**
* Default order in which the event details will be rendered.
*/
const defaultEventDetailsBlocks = [
EventDetailBlocks.DESCRIPTION,
EventDetailBlocks.REQUIRES_CONFIRMATION,
EventDetailBlocks.DURATION,
EventDetailBlocks.OCCURENCES,
EventDetailBlocks.LOCATION,
EventDetailBlocks.PRICE,
];
/**
* Helper component that ensures the meta data of an event is
* rendered in a consistent way adds an icon and children (text usually).
*/
export const EventMetaBlock = ({
icon: Icon,
children,
highlight,
contentClassName,
className,
}: EventMetaProps) => {
if (!React.Children.count(children)) return null;
return (
<div
className={classNames(
"flex items-start justify-start text-sm",
highlight ? "text-emphasis" : "text-text",
className
)}>
{typeof Icon === "string" ? (
<img
src={Icon}
alt=""
// @TODO: Use SVG's instead of images, so we can get rid of the filter.
className="mr-2 mt-[2px] h-4 w-4 flex-shrink-0 [filter:invert(0.5)_brightness(0.5)] dark:[filter:invert(1)_brightness(0.9)]"
/>
) : (
<Icon className="relative z-20 mr-2 mt-[2px] h-4 w-4 flex-shrink-0" />
)}
<div className={classNames("relative z-10", contentClassName)}>{children}</div>
</div>
);
};
/**
* Component that renders event meta data in a structured way, with icons and labels.
* The component can be configured to show only specific blocks by overriding the
* `blocks` prop. The blocks prop takes in an array of block names, defined
* in the `EventDetailBlocks` enum. See the `defaultEventDetailsBlocks` const
* for the default order in which the blocks will be rendered.
*
* As part of the blocks array you can also decide to render a custom React Component,
* which will then also be rendered.
*
* Example:
* const MyCustomBlock = () => <div>Something nice</div>;
* <EventDetails event={event} blocks={[EventDetailBlocks.LOCATION, MyCustomBlock]} />
*/
export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: EventDetailsProps) => {
const { t } = useLocale();
return (
<>
{blocks.map((block) => {
if (typeof block === "function") {
return <Fragment key={block.name}>{block(event)}</Fragment>;
}
switch (block) {
case EventDetailBlocks.DESCRIPTION:
if (!event.description) return null;
return (
<EventMetaBlock key={block} icon={Info} contentClassName="break-words max-w-full overflow-clip">
<div dangerouslySetInnerHTML={{ __html: event.description }} />
</EventMetaBlock>
);
case EventDetailBlocks.DURATION:
return (
<EventMetaBlock key={block} icon={Clock}>
<EventDuration event={event} />
</EventMetaBlock>
);
case EventDetailBlocks.LOCATION:
if (!event?.locations?.length) return null;
return (
<React.Fragment key={block}>
<EventLocations event={event} />
</React.Fragment>
);
case EventDetailBlocks.REQUIRES_CONFIRMATION:
if (!event.requiresConfirmation) return null;
return (
<EventMetaBlock key={block} icon={CheckSquare}>
{t("requires_confirmation")}
</EventMetaBlock>
);
case EventDetailBlocks.OCCURENCES:
if (!event.requiresConfirmation || !event.recurringEvent) return null;
return (
<EventMetaBlock key={block} icon={RefreshCcw}>
<EventOccurences event={event} />
</EventMetaBlock>
);
case EventDetailBlocks.PRICE:
if (event.price === 0) return null;
return (
<EventMetaBlock key={block} icon={CreditCard}>
<EventPrice event={event} />
</EventMetaBlock>
);
}
})}
</>
);
};

View File

@ -0,0 +1,37 @@
import { useEffect } from "react";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui";
import type { PublicEvent } from "../../types";
export const EventDuration = ({ event }: { event: PublicEvent }) => {
const { t } = useLocale();
const [selectedDuration, setSelectedDuration] = useBookerStore((state) => [
state.selectedDuration,
state.setSelectedDuration,
]);
// Sets initial value of selected duration to the default duration.
useEffect(() => {
// Only store event duration in url if event has multiple durations.
if (!selectedDuration && event.metadata?.multipleDuration) setSelectedDuration(event.length);
}, [selectedDuration, setSelectedDuration, event.length, event.metadata?.multipleDuration]);
if (!event?.metadata?.multipleDuration) return <>{t("multiple_duration_mins", { count: event.length })}</>;
return (
<div className="flex flex-wrap gap-2">
{event.metadata.multipleDuration.map((duration) => (
<Badge
variant="gray"
className={classNames(selectedDuration === duration && "bg-inverted text-inverted")}
size="md"
key={duration}
onClick={() => setSelectedDuration(duration)}>{`${duration} ${t("minute_timeUnit")}`}</Badge>
))}
</div>
);
};

View File

@ -0,0 +1,42 @@
import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs';
import { Examples, Example, Note, Title, VariantsTable, VariantColumn, RowTitles, CustomArgsTable} from '@calcom/storybook/components'
import { Icon } from "@calcom/ui";
import { EventDetails } from './Details';
import { EventTitle } from './Title';
import { EventMembers } from './Members';
import { mockEvent } from './event.mock.ts';
<Meta title="Features/Events/Meta" component={EventDetails} />
<Title title="Event Meta" suffix="Brief" subtitle="Version 2.0 — Last Update: 12 Dec 2022"/>
<Examples title="Combined event meta block">
<div style={{maxWidth: 300}}>
<Example title="Event Title">
<EventTitle event={mockEvent}/>
</Example>
<Example title="Event Details">
<EventDetails event={mockEvent}/>
</Example>
</div>
</Examples>
<Canvas>
<Story name="All variants">
<VariantsTable titles={['Event Meta Components']} columnMinWidth={150}>
<VariantRow variant="">
<div style={{maxWidth: 300}}>
<EventMembers users={
[
{name: "Pro example", username: "pro"},
{name: "Team example", username: "team"}
]
} />
<EventTitle>Quick catch-up</EventTitle>
<EventDetails event={mockEvent} />
</div>
</VariantRow>
</VariantsTable>
</Story>
</Canvas>

View File

@ -0,0 +1,42 @@
import { getEventLocationType } from "@calcom/app-store/locations";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Tooltip } from "@calcom/ui";
import { MapPin } from "@calcom/ui/components/icon";
import type { PublicEvent } from "../../types";
import { EventMetaBlock } from "./Details";
export const EventLocations = ({ event }: { event: PublicEvent }) => {
const { t } = useLocale();
const locations = event.locations;
if (!locations?.length) return null;
return (
<EventMetaBlock icon={MapPin}>
{locations.length === 1 && (
<div key={locations[0].type}>{t(getEventLocationType(locations[0].type)?.label ?? "")}</div>
)}
{locations.length > 1 && (
<div
key={locations[0].type}
className="before:bg-subtle relative before:pointer-events-none before:absolute before:inset-0 before:left-[-30px] before:top-[-5px] before:bottom-[-5px] before:w-[calc(100%_+_35px)] before:rounded-md before:py-3 before:opacity-0 before:transition-opacity hover:before:opacity-100">
<Tooltip
content={
<>
<p className="mb-2">{t("select_on_next_step")}</p>
<ul className="list-disc pl-3">
{locations.map((location) => (
<li key={location.type}>
<span>{t(getEventLocationType(location.type)?.label ?? "")}</span>
</li>
))}
</ul>
</>
}>
<span className="relative z-[2] py-2">{t("num_locations", { num: locations.length })}</span>
</Tooltip>
</div>
)}
</EventMetaBlock>
);
};

View File

@ -0,0 +1,42 @@
import { CAL_URL } from "@calcom/lib/constants";
import { AvatarGroup } from "@calcom/ui";
import type { PublicEvent } from "../../types";
import { SchedulingType } from ".prisma/client";
export interface EventMembersProps {
/**
* Used to determine whether all members should be shown or not.
* In case of Round Robin type, members aren't shown.
*/
schedulingType: PublicEvent["schedulingType"];
users: PublicEvent["users"];
profile: PublicEvent["profile"];
}
export const EventMembers = ({ schedulingType, users, profile }: EventMembersProps) => {
const showMembers = schedulingType !== SchedulingType.ROUND_ROBIN;
const shownUsers = showMembers ? [...users, profile] : [profile];
const avatars = shownUsers
.map((user) => ({
title: `${user.name}`,
image: "image" in user ? `${user.image}` : `${CAL_URL}/${user.username}/avatar.png`,
alt: user.name || undefined,
href: user.username ? `${CAL_URL}/${user.username}` : undefined,
}))
.filter((item) => !!item.image)
.filter((item, index, self) => self.findIndex((t) => t.image === item.image) === index);
return (
<>
<AvatarGroup size="sm" className="border-muted" items={avatars} />
<p className="text-subtle text-sm">
{users
.map((user) => user.name)
.filter((name) => name)
.join(", ")}
</p>
</>
);
};

View File

@ -0,0 +1,42 @@
import { useEffect } from "react";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
import { Input } from "@calcom/ui";
import type { PublicEvent } from "../../types";
export const EventOccurences = ({ event }: { event: PublicEvent }) => {
const { t } = useLocale();
const [setRecurringEventCount, recurringEventCount] = useBookerStore((state) => [
state.setRecurringEventCount,
state.recurringEventCount,
]);
// Set initial value in booker store.
useEffect(() => {
if (!event.recurringEvent?.count) return;
setRecurringEventCount(event.recurringEvent.count);
}, [setRecurringEventCount, event.recurringEvent]);
if (!event.recurringEvent) return null;
return (
<>
{getRecurringFreq({ t, recurringEvent: event.recurringEvent })}
<br />
<Input
className="my-1 mr-3 inline-flex h-[26px] w-[46px] py-0 px-1"
type="number"
defaultValue={event.recurringEvent.count}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
}}
/>
{t("occurrence", {
count: recurringEventCount || event.recurringEvent.count,
})}
</>
);
};

View File

@ -0,0 +1,18 @@
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import type { PublicEvent } from "../../types";
export const EventPrice = ({ event }: { event: PublicEvent }) => {
const stripeAppData = getPaymentAppData(event);
if (stripeAppData.price === 0) return null;
return (
<>
{Intl.NumberFormat("en", {
style: "currency",
currency: stripeAppData.currency.toUpperCase(),
}).format(stripeAppData.price / 100.0)}
</>
);
};

View File

@ -0,0 +1,19 @@
import classNames from "@calcom/lib/classNames";
import { SkeletonText } from "@calcom/ui";
export const EventMetaSkeleton = () => (
<div className="flex flex-col">
<SkeletonText className="h-6 w-6 rounded-full" />
<SkeletonText className="mt-2 h-5 w-32" />
<SkeletonText className="mt-2 h-8 w-48" />
<div className="mt-8">
{Array.from({ length: 4 }).map((_, i) => (
<div className="mb-2 flex flex-row items-center" key={i}>
<SkeletonText className="mr-3 h-5 w-5 rounded-full" />
<SkeletonText className={classNames("h-6", i > 1 ? "w-24" : "w-32")} />
</div>
))}
</div>
</div>
);

View File

@ -0,0 +1,15 @@
import classNames from "@calcom/lib/classNames";
interface EventTitleProps {
children: React.ReactNode;
/**
* Option to override the default h1 tag.
*/
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span";
className?: string;
}
export const EventTitle = ({ children, as, className }: EventTitleProps) => {
const El = as || "h1";
return <El className={classNames("text-text text-xl font-semibold", className)}>{children}</El>;
};

View File

@ -0,0 +1,14 @@
import { RouterOutputs } from "@calcom/trpc/react";
export const mockEvent: RouterOutputs["viewer"]["public"]["event"] = {
id: 1,
title: "Quick check-in",
slug: "quick-check-in",
eventName: "Quick check-in",
description:
"Use this event for a quick 15 minute catchup. Visit this long url to test the component https://cal.com/averylongurlwithoutspacesthatshouldntbreaklayout",
users: [{ name: "Pro Example", username: "pro" }],
schedulingType: null,
length: 30,
locations: [{ type: "integrations:google:meet" }, { type: "integrations:zoom" }],
};

View File

@ -0,0 +1,4 @@
export { EventDetails, EventMetaBlock } from "./Details";
export { EventTitle } from "./Title";
export { EventMetaSkeleton } from "./Skeleton";
export { EventMembers } from "./Members";

View File

@ -0,0 +1,8 @@
export {
EventDetails,
EventMembers,
EventMetaBlock,
EventMetaSkeleton,
EventTitle,
} from "./components/event-meta";
export { AvailableTimes, AvailableTimesSkeleton } from "./components/AvailableTimes";

View File

@ -0,0 +1,83 @@
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { parseRecurringDates } from "@calcom/lib/parse-dates";
import type { PublicEvent, BookingCreateBody, RecurringBookingCreateBody } from "../../types";
type BookingOptions = {
values: Record<string, unknown>;
event: PublicEvent;
date: string;
// @NOTE: duration is not validated in this function
duration: number | undefined | null;
timeZone: string;
language: string;
rescheduleUid: string | undefined;
username: string;
metadata?: Record<string, string>;
};
export const mapBookingToMutationInput = ({
values,
event,
date,
duration,
timeZone,
language,
rescheduleUid,
username,
metadata,
}: BookingOptions): BookingCreateBody => {
return {
...values,
user: username,
start: dayjs(date).format(),
end: dayjs(date)
// Defaults to the default event length in case no custom duration is set.
.add(duration || event.length, "minute")
.format(),
eventTypeId: event.id,
eventTypeSlug: event.slug,
timeZone: timeZone,
language: language,
rescheduleUid,
metadata: metadata || {},
hasHashedBookingLink: false,
// hasHashedBookingLink,
// hashedLink,
};
};
// This method is here to ensure that the types are correct (recurring count is required),
// as well as generate a unique ID for the recurring bookings and turn one single booking
// into an array of mutiple bookings based on the recurring count.
// Other than that it forwards the mapping to mapBookingToMutationInput.
export const mapRecurringBookingToMutationInput = (
booking: BookingOptions,
recurringCount: number
): RecurringBookingCreateBody[] => {
const recurringEventId = uuidv4();
const [, recurringDates] = parseRecurringDates(
{
startDate: booking.date,
timeZone: booking.timeZone,
recurringEvent: booking.event.recurringEvent,
recurringCount,
withDefaultTimeFormat: true,
},
booking.language
);
const input = mapBookingToMutationInput(booking);
return recurringDates.map((recurringDate) => ({
...input,
start: dayjs(recurringDate).format(),
end: dayjs(recurringDate)
.add(booking.duration || booking.event.length, "minute")
.format(),
recurringEventId,
recurringCount: recurringDates.length,
}));
};

View File

@ -0,0 +1,8 @@
import { post } from "@calcom/lib/fetch-wrapper";
import type { BookingCreateBody, BookingResponse } from "../types";
export const createBooking = async (data: BookingCreateBody) => {
const response = await post<BookingCreateBody, BookingResponse>("/api/book/event", data);
return response;
};

View File

@ -1,18 +1,10 @@
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
import * as fetch from "@calcom/lib/fetch-wrapper";
import type { AppsStatus } from "@calcom/types/Calendar";
import * as fetch from "@lib/core/http/fetch-wrapper";
import type { BookingResponse } from "@lib/types/booking";
import type { RecurringBookingCreateBody, BookingResponse } from "../types";
type ExtendedBookingCreateBody = BookingCreateBody & {
noEmail?: boolean;
recurringCount?: number;
appsStatus?: AppsStatus[] | undefined;
allRecurringDates?: string[];
currentRecurringIndex?: number;
};
const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
export const createRecurringBooking = async (data: RecurringBookingCreateBody[]) => {
const createdBookings: BookingResponse[] = [];
const allRecurringDates: string[] = data.map((booking) => booking.start);
let appsStatus: AppsStatus[] | undefined = undefined;
@ -35,7 +27,7 @@ const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
appsStatus = Object.values(calcAppsStatus);
}
const response = await fetch.post<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", {
const response = await fetch.post<RecurringBookingCreateBody, BookingResponse>("/api/book/event", {
...booking,
appsStatus,
allRecurringDates,
@ -46,5 +38,3 @@ const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
}
return createdBookings;
};
export default createRecurringBooking;

View File

@ -0,0 +1,163 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import type { z } from "zod";
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
type BookingSelect = {
description: true;
customInputs: true;
attendees: {
select: {
email: true;
name: true;
};
};
location: true;
};
// Backward Compatibility for booking created before we had managed booking questions
function getResponsesFromOldBooking(
rawBooking: Prisma.BookingGetPayload<{
select: BookingSelect;
}>
) {
const customInputs = rawBooking.customInputs || {};
const responses = Object.keys(customInputs).reduce((acc, label) => {
acc[slugify(label) as keyof typeof acc] = customInputs[label as keyof typeof customInputs];
return acc;
}, {});
return {
// It is possible to have no attendees in a booking when the booking is cancelled.
name: rawBooking.attendees[0]?.name || "Nameless",
email: rawBooking.attendees[0]?.email || "",
guests: rawBooking.attendees.slice(1).map((attendee) => {
return attendee.email;
}),
notes: rawBooking.description || "",
location: {
value: rawBooking.location || "",
optionValue: rawBooking.location || "",
},
...responses,
};
}
async function getBooking(prisma: PrismaClient, uid: string) {
const rawBooking = await prisma.booking.findFirst({
where: {
uid,
},
select: {
id: true,
uid: true,
startTime: true,
description: true,
customInputs: true,
responses: true,
smsReminderNumber: true,
location: true,
attendees: {
select: {
email: true,
name: true,
bookingSeat: true,
},
},
user: {
select: {
id: true,
},
},
},
});
if (!rawBooking) {
return rawBooking;
}
const booking = getBookingWithResponses(rawBooking);
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
booking["startTime"] = (booking?.startTime as Date)?.toISOString() as unknown as Date;
}
return booking;
}
export type GetBookingType = Prisma.PromiseReturnType<typeof getBooking>;
export const getBookingWithResponses = <
T extends Prisma.BookingGetPayload<{
select: BookingSelect & {
responses: true;
};
}>
>(
booking: T
) => {
return {
...booking,
responses: bookingResponsesDbSchema.parse(booking.responses || getResponsesFromOldBooking(booking)),
} as Omit<T, "responses"> & { responses: z.infer<typeof bookingResponsesDbSchema> };
};
export default getBooking;
export const getBookingByUidOrRescheduleUid = async (uid: string) => {
let eventTypeId: number | null = null;
let rescheduleUid: string | null = null;
eventTypeId =
(
await prisma.booking.findFirst({
where: {
uid,
},
select: {
eventTypeId: true,
},
})
)?.eventTypeId || null;
// If no booking is found via the uid, it's probably a booking seat,
// which we query next.
let attendeeEmail: string | null = null;
if (!eventTypeId) {
const bookingSeat = await prisma.bookingSeat.findFirst({
where: {
referenceUid: uid,
},
select: {
id: true,
attendee: true,
booking: {
select: {
uid: true,
},
},
},
});
if (bookingSeat) {
rescheduleUid = bookingSeat.booking.uid;
attendeeEmail = bookingSeat.attendee.email;
}
}
// If we don't have a booking and no rescheduleUid, the ID is invalid,
// and we return null here.
if (!eventTypeId && !rescheduleUid) return null;
const booking = await getBooking(prisma, rescheduleUid || uid);
if (!booking) return null;
return {
...booking,
attendees: rescheduleUid
? booking.attendees.filter((attendee) => attendee.email === attendeeEmail)
: booking.attendees,
};
};

View File

@ -0,0 +1,7 @@
export { useTimePreferences, timePreferencesStore } from "./timePreferences";
export {
mapBookingToMutationInput,
mapRecurringBookingToMutationInput,
} from "./book-event-form/booking-to-mutation-input-mapper";
export { createBooking } from "./create-booking";
export { createRecurringBooking } from "./create-recurring-booking";

View File

@ -0,0 +1,34 @@
import { create } from "zustand";
import dayjs from "@calcom/dayjs";
import { TimeFormat, detectBrowserTimeFormat, setIs24hClockInLocalStorage } from "@calcom/lib/timeFormat";
import { localStorage } from "@calcom/lib/webstorage";
type TimePreferencesStore = {
timeFormat: TimeFormat.TWELVE_HOUR | TimeFormat.TWENTY_FOUR_HOUR;
setTimeFormat: (format: TimeFormat.TWELVE_HOUR | TimeFormat.TWENTY_FOUR_HOUR) => void;
timezone: string;
setTimezone: (timeZone: string) => void;
};
const timezoneLocalStorageKey = "timeOption.preferredTimeZone";
/**
* This hook is NOT inside the user feature, since
* these settings only apply to the booker component. They will not reflect
* any changes made in the user settings.
*/
export const timePreferencesStore = create<TimePreferencesStore>((set) => ({
timeFormat: detectBrowserTimeFormat,
setTimeFormat: (format: TimeFormat.TWELVE_HOUR | TimeFormat.TWENTY_FOUR_HOUR) => {
setIs24hClockInLocalStorage(format === TimeFormat.TWENTY_FOUR_HOUR);
set({ timeFormat: format });
},
timezone: localStorage.getItem(timezoneLocalStorageKey) || dayjs.tz.guess(),
setTimezone: (timezone: string) => {
localStorage.setItem(timezoneLocalStorageKey, timezone);
set({ timezone });
},
}));
export const useTimePreferences = timePreferencesStore;

View File

@ -0,0 +1,33 @@
import type { ErrorOption, FieldPath } from "react-hook-form";
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { AppsStatus } from "@calcom/types/Calendar";
export type PublicEvent = NonNullable<RouterOutputs["viewer"]["public"]["event"]>;
export type ValidationErrors<T extends object> = { key: FieldPath<T>; error: ErrorOption }[];
export enum EventDetailBlocks {
DESCRIPTION,
// Includes duration select when event has multiple durations.
DURATION,
LOCATION,
REQUIRES_CONFIRMATION,
// Includes input to select # of occurences.
OCCURENCES,
PRICE,
}
export type { BookingCreateBody };
export type RecurringBookingCreateBody = BookingCreateBody & {
noEmail?: boolean;
recurringCount?: number;
appsStatus?: AppsStatus[] | undefined;
allRecurringDates?: string[];
currentRecurringIndex?: number;
};
export type BookingResponse = Awaited<
ReturnType<typeof import("@calcom/features/bookings/lib/handleNewBooking").default>
>;

View File

@ -1,10 +1,9 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { Props } from "react-select";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar, EmptyScreen, Label, Select } from "@calcom/ui";
import { FiUserPlus, FiX } from "@calcom/ui/components/icon";
import { UserPlus, X } from "@calcom/ui/components/icon";
export type CheckedUserSelectOption = {
avatar: string;
@ -48,14 +47,13 @@ export const CheckedUserSelect = ({
<div className="flex overflow-hidden rounded-md border border-gray-200 bg-white">
<ul className="w-full" data-testid="managed-event-types" ref={animationRef}>
{value.map((option, index) => {
const calLink = `${CAL_URL}/${option.value}`;
return (
<li
key={option.value}
className={`flex py-2 px-3 ${index === value.length - 1 ? "" : "border-b"}`}>
<Avatar size="sm" imageSrc={option.avatar} alt={option.label} />
<p className="my-auto ml-3 text-sm text-gray-900">{option.label}</p>
<FiX
<X
onClick={() => props.onChange(value.filter((item) => item.value !== option.value))}
className="my-auto ml-auto"
/>
@ -68,7 +66,7 @@ export const CheckedUserSelect = ({
) : (
<div className="mt-6">
<EmptyScreen
Icon={FiUserPlus}
Icon={UserPlus}
headline={t("no_assigned_members")}
description={t("start_assigning_members_above")}
/>

View File

@ -0,0 +1,201 @@
import type { User } from "@prisma/client";
import { Prisma } from "@prisma/client";
import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { isRecurringEvent, parseRecurringEvent } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { PrismaClient } from "@calcom/prisma/client";
import {
EventTypeMetaDataSchema,
customInputSchema,
userMetadata as userMetadataSchema,
} from "@calcom/prisma/zod-utils";
const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
title: true,
description: true,
eventName: true,
slug: true,
schedulingType: true,
length: true,
locations: true,
customInputs: true,
disableGuests: true,
// @TODO: Could this contain sensitive data?
metadata: true,
requiresConfirmation: true,
recurringEvent: true,
price: true,
currency: true,
seatsPerTimeSlot: true,
bookingFields: true,
team: true,
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
hosts: {
select: {
user: {
select: {
username: true,
name: true,
weekStart: true,
brandColor: true,
darkBrandColor: true,
},
},
},
},
owner: true,
});
export const getPublicEvent = async (username: string, eventSlug: string, prisma: PrismaClient) => {
const usernameList = username.split("+");
// In case of dynamic group event, we fetch user's data and use the default event.
if (usernameList.length > 1) {
const users = await prisma.user.findMany({
where: {
username: {
in: usernameList,
},
},
select: {
username: true,
name: true,
weekStart: true,
metadata: true,
brandColor: true,
darkBrandColor: true,
},
});
const defaultEvent = getDefaultEvent(eventSlug);
let locations = defaultEvent.locations ? (defaultEvent.locations as LocationObject[]) : [];
// Get the prefered location type from the first user
const firstUsersMetadata = userMetadataSchema.parse(users[0].metadata || {});
const preferedLocationType = firstUsersMetadata?.defaultConferencingApp;
if (preferedLocationType?.appSlug) {
const foundApp = getAppFromSlug(preferedLocationType.appSlug);
const appType = foundApp?.appData?.location?.type;
if (appType) {
// Replace the location with the prefered location type
// This will still be default to daily if the app is not found
locations = [{ type: appType, link: preferedLocationType.appLink }] as LocationObject[];
}
}
return {
...defaultEvent,
bookingFields: getBookingFieldsWithSystemFields(defaultEvent),
// Clears meta data since we don't want to send this in the public api.
users: users.map((user) => ({ ...user, metadata: undefined })),
locations: privacyFilteredLocations(locations),
profile: {
username: users[0].username,
name: users[0].name,
weekStart: users[0].weekStart,
image: `${WEBAPP_URL}/${users[0].username}/avatar.png`,
brandColor: users[0].brandColor,
darkBrandColor: users[0].darkBrandColor,
},
};
}
// In case it's not a group event, it's either a single user or a team, and we query that data.
const event = await prisma.eventType.findFirst({
where: {
slug: eventSlug,
OR: [
{
users: {
some: {
username,
},
},
},
{
team: {
slug: username,
},
},
],
},
select: publicEventSelect,
});
if (!event) return null;
return {
...event,
description: markdownToSafeHTML(event.description),
metadata: EventTypeMetaDataSchema.parse(event.metadata || {}),
customInputs: customInputSchema.array().parse(event.customInputs || []),
locations: privacyFilteredLocations((event.locations || []) as LocationObject[]),
bookingFields: getBookingFieldsWithSystemFields(event),
recurringEvent: isRecurringEvent(event.recurringEvent) ? parseRecurringEvent(event.recurringEvent) : null,
// Sets user data on profile object for easier access
profile: getProfileFromEvent(event),
users: getUsersFromEvent(event),
};
};
const eventData = Prisma.validator<Prisma.EventTypeArgs>()({
select: publicEventSelect,
});
type Event = Prisma.EventTypeGetPayload<typeof eventData>;
function getProfileFromEvent(event: Event) {
const { team, hosts, owner } = event;
const profile = team || hosts?.[0]?.user || owner;
if (!profile) throw new Error("Event has no owner");
const username = "username" in profile ? profile.username : team?.slug;
if (!username) throw new Error("Event has no username/team slug");
const weekStart = hosts?.[0]?.user?.weekStart || owner?.weekStart || "Monday";
const basePath = team ? `/team/${username}` : `/${username}`;
return {
username,
name: profile.name,
weekStart,
image: `${WEBAPP_URL}${basePath}/avatar.png`,
brandColor: profile.brandColor,
darkBrandColor: profile.darkBrandColor,
};
}
function getUsersFromEvent(event: Event) {
const { team, hosts, owner } = event;
if (team) {
return (hosts || []).map(mapHostsToUsers);
}
if (!owner) throw new Error("Event has no owner");
const { username, name, weekStart } = owner;
return [{ username, name, weekStart }];
}
function mapHostsToUsers(host: { user: Pick<User, "username" | "name" | "weekStart"> }) {
return {
username: host.user.username,
name: host.user.name,
weekStart: host.user.weekStart,
};
}

View File

@ -12,7 +12,13 @@
"@calcom/ui": "*",
"@lexical/react": "^0.5.0",
"dompurify": "^2.4.1",
"framer-motion": "^10.12.3",
"lexical": "^0.5.0",
"zustand": "^4.1.4"
"react-sticky-box": "^2.0.4",
"zustand": "^4.3.2"
},
"devDependencies": {
"@testing-library/react-hooks": "^8.0.1",
"mockdate": "^3.0.5"
}
}

View File

@ -1 +1,3 @@
export * from "./components";
export type { Slots } from "./lib/use-schedule";
export { useSchedule, useSlotsForDate, useNonEmptyScheduleDays } from "./lib/use-schedule";

View File

@ -0,0 +1,4 @@
export { useSchedule } from "./useSchedule";
export { useSlotsForDate } from "./useSlotsForDate";
export { useNonEmptyScheduleDays } from "./useNonEmptyScheduleDays";
export type { Slots } from "./types";

View File

@ -0,0 +1,3 @@
import { RouterOutputs } from "@calcom/trpc/react";
export type Slots = RouterOutputs["viewer"]["public"]["slots"]["getSchedule"]["slots"];

View File

@ -0,0 +1,14 @@
import { useMemo } from "react";
import type { Slots } from "../use-schedule";
export const getNonEmptyScheduleDays = (slots?: Slots) => {
if (typeof slots === "undefined") return [];
return Object.keys(slots).filter((day) => slots[day].length > 0);
};
export const useNonEmptyScheduleDays = (slots?: Slots) => {
const days = useMemo(() => getNonEmptyScheduleDays(slots), [slots]);
return days;
};

View File

@ -0,0 +1,49 @@
import dayjs from "@calcom/dayjs";
import { trpc } from "@calcom/trpc/react";
type UseScheduleWithCacheArgs = {
username?: string | null;
eventSlug?: string | null;
eventId?: number | null;
month?: string | null;
timezone?: string | null;
prefetchNextMonth?: boolean;
};
export const useSchedule = ({
month,
timezone,
username,
eventSlug,
eventId,
prefetchNextMonth,
}: UseScheduleWithCacheArgs) => {
const monthDayjs = month ? dayjs(month) : dayjs();
const nextMonthDayjs = monthDayjs.add(1, "month");
// Why the non-null assertions? All of these arguments are checked in the enabled condition,
// and the query will not run if they are null. However, the check in `enabled` does
// no satisfy typscript.
return trpc.viewer.public.slots.getSchedule.useQuery(
{
usernameList: username && username.indexOf("+") > -1 ? username.split("+") : [username!],
eventTypeSlug: eventSlug!,
// @TODO: Old code fetched 2 days ago if we were fetching the current month.
// Do we want / need to keep that behavior?
startTime: monthDayjs.startOf("month").toISOString(),
// if `prefetchNextMonth` is true, two months are fetched at once.
endTime: (prefetchNextMonth ? nextMonthDayjs : monthDayjs).endOf("month").toISOString(),
timeZone: timezone!,
eventTypeId: eventId!,
},
{
refetchOnWindowFocus: false,
enabled:
Boolean(username) &&
Boolean(eventSlug) &&
(Boolean(eventId) || eventId === 0) &&
Boolean(month) &&
Boolean(timezone),
}
);
};

View File

@ -0,0 +1,31 @@
import { useMemo } from "react";
import type { Slots } from "./types";
/**
* Get's slots for a specific date from the schedul cache.
* @param date Format YYYY-MM-DD
* @param scheduleCache Instance of useScheduleWithCache
*/
export const useSlotsForDate = (date: string | null, slots?: Slots) => {
const slotsForDate = useMemo(() => {
if (!date || typeof slots === "undefined") return [];
return slots[date] || [];
}, [date, slots]);
return slotsForDate;
};
export const useSlotsForMultipleDates = (dates: (string | null)[], slots?: Slots) => {
const slotsForDates = useMemo(() => {
if (typeof slots === "undefined") return [];
return dates
.filter((date) => date !== null)
.map((date) => ({
slots: slots[`${date}`] || [],
date,
}));
}, [dates, slots]);
return slotsForDates;
};

View File

@ -5,7 +5,8 @@
"paths": {
"~/*": ["/*"]
},
"resolveJsonModule": true
"resolveJsonModule": true,
"esModuleInterop": true
},
"include": [".", "../types/next-auth.d.ts"],
"exclude": ["dist", "build", "node_modules"]

1
packages/lib/array.ts Normal file
View File

@ -0,0 +1 @@
export const notUndefined = <T>(val: T | undefined): val is T => Boolean(val);

View File

@ -130,6 +130,21 @@ export const isNextDayInTimezone = (time: string, timezoneA: string, timezoneB:
return hoursTimezoneBIsEarlier && timezoneBIsLaterTimezone;
};
const weekDays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
type WeekDays = (typeof weekDays)[number];
type WeekDayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
/**
* Turns weekday string (eg "Monday") into a number (eg 1).
* Also accepts a number as parameter (and straight returns that), and accepts
* undefined as a parameter; returns 0 in that case.
*/
export const weekdayToWeekIndex = (weekday: WeekDays | string | number | undefined) => {
if (typeof weekday === "undefined") return 0;
if (typeof weekday === "number") return weekday >= 0 && weekday >= 6 ? (weekday as WeekDayIndex) : 0;
return (weekDays.indexOf(weekday as WeekDays) as WeekDayIndex) || 0;
};
/**
* Dayjs does not expose the timeZone value publicly through .get("timeZone")
* instead, we as devs are required to somewhat hack our way to get the

View File

@ -1,4 +1,4 @@
import { HttpError } from "@lib/core/http/error";
import { HttpError } from "./http-error";
async function http<T>(path: string, config: RequestInit): Promise<T> {
const request = new Request(path, config);

View File

@ -1,24 +1,28 @@
import type { I18n } from "next-i18next";
import { RRule } from "rrule";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
import { detectBrowserTimeFormat, TimeFormat } from "@calcom/lib/timeFormat";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { parseZone } from "./parseZone";
import { parseZone } from "./parse-zone";
const processDate = (date: string | null | Dayjs, i18n: I18n, selectedTimeFormat?: TimeFormat) => {
type ExtraOptions = { withDefaultTimeFormat?: boolean; selectedTimeFormat?: TimeFormat };
const processDate = (date: string | null | Dayjs, language: string, options?: ExtraOptions) => {
const parsedZone = parseZone(date);
if (!parsedZone?.isValid()) return "Invalid date";
const formattedTime = parsedZone?.format(selectedTimeFormat || detectBrowserTimeFormat);
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
const formattedTime = parsedZone?.format(
options?.withDefaultTimeFormat
? TimeFormat.TWELVE_HOUR
: options?.selectedTimeFormat || detectBrowserTimeFormat
);
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(language, { dateStyle: "full" });
};
export const parseDate = (date: string | null | Dayjs, i18n: I18n, selectedTimeFormat?: TimeFormat) => {
export const parseDate = (date: string | null | Dayjs, language: string, options?: ExtraOptions) => {
if (!date) return ["No date"];
return processDate(date, i18n, selectedTimeFormat);
return processDate(date, language, options);
};
export const parseRecurringDates = (
@ -28,14 +32,16 @@ export const parseRecurringDates = (
recurringEvent,
recurringCount,
selectedTimeFormat,
withDefaultTimeFormat,
}: {
startDate: string | null | Dayjs;
timeZone?: string;
recurringEvent: RecurringEvent | null;
recurringCount: number;
selectedTimeFormat?: TimeFormat;
withDefaultTimeFormat?: boolean;
},
i18n: I18n
language: string
): [string[], Date[]] => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { count, ...restRecurringEvent } = recurringEvent || {};
@ -53,7 +59,7 @@ export const parseRecurringDates = (
});
const dateStrings = times.map((t) => {
// finally; show in local timeZone again
return processDate(t.tz(timeZone), i18n, selectedTimeFormat);
return processDate(t.tz(timeZone), language, { selectedTimeFormat, withDefaultTimeFormat });
});
return [dateStrings, times.map((t) => t.toDate())];

Some files were not shown because too many files have changed in this diff Show More