Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f942e1a13 | ||
|
|
38f389bce9 | ||
|
|
39c3ef0d94 | ||
|
|
0f92afb4bd | ||
|
|
6d0bcb709d | ||
|
|
ff3a9d03a4 | ||
|
|
303073822c | ||
|
|
0cfbf7159e | ||
|
|
150dc3acb9 | ||
|
|
778485b31d | ||
|
|
b4c6388ce0 | ||
|
|
f9af15175d | ||
|
|
84eda78c2c | ||
|
|
2faf24fb98 | ||
|
|
1456e2d4d5 | ||
|
|
a53ea33168 | ||
|
|
6c8c3de87c | ||
|
|
b4ab710b35 | ||
|
|
b5cf4e50a9 | ||
|
|
d8f6400add | ||
|
|
018b0e0543 | ||
|
|
a5993061f6 | ||
|
|
0b310c0ea8 | ||
|
|
da62984b5c | ||
|
|
16d73fab89 | ||
|
|
873a4e2000 | ||
|
|
d0d37ba34f | ||
|
|
76d476441f | ||
|
|
9857b46121 | ||
|
|
a35750734c | ||
|
|
5b7224e47e | ||
|
|
f3623096e4 | ||
|
|
88d438f227 | ||
|
|
8a8d284113 | ||
|
|
b4315b186a |
No files matched your search
@@ -161,3 +161,48 @@ If you get errors, be sure to fix them before committing.
|
|||||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||||
- Be sure to fill the PR Template accordingly.
|
- Be sure to fill the PR Template accordingly.
|
||||||
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
|
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
|
||||||
|
|
||||||
|
## Guidelines for committing yarn lockfile
|
||||||
|
|
||||||
|
Do not commit your `yarn.lock` unless you've made changes to the `package.json`. If you've already committed `yarn.lock` unintentionally, follow these steps to undo:
|
||||||
|
|
||||||
|
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
|
||||||
|
```bash
|
||||||
|
git checkout HEAD~1 yarn.lock
|
||||||
|
git commit -m "Revert yarn.lock changes"
|
||||||
|
```
|
||||||
|
If you've pushed the commit with the `yarn.lock`:
|
||||||
|
1. Correct the commit locally using the above method.
|
||||||
|
2. Carefully force push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin <your-branch-name> --force
|
||||||
|
```
|
||||||
|
|
||||||
|
If `yarn.lock` was committed a while ago and there have been several commits since, you can use the following steps to revert just the `yarn.lock` changes without impacting the subsequent changes:
|
||||||
|
|
||||||
|
1. **Checkout a Previous Version**:
|
||||||
|
- Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
|
||||||
|
```bash
|
||||||
|
git log yarn.lock
|
||||||
|
```
|
||||||
|
- Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`:
|
||||||
|
```bash
|
||||||
|
git checkout <commit_hash> yarn.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Commit the Reverted Version**:
|
||||||
|
- After checking out the previous version of the `yarn.lock`, commit this change:
|
||||||
|
```bash
|
||||||
|
git commit -m "Revert yarn.lock to its state before unintended changes"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Proceed with Caution**:
|
||||||
|
- If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes:
|
||||||
|
```bash
|
||||||
|
git pull origin <your-branch-name>
|
||||||
|
```
|
||||||
|
- Then push the updated branch:
|
||||||
|
```bash
|
||||||
|
git push origin <your-branch-name>
|
||||||
|
```
|
||||||
@@ -253,6 +253,8 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||||||
|
|
||||||
#### Setting up your first user
|
#### Setting up your first user
|
||||||
|
|
||||||
|
##### Approach 1
|
||||||
|
|
||||||
1. Open [Prisma Studio](https://prisma.io/studio) to look at or modify the database content:
|
1. Open [Prisma Studio](https://prisma.io/studio) to look at or modify the database content:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -264,6 +266,17 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||||||
> New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `packages/prisma/schema.prisma` file.
|
> New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `packages/prisma/schema.prisma` file.
|
||||||
1. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
|
1. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
|
||||||
|
|
||||||
|
##### Approach 2
|
||||||
|
|
||||||
|
Seed the local db by running
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd packages/prisma
|
||||||
|
yarn db-seed
|
||||||
|
```
|
||||||
|
|
||||||
|
The above command will populate the local db with dummy users.
|
||||||
|
|
||||||
### E2E-Testing
|
### E2E-Testing
|
||||||
|
|
||||||
Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`.
|
Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const schemaBookingReadPublic = Booking.extend({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
responses: z.record(z.any()),
|
responses: z.record(z.any()).nullable(),
|
||||||
}).pick({
|
}).pick({
|
||||||
id: true,
|
id: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ export async function patchHandler(req: NextApiRequest) {
|
|||||||
hosts = [],
|
hosts = [],
|
||||||
bookingLimits,
|
bookingLimits,
|
||||||
durationLimits,
|
durationLimits,
|
||||||
|
/** FIXME: Updating event-type children from API not supported for now */
|
||||||
|
children: _,
|
||||||
...parsedBody
|
...parsedBody
|
||||||
} = schemaEventTypeEditBodyParams.parse(body);
|
} = schemaEventTypeEditBodyParams.parse(body);
|
||||||
|
|
||||||
|
|||||||
@@ -268,6 +268,8 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
hosts = [],
|
hosts = [],
|
||||||
bookingLimits,
|
bookingLimits,
|
||||||
durationLimits,
|
durationLimits,
|
||||||
|
/** FIXME: Adding event-type children from API not supported for now */
|
||||||
|
children: _,
|
||||||
...parsedBody
|
...parsedBody
|
||||||
} = schemaEventTypeCreateBodyParams.parse(body || {});
|
} = schemaEventTypeCreateBodyParams.parse(body || {});
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { HttpError } from "@calcom/lib/http-error";
|
|||||||
* if the event type doesn't belong to any team,
|
* if the event type doesn't belong to any team,
|
||||||
* or if the user isn't a member of the associated team.
|
* or if the user isn't a member of the associated team.
|
||||||
*/
|
*/
|
||||||
export default async function checkUserMembership(parentId: number, userId: number) {
|
export default async function checkUserMembership(parentId: number, userId?: number) {
|
||||||
const parentEventType = await prisma.eventType.findUnique({
|
const parentEventType = await prisma.eventType.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: parentId,
|
id: parentId,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
import { createContext } from "@calcom/trpc/server/createContext";
|
import { createContext } from "@calcom/trpc/server/createContext";
|
||||||
import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router";
|
import { slotsRouter } from "@calcom/trpc/server/routers/viewer/slots/_router";
|
||||||
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||||
@@ -11,10 +11,10 @@ import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
|||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
/** @see https://trpc.io/docs/server-side-calls */
|
/** @see https://trpc.io/docs/server-side-calls */
|
||||||
const ctx = await createContext({ req, res });
|
const ctx = await createContext({ req, res });
|
||||||
const caller = viewerRouter.createCaller(ctx);
|
const caller = slotsRouter.createCaller(ctx);
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return await caller.slots.getSchedule(req.query as any /* Let tRPC handle this */);
|
return await caller.getSchedule(req.query as any /* Let tRPC handle this */);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (cause instanceof TRPCError) {
|
if (cause instanceof TRPCError) {
|
||||||
const statusCode = getHTTPStatusCodeFromError(cause);
|
const statusCode = getHTTPStatusCodeFromError(cause);
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { lookup } from "bcp-47-match";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
import { CALCOM_VERSION } from "@calcom/lib/constants";
|
import { CALCOM_VERSION } from "@calcom/lib/constants";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
|
||||||
function useViewerI18n(locale: string) {
|
export function useViewerI18n(locale: string) {
|
||||||
return trpc.viewer.public.i18n.useQuery(
|
return trpc.viewer.public.i18n.useQuery(
|
||||||
{ locale, CalComVersion: CALCOM_VERSION },
|
{ locale, CalComVersion: CALCOM_VERSION },
|
||||||
{
|
{
|
||||||
@@ -19,46 +14,3 @@ function useViewerI18n(locale: string) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useClientLocale(locales: string[]) {
|
|
||||||
const session = useSession();
|
|
||||||
// If the user is logged in, use their locale
|
|
||||||
if (session.data?.user.locale) return session.data.user.locale;
|
|
||||||
// If the user is not logged in, use the browser locale
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
// This is the only way I found to ensure the prefetched locale is used on first render
|
|
||||||
// FIXME: Find a better way to pick the best matching locale from the browser
|
|
||||||
return lookup(locales, window.navigator.language) || window.navigator.language;
|
|
||||||
}
|
|
||||||
// If the browser is not available, use English
|
|
||||||
return "en";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useClientViewerI18n(locales: string[]) {
|
|
||||||
const clientLocale = useClientLocale(locales);
|
|
||||||
return useViewerI18n(clientLocale);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-switches locale client-side to the logged in user's preference
|
|
||||||
*/
|
|
||||||
const I18nLanguageHandler = (props: { locales: string[] }) => {
|
|
||||||
const { locales } = props;
|
|
||||||
const { i18n } = useTranslation("common");
|
|
||||||
const locale = useClientViewerI18n(locales).data?.locale || i18n.language;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// bail early when i18n = {}
|
|
||||||
if (Object.keys(i18n).length === 0) return;
|
|
||||||
// if locale is ready and the i18n.language does != locale - changeLanguage
|
|
||||||
if (locale && i18n.language !== locale) {
|
|
||||||
i18n.changeLanguage(locale);
|
|
||||||
}
|
|
||||||
// set dir="rtl|ltr"
|
|
||||||
document.dir = i18n.dir();
|
|
||||||
document.documentElement.setAttribute("lang", locale);
|
|
||||||
}, [locale, i18n]);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default I18nLanguageHandler;
|
|
||||||
@@ -13,8 +13,6 @@ import type { AppProps } from "@lib/app-providers";
|
|||||||
import AppProviders from "@lib/app-providers";
|
import AppProviders from "@lib/app-providers";
|
||||||
import { seoConfig } from "@lib/config/next-seo.config";
|
import { seoConfig } from "@lib/config/next-seo.config";
|
||||||
|
|
||||||
import I18nLanguageHandler from "@components/I18nLanguageHandler";
|
|
||||||
|
|
||||||
export interface CalPageWrapper {
|
export interface CalPageWrapper {
|
||||||
(props?: AppProps): JSX.Element;
|
(props?: AppProps): JSX.Element;
|
||||||
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
||||||
@@ -72,7 +70,6 @@ function PageWrapper(props: AppProps) {
|
|||||||
}
|
}
|
||||||
{...seoConfig.defaultNextSeo}
|
{...seoConfig.defaultNextSeo}
|
||||||
/>
|
/>
|
||||||
<I18nLanguageHandler locales={props.router.locales || []} />
|
|
||||||
<Script
|
<Script
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
id="page-status"
|
id="page-status"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface Props {
|
|||||||
|
|
||||||
export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col justify-center bg-[#f3f4f6] py-12 sm:px-6 lg:px-8">
|
<div className="bg-subtle dark:bg-darkgray-50 flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
<HeadSeo title={props.title} description={props.description} />
|
<HeadSeo title={props.title} description={props.description} />
|
||||||
{props.showLogo && <Logo small inline={false} className="mx-auto mb-auto" />}
|
{props.showLogo && <Logo small inline={false} className="mx-auto mb-auto" />}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mb-auto mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="mb-auto mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<div className="bg-default border-subtle mx-2 rounded-md border px-4 py-10 sm:px-10">
|
<div className="bg-default dark:bg-muted border-subtle mx-2 rounded-md border px-4 py-10 sm:px-10">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-default mt-8 text-center text-sm">{props.footerText}</div>
|
<div className="text-default mt-8 text-center text-sm">{props.footerText}</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { appWithTranslation } from "next-i18next";
|
|||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
|
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
|
||||||
import type { ParsedUrlQuery } from "querystring";
|
import type { ParsedUrlQuery } from "querystring";
|
||||||
import type { ComponentProps, PropsWithChildren, ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
|
|
||||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||||
@@ -17,9 +17,10 @@ import { useFlags } from "@calcom/features/flags/hooks";
|
|||||||
import { MetaProvider } from "@calcom/ui";
|
import { MetaProvider } from "@calcom/ui";
|
||||||
|
|
||||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||||
|
import type { WithLocaleProps } from "@lib/withLocale";
|
||||||
import type { WithNonceProps } from "@lib/withNonce";
|
import type { WithNonceProps } from "@lib/withNonce";
|
||||||
|
|
||||||
import { useClientViewerI18n } from "@components/I18nLanguageHandler";
|
import { useViewerI18n } from "@components/I18nLanguageHandler";
|
||||||
|
|
||||||
const I18nextAdapter = appWithTranslation<
|
const I18nextAdapter = appWithTranslation<
|
||||||
NextJsAppProps<SSRConfig> & {
|
NextJsAppProps<SSRConfig> & {
|
||||||
@@ -30,10 +31,12 @@ const I18nextAdapter = appWithTranslation<
|
|||||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||||
export type AppProps = Omit<
|
export type AppProps = Omit<
|
||||||
NextAppProps<
|
NextAppProps<
|
||||||
WithNonceProps & {
|
WithLocaleProps<
|
||||||
themeBasis?: string;
|
WithNonceProps<{
|
||||||
session: Session;
|
themeBasis?: string;
|
||||||
} & Record<string, unknown>
|
session: Session;
|
||||||
|
}>
|
||||||
|
>
|
||||||
>,
|
>,
|
||||||
"Component"
|
"Component"
|
||||||
> & {
|
> & {
|
||||||
@@ -68,8 +71,8 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
|
|||||||
/**
|
/**
|
||||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||||
**/
|
**/
|
||||||
const clientViewerI18n = useClientViewerI18n(props.router.locales || []);
|
const clientViewerI18n = useViewerI18n(props.pageProps.newLocale);
|
||||||
const { i18n, locale } = clientViewerI18n.data || {};
|
const i18n = clientViewerI18n.data?.i18n;
|
||||||
|
|
||||||
const passedProps = {
|
const passedProps = {
|
||||||
...props,
|
...props,
|
||||||
@@ -77,8 +80,7 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
|
|||||||
...props.pageProps,
|
...props.pageProps,
|
||||||
...i18n,
|
...i18n,
|
||||||
},
|
},
|
||||||
router: locale ? { locale } : props.router,
|
};
|
||||||
} as unknown as ComponentProps<typeof I18nextAdapter>;
|
|
||||||
|
|
||||||
return <I18nextAdapter {...passedProps} />;
|
return <I18nextAdapter {...passedProps} />;
|
||||||
};
|
};
|
||||||
@@ -233,7 +235,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
|||||||
// No need to have intercom on public pages - Good for Page Performance
|
// No need to have intercom on public pages - Good for Page Performance
|
||||||
const isBookingPage = useIsBookingPage();
|
const isBookingPage = useIsBookingPage();
|
||||||
const { pageProps, ...rest } = props;
|
const { pageProps, ...rest } = props;
|
||||||
const { _nonce, ...restPageProps } = pageProps;
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { nonce, ...restPageProps } = pageProps;
|
||||||
const propsWithoutNonce = {
|
const propsWithoutNonce = {
|
||||||
pageProps: {
|
pageProps: {
|
||||||
...restPageProps,
|
...restPageProps,
|
||||||
@@ -243,8 +247,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
|||||||
|
|
||||||
const RemainingProviders = (
|
const RemainingProviders = (
|
||||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||||
<SessionProvider session={pageProps.session ?? undefined}>
|
<CustomI18nextProvider {...propsWithoutNonce}>
|
||||||
<CustomI18nextProvider {...propsWithoutNonce}>
|
<SessionProvider session={pageProps.session ?? undefined}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||||
<CalcomThemeProvider
|
<CalcomThemeProvider
|
||||||
@@ -260,8 +264,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
|||||||
</FeatureFlagsProvider>
|
</FeatureFlagsProvider>
|
||||||
</CalcomThemeProvider>
|
</CalcomThemeProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</CustomI18nextProvider>
|
</SessionProvider>
|
||||||
</SessionProvider>
|
</CustomI18nextProvider>
|
||||||
</EventCollectionProvider>
|
</EventCollectionProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export type WithLocaleProps<T extends Record<string, unknown>> = T & {
|
||||||
|
newLocale: string;
|
||||||
|
};
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { GetServerSideProps, GetServerSidePropsContext } from "next";
|
import type { GetServerSideProps } from "next";
|
||||||
|
|
||||||
import { csp } from "@lib/csp";
|
import { csp } from "@lib/csp";
|
||||||
|
|
||||||
export type WithNonceProps = {
|
export type WithNonceProps<T extends Record<string, any>> = T & {
|
||||||
nonce?: string;
|
nonce?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,9 +11,16 @@ export type WithNonceProps = {
|
|||||||
* Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages
|
* Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages
|
||||||
* There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag.
|
* There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag.
|
||||||
*/
|
*/
|
||||||
export default function withNonce(getServerSideProps: GetServerSideProps) {
|
export default function withNonce<T extends Record<string, any>>(
|
||||||
return async (context: GetServerSidePropsContext) => {
|
getServerSideProps: GetServerSideProps<T>
|
||||||
|
): GetServerSideProps<WithNonceProps<T>> {
|
||||||
|
return async (context) => {
|
||||||
const ssrResponse = await getServerSideProps(context);
|
const ssrResponse = await getServerSideProps(context);
|
||||||
|
|
||||||
|
if (!("props" in ssrResponse)) {
|
||||||
|
return ssrResponse;
|
||||||
|
}
|
||||||
|
|
||||||
const { nonce } = csp(context.req, context.res);
|
const { nonce } = csp(context.req, context.res);
|
||||||
|
|
||||||
// Skip nonce property if it's not available instead of setting it to undefined because undefined can't be serialized.
|
// Skip nonce property if it's not available instead of setting it to undefined because undefined can't be serialized.
|
||||||
@@ -23,10 +30,6 @@ export default function withNonce(getServerSideProps: GetServerSideProps) {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!("props" in ssrResponse)) {
|
|
||||||
return ssrResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helps in debugging that withNonce was used but a valid nonce couldn't be set
|
// Helps in debugging that withNonce was used but a valid nonce couldn't be set
|
||||||
context.res.setHeader("x-csp", nonce ? "ssr" : "false");
|
context.res.setHeader("x-csp", nonce ? "ssr" : "false");
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "3.3.6",
|
"version": "3.3.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { Booker } from "@calcom/atoms";
|
import { Booker } from "@calcom/atoms";
|
||||||
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +44,7 @@ export default function Type({
|
|||||||
hideBranding={isBrandingHidden}
|
hideBranding={isBrandingHidden}
|
||||||
isSEOIndexable={isSEOIndexable ?? true}
|
isSEOIndexable={isSEOIndexable ?? true}
|
||||||
entity={entity}
|
entity={entity}
|
||||||
|
bookingData={booking}
|
||||||
/>
|
/>
|
||||||
<Booker
|
<Booker
|
||||||
username={user}
|
username={user}
|
||||||
@@ -61,6 +63,7 @@ Type.isBookingPage = true;
|
|||||||
Type.PageWrapper = PageWrapper;
|
Type.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||||
|
const session = await getServerSession(context);
|
||||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||||
|
|
||||||
@@ -96,7 +99,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
let booking: GetBookingType | null = null;
|
let booking: GetBookingType | null = null;
|
||||||
if (rescheduleUid) {
|
if (rescheduleUid) {
|
||||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||||
} else if (bookingUid) {
|
} else if (bookingUid) {
|
||||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||||
}
|
}
|
||||||
@@ -138,6 +141,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||||
|
const session = await getServerSession(context);
|
||||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||||
const username = usernames[0];
|
const username = usernames[0];
|
||||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||||
@@ -168,7 +172,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
let booking: GetBookingType | null = null;
|
let booking: GetBookingType | null = null;
|
||||||
if (rescheduleUid) {
|
if (rescheduleUid) {
|
||||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||||
} else if (bookingUid) {
|
} else if (bookingUid) {
|
||||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-1
@@ -1,5 +1,8 @@
|
|||||||
|
import type { IncomingMessage } from "http";
|
||||||
|
import type { AppContextType } from "next/dist/shared/lib/utils";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
|
||||||
import type { AppProps } from "@lib/app-providers";
|
import type { AppProps } from "@lib/app-providers";
|
||||||
@@ -8,8 +11,35 @@ import "../styles/globals.css";
|
|||||||
|
|
||||||
function MyApp(props: AppProps) {
|
function MyApp(props: AppProps) {
|
||||||
const { Component, pageProps } = props;
|
const { Component, pageProps } = props;
|
||||||
|
|
||||||
if (Component.PageWrapper !== undefined) return Component.PageWrapper(props);
|
if (Component.PageWrapper !== undefined) return Component.PageWrapper(props);
|
||||||
return <Component {...pageProps} />;
|
return <Component {...pageProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default trpc.withTRPC(MyApp);
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
calNewLocale: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MyApp.getInitialProps = async (ctx: AppContextType) => {
|
||||||
|
const { req } = ctx.ctx;
|
||||||
|
|
||||||
|
let newLocale = "en";
|
||||||
|
|
||||||
|
if (req) {
|
||||||
|
newLocale = await getLocale(req as IncomingMessage & { cookies: Record<string, any> });
|
||||||
|
} else if (typeof window !== "undefined" && window.calNewLocale) {
|
||||||
|
newLocale = window.calNewLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageProps: {
|
||||||
|
newLocale,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const WrappedMyApp = trpc.withTRPC(MyApp);
|
||||||
|
|
||||||
|
export default WrappedMyApp;
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
|
import type { IncomingMessage } from "http";
|
||||||
import type { NextPageContext } from "next";
|
import type { NextPageContext } from "next";
|
||||||
import type { DocumentContext, DocumentProps } from "next/document";
|
import type { DocumentContext, DocumentProps } from "next/document";
|
||||||
import Document, { Head, Html, Main, NextScript } from "next/document";
|
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
|
|
||||||
import { csp } from "@lib/csp";
|
import { csp } from "@lib/csp";
|
||||||
|
|
||||||
type Props = Record<string, unknown> & DocumentProps;
|
type Props = Record<string, unknown> & DocumentProps & { newLocale: string };
|
||||||
function setHeader(ctx: NextPageContext, name: string, value: string) {
|
function setHeader(ctx: NextPageContext, name: string, value: string) {
|
||||||
try {
|
try {
|
||||||
ctx.res?.setHeader(name, value);
|
ctx.res?.setHeader(name, value);
|
||||||
@@ -26,6 +28,10 @@ class MyDocument extends Document<Props> {
|
|||||||
setHeader(ctx, "x-csp", "initialPropsOnly");
|
setHeader(ctx, "x-csp", "initialPropsOnly");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newLocale = ctx.req
|
||||||
|
? await getLocale(ctx.req as IncomingMessage & { cookies: Record<string, any> })
|
||||||
|
: "en";
|
||||||
|
|
||||||
const asPath = ctx.asPath || "";
|
const asPath = ctx.asPath || "";
|
||||||
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
|
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
|
||||||
const parsedUrl = new URL(asPath, "https://dummyurl");
|
const parsedUrl = new URL(asPath, "https://dummyurl");
|
||||||
@@ -36,17 +42,36 @@ class MyDocument extends Document<Props> {
|
|||||||
!isEmbedSnippetGeneratorPath;
|
!isEmbedSnippetGeneratorPath;
|
||||||
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
|
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
|
||||||
const initialProps = await Document.getInitialProps(ctx);
|
const initialProps = await Document.getInitialProps(ctx);
|
||||||
return { isEmbed, embedColorScheme, nonce, ...initialProps };
|
return { isEmbed, embedColorScheme, nonce, ...initialProps, newLocale };
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { locale } = this.props.__NEXT_DATA__;
|
|
||||||
const { isEmbed, embedColorScheme } = this.props;
|
const { isEmbed, embedColorScheme } = this.props;
|
||||||
|
const newLocale = this.props.newLocale || "en";
|
||||||
|
|
||||||
const nonceParsed = z.string().safeParse(this.props.nonce);
|
const nonceParsed = z.string().safeParse(this.props.nonce);
|
||||||
const nonce = nonceParsed.success ? nonceParsed.data : "";
|
const nonce = nonceParsed.success ? nonceParsed.data : "";
|
||||||
|
|
||||||
|
const intlLocale = new Intl.Locale(newLocale);
|
||||||
|
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
||||||
|
const direction = intlLocale.textInfo?.direction;
|
||||||
|
if (!direction) {
|
||||||
|
throw new Error("NodeJS major breaking change detected, use getTextInfo() instead.");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html lang={locale} style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
<Html
|
||||||
|
lang={newLocale}
|
||||||
|
dir={direction}
|
||||||
|
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
||||||
<Head nonce={nonce}>
|
<Head nonce={nonce}>
|
||||||
|
<script
|
||||||
|
nonce={nonce}
|
||||||
|
id="newLocale"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `window.calNewLocale = "${newLocale}";`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/api/logo?type=apple-touch-icon" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/api/logo?type=apple-touch-icon" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/api/logo?type=favicon-32" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/api/logo?type=favicon-32" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/api/logo?type=favicon-16" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/api/logo?type=favicon-16" />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import prisma from "@calcom/prisma";
|
|||||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||||
import { TRPCError } from "@calcom/trpc/server";
|
import { TRPCError } from "@calcom/trpc/server";
|
||||||
import { createContext } from "@calcom/trpc/server/createContext";
|
import { createContext } from "@calcom/trpc/server/createContext";
|
||||||
import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router";
|
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
|
||||||
|
|
||||||
enum DirectAction {
|
enum DirectAction {
|
||||||
ACCEPT = "accept",
|
ACCEPT = "accept",
|
||||||
@@ -55,13 +55,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse<Response>) {
|
|||||||
try {
|
try {
|
||||||
/** @see https://trpc.io/docs/server-side-calls */
|
/** @see https://trpc.io/docs/server-side-calls */
|
||||||
const ctx = await createContext({ req, res }, sessionGetter);
|
const ctx = await createContext({ req, res }, sessionGetter);
|
||||||
const caller = viewerRouter.createCaller({
|
const caller = bookingsRouter.createCaller({
|
||||||
...ctx,
|
...ctx,
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
user: { ...user, locale: user?.locale ?? "en" },
|
user: { ...user, locale: user?.locale ?? "en" },
|
||||||
});
|
});
|
||||||
await caller.bookings.confirm({
|
await caller.confirm({
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
recurringEventId: booking.recurringEventId || undefined,
|
recurringEventId: booking.recurringEventId || undefined,
|
||||||
confirmed: action === DirectAction.ACCEPT,
|
confirmed: action === DirectAction.ACCEPT,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { Button, PasswordField, Form } from "@calcom/ui";
|
import { Button, PasswordField, Form } from "@calcom/ui";
|
||||||
@@ -141,7 +142,6 @@ export default function Page({ requestId, isRequestExpired, csrfToken }: Props)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Page.isThemeSupported = false;
|
|
||||||
Page.PageWrapper = PageWrapper;
|
Page.PageWrapper = PageWrapper;
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const id = context.params?.id as string;
|
const id = context.params?.id as string;
|
||||||
@@ -163,12 +163,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
resetPasswordRequest = null;
|
resetPasswordRequest = null;
|
||||||
}
|
}
|
||||||
|
const locale = await getLocale(context.req);
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
isRequestExpired: !resetPasswordRequest,
|
isRequestExpired: !resetPasswordRequest,
|
||||||
requestId: id,
|
requestId: id,
|
||||||
csrfToken: await getCsrfToken({ req: context.req }),
|
csrfToken: await getCsrfToken({ req: context.req }),
|
||||||
...(await serverSideTranslations(context.locale || "en", ["common"])),
|
...(await serverSideTranslations(locale, ["common"])),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import Link from "next/link";
|
|||||||
import type { CSSProperties, SyntheticEvent } from "react";
|
import type { CSSProperties, SyntheticEvent } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button, EmailField } from "@calcom/ui";
|
import { Button, EmailField } from "@calcom/ui";
|
||||||
@@ -126,8 +127,9 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
|||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Button
|
<Button
|
||||||
className="w-full justify-center"
|
className="w-full justify-center dark:bg-white dark:text-black"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
color="primary"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
aria-label={t("request_password_reset")}
|
aria-label={t("request_password_reset")}
|
||||||
loading={loading}>
|
loading={loading}>
|
||||||
@@ -141,7 +143,6 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ForgotPassword.isThemeSupported = false;
|
|
||||||
ForgotPassword.PageWrapper = PageWrapper;
|
ForgotPassword.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
@@ -154,11 +155,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
res.end();
|
res.end();
|
||||||
return { props: {} };
|
return { props: {} };
|
||||||
}
|
}
|
||||||
|
const locale = await getLocale(context.req);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
csrfToken: await getCsrfToken(context),
|
csrfToken: await getCsrfToken(context),
|
||||||
...(await serverSideTranslations(context.locale || "en", ["common"])),
|
...(await serverSideTranslations(locale, ["common"])),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -15,11 +15,12 @@ import { SAMLLogin } from "@calcom/features/auth/SAMLLogin";
|
|||||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL, WEBSITE_URL, HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
||||||
import { ArrowLeft, Lock } from "@calcom/ui/components/icon";
|
import { ArrowLeft, Lock } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
@@ -50,7 +51,8 @@ export default function Login({
|
|||||||
samlTenantID,
|
samlTenantID,
|
||||||
samlProductID,
|
samlProductID,
|
||||||
totpEmail,
|
totpEmail,
|
||||||
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
|
}: // eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -160,6 +162,16 @@ export default function Login({
|
|||||||
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
|
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.viewer.public.ssoConnections.useQuery(undefined, {
|
||||||
|
onError: (err) => {
|
||||||
|
setErrorMessage(err.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const displaySSOLogin = HOSTED_CAL_FEATURES
|
||||||
|
? true
|
||||||
|
: isSAMLLoginEnabled && !isLoading && data?.connectionExists;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={
|
style={
|
||||||
@@ -225,14 +237,14 @@ export default function Login({
|
|||||||
type="submit"
|
type="submit"
|
||||||
color="primary"
|
color="primary"
|
||||||
disabled={formState.isSubmitting}
|
disabled={formState.isSubmitting}
|
||||||
className="w-full justify-center">
|
className="w-full justify-center dark:bg-white dark:text-black">
|
||||||
{twoFactorRequired ? t("submit") : t("sign_in")}
|
{twoFactorRequired ? t("submit") : t("sign_in")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{!twoFactorRequired && (
|
{!twoFactorRequired && (
|
||||||
<>
|
<>
|
||||||
{(isGoogleLoginEnabled || isSAMLLoginEnabled) && <hr className="border-subtle my-8" />}
|
{(isGoogleLoginEnabled || displaySSOLogin) && <hr className="border-subtle my-8" />}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{isGoogleLoginEnabled && (
|
{isGoogleLoginEnabled && (
|
||||||
<Button
|
<Button
|
||||||
@@ -247,7 +259,7 @@ export default function Login({
|
|||||||
{t("signin_with_google")}
|
{t("signin_with_google")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isSAMLLoginEnabled && (
|
{displaySSOLogin && (
|
||||||
<SAMLLogin
|
<SAMLLogin
|
||||||
samlTenantID={samlTenantID}
|
samlTenantID={samlTenantID}
|
||||||
samlProductID={samlProductID}
|
samlProductID={samlProductID}
|
||||||
@@ -337,7 +349,6 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
Login.isThemeSupported = false;
|
|
||||||
Login.PageWrapper = PageWrapper;
|
Login.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
export const getServerSideProps = withNonce(_getServerSideProps);
|
export const getServerSideProps = withNonce(_getServerSideProps);
|
||||||
@@ -57,7 +57,6 @@ export function Logout(props: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Logout.isThemeSupported = false;
|
|
||||||
Logout.PageWrapper = PageWrapper;
|
Logout.PageWrapper = PageWrapper;
|
||||||
export default Logout;
|
export default Logout;
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default function Success(props: SuccessProps) {
|
|||||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||||
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
|
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
|
||||||
|
const { requiresLoginToUpdate } = props;
|
||||||
function setIsCancellationMode(value: boolean) {
|
function setIsCancellationMode(value: boolean) {
|
||||||
const _searchParams = new URLSearchParams(searchParams);
|
const _searchParams = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
@@ -532,7 +532,28 @@ export default function Success(props: SuccessProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(!needsConfirmation || !userIsOwner) &&
|
{requiresLoginToUpdate && (
|
||||||
|
<>
|
||||||
|
<hr className="border-subtle mb-8" />
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-emphasis ltr:mr-2 rtl:ml-2">{t("need_to_make_a_change")}</span>
|
||||||
|
{/* Login button but redirect to here */}
|
||||||
|
<span className="text-default inline">
|
||||||
|
<span className="underline" data-testid="reschedule-link">
|
||||||
|
<Link
|
||||||
|
href={`/auth/login?callbackUrl=${encodeURIComponent(
|
||||||
|
`/booking/${bookingInfo?.uid}`
|
||||||
|
)}`}
|
||||||
|
legacyBehavior>
|
||||||
|
{t("login")}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!requiresLoginToUpdate &&
|
||||||
|
(!needsConfirmation || !userIsOwner) &&
|
||||||
!isCancelled &&
|
!isCancelled &&
|
||||||
(!isCancellationMode ? (
|
(!isCancellationMode ? (
|
||||||
<>
|
<>
|
||||||
@@ -540,28 +561,30 @@ export default function Success(props: SuccessProps) {
|
|||||||
<div className="text-center last:pb-0">
|
<div className="text-center last:pb-0">
|
||||||
<span className="text-emphasis ltr:mr-2 rtl:ml-2">{t("need_to_make_a_change")}</span>
|
<span className="text-emphasis ltr:mr-2 rtl:ml-2">{t("need_to_make_a_change")}</span>
|
||||||
|
|
||||||
{!props.recurringBookings && (
|
<>
|
||||||
<span className="text-default inline">
|
{!props.recurringBookings && (
|
||||||
<span className="underline" data-testid="reschedule-link">
|
<span className="text-default inline">
|
||||||
<Link
|
<span className="underline" data-testid="reschedule-link">
|
||||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
|
<Link
|
||||||
legacyBehavior>
|
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
|
||||||
{t("reschedule")}
|
legacyBehavior>
|
||||||
</Link>
|
{t("reschedule")}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
<span className="mx-2">{t("or_lowercase")}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="mx-2">{t("or_lowercase")}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
data-testid="cancel"
|
|
||||||
className={classNames(
|
|
||||||
"text-default underline",
|
|
||||||
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
|
|
||||||
)}
|
)}
|
||||||
onClick={() => setIsCancellationMode(true)}>
|
|
||||||
{t("cancel")}
|
<button
|
||||||
</button>
|
data-testid="cancel"
|
||||||
|
className={classNames(
|
||||||
|
"text-default underline",
|
||||||
|
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
|
||||||
|
)}
|
||||||
|
onClick={() => setIsCancellationMode(true)}>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -1010,7 +1033,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
const session = await getServerSession(context);
|
const session = await getServerSession(context);
|
||||||
let tz: string | null = null;
|
let tz: string | null = null;
|
||||||
let userTimeFormat: number | null = null;
|
let userTimeFormat: number | null = null;
|
||||||
|
let requiresLoginToUpdate = false;
|
||||||
if (session) {
|
if (session) {
|
||||||
const user = await ssr.viewer.me.fetch();
|
const user = await ssr.viewer.me.fetch();
|
||||||
tz = user.timeZone;
|
tz = user.timeZone;
|
||||||
@@ -1022,9 +1045,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
if (!parsedQuery.success) return { notFound: true };
|
if (!parsedQuery.success) return { notFound: true };
|
||||||
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
|
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
|
||||||
|
|
||||||
|
const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid);
|
||||||
const bookingInfoRaw = await prisma.booking.findFirst({
|
const bookingInfoRaw = await prisma.booking.findFirst({
|
||||||
where: {
|
where: {
|
||||||
uid: await maybeGetBookingUidFromSeat(prisma, uid),
|
uid: maybeUid,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
title: true,
|
title: true,
|
||||||
@@ -1088,6 +1112,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) {
|
||||||
|
requiresLoginToUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
const bookingInfo = getBookingWithResponses(bookingInfoRaw);
|
const bookingInfo = getBookingWithResponses(bookingInfoRaw);
|
||||||
// @NOTE: had to do this because Server side cant return [Object objects]
|
// @NOTE: had to do this because Server side cant return [Object objects]
|
||||||
// probably fixable with json.stringify -> json.parse
|
// probably fixable with json.stringify -> json.parse
|
||||||
@@ -1156,6 +1184,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
paymentStatus: payment,
|
paymentStatus: payment,
|
||||||
...(tz && { tz }),
|
...(tz && { tz }),
|
||||||
userTimeFormat,
|
userTimeFormat,
|
||||||
|
requiresLoginToUpdate,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { Booker } from "@calcom/atoms";
|
import { Booker } from "@calcom/atoms";
|
||||||
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||||
@@ -57,6 +58,7 @@ Type.PageWrapper = PageWrapper;
|
|||||||
Type.isBookingPage = true;
|
Type.isBookingPage = true;
|
||||||
|
|
||||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||||
|
const session = await getServerSession(context);
|
||||||
const { link, slug } = paramsSchema.parse(context.params);
|
const { link, slug } = paramsSchema.parse(context.params);
|
||||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||||
@@ -119,7 +121,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
let booking: GetBookingType | null = null;
|
let booking: GetBookingType | null = null;
|
||||||
if (rescheduleUid) {
|
if (rescheduleUid) {
|
||||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTeamEvent = !!hashedLink.eventType?.team?.id;
|
const isTeamEvent = !!hashedLink.eventType?.team?.id;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { CSSProperties } from "react";
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -219,10 +220,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
if (user.completedOnboarding) {
|
if (user.completedOnboarding) {
|
||||||
return { redirect: { permanent: false, destination: "/event-types" } };
|
return { redirect: { permanent: false, destination: "/event-types" } };
|
||||||
}
|
}
|
||||||
|
const locale = await getLocale(context.req);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
...(await serverSideTranslations(context.locale ?? "", ["common"])),
|
...(await serverSideTranslations(locale, ["common"])),
|
||||||
trpcState: ssr.dehydrate(),
|
trpcState: ssr.dehydrate(),
|
||||||
hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false,
|
hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
|||||||
import { URLSearchParams } from "url";
|
import { URLSearchParams } from "url";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||||
@@ -12,11 +13,16 @@ export default function Type() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const { uid: bookingId, seatReferenceUid } = z
|
const session = await getServerSession(context);
|
||||||
|
|
||||||
|
const { uid: bookingUid, seatReferenceUid } = z
|
||||||
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
|
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
|
||||||
.parse(context.query);
|
.parse(context.query);
|
||||||
|
|
||||||
const uid = await maybeGetBookingUidFromSeat(prisma, bookingId);
|
const { uid, seatReferenceUid: maybeSeatReferenceUid } = await maybeGetBookingUidFromSeat(
|
||||||
|
prisma,
|
||||||
|
bookingUid
|
||||||
|
);
|
||||||
const booking = await prisma.booking.findUnique({
|
const booking = await prisma.booking.findUnique({
|
||||||
where: {
|
where: {
|
||||||
uid,
|
uid,
|
||||||
@@ -37,6 +43,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
seatsPerTimeSlot: true,
|
seatsPerTimeSlot: true,
|
||||||
|
userId: true,
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hosts: {
|
||||||
|
select: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dynamicEventSlugRef: true,
|
dynamicEventSlugRef: true,
|
||||||
@@ -53,7 +74,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!booking?.eventType && !booking?.dynamicEventSlugRef) {
|
if (!booking?.eventType && !booking?.dynamicEventSlugRef) {
|
||||||
// TODO: Show something in UI to let user know that this booking is not rescheduleable.
|
// TODO: Show something in UI to let user know that this booking is not rescheduleable
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
} as {
|
} as {
|
||||||
@@ -61,6 +82,33 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if booking event type is for a seated event and no seat reference uid is provided, throw not found
|
||||||
|
if (booking?.eventType?.seatsPerTimeSlot && !maybeSeatReferenceUid) {
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
|
if (!userId && !seatReferenceUid) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: `/auth/login?callbackUrl=/reschedule/${bookingUid}`,
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const userIsHost = booking?.eventType.hosts.find((host) => {
|
||||||
|
if (host.user.id === userId) return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const userIsOwnerOfEventType = booking?.eventType.owner?.id === userId;
|
||||||
|
|
||||||
|
if (!userIsHost && !userIsOwnerOfEventType) {
|
||||||
|
return {
|
||||||
|
notFound: true,
|
||||||
|
} as {
|
||||||
|
notFound: true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
|
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
|
||||||
|
|
||||||
const eventPage = `${
|
const eventPage = `${
|
||||||
@@ -72,7 +120,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
}/${eventType?.slug}`;
|
}/${eventType?.slug}`;
|
||||||
const destinationUrl = new URLSearchParams();
|
const destinationUrl = new URLSearchParams();
|
||||||
|
|
||||||
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingId);
|
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingUid);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||||||
await utils.viewer.me.invalidate();
|
await utils.viewer.me.invalidate();
|
||||||
reset(getValues());
|
reset(getValues());
|
||||||
showToast(t("settings_updated_successfully"), "success");
|
showToast(t("settings_updated_successfully"), "success");
|
||||||
update(res);
|
await update(res);
|
||||||
|
|
||||||
|
if (res.locale) {
|
||||||
|
window.calNewLocale = res.locale;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showToast(t("error_updating_settings"), "error");
|
showToast(t("error_updating_settings"), "error");
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { usePathname } from "next/navigation";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
|
||||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
|
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
|
||||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||||
@@ -33,7 +32,13 @@ import { ssrInit } from "@server/lib/ssr";
|
|||||||
|
|
||||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }: PageProps) {
|
function TeamPage({
|
||||||
|
team,
|
||||||
|
isUnpublished,
|
||||||
|
markdownStrippedBio,
|
||||||
|
isValidOrgDomain,
|
||||||
|
currentOrgDomain,
|
||||||
|
}: PageProps) {
|
||||||
useTheme(team.theme);
|
useTheme(team.theme);
|
||||||
const routerQuery = useRouterQuery();
|
const routerQuery = useRouterQuery();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -44,7 +49,6 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
|||||||
const teamName = team.name || "Nameless Team";
|
const teamName = team.name || "Nameless Team";
|
||||||
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
|
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
|
||||||
const metadata = teamMetadataSchema.parse(team.metadata);
|
const metadata = teamMetadataSchema.parse(team.metadata);
|
||||||
const orgBranding = useOrgBranding();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
telemetry.event(
|
telemetry.event(
|
||||||
@@ -182,8 +186,8 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
|||||||
<Avatar
|
<Avatar
|
||||||
alt={teamName}
|
alt={teamName}
|
||||||
imageSrc={
|
imageSrc={
|
||||||
!!team.parent && !!orgBranding
|
isValidOrgDomain
|
||||||
? `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`
|
? `/org/${currentOrgDomain}/avatar.png`
|
||||||
: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`
|
: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`
|
||||||
}
|
}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -354,6 +358,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
trpcState: ssr.dehydrate(),
|
trpcState: ssr.dehydrate(),
|
||||||
markdownStrippedBio,
|
markdownStrippedBio,
|
||||||
isValidOrgDomain,
|
isValidOrgDomain,
|
||||||
|
currentOrgDomain,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { Booker } from "@calcom/atoms";
|
import { Booker } from "@calcom/atoms";
|
||||||
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||||
@@ -37,6 +38,7 @@ export default function Type({
|
|||||||
hideBranding={isBrandingHidden}
|
hideBranding={isBrandingHidden}
|
||||||
isTeamEvent
|
isTeamEvent
|
||||||
entity={entity}
|
entity={entity}
|
||||||
|
bookingData={booking}
|
||||||
/>
|
/>
|
||||||
<Booker
|
<Booker
|
||||||
username={user}
|
username={user}
|
||||||
@@ -64,6 +66,7 @@ const paramsSchema = z.object({
|
|||||||
// 1. Check if team exists, to show 404
|
// 1. Check if team exists, to show 404
|
||||||
// 2. If rescheduling, get the booking details
|
// 2. If rescheduling, get the booking details
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
const session = await getServerSession(context);
|
||||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
|
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
|
||||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||||
const { ssrInit } = await import("@server/lib/ssr");
|
const { ssrInit } = await import("@server/lib/ssr");
|
||||||
@@ -92,7 +95,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
|
|
||||||
let booking: GetBookingType | null = null;
|
let booking: GetBookingType | null = null;
|
||||||
if (rescheduleUid) {
|
if (rescheduleUid) {
|
||||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ test.describe("Availablity tests", () => {
|
|||||||
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
|
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
|
||||||
await page.locator('[data-testid="date-override-mark-unavailable"]').click();
|
await page.locator('[data-testid="date-override-mark-unavailable"]').click();
|
||||||
await page.locator('[data-testid="add-override-submit-btn"]').click();
|
await page.locator('[data-testid="add-override-submit-btn"]').click();
|
||||||
|
await page.locator('[data-testid="dialog-rejection"]').click();
|
||||||
await expect(page.locator('[data-testid="date-overrides-list"] > li')).toHaveCount(1);
|
await expect(page.locator('[data-testid="date-overrides-list"] > li')).toHaveCount(1);
|
||||||
await page.locator('[form="availability-form"][type="submit"]').click();
|
await page.locator('[form="availability-form"][type="submit"]').click();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { expect } from "@playwright/test";
|
|||||||
import { uuid } from "short-uuid";
|
import { uuid } from "short-uuid";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
import { randomString } from "@calcom/lib/random";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ test.describe("Booking with Seats", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(`Attendees can cancel a seated event time slot`, async ({ page, users, bookings }) => {
|
test(`Attendees can cancel a seated event time slot`, async ({ page, users, bookings }) => {
|
||||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
|
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
@@ -143,6 +144,19 @@ test.describe("Booking with Seats", () => {
|
|||||||
expect(attendeeIds).not.toContain(bookingAttendees[0].id);
|
expect(attendeeIds).not.toContain(bookingAttendees[0].id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await test.step("Attendee #2 shouldn't be able to cancel booking using only booking/uid", async () => {
|
||||||
|
await page.goto(`/booking/${booking.uid}`);
|
||||||
|
|
||||||
|
await expect(page.locator("[text=Cancel]")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Attendee #2 shouldn't be able to cancel booking using randomString for seatReferenceUId", async () => {
|
||||||
|
await page.goto(`/booking/${booking.uid}?seatReferenceUid=${randomString(10)}`);
|
||||||
|
|
||||||
|
// expect cancel button to don't be in the page
|
||||||
|
await expect(page.locator("[text=Cancel]")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
await test.step("All attendees cancelling should delete the booking for the user", async () => {
|
await test.step("All attendees cancelling should delete the booking for the user", async () => {
|
||||||
// The remaining 2 attendees cancel
|
// The remaining 2 attendees cancel
|
||||||
for (let i = 1; i < bookingSeats.length; i++) {
|
for (let i = 1; i < bookingSeats.length; i++) {
|
||||||
@@ -166,6 +180,47 @@ test.describe("Booking with Seats", () => {
|
|||||||
expect(updatedBooking?.status).toBe(BookingStatus.CANCELLED);
|
expect(updatedBooking?.status).toBe(BookingStatus.CANCELLED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Owner shouldn't be able to cancel booking without login in", async ({ page, bookings, users }) => {
|
||||||
|
const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||||
|
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
]);
|
||||||
|
await page.goto(`/booking/${booking.uid}?cancel=true`);
|
||||||
|
await expect(page.locator("[text=Cancel]")).toHaveCount(0);
|
||||||
|
|
||||||
|
// expect login text to be in the page, not data-testid
|
||||||
|
await expect(page.locator("text=Login")).toHaveCount(1);
|
||||||
|
|
||||||
|
// click on login button text
|
||||||
|
await page.locator("text=Login").click();
|
||||||
|
|
||||||
|
// expect to be redirected to login page with query parameter callbackUrl
|
||||||
|
await expect(page).toHaveURL(/\/auth\/login\?callbackUrl=.*/);
|
||||||
|
|
||||||
|
await user.apiLogin();
|
||||||
|
|
||||||
|
// manual redirect to booking page
|
||||||
|
await page.goto(`/booking/${booking.uid}?cancel=true`);
|
||||||
|
|
||||||
|
// expect login button to don't be in the page
|
||||||
|
await expect(page.locator("text=Login")).toHaveCount(0);
|
||||||
|
|
||||||
|
// fill reason for cancellation
|
||||||
|
await page.fill('[data-testid="cancel_reason"]', "Double booked!");
|
||||||
|
|
||||||
|
// confirm cancellation
|
||||||
|
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
const updatedBooking = await prisma.booking.findFirst({
|
||||||
|
where: { id: booking.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatedBooking).not.toBeNull();
|
||||||
|
expect(updatedBooking?.status).toBe(BookingStatus.CANCELLED);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Reschedule for booking with seats", () => {
|
test.describe("Reschedule for booking with seats", () => {
|
||||||
@@ -543,4 +598,113 @@ test.describe("Reschedule for booking with seats", () => {
|
|||||||
.first();
|
.first();
|
||||||
await expect(foundFirstAttendeeAgain).toHaveCount(1);
|
await expect(foundFirstAttendeeAgain).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Owner shouldn't be able to reschedule booking without login in", async ({
|
||||||
|
page,
|
||||||
|
bookings,
|
||||||
|
users,
|
||||||
|
}) => {
|
||||||
|
const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||||
|
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
]);
|
||||||
|
const getBooking = await booking.self();
|
||||||
|
|
||||||
|
await page.goto(`/booking/${booking.uid}`);
|
||||||
|
await expect(page.locator('[data-testid="reschedule"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
// expect login text to be in the page, not data-testid
|
||||||
|
await expect(page.locator("text=Login")).toHaveCount(1);
|
||||||
|
|
||||||
|
// click on login button text
|
||||||
|
await page.locator("text=Login").click();
|
||||||
|
|
||||||
|
// expect to be redirected to login page with query parameter callbackUrl
|
||||||
|
await expect(page).toHaveURL(/\/auth\/login\?callbackUrl=.*/);
|
||||||
|
|
||||||
|
await user.apiLogin();
|
||||||
|
|
||||||
|
// manual redirect to booking page
|
||||||
|
await page.goto(`/booking/${booking.uid}`);
|
||||||
|
|
||||||
|
// expect login button to don't be in the page
|
||||||
|
await expect(page.locator("text=Login")).toHaveCount(0);
|
||||||
|
|
||||||
|
// reschedule-link click
|
||||||
|
await page.locator('[data-testid="reschedule-link"]').click();
|
||||||
|
|
||||||
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
|
|
||||||
|
// data displayed in form should be user owner
|
||||||
|
const nameElement = await page.locator("input[name=name]");
|
||||||
|
const name = await nameElement.inputValue();
|
||||||
|
expect(name).toBe(user.username);
|
||||||
|
|
||||||
|
//same for email
|
||||||
|
const emailElement = await page.locator("input[name=email]");
|
||||||
|
const email = await emailElement.inputValue();
|
||||||
|
expect(email).toBe(user.email);
|
||||||
|
|
||||||
|
// reason to reschedule input should be visible textfield with name rescheduleReason
|
||||||
|
const reasonElement = await page.locator("textarea[name=rescheduleReason]");
|
||||||
|
await expect(reasonElement).toBeVisible();
|
||||||
|
|
||||||
|
// expect to be redirected to reschedule page
|
||||||
|
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||||
|
|
||||||
|
// should wait for URL but that path starts with booking/
|
||||||
|
await page.waitForURL(/\/booking\/.*/);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
const updatedBooking = await prisma.booking.findFirst({
|
||||||
|
where: { id: booking.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatedBooking).not.toBeNull();
|
||||||
|
expect(getBooking?.startTime).not.toBe(updatedBooking?.startTime);
|
||||||
|
expect(getBooking?.endTime).not.toBe(updatedBooking?.endTime);
|
||||||
|
expect(updatedBooking?.status).toBe(BookingStatus.ACCEPTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Owner shouldn't be able to reschedule when going directly to booking/rescheduleUid", async ({
|
||||||
|
page,
|
||||||
|
bookings,
|
||||||
|
users,
|
||||||
|
}) => {
|
||||||
|
const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||||
|
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
]);
|
||||||
|
const getBooking = await booking.self();
|
||||||
|
|
||||||
|
await page.goto(`/${user.username}/seats?rescheduleUid=${getBooking?.uid}&bookingUid=null`);
|
||||||
|
|
||||||
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
|
|
||||||
|
// expect textarea with name notes to be visible
|
||||||
|
const notesElement = await page.locator("textarea[name=notes]");
|
||||||
|
await expect(notesElement).toBeVisible();
|
||||||
|
|
||||||
|
// expect button confirm instead of reschedule
|
||||||
|
await expect(page.locator('[data-testid="confirm-book-button"]')).toHaveCount(1);
|
||||||
|
|
||||||
|
// now login and try again
|
||||||
|
await user.apiLogin();
|
||||||
|
|
||||||
|
await page.goto(`/${user.username}/seats?rescheduleUid=${getBooking?.uid}&bookingUid=null`);
|
||||||
|
|
||||||
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
|
|
||||||
|
await expect(page).toHaveTitle(/(?!.*reschedule).*/);
|
||||||
|
|
||||||
|
// expect button reschedule
|
||||||
|
await expect(page.locator('[data-testid="confirm-reschedule-button"]')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// @TODO: force 404 when rescheduleUid is not found
|
||||||
});
|
});
|
||||||
@@ -1,81 +1,90 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
export const createEmbedsFixture = (page: Page) => {
|
export const createEmbedsFixture = (page: Page) => {
|
||||||
return async (calNamespace: string) => {
|
return {
|
||||||
await page.addInitScript(
|
/**
|
||||||
({ calNamespace }: { calNamespace: string }) => {
|
* @deprecated Use gotoPlayground instead
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
*/
|
||||||
//@ts-ignore
|
async addEmbedListeners(calNamespace: string) {
|
||||||
window.eventsFiredStoreForPlaywright = window.eventsFiredStoreForPlaywright || {};
|
await page.addInitScript(
|
||||||
document.addEventListener("DOMContentLoaded", function tryAddingListener() {
|
({ calNamespace }: { calNamespace: string }) => {
|
||||||
if (parent !== window) {
|
console.log("PlaywrightTest:", "Adding listener for __iframeReady on namespace:", calNamespace);
|
||||||
// Firefox seems to execute this snippet for iframe as well. Avoid that. It must be executed only for parent frame.
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
//@ts-ignore
|
|
||||||
window.initialBodyVisibility = document.body.style.visibility;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
//@ts-ignore
|
|
||||||
window.initialBodyBackground = document.body.style.background;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
//@ts-ignore
|
|
||||||
window.initialValuesSet = true;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
let api = window.Cal;
|
window.eventsFiredStoreForPlaywright = window.eventsFiredStoreForPlaywright || {};
|
||||||
|
document.addEventListener("DOMContentLoaded", function tryAddingListener() {
|
||||||
|
if (parent !== window) {
|
||||||
|
// Firefox seems to execute this snippet for iframe as well. Avoid that. It must be executed only for parent frame.
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
window.initialBodyVisibility = document.body.style.visibility;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
window.initialBodyBackground = document.body.style.background;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
window.initialValuesSet = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!api) {
|
|
||||||
setTimeout(tryAddingListener, 500);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (calNamespace) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
api = window.Cal.ns[calNamespace];
|
let api = window.Cal;
|
||||||
}
|
|
||||||
console.log("PlaywrightTest:", "Adding listener for __iframeReady");
|
|
||||||
if (!api) {
|
|
||||||
throw new Error(`namespace "${calNamespace}" not found`);
|
|
||||||
}
|
|
||||||
api("on", {
|
|
||||||
action: "*",
|
|
||||||
callback: (e) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work
|
|
||||||
|
|
||||||
|
if (!api) {
|
||||||
|
console.log("PlaywrightTest:", "window.Cal not available yet, trying again");
|
||||||
|
setTimeout(tryAddingListener, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (calNamespace) {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
//@ts-ignore
|
||||||
const store = window.eventsFiredStoreForPlaywright;
|
api = window.Cal.ns[calNamespace];
|
||||||
const eventStore = (store[`${e.detail.type}-${e.detail.namespace}`] =
|
}
|
||||||
store[`${e.detail.type}-${e.detail.namespace}`] || []);
|
console.log("PlaywrightTest:", `Adding listener for __iframeReady on namespace:${calNamespace}`);
|
||||||
eventStore.push(e.detail);
|
if (!api) {
|
||||||
},
|
throw new Error(`namespace "${calNamespace}" not found`);
|
||||||
|
}
|
||||||
|
api("on", {
|
||||||
|
action: "*",
|
||||||
|
callback: (e) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
const store = window.eventsFiredStoreForPlaywright;
|
||||||
|
const eventStore = (store[`${e.detail.type}-${e.detail.namespace}`] =
|
||||||
|
store[`${e.detail.type}-${e.detail.namespace}`] || []);
|
||||||
|
eventStore.push(e.detail);
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
},
|
|
||||||
{ calNamespace }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createGetActionFiredDetails = (page: Page) => {
|
|
||||||
return async ({ calNamespace, actionType }: { calNamespace: string; actionType: string }) => {
|
|
||||||
if (!page.isClosed()) {
|
|
||||||
return await page.evaluate(
|
|
||||||
({ actionType, calNamespace }) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
//@ts-ignore
|
|
||||||
return window.eventsFiredStoreForPlaywright[`${actionType}-${calNamespace}`];
|
|
||||||
},
|
},
|
||||||
{ actionType, calNamespace }
|
{ calNamespace }
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
|
||||||
|
async getActionFiredDetails({ calNamespace, actionType }: { calNamespace: string; actionType: string }) {
|
||||||
|
if (!page.isClosed()) {
|
||||||
|
return await page.evaluate(
|
||||||
|
({ actionType, calNamespace }) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
return window.eventsFiredStoreForPlaywright[`${actionType}-${calNamespace}`];
|
||||||
|
},
|
||||||
|
{ actionType, calNamespace }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async gotoPlayground({ calNamespace, url }: { calNamespace: string; url: string }) {
|
||||||
|
await this.addEmbedListeners(calNamespace);
|
||||||
|
await page.goto(url);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -8,7 +8,7 @@ import prisma from "@calcom/prisma";
|
|||||||
|
|
||||||
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||||
import { createBookingsFixture } from "../fixtures/bookings";
|
import { createBookingsFixture } from "../fixtures/bookings";
|
||||||
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
|
import { createEmbedsFixture } from "../fixtures/embeds";
|
||||||
import { createPaymentsFixture } from "../fixtures/payments";
|
import { createPaymentsFixture } from "../fixtures/payments";
|
||||||
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
||||||
import { createServersFixture } from "../fixtures/servers";
|
import { createServersFixture } from "../fixtures/servers";
|
||||||
@@ -19,8 +19,7 @@ export interface Fixtures {
|
|||||||
users: ReturnType<typeof createUsersFixture>;
|
users: ReturnType<typeof createUsersFixture>;
|
||||||
bookings: ReturnType<typeof createBookingsFixture>;
|
bookings: ReturnType<typeof createBookingsFixture>;
|
||||||
payments: ReturnType<typeof createPaymentsFixture>;
|
payments: ReturnType<typeof createPaymentsFixture>;
|
||||||
addEmbedListeners: ReturnType<typeof createEmbedsFixture>;
|
embeds: ReturnType<typeof createEmbedsFixture>;
|
||||||
getActionFiredDetails: ReturnType<typeof createGetActionFiredDetails>;
|
|
||||||
servers: ReturnType<typeof createServersFixture>;
|
servers: ReturnType<typeof createServersFixture>;
|
||||||
prisma: typeof prisma;
|
prisma: typeof prisma;
|
||||||
emails?: API;
|
emails?: API;
|
||||||
@@ -36,7 +35,8 @@ declare global {
|
|||||||
calNamespace: string,
|
calNamespace: string,
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise<any>,
|
getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise<any>,
|
||||||
expectedUrlDetails?: ExpectedUrlDetails
|
expectedUrlDetails?: ExpectedUrlDetails,
|
||||||
|
isPrendered?: boolean
|
||||||
): Promise<R>;
|
): Promise<R>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,14 +58,10 @@ export const test = base.extend<Fixtures>({
|
|||||||
const payemntsFixture = createPaymentsFixture(page);
|
const payemntsFixture = createPaymentsFixture(page);
|
||||||
await use(payemntsFixture);
|
await use(payemntsFixture);
|
||||||
},
|
},
|
||||||
addEmbedListeners: async ({ page }, use) => {
|
embeds: async ({ page }, use) => {
|
||||||
const embedsFixture = createEmbedsFixture(page);
|
const embedsFixture = createEmbedsFixture(page);
|
||||||
await use(embedsFixture);
|
await use(embedsFixture);
|
||||||
},
|
},
|
||||||
getActionFiredDetails: async ({ page }, use) => {
|
|
||||||
const getActionFiredDetailsFixture = createGetActionFiredDetails(page);
|
|
||||||
await use(getActionFiredDetailsFixture);
|
|
||||||
},
|
|
||||||
servers: async ({}, use) => {
|
servers: async ({}, use) => {
|
||||||
const servers = createServersFixture();
|
const servers = createServersFixture();
|
||||||
await use(servers);
|
await use(servers);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Frame, Page } from "@playwright/test";
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import type { IncomingMessage, ServerResponse } from "http";
|
import type { IncomingMessage, ServerResponse } from "http";
|
||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
@@ -86,7 +86,7 @@ export async function waitFor(fn: () => Promise<unknown> | unknown, opts: { time
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function selectFirstAvailableTimeSlotNextMonth(page: Page) {
|
export async function selectFirstAvailableTimeSlotNextMonth(page: Page | Frame) {
|
||||||
// Let current month dates fully render.
|
// Let current month dates fully render.
|
||||||
await page.click('[data-testid="incrementMonth"]');
|
await page.click('[data-testid="incrementMonth"]');
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ test("Should display SAML Login button", async ({ page }) => {
|
|||||||
// eslint-disable-next-line playwright/no-skipped-test
|
// eslint-disable-next-line playwright/no-skipped-test
|
||||||
test.skip(!IS_SAML_LOGIN_ENABLED, "It should only run if SAML Login is installed");
|
test.skip(!IS_SAML_LOGIN_ENABLED, "It should only run if SAML Login is installed");
|
||||||
|
|
||||||
await page.goto(`/auth/login`);
|
// TODO: Fix this later
|
||||||
|
// Button is visible only if there is a SAML connection exists (self-hosted)
|
||||||
await expect(page.locator(`[data-testid=saml]`)).toBeVisible();
|
// await page.goto(`/auth/login`);
|
||||||
|
// await expect(page.locator(`[data-testid=saml]`)).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export {};
|
||||||
|
// TODO: @sean - I can't run E2E locally - causing me a lot of pain to try and debug.
|
||||||
|
// Will tackle in follow up once i reset my system.
|
||||||
|
// test.describe("User can overlay their calendar", async () => {
|
||||||
|
// test.afterAll(async ({ users }) => {
|
||||||
|
// await users.deleteAll();
|
||||||
|
// });
|
||||||
|
// test("Continue with Cal.com flow", async ({ page, users }) => {
|
||||||
|
// await users.create({
|
||||||
|
// username: "overflow-user-test",
|
||||||
|
// });
|
||||||
|
// await test.step("toggles overlay without a session", async () => {
|
||||||
|
// await page.goto("/overflow-user-test/30-min");
|
||||||
|
// const switchLocator = page.locator(`[data-testid=overlay-calendar-switch]`);
|
||||||
|
// await switchLocator.click();
|
||||||
|
// const continueWithCalCom = page.locator(`[data-testid=overlay-calendar-continue-button]`);
|
||||||
|
// await expect(continueWithCalCom).toBeVisible();
|
||||||
|
// await continueWithCalCom.click();
|
||||||
|
// });
|
||||||
|
// // log in trail user
|
||||||
|
// await test.step("Log in and return to booking page", async () => {
|
||||||
|
// const user = await users.create();
|
||||||
|
// await user.login();
|
||||||
|
// // Expect page to be redirected to the test users booking page
|
||||||
|
// await page.waitForURL("/overflow-user-test/30-min");
|
||||||
|
// });
|
||||||
|
// await test.step("Expect settings cog to be visible when session exists", async () => {
|
||||||
|
// const settingsCog = page.locator(`[data-testid=overlay-calendar-settings-button]`);
|
||||||
|
// await expect(settingsCog).toBeVisible();
|
||||||
|
// });
|
||||||
|
// await test.step("Settings should so no calendars connected", async () => {
|
||||||
|
// const settingsCog = page.locator(`[data-testid=overlay-calendar-settings-button]`);
|
||||||
|
// await settingsCog.click();
|
||||||
|
// await page.waitForLoadState("networkidle");
|
||||||
|
// const emptyScreenLocator = page.locator(`[data-testid=empty-screen]`);
|
||||||
|
// await expect(emptyScreenLocator).toBeVisible();
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// });
|
||||||
@@ -288,6 +288,7 @@
|
|||||||
"when": "Wann",
|
"when": "Wann",
|
||||||
"where": "Wo",
|
"where": "Wo",
|
||||||
"add_to_calendar": "Zum Kalender hinzufügen",
|
"add_to_calendar": "Zum Kalender hinzufügen",
|
||||||
|
"add_to_calendar_description": "Legen Sie fest, wo neue Termine hinzugefügt werden sollen, wenn Sie gebucht werden.",
|
||||||
"add_another_calendar": "Einen weiteren Kalender hinzufügen",
|
"add_another_calendar": "Einen weiteren Kalender hinzufügen",
|
||||||
"other": "Sonstige",
|
"other": "Sonstige",
|
||||||
"email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}",
|
"email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}",
|
||||||
@@ -599,6 +600,7 @@
|
|||||||
"hide_book_a_team_member": "„Ein Teammitglied buchen“-Schaltfläche ausblenden",
|
"hide_book_a_team_member": "„Ein Teammitglied buchen“-Schaltfläche ausblenden",
|
||||||
"hide_book_a_team_member_description": "Blendet die „Ein Teammitglied buchen“-Schaltflächen auf Ihren öffentlichen Seiten aus.",
|
"hide_book_a_team_member_description": "Blendet die „Ein Teammitglied buchen“-Schaltflächen auf Ihren öffentlichen Seiten aus.",
|
||||||
"danger_zone": "Achtung",
|
"danger_zone": "Achtung",
|
||||||
|
"account_deletion_cannot_be_undone": "Vorsicht. Löschen eines Kontos kann nicht rückgängig gemacht werden.",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"cancel": "Absagen",
|
"cancel": "Absagen",
|
||||||
"cancel_all_remaining": "Alle verbleibenden absagen",
|
"cancel_all_remaining": "Alle verbleibenden absagen",
|
||||||
@@ -688,6 +690,7 @@
|
|||||||
"people": "Personen",
|
"people": "Personen",
|
||||||
"your_email": "Ihre E-Mail-Adresse",
|
"your_email": "Ihre E-Mail-Adresse",
|
||||||
"change_avatar": "Profilbild ändern",
|
"change_avatar": "Profilbild ändern",
|
||||||
|
"upload_avatar": "Avatar hochladen",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"timezone": "Zeitzone",
|
"timezone": "Zeitzone",
|
||||||
"first_day_of_week": "Erster Tag der Woche",
|
"first_day_of_week": "Erster Tag der Woche",
|
||||||
@@ -1276,6 +1279,7 @@
|
|||||||
"personal_cal_url": "Meine persönliche {{appName}}-URL",
|
"personal_cal_url": "Meine persönliche {{appName}}-URL",
|
||||||
"bio_hint": "Schreiben Sie eine kurze Beschreibung, welche auf Ihrer persönlichen Profil-Seite erscheinen wird.",
|
"bio_hint": "Schreiben Sie eine kurze Beschreibung, welche auf Ihrer persönlichen Profil-Seite erscheinen wird.",
|
||||||
"user_has_no_bio": "Dieser Benutzer hat noch keine Bio hinzugefügt.",
|
"user_has_no_bio": "Dieser Benutzer hat noch keine Bio hinzugefügt.",
|
||||||
|
"bio": "Biografie",
|
||||||
"delete_account_modal_title": "Account löschen",
|
"delete_account_modal_title": "Account löschen",
|
||||||
"confirm_delete_account_modal": "Sind Sie sicher, dass Sie Ihr {{appName}}-Konto löschen möchten?",
|
"confirm_delete_account_modal": "Sind Sie sicher, dass Sie Ihr {{appName}}-Konto löschen möchten?",
|
||||||
"delete_my_account": "Meinen Account löschen",
|
"delete_my_account": "Meinen Account löschen",
|
||||||
@@ -1530,6 +1534,7 @@
|
|||||||
"problem_registering_domain": "Es gab ein Problem bei der Registrierung der Subdomain, bitte versuchen Sie es erneut oder kontaktieren Sie einen Administrator",
|
"problem_registering_domain": "Es gab ein Problem bei der Registrierung der Subdomain, bitte versuchen Sie es erneut oder kontaktieren Sie einen Administrator",
|
||||||
"team_publish": "Team veröffentlichen",
|
"team_publish": "Team veröffentlichen",
|
||||||
"number_text_notifications": "Telefonnummer (Textbenachrichtigungen)",
|
"number_text_notifications": "Telefonnummer (Textbenachrichtigungen)",
|
||||||
|
"number_sms_notifications": "Telefonnummer (SMS-Benachrichtigungen)",
|
||||||
"attendee_email_variable": "Teilnehmer E-Mail",
|
"attendee_email_variable": "Teilnehmer E-Mail",
|
||||||
"attendee_email_info": "Die E-Mail-Adresse der buchenden Person",
|
"attendee_email_info": "Die E-Mail-Adresse der buchenden Person",
|
||||||
"kbar_search_placeholder": "Geben Sie einen Befehl ein oder suchen Sie ...",
|
"kbar_search_placeholder": "Geben Sie einen Befehl ein oder suchen Sie ...",
|
||||||
@@ -1639,6 +1644,7 @@
|
|||||||
"minimum_round_robin_hosts_count": "Anzahl der Veranstalter, die teilnehmen müssen",
|
"minimum_round_robin_hosts_count": "Anzahl der Veranstalter, die teilnehmen müssen",
|
||||||
"hosts": "Veranstalter",
|
"hosts": "Veranstalter",
|
||||||
"upgrade_to_enable_feature": "Sie müssen ein Team erstellen, um diese Funktion zu aktivieren. Klicken Sie hier, um ein Team zu erstellen.",
|
"upgrade_to_enable_feature": "Sie müssen ein Team erstellen, um diese Funktion zu aktivieren. Klicken Sie hier, um ein Team zu erstellen.",
|
||||||
|
"orgs_upgrade_to_enable_feature": "Sie müssen auf unsere Enterprise-Lizenz aktualisieren, um diese Funktion zu aktivieren.",
|
||||||
"new_attendee": "Neuer Teilnehmer",
|
"new_attendee": "Neuer Teilnehmer",
|
||||||
"awaiting_approval": "Wartet auf Genehmigung",
|
"awaiting_approval": "Wartet auf Genehmigung",
|
||||||
"requires_google_calendar": "Diese App erfordert eine Verbindung mit Google Calendar",
|
"requires_google_calendar": "Diese App erfordert eine Verbindung mit Google Calendar",
|
||||||
@@ -1743,6 +1749,7 @@
|
|||||||
"show_on_booking_page": "Auf der Buchungsseite anzeigen",
|
"show_on_booking_page": "Auf der Buchungsseite anzeigen",
|
||||||
"get_started_zapier_templates": "Legen Sie mit Zapier-Vorlagen los",
|
"get_started_zapier_templates": "Legen Sie mit Zapier-Vorlagen los",
|
||||||
"team_is_unpublished": "{{team}} ist unveröffentlicht",
|
"team_is_unpublished": "{{team}} ist unveröffentlicht",
|
||||||
|
"org_is_unpublished_description": "Dieser Organisations-Link ist derzeit nicht verfügbar. Bitte kontaktieren Sie den Organisations-Besitzer oder fragen Sie ihn, ob er ihn veröffentlicht.",
|
||||||
"team_is_unpublished_description": "Dieser {{entity}}-Link ist derzeit nicht verfügbar. Bitte kontaktieren Sie den {{entity}}-Besitzer oder fragen Sie ihn, ob er ihn veröffentlicht.",
|
"team_is_unpublished_description": "Dieser {{entity}}-Link ist derzeit nicht verfügbar. Bitte kontaktieren Sie den {{entity}}-Besitzer oder fragen Sie ihn, ob er ihn veröffentlicht.",
|
||||||
"team_member": "Teammitglied",
|
"team_member": "Teammitglied",
|
||||||
"a_routing_form": "Ein Weiterleitungsformular",
|
"a_routing_form": "Ein Weiterleitungsformular",
|
||||||
@@ -1877,6 +1884,7 @@
|
|||||||
"edit_invite_link": "Linkeinstellungen bearbeiten",
|
"edit_invite_link": "Linkeinstellungen bearbeiten",
|
||||||
"invite_link_copied": "Einladungslink kopiert",
|
"invite_link_copied": "Einladungslink kopiert",
|
||||||
"invite_link_deleted": "Einladungslink gelöscht",
|
"invite_link_deleted": "Einladungslink gelöscht",
|
||||||
|
"api_key_deleted": "API-Schlüssel gelöscht",
|
||||||
"invite_link_updated": "Einladungslink-Einstellungen gespeichert",
|
"invite_link_updated": "Einladungslink-Einstellungen gespeichert",
|
||||||
"link_expires_after": "Links verfallen nach...",
|
"link_expires_after": "Links verfallen nach...",
|
||||||
"one_day": "1 Tag",
|
"one_day": "1 Tag",
|
||||||
@@ -2011,7 +2019,13 @@
|
|||||||
"attendee_last_name_variable": "Nachname des Teilnehmers",
|
"attendee_last_name_variable": "Nachname des Teilnehmers",
|
||||||
"attendee_first_name_info": "Vorname der buchenden Person",
|
"attendee_first_name_info": "Vorname der buchenden Person",
|
||||||
"attendee_last_name_info": "Nachname der buchenden Person",
|
"attendee_last_name_info": "Nachname der buchenden Person",
|
||||||
|
"your_monthly_digest": "Ihre monatliche Statistik",
|
||||||
|
"member_name": "Mitgliedsname",
|
||||||
|
"most_popular_events": "Beliebteste Termine",
|
||||||
|
"summary_of_events_for_your_team_for_the_last_30_days": "Hier ist Ihre Zusammenfassung der beliebten Termine für Ihr Team {{teamName}} der letzten 30 Tage",
|
||||||
"me": "Ich",
|
"me": "Ich",
|
||||||
|
"monthly_digest_email": "Monatliche Statistik E-Mail",
|
||||||
|
"monthly_digest_email_for_teams": "Monatliche Statistik E-Mail für Teams",
|
||||||
"verify_team_tooltip": "Verifizieren Sie Ihr Team, um das Senden von Nachrichten an Teilnehmer zu aktivieren",
|
"verify_team_tooltip": "Verifizieren Sie Ihr Team, um das Senden von Nachrichten an Teilnehmer zu aktivieren",
|
||||||
"member_removed": "Mitglied entfernt",
|
"member_removed": "Mitglied entfernt",
|
||||||
"my_availability": "Meine Verfügbarkeit",
|
"my_availability": "Meine Verfügbarkeit",
|
||||||
@@ -2041,12 +2055,28 @@
|
|||||||
"team_no_event_types": "Dieses Team hat keine Ereignistypen",
|
"team_no_event_types": "Dieses Team hat keine Ereignistypen",
|
||||||
"seat_options_doesnt_multiple_durations": "Platzoption unterstützt mehrere Dauern nicht",
|
"seat_options_doesnt_multiple_durations": "Platzoption unterstützt mehrere Dauern nicht",
|
||||||
"include_calendar_event": "Kalenderereignis hinzufügen",
|
"include_calendar_event": "Kalenderereignis hinzufügen",
|
||||||
|
"oAuth": "OAuth",
|
||||||
"recently_added": "Kürzlich hinzugefügt",
|
"recently_added": "Kürzlich hinzugefügt",
|
||||||
"no_members_found": "Keine Mitglieder gefunden",
|
"no_members_found": "Keine Mitglieder gefunden",
|
||||||
"event_setup_length_error": "Ereignis-Einrichtung: Die Dauer muss mindestens 1 Minute betragen.",
|
"event_setup_length_error": "Ereignis-Einrichtung: Die Dauer muss mindestens 1 Minute betragen.",
|
||||||
"availability_schedules": "Verfügbarkeitspläne",
|
"availability_schedules": "Verfügbarkeitspläne",
|
||||||
|
"unauthorized": "Nicht authorisiert",
|
||||||
|
"access_cal_account": "{{clientName}} möchte auf Ihr {{appName}} Konto zugreifen",
|
||||||
|
"select_account_team": "Konto oder Team auswählen",
|
||||||
|
"allow_client_to": "Dies wird {{clientName}} erlauben",
|
||||||
|
"see_personal_info": "Ihre persönlichen Daten einzusehen, einschließlich persönlicher Informationen, die Sie öffentlich zugänglich gemacht haben",
|
||||||
|
"see_primary_email_address": "Ihre primäre E-Mail-Adresse einzusehen",
|
||||||
|
"connect_installed_apps": "Sich mit Ihren installierten Apps zu verbinden",
|
||||||
|
"access_event_type": "Lesen, Bearbeiten, Löschen Ihrer Ereignis-Typen",
|
||||||
|
"access_availability": "Lesen, Bearbeiten, Löschen Ihrer Verfügbarkeiten",
|
||||||
|
"access_bookings": "Lesen, Bearbeiten, Löschen Ihrer Termine",
|
||||||
|
"allow_client_to_do": "{{clientName}} zulassen, dies zu tun?",
|
||||||
|
"allow": "Zulassen",
|
||||||
"view_only_edit_availability_not_onboarded": "Dieser Benutzer hat das Onboarding noch nicht abgeschlossen. Sie können seine Verfügbarkeit erst festlegen, wenn er das Onboarding abgeschlossen hat.",
|
"view_only_edit_availability_not_onboarded": "Dieser Benutzer hat das Onboarding noch nicht abgeschlossen. Sie können seine Verfügbarkeit erst festlegen, wenn er das Onboarding abgeschlossen hat.",
|
||||||
"view_only_edit_availability": "Sie sehen die Verfügbarkeit dieses Benutzers. Sie können nur Ihre eigene Verfügbarkeit bearbeiten.",
|
"view_only_edit_availability": "Sie sehen die Verfügbarkeit dieses Benutzers. Sie können nur Ihre eigene Verfügbarkeit bearbeiten.",
|
||||||
"edit_users_availability": "Benutzerverfügbarkeit bearbeiten: {{username}}",
|
"edit_users_availability": "Benutzerverfügbarkeit bearbeiten: {{username}}",
|
||||||
|
"resend_invitation": "Einladung erneut senden",
|
||||||
|
"invitation_resent": "Die Einladung wurde erneut gesendet.",
|
||||||
|
"this_app_is_not_setup_already": "Diese App wurde noch nicht eingerichtet",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
@@ -268,6 +268,7 @@
|
|||||||
"set_availability": "Set your availability",
|
"set_availability": "Set your availability",
|
||||||
"availability_settings": "Availability Settings",
|
"availability_settings": "Availability Settings",
|
||||||
"continue_without_calendar": "Continue without calendar",
|
"continue_without_calendar": "Continue without calendar",
|
||||||
|
"continue_with": "Continue with {{appName}}",
|
||||||
"connect_your_calendar": "Connect your calendar",
|
"connect_your_calendar": "Connect your calendar",
|
||||||
"connect_your_video_app": "Connect your video apps",
|
"connect_your_video_app": "Connect your video apps",
|
||||||
"connect_your_video_app_instructions": "Connect your video apps to use them on your event types.",
|
"connect_your_video_app_instructions": "Connect your video apps to use them on your event types.",
|
||||||
@@ -1619,6 +1620,7 @@
|
|||||||
"date_overrides_mark_all_day_unavailable_other": "Mark unavailable on selected dates",
|
"date_overrides_mark_all_day_unavailable_other": "Mark unavailable on selected dates",
|
||||||
"date_overrides_add_btn": "Add Override",
|
"date_overrides_add_btn": "Add Override",
|
||||||
"date_overrides_update_btn": "Update Override",
|
"date_overrides_update_btn": "Update Override",
|
||||||
|
"date_successfully_added": "Date override added successfully",
|
||||||
"event_type_duplicate_copy_text": "{{slug}}-copy",
|
"event_type_duplicate_copy_text": "{{slug}}-copy",
|
||||||
"set_as_default": "Set as default",
|
"set_as_default": "Set as default",
|
||||||
"hide_eventtype_details": "Hide event type details",
|
"hide_eventtype_details": "Hide event type details",
|
||||||
@@ -2084,5 +2086,8 @@
|
|||||||
"copy_client_secret_info": "After copying the secret you won't be able to view it anymore",
|
"copy_client_secret_info": "After copying the secret you won't be able to view it anymore",
|
||||||
"add_new_client": "Add new Client",
|
"add_new_client": "Add new Client",
|
||||||
"this_app_is_not_setup_already": "This app has not been setup yet",
|
"this_app_is_not_setup_already": "This app has not been setup yet",
|
||||||
|
"overlay_my_calendar":"Overlay my calendar",
|
||||||
|
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
|
||||||
|
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -2,8 +2,8 @@ import type { GetServerSidePropsContext } from "next";
|
|||||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
|
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||||
import { CALCOM_VERSION } from "@calcom/lib/constants";
|
import { CALCOM_VERSION } from "@calcom/lib/constants";
|
||||||
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
|
|
||||||
import { createProxySSGHelpers } from "@calcom/trpc/react/ssg";
|
import { createProxySSGHelpers } from "@calcom/trpc/react/ssg";
|
||||||
import { createContext } from "@calcom/trpc/server/createContext";
|
import { createContext } from "@calcom/trpc/server/createContext";
|
||||||
import { appRouter } from "@calcom/trpc/server/routers/_app";
|
import { appRouter } from "@calcom/trpc/server/routers/_app";
|
||||||
@@ -16,7 +16,7 @@ import { appRouter } from "@calcom/trpc/server/routers/_app";
|
|||||||
*/
|
*/
|
||||||
export async function ssrInit(context: GetServerSidePropsContext, options?: { noI18nPreload: boolean }) {
|
export async function ssrInit(context: GetServerSidePropsContext, options?: { noI18nPreload: boolean }) {
|
||||||
const ctx = await createContext(context);
|
const ctx = await createContext(context);
|
||||||
const locale = await getLocaleFromRequest(context.req);
|
const locale = await getLocale(context.req);
|
||||||
const i18n = await serverSideTranslations(locale, ["common", "vital"]);
|
const i18n = await serverSideTranslations(locale, ["common", "vital"]);
|
||||||
|
|
||||||
const ssr = createProxySSGHelpers({
|
const ssr = createProxySSGHelpers({
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import "vitest-fetch-mock";
|
import "vitest-fetch-mock";
|
||||||
|
|
||||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||||
|
import type { getMockRequestDataForBooking } from "@calcom/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking";
|
||||||
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
|
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
|
||||||
import type { HttpError } from "@calcom/lib/http-error";
|
import type { HttpError } from "@calcom/lib/http-error";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import type { SchedulingType } from "@calcom/prisma/enums";
|
import type { SchedulingType } from "@calcom/prisma/enums";
|
||||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||||
|
import type { AppMeta } from "@calcom/types/App";
|
||||||
import type { NewCalendarEventType } from "@calcom/types/Calendar";
|
import type { NewCalendarEventType } from "@calcom/types/Calendar";
|
||||||
import type { EventBusyDate } from "@calcom/types/Calendar";
|
import type { EventBusyDate } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
@@ -22,10 +24,6 @@ import { getMockPaymentService } from "./MockPaymentService";
|
|||||||
|
|
||||||
logger.setSettings({ minLevel: "silly" });
|
logger.setSettings({ minLevel: "silly" });
|
||||||
const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] });
|
const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] });
|
||||||
type App = {
|
|
||||||
slug: string;
|
|
||||||
dirName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type InputWebhook = {
|
type InputWebhook = {
|
||||||
appId: string | null;
|
appId: string | null;
|
||||||
@@ -52,24 +50,27 @@ type ScenarioData = {
|
|||||||
/**
|
/**
|
||||||
* Prisma would return these apps
|
* Prisma would return these apps
|
||||||
*/
|
*/
|
||||||
apps?: App[];
|
apps?: Partial<AppMeta>[];
|
||||||
bookings?: InputBooking[];
|
bookings?: InputBooking[];
|
||||||
webhooks?: InputWebhook[];
|
webhooks?: InputWebhook[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type InputCredential = typeof TestData.credentials.google;
|
type InputCredential = typeof TestData.credentials.google & {
|
||||||
|
id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type InputSelectedCalendar = typeof TestData.selectedCalendars.google;
|
type InputSelectedCalendar = typeof TestData.selectedCalendars.google;
|
||||||
|
|
||||||
type InputUser = typeof TestData.users.example & { id: number } & {
|
type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
|
||||||
|
id: number;
|
||||||
|
defaultScheduleId?: number | null;
|
||||||
credentials?: InputCredential[];
|
credentials?: InputCredential[];
|
||||||
selectedCalendars?: InputSelectedCalendar[];
|
selectedCalendars?: InputSelectedCalendar[];
|
||||||
schedules: {
|
schedules: {
|
||||||
id: number;
|
// Allows giving id in the input directly so that it can be referenced somewhere else as well
|
||||||
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
availability: {
|
availability: {
|
||||||
userId: number | null;
|
|
||||||
eventTypeId: number | null;
|
|
||||||
days: number[];
|
days: number[];
|
||||||
startTime: Date;
|
startTime: Date;
|
||||||
endTime: Date;
|
endTime: Date;
|
||||||
@@ -97,7 +98,8 @@ export type InputEventType = {
|
|||||||
afterEventBuffer?: number;
|
afterEventBuffer?: number;
|
||||||
requiresConfirmation?: boolean;
|
requiresConfirmation?: boolean;
|
||||||
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
|
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
|
||||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users">>;
|
schedule?: InputUser["schedules"][number];
|
||||||
|
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
|
||||||
|
|
||||||
type InputBooking = {
|
type InputBooking = {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -122,37 +124,75 @@ type InputBooking = {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const Timezones = {
|
export const Timezones = {
|
||||||
"+5:30": "Asia/Kolkata",
|
"+5:30": "Asia/Kolkata",
|
||||||
"+6:00": "Asia/Dhaka",
|
"+6:00": "Asia/Dhaka",
|
||||||
};
|
};
|
||||||
|
|
||||||
async function addEventTypesToDb(
|
async function addEventTypesToDb(
|
||||||
eventTypes: (Omit<Prisma.EventTypeCreateInput, "users" | "worflows" | "destinationCalendar"> & {
|
eventTypes: (Omit<
|
||||||
|
Prisma.EventTypeCreateInput,
|
||||||
|
"users" | "worflows" | "destinationCalendar" | "schedule"
|
||||||
|
> & {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
users?: any[];
|
users?: any[];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
workflows?: any[];
|
workflows?: any[];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
destinationCalendar?: any;
|
destinationCalendar?: any;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
schedule?: any;
|
||||||
})[]
|
})[]
|
||||||
) {
|
) {
|
||||||
log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
|
log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
|
||||||
await prismock.eventType.createMany({
|
await prismock.eventType.createMany({
|
||||||
data: eventTypes,
|
data: eventTypes,
|
||||||
});
|
});
|
||||||
|
const allEventTypes = await prismock.eventType.findMany({
|
||||||
|
include: {
|
||||||
|
users: true,
|
||||||
|
workflows: true,
|
||||||
|
destinationCalendar: true,
|
||||||
|
schedule: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a hack to get the relationship of schedule to be established with eventType. Looks like a prismock bug that creating eventType along with schedule.create doesn't establish the relationship.
|
||||||
|
* HACK STARTS
|
||||||
|
*/
|
||||||
|
log.silly("Fixed possible prismock bug by creating schedule separately");
|
||||||
|
for (let i = 0; i < eventTypes.length; i++) {
|
||||||
|
const eventType = eventTypes[i];
|
||||||
|
const createdEventType = allEventTypes[i];
|
||||||
|
|
||||||
|
if (eventType.schedule) {
|
||||||
|
log.silly("TestData: Creating Schedule for EventType", JSON.stringify(eventType));
|
||||||
|
await prismock.schedule.create({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
data: {
|
||||||
|
...eventType.schedule.create,
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: createdEventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/***
|
||||||
|
* HACK ENDS
|
||||||
|
*/
|
||||||
|
|
||||||
log.silly(
|
log.silly(
|
||||||
"TestData: All EventTypes in DB are",
|
"TestData: All EventTypes in DB are",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
eventTypes: await prismock.eventType.findMany({
|
eventTypes: allEventTypes,
|
||||||
include: {
|
|
||||||
users: true,
|
|
||||||
workflows: true,
|
|
||||||
destinationCalendar: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
return allEventTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
|
async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
|
||||||
@@ -197,10 +237,22 @@ async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser
|
|||||||
create: eventType.destinationCalendar,
|
create: eventType.destinationCalendar,
|
||||||
}
|
}
|
||||||
: eventType.destinationCalendar,
|
: eventType.destinationCalendar,
|
||||||
|
schedule: eventType.schedule
|
||||||
|
? {
|
||||||
|
create: {
|
||||||
|
...eventType.schedule,
|
||||||
|
availability: {
|
||||||
|
createMany: {
|
||||||
|
data: eventType.schedule.availability,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: eventType.schedule,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers));
|
log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers));
|
||||||
await addEventTypesToDb(eventTypesWithUsers);
|
return await addEventTypesToDb(eventTypesWithUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) {
|
function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) {
|
||||||
@@ -289,10 +341,21 @@ async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma
|
|||||||
await prismock.user.createMany({
|
await prismock.user.createMany({
|
||||||
data: users,
|
data: users,
|
||||||
});
|
});
|
||||||
|
|
||||||
log.silly(
|
log.silly(
|
||||||
"Added users to Db",
|
"Added users to Db",
|
||||||
safeStringify({
|
safeStringify({
|
||||||
allUsers: await prismock.user.findMany(),
|
allUsers: await prismock.user.findMany({
|
||||||
|
include: {
|
||||||
|
credentials: true,
|
||||||
|
schedules: {
|
||||||
|
include: {
|
||||||
|
availability: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
destinationCalendar: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -343,16 +406,28 @@ async function addUsers(users: InputUser[]) {
|
|||||||
await addUsersToDb(prismaUsersCreate);
|
await addUsersToDb(prismaUsersCreate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function addAppsToDb(apps: any[]) {
|
||||||
|
log.silly("TestData: Creating Apps", JSON.stringify({ apps }));
|
||||||
|
await prismock.app.createMany({
|
||||||
|
data: apps,
|
||||||
|
});
|
||||||
|
const allApps = await prismock.app.findMany();
|
||||||
|
log.silly("TestData: Apps as in DB", JSON.stringify({ apps: allApps }));
|
||||||
|
}
|
||||||
export async function createBookingScenario(data: ScenarioData) {
|
export async function createBookingScenario(data: ScenarioData) {
|
||||||
log.silly("TestData: Creating Scenario", JSON.stringify({ data }));
|
log.silly("TestData: Creating Scenario", JSON.stringify({ data }));
|
||||||
await addUsers(data.users);
|
await addUsers(data.users);
|
||||||
|
|
||||||
const eventType = await addEventTypes(data.eventTypes, data.users);
|
|
||||||
if (data.apps) {
|
if (data.apps) {
|
||||||
prismock.app.createMany({
|
await addAppsToDb(
|
||||||
data: data.apps,
|
data.apps.map((app) => {
|
||||||
});
|
// Enable the app by default
|
||||||
|
return { enabled: true, ...app };
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
const eventTypes = await addEventTypes(data.eventTypes, data.users);
|
||||||
|
|
||||||
data.bookings = data.bookings || [];
|
data.bookings = data.bookings || [];
|
||||||
// allowSuccessfulBookingCreation();
|
// allowSuccessfulBookingCreation();
|
||||||
await addBookings(data.bookings);
|
await addBookings(data.bookings);
|
||||||
@@ -360,7 +435,7 @@ export async function createBookingScenario(data: ScenarioData) {
|
|||||||
await addWebhooks(data.webhooks || []);
|
await addWebhooks(data.webhooks || []);
|
||||||
// addPaymentMock();
|
// addPaymentMock();
|
||||||
return {
|
return {
|
||||||
eventType,
|
eventTypes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,12 +558,11 @@ export const TestData = {
|
|||||||
},
|
},
|
||||||
schedules: {
|
schedules: {
|
||||||
IstWorkHours: {
|
IstWorkHours: {
|
||||||
id: 1,
|
|
||||||
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT",
|
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT",
|
||||||
availability: [
|
availability: [
|
||||||
{
|
{
|
||||||
userId: null,
|
// userId: null,
|
||||||
eventTypeId: null,
|
// eventTypeId: null,
|
||||||
days: [0, 1, 2, 3, 4, 5, 6],
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
startTime: new Date("1970-01-01T09:30:00.000Z"),
|
startTime: new Date("1970-01-01T09:30:00.000Z"),
|
||||||
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||||
@@ -497,21 +571,50 @@ export const TestData = {
|
|||||||
],
|
],
|
||||||
timeZone: Timezones["+5:30"],
|
timeZone: Timezones["+5:30"],
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Has an overlap with IstEveningShift from 5PM to 6PM IST(11:30AM to 12:30PM GMT)
|
||||||
|
*/
|
||||||
|
IstMorningShift: {
|
||||||
|
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT",
|
||||||
|
availability: [
|
||||||
|
{
|
||||||
|
// userId: null,
|
||||||
|
// eventTypeId: null,
|
||||||
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
|
startTime: new Date("1970-01-01T09:30:00.000Z"),
|
||||||
|
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||||
|
date: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Has an overlap with IstMorningShift from 5PM to 6PM IST(11:30AM to 12:30PM GMT)
|
||||||
|
*/
|
||||||
|
IstEveningShift: {
|
||||||
|
name: "5:00PM to 10PM in India - 11:30AM to 16:30PM in GMT",
|
||||||
|
availability: [
|
||||||
|
{
|
||||||
|
// userId: null,
|
||||||
|
// eventTypeId: null,
|
||||||
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
|
startTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||||
|
endTime: new Date("1970-01-01T22:00:00.000Z"),
|
||||||
|
date: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
},
|
||||||
IstWorkHoursWithDateOverride: (dateString: string) => ({
|
IstWorkHoursWithDateOverride: (dateString: string) => ({
|
||||||
id: 1,
|
|
||||||
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT but with a Date Override for 2PM to 6PM IST(in GST time it is 8:30AM to 12:30PM)",
|
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT but with a Date Override for 2PM to 6PM IST(in GST time it is 8:30AM to 12:30PM)",
|
||||||
availability: [
|
availability: [
|
||||||
{
|
{
|
||||||
userId: null,
|
|
||||||
eventTypeId: null,
|
|
||||||
days: [0, 1, 2, 3, 4, 5, 6],
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
startTime: new Date("1970-01-01T09:30:00.000Z"),
|
startTime: new Date("1970-01-01T09:30:00.000Z"),
|
||||||
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||||
date: null,
|
date: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: null,
|
|
||||||
eventTypeId: null,
|
|
||||||
days: [0, 1, 2, 3, 4, 5, 6],
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
startTime: new Date(`1970-01-01T14:00:00.000Z`),
|
startTime: new Date(`1970-01-01T14:00:00.000Z`),
|
||||||
endTime: new Date(`1970-01-01T18:00:00.000Z`),
|
endTime: new Date(`1970-01-01T18:00:00.000Z`),
|
||||||
@@ -532,9 +635,7 @@ export const TestData = {
|
|||||||
},
|
},
|
||||||
apps: {
|
apps: {
|
||||||
"google-calendar": {
|
"google-calendar": {
|
||||||
slug: "google-calendar",
|
...appStoreMetadata.googlecalendar,
|
||||||
enabled: true,
|
|
||||||
dirName: "whatever",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
keys: {
|
keys: {
|
||||||
@@ -545,9 +646,7 @@ export const TestData = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"daily-video": {
|
"daily-video": {
|
||||||
slug: "daily-video",
|
...appStoreMetadata.dailyvideo,
|
||||||
dirName: "whatever",
|
|
||||||
enabled: true,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
keys: {
|
keys: {
|
||||||
@@ -560,9 +659,7 @@ export const TestData = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
zoomvideo: {
|
zoomvideo: {
|
||||||
slug: "zoom",
|
...appStoreMetadata.zoomvideo,
|
||||||
enabled: true,
|
|
||||||
dirName: "whatever",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
keys: {
|
keys: {
|
||||||
@@ -575,10 +672,7 @@ export const TestData = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"stripe-payment": {
|
"stripe-payment": {
|
||||||
//TODO: Read from appStoreMeta
|
...appStoreMetadata.stripepayment,
|
||||||
slug: "stripe",
|
|
||||||
enabled: true,
|
|
||||||
dirName: "stripepayment",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
keys: {
|
keys: {
|
||||||
@@ -608,6 +702,7 @@ export function getOrganizer({
|
|||||||
credentials,
|
credentials,
|
||||||
selectedCalendars,
|
selectedCalendars,
|
||||||
destinationCalendar,
|
destinationCalendar,
|
||||||
|
defaultScheduleId,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -615,6 +710,7 @@ export function getOrganizer({
|
|||||||
schedules: InputUser["schedules"];
|
schedules: InputUser["schedules"];
|
||||||
credentials?: InputCredential[];
|
credentials?: InputCredential[];
|
||||||
selectedCalendars?: InputSelectedCalendar[];
|
selectedCalendars?: InputSelectedCalendar[];
|
||||||
|
defaultScheduleId?: number | null;
|
||||||
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
|
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
@@ -626,6 +722,7 @@ export function getOrganizer({
|
|||||||
credentials,
|
credentials,
|
||||||
selectedCalendars,
|
selectedCalendars,
|
||||||
destinationCalendar,
|
destinationCalendar,
|
||||||
|
defaultScheduleId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,7 +953,9 @@ export function mockVideoApp({
|
|||||||
url: `http://mock-${metadataLookupKey}.example.com`,
|
url: `http://mock-${metadataLookupKey}.example.com`,
|
||||||
};
|
};
|
||||||
log.silly("mockSuccessfulVideoMeetingCreation", JSON.stringify({ metadataLookupKey, appStoreLookupKey }));
|
log.silly("mockSuccessfulVideoMeetingCreation", JSON.stringify({ metadataLookupKey, appStoreLookupKey }));
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const createMeetingCalls: any[] = [];
|
const createMeetingCalls: any[] = [];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const updateMeetingCalls: any[] = [];
|
const updateMeetingCalls: any[] = [];
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
@@ -866,42 +965,50 @@ export function mockVideoApp({
|
|||||||
lib: {
|
lib: {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
VideoApiAdapter: () => ({
|
VideoApiAdapter: (credential) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
return {
|
||||||
createMeeting: (...rest: any[]) => {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if (creationCrash) {
|
createMeeting: (...rest: any[]) => {
|
||||||
throw new Error("MockVideoApiAdapter.createMeeting fake error");
|
if (creationCrash) {
|
||||||
}
|
throw new Error("MockVideoApiAdapter.createMeeting fake error");
|
||||||
createMeetingCalls.push(rest);
|
}
|
||||||
|
createMeetingCalls.push({
|
||||||
|
credential,
|
||||||
|
args: rest,
|
||||||
|
});
|
||||||
|
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
||||||
...videoMeetingData,
|
...videoMeetingData,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
updateMeeting: async (...rest: any[]) => {
|
updateMeeting: async (...rest: any[]) => {
|
||||||
if (updationCrash) {
|
if (updationCrash) {
|
||||||
throw new Error("MockVideoApiAdapter.updateMeeting fake error");
|
throw new Error("MockVideoApiAdapter.updateMeeting fake error");
|
||||||
}
|
}
|
||||||
const [bookingRef, calEvent] = rest;
|
const [bookingRef, calEvent] = rest;
|
||||||
updateMeetingCalls.push(rest);
|
updateMeetingCalls.push({
|
||||||
if (!bookingRef.type) {
|
credential,
|
||||||
throw new Error("bookingRef.type is not defined");
|
args: rest,
|
||||||
}
|
});
|
||||||
if (!calEvent.organizer) {
|
if (!bookingRef.type) {
|
||||||
throw new Error("calEvent.organizer is not defined");
|
throw new Error("bookingRef.type is not defined");
|
||||||
}
|
}
|
||||||
log.silly(
|
if (!calEvent.organizer) {
|
||||||
"mockSuccessfulVideoMeetingCreation.updateMeeting",
|
throw new Error("calEvent.organizer is not defined");
|
||||||
JSON.stringify({ bookingRef, calEvent })
|
}
|
||||||
);
|
log.silly(
|
||||||
return Promise.resolve({
|
"mockSuccessfulVideoMeetingCreation.updateMeeting",
|
||||||
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
JSON.stringify({ bookingRef, calEvent })
|
||||||
...videoMeetingData,
|
);
|
||||||
});
|
return Promise.resolve({
|
||||||
},
|
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
||||||
}),
|
...videoMeetingData,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1029,3 +1136,25 @@ export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { exte
|
|||||||
}
|
}
|
||||||
return { webhookResponse };
|
return { webhookResponse };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getExpectedCalEventForBookingRequest({
|
||||||
|
bookingRequest,
|
||||||
|
eventType,
|
||||||
|
}: {
|
||||||
|
bookingRequest: ReturnType<typeof getMockRequestDataForBooking>;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
eventType: any;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
// keep adding more fields as needed, so that they can be verified in all scenarios
|
||||||
|
type: eventType.title,
|
||||||
|
// Not sure why, but milliseconds are missing in cal Event.
|
||||||
|
startTime: bookingRequest.start.replace(".000Z", "Z"),
|
||||||
|
endTime: bookingRequest.end.replace(".000Z", "Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum BookingLocations {
|
||||||
|
CalVideo = "integrations:daily",
|
||||||
|
ZoomVideo = "integrations:zoom",
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
||||||
|
|
||||||
import type { WebhookTriggerEvents, Booking, BookingReference } from "@prisma/client";
|
import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client";
|
||||||
import ical from "node-ical";
|
import ical from "node-ical";
|
||||||
import { expect } from "vitest";
|
import { expect } from "vitest";
|
||||||
import "vitest-fetch-mock";
|
import "vitest-fetch-mock";
|
||||||
@@ -182,11 +182,15 @@ export function expectSuccessfulBookingCreationEmails({
|
|||||||
emails,
|
emails,
|
||||||
organizer,
|
organizer,
|
||||||
booker,
|
booker,
|
||||||
|
guests,
|
||||||
|
otherTeamMembers,
|
||||||
iCalUID,
|
iCalUID,
|
||||||
}: {
|
}: {
|
||||||
emails: Fixtures["emails"];
|
emails: Fixtures["emails"];
|
||||||
organizer: { email: string; name: string };
|
organizer: { email: string; name: string };
|
||||||
booker: { email: string; name: string };
|
booker: { email: string; name: string };
|
||||||
|
guests?: { email: string; name: string }[];
|
||||||
|
otherTeamMembers?: { email: string; name: string }[];
|
||||||
iCalUID: string;
|
iCalUID: string;
|
||||||
}) {
|
}) {
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
@@ -212,6 +216,39 @@ export function expectSuccessfulBookingCreationEmails({
|
|||||||
},
|
},
|
||||||
`${booker.name} <${booker.email}>`
|
`${booker.name} <${booker.email}>`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (otherTeamMembers) {
|
||||||
|
otherTeamMembers.forEach((otherTeamMember) => {
|
||||||
|
expect(emails).toHaveEmail(
|
||||||
|
{
|
||||||
|
htmlToContain: "<title>confirmed_event_type_subject</title>",
|
||||||
|
// Don't know why but organizer and team members of the eventType don'thave their name here like Booker
|
||||||
|
to: `${otherTeamMember.email}`,
|
||||||
|
ics: {
|
||||||
|
filename: "event.ics",
|
||||||
|
iCalUID: iCalUID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
`${otherTeamMember.email}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guests) {
|
||||||
|
guests.forEach((guest) => {
|
||||||
|
expect(emails).toHaveEmail(
|
||||||
|
{
|
||||||
|
htmlToContain: "<title>confirmed_event_type_subject</title>",
|
||||||
|
to: `${guest.email}`,
|
||||||
|
ics: {
|
||||||
|
filename: "event.ics",
|
||||||
|
iCalUID: iCalUID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
`${guest.name} <${guest.email}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function expectBrokenIntegrationEmails({
|
export function expectBrokenIntegrationEmails({
|
||||||
@@ -537,8 +574,9 @@ export function expectSuccessfulCalendarEventCreationInCalendar(
|
|||||||
updateEventCalls: any[];
|
updateEventCalls: any[];
|
||||||
},
|
},
|
||||||
expected: {
|
expected: {
|
||||||
calendarId: string | null;
|
calendarId?: string | null;
|
||||||
videoCallUrl: string;
|
videoCallUrl: string;
|
||||||
|
destinationCalendars: Partial<DestinationCalendar>[];
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
expect(calendarMock.createEventCalls.length).toBe(1);
|
expect(calendarMock.createEventCalls.length).toBe(1);
|
||||||
@@ -553,6 +591,8 @@ export function expectSuccessfulCalendarEventCreationInCalendar(
|
|||||||
externalId: expected.calendarId,
|
externalId: expected.calendarId,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
: expected.destinationCalendars
|
||||||
|
? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal)))
|
||||||
: null,
|
: null,
|
||||||
videoCallData: expect.objectContaining({
|
videoCallData: expect.objectContaining({
|
||||||
url: expected.videoCallUrl,
|
url: expected.videoCallUrl,
|
||||||
@@ -584,7 +624,7 @@ export function expectSuccessfulCalendarEventUpdationInCalendar(
|
|||||||
expect(externalId).toBe(expected.externalCalendarId);
|
expect(externalId).toBe(expected.externalCalendarId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function expectSuccessfulVideoMeetingCreationInCalendar(
|
export function expectSuccessfulVideoMeetingCreation(
|
||||||
videoMock: {
|
videoMock: {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
createMeetingCalls: any[];
|
createMeetingCalls: any[];
|
||||||
@@ -592,19 +632,20 @@ export function expectSuccessfulVideoMeetingCreationInCalendar(
|
|||||||
updateMeetingCalls: any[];
|
updateMeetingCalls: any[];
|
||||||
},
|
},
|
||||||
expected: {
|
expected: {
|
||||||
externalCalendarId: string;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
calEvent: Partial<CalendarEvent>;
|
credential: any;
|
||||||
uid: string;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
calEvent: any;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
expect(videoMock.createMeetingCalls.length).toBe(1);
|
expect(videoMock.createMeetingCalls.length).toBe(1);
|
||||||
const call = videoMock.createMeetingCalls[0];
|
const call = videoMock.createMeetingCalls[0];
|
||||||
const uid = call[0];
|
const callArgs = call.args;
|
||||||
const calendarEvent = call[1];
|
const calEvent = callArgs[0];
|
||||||
const externalId = call[2];
|
const credential = call.credential;
|
||||||
expect(uid).toBe(expected.uid);
|
|
||||||
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
|
expect(credential).toEqual(expected.credential);
|
||||||
expect(externalId).toBe(expected.externalCalendarId);
|
expect(calEvent).toEqual(expected.calEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function expectSuccessfulVideoMeetingUpdationInCalendar(
|
export function expectSuccessfulVideoMeetingUpdationInCalendar(
|
||||||
@@ -622,8 +663,8 @@ export function expectSuccessfulVideoMeetingUpdationInCalendar(
|
|||||||
) {
|
) {
|
||||||
expect(videoMock.updateMeetingCalls.length).toBe(1);
|
expect(videoMock.updateMeetingCalls.length).toBe(1);
|
||||||
const call = videoMock.updateMeetingCalls[0];
|
const call = videoMock.updateMeetingCalls[0];
|
||||||
const bookingRef = call[0];
|
const bookingRef = call.args[0];
|
||||||
const calendarEvent = call[1];
|
const calendarEvent = call.args[1];
|
||||||
expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef));
|
expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef));
|
||||||
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
|
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
// see https://inlang.com/
|
|
||||||
export async function defineConfig(env) {
|
|
||||||
const { default: i18nextPlugin } = await env.$import(
|
|
||||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-i18next@2/dist/index.js"
|
|
||||||
);
|
|
||||||
|
|
||||||
const { default: standardLintRules } = await env.$import(
|
|
||||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-standard-lint-rules@3/dist/index.js"
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
referenceLanguage: "en",
|
|
||||||
plugins: [
|
|
||||||
i18nextPlugin({
|
|
||||||
pathPattern: "./apps/web/public/static/locales/{language}/common.json",
|
|
||||||
}),
|
|
||||||
standardLintRules({
|
|
||||||
// deactivating identical pattern because of nested
|
|
||||||
// resources like "de-DE" and "de-AT" where "de-AT"
|
|
||||||
// contrains overwrites but the majority are identical patterns.
|
|
||||||
identicalPattern: "off",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -122,9 +122,9 @@ function AlbySetupPage(props: IAlbySetupProps) {
|
|||||||
|
|
||||||
const albyIcon = (
|
const albyIcon = (
|
||||||
<>
|
<>
|
||||||
<img className="h-16 w-16 dark:hidden" src="/api/app-store/alby/icon-borderless.svg" alt="Alby Icon" />
|
<img className="h-12 w-12 dark:hidden" src="/api/app-store/alby/icon-borderless.svg" alt="Alby Icon" />
|
||||||
<img
|
<img
|
||||||
className="hidden h-16 w-16 dark:block"
|
className="hidden h-12 w-12 dark:block"
|
||||||
src="/api/app-store/alby/icon-borderless-dark.svg"
|
src="/api/app-store/alby/icon-borderless-dark.svg"
|
||||||
alt="Alby Icon"
|
alt="Alby Icon"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getNormalizedAppMetadata } from "./getNormalizedAppMetadata";
|
|||||||
|
|
||||||
type RawAppStoreMetaData = typeof rawAppStoreMetadata;
|
type RawAppStoreMetaData = typeof rawAppStoreMetadata;
|
||||||
type AppStoreMetaData = {
|
type AppStoreMetaData = {
|
||||||
[key in keyof RawAppStoreMetaData]: AppMeta;
|
[key in keyof RawAppStoreMetaData]: Omit<AppMeta, "dirName"> & { dirName: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const appStoreMetadata = {} as AppStoreMetaData;
|
export const appStoreMetadata = {} as AppStoreMetaData;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
|
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
import { createContext } from "@calcom/trpc/server/createContext";
|
import { createContext } from "@calcom/trpc/server/createContext";
|
||||||
import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router";
|
import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router";
|
||||||
|
|
||||||
import checkSession from "../../_utils/auth";
|
import checkSession from "../../_utils/auth";
|
||||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||||
@@ -15,9 +15,9 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const appType = appConfig.type;
|
const appType = appConfig.type;
|
||||||
|
|
||||||
const ctx = await createContext({ req, res });
|
const ctx = await createContext({ req, res });
|
||||||
const caller = viewerRouter.createCaller(ctx);
|
const caller = apiKeysRouter.createCaller(ctx);
|
||||||
|
|
||||||
const apiKey = await caller.apiKeys.create({
|
const apiKey = await caller.create({
|
||||||
note: "Cal.ai",
|
note: "Cal.ai",
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
appId: "cal-ai",
|
appId: "cal-ai",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const getNormalizedAppMetadata = (appMeta: RawAppStoreMetaData[keyof RawA
|
|||||||
dirName,
|
dirName,
|
||||||
__template: "",
|
__template: "",
|
||||||
...appMeta,
|
...appMeta,
|
||||||
} as AppStoreMetaData[keyof AppStoreMetaData];
|
} as Omit<AppStoreMetaData[keyof AppStoreMetaData], "dirName"> & { dirName: string };
|
||||||
metadata.logo = getAppAssetFullPath(metadata.logo, {
|
metadata.logo = getAppAssetFullPath(metadata.logo, {
|
||||||
dirName,
|
dirName,
|
||||||
isTemplate: metadata.isTemplate,
|
isTemplate: metadata.isTemplate,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
import type { TFunction } from "next-i18next";
|
import type { TFunction } from "next-i18next";
|
||||||
|
|
||||||
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
|
||||||
import { defaultVideoAppCategories } from "@calcom/app-store/utils";
|
import { defaultVideoAppCategories } from "@calcom/app-store/utils";
|
||||||
import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials";
|
import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
@@ -21,11 +21,11 @@ export async function getLocationGroupedOptions(
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
credential?: CredentialDataWithTeamName;
|
|
||||||
}[]
|
}[]
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
let idToSearchObject = {};
|
// don't default to {}, when you do TS no longer determines the right types.
|
||||||
|
let idToSearchObject: Prisma.CredentialWhereInput;
|
||||||
|
|
||||||
if ("teamId" in userOrTeamId) {
|
if ("teamId" in userOrTeamId) {
|
||||||
const teamId = userOrTeamId.teamId;
|
const teamId = userOrTeamId.teamId;
|
||||||
@@ -84,11 +84,11 @@ export async function getLocationGroupedOptions(
|
|||||||
: app.categories[0] || app.category;
|
: app.categories[0] || app.category;
|
||||||
if (!groupByCategory) groupByCategory = AppCategories.conferencing;
|
if (!groupByCategory) groupByCategory = AppCategories.conferencing;
|
||||||
|
|
||||||
for (const credential of app.credentials) {
|
for (const { teamName } of app.credentials.map((credential) => ({
|
||||||
const label = `${app.locationOption.label} ${
|
teamName: credential.team?.name,
|
||||||
credential.team?.name ? `(${credential.team.name})` : ""
|
}))) {
|
||||||
}`;
|
const label = `${app.locationOption.label} ${teamName ? `(${teamName})` : ""}`;
|
||||||
const option = { ...app.locationOption, label, icon: app.logo, slug: app.slug, credential };
|
const option = { ...app.locationOption, label, icon: app.logo, slug: app.slug };
|
||||||
if (apps[groupByCategory]) {
|
if (apps[groupByCategory]) {
|
||||||
apps[groupByCategory] = [...apps[groupByCategory], option];
|
apps[groupByCategory] = [...apps[groupByCategory], option];
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import type { AppCategories } from "@prisma/client";
|
|||||||
// import appStore from "./index";
|
// import appStore from "./index";
|
||||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { getPiiFreeCredential } from "@calcom/lib/piiFreeData";
|
||||||
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import type { App, AppMeta } from "@calcom/types/App";
|
import type { App, AppMeta } from "@calcom/types/App";
|
||||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||||
|
|
||||||
@@ -52,7 +55,7 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials?
|
|||||||
|
|
||||||
/** If the app is a globally installed one, let's inject it's key */
|
/** If the app is a globally installed one, let's inject it's key */
|
||||||
if (appMeta.isGlobal) {
|
if (appMeta.isGlobal) {
|
||||||
appCredentials.push({
|
const credential = {
|
||||||
id: 0,
|
id: 0,
|
||||||
type: appMeta.type,
|
type: appMeta.type,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
@@ -65,7 +68,12 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials?
|
|||||||
team: {
|
team: {
|
||||||
name: "Global",
|
name: "Global",
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
logger.debug(
|
||||||
|
`${appMeta.type} is a global app, injecting credential`,
|
||||||
|
safeStringify(getPiiFreeCredential(credential))
|
||||||
|
);
|
||||||
|
appCredentials.push(credential);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if app has location option AND add it if user has credentials for it */
|
/** Check if app has location option AND add it if user has credentials for it */
|
||||||
|
|||||||
@@ -460,16 +460,23 @@ export default class EventManager {
|
|||||||
|
|
||||||
/** @fixme potential bug since Google Meet are saved as `integrations:google:meet` and there are no `google:meet` type in our DB */
|
/** @fixme potential bug since Google Meet are saved as `integrations:google:meet` and there are no `google:meet` type in our DB */
|
||||||
const integrationName = event.location.replace("integrations:", "");
|
const integrationName = event.location.replace("integrations:", "");
|
||||||
|
let videoCredential;
|
||||||
let videoCredential = event.conferenceCredentialId
|
if (event.conferenceCredentialId) {
|
||||||
? this.videoCredentials.find((credential) => credential.id === event.conferenceCredentialId)
|
videoCredential = this.videoCredentials.find(
|
||||||
: this.videoCredentials
|
(credential) => credential.id === event.conferenceCredentialId
|
||||||
// Whenever a new video connection is added, latest credentials are added with the highest ID.
|
);
|
||||||
// Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order
|
} else {
|
||||||
.sort((a, b) => {
|
videoCredential = this.videoCredentials
|
||||||
return b.id - a.id;
|
// Whenever a new video connection is added, latest credentials are added with the highest ID.
|
||||||
})
|
// Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order
|
||||||
.find((credential: CredentialPayload) => credential.type.includes(integrationName));
|
.sort((a, b) => {
|
||||||
|
return b.id - a.id;
|
||||||
|
})
|
||||||
|
.find((credential: CredentialPayload) => credential.type.includes(integrationName));
|
||||||
|
log.warn(
|
||||||
|
`Could not find conferenceCredentialId for event with location: ${event.location}, trying to use last added video credential`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This might happen if someone tries to use a location with a missing credential, so we fallback to Cal Video.
|
* This might happen if someone tries to use a location with a missing credential, so we fallback to Cal Video.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
|
|||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
|
import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import { checkBookingLimit } from "@calcom/lib/server";
|
import { checkBookingLimit } from "@calcom/lib/server";
|
||||||
import { performance } from "@calcom/lib/server/perfObserver";
|
import { performance } from "@calcom/lib/server/perfObserver";
|
||||||
import { getTotalBookingDuration } from "@calcom/lib/server/queries";
|
import { getTotalBookingDuration } from "@calcom/lib/server/queries";
|
||||||
@@ -25,6 +26,7 @@ import type {
|
|||||||
|
|
||||||
import { getBusyTimes, getBusyTimesForLimitChecks } from "./getBusyTimes";
|
import { getBusyTimes, getBusyTimesForLimitChecks } from "./getBusyTimes";
|
||||||
|
|
||||||
|
const log = logger.getChildLogger({ prefix: ["getUserAvailability"] });
|
||||||
const availabilitySchema = z
|
const availabilitySchema = z
|
||||||
.object({
|
.object({
|
||||||
dateFrom: stringToDayjs,
|
dateFrom: stringToDayjs,
|
||||||
@@ -161,7 +163,12 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
|
|||||||
if (userId) where.id = userId;
|
if (userId) where.id = userId;
|
||||||
|
|
||||||
const user = initialData?.user || (await getUser(where));
|
const user = initialData?.user || (await getUser(where));
|
||||||
|
|
||||||
if (!user) throw new HttpError({ statusCode: 404, message: "No user found" });
|
if (!user) throw new HttpError({ statusCode: 404, message: "No user found" });
|
||||||
|
log.debug(
|
||||||
|
"getUserAvailability for user",
|
||||||
|
safeStringify({ user: { id: user.id }, slot: { dateFrom, dateTo } })
|
||||||
|
);
|
||||||
|
|
||||||
let eventType: EventType | null = initialData?.eventType || null;
|
let eventType: EventType | null = initialData?.eventType || null;
|
||||||
if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId);
|
if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId);
|
||||||
@@ -225,10 +232,17 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
|
|||||||
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
|
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
|
||||||
)[0];
|
)[0];
|
||||||
|
|
||||||
const schedule =
|
const useHostSchedulesForTeamEvent = eventType?.metadata?.config?.useHostSchedulesForTeamEvent;
|
||||||
!eventType?.metadata?.config?.useHostSchedulesForTeamEvent && eventType?.schedule
|
const schedule = !useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule;
|
||||||
? eventType.schedule
|
log.debug(
|
||||||
: userSchedule;
|
"Using schedule:",
|
||||||
|
safeStringify({
|
||||||
|
chosenSchedule: schedule,
|
||||||
|
eventTypeSchedule: eventType?.schedule,
|
||||||
|
userSchedule: userSchedule,
|
||||||
|
useHostSchedulesForTeamEvent: eventType?.metadata?.config?.useHostSchedulesForTeamEvent,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const startGetWorkingHours = performance.now();
|
const startGetWorkingHours = performance.now();
|
||||||
|
|
||||||
@@ -270,7 +284,7 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
|
|||||||
|
|
||||||
const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes);
|
const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes);
|
||||||
|
|
||||||
logger.debug(
|
log.debug(
|
||||||
`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`,
|
`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
workingHoursInUtc: workingHours,
|
workingHoursInUtc: workingHours,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const getBusyVideoTimes = async (withCredentials: CredentialPayload[]) =>
|
|||||||
|
|
||||||
const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => {
|
const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => {
|
||||||
const uid: string = getUid(calEvent);
|
const uid: string = getUid(calEvent);
|
||||||
log.silly(
|
log.debug(
|
||||||
"createMeeting",
|
"createMeeting",
|
||||||
safeStringify({
|
safeStringify({
|
||||||
credential: getPiiFreeCredential(credential),
|
credential: getPiiFreeCredential(credential),
|
||||||
@@ -100,11 +100,13 @@ const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEv
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!enabledApp?.enabled) throw "Current location app is not enabled";
|
if (!enabledApp?.enabled)
|
||||||
|
throw `Location app ${credential.appId} is either disabled or not seeded at all`;
|
||||||
|
|
||||||
createdMeeting = await firstVideoAdapter?.createMeeting(calEvent);
|
createdMeeting = await firstVideoAdapter?.createMeeting(calEvent);
|
||||||
|
|
||||||
returnObject = { ...returnObject, createdEvent: createdMeeting, success: true };
|
returnObject = { ...returnObject, createdEvent: createdMeeting, success: true };
|
||||||
|
log.debug("created Meeting", safeStringify(returnObject));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await sendBrokenIntegrationEmail(calEvent, "video");
|
await sendBrokenIntegrationEmail(calEvent, "video");
|
||||||
log.error("createMeeting failed", safeStringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) }));
|
log.error("createMeeting failed", safeStringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) }));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/** @type {import("eslint").Linter.Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ["../../.eslintrc.js"],
|
extends: ["../../.eslintrc.js"],
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
@@ -85,20 +85,30 @@
|
|||||||
<span style="display: block"><a href="?color-scheme=dark">With Dark Color Scheme for the Page</a></span>
|
<span style="display: block"><a href="?color-scheme=dark">With Dark Color Scheme for the Page</a></span>
|
||||||
<span style="display: block"><a href="?nonResponsive">Non responsive version of this page here</a></span>
|
<span style="display: block"><a href="?nonResponsive">Non responsive version of this page here</a></span>
|
||||||
<span style="display: block"
|
<span style="display: block"
|
||||||
><a href="?only=prerender-test">Go to Pre-render test page only</a><small></small
|
><a href="?only=prerender-test">Go to Prerender test page only</a><small></small
|
||||||
></span>
|
></span>
|
||||||
|
<span style="display: block"
|
||||||
|
><a href="?only=preload-test">Go to Preload test page only</a><small></small
|
||||||
|
></span>
|
||||||
<button onclick="document.documentElement.style.colorScheme='dark'">Toggle Dark Scheme</button>
|
<button onclick="document.documentElement.style.colorScheme='dark'">Toggle Dark Scheme</button>
|
||||||
<button onclick="document.documentElement.style.colorScheme='light'">Toggle Light Scheme</button>
|
<button onclick="document.documentElement.style.colorScheme='light'">Toggle Light Scheme</button>
|
||||||
<div>
|
<div>
|
||||||
<script>
|
<script>
|
||||||
if (only === "all" || only === "prerender-test") {
|
if (only === "all" || only === "prerender-test") {
|
||||||
document.write(`
|
document.write(`
|
||||||
<button data-cal-namespace="prerendertestLightTheme" data-cal-config='{"theme":"light"}' data-cal-link="free?light&popup">Book with Free User[Light Theme]</button>
|
<button data-cal-namespace="e2ePrerenderLightTheme" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button>
|
||||||
<i
|
<i
|
||||||
>Corresponding Cal Link is being preloaded. Assuming that it would take you some time to click this
|
>Corresponding Cal Link is being prerendered. Assuming that it would take you some time to click this
|
||||||
as you are reading this text, it would open up super fast[If you are running a production build on
|
as you are reading this text, it would open up super fast[If you are running a production build on
|
||||||
local]. Try switching to slow 3G or create a custom Network configuration which is impossibly
|
local]. Try switching to slow 3G or create a custom Network configuration which is impossibly
|
||||||
slow</i
|
slow. This should be used if you know beforehand which type of embed is going to be opened.</i
|
||||||
|
>`);
|
||||||
|
}
|
||||||
|
if (only === "all" || only === "preload-test") {
|
||||||
|
document.write(`
|
||||||
|
<button data-cal-namespace="preloadTest" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button>
|
||||||
|
<i
|
||||||
|
>Corresponding Cal Link is being preloaded. That means that all the resources would be preloaded. This could be useful in preloading possible resources if you don't know before hand which type of embed you want to show</i
|
||||||
>`);
|
>`);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -110,6 +120,7 @@
|
|||||||
<a href="?only=ns:floatingButton">Floating Popup</a>
|
<a href="?only=ns:floatingButton">Floating Popup</a>
|
||||||
|
|
||||||
<h2>Popup Examples</h2>
|
<h2>Popup Examples</h2>
|
||||||
|
<button data-cal-namespace="e2ePopupLightTheme" data-cal-link="free" data-cal-config='{"theme":"light"}'>Book an event with Free[Light Theme]</button>
|
||||||
<button data-cal-namespace="popupAutoTheme" data-cal-link="free">
|
<button data-cal-namespace="popupAutoTheme" data-cal-link="free">
|
||||||
Book with Free User[Auto Theme]
|
Book with Free User[Auto Theme]
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ document.addEventListener("click", (e) => {
|
|||||||
const searchParams = new URL(document.URL).searchParams;
|
const searchParams = new URL(document.URL).searchParams;
|
||||||
const only = searchParams.get("only");
|
const only = searchParams.get("only");
|
||||||
const colorScheme = searchParams.get("color-scheme");
|
const colorScheme = searchParams.get("color-scheme");
|
||||||
|
const prerender = searchParams.get("prerender");
|
||||||
if (colorScheme) {
|
if (colorScheme) {
|
||||||
document.documentElement.style.colorScheme = colorScheme;
|
document.documentElement.style.colorScheme = colorScheme;
|
||||||
}
|
}
|
||||||
@@ -211,13 +211,25 @@ if (only === "all" || only === "ns:fifth") {
|
|||||||
callback,
|
callback,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (only === "all" || only === "prerender-test") {
|
if (only === "all" || only === "prerender-test") {
|
||||||
Cal("init", "prerendertestLightTheme", {
|
Cal("init", "e2ePrerenderLightTheme", {
|
||||||
debug: true,
|
debug: true,
|
||||||
origin: "http://localhost:3000",
|
origin: "http://localhost:3000",
|
||||||
});
|
});
|
||||||
Cal.ns.prerendertestLightTheme("preload", {
|
Cal.ns.e2ePrerenderLightTheme("prerender", {
|
||||||
calLink: "free",
|
calLink: "free/30min",
|
||||||
|
type: "modal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (only === "all" || only === "preload-test") {
|
||||||
|
Cal("init", "preloadTest", {
|
||||||
|
debug: true,
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
Cal.ns.preloadTest("preload", {
|
||||||
|
calLink: "free/30min",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +312,11 @@ Cal("init", "popupDarkTheme", {
|
|||||||
origin: "http://localhost:3000",
|
origin: "http://localhost:3000",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cal("init", "e2ePopupLightTheme", {
|
||||||
|
debug: true,
|
||||||
|
origin: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|
||||||
Cal("init", "popupHideEventTypeDetails", {
|
Cal("init", "popupHideEventTypeDetails", {
|
||||||
debug: true,
|
debug: true,
|
||||||
origin: "http://localhost:3000",
|
origin: "http://localhost:3000",
|
||||||
@@ -360,6 +377,12 @@ Cal("init", "routingFormDark", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (only === "all" || only == "ns:floatingButton") {
|
if (only === "all" || only == "ns:floatingButton") {
|
||||||
|
if (prerender == "true") {
|
||||||
|
Cal.ns.floatingButton("prerender", {
|
||||||
|
calLink: calLink || "pro",
|
||||||
|
type: "floatingButton",
|
||||||
|
});
|
||||||
|
}
|
||||||
Cal.ns.floatingButton("floatingButton", {
|
Cal.ns.floatingButton("floatingButton", {
|
||||||
calLink: calLink || "pro",
|
calLink: calLink || "pro",
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -56,9 +56,13 @@ export const getEmbedIframe = async ({
|
|||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
resolve(true);
|
resolve(true);
|
||||||
} else {
|
} else {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
console.log("Waiting for all three to be true:", {
|
||||||
// @ts-ignore
|
iframeElement: iframe,
|
||||||
console.log("Iframe Status:", !!iframe, !!iframe?.contentWindow, window.iframeReady);
|
contentWindow: iframe?.contentWindow,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
iframeReady: window.iframeReady,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
|
|||||||
|
|
||||||
import { test } from "@calcom/web/playwright/lib/fixtures";
|
import { test } from "@calcom/web/playwright/lib/fixtures";
|
||||||
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
||||||
|
import { selectFirstAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
todo,
|
todo,
|
||||||
@@ -18,9 +19,9 @@ async function bookFirstFreeUserEventThroughEmbed({
|
|||||||
page,
|
page,
|
||||||
getActionFiredDetails,
|
getActionFiredDetails,
|
||||||
}: {
|
}: {
|
||||||
addEmbedListeners: Fixtures["addEmbedListeners"];
|
addEmbedListeners: Fixtures["embeds"]["addEmbedListeners"];
|
||||||
page: Page;
|
page: Page;
|
||||||
getActionFiredDetails: Fixtures["getActionFiredDetails"];
|
getActionFiredDetails: Fixtures["embeds"]["getActionFiredDetails"];
|
||||||
}) {
|
}) {
|
||||||
const embedButtonLocator = page.locator('[data-cal-link="free"]').first();
|
const embedButtonLocator = page.locator('[data-cal-link="free"]').first();
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
@@ -50,24 +51,16 @@ test.describe("Popup Tests", () => {
|
|||||||
await deleteAllBookingsByEmail("embed-user@example.com");
|
await deleteAllBookingsByEmail("embed-user@example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should open embed iframe on click - Configured with light theme", async ({
|
test("should open embed iframe on click - Configured with light theme", async ({ page, embeds }) => {
|
||||||
page,
|
|
||||||
addEmbedListeners,
|
|
||||||
getActionFiredDetails,
|
|
||||||
}) => {
|
|
||||||
await deleteAllBookingsByEmail("embed-user@example.com");
|
await deleteAllBookingsByEmail("embed-user@example.com");
|
||||||
|
const calNamespace = "e2ePopupLightTheme";
|
||||||
|
await embeds.gotoPlayground({ calNamespace, url: "/" });
|
||||||
|
|
||||||
const calNamespace = "prerendertestLightTheme";
|
await page.click(`[data-cal-namespace="${calNamespace}"]`);
|
||||||
await addEmbedListeners(calNamespace);
|
|
||||||
await page.goto("/?only=prerender-test");
|
|
||||||
let embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
|
|
||||||
expect(embedIframe).toBeFalsy();
|
|
||||||
|
|
||||||
await page.click('[data-cal-link="free?light&popup"]');
|
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
|
||||||
|
|
||||||
embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
|
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
|
||||||
|
|
||||||
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
|
|
||||||
pathname: "/free",
|
pathname: "/free",
|
||||||
});
|
});
|
||||||
// expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
|
// expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
|
||||||
@@ -82,7 +75,10 @@ test.describe("Popup Tests", () => {
|
|||||||
await deleteAllBookingsByEmail("embed-user@example.com");
|
await deleteAllBookingsByEmail("embed-user@example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should be able to reschedule", async ({ page, addEmbedListeners, getActionFiredDetails }) => {
|
test("should be able to reschedule", async ({
|
||||||
|
page,
|
||||||
|
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||||
|
}) => {
|
||||||
const booking = await test.step("Create a booking", async () => {
|
const booking = await test.step("Create a booking", async () => {
|
||||||
return await bookFirstFreeUserEventThroughEmbed({
|
return await bookFirstFreeUserEventThroughEmbed({
|
||||||
page,
|
page,
|
||||||
@@ -108,8 +104,7 @@ test.describe("Popup Tests", () => {
|
|||||||
|
|
||||||
test("should open Routing Forms embed on click", async ({
|
test("should open Routing Forms embed on click", async ({
|
||||||
page,
|
page,
|
||||||
addEmbedListeners,
|
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||||
getActionFiredDetails,
|
|
||||||
}) => {
|
}) => {
|
||||||
await deleteAllBookingsByEmail("embed-user@example.com");
|
await deleteAllBookingsByEmail("embed-user@example.com");
|
||||||
|
|
||||||
@@ -143,8 +138,7 @@ test.describe("Popup Tests", () => {
|
|||||||
test.describe("Pro User - Configured in App with default setting of system theme", () => {
|
test.describe("Pro User - Configured in App with default setting of system theme", () => {
|
||||||
test("should open embed iframe according to system theme when no theme is configured through Embed API", async ({
|
test("should open embed iframe according to system theme when no theme is configured through Embed API", async ({
|
||||||
page,
|
page,
|
||||||
addEmbedListeners,
|
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||||
getActionFiredDetails,
|
|
||||||
}) => {
|
}) => {
|
||||||
const calNamespace = "floatingButton";
|
const calNamespace = "floatingButton";
|
||||||
await addEmbedListeners(calNamespace);
|
await addEmbedListeners(calNamespace);
|
||||||
@@ -175,8 +169,7 @@ test.describe("Popup Tests", () => {
|
|||||||
|
|
||||||
test("should open embed iframe according to system theme when configured with 'auto' theme using Embed API", async ({
|
test("should open embed iframe according to system theme when configured with 'auto' theme using Embed API", async ({
|
||||||
page,
|
page,
|
||||||
addEmbedListeners,
|
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||||
getActionFiredDetails,
|
|
||||||
}) => {
|
}) => {
|
||||||
const calNamespace = "floatingButton";
|
const calNamespace = "floatingButton";
|
||||||
await addEmbedListeners(calNamespace);
|
await addEmbedListeners(calNamespace);
|
||||||
@@ -203,8 +196,7 @@ test.describe("Popup Tests", () => {
|
|||||||
|
|
||||||
test("should open embed iframe(Booker Profile Page) with dark theme when configured with dark theme using Embed API", async ({
|
test("should open embed iframe(Booker Profile Page) with dark theme when configured with dark theme using Embed API", async ({
|
||||||
page,
|
page,
|
||||||
addEmbedListeners,
|
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||||
getActionFiredDetails,
|
|
||||||
}) => {
|
}) => {
|
||||||
const calNamespace = "floatingButton";
|
const calNamespace = "floatingButton";
|
||||||
await addEmbedListeners(calNamespace);
|
await addEmbedListeners(calNamespace);
|
||||||
@@ -227,8 +219,7 @@ test.describe("Popup Tests", () => {
|
|||||||
|
|
||||||
test("should open embed iframe(Event Booking Page) with dark theme when configured with dark theme using Embed API", async ({
|
test("should open embed iframe(Event Booking Page) with dark theme when configured with dark theme using Embed API", async ({
|
||||||
page,
|
page,
|
||||||
addEmbedListeners,
|
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||||
getActionFiredDetails,
|
|
||||||
}) => {
|
}) => {
|
||||||
const calNamespace = "floatingButton";
|
const calNamespace = "floatingButton";
|
||||||
await addEmbedListeners(calNamespace);
|
await addEmbedListeners(calNamespace);
|
||||||
@@ -250,4 +241,52 @@ test.describe("Popup Tests", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("prendered embed should be loaded and apply the config given to it", async ({ page, embeds }) => {
|
||||||
|
const calNamespace = "e2ePrerenderLightTheme";
|
||||||
|
const calLink = "/free/30min";
|
||||||
|
await embeds.gotoPlayground({ calNamespace, url: "/?only=prerender-test" });
|
||||||
|
await expectPrerenderedIframe({ calNamespace, calLink, embeds, page });
|
||||||
|
|
||||||
|
await page.click(`[data-cal-namespace="${calNamespace}"]`);
|
||||||
|
|
||||||
|
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: calLink });
|
||||||
|
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||||
|
if (!embedIframe) {
|
||||||
|
throw new Error("Embed iframe not found");
|
||||||
|
}
|
||||||
|
await selectFirstAvailableTimeSlotNextMonth(embedIframe);
|
||||||
|
await expect(embedIframe.locator('[name="name"]')).toHaveValue("Preloaded Prefilled");
|
||||||
|
await expect(embedIframe.locator('[name="email"]')).toHaveValue("preloaded-prefilled@example.com");
|
||||||
|
|
||||||
|
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
|
||||||
|
pathname: calLink,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function expectPrerenderedIframe({
|
||||||
|
page,
|
||||||
|
calNamespace,
|
||||||
|
calLink,
|
||||||
|
embeds,
|
||||||
|
}: {
|
||||||
|
page: Page;
|
||||||
|
calNamespace: string;
|
||||||
|
calLink: string;
|
||||||
|
embeds: Fixtures["embeds"];
|
||||||
|
}) {
|
||||||
|
const prerenderedIframe = await getEmbedIframe({ calNamespace, page, pathname: calLink });
|
||||||
|
|
||||||
|
if (!prerenderedIframe) {
|
||||||
|
throw new Error("Prerendered iframe not found");
|
||||||
|
}
|
||||||
|
await expect(prerenderedIframe).toBeEmbedCalLink(
|
||||||
|
calNamespace,
|
||||||
|
embeds.getActionFiredDetails,
|
||||||
|
{
|
||||||
|
pathname: calLink,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,8 +7,7 @@ import { bookFirstEvent, deleteAllBookingsByEmail, getEmbedIframe, todo } from "
|
|||||||
test.describe("Inline Iframe", () => {
|
test.describe("Inline Iframe", () => {
|
||||||
test("Inline Iframe - Configured with Dark Theme", async ({
|
test("Inline Iframe - Configured with Dark Theme", async ({
|
||||||
page,
|
page,
|
||||||
getActionFiredDetails,
|
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||||
addEmbedListeners,
|
|
||||||
}) => {
|
}) => {
|
||||||
await deleteAllBookingsByEmail("embed-user@example.com");
|
await deleteAllBookingsByEmail("embed-user@example.com");
|
||||||
await addEmbedListeners("");
|
await addEmbedListeners("");
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ export class ModalBox extends HTMLElement {
|
|||||||
this.dispatchEvent(event);
|
this.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hideIframe() {
|
||||||
|
const iframe = this.querySelector("iframe");
|
||||||
|
if (iframe) {
|
||||||
|
iframe.style.visibility = "hidden";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showIframe() {
|
||||||
|
const iframe = this.querySelector("iframe");
|
||||||
|
if (iframe) {
|
||||||
|
// Don't use visibility visible as that will make the iframe visible even when the modal is closed
|
||||||
|
iframe.style.visibility = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getLoaderElement() {
|
getLoaderElement() {
|
||||||
this.assertHasShadowRoot();
|
this.assertHasShadowRoot();
|
||||||
const loaderEl = this.shadowRoot.querySelector<HTMLElement>(".loader");
|
const loaderEl = this.shadowRoot.querySelector<HTMLElement>(".loader");
|
||||||
@@ -68,10 +83,14 @@ export class ModalBox extends HTMLElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newValue == "loaded") {
|
if (newValue === "loading") {
|
||||||
this.getLoaderElement().style.display = "none";
|
|
||||||
} else if (newValue === "started") {
|
|
||||||
this.open();
|
this.open();
|
||||||
|
this.hideIframe();
|
||||||
|
this.getLoaderElement().style.display = "block";
|
||||||
|
} else if (newValue == "loaded" || newValue === "reopening") {
|
||||||
|
this.open();
|
||||||
|
this.showIframe();
|
||||||
|
this.getLoaderElement().style.display = "none";
|
||||||
} else if (newValue == "closed") {
|
} else if (newValue == "closed") {
|
||||||
this.close();
|
this.close();
|
||||||
} else if (newValue === "failed") {
|
} else if (newValue === "failed") {
|
||||||
@@ -79,6 +98,8 @@ export class ModalBox extends HTMLElement {
|
|||||||
this.getErrorElement().style.display = "inline-block";
|
this.getErrorElement().style.display = "inline-block";
|
||||||
const errorString = getErrorString(this.dataset.errorCode);
|
const errorString = getErrorString(this.dataset.errorCode);
|
||||||
this.getErrorElement().innerText = errorString;
|
this.getErrorElement().innerText = errorString;
|
||||||
|
} else if (newValue === "prerendering") {
|
||||||
|
this.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
|
||||||
@@ -7,6 +8,29 @@ import type { EmbedThemeConfig, UiConfig, EmbedNonStylesConfig, BookerLayouts, E
|
|||||||
|
|
||||||
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
|
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
|
||||||
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
|
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
|
||||||
|
const enum EMBED_IFRAME_STATE {
|
||||||
|
NOT_INITIALIZED,
|
||||||
|
INITIALIZED,
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All types of config that are critical to be processed as soon as possible are provided as query params to the iframe
|
||||||
|
*/
|
||||||
|
export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
|
||||||
|
// TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
|
||||||
|
iframeAttrs?: Record<string, string> & {
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: It should have a dedicated prefill prop
|
||||||
|
// prefill: {},
|
||||||
|
|
||||||
|
// TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time.
|
||||||
|
// ui: {layout; theme}
|
||||||
|
layout?: BookerLayouts;
|
||||||
|
// TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time.
|
||||||
|
"ui.color-scheme"?: string;
|
||||||
|
theme?: EmbedThemeConfig;
|
||||||
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -17,10 +41,34 @@ declare global {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
|
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
|
||||||
*/
|
*/
|
||||||
const embedStore = {
|
const embedStore = {
|
||||||
|
// Handles the commands of routing received from parent even when React hasn't initialized and nextRouter isn't available
|
||||||
|
router: {
|
||||||
|
setNextRouter(nextRouter: ReturnType<typeof useRouter>) {
|
||||||
|
this.nextRouter = nextRouter;
|
||||||
|
|
||||||
|
// Empty the queue after running push on nextRouter. This is important because setNextRouter is be called multiple times
|
||||||
|
this.queue.forEach((url) => {
|
||||||
|
nextRouter.push(url);
|
||||||
|
this.queue.splice(0, 1);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
nextRouter: null as null | ReturnType<typeof useRouter>,
|
||||||
|
queue: [] as string[],
|
||||||
|
goto(url: string) {
|
||||||
|
if (this.nextRouter) {
|
||||||
|
this.nextRouter.push(url.toString());
|
||||||
|
} else {
|
||||||
|
this.queue.push(url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
state: EMBED_IFRAME_STATE.NOT_INITIALIZED,
|
||||||
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
|
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
|
||||||
styles: {} as EmbedStyles | undefined,
|
styles: {} as EmbedStyles | undefined,
|
||||||
nonStyles: {} as EmbedNonStylesConfig | undefined,
|
nonStyles: {} as EmbedNonStylesConfig | undefined,
|
||||||
@@ -148,6 +196,8 @@ const useUrlChange = (callback: (newUrl: string) => void) => {
|
|||||||
const pathname = currentFullUrl?.pathname ?? "";
|
const pathname = currentFullUrl?.pathname ?? "";
|
||||||
const searchParams = currentFullUrl?.searchParams ?? null;
|
const searchParams = currentFullUrl?.searchParams ?? null;
|
||||||
const lastKnownUrl = useRef(`${pathname}?${searchParams}`);
|
const lastKnownUrl = useRef(`${pathname}?${searchParams}`);
|
||||||
|
const router = useRouter();
|
||||||
|
embedStore.router.setNextRouter(router);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newUrl = `${pathname}?${searchParams}`;
|
const newUrl = `${pathname}?${searchParams}`;
|
||||||
if (lastKnownUrl.current !== newUrl) {
|
if (lastKnownUrl.current !== newUrl) {
|
||||||
@@ -340,9 +390,28 @@ const methods = {
|
|||||||
}
|
}
|
||||||
// No UI change should happen in sight. Let the parent height adjust and in next cycle show it.
|
// No UI change should happen in sight. Let the parent height adjust and in next cycle show it.
|
||||||
unhideBody();
|
unhideBody();
|
||||||
sdkActionManager?.fire("linkReady", {});
|
if (!isPrerendering()) {
|
||||||
|
sdkActionManager?.fire("linkReady", {});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
connect: function connect(queryObject: PrefillAndIframeAttrsConfig) {
|
||||||
|
const currentUrl = new URL(document.URL);
|
||||||
|
const searchParams = currentUrl.searchParams;
|
||||||
|
searchParams.delete("preload");
|
||||||
|
for (const [key, value] of Object.entries(queryObject)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value instanceof Array) {
|
||||||
|
value.forEach((val) => searchParams.append(key, val));
|
||||||
|
} else {
|
||||||
|
searchParams.set(key, value as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectPreloadedEmbed({ url: currentUrl });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InterfaceWithParent = {
|
export type InterfaceWithParent = {
|
||||||
@@ -451,58 +520,71 @@ if (isBrowser) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
actOnColorScheme(embedStore.uiConfig.colorScheme);
|
actOnColorScheme(embedStore.uiConfig.colorScheme);
|
||||||
|
// If embed link is opened in top, and not in iframe. Let the page be visible.
|
||||||
if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) {
|
if (top === window) {
|
||||||
log("Initializing embed-iframe");
|
unhideBody();
|
||||||
// HACK
|
|
||||||
const pageStatus = window.CalComPageStatus;
|
|
||||||
// If embed link is opened in top, and not in iframe. Let the page be visible.
|
|
||||||
if (top === window) {
|
|
||||||
unhideBody();
|
|
||||||
}
|
|
||||||
|
|
||||||
sdkActionManager?.on("*", (e) => {
|
|
||||||
const detail = e.detail;
|
|
||||||
log(detail);
|
|
||||||
messageParent(detail);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("message", (e) => {
|
|
||||||
const data: Message = e.data;
|
|
||||||
if (!data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const method: keyof typeof interfaceWithParent = data.method;
|
|
||||||
if (data.originator === "CAL" && typeof method === "string") {
|
|
||||||
interfaceWithParent[method]?.(data.arg as never);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("click", (e) => {
|
|
||||||
if (!e.target || !(e.target instanceof Node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const mainElement =
|
|
||||||
document.getElementsByClassName("main")[0] ||
|
|
||||||
document.getElementsByTagName("main")[0] ||
|
|
||||||
document.documentElement;
|
|
||||||
if (e.target.contains(mainElement)) {
|
|
||||||
sdkActionManager?.fire("__closeIframe", {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!pageStatus || pageStatus == "200") {
|
|
||||||
keepParentInformedAboutDimensionChanges();
|
|
||||||
sdkActionManager?.fire("__iframeReady", {});
|
|
||||||
} else
|
|
||||||
sdkActionManager?.fire("linkFailed", {
|
|
||||||
code: pageStatus,
|
|
||||||
msg: "Problem loading the link",
|
|
||||||
data: {
|
|
||||||
url: document.URL,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.addEventListener("message", (e) => {
|
||||||
|
const data: Message = e.data;
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const method: keyof typeof interfaceWithParent = data.method;
|
||||||
|
if (data.originator === "CAL" && typeof method === "string") {
|
||||||
|
interfaceWithParent[method]?.(data.arg as never);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (!e.target || !(e.target instanceof Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mainElement =
|
||||||
|
document.getElementsByClassName("main")[0] ||
|
||||||
|
document.getElementsByTagName("main")[0] ||
|
||||||
|
document.documentElement;
|
||||||
|
if (e.target.contains(mainElement)) {
|
||||||
|
sdkActionManager?.fire("__closeIframe", {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sdkActionManager?.on("*", (e) => {
|
||||||
|
const detail = e.detail;
|
||||||
|
log(detail);
|
||||||
|
messageParent(detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
|
||||||
|
initializeAndSetupEmbed();
|
||||||
|
} else {
|
||||||
|
log(`Preloaded scenario - Skipping initialization and setup`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeAndSetupEmbed() {
|
||||||
|
sdkActionManager?.fire("__iframeReady", {});
|
||||||
|
|
||||||
|
// Only NOT_INITIALIZED -> INITIALIZED transition is allowed
|
||||||
|
if (embedStore.state !== EMBED_IFRAME_STATE.NOT_INITIALIZED) {
|
||||||
|
log("Embed Iframe already initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
embedStore.state = EMBED_IFRAME_STATE.INITIALIZED;
|
||||||
|
log("Initializing embed-iframe");
|
||||||
|
// HACK
|
||||||
|
const pageStatus = window.CalComPageStatus;
|
||||||
|
|
||||||
|
if (!pageStatus || pageStatus == "200") {
|
||||||
|
keepParentInformedAboutDimensionChanges();
|
||||||
|
} else
|
||||||
|
sdkActionManager?.fire("linkFailed", {
|
||||||
|
code: pageStatus,
|
||||||
|
msg: "Problem loading the link",
|
||||||
|
data: {
|
||||||
|
url: document.URL,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function runAllUiSetters(uiConfig: UiConfig) {
|
function runAllUiSetters(uiConfig: UiConfig) {
|
||||||
@@ -517,3 +599,22 @@ function actOnColorScheme(colorScheme: string | null | undefined) {
|
|||||||
}
|
}
|
||||||
document.documentElement.style.colorScheme = colorScheme;
|
document.documentElement.style.colorScheme = colorScheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply configurations to the preloaded page and then ask parent to show the embed
|
||||||
|
* url has the config as params
|
||||||
|
*/
|
||||||
|
function connectPreloadedEmbed({ url }: { url: URL }) {
|
||||||
|
// TODO: Use a better way to detect that React has initialized. Currently, we are using setTimeout which is a hack.
|
||||||
|
const MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES = 700;
|
||||||
|
// It can be fired before React has initialized, so use embedStore.router(which is a nextRouter wrapper that supports a queue)
|
||||||
|
embedStore.router.goto(url.toString());
|
||||||
|
setTimeout(() => {
|
||||||
|
// Firing this event would stop the loader and show the embed
|
||||||
|
sdkActionManager?.fire("linkReady", {});
|
||||||
|
}, MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPrerendering = () => {
|
||||||
|
return new URL(document.URL).searchParams.get("prerender") === "true";
|
||||||
|
};
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
import { FloatingButton } from "./FloatingButton/FloatingButton";
|
import { FloatingButton } from "./FloatingButton/FloatingButton";
|
||||||
import { Inline } from "./Inline/inline";
|
import { Inline } from "./Inline/inline";
|
||||||
import { ModalBox } from "./ModalBox/ModalBox";
|
import { ModalBox } from "./ModalBox/ModalBox";
|
||||||
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
|
import type { InterfaceWithParent, interfaceWithParent, PrefillAndIframeAttrsConfig } from "./embed-iframe";
|
||||||
import css from "./embed.css";
|
import css from "./embed.css";
|
||||||
import { SdkActionManager } from "./sdk-action-manager";
|
import { SdkActionManager } from "./sdk-action-manager";
|
||||||
import type { EventData, EventDataMap } from "./sdk-action-manager";
|
import type { EventData, EventDataMap } from "./sdk-action-manager";
|
||||||
import allCss from "./tailwind.generated.css?inline";
|
import allCss from "./tailwind.generated.css?inline";
|
||||||
import type { UiConfig, EmbedThemeConfig, BookerLayouts } from "./types";
|
import type { UiConfig } from "./types";
|
||||||
|
|
||||||
|
export type { PrefillAndIframeAttrsConfig } from "./embed-iframe";
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type Rest<T extends any[]> = T extends [any, ...infer U] ? U : never;
|
type Rest<T extends any[]> = T extends [any, ...infer U] ? U : never;
|
||||||
export type Message = {
|
export type Message = {
|
||||||
@@ -151,34 +152,14 @@ type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap];
|
|||||||
export type Instruction = SingleInstruction | SingleInstruction[];
|
export type Instruction = SingleInstruction | SingleInstruction[];
|
||||||
export type InstructionQueue = Instruction[];
|
export type InstructionQueue = Instruction[];
|
||||||
|
|
||||||
/**
|
|
||||||
* All types of config that are critical to be processed as soon as possible are provided as query params to the iframe
|
|
||||||
*/
|
|
||||||
export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
|
|
||||||
// TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
|
|
||||||
iframeAttrs?: Record<string, string> & {
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: It should have a dedicated prefill prop
|
|
||||||
// prefill: {},
|
|
||||||
|
|
||||||
// TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time.
|
|
||||||
// ui: {layout; theme}
|
|
||||||
layout?: BookerLayouts;
|
|
||||||
// TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time.
|
|
||||||
"ui.color-scheme"?: string;
|
|
||||||
theme?: EmbedThemeConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class Cal {
|
export class Cal {
|
||||||
iframe?: HTMLIFrameElement;
|
iframe?: HTMLIFrameElement;
|
||||||
|
|
||||||
__config: Config;
|
__config: Config;
|
||||||
|
|
||||||
modalBox!: Element;
|
modalBox?: Element;
|
||||||
|
|
||||||
inlineEl!: Element;
|
inlineEl?: Element;
|
||||||
|
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
|
||||||
@@ -190,6 +171,8 @@ export class Cal {
|
|||||||
|
|
||||||
api: CalApi;
|
api: CalApi;
|
||||||
|
|
||||||
|
isPerendering?: boolean;
|
||||||
|
|
||||||
static actionsManagers: Record<Namespace, SdkActionManager>;
|
static actionsManagers: Record<Namespace, SdkActionManager>;
|
||||||
|
|
||||||
static getQueryObject(config: PrefillAndIframeAttrsConfig) {
|
static getQueryObject(config: PrefillAndIframeAttrsConfig) {
|
||||||
@@ -389,6 +372,9 @@ export class Cal {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.actionManager.on("__routeChanged", () => {
|
this.actionManager.on("__routeChanged", () => {
|
||||||
|
if (!this.inlineEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { top, height } = this.inlineEl.getBoundingClientRect();
|
const { top, height } = this.inlineEl.getBoundingClientRect();
|
||||||
// Try to readjust and scroll into view if more than 25% is hidden.
|
// Try to readjust and scroll into view if more than 25% is hidden.
|
||||||
// Otherwise we assume that user might have positioned the content appropriately already
|
// Otherwise we assume that user might have positioned the content appropriately already
|
||||||
@@ -398,6 +384,10 @@ export class Cal {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.actionManager.on("linkReady", () => {
|
this.actionManager.on("linkReady", () => {
|
||||||
|
if (this.isPerendering) {
|
||||||
|
// Absolute check to ensure that we don't mark embed as loaded if it's prerendering otherwise prerendered embed would showup without any user action
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.modalBox?.setAttribute("state", "loaded");
|
this.modalBox?.setAttribute("state", "loaded");
|
||||||
this.inlineEl?.setAttribute("loading", "done");
|
this.inlineEl?.setAttribute("loading", "done");
|
||||||
});
|
});
|
||||||
@@ -418,6 +408,8 @@ export class Cal {
|
|||||||
class CalApi {
|
class CalApi {
|
||||||
cal: Cal;
|
cal: Cal;
|
||||||
static initializedNamespaces = [] as string[];
|
static initializedNamespaces = [] as string[];
|
||||||
|
modalUid?: string;
|
||||||
|
preloadedModalUid?: string;
|
||||||
constructor(cal: Cal) {
|
constructor(cal: Cal) {
|
||||||
this.cal = cal;
|
this.cal = cal;
|
||||||
}
|
}
|
||||||
@@ -563,41 +555,71 @@ class CalApi {
|
|||||||
modal({
|
modal({
|
||||||
calLink,
|
calLink,
|
||||||
config = {},
|
config = {},
|
||||||
uid,
|
__prerender = false,
|
||||||
}: {
|
}: {
|
||||||
calLink: string;
|
calLink: string;
|
||||||
config?: PrefillAndIframeAttrsConfig;
|
config?: PrefillAndIframeAttrsConfig;
|
||||||
uid?: string | number;
|
|
||||||
calOrigin?: string;
|
calOrigin?: string;
|
||||||
|
__prerender?: boolean;
|
||||||
}) {
|
}) {
|
||||||
uid = uid || 0;
|
const uid = this.modalUid || this.preloadedModalUid || String(Date.now()) || "0";
|
||||||
|
const isConnectingToPreloadedModal = this.preloadedModalUid && !this.modalUid;
|
||||||
|
|
||||||
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
|
const containerEl = document.body;
|
||||||
if (existingModalEl) {
|
|
||||||
existingModalEl.setAttribute("state", "started");
|
this.cal.isPerendering = !!__prerender;
|
||||||
return;
|
|
||||||
|
if (__prerender) {
|
||||||
|
// Add preload query param
|
||||||
|
config.prerender = "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryObject = withColorScheme(Cal.getQueryObject(config), containerEl);
|
||||||
|
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
|
||||||
|
|
||||||
|
if (existingModalEl) {
|
||||||
|
if (isConnectingToPreloadedModal) {
|
||||||
|
this.cal.doInIframe({
|
||||||
|
method: "connect",
|
||||||
|
arg: queryObject,
|
||||||
|
});
|
||||||
|
this.modalUid = uid;
|
||||||
|
existingModalEl.setAttribute("state", "loading");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
existingModalEl.setAttribute("state", "reopening");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__prerender) {
|
||||||
|
this.preloadedModalUid = uid;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
|
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
|
||||||
throw new Error("iframeAttrs should be an object");
|
throw new Error("iframeAttrs should be an object");
|
||||||
}
|
}
|
||||||
|
|
||||||
config.embedType = "modal";
|
config.embedType = "modal";
|
||||||
const containerEl = document.body;
|
let iframe = null;
|
||||||
const iframe = this.cal.createIframe({
|
|
||||||
calLink,
|
if (!iframe) {
|
||||||
queryObject: withColorScheme(Cal.getQueryObject(config), containerEl),
|
iframe = this.cal.createIframe({
|
||||||
});
|
calLink,
|
||||||
|
queryObject,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
iframe.style.borderRadius = "8px";
|
iframe.style.borderRadius = "8px";
|
||||||
|
|
||||||
iframe.style.height = "100%";
|
iframe.style.height = "100%";
|
||||||
iframe.style.width = "100%";
|
iframe.style.width = "100%";
|
||||||
const template = document.createElement("template");
|
const template = document.createElement("template");
|
||||||
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
|
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
|
||||||
|
|
||||||
this.cal.modalBox = template.content.children[0];
|
this.cal.modalBox = template.content.children[0];
|
||||||
this.cal.modalBox.appendChild(iframe);
|
this.cal.modalBox.appendChild(iframe);
|
||||||
|
if (__prerender) {
|
||||||
|
this.cal.modalBox.setAttribute("state", "prerendering");
|
||||||
|
}
|
||||||
this.handleClose();
|
this.handleClose();
|
||||||
containerEl.appendChild(template.content);
|
containerEl.appendChild(template.content);
|
||||||
}
|
}
|
||||||
@@ -605,7 +627,7 @@ class CalApi {
|
|||||||
private handleClose() {
|
private handleClose() {
|
||||||
// A request, to close from the iframe, should close the modal
|
// A request, to close from the iframe, should close the modal
|
||||||
this.cal.actionManager.on("__closeIframe", () => {
|
this.cal.actionManager.on("__closeIframe", () => {
|
||||||
this.cal.modalBox.setAttribute("state", "closed");
|
this.cal.modalBox?.setAttribute("state", "closed");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,8 +664,24 @@ class CalApi {
|
|||||||
}) {
|
}) {
|
||||||
this.cal.actionManager.off(action, callback);
|
this.cal.actionManager.off(action, callback);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
preload({ calLink }: { calLink: string }) {
|
*
|
||||||
|
* type is provided and prerenderIframe not set. We would assume prerenderIframe to be true
|
||||||
|
* type is provided and prerenderIframe set to false. We would ignore the type and preload assets only
|
||||||
|
* type is not provided and prerenderIframe set to true. We would throw error as we don't know what to prerender
|
||||||
|
* type is not provided and prerenderIframe set to false. We would preload assets only
|
||||||
|
*/
|
||||||
|
preload({
|
||||||
|
calLink,
|
||||||
|
type,
|
||||||
|
options = {},
|
||||||
|
}: {
|
||||||
|
calLink: string;
|
||||||
|
type?: "modal" | "floatingButton";
|
||||||
|
options?: {
|
||||||
|
prerenderIframe?: boolean;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
// eslint-disable-next-line prefer-rest-params
|
// eslint-disable-next-line prefer-rest-params
|
||||||
validate(arguments[0], {
|
validate(arguments[0], {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -652,17 +690,58 @@ class CalApi {
|
|||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
type: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const iframe = document.body.appendChild(document.createElement("iframe"));
|
let api: GlobalCalWithoutNs = globalCal;
|
||||||
const config = this.cal.getConfig();
|
const namespace = this.cal.namespace;
|
||||||
|
if (namespace) {
|
||||||
|
api = globalCal.ns[namespace];
|
||||||
|
}
|
||||||
|
|
||||||
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
|
if (!api) {
|
||||||
urlInstance.searchParams.set("prerender", "true");
|
throw new Error(`Namespace ${namespace} isn't defined`);
|
||||||
iframe.src = urlInstance.toString();
|
}
|
||||||
iframe.style.width = "0";
|
|
||||||
iframe.style.height = "0";
|
const config = this.cal.getConfig();
|
||||||
iframe.style.display = "none";
|
let prerenderIframe = options.prerenderIframe;
|
||||||
|
if (type && prerenderIframe === undefined) {
|
||||||
|
prerenderIframe = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!type && prerenderIframe) {
|
||||||
|
throw new Error("You should provide 'type'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prerenderIframe) {
|
||||||
|
if (type === "modal" || type === "floatingButton") {
|
||||||
|
this.cal.isPerendering = true;
|
||||||
|
this.modal({
|
||||||
|
calLink,
|
||||||
|
calOrigin: config.calOrigin,
|
||||||
|
__prerender: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("Ignoring - full preload for inline embed and instead preloading assets only");
|
||||||
|
preloadAssetsForCalLink({ calLink, config });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
preloadAssetsForCalLink({ calLink, config });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prerender({ calLink, type }: { calLink: string; type: "modal" | "floatingButton" }) {
|
||||||
|
this.preload({
|
||||||
|
calLink,
|
||||||
|
type,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ui(uiConfig: UiConfig) {
|
ui(uiConfig: UiConfig) {
|
||||||
@@ -755,7 +834,6 @@ document.addEventListener("click", (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modalUniqueId = (targetEl.dataset.uniqueId = targetEl.dataset.uniqueId || String(Date.now()));
|
|
||||||
const namespace = targetEl.dataset.calNamespace;
|
const namespace = targetEl.dataset.calNamespace;
|
||||||
const configString = targetEl.dataset.calConfig || "";
|
const configString = targetEl.dataset.calConfig || "";
|
||||||
const calOrigin = targetEl.dataset.calOrigin || "";
|
const calOrigin = targetEl.dataset.calOrigin || "";
|
||||||
@@ -779,7 +857,6 @@ document.addEventListener("click", (e) => {
|
|||||||
api("modal", {
|
api("modal", {
|
||||||
calLink: path,
|
calLink: path,
|
||||||
config,
|
config,
|
||||||
uid: modalUniqueId,
|
|
||||||
calOrigin,
|
calOrigin,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -812,3 +889,14 @@ function getEmbedApiFn(ns: string) {
|
|||||||
}
|
}
|
||||||
return api;
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preloadAssetsForCalLink({ config, calLink }: { config: Config; calLink: string }) {
|
||||||
|
const iframe = document.body.appendChild(document.createElement("iframe"));
|
||||||
|
|
||||||
|
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
|
||||||
|
urlInstance.searchParams.set("preload", "true");
|
||||||
|
iframe.src = urlInstance.toString();
|
||||||
|
iframe.style.width = "0";
|
||||||
|
iframe.style.height = "0";
|
||||||
|
iframe.style.display = "none";
|
||||||
|
}
|
||||||
@@ -6,8 +6,7 @@ import { test } from "@calcom/web/playwright/lib/fixtures";
|
|||||||
test.describe("Inline Embed", () => {
|
test.describe("Inline Embed", () => {
|
||||||
test("should verify that the iframe got created with correct URL", async ({
|
test("should verify that the iframe got created with correct URL", async ({
|
||||||
page,
|
page,
|
||||||
getActionFiredDetails,
|
embeds: { getActionFiredDetails, addEmbedListeners },
|
||||||
addEmbedListeners,
|
|
||||||
}) => {
|
}) => {
|
||||||
//TODO: Do it with page.goto automatically
|
//TODO: Do it with page.goto automatically
|
||||||
await addEmbedListeners("");
|
await addEmbedListeners("");
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { parse } from "accept-language-parser";
|
||||||
|
import type { GetTokenParams } from "next-auth/jwt";
|
||||||
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a slimmed down version of the `getServerSession` function from
|
||||||
|
* `next-auth`.
|
||||||
|
*
|
||||||
|
* Instead of requiring the entire options object for NextAuth, we create
|
||||||
|
* a compatible session using information from the incoming token.
|
||||||
|
*
|
||||||
|
* The downside to this is that we won't refresh sessions if the users
|
||||||
|
* token has expired (30 days). This should be fine as we call `/auth/session`
|
||||||
|
* frequently enough on the client-side to keep the session alive.
|
||||||
|
*/
|
||||||
|
export const getLocale = async (req: GetTokenParams["req"]): Promise<string> => {
|
||||||
|
const token = await getToken({
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenLocale = token?.["locale"];
|
||||||
|
|
||||||
|
if (tokenLocale) {
|
||||||
|
return tokenLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptLanguage =
|
||||||
|
req.headers instanceof Headers ? req.headers.get("accept-language") : req.headers["accept-language"];
|
||||||
|
|
||||||
|
const languages = acceptLanguage ? parse(acceptLanguage) : [];
|
||||||
|
|
||||||
|
return languages[0]?.code || "en";
|
||||||
|
};
|
||||||
@@ -133,7 +133,7 @@ export const AvailableTimeSlots = ({
|
|||||||
: slotsPerDay.length > 0 &&
|
: slotsPerDay.length > 0 &&
|
||||||
slotsPerDay.map((slots) => (
|
slotsPerDay.map((slots) => (
|
||||||
<AvailableTimes
|
<AvailableTimes
|
||||||
className="scroll-bar w-full overflow-auto"
|
className="scroll-bar w-full overflow-y-auto overflow-x-hidden"
|
||||||
key={slots.date}
|
key={slots.date}
|
||||||
showTimeFormatToggle={!isColumnView}
|
showTimeFormatToggle={!isColumnView}
|
||||||
onTimeSelect={onTimeSelect}
|
onTimeSelect={onTimeSelect}
|
||||||
|
|||||||
@@ -375,6 +375,7 @@ export const BookEventFormChild = ({
|
|||||||
fields={eventType.bookingFields}
|
fields={eventType.bookingFields}
|
||||||
locations={eventType.locations}
|
locations={eventType.locations}
|
||||||
rescheduleUid={rescheduleUid || undefined}
|
rescheduleUid={rescheduleUid || undefined}
|
||||||
|
bookingData={bookingData}
|
||||||
/>
|
/>
|
||||||
{(createBookingMutation.isError ||
|
{(createBookingMutation.isError ||
|
||||||
createRecurringBookingMutation.isError ||
|
createRecurringBookingMutation.isError ||
|
||||||
@@ -404,8 +405,8 @@ export const BookEventFormChild = ({
|
|||||||
type="submit"
|
type="submit"
|
||||||
color="primary"
|
color="primary"
|
||||||
loading={createBookingMutation.isLoading || createRecurringBookingMutation.isLoading}
|
loading={createBookingMutation.isLoading || createRecurringBookingMutation.isLoading}
|
||||||
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}>
|
data-testid={rescheduleUid && bookingData ? "confirm-reschedule-button" : "confirm-book-button"}>
|
||||||
{rescheduleUid
|
{rescheduleUid && bookingData
|
||||||
? t("reschedule")
|
? t("reschedule")
|
||||||
: renderConfirmNotVerifyEmailButtonCond
|
: renderConfirmNotVerifyEmailButtonCond
|
||||||
? t("confirm")
|
? t("confirm")
|
||||||
@@ -497,12 +498,18 @@ function useInitialFormValues({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const defaultUserValues = {
|
const defaultUserValues = {
|
||||||
email: rescheduleUid
|
email:
|
||||||
? bookingData?.attendees[0].email
|
rescheduleUid && bookingData && bookingData.attendees.length > 0
|
||||||
: parsedQuery["email"] || session.data?.user?.email || "",
|
? bookingData?.attendees[0].email
|
||||||
name: rescheduleUid
|
: !!parsedQuery["email"]
|
||||||
? bookingData?.attendees[0].name
|
? parsedQuery["email"]
|
||||||
: parsedQuery["name"] || session.data?.user?.name || "",
|
: session.data?.user?.email ?? "",
|
||||||
|
name:
|
||||||
|
rescheduleUid && bookingData && bookingData.attendees.length > 0
|
||||||
|
? bookingData?.attendees[0].name
|
||||||
|
: !!parsedQuery["name"]
|
||||||
|
? parsedQuery["name"]
|
||||||
|
: session.data?.user?.name ?? session.data?.user?.username ?? "",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isRescheduling) {
|
if (!isRescheduling) {
|
||||||
@@ -526,14 +533,12 @@ function useInitialFormValues({
|
|||||||
setDefaultValues(defaults);
|
setDefaultValues(defaults);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((!rescheduleUid && !bookingData) || !bookingData?.attendees.length) {
|
if (!rescheduleUid && !bookingData) {
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const primaryAttendee = bookingData.attendees[0];
|
|
||||||
if (!primaryAttendee) {
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We should allow current session user as default values for booking form
|
||||||
|
|
||||||
const defaults = {
|
const defaults = {
|
||||||
responses: {} as Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
|
responses: {} as Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
|
||||||
};
|
};
|
||||||
@@ -541,7 +546,7 @@ function useInitialFormValues({
|
|||||||
const responses = eventType.bookingFields.reduce((responses, field) => {
|
const responses = eventType.bookingFields.reduce((responses, field) => {
|
||||||
return {
|
return {
|
||||||
...responses,
|
...responses,
|
||||||
[field.name]: bookingData.responses[field.name],
|
[field.name]: bookingData?.responses[field.name],
|
||||||
};
|
};
|
||||||
}, {});
|
}, {});
|
||||||
defaults.responses = {
|
defaults.responses = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import type { LocationObject } from "@calcom/app-store/locations";
|
import type { LocationObject } from "@calcom/app-store/locations";
|
||||||
|
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||||
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||||
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField";
|
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -13,10 +14,12 @@ export const BookingFields = ({
|
|||||||
locations,
|
locations,
|
||||||
rescheduleUid,
|
rescheduleUid,
|
||||||
isDynamicGroupBooking,
|
isDynamicGroupBooking,
|
||||||
|
bookingData,
|
||||||
}: {
|
}: {
|
||||||
fields: NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"];
|
fields: NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"];
|
||||||
locations: LocationObject[];
|
locations: LocationObject[];
|
||||||
rescheduleUid?: string;
|
rescheduleUid?: string;
|
||||||
|
bookingData?: GetBookingType | null;
|
||||||
isDynamicGroupBooking: boolean;
|
isDynamicGroupBooking: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -32,7 +35,9 @@ export const BookingFields = ({
|
|||||||
// During reschedule by default all system fields are readOnly. Make them editable on case by case basis.
|
// 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
|
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
|
||||||
let readOnly =
|
let readOnly =
|
||||||
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
|
(field.editable === "system" || field.editable === "system-but-optional") &&
|
||||||
|
!!rescheduleUid &&
|
||||||
|
bookingData !== null;
|
||||||
|
|
||||||
let hidden = !!field.hidden;
|
let hidden = !!field.hidden;
|
||||||
const fieldViews = field.views;
|
const fieldViews = field.views;
|
||||||
@@ -42,6 +47,9 @@ export const BookingFields = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (field.name === SystemField.Enum.rescheduleReason) {
|
if (field.name === SystemField.Enum.rescheduleReason) {
|
||||||
|
if (bookingData === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule
|
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule
|
||||||
readOnly = false;
|
readOnly = false;
|
||||||
}
|
}
|
||||||
@@ -64,8 +72,8 @@ export const BookingFields = ({
|
|||||||
hidden = isDynamicGroupBooking ? true : !!field.hidden;
|
hidden = isDynamicGroupBooking ? true : !!field.hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't show `notes` field during reschedule
|
// We don't show `notes` field during reschedule but since it's a query param we better valid if rescheduleUid brought any bookingData
|
||||||
if (field.name === SystemField.Enum.notes && !!rescheduleUid) {
|
if (field.name === SystemField.Enum.notes && bookingData !== null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { m } from "framer-motion";
|
import { m } from "framer-motion";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { shallow } from "zustand/shallow";
|
import { shallow } from "zustand/shallow";
|
||||||
|
|
||||||
import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||||
@@ -22,6 +23,7 @@ const TimezoneSelect = dynamic(() => import("@calcom/ui").then((mod) => mod.Time
|
|||||||
export const EventMeta = () => {
|
export const EventMeta = () => {
|
||||||
const { setTimezone, timeFormat, timezone } = useTimePreferences();
|
const { setTimezone, timeFormat, timezone } = useTimePreferences();
|
||||||
const selectedDuration = useBookerStore((state) => state.selectedDuration);
|
const selectedDuration = useBookerStore((state) => state.selectedDuration);
|
||||||
|
const setSelectedDuration = useBookerStore((state) => state.setSelectedDuration);
|
||||||
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
|
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
|
||||||
const bookerState = useBookerStore((state) => state.state);
|
const bookerState = useBookerStore((state) => state.state);
|
||||||
const bookingData = useBookerStore((state) => state.bookingData);
|
const bookingData = useBookerStore((state) => state.bookingData);
|
||||||
@@ -36,6 +38,13 @@ export const EventMeta = () => {
|
|||||||
const isEmbed = useIsEmbed();
|
const isEmbed = useIsEmbed();
|
||||||
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
|
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDuration && event?.length) {
|
||||||
|
setSelectedDuration(event.length);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [event?.length, selectedDuration]);
|
||||||
|
|
||||||
if (hideEventTypeDetails) {
|
if (hideEventTypeDetails) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Calendar, Columns, Grid } from "@calcom/ui/components/icon";
|
|||||||
import { TimeFormatToggle } from "../../components/TimeFormatToggle";
|
import { TimeFormatToggle } from "../../components/TimeFormatToggle";
|
||||||
import { useBookerStore } from "../store";
|
import { useBookerStore } from "../store";
|
||||||
import type { BookerLayout } from "../types";
|
import type { BookerLayout } from "../types";
|
||||||
|
import { OverlayCalendarContainer } from "./OverlayCalendar/OverlayCalendarContainer";
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
extraDays,
|
extraDays,
|
||||||
@@ -56,7 +57,12 @@ export function Header({
|
|||||||
|
|
||||||
// In month view we only show the layout toggle.
|
// In month view we only show the layout toggle.
|
||||||
if (isMonthView) {
|
if (isMonthView) {
|
||||||
return <LayoutToggleWithData />;
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<OverlayCalendarContainer />
|
||||||
|
<LayoutToggleWithData />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const endDate = selectedDate.add(layout === BookerLayouts.COLUMN_VIEW ? extraDays : extraDays - 1, "days");
|
const endDate = selectedDate.add(layout === BookerLayouts.COLUMN_VIEW ? extraDays : extraDays - 1, "days");
|
||||||
|
|
||||||
@@ -113,6 +119,7 @@ export function Header({
|
|||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="ml-auto flex gap-2">
|
||||||
|
<OverlayCalendarContainer />
|
||||||
<TimeFormatToggle />
|
<TimeFormatToggle />
|
||||||
<div className="fixed top-4 ltr:right-4 rtl:left-4">
|
<div className="fixed top-4 ltr:right-4 rtl:left-4">
|
||||||
<LayoutToggleWithData />
|
<LayoutToggleWithData />
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useEffect } from "react";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { Calendar } from "@calcom/features/calendars/weeklyview";
|
import { Calendar } from "@calcom/features/calendars/weeklyview";
|
||||||
|
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||||
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
|
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
|
||||||
|
|
||||||
import { useBookerStore } from "../store";
|
import { useBookerStore } from "../store";
|
||||||
import { useEvent, useScheduleForEvent } from "../utils/event";
|
import { useEvent, useScheduleForEvent } from "../utils/event";
|
||||||
|
import { getQueryParam } from "../utils/query-param";
|
||||||
|
import { useOverlayCalendarStore } from "./OverlayCalendar/store";
|
||||||
|
|
||||||
export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
||||||
const selectedDate = useBookerStore((state) => state.selectedDate);
|
const selectedDate = useBookerStore((state) => state.selectedDate);
|
||||||
const date = selectedDate || dayjs().format("YYYY-MM-DD");
|
const date = selectedDate || dayjs().format("YYYY-MM-DD");
|
||||||
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
|
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
|
||||||
const selectedEventDuration = useBookerStore((state) => state.selectedDuration);
|
const selectedEventDuration = useBookerStore((state) => state.selectedDuration);
|
||||||
|
const overlayEvents = useOverlayCalendarStore((state) => state.overlayBusyDates);
|
||||||
const schedule = useScheduleForEvent({
|
const schedule = useScheduleForEvent({
|
||||||
prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(),
|
prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(),
|
||||||
});
|
});
|
||||||
|
const displayOverlay = getQueryParam("overlayCalendar") === "true";
|
||||||
|
|
||||||
const event = useEvent();
|
const event = useEvent();
|
||||||
const eventDuration = selectedEventDuration || event?.data?.length || 30;
|
const eventDuration = selectedEventDuration || event?.data?.length || 30;
|
||||||
@@ -39,6 +44,24 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
|||||||
.add(extraDays - 1, "day")
|
.add(extraDays - 1, "day")
|
||||||
.toDate();
|
.toDate();
|
||||||
|
|
||||||
|
// HACK: force rerender when overlay events change
|
||||||
|
// Sine we dont use react router here we need to force rerender (ATOM SUPPORT)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
useEffect(() => {}, [displayOverlay]);
|
||||||
|
|
||||||
|
const overlayEventsForDate = useMemo(() => {
|
||||||
|
if (!overlayEvents || !displayOverlay) return [];
|
||||||
|
return overlayEvents.map((event, id) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
start: dayjs(event.start).toDate(),
|
||||||
|
end: dayjs(event.end).toDate(),
|
||||||
|
title: "Busy",
|
||||||
|
status: "ACCEPTED",
|
||||||
|
} as CalendarEvent;
|
||||||
|
});
|
||||||
|
}, [overlayEvents, displayOverlay]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full [--calendar-dates-sticky-offset:66px]">
|
<div className="h-full [--calendar-dates-sticky-offset:66px]">
|
||||||
<Calendar
|
<Calendar
|
||||||
@@ -46,7 +69,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
|||||||
availableTimeslots={availableSlots}
|
availableTimeslots={availableSlots}
|
||||||
startHour={0}
|
startHour={0}
|
||||||
endHour={23}
|
endHour={23}
|
||||||
events={[]}
|
events={overlayEventsForDate}
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
onEmptyCellClick={(date) => setSelectedTimeslot(date.toISOString())}
|
onEmptyCellClick={(date) => setSelectedTimeslot(date.toISOString())}
|
||||||
|
|||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import { Button, Switch } from "@calcom/ui";
|
||||||
|
import { Settings } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
import { useBookerStore } from "../../store";
|
||||||
|
import { OverlayCalendarContinueModal } from "../OverlayCalendar/OverlayCalendarContinueModal";
|
||||||
|
import { OverlayCalendarSettingsModal } from "../OverlayCalendar/OverlayCalendarSettingsModal";
|
||||||
|
import { useLocalSet } from "../hooks/useLocalSet";
|
||||||
|
import { useOverlayCalendarStore } from "./store";
|
||||||
|
|
||||||
|
export function OverlayCalendarContainer() {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const [continueWithProvider, setContinueWithProvider] = useState(false);
|
||||||
|
const [calendarSettingsOverlay, setCalendarSettingsOverlay] = useState(false);
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates);
|
||||||
|
|
||||||
|
const layout = useBookerStore((state) => state.layout);
|
||||||
|
const selectedDate = useBookerStore((state) => state.selectedDate);
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { timezone } = useTimePreferences();
|
||||||
|
|
||||||
|
// Move this to a hook
|
||||||
|
const { set, clearSet } = useLocalSet<{
|
||||||
|
credentialId: number;
|
||||||
|
externalId: string;
|
||||||
|
}>("toggledConnectedCalendars", []);
|
||||||
|
const overlayCalendarQueryParam = searchParams.get("overlayCalendar");
|
||||||
|
|
||||||
|
const { data: overlayBusyDates } = trpc.viewer.availability.calendarOverlay.useQuery(
|
||||||
|
{
|
||||||
|
loggedInUsersTz: timezone || "Europe/London",
|
||||||
|
dateFrom: selectedDate,
|
||||||
|
dateTo: selectedDate,
|
||||||
|
calendarsToLoad: Array.from(set).map((item) => ({
|
||||||
|
credentialId: item.credentialId,
|
||||||
|
externalId: item.externalId,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!session && set.size > 0 && overlayCalendarQueryParam === "true",
|
||||||
|
onError: () => {
|
||||||
|
clearSet();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (overlayBusyDates) {
|
||||||
|
const nowDate = dayjs();
|
||||||
|
const usersTimezoneDate = nowDate.tz(timezone);
|
||||||
|
|
||||||
|
const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60;
|
||||||
|
|
||||||
|
const offsettedArray = overlayBusyDates.map((item) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
start: dayjs(item.start).add(offset, "hours").toDate(),
|
||||||
|
end: dayjs(item.end).add(offset, "hours").toDate(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setOverlayBusyDates(offsettedArray);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [overlayBusyDates]);
|
||||||
|
|
||||||
|
// Toggle query param for overlay calendar
|
||||||
|
const toggleOverlayCalendarQueryParam = useCallback(
|
||||||
|
(state: boolean) => {
|
||||||
|
const current = new URLSearchParams(Array.from(searchParams.entries()));
|
||||||
|
if (state) {
|
||||||
|
current.set("overlayCalendar", "true");
|
||||||
|
} else {
|
||||||
|
current.delete("overlayCalendar");
|
||||||
|
}
|
||||||
|
// cast to string
|
||||||
|
const value = current.toString();
|
||||||
|
const query = value ? `?${value}` : "";
|
||||||
|
router.push(`${pathname}${query}`);
|
||||||
|
},
|
||||||
|
[searchParams, pathname, router]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a user is not logged in and the overlay calendar query param is true,
|
||||||
|
* show the continue modal so they can login / create an account
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session && overlayCalendarQueryParam === "true") {
|
||||||
|
toggleOverlayCalendarQueryParam(false);
|
||||||
|
setContinueWithProvider(true);
|
||||||
|
}
|
||||||
|
}, [session, overlayCalendarQueryParam, toggleOverlayCalendarQueryParam]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={classNames("hidden gap-2", layout === "week_view" ? "lg:flex" : "md:flex")}>
|
||||||
|
<div className="flex items-center gap-2 pr-2">
|
||||||
|
<Switch
|
||||||
|
data-testid="overlay-calendar-switch"
|
||||||
|
checked={overlayCalendarQueryParam === "true"}
|
||||||
|
id="overlayCalendar"
|
||||||
|
onCheckedChange={(state) => {
|
||||||
|
if (!session) {
|
||||||
|
setContinueWithProvider(state);
|
||||||
|
} else {
|
||||||
|
toggleOverlayCalendarQueryParam(state);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="overlayCalendar"
|
||||||
|
className="text-emphasis text-sm font-medium leading-none hover:cursor-pointer">
|
||||||
|
{t("overlay_my_calendar")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{session && (
|
||||||
|
<Button
|
||||||
|
size="base"
|
||||||
|
data-testid="overlay-calendar-settings-button"
|
||||||
|
variant="icon"
|
||||||
|
color="secondary"
|
||||||
|
StartIcon={Settings}
|
||||||
|
onClick={() => {
|
||||||
|
setCalendarSettingsOverlay(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<OverlayCalendarContinueModal
|
||||||
|
open={continueWithProvider}
|
||||||
|
onClose={(val) => {
|
||||||
|
setContinueWithProvider(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<OverlayCalendarSettingsModal
|
||||||
|
open={calendarSettingsOverlay}
|
||||||
|
onClose={(val) => {
|
||||||
|
setCalendarSettingsOverlay(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { CalendarSearch } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button, Dialog, DialogContent, DialogFooter } from "@calcom/ui";
|
||||||
|
|
||||||
|
interface IOverlayCalendarContinueModalProps {
|
||||||
|
open?: boolean;
|
||||||
|
onClose?: (state: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverlayCalendarContinueModal(props: IOverlayCalendarContinueModalProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useLocale();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={props.open} onOpenChange={props.onClose}>
|
||||||
|
<DialogContent
|
||||||
|
type="creation"
|
||||||
|
title={t("overlay_my_calendar")}
|
||||||
|
description={t("overlay_my_calendar_toc")}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
data-testid="overlay-calendar-continue-button"
|
||||||
|
onClick={() => {
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
currentUrl.pathname = "/login/";
|
||||||
|
currentUrl.searchParams.set("callbackUrl", window.location.pathname);
|
||||||
|
currentUrl.searchParams.set("overlayCalendar", "true");
|
||||||
|
|
||||||
|
router.push(currentUrl.toString());
|
||||||
|
}}
|
||||||
|
className="gap w-full items-center justify-center font-semibold"
|
||||||
|
StartIcon={CalendarSearch}>
|
||||||
|
{t("continue_with", { appName: APP_NAME })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
{/* Agh modal hacks */}
|
||||||
|
<></>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
EmptyScreen,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemTitle,
|
||||||
|
Switch,
|
||||||
|
DialogClose,
|
||||||
|
SkeletonContainer,
|
||||||
|
SkeletonText,
|
||||||
|
} from "@calcom/ui";
|
||||||
|
import { Calendar } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
import { useLocalSet } from "../hooks/useLocalSet";
|
||||||
|
import { useOverlayCalendarStore } from "./store";
|
||||||
|
|
||||||
|
interface IOverlayCalendarContinueModalProps {
|
||||||
|
open?: boolean;
|
||||||
|
onClose?: (state: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkeletonLoader = () => {
|
||||||
|
return (
|
||||||
|
<SkeletonContainer>
|
||||||
|
<div className="border-subtle mt-3 space-y-4 rounded-xl border px-4 py-4 ">
|
||||||
|
<SkeletonText className="h-4 w-full" />
|
||||||
|
<SkeletonText className="h-4 w-full" />
|
||||||
|
<SkeletonText className="h-4 w-full" />
|
||||||
|
<SkeletonText className="h-4 w-full" />
|
||||||
|
</div>
|
||||||
|
</SkeletonContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModalProps) {
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates);
|
||||||
|
const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery(undefined, {
|
||||||
|
enabled: !!props.open,
|
||||||
|
});
|
||||||
|
const { toggleValue, hasItem } = useLocalSet<{
|
||||||
|
credentialId: number;
|
||||||
|
externalId: string;
|
||||||
|
}>("toggledConnectedCalendars", []);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useLocale();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={props.open} onOpenChange={props.onClose}>
|
||||||
|
<DialogContent
|
||||||
|
enableOverflow
|
||||||
|
type="creation"
|
||||||
|
title="Calendar Settings"
|
||||||
|
className="pb-4"
|
||||||
|
description={t("view_overlay_calendar_events")}>
|
||||||
|
<div className="no-scrollbar max-h-full overflow-y-scroll ">
|
||||||
|
{isLoading ? (
|
||||||
|
<SkeletonLoader />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{data?.connectedCalendars.length === 0 ? (
|
||||||
|
<EmptyScreen
|
||||||
|
Icon={Calendar}
|
||||||
|
headline={t("no_calendar_installed")}
|
||||||
|
description={t("no_calendar_installed_description")}
|
||||||
|
buttonText={t("add_a_calendar")}
|
||||||
|
buttonOnClick={() => router.push("/apps/categories/calendar")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{data?.connectedCalendars.map((item) => (
|
||||||
|
<Fragment key={item.credentialId}>
|
||||||
|
{item.error && !item.calendars && (
|
||||||
|
<Alert severity="error" title={item.error.message} />
|
||||||
|
)}
|
||||||
|
{item?.error === undefined && item.calendars && (
|
||||||
|
<ListItem className="flex-col rounded-md">
|
||||||
|
<div className="flex w-full flex-1 items-center space-x-3 pb-4 rtl:space-x-reverse">
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
item.integration.logo && (
|
||||||
|
<img
|
||||||
|
className={classNames(
|
||||||
|
"h-10 w-10",
|
||||||
|
item.integration.logo.includes("-dark") && "dark:invert"
|
||||||
|
)}
|
||||||
|
src={item.integration.logo}
|
||||||
|
alt={item.integration.title}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<div className="flex-grow truncate pl-2">
|
||||||
|
<ListItemTitle component="h3" className="space-x-2 rtl:space-x-reverse">
|
||||||
|
<Link href={`/apps/${item.integration.slug}`}>
|
||||||
|
{item.integration.name || item.integration.title}
|
||||||
|
</Link>
|
||||||
|
</ListItemTitle>
|
||||||
|
<ListItemText component="p">{item.primary.email}</ListItemText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-subtle w-full border-t pt-4">
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{item.calendars.map((cal, index) => {
|
||||||
|
const id = cal.integrationTitle ?? `calendar-switch-${index}`;
|
||||||
|
return (
|
||||||
|
<li className="flex gap-3" key={id}>
|
||||||
|
<Switch
|
||||||
|
id={id}
|
||||||
|
checked={hasItem({
|
||||||
|
credentialId: item.credentialId,
|
||||||
|
externalId: cal.externalId,
|
||||||
|
})}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
toggleValue({
|
||||||
|
credentialId: item.credentialId,
|
||||||
|
externalId: cal.externalId,
|
||||||
|
});
|
||||||
|
setOverlayBusyDates([]);
|
||||||
|
utils.viewer.availability.calendarOverlay.reset();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor={id}>{cal.name}</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-2 self-end">
|
||||||
|
<DialogClose>{t("done")}</DialogClose>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import type { EventBusyDate } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
|
interface IOverlayCalendarStore {
|
||||||
|
overlayBusyDates: EventBusyDate[] | undefined;
|
||||||
|
setOverlayBusyDates: (busyDates: EventBusyDate[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOverlayCalendarStore = create<IOverlayCalendarStore>((set) => ({
|
||||||
|
overlayBusyDates: undefined,
|
||||||
|
setOverlayBusyDates: (busyDates: EventBusyDate[]) => {
|
||||||
|
set({ overlayBusyDates: busyDates });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface HasExternalId {
|
||||||
|
externalId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocalSet<T extends HasExternalId>(key: string, initialValue: T[]) {
|
||||||
|
const [set, setSet] = useState<Set<T>>(() => {
|
||||||
|
const storedValue = localStorage.getItem(key);
|
||||||
|
return storedValue ? new Set(JSON.parse(storedValue)) : new Set(initialValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(key, JSON.stringify(Array.from(set)));
|
||||||
|
}, [key, set]);
|
||||||
|
|
||||||
|
const addValue = (value: T) => {
|
||||||
|
setSet((prevSet) => new Set(prevSet).add(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeById = (id: string) => {
|
||||||
|
setSet((prevSet) => {
|
||||||
|
const updatedSet = new Set(prevSet);
|
||||||
|
updatedSet.forEach((item) => {
|
||||||
|
if (item.externalId === id) {
|
||||||
|
updatedSet.delete(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return updatedSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleValue = (value: T) => {
|
||||||
|
setSet((prevSet) => {
|
||||||
|
const updatedSet = new Set(prevSet);
|
||||||
|
let itemFound = false;
|
||||||
|
|
||||||
|
updatedSet.forEach((item) => {
|
||||||
|
if (item.externalId === value.externalId) {
|
||||||
|
itemFound = true;
|
||||||
|
updatedSet.delete(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!itemFound) {
|
||||||
|
updatedSet.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasItem = (value: T) => {
|
||||||
|
return Array.from(set).some((item) => item.externalId === value.externalId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSet = () => {
|
||||||
|
setSet(() => new Set());
|
||||||
|
// clear local storage too
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { set, addValue, removeById, toggleValue, hasItem, clearSet };
|
||||||
|
}
|
||||||
@@ -28,6 +28,17 @@ export const fadeInUp = {
|
|||||||
transition: { ease: "easeInOut", delay: 0.1 },
|
transition: { ease: "easeInOut", delay: 0.1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fadeInRight = {
|
||||||
|
variants: {
|
||||||
|
visible: { opacity: 1, x: 0 },
|
||||||
|
hidden: { opacity: 0, x: -20 },
|
||||||
|
},
|
||||||
|
initial: "hidden",
|
||||||
|
exit: "hidden",
|
||||||
|
animate: "visible",
|
||||||
|
transition: { ease: "easeInOut", delay: 0.1 },
|
||||||
|
};
|
||||||
|
|
||||||
type ResizeAnimationConfig = {
|
type ResizeAnimationConfig = {
|
||||||
[key in BookerLayout]: {
|
[key in BookerLayout]: {
|
||||||
[key in BookerState | "default"]?: React.CSSProperties;
|
[key in BookerState | "default"]?: React.CSSProperties;
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { CalendarX2 } from "lucide-react";
|
// We do not need to worry about importing framer-motion here as it is lazy imported in Booker.
|
||||||
|
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||||
|
import { AnimatePresence, m } from "framer-motion";
|
||||||
|
import { CalendarX2, ChevronRight } from "lucide-react";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import type { Slots } from "@calcom/features/schedules";
|
import type { Slots } from "@calcom/features/schedules";
|
||||||
@@ -7,17 +11,21 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import { Button, SkeletonText } from "@calcom/ui";
|
import { Button, SkeletonText } from "@calcom/ui";
|
||||||
|
|
||||||
import { useBookerStore } from "../Booker/store";
|
import { useBookerStore } from "../Booker/store";
|
||||||
|
import { getQueryParam } from "../Booker/utils/query-param";
|
||||||
import { useTimePreferences } from "../lib";
|
import { useTimePreferences } from "../lib";
|
||||||
|
import { useCheckOverlapWithOverlay } from "../lib/useCheckOverlapWithOverlay";
|
||||||
import { SeatsAvailabilityText } from "./SeatsAvailabilityText";
|
import { SeatsAvailabilityText } from "./SeatsAvailabilityText";
|
||||||
|
|
||||||
|
type TOnTimeSelect = (
|
||||||
|
time: string,
|
||||||
|
attendees: number,
|
||||||
|
seatsPerTimeSlot?: number | null,
|
||||||
|
bookingUid?: string
|
||||||
|
) => void;
|
||||||
|
|
||||||
type AvailableTimesProps = {
|
type AvailableTimesProps = {
|
||||||
slots: Slots[string];
|
slots: Slots[string];
|
||||||
onTimeSelect: (
|
onTimeSelect: TOnTimeSelect;
|
||||||
time: string,
|
|
||||||
attendees: number,
|
|
||||||
seatsPerTimeSlot?: number | null,
|
|
||||||
bookingUid?: string
|
|
||||||
) => void;
|
|
||||||
seatsPerTimeSlot?: number | null;
|
seatsPerTimeSlot?: number | null;
|
||||||
showAvailableSeatsCount?: boolean | null;
|
showAvailableSeatsCount?: boolean | null;
|
||||||
showTimeFormatToggle?: boolean;
|
showTimeFormatToggle?: boolean;
|
||||||
@@ -25,6 +33,148 @@ type AvailableTimesProps = {
|
|||||||
selectedSlots?: string[];
|
selectedSlots?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SlotItem = ({
|
||||||
|
slot,
|
||||||
|
seatsPerTimeSlot,
|
||||||
|
selectedSlots,
|
||||||
|
onTimeSelect,
|
||||||
|
showAvailableSeatsCount,
|
||||||
|
}: {
|
||||||
|
slot: Slots[string][number];
|
||||||
|
seatsPerTimeSlot?: number | null;
|
||||||
|
selectedSlots?: string[];
|
||||||
|
onTimeSelect: TOnTimeSelect;
|
||||||
|
showAvailableSeatsCount?: boolean | null;
|
||||||
|
}) => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const overlayCalendarToggled = getQueryParam("overlayCalendar") === "true";
|
||||||
|
const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]);
|
||||||
|
const selectedDuration = useBookerStore((state) => state.selectedDuration);
|
||||||
|
const bookingData = useBookerStore((state) => state.bookingData);
|
||||||
|
const layout = useBookerStore((state) => state.layout);
|
||||||
|
const hasTimeSlots = !!seatsPerTimeSlot;
|
||||||
|
const computedDateWithUsersTimezone = dayjs.utc(slot.time).tz(timezone);
|
||||||
|
|
||||||
|
const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeSlot);
|
||||||
|
const isHalfFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5;
|
||||||
|
const isNearlyFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83;
|
||||||
|
const colorClass = isNearlyFull ? "bg-rose-600" : isHalfFull ? "bg-yellow-500" : "bg-emerald-400";
|
||||||
|
|
||||||
|
const nowDate = dayjs();
|
||||||
|
const usersTimezoneDate = nowDate.tz(timezone);
|
||||||
|
|
||||||
|
const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60;
|
||||||
|
|
||||||
|
const { isOverlapping, overlappingTimeEnd, overlappingTimeStart } = useCheckOverlapWithOverlay(
|
||||||
|
computedDateWithUsersTimezone,
|
||||||
|
selectedDuration,
|
||||||
|
offset
|
||||||
|
);
|
||||||
|
const [overlapConfirm, setOverlapConfirm] = useState(false);
|
||||||
|
|
||||||
|
const onButtonClick = useCallback(() => {
|
||||||
|
if (!overlayCalendarToggled) {
|
||||||
|
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isOverlapping && overlapConfirm) {
|
||||||
|
setOverlapConfirm(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOverlapping && !overlapConfirm) {
|
||||||
|
setOverlapConfirm(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!overlapConfirm) {
|
||||||
|
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
overlayCalendarToggled,
|
||||||
|
isOverlapping,
|
||||||
|
overlapConfirm,
|
||||||
|
onTimeSelect,
|
||||||
|
slot.time,
|
||||||
|
slot?.attendees,
|
||||||
|
slot.bookingUid,
|
||||||
|
seatsPerTimeSlot,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
key={slot.time}
|
||||||
|
disabled={bookingFull || !!(slot.bookingUid && slot.bookingUid === bookingData?.uid)}
|
||||||
|
data-testid="time"
|
||||||
|
data-disabled={bookingFull}
|
||||||
|
data-time={slot.time}
|
||||||
|
onClick={onButtonClick}
|
||||||
|
className={classNames(
|
||||||
|
"min-h-9 hover:border-brand-default mb-2 flex h-auto w-full flex-grow flex-col justify-center py-2",
|
||||||
|
selectedSlots?.includes(slot.time) && "border-brand-default"
|
||||||
|
)}
|
||||||
|
color="secondary">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!hasTimeSlots && overlayCalendarToggled && (
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
"inline-block h-2 w-2 rounded-full",
|
||||||
|
isOverlapping ? "bg-rose-600" : "bg-emerald-400"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{computedDateWithUsersTimezone.format(timeFormat)}
|
||||||
|
</div>
|
||||||
|
{bookingFull && <p className="text-sm">{t("booking_full")}</p>}
|
||||||
|
{hasTimeSlots && !bookingFull && (
|
||||||
|
<p className="flex items-center text-sm">
|
||||||
|
<span
|
||||||
|
className={classNames(colorClass, "mr-1 inline-block h-2 w-2 rounded-full")}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<SeatsAvailabilityText
|
||||||
|
showExact={!!showAvailableSeatsCount}
|
||||||
|
totalSeats={seatsPerTimeSlot}
|
||||||
|
bookedSeats={slot.attendees || 0}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{overlapConfirm && isOverlapping && (
|
||||||
|
<HoverCard.Root>
|
||||||
|
<HoverCard.Trigger asChild>
|
||||||
|
<m.div initial={{ width: 0 }} animate={{ width: "auto" }} exit={{ width: 0 }}>
|
||||||
|
<Button
|
||||||
|
variant={layout === "column_view" ? "icon" : "button"}
|
||||||
|
StartIcon={layout === "column_view" ? ChevronRight : undefined}
|
||||||
|
onClick={() =>
|
||||||
|
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)
|
||||||
|
}>
|
||||||
|
{layout !== "column_view" && t("confirm")}
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
</HoverCard.Trigger>
|
||||||
|
<HoverCard.Portal>
|
||||||
|
<HoverCard.Content side="top" align="end" sideOffset={2}>
|
||||||
|
<div className="text-emphasis bg-inverted text-inverted w-[var(--booker-timeslots-width)] rounded-md p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p>Busy</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted">
|
||||||
|
{overlappingTimeStart} - {overlappingTimeEnd}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</HoverCard.Content>
|
||||||
|
</HoverCard.Portal>
|
||||||
|
</HoverCard.Root>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const AvailableTimes = ({
|
export const AvailableTimes = ({
|
||||||
slots,
|
slots,
|
||||||
onTimeSelect,
|
onTimeSelect,
|
||||||
@@ -34,10 +184,7 @@ export const AvailableTimes = ({
|
|||||||
className,
|
className,
|
||||||
selectedSlots,
|
selectedSlots,
|
||||||
}: AvailableTimesProps) => {
|
}: AvailableTimesProps) => {
|
||||||
const { t, i18n } = useLocale();
|
const { t } = useLocale();
|
||||||
const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]);
|
|
||||||
const bookingData = useBookerStore((state) => state.bookingData);
|
|
||||||
const hasTimeSlots = !!seatsPerTimeSlot;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames("text-default flex flex-col", className)}>
|
<div className={classNames("text-default flex flex-col", className)}>
|
||||||
@@ -50,45 +197,16 @@ export const AvailableTimes = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{slots.map((slot) => (
|
||||||
{slots.map((slot) => {
|
<SlotItem
|
||||||
const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeSlot);
|
key={slot.time}
|
||||||
const isHalfFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5;
|
onTimeSelect={onTimeSelect}
|
||||||
const isNearlyFull =
|
slot={slot}
|
||||||
slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83;
|
selectedSlots={selectedSlots}
|
||||||
|
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||||
const colorClass = isNearlyFull ? "bg-rose-600" : isHalfFull ? "bg-yellow-500" : "bg-emerald-400";
|
showAvailableSeatsCount={showAvailableSeatsCount}
|
||||||
return (
|
/>
|
||||||
<Button
|
))}
|
||||||
key={slot.time}
|
|
||||||
disabled={bookingFull || !!(slot.bookingUid && slot.bookingUid === bookingData?.uid)}
|
|
||||||
data-testid="time"
|
|
||||||
data-disabled={bookingFull}
|
|
||||||
data-time={slot.time}
|
|
||||||
onClick={() => onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)}
|
|
||||||
className={classNames(
|
|
||||||
"min-h-9 hover:border-brand-default mb-2 flex h-auto w-full flex-col justify-center py-2",
|
|
||||||
selectedSlots?.includes(slot.time) && "border-brand-default"
|
|
||||||
)}
|
|
||||||
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">
|
|
||||||
<span
|
|
||||||
className={classNames(colorClass, "mr-1 inline-block h-2 w-2 rounded-full")}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<SeatsAvailabilityText
|
|
||||||
showExact={!!showAvailableSeatsCount}
|
|
||||||
totalSeats={seatsPerTimeSlot}
|
|
||||||
bookedSeats={slot.attendees || 0}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { HeadSeo } from "@calcom/ui";
|
import { HeadSeo } from "@calcom/ui";
|
||||||
@@ -14,10 +15,20 @@ interface BookerSeoProps {
|
|||||||
teamSlug?: string | null;
|
teamSlug?: string | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
};
|
};
|
||||||
|
bookingData?: GetBookingType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookerSeo = (props: BookerSeoProps) => {
|
export const BookerSeo = (props: BookerSeoProps) => {
|
||||||
const { eventSlug, username, rescheduleUid, hideBranding, isTeamEvent, entity, isSEOIndexable } = props;
|
const {
|
||||||
|
eventSlug,
|
||||||
|
username,
|
||||||
|
rescheduleUid,
|
||||||
|
hideBranding,
|
||||||
|
isTeamEvent,
|
||||||
|
entity,
|
||||||
|
isSEOIndexable,
|
||||||
|
bookingData,
|
||||||
|
} = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { data: event } = trpc.viewer.public.event.useQuery(
|
const { data: event } = trpc.viewer.public.event.useQuery(
|
||||||
{ username, eventSlug, isTeamEvent, org: entity.orgSlug ?? null },
|
{ username, eventSlug, isTeamEvent, org: entity.orgSlug ?? null },
|
||||||
@@ -29,7 +40,7 @@ export const BookerSeo = (props: BookerSeoProps) => {
|
|||||||
const title = event?.title ?? "";
|
const title = event?.title ?? "";
|
||||||
return (
|
return (
|
||||||
<HeadSeo
|
<HeadSeo
|
||||||
title={`${rescheduleUid ? t("reschedule") : ""} ${title} | ${profileName}`}
|
title={`${rescheduleUid && !!bookingData ? t("reschedule") : ""} ${title} | ${profileName}`}
|
||||||
description={`${rescheduleUid ? t("reschedule") : ""} ${title}`}
|
description={`${rescheduleUid ? t("reschedule") : ""} ${title}`}
|
||||||
meeting={{
|
meeting={{
|
||||||
title: title,
|
title: title,
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const getBookingWithResponses = <
|
|||||||
|
|
||||||
export default getBooking;
|
export default getBooking;
|
||||||
|
|
||||||
export const getBookingForReschedule = async (uid: string) => {
|
export const getBookingForReschedule = async (uid: string, userId?: number) => {
|
||||||
let rescheduleUid: string | null = null;
|
let rescheduleUid: string | null = null;
|
||||||
const theBooking = await prisma.booking.findFirst({
|
const theBooking = await prisma.booking.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -117,8 +117,25 @@ export const getBookingForReschedule = async (uid: string) => {
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
eventType: {
|
||||||
|
select: {
|
||||||
|
seatsPerTimeSlot: true,
|
||||||
|
hosts: {
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
let bookingSeatReferenceUid: number | null = null;
|
||||||
|
|
||||||
// If no booking is found via the uid, it's probably a booking seat
|
// If no booking is found via the uid, it's probably a booking seat
|
||||||
// that its being rescheduled, which we query next.
|
// that its being rescheduled, which we query next.
|
||||||
@@ -144,11 +161,26 @@ export const getBookingForReschedule = async (uid: string) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (bookingSeat) {
|
if (bookingSeat) {
|
||||||
|
bookingSeatReferenceUid = bookingSeat.id;
|
||||||
rescheduleUid = bookingSeat.booking.uid;
|
rescheduleUid = bookingSeat.booking.uid;
|
||||||
attendeeEmail = bookingSeat.attendee.email;
|
attendeeEmail = bookingSeat.attendee.email;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we have the booking and not bookingSeat, we need to make sure the booking belongs to the userLoggedIn
|
||||||
|
// Otherwise, we return null here.
|
||||||
|
let hasOwnershipOnBooking = false;
|
||||||
|
if (theBooking && theBooking?.eventType?.seatsPerTimeSlot && bookingSeatReferenceUid === null) {
|
||||||
|
const isOwnerOfBooking = theBooking.userId === userId;
|
||||||
|
|
||||||
|
const isHostOfEventType = theBooking?.eventType?.hosts.some((host) => host.userId === userId);
|
||||||
|
|
||||||
|
const isUserIdInBooking = theBooking.userId === userId;
|
||||||
|
|
||||||
|
if (!isOwnerOfBooking && !isHostOfEventType && !isUserIdInBooking) return null;
|
||||||
|
hasOwnershipOnBooking = true;
|
||||||
|
}
|
||||||
|
|
||||||
// If we don't have a booking and no rescheduleUid, the ID is invalid,
|
// If we don't have a booking and no rescheduleUid, the ID is invalid,
|
||||||
// and we return null here.
|
// and we return null here.
|
||||||
if (!theBooking && !rescheduleUid) return null;
|
if (!theBooking && !rescheduleUid) return null;
|
||||||
@@ -161,6 +193,8 @@ export const getBookingForReschedule = async (uid: string) => {
|
|||||||
...booking,
|
...booking,
|
||||||
attendees: rescheduleUid
|
attendees: rescheduleUid
|
||||||
? booking.attendees.filter((attendee) => attendee.email === attendeeEmail)
|
? booking.attendees.filter((attendee) => attendee.email === attendeeEmail)
|
||||||
|
: hasOwnershipOnBooking
|
||||||
|
? []
|
||||||
: booking.attendees,
|
: booking.attendees,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -136,6 +136,19 @@ async function handler(req: CustomRequest) {
|
|||||||
throw new HttpError({ statusCode: 400, message: "User not found" });
|
throw new HttpError({ statusCode: 400, message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the booking is a seated event and there is no seatReferenceUid we should validate that logged in user is host
|
||||||
|
if (bookingToDelete.eventType?.seatsPerTimeSlot && !seatReferenceUid) {
|
||||||
|
const userIsHost = bookingToDelete.eventType.hosts.find((host) => {
|
||||||
|
if (host.user.id === userId) return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const userIsOwnerOfEventType = bookingToDelete.eventType.owner?.id === userId;
|
||||||
|
|
||||||
|
if (!userIsHost && !userIsOwnerOfEventType) {
|
||||||
|
throw new HttpError({ statusCode: 401, message: "User not a host of this event" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// get webhooks
|
// get webhooks
|
||||||
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
|
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
|
||||||
|
|
||||||
|
|||||||
@@ -379,7 +379,6 @@ async function ensureAvailableUsers(
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
log.debug("getUserAvailability for users", JSON.stringify({ users: eventType.users.map((u) => u.id) }));
|
|
||||||
/** Let's start checking for availability */
|
/** Let's start checking for availability */
|
||||||
for (const user of eventType.users) {
|
for (const user of eventType.users) {
|
||||||
const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability(
|
const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability(
|
||||||
@@ -968,7 +967,7 @@ async function handler(
|
|||||||
if (
|
if (
|
||||||
availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length
|
availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length
|
||||||
) {
|
) {
|
||||||
throw new Error("Some users are unavailable for booking.");
|
throw new Error("Some of the hosts are unavailable for booking.");
|
||||||
}
|
}
|
||||||
// Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer.
|
// Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer.
|
||||||
users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers];
|
users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers];
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { describe } from "vitest";
|
||||||
|
|
||||||
|
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||||
|
|
||||||
|
describe("Booking Limits", () => {
|
||||||
|
test.todo("Test these cases that were failing earlier https://github.com/calcom/cal.com/pull/10480");
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe } from "vitest";
|
||||||
|
|
||||||
|
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||||
|
|
||||||
|
import { setupAndTeardown } from "./lib/setupAndTeardown";
|
||||||
|
|
||||||
|
describe("handleNewBooking", () => {
|
||||||
|
setupAndTeardown();
|
||||||
|
test.todo("Dynamic Group Booking");
|
||||||
|
});
|
||||||
+200
-687
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
|||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
|
||||||
|
import type { CustomNextApiRequest, CustomNextApiResponse } from "../fresh-booking.test";
|
||||||
|
|
||||||
|
export function createMockNextJsRequest(...args: Parameters<typeof createMocks>) {
|
||||||
|
return createMocks<CustomNextApiRequest, CustomNextApiResponse>(...args);
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
import { getDate } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||||
|
|
||||||
|
export function getBasicMockRequestDataForBooking() {
|
||||||
|
return {
|
||||||
|
start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`,
|
||||||
|
end: `${getDate({ dateIncrement: 1 }).dateString}T04:30:00.000Z`,
|
||||||
|
eventTypeSlug: "no-confirmation",
|
||||||
|
timeZone: "Asia/Calcutta",
|
||||||
|
language: "en",
|
||||||
|
user: "teampro",
|
||||||
|
metadata: {},
|
||||||
|
hasHashedBookingLink: false,
|
||||||
|
hashedLink: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function getMockRequestDataForBooking({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: Partial<ReturnType<typeof getBasicMockRequestDataForBooking>> & {
|
||||||
|
eventTypeId: number;
|
||||||
|
rescheduleUid?: string;
|
||||||
|
bookingUid?: string;
|
||||||
|
responses: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
location: { optionValue: ""; value: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
...getBasicMockRequestDataForBooking(),
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
enableEmailFeature,
|
||||||
|
mockNoTranslations,
|
||||||
|
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||||
|
|
||||||
|
export function setupAndTeardown() {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Required to able to generate token in email in some cases
|
||||||
|
process.env.CALENDSO_ENCRYPTION_KEY = "abcdefghjnmkljhjklmnhjklkmnbhjui";
|
||||||
|
process.env.STRIPE_WEBHOOK_SECRET = "MOCK_STRIPE_WEBHOOK_SECRET";
|
||||||
|
// We are setting it in vitest.config.ts because otherwise it's too late to set it.
|
||||||
|
// process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY";
|
||||||
|
mockNoTranslations();
|
||||||
|
// mockEnableEmailFeature();
|
||||||
|
enableEmailFeature();
|
||||||
|
globalThis.testEmails = [];
|
||||||
|
fetchMock.resetMocks();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.CALENDSO_ENCRYPTION_KEY;
|
||||||
|
delete process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
delete process.env.DAILY_API_KEY;
|
||||||
|
globalThis.testEmails = [];
|
||||||
|
fetchMock.resetMocks();
|
||||||
|
// process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY";
|
||||||
|
});
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe } from "vitest";
|
||||||
|
|
||||||
|
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||||
|
|
||||||
|
import { setupAndTeardown } from "./lib/setupAndTeardown";
|
||||||
|
|
||||||
|
describe("handleNewBooking", () => {
|
||||||
|
setupAndTeardown();
|
||||||
|
|
||||||
|
test.todo("Managed Event Type booking");
|
||||||
|
});
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
import prismaMock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||||
|
|
||||||
|
import { describe, expect } from "vitest";
|
||||||
|
|
||||||
|
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
|
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||||
|
import {
|
||||||
|
createBookingScenario,
|
||||||
|
getDate,
|
||||||
|
getGoogleCalendarCredential,
|
||||||
|
TestData,
|
||||||
|
getOrganizer,
|
||||||
|
getBooker,
|
||||||
|
getScenarioData,
|
||||||
|
mockSuccessfulVideoMeetingCreation,
|
||||||
|
mockCalendarToHaveNoBusySlots,
|
||||||
|
mockCalendarToCrashOnUpdateEvent,
|
||||||
|
BookingLocations,
|
||||||
|
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||||
|
import {
|
||||||
|
expectWorkflowToBeTriggered,
|
||||||
|
expectBookingToBeInDatabase,
|
||||||
|
expectBookingRescheduledWebhookToHaveBeenFired,
|
||||||
|
expectSuccessfulBookingRescheduledEmails,
|
||||||
|
expectSuccessfulCalendarEventUpdationInCalendar,
|
||||||
|
expectSuccessfulVideoMeetingUpdationInCalendar,
|
||||||
|
expectBookingInDBToBeRescheduledFromTo,
|
||||||
|
} from "@calcom/web/test/utils/bookingScenario/expects";
|
||||||
|
|
||||||
|
import { createMockNextJsRequest } from "./lib/createMockNextJsRequest";
|
||||||
|
import { getMockRequestDataForBooking } from "./lib/getMockRequestDataForBooking";
|
||||||
|
import { setupAndTeardown } from "./lib/setupAndTeardown";
|
||||||
|
|
||||||
|
// Local test runs sometime gets too slow
|
||||||
|
const timeout = process.env.CI ? 5000 : 20000;
|
||||||
|
|
||||||
|
describe("handleNewBooking", () => {
|
||||||
|
setupAndTeardown();
|
||||||
|
|
||||||
|
describe("Reschedule", () => {
|
||||||
|
test(
|
||||||
|
`should rechedule an existing booking successfully with Cal Video(Daily Video)
|
||||||
|
1. Should cancel the existing booking
|
||||||
|
2. Should create a new booking in the database
|
||||||
|
3. Should send emails to the booker as well as organizer
|
||||||
|
4. Should trigger BOOKING_RESCHEDULED webhook
|
||||||
|
`,
|
||||||
|
async ({ emails }) => {
|
||||||
|
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||||
|
const booker = getBooker({
|
||||||
|
email: "booker@example.com",
|
||||||
|
name: "Booker",
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizer = getOrganizer({
|
||||||
|
name: "Organizer",
|
||||||
|
email: "organizer@example.com",
|
||||||
|
id: 101,
|
||||||
|
schedules: [TestData.schedules.IstWorkHours],
|
||||||
|
credentials: [getGoogleCalendarCredential()],
|
||||||
|
selectedCalendars: [TestData.selectedCalendars.google],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||||
|
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
webhooks: [
|
||||||
|
{
|
||||||
|
userId: organizer.id,
|
||||||
|
eventTriggers: ["BOOKING_CREATED"],
|
||||||
|
subscriberUrl: "http://my-webhook.example.com",
|
||||||
|
active: true,
|
||||||
|
eventTypeId: 1,
|
||||||
|
appId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
slotInterval: 45,
|
||||||
|
length: 45,
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bookings: [
|
||||||
|
{
|
||||||
|
uid: uidOfBookingToBeRescheduled,
|
||||||
|
eventTypeId: 1,
|
||||||
|
status: BookingStatus.ACCEPTED,
|
||||||
|
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||||
|
endTime: `${plus1DateString}T05:15:00.000Z`,
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.dailyvideo.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASS",
|
||||||
|
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.googlecalendar.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASSWORD",
|
||||||
|
meetingUrl: "https://UNUSED_URL",
|
||||||
|
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||||
|
credentialId: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organizer,
|
||||||
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoMock = mockSuccessfulVideoMeetingCreation({
|
||||||
|
metadataLookupKey: "dailyvideo",
|
||||||
|
});
|
||||||
|
|
||||||
|
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||||
|
create: {
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
uid: "UPDATED_MOCK_ID",
|
||||||
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockBookingData = getMockRequestDataForBooking({
|
||||||
|
data: {
|
||||||
|
eventTypeId: 1,
|
||||||
|
rescheduleUid: uidOfBookingToBeRescheduled,
|
||||||
|
start: `${plus1DateString}T04:00:00.000Z`,
|
||||||
|
end: `${plus1DateString}T04:15:00.000Z`,
|
||||||
|
responses: {
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { req } = createMockNextJsRequest({
|
||||||
|
method: "POST",
|
||||||
|
body: mockBookingData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdBooking = await handleNewBooking(req);
|
||||||
|
|
||||||
|
const previousBooking = await prismaMock.booking.findUnique({
|
||||||
|
where: {
|
||||||
|
uid: uidOfBookingToBeRescheduled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.silly({
|
||||||
|
previousBooking,
|
||||||
|
allBookings: await prismaMock.booking.findMany(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expect previous booking to be cancelled
|
||||||
|
await expectBookingToBeInDatabase({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
uid: uidOfBookingToBeRescheduled,
|
||||||
|
status: BookingStatus.CANCELLED,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(previousBooking?.status).toBe(BookingStatus.CANCELLED);
|
||||||
|
/**
|
||||||
|
* Booking Time should be new time
|
||||||
|
*/
|
||||||
|
expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`);
|
||||||
|
expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`);
|
||||||
|
|
||||||
|
await expectBookingInDBToBeRescheduledFromTo({
|
||||||
|
from: {
|
||||||
|
uid: uidOfBookingToBeRescheduled,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
description: "",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
uid: createdBooking.uid!,
|
||||||
|
eventTypeId: mockBookingData.eventTypeId,
|
||||||
|
status: BookingStatus.ACCEPTED,
|
||||||
|
location: BookingLocations.CalVideo,
|
||||||
|
responses: expect.objectContaining({
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
}),
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.dailyvideo.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASS",
|
||||||
|
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.googlecalendar.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASSWORD",
|
||||||
|
meetingUrl: "https://UNUSED_URL",
|
||||||
|
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expectWorkflowToBeTriggered();
|
||||||
|
|
||||||
|
expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, {
|
||||||
|
calEvent: {
|
||||||
|
location: "http://mock-dailyvideo.example.com",
|
||||||
|
},
|
||||||
|
bookingRef: {
|
||||||
|
type: appStoreMetadata.dailyvideo.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASS",
|
||||||
|
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, {
|
||||||
|
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||||
|
calEvent: {
|
||||||
|
videoCallData: expect.objectContaining({
|
||||||
|
url: "http://mock-dailyvideo.example.com",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessfulBookingRescheduledEmails({
|
||||||
|
booker,
|
||||||
|
organizer,
|
||||||
|
emails,
|
||||||
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||||
|
});
|
||||||
|
expectBookingRescheduledWebhookToHaveBeenFired({
|
||||||
|
booker,
|
||||||
|
organizer,
|
||||||
|
location: BookingLocations.CalVideo,
|
||||||
|
subscriberUrl: "http://my-webhook.example.com",
|
||||||
|
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
timeout
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
`should rechedule a booking successfully and update the event in the same externalCalendarId as was used in the booking earlier.
|
||||||
|
1. Should cancel the existing booking
|
||||||
|
2. Should create a new booking in the database
|
||||||
|
3. Should send emails to the booker as well as organizer
|
||||||
|
4. Should trigger BOOKING_RESCHEDULED webhook
|
||||||
|
`,
|
||||||
|
async ({ emails }) => {
|
||||||
|
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||||
|
const booker = getBooker({
|
||||||
|
email: "booker@example.com",
|
||||||
|
name: "Booker",
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizer = getOrganizer({
|
||||||
|
name: "Organizer",
|
||||||
|
email: "organizer@example.com",
|
||||||
|
id: 101,
|
||||||
|
schedules: [TestData.schedules.IstWorkHours],
|
||||||
|
credentials: [getGoogleCalendarCredential()],
|
||||||
|
selectedCalendars: [TestData.selectedCalendars.google],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||||
|
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
webhooks: [
|
||||||
|
{
|
||||||
|
userId: organizer.id,
|
||||||
|
eventTriggers: ["BOOKING_CREATED"],
|
||||||
|
subscriberUrl: "http://my-webhook.example.com",
|
||||||
|
active: true,
|
||||||
|
eventTypeId: 1,
|
||||||
|
appId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
slotInterval: 45,
|
||||||
|
length: 45,
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
destinationCalendar: {
|
||||||
|
integration: "google_calendar",
|
||||||
|
externalId: "event-type-1@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bookings: [
|
||||||
|
{
|
||||||
|
uid: uidOfBookingToBeRescheduled,
|
||||||
|
eventTypeId: 1,
|
||||||
|
status: BookingStatus.ACCEPTED,
|
||||||
|
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||||
|
endTime: `${plus1DateString}T05:15:00.000Z`,
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.dailyvideo.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASS",
|
||||||
|
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.googlecalendar.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASSWORD",
|
||||||
|
meetingUrl: "https://UNUSED_URL",
|
||||||
|
externalCalendarId: "existing-event-type@example.com",
|
||||||
|
credentialId: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organizer,
|
||||||
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoMock = mockSuccessfulVideoMeetingCreation({
|
||||||
|
metadataLookupKey: "dailyvideo",
|
||||||
|
});
|
||||||
|
|
||||||
|
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||||
|
create: {
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||||
|
uid: "UPDATED_MOCK_ID",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockBookingData = getMockRequestDataForBooking({
|
||||||
|
data: {
|
||||||
|
eventTypeId: 1,
|
||||||
|
rescheduleUid: uidOfBookingToBeRescheduled,
|
||||||
|
start: `${plus1DateString}T04:00:00.000Z`,
|
||||||
|
end: `${plus1DateString}T04:15:00.000Z`,
|
||||||
|
responses: {
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { req } = createMockNextJsRequest({
|
||||||
|
method: "POST",
|
||||||
|
body: mockBookingData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdBooking = await handleNewBooking(req);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking Time should be new time
|
||||||
|
*/
|
||||||
|
expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`);
|
||||||
|
expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`);
|
||||||
|
|
||||||
|
await expectBookingInDBToBeRescheduledFromTo({
|
||||||
|
from: {
|
||||||
|
uid: uidOfBookingToBeRescheduled,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
description: "",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
uid: createdBooking.uid!,
|
||||||
|
eventTypeId: mockBookingData.eventTypeId,
|
||||||
|
status: BookingStatus.ACCEPTED,
|
||||||
|
location: BookingLocations.CalVideo,
|
||||||
|
responses: expect.objectContaining({
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
}),
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.dailyvideo.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASS",
|
||||||
|
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.googlecalendar.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASSWORD",
|
||||||
|
meetingUrl: "https://UNUSED_URL",
|
||||||
|
externalCalendarId: "existing-event-type@example.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expectWorkflowToBeTriggered();
|
||||||
|
|
||||||
|
expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, {
|
||||||
|
calEvent: {
|
||||||
|
location: "http://mock-dailyvideo.example.com",
|
||||||
|
},
|
||||||
|
bookingRef: {
|
||||||
|
type: appStoreMetadata.dailyvideo.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASS",
|
||||||
|
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// updateEvent uses existing booking's externalCalendarId to update the event in calendar.
|
||||||
|
// and not the event-type's organizer's which is event-type-1@example.com
|
||||||
|
expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, {
|
||||||
|
externalCalendarId: "existing-event-type@example.com",
|
||||||
|
calEvent: {
|
||||||
|
location: "http://mock-dailyvideo.example.com",
|
||||||
|
},
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessfulBookingRescheduledEmails({
|
||||||
|
booker,
|
||||||
|
organizer,
|
||||||
|
emails,
|
||||||
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||||
|
});
|
||||||
|
expectBookingRescheduledWebhookToHaveBeenFired({
|
||||||
|
booker,
|
||||||
|
organizer,
|
||||||
|
location: BookingLocations.CalVideo,
|
||||||
|
subscriberUrl: "http://my-webhook.example.com",
|
||||||
|
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
`an error in updating a calendar event should not stop the rescheduling - Current behaviour is wrong as the booking is resheduled but no-one is notified of it`,
|
||||||
|
async ({}) => {
|
||||||
|
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||||
|
const booker = getBooker({
|
||||||
|
email: "booker@example.com",
|
||||||
|
name: "Booker",
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizer = getOrganizer({
|
||||||
|
name: "Organizer",
|
||||||
|
email: "organizer@example.com",
|
||||||
|
id: 101,
|
||||||
|
schedules: [TestData.schedules.IstWorkHours],
|
||||||
|
credentials: [getGoogleCalendarCredential()],
|
||||||
|
selectedCalendars: [TestData.selectedCalendars.google],
|
||||||
|
destinationCalendar: {
|
||||||
|
integration: "google_calendar",
|
||||||
|
externalId: "organizer@google-calendar.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
|
||||||
|
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
webhooks: [
|
||||||
|
{
|
||||||
|
userId: organizer.id,
|
||||||
|
eventTriggers: ["BOOKING_CREATED"],
|
||||||
|
subscriberUrl: "http://my-webhook.example.com",
|
||||||
|
active: true,
|
||||||
|
eventTypeId: 1,
|
||||||
|
appId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
slotInterval: 45,
|
||||||
|
length: 45,
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bookings: [
|
||||||
|
{
|
||||||
|
uid: uidOfBookingToBeRescheduled,
|
||||||
|
eventTypeId: 1,
|
||||||
|
status: BookingStatus.ACCEPTED,
|
||||||
|
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||||
|
endTime: `${plus1DateString}T05:15:00.000Z`,
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.dailyvideo.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASS",
|
||||||
|
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.googlecalendar.type,
|
||||||
|
uid: "ORIGINAL_BOOKING_UID",
|
||||||
|
meetingId: "ORIGINAL_MEETING_ID",
|
||||||
|
meetingPassword: "ORIGINAL_MEETING_PASSWORD",
|
||||||
|
meetingUrl: "https://ORIGINAL_MEETING_URL",
|
||||||
|
externalCalendarId: "existing-event-type@example.com",
|
||||||
|
credentialId: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organizer,
|
||||||
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const _calendarMock = mockCalendarToCrashOnUpdateEvent("googlecalendar");
|
||||||
|
|
||||||
|
const mockBookingData = getMockRequestDataForBooking({
|
||||||
|
data: {
|
||||||
|
eventTypeId: 1,
|
||||||
|
rescheduleUid: uidOfBookingToBeRescheduled,
|
||||||
|
responses: {
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
location: { optionValue: "", value: "New York" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { req } = createMockNextJsRequest({
|
||||||
|
method: "POST",
|
||||||
|
body: mockBookingData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdBooking = await handleNewBooking(req);
|
||||||
|
|
||||||
|
await expectBookingInDBToBeRescheduledFromTo({
|
||||||
|
from: {
|
||||||
|
uid: uidOfBookingToBeRescheduled,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
description: "",
|
||||||
|
location: "New York",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
uid: createdBooking.uid!,
|
||||||
|
eventTypeId: mockBookingData.eventTypeId,
|
||||||
|
status: BookingStatus.ACCEPTED,
|
||||||
|
responses: expect.objectContaining({
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
}),
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.googlecalendar.type,
|
||||||
|
// A reference is still created in case of event creation failure, with nullish values. Not sure what's the purpose for this.
|
||||||
|
uid: "ORIGINAL_BOOKING_UID",
|
||||||
|
meetingId: "ORIGINAL_MEETING_ID",
|
||||||
|
meetingPassword: "ORIGINAL_MEETING_PASSWORD",
|
||||||
|
meetingUrl: "https://ORIGINAL_MEETING_URL",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expectWorkflowToBeTriggered();
|
||||||
|
|
||||||
|
// FIXME: We should send Broken Integration emails on calendar event updation failure
|
||||||
|
// expectBrokenIntegrationEmails({ booker, organizer, emails });
|
||||||
|
|
||||||
|
expectBookingRescheduledWebhookToHaveBeenFired({
|
||||||
|
booker,
|
||||||
|
organizer,
|
||||||
|
location: "New York",
|
||||||
|
subscriberUrl: "http://my-webhook.example.com",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
timeout
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1086
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Dayjs } from "@calcom/dayjs";
|
||||||
|
import dayjs from "@calcom/dayjs";
|
||||||
|
|
||||||
|
import { useOverlayCalendarStore } from "../Booker/components/OverlayCalendar/store";
|
||||||
|
|
||||||
|
function getCurrentTime(date: Date) {
|
||||||
|
const hours = date.getHours().toString().padStart(2, "0");
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCheckOverlapWithOverlay(start: Dayjs, selectedDuration: number | null, offset: number) {
|
||||||
|
const overlayBusyDates = useOverlayCalendarStore((state) => state.overlayBusyDates);
|
||||||
|
|
||||||
|
let overlappingTimeStart: string | null = null;
|
||||||
|
let overlappingTimeEnd: string | null = null;
|
||||||
|
|
||||||
|
const isOverlapping =
|
||||||
|
overlayBusyDates &&
|
||||||
|
overlayBusyDates.some((busyDate) => {
|
||||||
|
const busyDateStart = dayjs(busyDate.start);
|
||||||
|
const busyDateEnd = dayjs(busyDate.end);
|
||||||
|
const selectedEndTime = dayjs(start.add(offset, "hours")).add(selectedDuration ?? 0, "minute");
|
||||||
|
|
||||||
|
const isOverlapping =
|
||||||
|
(selectedEndTime.isSame(busyDateStart) || selectedEndTime.isAfter(busyDateStart)) &&
|
||||||
|
start.add(offset, "hours") < busyDateEnd &&
|
||||||
|
selectedEndTime > busyDateStart;
|
||||||
|
|
||||||
|
overlappingTimeStart = isOverlapping ? getCurrentTime(busyDateStart.toDate()) : null;
|
||||||
|
overlappingTimeEnd = isOverlapping ? getCurrentTime(busyDateEnd.toDate()) : null;
|
||||||
|
|
||||||
|
return isOverlapping;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { isOverlapping, overlappingTimeStart, overlappingTimeEnd } as {
|
||||||
|
isOverlapping: boolean;
|
||||||
|
overlappingTimeStart: string | null;
|
||||||
|
overlappingTimeEnd: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ export const FormBuilder = function FormBuilder({
|
|||||||
// I would have liked to give Form Builder it's own Form but nested Forms aren't something that browsers support.
|
// I would have liked to give Form Builder it's own Form but nested Forms aren't something that browsers support.
|
||||||
// So, this would reuse the same Form as the parent form.
|
// So, this would reuse the same Form as the parent form.
|
||||||
const fieldsForm = useFormContext<RhfForm>();
|
const fieldsForm = useFormContext<RhfForm>();
|
||||||
|
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const { fields, swap, remove, update, append } = useFieldArray({
|
const { fields, swap, remove, update, append } = useFieldArray({
|
||||||
@@ -112,7 +112,7 @@ export const FormBuilder = function FormBuilder({
|
|||||||
{LockedIcon}
|
{LockedIcon}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-subtle max-w-[280px] break-words py-1 text-sm sm:max-w-[500px]">{description}</p>
|
<p className="text-subtle max-w-[280px] break-words py-1 text-sm sm:max-w-[500px]">{description}</p>
|
||||||
<ul className="border-subtle divide-subtle mt-2 divide-y rounded-md border">
|
<ul ref={parent} className="border-subtle divide-subtle mt-2 divide-y rounded-md border">
|
||||||
{fields.map((field, index) => {
|
{fields.map((field, index) => {
|
||||||
const options = field.options
|
const options = field.options
|
||||||
? field.options
|
? field.options
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useForm } from "react-hook-form";
|
|||||||
|
|
||||||
import type { Dayjs } from "@calcom/dayjs";
|
import type { Dayjs } from "@calcom/dayjs";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { classNames } from "@calcom/lib";
|
|
||||||
import { yyyymmdd } from "@calcom/lib/date-fns";
|
import { yyyymmdd } from "@calcom/lib/date-fns";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||||
@@ -15,6 +14,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
Switch,
|
Switch,
|
||||||
|
showToast,
|
||||||
Form,
|
Form,
|
||||||
Button,
|
Button,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
@@ -23,15 +23,11 @@ import DatePicker from "../../calendars/DatePicker";
|
|||||||
import type { TimeRange } from "./Schedule";
|
import type { TimeRange } from "./Schedule";
|
||||||
import { DayRanges } from "./Schedule";
|
import { DayRanges } from "./Schedule";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
const DateOverrideForm = ({
|
const DateOverrideForm = ({
|
||||||
value,
|
value,
|
||||||
workingHours,
|
workingHours,
|
||||||
excludedDates,
|
excludedDates,
|
||||||
onChange,
|
onChange,
|
||||||
onClose = noop,
|
|
||||||
}: {
|
}: {
|
||||||
workingHours?: WorkingHours[];
|
workingHours?: WorkingHours[];
|
||||||
onChange: (newValue: TimeRange[]) => void;
|
onChange: (newValue: TimeRange[]) => void;
|
||||||
@@ -137,14 +133,10 @@ const DateOverrideForm = ({
|
|||||||
})
|
})
|
||||||
: datesInRanges
|
: datesInRanges
|
||||||
);
|
);
|
||||||
onClose();
|
setSelectedDates([]);
|
||||||
}}
|
}}
|
||||||
className="p-6 sm:flex sm:p-0 md:flex-col lg:flex-col xl:flex-row">
|
className="p-6 sm:flex sm:p-0 md:flex-col lg:flex-col xl:flex-row">
|
||||||
<div
|
<div className="sm:border-subtle w-full sm:border-r sm:p-4 sm:pr-6 md:p-8">
|
||||||
className={classNames(
|
|
||||||
selectedDates[0] && "sm:border-subtle w-full sm:border-r sm:pr-6",
|
|
||||||
"sm:p-4 md:p-8"
|
|
||||||
)}>
|
|
||||||
<DialogHeader title={t("date_overrides_dialog_title")} />
|
<DialogHeader title={t("date_overrides_dialog_title")} />
|
||||||
<DatePicker
|
<DatePicker
|
||||||
excludedDates={excludedDates}
|
excludedDates={excludedDates}
|
||||||
@@ -160,39 +152,48 @@ const DateOverrideForm = ({
|
|||||||
locale={isLocaleReady ? i18n.language : "en"}
|
locale={isLocaleReady ? i18n.language : "en"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedDates[0] && (
|
<div className="relative mt-8 flex w-full flex-col sm:mt-0 sm:p-4 md:p-8">
|
||||||
<div className="relative mt-8 flex w-full flex-col sm:mt-0 sm:p-4 md:p-8">
|
{selectedDates[0] ? (
|
||||||
<div className="mb-4 flex-grow space-y-4">
|
<>
|
||||||
<p className="text-medium text-emphasis text-sm">{t("date_overrides_dialog_which_hours")}</p>
|
<div className="mb-4 flex-grow space-y-4">
|
||||||
<div>
|
<p className="text-medium text-emphasis text-sm">{t("date_overrides_dialog_which_hours")}</p>
|
||||||
{datesUnavailable ? (
|
<div>
|
||||||
<p className="text-subtle border-default rounded border p-2 text-sm">
|
{datesUnavailable ? (
|
||||||
{t("date_overrides_unavailable")}
|
<p className="text-subtle border-default rounded border p-2 text-sm">
|
||||||
</p>
|
{t("date_overrides_unavailable")}
|
||||||
) : (
|
</p>
|
||||||
<DayRanges name="range" />
|
) : (
|
||||||
)}
|
<DayRanges name="range" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
label={t("date_overrides_mark_all_day_unavailable_one")}
|
||||||
|
checked={datesUnavailable}
|
||||||
|
onCheckedChange={setDatesUnavailable}
|
||||||
|
data-testid="date-override-mark-unavailable"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<div className="mt-4 flex flex-row-reverse sm:mt-0">
|
||||||
label={t("date_overrides_mark_all_day_unavailable_one")}
|
<Button
|
||||||
checked={datesUnavailable}
|
className="ml-2"
|
||||||
onCheckedChange={setDatesUnavailable}
|
color="primary"
|
||||||
data-testid="date-override-mark-unavailable"
|
type="submit"
|
||||||
/>
|
onClick={() => {
|
||||||
|
showToast(t("date_successfully_added"), "success");
|
||||||
|
}}
|
||||||
|
disabled={selectedDates.length === 0}
|
||||||
|
data-testid="add-override-submit-btn">
|
||||||
|
{value ? t("date_overrides_update_btn") : t("date_overrides_add_btn")}
|
||||||
|
</Button>
|
||||||
|
<DialogClose />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="bottom-7 right-8 flex flex-row-reverse sm:absolute">
|
||||||
|
<DialogClose />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-row-reverse sm:mt-0">
|
)}
|
||||||
<Button
|
</div>
|
||||||
className="ml-2"
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
disabled={selectedDates.length === 0}
|
|
||||||
data-testid="add-override-submit-btn">
|
|
||||||
{value ? t("date_overrides_update_btn") : t("date_overrides_add_btn")}
|
|
||||||
</Button>
|
|
||||||
<DialogClose onClick={onClose} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -220,7 +221,7 @@ const DateOverrideInputDialog = ({
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>{Trigger}</DialogTrigger>
|
<DialogTrigger asChild>{Trigger}</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent enableOverflow={enableOverflow} size="md" className="p-0 md:w-auto">
|
<DialogContent enableOverflow={enableOverflow} size="md" className="p-0">
|
||||||
<DateOverrideForm
|
<DateOverrideForm
|
||||||
excludedDates={excludedDates}
|
excludedDates={excludedDates}
|
||||||
{...passThroughProps}
|
{...passThroughProps}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const BookerLayoutSelector = ({
|
|||||||
if (flags["booker-layouts"] !== true) return null;
|
if (flags["booker-layouts"] !== true) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<div className="border-subtle rounded-t-xl border p-6">
|
<div className="border-subtle rounded-t-xl border p-6">
|
||||||
<Label className="mb-0 text-base font-semibold">{title ? title : t("layout")}</Label>
|
<Label className="mb-0 text-base font-semibold">{title ? title : t("layout")}</Label>
|
||||||
<p className="text-subtle max-w-full break-words py-1 text-sm">
|
<p className="text-subtle max-w-full break-words py-1 text-sm">
|
||||||
@@ -84,7 +84,7 @@ export const BookerLayoutSelector = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1008,7 +1008,7 @@ function MainContainer({
|
|||||||
<main className="bg-default relative z-0 flex-1 focus:outline-none">
|
<main className="bg-default relative z-0 flex-1 focus:outline-none">
|
||||||
{/* show top navigation for md and smaller (tablet and phones) */}
|
{/* show top navigation for md and smaller (tablet and phones) */}
|
||||||
{TopNavContainerProp}
|
{TopNavContainerProp}
|
||||||
<div className="max-w-full px-2 py-4 md:py-12 lg:px-6">
|
<div className="max-w-full px-2 py-4 lg:px-6">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{!props.withoutMain ? <ShellMain {...props}>{props.children}</ShellMain> : props.children}
|
{!props.withoutMain ? <ShellMain {...props}>{props.children}</ShellMain> : props.children}
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CAL_URL } from "@calcom/lib/constants";
|
|||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
import type { PrismaClient } from "@calcom/prisma";
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
import type { Credential } from "@calcom/prisma/client";
|
import type { Credential } from "@calcom/prisma/client";
|
||||||
import { SchedulingType, MembershipRole, AppCategories } from "@calcom/prisma/enums";
|
import { SchedulingType, MembershipRole } from "@calcom/prisma/enums";
|
||||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
@@ -230,27 +230,6 @@ export default async function getEventTypeById({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = await prisma.credential.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
app: {
|
|
||||||
enabled: true,
|
|
||||||
categories: {
|
|
||||||
hasSome: [AppCategories.conferencing, AppCategories.video, AppCategories.payment],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
type: true,
|
|
||||||
key: true,
|
|
||||||
userId: true,
|
|
||||||
teamId: true,
|
|
||||||
appId: true,
|
|
||||||
invalid: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { locations, metadata, ...restEventType } = rawEventType;
|
const { locations, metadata, ...restEventType } = rawEventType;
|
||||||
const newMetadata = EventTypeMetaDataSchema.parse(metadata || {}) || {};
|
const newMetadata = EventTypeMetaDataSchema.parse(metadata || {}) || {};
|
||||||
const apps = newMetadata?.apps || {};
|
const apps = newMetadata?.apps || {};
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
|
||||||
import parser from "accept-language-parser";
|
|
||||||
import type { GetServerSidePropsContext, NextApiRequest } from "next";
|
|
||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
|
||||||
import type { Maybe } from "@calcom/trpc/server";
|
|
||||||
|
|
||||||
const { i18n } = require("@calcom/config/next-i18next.config");
|
|
||||||
|
|
||||||
export async function getLocaleFromRequest(
|
|
||||||
req: NextApiRequest | GetServerSidePropsContext["req"]
|
|
||||||
): Promise<string> {
|
|
||||||
const session = await getServerSession({ req });
|
|
||||||
if (session?.user?.locale) return session.user.locale;
|
|
||||||
let preferredLocale: string | null | undefined;
|
|
||||||
if (req.headers["accept-language"]) {
|
|
||||||
preferredLocale = parser.pick(i18n.locales, req.headers["accept-language"], {
|
|
||||||
loose: true,
|
|
||||||
}) as Maybe<string>;
|
|
||||||
}
|
|
||||||
return preferredLocale ?? i18n.defaultLocale;
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,13 @@ import { useSearchParams } from "next/navigation";
|
|||||||
/**
|
/**
|
||||||
* An alternative to Object.fromEntries that allows duplicate keys.
|
* An alternative to Object.fromEntries that allows duplicate keys.
|
||||||
*/
|
*/
|
||||||
function fromEntriesWithDuplicateKeys(entries: ReturnType<ReturnType<typeof useSearchParams>["entries"]>) {
|
function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string, string]> | null) {
|
||||||
const result: Record<string, string | string[]> = {};
|
const result: Record<string, string | string[]> = {};
|
||||||
|
|
||||||
|
if (entries === null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// Consider setting atleast ES2015 as target
|
// Consider setting atleast ES2015 as target
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -31,6 +36,6 @@ function fromEntriesWithDuplicateKeys(entries: ReturnType<ReturnType<typeof useS
|
|||||||
*/
|
*/
|
||||||
export const useRouterQuery = () => {
|
export const useRouterQuery = () => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const routerQuery = fromEntriesWithDuplicateKeys(searchParams.entries());
|
const routerQuery = fromEntriesWithDuplicateKeys(searchParams?.entries() ?? null);
|
||||||
return routerQuery;
|
return routerQuery;
|
||||||
};
|
};
|
||||||
@@ -96,7 +96,9 @@ export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
|
|||||||
|
|
||||||
// Remove all query params from the URL
|
// Remove all query params from the URL
|
||||||
function removeAllQueryParams() {
|
function removeAllQueryParams() {
|
||||||
router.replace(pathname);
|
if (pathname !== null) {
|
||||||
|
router.replace(pathname);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const useUrlMatchesCurrentUrl = (url: string) => {
|
|||||||
// It can certainly have null value https://nextjs.org/docs/app/api-reference/functions/use-pathname#:~:text=usePathname%20can%20return%20null%20when%20a%20fallback%20route%20is%20being%20rendered%20or%20when%20a%20pages%20directory%20page%20has%20been%20automatically%20statically%20optimized%20by%20Next.js%20and%20the%20router%20is%20not%20ready.
|
// It can certainly have null value https://nextjs.org/docs/app/api-reference/functions/use-pathname#:~:text=usePathname%20can%20return%20null%20when%20a%20fallback%20route%20is%20being%20rendered%20or%20when%20a%20pages%20directory%20page%20has%20been%20automatically%20statically%20optimized%20by%20Next.js%20and%20the%20router%20is%20not%20ready.
|
||||||
const pathname = usePathname() as null | string;
|
const pathname = usePathname() as null | string;
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const query = searchParams.toString();
|
const query = searchParams?.toString();
|
||||||
let pathnameWithQuery;
|
let pathnameWithQuery;
|
||||||
if (query) {
|
if (query) {
|
||||||
pathnameWithQuery = `${pathname}?${query}`;
|
pathnameWithQuery = `${pathname}?${query}`;
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ export const seoConfig: {
|
|||||||
* @param path NextJS' useRouter().asPath
|
* @param path NextJS' useRouter().asPath
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export const buildCanonical = ({ origin, path }: { origin: Location["origin"]; path: Router["asPath"] }) => {
|
export const buildCanonical = ({
|
||||||
|
origin,
|
||||||
|
path,
|
||||||
|
}: {
|
||||||
|
origin: Location["origin"];
|
||||||
|
path: Router["asPath"] | null;
|
||||||
|
}) => {
|
||||||
return `${origin}${path === "/" ? "" : path}`.split("?")[0];
|
return `${origin}${path === "/" ? "" : path}`.split("?")[0];
|
||||||
};
|
};
|
||||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user