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 |
@@ -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).
|
||||
- Be sure to fill the PR Template accordingly.
|
||||
- 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
|
||||
|
||||
##### Approach 1
|
||||
|
||||
1. Open [Prisma Studio](https://prisma.io/studio) to look at or modify the database content:
|
||||
|
||||
```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.
|
||||
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
|
||||
|
||||
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(),
|
||||
responses: z.record(z.any()),
|
||||
responses: z.record(z.any()).nullable(),
|
||||
}).pick({
|
||||
id: true,
|
||||
userId: true,
|
||||
|
||||
@@ -209,6 +209,8 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
hosts = [],
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
/** FIXME: Updating event-type children from API not supported for now */
|
||||
children: _,
|
||||
...parsedBody
|
||||
} = schemaEventTypeEditBodyParams.parse(body);
|
||||
|
||||
|
||||
@@ -268,6 +268,8 @@ async function postHandler(req: NextApiRequest) {
|
||||
hosts = [],
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
/** FIXME: Adding event-type children from API not supported for now */
|
||||
children: _,
|
||||
...parsedBody
|
||||
} = schemaEventTypeCreateBodyParams.parse(body || {});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { HttpError } from "@calcom/lib/http-error";
|
||||
* if the event type doesn't belong to any 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({
|
||||
where: {
|
||||
id: parentId,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
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 { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
@@ -11,10 +11,10 @@ import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res });
|
||||
const caller = viewerRouter.createCaller(ctx);
|
||||
const caller = slotsRouter.createCaller(ctx);
|
||||
try {
|
||||
// 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) {
|
||||
if (cause instanceof TRPCError) {
|
||||
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 { trpc } from "@calcom/trpc/react";
|
||||
|
||||
function useViewerI18n(locale: string) {
|
||||
export function useViewerI18n(locale: string) {
|
||||
return trpc.viewer.public.i18n.useQuery(
|
||||
{ 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 { seoConfig } from "@lib/config/next-seo.config";
|
||||
|
||||
import I18nLanguageHandler from "@components/I18nLanguageHandler";
|
||||
|
||||
export interface CalPageWrapper {
|
||||
(props?: AppProps): JSX.Element;
|
||||
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
||||
@@ -72,7 +70,6 @@ function PageWrapper(props: AppProps) {
|
||||
}
|
||||
{...seoConfig.defaultNextSeo}
|
||||
/>
|
||||
<I18nLanguageHandler locales={props.router.locales || []} />
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
|
||||
export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
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} />
|
||||
{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 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}
|
||||
</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 type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
|
||||
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 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 useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import type { WithLocaleProps } from "@lib/withLocale";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
import { useClientViewerI18n } from "@components/I18nLanguageHandler";
|
||||
import { useViewerI18n } from "@components/I18nLanguageHandler";
|
||||
|
||||
const I18nextAdapter = appWithTranslation<
|
||||
NextJsAppProps<SSRConfig> & {
|
||||
@@ -30,10 +31,12 @@ const I18nextAdapter = appWithTranslation<
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
export type AppProps = Omit<
|
||||
NextAppProps<
|
||||
WithNonceProps & {
|
||||
themeBasis?: string;
|
||||
session: Session;
|
||||
} & Record<string, unknown>
|
||||
WithLocaleProps<
|
||||
WithNonceProps<{
|
||||
themeBasis?: string;
|
||||
session: Session;
|
||||
}>
|
||||
>
|
||||
>,
|
||||
"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.
|
||||
**/
|
||||
const clientViewerI18n = useClientViewerI18n(props.router.locales || []);
|
||||
const { i18n, locale } = clientViewerI18n.data || {};
|
||||
const clientViewerI18n = useViewerI18n(props.pageProps.newLocale);
|
||||
const i18n = clientViewerI18n.data?.i18n;
|
||||
|
||||
const passedProps = {
|
||||
...props,
|
||||
@@ -77,8 +80,7 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
|
||||
...props.pageProps,
|
||||
...i18n,
|
||||
},
|
||||
router: locale ? { locale } : props.router,
|
||||
} as unknown as ComponentProps<typeof I18nextAdapter>;
|
||||
};
|
||||
|
||||
return <I18nextAdapter {...passedProps} />;
|
||||
};
|
||||
@@ -233,7 +235,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
// No need to have intercom on public pages - Good for Page Performance
|
||||
const isBookingPage = useIsBookingPage();
|
||||
const { pageProps, ...rest } = props;
|
||||
const { _nonce, ...restPageProps } = pageProps;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { nonce, ...restPageProps } = pageProps;
|
||||
const propsWithoutNonce = {
|
||||
pageProps: {
|
||||
...restPageProps,
|
||||
@@ -243,8 +247,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<SessionProvider session={pageProps.session ?? undefined}>
|
||||
<CustomI18nextProvider {...propsWithoutNonce}>
|
||||
<CustomI18nextProvider {...propsWithoutNonce}>
|
||||
<SessionProvider session={pageProps.session ?? undefined}>
|
||||
<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 */}
|
||||
<CalcomThemeProvider
|
||||
@@ -260,8 +264,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</CustomI18nextProvider>
|
||||
</SessionProvider>
|
||||
</SessionProvider>
|
||||
</CustomI18nextProvider>
|
||||
</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";
|
||||
|
||||
export type WithNonceProps = {
|
||||
export type WithNonceProps<T extends Record<string, any>> = T & {
|
||||
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
|
||||
* 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) {
|
||||
return async (context: GetServerSidePropsContext) => {
|
||||
export default function withNonce<T extends Record<string, any>>(
|
||||
getServerSideProps: GetServerSideProps<T>
|
||||
): GetServerSideProps<WithNonceProps<T>> {
|
||||
return async (context) => {
|
||||
const ssrResponse = await getServerSideProps(context);
|
||||
|
||||
if (!("props" in ssrResponse)) {
|
||||
return ssrResponse;
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -23,10 +30,6 @@ export default function withNonce(getServerSideProps: GetServerSideProps) {
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!("props" in ssrResponse)) {
|
||||
return ssrResponse;
|
||||
}
|
||||
|
||||
// Helps in debugging that withNonce was used but a valid nonce couldn't be set
|
||||
context.res.setHeader("x-csp", nonce ? "ssr" : "false");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.3.6",
|
||||
"version": "3.3.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import {
|
||||
@@ -43,6 +44,7 @@ export default function Type({
|
||||
hideBranding={isBrandingHidden}
|
||||
isSEOIndexable={isSEOIndexable ?? true}
|
||||
entity={entity}
|
||||
bookingData={booking}
|
||||
/>
|
||||
<Booker
|
||||
username={user}
|
||||
@@ -61,6 +63,7 @@ Type.isBookingPage = true;
|
||||
Type.PageWrapper = PageWrapper;
|
||||
|
||||
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||
|
||||
@@ -96,7 +99,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
} else if (bookingUid) {
|
||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||
}
|
||||
@@ -138,6 +141,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
}
|
||||
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||
@@ -168,7 +172,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
} else if (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 { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
@@ -8,8 +11,35 @@ import "../styles/globals.css";
|
||||
|
||||
function MyApp(props: AppProps) {
|
||||
const { Component, pageProps } = props;
|
||||
|
||||
if (Component.PageWrapper !== undefined) return Component.PageWrapper(props);
|
||||
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 { DocumentContext, DocumentProps } from "next/document";
|
||||
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
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) {
|
||||
try {
|
||||
ctx.res?.setHeader(name, value);
|
||||
@@ -26,6 +28,10 @@ class MyDocument extends Document<Props> {
|
||||
setHeader(ctx, "x-csp", "initialPropsOnly");
|
||||
}
|
||||
|
||||
const newLocale = ctx.req
|
||||
? await getLocale(ctx.req as IncomingMessage & { cookies: Record<string, any> })
|
||||
: "en";
|
||||
|
||||
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
|
||||
const parsedUrl = new URL(asPath, "https://dummyurl");
|
||||
@@ -36,17 +42,36 @@ class MyDocument extends Document<Props> {
|
||||
!isEmbedSnippetGeneratorPath;
|
||||
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return { isEmbed, embedColorScheme, nonce, ...initialProps };
|
||||
return { isEmbed, embedColorScheme, nonce, ...initialProps, newLocale };
|
||||
}
|
||||
|
||||
render() {
|
||||
const { locale } = this.props.__NEXT_DATA__;
|
||||
const { isEmbed, embedColorScheme } = this.props;
|
||||
const newLocale = this.props.newLocale || "en";
|
||||
|
||||
const nonceParsed = z.string().safeParse(this.props.nonce);
|
||||
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 (
|
||||
<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}>
|
||||
<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="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" />
|
||||
|
||||
@@ -7,7 +7,7 @@ import prisma from "@calcom/prisma";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
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 {
|
||||
ACCEPT = "accept",
|
||||
@@ -55,13 +55,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse<Response>) {
|
||||
try {
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
const caller = viewerRouter.createCaller({
|
||||
const caller = bookingsRouter.createCaller({
|
||||
...ctx,
|
||||
req,
|
||||
res,
|
||||
user: { ...user, locale: user?.locale ?? "en" },
|
||||
});
|
||||
await caller.bookings.confirm({
|
||||
await caller.confirm({
|
||||
bookingId: booking.id,
|
||||
recurringEventId: booking.recurringEventId || undefined,
|
||||
confirmed: action === DirectAction.ACCEPT,
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
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;
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const id = context.params?.id as string;
|
||||
@@ -163,12 +163,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
} catch (e) {
|
||||
resetPasswordRequest = null;
|
||||
}
|
||||
const locale = await getLocale(context.req);
|
||||
return {
|
||||
props: {
|
||||
isRequestExpired: !resetPasswordRequest,
|
||||
requestId: id,
|
||||
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 React from "react";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, EmailField } from "@calcom/ui";
|
||||
@@ -126,8 +127,9 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
className="w-full justify-center"
|
||||
className="w-full justify-center dark:bg-white dark:text-black"
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
aria-label={t("request_password_reset")}
|
||||
loading={loading}>
|
||||
@@ -141,7 +143,6 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
ForgotPassword.isThemeSupported = false;
|
||||
ForgotPassword.PageWrapper = PageWrapper;
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
@@ -154,11 +155,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
res.end();
|
||||
return { props: {} };
|
||||
}
|
||||
const locale = await getLocale(context.req);
|
||||
|
||||
return {
|
||||
props: {
|
||||
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 { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
||||
import { ArrowLeft, Lock } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -50,7 +51,8 @@ export default function Login({
|
||||
samlTenantID,
|
||||
samlProductID,
|
||||
totpEmail,
|
||||
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
|
||||
}: // eslint-disable-next-line @typescript-eslint/ban-types
|
||||
inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -160,6 +162,16 @@ export default function Login({
|
||||
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 (
|
||||
<div
|
||||
style={
|
||||
@@ -225,14 +237,14 @@ export default function Login({
|
||||
type="submit"
|
||||
color="primary"
|
||||
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")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{!twoFactorRequired && (
|
||||
<>
|
||||
{(isGoogleLoginEnabled || isSAMLLoginEnabled) && <hr className="border-subtle my-8" />}
|
||||
{(isGoogleLoginEnabled || displaySSOLogin) && <hr className="border-subtle my-8" />}
|
||||
<div className="space-y-3">
|
||||
{isGoogleLoginEnabled && (
|
||||
<Button
|
||||
@@ -247,7 +259,7 @@ export default function Login({
|
||||
{t("signin_with_google")}
|
||||
</Button>
|
||||
)}
|
||||
{isSAMLLoginEnabled && (
|
||||
{displaySSOLogin && (
|
||||
<SAMLLogin
|
||||
samlTenantID={samlTenantID}
|
||||
samlProductID={samlProductID}
|
||||
@@ -337,7 +349,6 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
|
||||
};
|
||||
};
|
||||
|
||||
Login.isThemeSupported = false;
|
||||
Login.PageWrapper = PageWrapper;
|
||||
|
||||
export const getServerSideProps = withNonce(_getServerSideProps);
|
||||
|
||||
@@ -57,7 +57,6 @@ export function Logout(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
Logout.isThemeSupported = false;
|
||||
Logout.PageWrapper = PageWrapper;
|
||||
export default Logout;
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ export default function Success(props: SuccessProps) {
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
|
||||
|
||||
const { requiresLoginToUpdate } = props;
|
||||
function setIsCancellationMode(value: boolean) {
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
|
||||
@@ -532,7 +532,28 @@ export default function Success(props: SuccessProps) {
|
||||
})}
|
||||
</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 &&
|
||||
(!isCancellationMode ? (
|
||||
<>
|
||||
@@ -540,28 +561,30 @@ export default function Success(props: SuccessProps) {
|
||||
<div className="text-center last:pb-0">
|
||||
<span className="text-emphasis ltr:mr-2 rtl:ml-2">{t("need_to_make_a_change")}</span>
|
||||
|
||||
{!props.recurringBookings && (
|
||||
<span className="text-default inline">
|
||||
<span className="underline" data-testid="reschedule-link">
|
||||
<Link
|
||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
|
||||
legacyBehavior>
|
||||
{t("reschedule")}
|
||||
</Link>
|
||||
<>
|
||||
{!props.recurringBookings && (
|
||||
<span className="text-default inline">
|
||||
<span className="underline" data-testid="reschedule-link">
|
||||
<Link
|
||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
|
||||
legacyBehavior>
|
||||
{t("reschedule")}
|
||||
</Link>
|
||||
</span>
|
||||
<span className="mx-2">{t("or_lowercase")}</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>
|
||||
</>
|
||||
) : (
|
||||
@@ -1010,7 +1033,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
let tz: string | null = null;
|
||||
let userTimeFormat: number | null = null;
|
||||
|
||||
let requiresLoginToUpdate = false;
|
||||
if (session) {
|
||||
const user = await ssr.viewer.me.fetch();
|
||||
tz = user.timeZone;
|
||||
@@ -1022,9 +1045,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
if (!parsedQuery.success) return { notFound: true };
|
||||
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
|
||||
|
||||
const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid);
|
||||
const bookingInfoRaw = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid: await maybeGetBookingUidFromSeat(prisma, uid),
|
||||
uid: maybeUid,
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
@@ -1088,6 +1112,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) {
|
||||
requiresLoginToUpdate = true;
|
||||
}
|
||||
|
||||
const bookingInfo = getBookingWithResponses(bookingInfoRaw);
|
||||
// @NOTE: had to do this because Server side cant return [Object objects]
|
||||
// probably fixable with json.stringify -> json.parse
|
||||
@@ -1156,6 +1184,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
paymentStatus: payment,
|
||||
...(tz && { tz }),
|
||||
userTimeFormat,
|
||||
requiresLoginToUpdate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
@@ -57,6 +58,7 @@ Type.PageWrapper = PageWrapper;
|
||||
Type.isBookingPage = true;
|
||||
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
const { link, slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
@@ -119,7 +121,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
}
|
||||
|
||||
const isTeamEvent = !!hashedLink.eventType?.team?.id;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { CSSProperties } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -219,10 +220,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
if (user.completedOnboarding) {
|
||||
return { redirect: { permanent: false, destination: "/event-types" } };
|
||||
}
|
||||
const locale = await getLocale(context.req);
|
||||
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(context.locale ?? "", ["common"])),
|
||||
...(await serverSideTranslations(locale, ["common"])),
|
||||
trpcState: ssr.dehydrate(),
|
||||
hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { URLSearchParams } from "url";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
@@ -12,11 +13,16 @@ export default function Type() {
|
||||
}
|
||||
|
||||
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() })
|
||||
.parse(context.query);
|
||||
|
||||
const uid = await maybeGetBookingUidFromSeat(prisma, bookingId);
|
||||
const { uid, seatReferenceUid: maybeSeatReferenceUid } = await maybeGetBookingUidFromSeat(
|
||||
prisma,
|
||||
bookingUid
|
||||
);
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
@@ -37,6 +43,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
},
|
||||
},
|
||||
seatsPerTimeSlot: true,
|
||||
userId: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
hosts: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dynamicEventSlugRef: true,
|
||||
@@ -53,7 +74,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
}
|
||||
|
||||
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 {
|
||||
notFound: true,
|
||||
} 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 eventPage = `${
|
||||
@@ -72,7 +120,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
}/${eventType?.slug}`;
|
||||
const destinationUrl = new URLSearchParams();
|
||||
|
||||
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingId);
|
||||
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingUid);
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
|
||||
@@ -68,7 +68,11 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
await utils.viewer.me.invalidate();
|
||||
reset(getValues());
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
update(res);
|
||||
await update(res);
|
||||
|
||||
if (res.locale) {
|
||||
window.calNewLocale = res.locale;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("error_updating_settings"), "error");
|
||||
|
||||
@@ -5,7 +5,6 @@ import { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
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 EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
@@ -33,7 +32,13 @@ import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }: PageProps) {
|
||||
function TeamPage({
|
||||
team,
|
||||
isUnpublished,
|
||||
markdownStrippedBio,
|
||||
isValidOrgDomain,
|
||||
currentOrgDomain,
|
||||
}: PageProps) {
|
||||
useTheme(team.theme);
|
||||
const routerQuery = useRouterQuery();
|
||||
const pathname = usePathname();
|
||||
@@ -44,7 +49,6 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
const teamName = team.name || "Nameless Team";
|
||||
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
|
||||
const metadata = teamMetadataSchema.parse(team.metadata);
|
||||
const orgBranding = useOrgBranding();
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.event(
|
||||
@@ -182,8 +186,8 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
<Avatar
|
||||
alt={teamName}
|
||||
imageSrc={
|
||||
!!team.parent && !!orgBranding
|
||||
? `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`
|
||||
isValidOrgDomain
|
||||
? `/org/${currentOrgDomain}/avatar.png`
|
||||
: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`
|
||||
}
|
||||
size="lg"
|
||||
@@ -354,6 +358,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
trpcState: ssr.dehydrate(),
|
||||
markdownStrippedBio,
|
||||
isValidOrgDomain,
|
||||
currentOrgDomain,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
@@ -37,6 +38,7 @@ export default function Type({
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
entity={entity}
|
||||
bookingData={booking}
|
||||
/>
|
||||
<Booker
|
||||
username={user}
|
||||
@@ -64,6 +66,7 @@ const paramsSchema = z.object({
|
||||
// 1. Check if team exists, to show 404
|
||||
// 2. If rescheduling, get the booking details
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const session = await getServerSession(context);
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
@@ -92,7 +95,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`);
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
}
|
||||
|
||||
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="date-override-mark-unavailable"]').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 page.locator('[form="availability-form"][type="submit"]').click();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect } from "@playwright/test";
|
||||
import { uuid } from "short-uuid";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import prisma from "@calcom/prisma";
|
||||
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 }) => {
|
||||
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: "Jane Second", email: "second+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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// The remaining 2 attendees cancel
|
||||
for (let i = 1; i < bookingSeats.length; i++) {
|
||||
@@ -166,6 +180,47 @@ test.describe("Booking with Seats", () => {
|
||||
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", () => {
|
||||
@@ -543,4 +598,113 @@ test.describe("Reschedule for booking with seats", () => {
|
||||
.first();
|
||||
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";
|
||||
|
||||
export const createEmbedsFixture = (page: Page) => {
|
||||
return async (calNamespace: string) => {
|
||||
await page.addInitScript(
|
||||
({ calNamespace }: { calNamespace: string }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
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;
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* @deprecated Use gotoPlayground instead
|
||||
*/
|
||||
async addEmbedListeners(calNamespace: string) {
|
||||
await page.addInitScript(
|
||||
({ calNamespace }: { calNamespace: string }) => {
|
||||
console.log("PlaywrightTest:", "Adding listener for __iframeReady on namespace:", calNamespace);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@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
|
||||
//@ts-ignore
|
||||
api = window.Cal.ns[calNamespace];
|
||||
}
|
||||
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
|
||||
let api = window.Cal;
|
||||
|
||||
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
|
||||
// @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);
|
||||
},
|
||||
//@ts-ignore
|
||||
api = window.Cal.ns[calNamespace];
|
||||
}
|
||||
console.log("PlaywrightTest:", `Adding listener for __iframeReady on namespace:${calNamespace}`);
|
||||
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 { createBookingsFixture } from "../fixtures/bookings";
|
||||
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
|
||||
import { createEmbedsFixture } from "../fixtures/embeds";
|
||||
import { createPaymentsFixture } from "../fixtures/payments";
|
||||
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
||||
import { createServersFixture } from "../fixtures/servers";
|
||||
@@ -19,8 +19,7 @@ export interface Fixtures {
|
||||
users: ReturnType<typeof createUsersFixture>;
|
||||
bookings: ReturnType<typeof createBookingsFixture>;
|
||||
payments: ReturnType<typeof createPaymentsFixture>;
|
||||
addEmbedListeners: ReturnType<typeof createEmbedsFixture>;
|
||||
getActionFiredDetails: ReturnType<typeof createGetActionFiredDetails>;
|
||||
embeds: ReturnType<typeof createEmbedsFixture>;
|
||||
servers: ReturnType<typeof createServersFixture>;
|
||||
prisma: typeof prisma;
|
||||
emails?: API;
|
||||
@@ -36,7 +35,8 @@ declare global {
|
||||
calNamespace: string,
|
||||
// eslint-disable-next-line
|
||||
getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise<any>,
|
||||
expectedUrlDetails?: ExpectedUrlDetails
|
||||
expectedUrlDetails?: ExpectedUrlDetails,
|
||||
isPrendered?: boolean
|
||||
): Promise<R>;
|
||||
}
|
||||
}
|
||||
@@ -58,14 +58,10 @@ export const test = base.extend<Fixtures>({
|
||||
const payemntsFixture = createPaymentsFixture(page);
|
||||
await use(payemntsFixture);
|
||||
},
|
||||
addEmbedListeners: async ({ page }, use) => {
|
||||
embeds: async ({ page }, use) => {
|
||||
const embedsFixture = createEmbedsFixture(page);
|
||||
await use(embedsFixture);
|
||||
},
|
||||
getActionFiredDetails: async ({ page }, use) => {
|
||||
const getActionFiredDetailsFixture = createGetActionFiredDetails(page);
|
||||
await use(getActionFiredDetailsFixture);
|
||||
},
|
||||
servers: async ({}, use) => {
|
||||
const servers = createServersFixture();
|
||||
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 type { IncomingMessage, ServerResponse } 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.
|
||||
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
|
||||
test.skip(!IS_SAML_LOGIN_ENABLED, "It should only run if SAML Login is installed");
|
||||
|
||||
await page.goto(`/auth/login`);
|
||||
|
||||
await expect(page.locator(`[data-testid=saml]`)).toBeVisible();
|
||||
// TODO: Fix this later
|
||||
// Button is visible only if there is a SAML connection exists (self-hosted)
|
||||
// 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",
|
||||
"where": "Wo",
|
||||
"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",
|
||||
"other": "Sonstige",
|
||||
"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_description": "Blendet die „Ein Teammitglied buchen“-Schaltflächen auf Ihren öffentlichen Seiten aus.",
|
||||
"danger_zone": "Achtung",
|
||||
"account_deletion_cannot_be_undone": "Vorsicht. Löschen eines Kontos kann nicht rückgängig gemacht werden.",
|
||||
"back": "Zurück",
|
||||
"cancel": "Absagen",
|
||||
"cancel_all_remaining": "Alle verbleibenden absagen",
|
||||
@@ -688,6 +690,7 @@
|
||||
"people": "Personen",
|
||||
"your_email": "Ihre E-Mail-Adresse",
|
||||
"change_avatar": "Profilbild ändern",
|
||||
"upload_avatar": "Avatar hochladen",
|
||||
"language": "Sprache",
|
||||
"timezone": "Zeitzone",
|
||||
"first_day_of_week": "Erster Tag der Woche",
|
||||
@@ -1276,6 +1279,7 @@
|
||||
"personal_cal_url": "Meine persönliche {{appName}}-URL",
|
||||
"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.",
|
||||
"bio": "Biografie",
|
||||
"delete_account_modal_title": "Account löschen",
|
||||
"confirm_delete_account_modal": "Sind Sie sicher, dass Sie Ihr {{appName}}-Konto löschen möchten?",
|
||||
"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",
|
||||
"team_publish": "Team veröffentlichen",
|
||||
"number_text_notifications": "Telefonnummer (Textbenachrichtigungen)",
|
||||
"number_sms_notifications": "Telefonnummer (SMS-Benachrichtigungen)",
|
||||
"attendee_email_variable": "Teilnehmer E-Mail",
|
||||
"attendee_email_info": "Die E-Mail-Adresse der buchenden Person",
|
||||
"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",
|
||||
"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.",
|
||||
"orgs_upgrade_to_enable_feature": "Sie müssen auf unsere Enterprise-Lizenz aktualisieren, um diese Funktion zu aktivieren.",
|
||||
"new_attendee": "Neuer Teilnehmer",
|
||||
"awaiting_approval": "Wartet auf Genehmigung",
|
||||
"requires_google_calendar": "Diese App erfordert eine Verbindung mit Google Calendar",
|
||||
@@ -1743,6 +1749,7 @@
|
||||
"show_on_booking_page": "Auf der Buchungsseite anzeigen",
|
||||
"get_started_zapier_templates": "Legen Sie mit Zapier-Vorlagen los",
|
||||
"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_member": "Teammitglied",
|
||||
"a_routing_form": "Ein Weiterleitungsformular",
|
||||
@@ -1877,6 +1884,7 @@
|
||||
"edit_invite_link": "Linkeinstellungen bearbeiten",
|
||||
"invite_link_copied": "Einladungslink kopiert",
|
||||
"invite_link_deleted": "Einladungslink gelöscht",
|
||||
"api_key_deleted": "API-Schlüssel gelöscht",
|
||||
"invite_link_updated": "Einladungslink-Einstellungen gespeichert",
|
||||
"link_expires_after": "Links verfallen nach...",
|
||||
"one_day": "1 Tag",
|
||||
@@ -2011,7 +2019,13 @@
|
||||
"attendee_last_name_variable": "Nachname des Teilnehmers",
|
||||
"attendee_first_name_info": "Vorname 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",
|
||||
"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",
|
||||
"member_removed": "Mitglied entfernt",
|
||||
"my_availability": "Meine Verfügbarkeit",
|
||||
@@ -2041,12 +2055,28 @@
|
||||
"team_no_event_types": "Dieses Team hat keine Ereignistypen",
|
||||
"seat_options_doesnt_multiple_durations": "Platzoption unterstützt mehrere Dauern nicht",
|
||||
"include_calendar_event": "Kalenderereignis hinzufügen",
|
||||
"oAuth": "OAuth",
|
||||
"recently_added": "Kürzlich hinzugefügt",
|
||||
"no_members_found": "Keine Mitglieder gefunden",
|
||||
"event_setup_length_error": "Ereignis-Einrichtung: Die Dauer muss mindestens 1 Minute betragen.",
|
||||
"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": "Sie sehen die Verfügbarkeit dieses Benutzers. Sie können nur Ihre eigene Verfügbarkeit bearbeiten.",
|
||||
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"set_availability": "Set your availability",
|
||||
"availability_settings": "Availability Settings",
|
||||
"continue_without_calendar": "Continue without calendar",
|
||||
"continue_with": "Continue with {{appName}}",
|
||||
"connect_your_calendar": "Connect your calendar",
|
||||
"connect_your_video_app": "Connect your video apps",
|
||||
"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_add_btn": "Add Override",
|
||||
"date_overrides_update_btn": "Update Override",
|
||||
"date_successfully_added": "Date override added successfully",
|
||||
"event_type_duplicate_copy_text": "{{slug}}-copy",
|
||||
"set_as_default": "Set as default",
|
||||
"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",
|
||||
"add_new_client": "Add new Client",
|
||||
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -2,8 +2,8 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { CALCOM_VERSION } from "@calcom/lib/constants";
|
||||
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
|
||||
import { createProxySSGHelpers } from "@calcom/trpc/react/ssg";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
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 }) {
|
||||
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 ssr = createProxySSGHelpers({
|
||||
|
||||
@@ -9,12 +9,14 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import "vitest-fetch-mock";
|
||||
|
||||
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 type { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { SchedulingType } 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 { EventBusyDate } from "@calcom/types/Calendar";
|
||||
|
||||
@@ -22,10 +24,6 @@ import { getMockPaymentService } from "./MockPaymentService";
|
||||
|
||||
logger.setSettings({ minLevel: "silly" });
|
||||
const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] });
|
||||
type App = {
|
||||
slug: string;
|
||||
dirName: string;
|
||||
};
|
||||
|
||||
type InputWebhook = {
|
||||
appId: string | null;
|
||||
@@ -52,24 +50,27 @@ type ScenarioData = {
|
||||
/**
|
||||
* Prisma would return these apps
|
||||
*/
|
||||
apps?: App[];
|
||||
apps?: Partial<AppMeta>[];
|
||||
bookings?: InputBooking[];
|
||||
webhooks?: InputWebhook[];
|
||||
};
|
||||
|
||||
type InputCredential = typeof TestData.credentials.google;
|
||||
type InputCredential = typeof TestData.credentials.google & {
|
||||
id?: number;
|
||||
};
|
||||
|
||||
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[];
|
||||
selectedCalendars?: InputSelectedCalendar[];
|
||||
schedules: {
|
||||
id: number;
|
||||
// Allows giving id in the input directly so that it can be referenced somewhere else as well
|
||||
id?: number;
|
||||
name: string;
|
||||
availability: {
|
||||
userId: number | null;
|
||||
eventTypeId: number | null;
|
||||
days: number[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
@@ -97,7 +98,8 @@ export type InputEventType = {
|
||||
afterEventBuffer?: number;
|
||||
requiresConfirmation?: boolean;
|
||||
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
|
||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users">>;
|
||||
schedule?: InputUser["schedules"][number];
|
||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
|
||||
|
||||
type InputBooking = {
|
||||
id?: number;
|
||||
@@ -122,37 +124,75 @@ type InputBooking = {
|
||||
}[];
|
||||
};
|
||||
|
||||
const Timezones = {
|
||||
export const Timezones = {
|
||||
"+5:30": "Asia/Kolkata",
|
||||
"+6:00": "Asia/Dhaka",
|
||||
};
|
||||
|
||||
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
|
||||
users?: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
workflows?: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
destinationCalendar?: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
schedule?: any;
|
||||
})[]
|
||||
) {
|
||||
log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
|
||||
await prismock.eventType.createMany({
|
||||
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(
|
||||
"TestData: All EventTypes in DB are",
|
||||
JSON.stringify({
|
||||
eventTypes: await prismock.eventType.findMany({
|
||||
include: {
|
||||
users: true,
|
||||
workflows: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
}),
|
||||
eventTypes: allEventTypes,
|
||||
})
|
||||
);
|
||||
return allEventTypes;
|
||||
}
|
||||
|
||||
async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
|
||||
@@ -197,10 +237,22 @@ async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser
|
||||
create: 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));
|
||||
await addEventTypesToDb(eventTypesWithUsers);
|
||||
return await addEventTypesToDb(eventTypesWithUsers);
|
||||
}
|
||||
|
||||
function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) {
|
||||
@@ -289,10 +341,21 @@ async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma
|
||||
await prismock.user.createMany({
|
||||
data: users,
|
||||
});
|
||||
|
||||
log.silly(
|
||||
"Added users to Db",
|
||||
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);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
log.silly("TestData: Creating Scenario", JSON.stringify({ data }));
|
||||
await addUsers(data.users);
|
||||
|
||||
const eventType = await addEventTypes(data.eventTypes, data.users);
|
||||
if (data.apps) {
|
||||
prismock.app.createMany({
|
||||
data: data.apps,
|
||||
});
|
||||
await addAppsToDb(
|
||||
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 || [];
|
||||
// allowSuccessfulBookingCreation();
|
||||
await addBookings(data.bookings);
|
||||
@@ -360,7 +435,7 @@ export async function createBookingScenario(data: ScenarioData) {
|
||||
await addWebhooks(data.webhooks || []);
|
||||
// addPaymentMock();
|
||||
return {
|
||||
eventType,
|
||||
eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -483,12 +558,11 @@ export const TestData = {
|
||||
},
|
||||
schedules: {
|
||||
IstWorkHours: {
|
||||
id: 1,
|
||||
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT",
|
||||
availability: [
|
||||
{
|
||||
userId: null,
|
||||
eventTypeId: null,
|
||||
// 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"),
|
||||
@@ -497,21 +571,50 @@ export const TestData = {
|
||||
],
|
||||
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) => ({
|
||||
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)",
|
||||
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,
|
||||
},
|
||||
{
|
||||
userId: null,
|
||||
eventTypeId: null,
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date(`1970-01-01T14:00:00.000Z`),
|
||||
endTime: new Date(`1970-01-01T18:00:00.000Z`),
|
||||
@@ -532,9 +635,7 @@ export const TestData = {
|
||||
},
|
||||
apps: {
|
||||
"google-calendar": {
|
||||
slug: "google-calendar",
|
||||
enabled: true,
|
||||
dirName: "whatever",
|
||||
...appStoreMetadata.googlecalendar,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
keys: {
|
||||
@@ -545,9 +646,7 @@ export const TestData = {
|
||||
},
|
||||
},
|
||||
"daily-video": {
|
||||
slug: "daily-video",
|
||||
dirName: "whatever",
|
||||
enabled: true,
|
||||
...appStoreMetadata.dailyvideo,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
keys: {
|
||||
@@ -560,9 +659,7 @@ export const TestData = {
|
||||
},
|
||||
},
|
||||
zoomvideo: {
|
||||
slug: "zoom",
|
||||
enabled: true,
|
||||
dirName: "whatever",
|
||||
...appStoreMetadata.zoomvideo,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
keys: {
|
||||
@@ -575,10 +672,7 @@ export const TestData = {
|
||||
},
|
||||
},
|
||||
"stripe-payment": {
|
||||
//TODO: Read from appStoreMeta
|
||||
slug: "stripe",
|
||||
enabled: true,
|
||||
dirName: "stripepayment",
|
||||
...appStoreMetadata.stripepayment,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
keys: {
|
||||
@@ -608,6 +702,7 @@ export function getOrganizer({
|
||||
credentials,
|
||||
selectedCalendars,
|
||||
destinationCalendar,
|
||||
defaultScheduleId,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -615,6 +710,7 @@ export function getOrganizer({
|
||||
schedules: InputUser["schedules"];
|
||||
credentials?: InputCredential[];
|
||||
selectedCalendars?: InputSelectedCalendar[];
|
||||
defaultScheduleId?: number | null;
|
||||
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
|
||||
}) {
|
||||
return {
|
||||
@@ -626,6 +722,7 @@ export function getOrganizer({
|
||||
credentials,
|
||||
selectedCalendars,
|
||||
destinationCalendar,
|
||||
defaultScheduleId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -856,7 +953,9 @@ export function mockVideoApp({
|
||||
url: `http://mock-${metadataLookupKey}.example.com`,
|
||||
};
|
||||
log.silly("mockSuccessfulVideoMeetingCreation", JSON.stringify({ metadataLookupKey, appStoreLookupKey }));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createMeetingCalls: any[] = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const updateMeetingCalls: any[] = [];
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
@@ -866,42 +965,50 @@ export function mockVideoApp({
|
||||
lib: {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
VideoApiAdapter: () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
createMeeting: (...rest: any[]) => {
|
||||
if (creationCrash) {
|
||||
throw new Error("MockVideoApiAdapter.createMeeting fake error");
|
||||
}
|
||||
createMeetingCalls.push(rest);
|
||||
VideoApiAdapter: (credential) => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
createMeeting: (...rest: any[]) => {
|
||||
if (creationCrash) {
|
||||
throw new Error("MockVideoApiAdapter.createMeeting fake error");
|
||||
}
|
||||
createMeetingCalls.push({
|
||||
credential,
|
||||
args: rest,
|
||||
});
|
||||
|
||||
return Promise.resolve({
|
||||
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
||||
...videoMeetingData,
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
updateMeeting: async (...rest: any[]) => {
|
||||
if (updationCrash) {
|
||||
throw new Error("MockVideoApiAdapter.updateMeeting fake error");
|
||||
}
|
||||
const [bookingRef, calEvent] = rest;
|
||||
updateMeetingCalls.push(rest);
|
||||
if (!bookingRef.type) {
|
||||
throw new Error("bookingRef.type is not defined");
|
||||
}
|
||||
if (!calEvent.organizer) {
|
||||
throw new Error("calEvent.organizer is not defined");
|
||||
}
|
||||
log.silly(
|
||||
"mockSuccessfulVideoMeetingCreation.updateMeeting",
|
||||
JSON.stringify({ bookingRef, calEvent })
|
||||
);
|
||||
return Promise.resolve({
|
||||
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
||||
...videoMeetingData,
|
||||
});
|
||||
},
|
||||
}),
|
||||
return Promise.resolve({
|
||||
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
||||
...videoMeetingData,
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
updateMeeting: async (...rest: any[]) => {
|
||||
if (updationCrash) {
|
||||
throw new Error("MockVideoApiAdapter.updateMeeting fake error");
|
||||
}
|
||||
const [bookingRef, calEvent] = rest;
|
||||
updateMeetingCalls.push({
|
||||
credential,
|
||||
args: rest,
|
||||
});
|
||||
if (!bookingRef.type) {
|
||||
throw new Error("bookingRef.type is not defined");
|
||||
}
|
||||
if (!calEvent.organizer) {
|
||||
throw new Error("calEvent.organizer is not defined");
|
||||
}
|
||||
log.silly(
|
||||
"mockSuccessfulVideoMeetingCreation.updateMeeting",
|
||||
JSON.stringify({ bookingRef, calEvent })
|
||||
);
|
||||
return Promise.resolve({
|
||||
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
||||
...videoMeetingData,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1029,3 +1136,25 @@ export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { exte
|
||||
}
|
||||
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 type { WebhookTriggerEvents, Booking, BookingReference } from "@prisma/client";
|
||||
import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client";
|
||||
import ical from "node-ical";
|
||||
import { expect } from "vitest";
|
||||
import "vitest-fetch-mock";
|
||||
@@ -182,11 +182,15 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
emails,
|
||||
organizer,
|
||||
booker,
|
||||
guests,
|
||||
otherTeamMembers,
|
||||
iCalUID,
|
||||
}: {
|
||||
emails: Fixtures["emails"];
|
||||
organizer: { email: string; name: string };
|
||||
booker: { email: string; name: string };
|
||||
guests?: { email: string; name: string }[];
|
||||
otherTeamMembers?: { email: string; name: string }[];
|
||||
iCalUID: string;
|
||||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
@@ -212,6 +216,39 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
},
|
||||
`${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({
|
||||
@@ -537,8 +574,9 @@ export function expectSuccessfulCalendarEventCreationInCalendar(
|
||||
updateEventCalls: any[];
|
||||
},
|
||||
expected: {
|
||||
calendarId: string | null;
|
||||
calendarId?: string | null;
|
||||
videoCallUrl: string;
|
||||
destinationCalendars: Partial<DestinationCalendar>[];
|
||||
}
|
||||
) {
|
||||
expect(calendarMock.createEventCalls.length).toBe(1);
|
||||
@@ -553,6 +591,8 @@ export function expectSuccessfulCalendarEventCreationInCalendar(
|
||||
externalId: expected.calendarId,
|
||||
}),
|
||||
]
|
||||
: expected.destinationCalendars
|
||||
? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal)))
|
||||
: null,
|
||||
videoCallData: expect.objectContaining({
|
||||
url: expected.videoCallUrl,
|
||||
@@ -584,7 +624,7 @@ export function expectSuccessfulCalendarEventUpdationInCalendar(
|
||||
expect(externalId).toBe(expected.externalCalendarId);
|
||||
}
|
||||
|
||||
export function expectSuccessfulVideoMeetingCreationInCalendar(
|
||||
export function expectSuccessfulVideoMeetingCreation(
|
||||
videoMock: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
createMeetingCalls: any[];
|
||||
@@ -592,19 +632,20 @@ export function expectSuccessfulVideoMeetingCreationInCalendar(
|
||||
updateMeetingCalls: any[];
|
||||
},
|
||||
expected: {
|
||||
externalCalendarId: string;
|
||||
calEvent: Partial<CalendarEvent>;
|
||||
uid: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
credential: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
calEvent: any;
|
||||
}
|
||||
) {
|
||||
expect(videoMock.createMeetingCalls.length).toBe(1);
|
||||
const call = videoMock.createMeetingCalls[0];
|
||||
const uid = call[0];
|
||||
const calendarEvent = call[1];
|
||||
const externalId = call[2];
|
||||
expect(uid).toBe(expected.uid);
|
||||
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
|
||||
expect(externalId).toBe(expected.externalCalendarId);
|
||||
const callArgs = call.args;
|
||||
const calEvent = callArgs[0];
|
||||
const credential = call.credential;
|
||||
|
||||
expect(credential).toEqual(expected.credential);
|
||||
expect(calEvent).toEqual(expected.calEvent);
|
||||
}
|
||||
|
||||
export function expectSuccessfulVideoMeetingUpdationInCalendar(
|
||||
@@ -622,8 +663,8 @@ export function expectSuccessfulVideoMeetingUpdationInCalendar(
|
||||
) {
|
||||
expect(videoMock.updateMeetingCalls.length).toBe(1);
|
||||
const call = videoMock.updateMeetingCalls[0];
|
||||
const bookingRef = call[0];
|
||||
const calendarEvent = call[1];
|
||||
const bookingRef = call.args[0];
|
||||
const calendarEvent = call.args[1];
|
||||
expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef));
|
||||
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 = (
|
||||
<>
|
||||
<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
|
||||
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"
|
||||
alt="Alby Icon"
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getNormalizedAppMetadata } from "./getNormalizedAppMetadata";
|
||||
|
||||
type RawAppStoreMetaData = typeof rawAppStoreMetadata;
|
||||
type AppStoreMetaData = {
|
||||
[key in keyof RawAppStoreMetaData]: AppMeta;
|
||||
[key in keyof RawAppStoreMetaData]: Omit<AppMeta, "dirName"> & { dirName: string };
|
||||
};
|
||||
|
||||
export const appStoreMetadata = {} as AppStoreMetaData;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
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 getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
@@ -15,9 +15,9 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const appType = appConfig.type;
|
||||
|
||||
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",
|
||||
expiresAt: null,
|
||||
appId: "cal-ai",
|
||||
|
||||
@@ -19,7 +19,7 @@ export const getNormalizedAppMetadata = (appMeta: RawAppStoreMetaData[keyof RawA
|
||||
dirName,
|
||||
__template: "",
|
||||
...appMeta,
|
||||
} as AppStoreMetaData[keyof AppStoreMetaData];
|
||||
} as Omit<AppStoreMetaData[keyof AppStoreMetaData], "dirName"> & { dirName: string };
|
||||
metadata.logo = getAppAssetFullPath(metadata.logo, {
|
||||
dirName,
|
||||
isTemplate: metadata.isTemplate,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
||||
import { defaultVideoAppCategories } from "@calcom/app-store/utils";
|
||||
import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
@@ -21,11 +21,11 @@ export async function getLocationGroupedOptions(
|
||||
disabled?: boolean;
|
||||
icon?: 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) {
|
||||
const teamId = userOrTeamId.teamId;
|
||||
@@ -84,11 +84,11 @@ export async function getLocationGroupedOptions(
|
||||
: app.categories[0] || app.category;
|
||||
if (!groupByCategory) groupByCategory = AppCategories.conferencing;
|
||||
|
||||
for (const credential of app.credentials) {
|
||||
const label = `${app.locationOption.label} ${
|
||||
credential.team?.name ? `(${credential.team.name})` : ""
|
||||
}`;
|
||||
const option = { ...app.locationOption, label, icon: app.logo, slug: app.slug, credential };
|
||||
for (const { teamName } of app.credentials.map((credential) => ({
|
||||
teamName: credential.team?.name,
|
||||
}))) {
|
||||
const label = `${app.locationOption.label} ${teamName ? `(${teamName})` : ""}`;
|
||||
const option = { ...app.locationOption, label, icon: app.logo, slug: app.slug };
|
||||
if (apps[groupByCategory]) {
|
||||
apps[groupByCategory] = [...apps[groupByCategory], option];
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { AppCategories } from "@prisma/client";
|
||||
// import appStore from "./index";
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
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 { 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 (appMeta.isGlobal) {
|
||||
appCredentials.push({
|
||||
const credential = {
|
||||
id: 0,
|
||||
type: appMeta.type,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@@ -65,7 +68,12 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials?
|
||||
team: {
|
||||
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 */
|
||||
|
||||
@@ -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 */
|
||||
const integrationName = event.location.replace("integrations:", "");
|
||||
|
||||
let videoCredential = event.conferenceCredentialId
|
||||
? this.videoCredentials.find((credential) => credential.id === event.conferenceCredentialId)
|
||||
: this.videoCredentials
|
||||
// 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
|
||||
.sort((a, b) => {
|
||||
return b.id - a.id;
|
||||
})
|
||||
.find((credential: CredentialPayload) => credential.type.includes(integrationName));
|
||||
let videoCredential;
|
||||
if (event.conferenceCredentialId) {
|
||||
videoCredential = this.videoCredentials.find(
|
||||
(credential) => credential.id === event.conferenceCredentialId
|
||||
);
|
||||
} else {
|
||||
videoCredential = this.videoCredentials
|
||||
// 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
|
||||
.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.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { checkBookingLimit } from "@calcom/lib/server";
|
||||
import { performance } from "@calcom/lib/server/perfObserver";
|
||||
import { getTotalBookingDuration } from "@calcom/lib/server/queries";
|
||||
@@ -25,6 +26,7 @@ import type {
|
||||
|
||||
import { getBusyTimes, getBusyTimesForLimitChecks } from "./getBusyTimes";
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["getUserAvailability"] });
|
||||
const availabilitySchema = z
|
||||
.object({
|
||||
dateFrom: stringToDayjs,
|
||||
@@ -161,7 +163,12 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
|
||||
if (userId) where.id = userId;
|
||||
|
||||
const user = initialData?.user || (await getUser(where));
|
||||
|
||||
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;
|
||||
if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId);
|
||||
@@ -225,10 +232,17 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
|
||||
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
|
||||
)[0];
|
||||
|
||||
const schedule =
|
||||
!eventType?.metadata?.config?.useHostSchedulesForTeamEvent && eventType?.schedule
|
||||
? eventType.schedule
|
||||
: userSchedule;
|
||||
const useHostSchedulesForTeamEvent = eventType?.metadata?.config?.useHostSchedulesForTeamEvent;
|
||||
const schedule = !useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule;
|
||||
log.debug(
|
||||
"Using schedule:",
|
||||
safeStringify({
|
||||
chosenSchedule: schedule,
|
||||
eventTypeSchedule: eventType?.schedule,
|
||||
userSchedule: userSchedule,
|
||||
useHostSchedulesForTeamEvent: eventType?.metadata?.config?.useHostSchedulesForTeamEvent,
|
||||
})
|
||||
);
|
||||
|
||||
const startGetWorkingHours = performance.now();
|
||||
|
||||
@@ -270,7 +284,7 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
|
||||
|
||||
const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes);
|
||||
|
||||
logger.debug(
|
||||
log.debug(
|
||||
`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`,
|
||||
JSON.stringify({
|
||||
workingHoursInUtc: workingHours,
|
||||
|
||||
@@ -55,7 +55,7 @@ const getBusyVideoTimes = async (withCredentials: CredentialPayload[]) =>
|
||||
|
||||
const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => {
|
||||
const uid: string = getUid(calEvent);
|
||||
log.silly(
|
||||
log.debug(
|
||||
"createMeeting",
|
||||
safeStringify({
|
||||
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);
|
||||
|
||||
returnObject = { ...returnObject, createdEvent: createdMeeting, success: true };
|
||||
log.debug("created Meeting", safeStringify(returnObject));
|
||||
} catch (err) {
|
||||
await sendBrokenIntegrationEmail(calEvent, "video");
|
||||
log.error("createMeeting failed", safeStringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) }));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ["../../.eslintrc.js"],
|
||||
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="?nonResponsive">Non responsive version of this page here</a></span>
|
||||
<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 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='light'">Toggle Light Scheme</button>
|
||||
<div>
|
||||
<script>
|
||||
if (only === "all" || only === "prerender-test") {
|
||||
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
|
||||
>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
|
||||
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>
|
||||
@@ -110,6 +120,7 @@
|
||||
<a href="?only=ns:floatingButton">Floating Popup</a>
|
||||
|
||||
<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">
|
||||
Book with Free User[Auto Theme]
|
||||
</button>
|
||||
|
||||
@@ -24,7 +24,7 @@ document.addEventListener("click", (e) => {
|
||||
const searchParams = new URL(document.URL).searchParams;
|
||||
const only = searchParams.get("only");
|
||||
const colorScheme = searchParams.get("color-scheme");
|
||||
|
||||
const prerender = searchParams.get("prerender");
|
||||
if (colorScheme) {
|
||||
document.documentElement.style.colorScheme = colorScheme;
|
||||
}
|
||||
@@ -211,13 +211,25 @@ if (only === "all" || only === "ns:fifth") {
|
||||
callback,
|
||||
});
|
||||
}
|
||||
|
||||
if (only === "all" || only === "prerender-test") {
|
||||
Cal("init", "prerendertestLightTheme", {
|
||||
Cal("init", "e2ePrerenderLightTheme", {
|
||||
debug: true,
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
Cal.ns.prerendertestLightTheme("preload", {
|
||||
calLink: "free",
|
||||
Cal.ns.e2ePrerenderLightTheme("prerender", {
|
||||
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",
|
||||
});
|
||||
|
||||
Cal("init", "e2ePopupLightTheme", {
|
||||
debug: true,
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
|
||||
Cal("init", "popupHideEventTypeDetails", {
|
||||
debug: true,
|
||||
origin: "http://localhost:3000",
|
||||
@@ -360,6 +377,12 @@ Cal("init", "routingFormDark", {
|
||||
});
|
||||
|
||||
if (only === "all" || only == "ns:floatingButton") {
|
||||
if (prerender == "true") {
|
||||
Cal.ns.floatingButton("prerender", {
|
||||
calLink: calLink || "pro",
|
||||
type: "floatingButton",
|
||||
});
|
||||
}
|
||||
Cal.ns.floatingButton("floatingButton", {
|
||||
calLink: calLink || "pro",
|
||||
config: {
|
||||
|
||||
@@ -56,9 +56,13 @@ export const getEmbedIframe = async ({
|
||||
clearInterval(interval);
|
||||
resolve(true);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
console.log("Iframe Status:", !!iframe, !!iframe?.contentWindow, window.iframeReady);
|
||||
console.log("Waiting for all three to be true:", {
|
||||
iframeElement: iframe,
|
||||
contentWindow: iframe?.contentWindow,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
iframeReady: window.iframeReady,
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
|
||||
|
||||
import { test } from "@calcom/web/playwright/lib/fixtures";
|
||||
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils";
|
||||
|
||||
import {
|
||||
todo,
|
||||
@@ -18,9 +19,9 @@ async function bookFirstFreeUserEventThroughEmbed({
|
||||
page,
|
||||
getActionFiredDetails,
|
||||
}: {
|
||||
addEmbedListeners: Fixtures["addEmbedListeners"];
|
||||
addEmbedListeners: Fixtures["embeds"]["addEmbedListeners"];
|
||||
page: Page;
|
||||
getActionFiredDetails: Fixtures["getActionFiredDetails"];
|
||||
getActionFiredDetails: Fixtures["embeds"]["getActionFiredDetails"];
|
||||
}) {
|
||||
const embedButtonLocator = page.locator('[data-cal-link="free"]').first();
|
||||
await page.goto("/");
|
||||
@@ -50,24 +51,16 @@ test.describe("Popup Tests", () => {
|
||||
await deleteAllBookingsByEmail("embed-user@example.com");
|
||||
});
|
||||
|
||||
test("should open embed iframe on click - Configured with light theme", async ({
|
||||
page,
|
||||
addEmbedListeners,
|
||||
getActionFiredDetails,
|
||||
}) => {
|
||||
test("should open embed iframe on click - Configured with light theme", async ({ page, embeds }) => {
|
||||
await deleteAllBookingsByEmail("embed-user@example.com");
|
||||
const calNamespace = "e2ePopupLightTheme";
|
||||
await embeds.gotoPlayground({ calNamespace, url: "/" });
|
||||
|
||||
const calNamespace = "prerendertestLightTheme";
|
||||
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-namespace="${calNamespace}"]`);
|
||||
|
||||
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, getActionFiredDetails, {
|
||||
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
|
||||
pathname: "/free",
|
||||
});
|
||||
// expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
|
||||
@@ -82,7 +75,10 @@ test.describe("Popup Tests", () => {
|
||||
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 () => {
|
||||
return await bookFirstFreeUserEventThroughEmbed({
|
||||
page,
|
||||
@@ -108,8 +104,7 @@ test.describe("Popup Tests", () => {
|
||||
|
||||
test("should open Routing Forms embed on click", async ({
|
||||
page,
|
||||
addEmbedListeners,
|
||||
getActionFiredDetails,
|
||||
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||
}) => {
|
||||
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("should open embed iframe according to system theme when no theme is configured through Embed API", async ({
|
||||
page,
|
||||
addEmbedListeners,
|
||||
getActionFiredDetails,
|
||||
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||
}) => {
|
||||
const calNamespace = "floatingButton";
|
||||
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 ({
|
||||
page,
|
||||
addEmbedListeners,
|
||||
getActionFiredDetails,
|
||||
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||
}) => {
|
||||
const calNamespace = "floatingButton";
|
||||
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 ({
|
||||
page,
|
||||
addEmbedListeners,
|
||||
getActionFiredDetails,
|
||||
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||
}) => {
|
||||
const calNamespace = "floatingButton";
|
||||
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 ({
|
||||
page,
|
||||
addEmbedListeners,
|
||||
getActionFiredDetails,
|
||||
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||
}) => {
|
||||
const calNamespace = "floatingButton";
|
||||
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("Inline Iframe - Configured with Dark Theme", async ({
|
||||
page,
|
||||
getActionFiredDetails,
|
||||
addEmbedListeners,
|
||||
embeds: { addEmbedListeners, getActionFiredDetails },
|
||||
}) => {
|
||||
await deleteAllBookingsByEmail("embed-user@example.com");
|
||||
await addEmbedListeners("");
|
||||
|
||||
@@ -41,6 +41,21 @@ export class ModalBox extends HTMLElement {
|
||||
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() {
|
||||
this.assertHasShadowRoot();
|
||||
const loaderEl = this.shadowRoot.querySelector<HTMLElement>(".loader");
|
||||
@@ -68,10 +83,14 @@ export class ModalBox extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue == "loaded") {
|
||||
this.getLoaderElement().style.display = "none";
|
||||
} else if (newValue === "started") {
|
||||
if (newValue === "loading") {
|
||||
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") {
|
||||
this.close();
|
||||
} else if (newValue === "failed") {
|
||||
@@ -79,6 +98,8 @@ export class ModalBox extends HTMLElement {
|
||||
this.getErrorElement().style.display = "inline-block";
|
||||
const errorString = getErrorString(this.dataset.errorCode);
|
||||
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 { 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 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 {
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
styles: {} as EmbedStyles | undefined,
|
||||
nonStyles: {} as EmbedNonStylesConfig | undefined,
|
||||
@@ -148,6 +196,8 @@ const useUrlChange = (callback: (newUrl: string) => void) => {
|
||||
const pathname = currentFullUrl?.pathname ?? "";
|
||||
const searchParams = currentFullUrl?.searchParams ?? null;
|
||||
const lastKnownUrl = useRef(`${pathname}?${searchParams}`);
|
||||
const router = useRouter();
|
||||
embedStore.router.setNextRouter(router);
|
||||
useEffect(() => {
|
||||
const newUrl = `${pathname}?${searchParams}`;
|
||||
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.
|
||||
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 = {
|
||||
@@ -451,58 +520,71 @@ if (isBrowser) {
|
||||
};
|
||||
|
||||
actOnColorScheme(embedStore.uiConfig.colorScheme);
|
||||
|
||||
if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) {
|
||||
log("Initializing embed-iframe");
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
// If embed link is opened in top, and not in iframe. Let the page be visible.
|
||||
if (top === window) {
|
||||
unhideBody();
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -517,3 +599,22 @@ function actOnColorScheme(colorScheme: string | null | undefined) {
|
||||
}
|
||||
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 { Inline } from "./Inline/inline";
|
||||
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 { SdkActionManager } from "./sdk-action-manager";
|
||||
import type { EventData, EventDataMap } from "./sdk-action-manager";
|
||||
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
|
||||
type Rest<T extends any[]> = T extends [any, ...infer U] ? U : never;
|
||||
export type Message = {
|
||||
@@ -151,34 +152,14 @@ type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap];
|
||||
export type Instruction = SingleInstruction | SingleInstruction[];
|
||||
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 {
|
||||
iframe?: HTMLIFrameElement;
|
||||
|
||||
__config: Config;
|
||||
|
||||
modalBox!: Element;
|
||||
modalBox?: Element;
|
||||
|
||||
inlineEl!: Element;
|
||||
inlineEl?: Element;
|
||||
|
||||
namespace: string;
|
||||
|
||||
@@ -190,6 +171,8 @@ export class Cal {
|
||||
|
||||
api: CalApi;
|
||||
|
||||
isPerendering?: boolean;
|
||||
|
||||
static actionsManagers: Record<Namespace, SdkActionManager>;
|
||||
|
||||
static getQueryObject(config: PrefillAndIframeAttrsConfig) {
|
||||
@@ -389,6 +372,9 @@ export class Cal {
|
||||
});
|
||||
|
||||
this.actionManager.on("__routeChanged", () => {
|
||||
if (!this.inlineEl) {
|
||||
return;
|
||||
}
|
||||
const { top, height } = this.inlineEl.getBoundingClientRect();
|
||||
// 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
|
||||
@@ -398,6 +384,10 @@ export class Cal {
|
||||
});
|
||||
|
||||
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.inlineEl?.setAttribute("loading", "done");
|
||||
});
|
||||
@@ -418,6 +408,8 @@ export class Cal {
|
||||
class CalApi {
|
||||
cal: Cal;
|
||||
static initializedNamespaces = [] as string[];
|
||||
modalUid?: string;
|
||||
preloadedModalUid?: string;
|
||||
constructor(cal: Cal) {
|
||||
this.cal = cal;
|
||||
}
|
||||
@@ -563,41 +555,71 @@ class CalApi {
|
||||
modal({
|
||||
calLink,
|
||||
config = {},
|
||||
uid,
|
||||
__prerender = false,
|
||||
}: {
|
||||
calLink: string;
|
||||
config?: PrefillAndIframeAttrsConfig;
|
||||
uid?: string | number;
|
||||
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}"]`);
|
||||
if (existingModalEl) {
|
||||
existingModalEl.setAttribute("state", "started");
|
||||
return;
|
||||
const containerEl = document.body;
|
||||
|
||||
this.cal.isPerendering = !!__prerender;
|
||||
|
||||
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) {
|
||||
throw new Error("iframeAttrs should be an object");
|
||||
}
|
||||
|
||||
config.embedType = "modal";
|
||||
const containerEl = document.body;
|
||||
const iframe = this.cal.createIframe({
|
||||
calLink,
|
||||
queryObject: withColorScheme(Cal.getQueryObject(config), containerEl),
|
||||
});
|
||||
let iframe = null;
|
||||
|
||||
if (!iframe) {
|
||||
iframe = this.cal.createIframe({
|
||||
calLink,
|
||||
queryObject,
|
||||
});
|
||||
}
|
||||
|
||||
iframe.style.borderRadius = "8px";
|
||||
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.width = "100%";
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
|
||||
|
||||
this.cal.modalBox = template.content.children[0];
|
||||
this.cal.modalBox.appendChild(iframe);
|
||||
|
||||
if (__prerender) {
|
||||
this.cal.modalBox.setAttribute("state", "prerendering");
|
||||
}
|
||||
this.handleClose();
|
||||
containerEl.appendChild(template.content);
|
||||
}
|
||||
@@ -605,7 +627,7 @@ class CalApi {
|
||||
private handleClose() {
|
||||
// A request, to close from the iframe, should close the modal
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
validate(arguments[0], {
|
||||
required: true,
|
||||
@@ -652,17 +690,58 @@ class CalApi {
|
||||
type: "string",
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const iframe = document.body.appendChild(document.createElement("iframe"));
|
||||
const config = this.cal.getConfig();
|
||||
let api: GlobalCalWithoutNs = globalCal;
|
||||
const namespace = this.cal.namespace;
|
||||
if (namespace) {
|
||||
api = globalCal.ns[namespace];
|
||||
}
|
||||
|
||||
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
|
||||
urlInstance.searchParams.set("prerender", "true");
|
||||
iframe.src = urlInstance.toString();
|
||||
iframe.style.width = "0";
|
||||
iframe.style.height = "0";
|
||||
iframe.style.display = "none";
|
||||
if (!api) {
|
||||
throw new Error(`Namespace ${namespace} isn't defined`);
|
||||
}
|
||||
|
||||
const config = this.cal.getConfig();
|
||||
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) {
|
||||
@@ -755,7 +834,6 @@ document.addEventListener("click", (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const modalUniqueId = (targetEl.dataset.uniqueId = targetEl.dataset.uniqueId || String(Date.now()));
|
||||
const namespace = targetEl.dataset.calNamespace;
|
||||
const configString = targetEl.dataset.calConfig || "";
|
||||
const calOrigin = targetEl.dataset.calOrigin || "";
|
||||
@@ -779,7 +857,6 @@ document.addEventListener("click", (e) => {
|
||||
api("modal", {
|
||||
calLink: path,
|
||||
config,
|
||||
uid: modalUniqueId,
|
||||
calOrigin,
|
||||
});
|
||||
});
|
||||
@@ -812,3 +889,14 @@ function getEmbedApiFn(ns: string) {
|
||||
}
|
||||
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("should verify that the iframe got created with correct URL", async ({
|
||||
page,
|
||||
getActionFiredDetails,
|
||||
addEmbedListeners,
|
||||
embeds: { getActionFiredDetails, addEmbedListeners },
|
||||
}) => {
|
||||
//TODO: Do it with page.goto automatically
|
||||
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.map((slots) => (
|
||||
<AvailableTimes
|
||||
className="scroll-bar w-full overflow-auto"
|
||||
className="scroll-bar w-full overflow-y-auto overflow-x-hidden"
|
||||
key={slots.date}
|
||||
showTimeFormatToggle={!isColumnView}
|
||||
onTimeSelect={onTimeSelect}
|
||||
|
||||
@@ -375,6 +375,7 @@ export const BookEventFormChild = ({
|
||||
fields={eventType.bookingFields}
|
||||
locations={eventType.locations}
|
||||
rescheduleUid={rescheduleUid || undefined}
|
||||
bookingData={bookingData}
|
||||
/>
|
||||
{(createBookingMutation.isError ||
|
||||
createRecurringBookingMutation.isError ||
|
||||
@@ -404,8 +405,8 @@ export const BookEventFormChild = ({
|
||||
type="submit"
|
||||
color="primary"
|
||||
loading={createBookingMutation.isLoading || createRecurringBookingMutation.isLoading}
|
||||
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}>
|
||||
{rescheduleUid
|
||||
data-testid={rescheduleUid && bookingData ? "confirm-reschedule-button" : "confirm-book-button"}>
|
||||
{rescheduleUid && bookingData
|
||||
? t("reschedule")
|
||||
: renderConfirmNotVerifyEmailButtonCond
|
||||
? t("confirm")
|
||||
@@ -497,12 +498,18 @@ function useInitialFormValues({
|
||||
});
|
||||
|
||||
const defaultUserValues = {
|
||||
email: rescheduleUid
|
||||
? bookingData?.attendees[0].email
|
||||
: parsedQuery["email"] || session.data?.user?.email || "",
|
||||
name: rescheduleUid
|
||||
? bookingData?.attendees[0].name
|
||||
: parsedQuery["name"] || session.data?.user?.name || "",
|
||||
email:
|
||||
rescheduleUid && bookingData && bookingData.attendees.length > 0
|
||||
? bookingData?.attendees[0].email
|
||||
: !!parsedQuery["email"]
|
||||
? parsedQuery["email"]
|
||||
: 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) {
|
||||
@@ -526,14 +533,12 @@ function useInitialFormValues({
|
||||
setDefaultValues(defaults);
|
||||
}
|
||||
|
||||
if ((!rescheduleUid && !bookingData) || !bookingData?.attendees.length) {
|
||||
return {};
|
||||
}
|
||||
const primaryAttendee = bookingData.attendees[0];
|
||||
if (!primaryAttendee) {
|
||||
if (!rescheduleUid && !bookingData) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// We should allow current session user as default values for booking form
|
||||
|
||||
const defaults = {
|
||||
responses: {} as Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
|
||||
};
|
||||
@@ -541,7 +546,7 @@ function useInitialFormValues({
|
||||
const responses = eventType.bookingFields.reduce((responses, field) => {
|
||||
return {
|
||||
...responses,
|
||||
[field.name]: bookingData.responses[field.name],
|
||||
[field.name]: bookingData?.responses[field.name],
|
||||
};
|
||||
}, {});
|
||||
defaults.responses = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
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 { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -13,10 +14,12 @@ export const BookingFields = ({
|
||||
locations,
|
||||
rescheduleUid,
|
||||
isDynamicGroupBooking,
|
||||
bookingData,
|
||||
}: {
|
||||
fields: NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"];
|
||||
locations: LocationObject[];
|
||||
rescheduleUid?: string;
|
||||
bookingData?: GetBookingType | null;
|
||||
isDynamicGroupBooking: boolean;
|
||||
}) => {
|
||||
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.
|
||||
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
|
||||
let readOnly =
|
||||
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
|
||||
(field.editable === "system" || field.editable === "system-but-optional") &&
|
||||
!!rescheduleUid &&
|
||||
bookingData !== null;
|
||||
|
||||
let hidden = !!field.hidden;
|
||||
const fieldViews = field.views;
|
||||
@@ -42,6 +47,9 @@ export const BookingFields = ({
|
||||
}
|
||||
|
||||
if (field.name === SystemField.Enum.rescheduleReason) {
|
||||
if (bookingData === null) {
|
||||
return null;
|
||||
}
|
||||
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule
|
||||
readOnly = false;
|
||||
}
|
||||
@@ -64,8 +72,8 @@ export const BookingFields = ({
|
||||
hidden = isDynamicGroupBooking ? true : !!field.hidden;
|
||||
}
|
||||
|
||||
// We don't show `notes` field during reschedule
|
||||
if (field.name === SystemField.Enum.notes && !!rescheduleUid) {
|
||||
// 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 && bookingData !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { m } from "framer-motion";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
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 = () => {
|
||||
const { setTimezone, timeFormat, timezone } = useTimePreferences();
|
||||
const selectedDuration = useBookerStore((state) => state.selectedDuration);
|
||||
const setSelectedDuration = useBookerStore((state) => state.setSelectedDuration);
|
||||
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
|
||||
const bookerState = useBookerStore((state) => state.state);
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
@@ -36,6 +38,13 @@ export const EventMeta = () => {
|
||||
const isEmbed = useIsEmbed();
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Calendar, Columns, Grid } from "@calcom/ui/components/icon";
|
||||
import { TimeFormatToggle } from "../../components/TimeFormatToggle";
|
||||
import { useBookerStore } from "../store";
|
||||
import type { BookerLayout } from "../types";
|
||||
import { OverlayCalendarContainer } from "./OverlayCalendar/OverlayCalendarContainer";
|
||||
|
||||
export function Header({
|
||||
extraDays,
|
||||
@@ -56,7 +57,12 @@ export function Header({
|
||||
|
||||
// In month view we only show the layout toggle.
|
||||
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");
|
||||
|
||||
@@ -113,6 +119,7 @@ export function Header({
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<OverlayCalendarContainer />
|
||||
<TimeFormatToggle />
|
||||
<div className="fixed top-4 ltr:right-4 rtl:left-4">
|
||||
<LayoutToggleWithData />
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
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 { useBookerStore } from "../store";
|
||||
import { useEvent, useScheduleForEvent } from "../utils/event";
|
||||
import { getQueryParam } from "../utils/query-param";
|
||||
import { useOverlayCalendarStore } from "./OverlayCalendar/store";
|
||||
|
||||
export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
||||
const selectedDate = useBookerStore((state) => state.selectedDate);
|
||||
const date = selectedDate || dayjs().format("YYYY-MM-DD");
|
||||
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
|
||||
const selectedEventDuration = useBookerStore((state) => state.selectedDuration);
|
||||
const overlayEvents = useOverlayCalendarStore((state) => state.overlayBusyDates);
|
||||
const schedule = useScheduleForEvent({
|
||||
prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(),
|
||||
});
|
||||
const displayOverlay = getQueryParam("overlayCalendar") === "true";
|
||||
|
||||
const event = useEvent();
|
||||
const eventDuration = selectedEventDuration || event?.data?.length || 30;
|
||||
@@ -39,6 +44,24 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
||||
.add(extraDays - 1, "day")
|
||||
.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 (
|
||||
<div className="h-full [--calendar-dates-sticky-offset:66px]">
|
||||
<Calendar
|
||||
@@ -46,7 +69,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
||||
availableTimeslots={availableSlots}
|
||||
startHour={0}
|
||||
endHour={23}
|
||||
events={[]}
|
||||
events={overlayEventsForDate}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
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 },
|
||||
};
|
||||
|
||||
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 = {
|
||||
[key in BookerLayout]: {
|
||||
[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 type { Slots } from "@calcom/features/schedules";
|
||||
@@ -7,17 +11,21 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { useBookerStore } from "../Booker/store";
|
||||
import { getQueryParam } from "../Booker/utils/query-param";
|
||||
import { useTimePreferences } from "../lib";
|
||||
import { useCheckOverlapWithOverlay } from "../lib/useCheckOverlapWithOverlay";
|
||||
import { SeatsAvailabilityText } from "./SeatsAvailabilityText";
|
||||
|
||||
type TOnTimeSelect = (
|
||||
time: string,
|
||||
attendees: number,
|
||||
seatsPerTimeSlot?: number | null,
|
||||
bookingUid?: string
|
||||
) => void;
|
||||
|
||||
type AvailableTimesProps = {
|
||||
slots: Slots[string];
|
||||
onTimeSelect: (
|
||||
time: string,
|
||||
attendees: number,
|
||||
seatsPerTimeSlot?: number | null,
|
||||
bookingUid?: string
|
||||
) => void;
|
||||
onTimeSelect: TOnTimeSelect;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
showAvailableSeatsCount?: boolean | null;
|
||||
showTimeFormatToggle?: boolean;
|
||||
@@ -25,6 +33,148 @@ type AvailableTimesProps = {
|
||||
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 = ({
|
||||
slots,
|
||||
onTimeSelect,
|
||||
@@ -34,10 +184,7 @@ export const AvailableTimes = ({
|
||||
className,
|
||||
selectedSlots,
|
||||
}: AvailableTimesProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]);
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
const hasTimeSlots = !!seatsPerTimeSlot;
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className={classNames("text-default flex flex-col", className)}>
|
||||
@@ -50,45 +197,16 @@ export const AvailableTimes = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{slots.map((slot) => {
|
||||
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";
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{slots.map((slot) => (
|
||||
<SlotItem
|
||||
key={slot.time}
|
||||
onTimeSelect={onTimeSelect}
|
||||
slot={slot}
|
||||
selectedSlots={selectedSlots}
|
||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||
showAvailableSeatsCount={showAvailableSeatsCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
@@ -14,10 +15,20 @@ interface BookerSeoProps {
|
||||
teamSlug?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
bookingData?: GetBookingType | null;
|
||||
}
|
||||
|
||||
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 { data: event } = trpc.viewer.public.event.useQuery(
|
||||
{ username, eventSlug, isTeamEvent, org: entity.orgSlug ?? null },
|
||||
@@ -29,7 +40,7 @@ export const BookerSeo = (props: BookerSeoProps) => {
|
||||
const title = event?.title ?? "";
|
||||
return (
|
||||
<HeadSeo
|
||||
title={`${rescheduleUid ? t("reschedule") : ""} ${title} | ${profileName}`}
|
||||
title={`${rescheduleUid && !!bookingData ? t("reschedule") : ""} ${title} | ${profileName}`}
|
||||
description={`${rescheduleUid ? t("reschedule") : ""} ${title}`}
|
||||
meeting={{
|
||||
title: title,
|
||||
|
||||
@@ -109,7 +109,7 @@ export const getBookingWithResponses = <
|
||||
|
||||
export default getBooking;
|
||||
|
||||
export const getBookingForReschedule = async (uid: string) => {
|
||||
export const getBookingForReschedule = async (uid: string, userId?: number) => {
|
||||
let rescheduleUid: string | null = null;
|
||||
const theBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
@@ -117,8 +117,25 @@ export const getBookingForReschedule = async (uid: string) => {
|
||||
},
|
||||
select: {
|
||||
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
|
||||
// that its being rescheduled, which we query next.
|
||||
@@ -144,11 +161,26 @@ export const getBookingForReschedule = async (uid: string) => {
|
||||
},
|
||||
});
|
||||
if (bookingSeat) {
|
||||
bookingSeatReferenceUid = bookingSeat.id;
|
||||
rescheduleUid = bookingSeat.booking.uid;
|
||||
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,
|
||||
// and we return null here.
|
||||
if (!theBooking && !rescheduleUid) return null;
|
||||
@@ -161,6 +193,8 @@ export const getBookingForReschedule = async (uid: string) => {
|
||||
...booking,
|
||||
attendees: rescheduleUid
|
||||
? booking.attendees.filter((attendee) => attendee.email === attendeeEmail)
|
||||
: hasOwnershipOnBooking
|
||||
? []
|
||||
: booking.attendees,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -136,6 +136,19 @@ async function handler(req: CustomRequest) {
|
||||
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
|
||||
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
|
||||
|
||||
|
||||
@@ -379,7 +379,6 @@ async function ensureAvailableUsers(
|
||||
)
|
||||
: undefined;
|
||||
|
||||
log.debug("getUserAvailability for users", JSON.stringify({ users: eventType.users.map((u) => u.id) }));
|
||||
/** Let's start checking for availability */
|
||||
for (const user of eventType.users) {
|
||||
const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability(
|
||||
@@ -968,7 +967,7 @@ async function handler(
|
||||
if (
|
||||
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.
|
||||
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.
|
||||
// So, this would reuse the same Form as the parent form.
|
||||
const fieldsForm = useFormContext<RhfForm>();
|
||||
|
||||
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||
const { t } = useLocale();
|
||||
|
||||
const { fields, swap, remove, update, append } = useFieldArray({
|
||||
@@ -112,7 +112,7 @@ export const FormBuilder = function FormBuilder({
|
||||
{LockedIcon}
|
||||
</div>
|
||||
<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) => {
|
||||
const options = field.options
|
||||
? field.options
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { yyyymmdd } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogClose,
|
||||
Switch,
|
||||
showToast,
|
||||
Form,
|
||||
Button,
|
||||
} from "@calcom/ui";
|
||||
@@ -23,15 +23,11 @@ import DatePicker from "../../calendars/DatePicker";
|
||||
import type { TimeRange } from "./Schedule";
|
||||
import { DayRanges } from "./Schedule";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const noop = () => {};
|
||||
|
||||
const DateOverrideForm = ({
|
||||
value,
|
||||
workingHours,
|
||||
excludedDates,
|
||||
onChange,
|
||||
onClose = noop,
|
||||
}: {
|
||||
workingHours?: WorkingHours[];
|
||||
onChange: (newValue: TimeRange[]) => void;
|
||||
@@ -137,14 +133,10 @@ const DateOverrideForm = ({
|
||||
})
|
||||
: datesInRanges
|
||||
);
|
||||
onClose();
|
||||
setSelectedDates([]);
|
||||
}}
|
||||
className="p-6 sm:flex sm:p-0 md:flex-col lg:flex-col xl:flex-row">
|
||||
<div
|
||||
className={classNames(
|
||||
selectedDates[0] && "sm:border-subtle w-full sm:border-r sm:pr-6",
|
||||
"sm:p-4 md:p-8"
|
||||
)}>
|
||||
<div className="sm:border-subtle w-full sm:border-r sm:p-4 sm:pr-6 md:p-8">
|
||||
<DialogHeader title={t("date_overrides_dialog_title")} />
|
||||
<DatePicker
|
||||
excludedDates={excludedDates}
|
||||
@@ -160,39 +152,48 @@ const DateOverrideForm = ({
|
||||
locale={isLocaleReady ? i18n.language : "en"}
|
||||
/>
|
||||
</div>
|
||||
{selectedDates[0] && (
|
||||
<div className="relative mt-8 flex w-full flex-col sm:mt-0 sm:p-4 md:p-8">
|
||||
<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>
|
||||
{datesUnavailable ? (
|
||||
<p className="text-subtle border-default rounded border p-2 text-sm">
|
||||
{t("date_overrides_unavailable")}
|
||||
</p>
|
||||
) : (
|
||||
<DayRanges name="range" />
|
||||
)}
|
||||
<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>
|
||||
{datesUnavailable ? (
|
||||
<p className="text-subtle border-default rounded border p-2 text-sm">
|
||||
{t("date_overrides_unavailable")}
|
||||
</p>
|
||||
) : (
|
||||
<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>
|
||||
<Switch
|
||||
label={t("date_overrides_mark_all_day_unavailable_one")}
|
||||
checked={datesUnavailable}
|
||||
onCheckedChange={setDatesUnavailable}
|
||||
data-testid="date-override-mark-unavailable"
|
||||
/>
|
||||
<div className="mt-4 flex flex-row-reverse sm:mt-0">
|
||||
<Button
|
||||
className="ml-2"
|
||||
color="primary"
|
||||
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 className="mt-4 flex flex-row-reverse sm:mt-0">
|
||||
<Button
|
||||
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>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -220,7 +221,7 @@ const DateOverrideInputDialog = ({
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{Trigger}</DialogTrigger>
|
||||
|
||||
<DialogContent enableOverflow={enableOverflow} size="md" className="p-0 md:w-auto">
|
||||
<DialogContent enableOverflow={enableOverflow} size="md" className="p-0">
|
||||
<DateOverrideForm
|
||||
excludedDates={excludedDates}
|
||||
{...passThroughProps}
|
||||
|
||||
@@ -55,7 +55,7 @@ export const BookerLayoutSelector = ({
|
||||
if (flags["booker-layouts"] !== true) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="border-subtle rounded-t-xl border p-6">
|
||||
<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">
|
||||
@@ -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">
|
||||
{/* show top navigation for md and smaller (tablet and phones) */}
|
||||
{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>
|
||||
{!props.withoutMain ? <ShellMain {...props}>{props.children}</ShellMain> : props.children}
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
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 { 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 newMetadata = EventTypeMetaDataSchema.parse(metadata || {}) || {};
|
||||
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.
|
||||
*/
|
||||
function fromEntriesWithDuplicateKeys(entries: ReturnType<ReturnType<typeof useSearchParams>["entries"]>) {
|
||||
function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string, string]> | null) {
|
||||
const result: Record<string, string | string[]> = {};
|
||||
|
||||
if (entries === null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Consider setting atleast ES2015 as target
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
@@ -31,6 +36,6 @@ function fromEntriesWithDuplicateKeys(entries: ReturnType<ReturnType<typeof useS
|
||||
*/
|
||||
export const useRouterQuery = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const routerQuery = fromEntriesWithDuplicateKeys(searchParams.entries());
|
||||
const routerQuery = fromEntriesWithDuplicateKeys(searchParams?.entries() ?? null);
|
||||
return routerQuery;
|
||||
};
|
||||
|
||||
@@ -96,7 +96,9 @@ export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
|
||||
|
||||
// Remove all query params from the URL
|
||||
function removeAllQueryParams() {
|
||||
router.replace(pathname);
|
||||
if (pathname !== null) {
|
||||
router.replace(pathname);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
const pathname = usePathname() as null | string;
|
||||
const searchParams = useSearchParams();
|
||||
const query = searchParams.toString();
|
||||
const query = searchParams?.toString();
|
||||
let pathnameWithQuery;
|
||||
if (query) {
|
||||
pathnameWithQuery = `${pathname}?${query}`;
|
||||
|
||||
@@ -47,6 +47,12 @@ export const seoConfig: {
|
||||
* @param path NextJS' useRouter().asPath
|
||||
* @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];
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user