Compare commits

...
19 Commits
Author SHA1 Message Date
GitHub Actions f88a91dad0 chore: release v4.8.18
Post release / release (push) Canceled after 0s
2025-01-13 18:12:32 +00:00
Keith WilliamsandAlex van Andel 35059d7727 revert: "feat: bulk shorten links with dub.links.createMany (#18539)" (#18587)
This reverts commit 4902c6a0f8.

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-01-13 18:07:27 +00:00
Carina WollendorferandCarinaWolli 68f76e2acc disable emails to all guests (#18628)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-01-13 15:58:43 +00:00
Benny Joo 80aac06ff3 chore: restore error pages for pages router (#18625) 2025-01-13 12:42:00 +00:00
sean-brydon 0f47528ba2 fix: parsing teamId (#18623) 2025-01-13 12:16:59 +00:00
Benny Joo 113dc5fca6 chore: try using custom 404 in pages/_error (#18622) 2025-01-13 11:35:10 +00:00
Benny Joo 0befdde5d7 chore: gracefully handle 404s from pages router's dynamic pages + tests (#18618)
* restore pages/_error

* set custom header in pages/_error

* handle it in middleware

* add test

* remove logs

* better test description
2025-01-13 15:46:56 +05:30
Benny Joo b3ed6e39e3 chore: redirect to /500 if pathname does not exist + better error handling (#18615)
* fix lint error

* fix booking page and better error handling
2025-01-13 00:04:36 +00:00
sean-brydon e07b1eda32 Async False (#18611) 2025-01-12 20:31:43 +00:00
Benny Joo 8262894bde fix: app/not-found cannot be a static page (#18610) 2025-01-12 20:21:03 +01:00
Benny Joo a3d7bcd22e feat: render custom error page for unexpected sever error + remove pages/_error (#18606)
* remove page/_error

* refactor app/error
2025-01-12 14:12:02 +01:00
Omar LópezandBenny Joo 62b753afba chore: app router 404 page (#18597)
* wip

* wip

* fix not found page

* render middleware for /settings pages

* fix

* remove global-error page

* add metadata to not-found page

* make not-found page static

* remove 404

* adding not-found to middleware is not necessary

* add every routes to config.matcher

* fix test

* fix style

* use i18n string

* fix tests

* fix

* fix

* revert unneeded changes

* fix

* fix

* fix

* fix style

* fix

* remove 404

* remove log

* fix

* fix

* fix

* fix

* better naming

* parallel testing

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-01-11 02:42:08 +00:00
bb40b2f6bf chore: app router - all sub-pages in /apps (#16976)
* chore: apps/[slug] remove pages router

* remove apps/[slug] pages from /future

* chore: apps/installed remove pages router

* chore: apps/installation remove pages router

* remove Head element

* fix metadata

* fix test

* fix another test

* chore: apps/categories remove pages router

* revert unneeded changes

* update middleware

* Remove <Head>

* remove unused import and code

* remove unused import and code again

* fix

* fix category page

* add split icon

* add /routing paths to middleware matcher

* wip

* remove HeadSeo from App.tsx

* clean up head-seo test

* add generateAppMetadata

* use generateAppMetadata in apps/[slug] page

* delete file

* remove log

* fix

* fix

* fix apps/installed pages

* fix cateogires pages

* fix

* fix imports

* wip

* fix

* fix

* fix metadata

* fix

* redirect /apps/routing-forms to /routing

* replace all usages of /apps/routing-forms to /routing

* better naming

* /routing -> /routing/forms

* fix

* fix

* fix

* fix

* remove backPath as it is irrelevant when withoutMain is true

* fix type checks

* fix type check in apps/[slug]

* refactors

* fix

* fix test

* fix

* fix

* fix

* Replace multiple leading slashes with a single slash

* migrate routing-forms too

* add re routing

* fix

* add redirection

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-01-10 20:16:11 -05:00
NizzyandOmar López f628dc83a8 detailed customer card (#18511)
Co-authored-by: Omar López <zomars@me.com>
2025-01-10 23:51:27 +00:00
sean-brydonandOmar López 2fd2578504 feat: Round Robin weights future members toggle (#17782)
Co-authored-by: Omar López <zomars@me.com>
2025-01-10 14:24:19 -07:00
Syed Ali ShahbazandCarinaWolli a29407349d docs: fix error introduced in #18578 (#18593)
* Fix error

* remove indent

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-01-10 19:48:12 +00:00
Joe Au-YeungandAnik Dhabal Babu e7df1ef493 fix: Create dub links if valid url (#18588)
* Create dub links if valid

* Map through filtered links

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-01-10 15:58:16 +00:00
Kartik Saini b652ae7714 fix: wrong Meeting Ended payload in zapier (#18202)
* fix: wrong Meeting Ended payload in zapier

* fix
2025-01-10 15:55:35 +01:00
Peer Richelsen cec1c9ee46 feat: granola.ai app (#18585)
* new granola app

* granola app
2025-01-10 14:47:18 +00:00
118 changed files with 1977 additions and 1063 deletions
-8
View File
@@ -355,14 +355,6 @@ E2E_TEST_OIDC_USER_PASSWORD=
# provide a value between 0 and 100 to ensure the percentage of traffic
# redirected from the legacy to the future pages
AB_TEST_BUCKET_PROBABILITY=50
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
APP_ROUTER_APPS_SLUG_ENABLED=0
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
# whether we redirect to the future/apps/categories from /apps/categories or not
APP_ROUTER_APPS_CATEGORIES_ENABLED=0
# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
APP_ROUTER_APPS_ENABLED=0
APP_ROUTER_TEAM_ENABLED=0
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
APP_ROUTER_AUTH_LOGIN_ENABLED=0
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
-5
View File
@@ -5,11 +5,6 @@ import { NextResponse, URLPattern } from "next/server";
import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME } from "@calcom/lib/constants";
const ROUTES: [URLPattern, boolean][] = [
["/apps/installed/:category", process.env.APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED === "1"] as const,
["/apps/:slug", process.env.APP_ROUTER_APPS_SLUG_ENABLED === "1"] as const,
["/apps/:slug/setup", process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED === "1"] as const,
["/apps/categories", process.env.APP_ROUTER_APPS_CATEGORIES_ENABLED === "1"] as const,
["/apps/categories/:category", process.env.APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED === "1"] as const,
["/auth/forgot-password/:path*", process.env.APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED === "1"] as const,
["/auth/login", process.env.APP_ROUTER_AUTH_LOGIN_ENABLED === "1"] as const,
["/auth/logout", process.env.APP_ROUTER_AUTH_LOGOUT_ENABLED === "1"] as const,
+26 -4
View File
@@ -3,7 +3,8 @@ import i18next from "i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { headers } from "next/headers";
import { constructGenericImage } from "@calcom/lib/OgImages";
import type { AppImageProps } from "@calcom/lib/OgImages";
import { constructGenericImage, constructAppImage } from "@calcom/lib/OgImages";
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG, CAL_URL } from "@calcom/lib/constants";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import { truncateOnWord } from "@calcom/lib/text";
@@ -61,17 +62,18 @@ const _generateMetadataWithoutImage = async (
const t = await getTranslationWithCache(locale);
const title = getTitle(t);
const description = getDescription(t);
const titleSuffix = `| ${APP_NAME}`;
const displayedTitle = title.includes(titleSuffix) || hideBranding ? title : `${title} ${titleSuffix}`;
const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL);
const truncatedDescription = truncateOnWord(getDescription(t), 158);
return {
title: title.length === 0 ? APP_NAME : displayedTitle,
description,
description: truncatedDescription,
alternates: { canonical },
openGraph: {
description: truncateOnWord(description, 158),
description: truncatedDescription,
url: canonical,
type: "website",
siteName: APP_NAME,
@@ -103,3 +105,23 @@ export const _generateMetadata = async (
},
};
};
export const generateAppMetadata = async (
app: AppImageProps,
getTitle: (t: TFunction<string, undefined>) => string,
getDescription: (t: TFunction<string, undefined>) => string,
hideBranding?: boolean,
origin?: string
) => {
const metadata = await _generateMetadataWithoutImage(getTitle, getDescription, hideBranding, origin);
const image = SEO_IMG_OGIMG + constructAppImage({ ...app, description: metadata.description });
return {
...metadata,
openGraph: {
...metadata.openGraph,
images: [image],
},
};
};
+22 -163
View File
@@ -10,12 +10,18 @@ import { userMetadata } from "@calcom/prisma/zod-utils";
const inputSchema = z.object({
customer: z.object({
name: z.string().optional(),
email: z.string().email(),
username: z.string().optional(),
timeZone: z.string().optional(),
emailVerified: z.boolean().optional(),
identityProvider: z.string().optional(),
twoFactorEnabled: z.boolean().optional(),
lastActiveAt: z.string().optional(),
teamName: z.string().optional(),
teamSlug: z.string().optional(),
isOrganization: z.boolean().optional(),
stripeCustomerId: z.string().optional(),
}),
cardKeys: z.array(z.string()),
});
@@ -53,168 +59,10 @@ async function handler(request: Request) {
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: customer.email || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email Verified?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: customer.emailVerified ? "Yes" : "No",
badgeColor: customer.emailVerified ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Username",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: customer.username || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "User ID",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Time Zone",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: customer.timeZone || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Two Factor Enabled?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: customer.twoFactorEnabled ? "Yes" : "No",
badgeColor: customer.twoFactorEnabled ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Identity Provider",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: customer.identityProvider || "Unknown",
},
},
],
componentText: {
textSize: "L",
textColor: "MUTED",
text: "User does not exist!",
},
},
],
@@ -223,18 +71,29 @@ async function handler(request: Request) {
});
}
// Fetch team details including userId and team name
const teamMemberships = await UserRepository.findTeamsByUserId({ userId: user.id });
const firstTeam = teamMemberships.teams[0] ?? null;
// Parse user metadata
const parsedMetadata = userMetadata.parse(user.metadata);
const cards = await Promise.all(
cardExamples.map(async (cardFn) => {
return cardFn(
user.name || "Unknown",
user.email,
user.id.toString(),
user.username || "Unknown",
user.timeZone,
user.emailVerified,
user.twoFactorEnabled
user.twoFactorEnabled,
user.identityProvider,
user.lastActiveAt,
firstTeam?.name || "Unknown",
firstTeam?.slug || "Unknown",
firstTeam?.isOrganization || false,
(user.metadata as { stripeCustomerId?: string })?.stripeCustomerId || "Unknown"
);
})
);
+68
View File
@@ -0,0 +1,68 @@
import type { PageProps as _PageProps } from "app/_types";
import { generateAppMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { notFound } from "next/navigation";
import { z } from "zod";
import { AppRepository } from "@calcom/lib/server/repository/app";
import { isPrismaAvailableCheck } from "@calcom/prisma/is-prisma-available-check";
import { getStaticProps } from "@lib/apps/[slug]/getStaticProps";
import AppView from "~/apps/[slug]/slug-view";
const paramsSchema = z.object({
slug: z.string(),
});
export const generateMetadata = async ({ params }: _PageProps) => {
const p = paramsSchema.safeParse(params);
if (!p.success) {
return notFound();
}
const props = await getStaticProps(p.data.slug);
if (!props) {
notFound();
}
const { name, logo, description } = props.data;
return await generateAppMetadata(
{ slug: logo, name, description },
() => name,
() => description
);
};
export const generateStaticParams = async () => {
const isPrismaAvailable = await isPrismaAvailableCheck();
if (!isPrismaAvailable) {
// Database is not available at build time. Make sure we fall back to building these pages on demand
return [];
}
const appStore = await AppRepository.findAppStore();
return appStore.map(({ slug }) => ({ slug }));
};
async function Page({ params }: _PageProps) {
const p = paramsSchema.safeParse(params);
if (!p.success) {
return notFound();
}
const props = await getStaticProps(p.data.slug);
if (!props) {
notFound();
}
return <AppView {...props} />;
}
export default WithLayout({ getLayout: null, ServerPage: Page });
export const dynamic = "force-static";
@@ -2,16 +2,34 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { notFound } from "next/navigation";
import { z } from "zod";
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import Page, { type PageProps } from "~/apps/[slug]/setup/setup-view";
const paramsSchema = z.object({
slug: z.string(),
});
export const generateMetadata = async ({ params }: _PageProps) => {
return await _generateMetadata(
() => `${params.slug}`,
const p = paramsSchema.safeParse(params);
if (!p.success) {
return notFound();
}
const metadata = await _generateMetadata(
() => `${p.data.slug}`,
() => ""
);
return {
...metadata,
robots: {
follow: false,
index: false,
},
};
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
@@ -0,0 +1,40 @@
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { redirect } from "next/navigation";
import { z } from "zod";
import { AppCategories } from "@calcom/prisma/enums";
import { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps";
import CategoryPage from "~/apps/categories/[category]/category-view";
const querySchema = z.object({
category: z.enum(Object.values(AppCategories) as [AppCategories, ...AppCategories[]]),
});
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("app_store"),
(t) => t("app_store_description")
);
};
export const generateStaticParams = async () => {
const paths = Object.keys(AppCategories);
return paths.map((category) => ({ category }));
};
async function Page({ params, searchParams }: PageProps) {
const parsed = querySchema.safeParse({ ...params, ...searchParams });
if (!parsed.success) {
redirect("/apps/categories/calendar");
}
const props = await getStaticProps(parsed.data.category);
return <CategoryPage {...props} />;
}
export default WithLayout({ getLayout: null, ServerPage: Page });
@@ -12,7 +12,6 @@ import Page from "~/apps/installation/[[...step]]/step-view";
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const { appMetadata } = await getData(legacyCtx);
return await _generateMetadata(
(t) => `${t("install")} ${appMetadata?.name ?? ""}`,
@@ -23,3 +22,4 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const getData = withAppDirSsr<OnboardingPageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, getData, Page });
@@ -0,0 +1,32 @@
import { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { redirect } from "next/navigation";
import { z } from "zod";
import { AppCategories } from "@calcom/prisma/enums";
import InstalledApps from "~/apps/installed/[category]/installed-category-view";
const querySchema = z.object({
category: z.nativeEnum(AppCategories),
});
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("installed_apps"),
(t) => t("manage_your_connected_apps")
);
};
const InstalledAppsWrapper = async ({ params }: PageProps) => {
const parsedParams = querySchema.safeParse(params);
if (!parsedParams.success) {
redirect("/apps/installed/calendar");
}
return <InstalledApps category={parsedParams.data.category} />;
};
export default WithLayout({ getLayout: null, ServerPage: InstalledAppsWrapper });
@@ -0,0 +1 @@
export { default } from "app/routing-forms/[...pages]/page";
+1
View File
@@ -0,0 +1 @@
export { default } from "app/routing-forms/page";
+41 -50
View File
@@ -1,64 +1,55 @@
"use client";
/**
* Typescript class based component for custom-error
* @link https://nextjs.org/docs/advanced-features/custom-error-page
*/
import type { NextPage } from "next";
import type { ErrorProps } from "next/error";
import React from "react";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { redactError } from "@calcom/lib/redactError";
import { ErrorPage } from "@components/error/error-page";
type NextError = Error & { digest?: string };
// Ref: https://nextjs.org/docs/app/api-reference/file-conventions/error#props
export type DefaultErrorProps = {
error: NextError;
reset: () => void; // A function to reset the error boundary
};
type AugmentedError = NextError | HttpError | null;
type CustomErrorProps = {
err?: AugmentedError;
statusCode?: number;
message?: string;
} & Omit<ErrorProps, "err" | "statusCode">;
const log = logger.getSubLogger({ prefix: ["[error]"] });
const CustomError: NextPage<DefaultErrorProps> = (props) => {
const { error } = props;
let errorObject: CustomErrorProps = {
message: error.message,
err: error,
};
if (error instanceof HttpError) {
const redactedError = redactError(error);
errorObject = {
statusCode: error.statusCode,
title: redactedError.name,
message: redactedError.message,
err: {
...redactedError,
...error,
},
};
}
// `error.digest` property contains an automatically generated hash of the error that can be used to match the corresponding error in server-side logs
log.debug(`${error?.toString() ?? JSON.stringify(error)}`);
log.info("errorObject: ", errorObject);
return (
<ErrorPage statusCode={errorObject.statusCode} error={errorObject.err} message={errorObject.message} />
);
type ErrorProps = {
error: Error;
reset: () => void;
};
export default CustomError;
export default function Error({ error, reset }: ErrorProps) {
React.useEffect(() => {
log.error(error);
}, [error]);
const processedError = React.useMemo(() => {
const err = getErrorFromUnknown(error);
if (err instanceof HttpError) {
const redactedError = redactError(err);
return {
statusCode: err.statusCode,
title: redactedError.name,
name: redactedError.name,
message: redactedError.message,
url: err.url,
method: err.method,
cause: err.cause,
};
}
return {
statusCode: 500,
title: "Internal Server Error",
name: "Internal Server Error",
message: "An unexpected error occurred.",
};
}, [error]);
return (
<ErrorPage
statusCode={processedError.statusCode}
error={processedError}
message={processedError.message}
/>
);
}
-45
View File
@@ -1,45 +0,0 @@
import { Prisma } from "@prisma/client";
import { withAppDirSsg } from "app/WithAppDirSsg";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { AppRepository } from "@calcom/lib/server/repository/app";
import { getStaticProps } from "@lib/apps/[slug]/getStaticProps";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import type { PageProps } from "~/apps/[slug]/slug-view";
import Page from "~/apps/[slug]/slug-view";
const getData = withAppDirSsg<PageProps>(getStaticProps, "future/apps/[slug]");
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const legacyContext = buildLegacyCtx(headers(), cookies(), params, searchParams);
const res = await getData(legacyContext);
return await _generateMetadata(
() => res?.data.name ?? "",
() => res?.data.description ?? ""
);
};
export const generateStaticParams = async () => {
try {
const appStore = await AppRepository.findAppStore();
return appStore.map(({ slug }) => ({ slug }));
} catch (e: unknown) {
if (e instanceof Prisma.PrismaClientInitializationError) {
// Database is not available at build time, but that's ok – we fall back to resolving paths on demand
} else {
throw e;
}
}
return [];
};
export default WithLayout({ getLayout: null, Page, getData });
export const dynamic = "force-static";
@@ -1,34 +0,0 @@
import { withAppDirSsg } from "app/WithAppDirSsg";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { AppCategories } from "@calcom/prisma/enums";
import { isPrismaAvailableCheck } from "@calcom/prisma/is-prisma-available-check";
import { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps";
import CategoryPage, { type PageProps } from "~/apps/categories/[category]/category-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("app_store"),
(t) => t("app_store_description")
);
};
export const generateStaticParams = async () => {
const paths = Object.keys(AppCategories);
const isPrismaAvailable = await isPrismaAvailableCheck();
if (!isPrismaAvailable) {
// Database is not available at build time. Make sure we fall back to building these pages on demand
return [];
}
return paths.map((category) => ({ category }));
};
const getData = withAppDirSsg<PageProps>(getStaticProps, "future/apps/categories/[category]");
export default WithLayout({ getData, Page: CategoryPage, getLayout: null })<"P">;
export const dynamic = "force-static";
@@ -1,18 +0,0 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSidePropsAppDir } from "@lib/apps/installed/[category]/getServerSideProps";
import Page from "~/apps/installed/[category]/installed-category-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("installed_apps"),
(t) => t("manage_your_connected_apps")
);
};
const getData = withAppDirSsr(getServerSidePropsAppDir);
export default WithLayout({ getLayout: null, getData, Page });
-17
View File
@@ -1,17 +0,0 @@
"use client";
import { type NextPage } from "next";
import CustomError, { type DefaultErrorProps } from "./error";
export const GlobalError: NextPage<DefaultErrorProps> = (props) => {
return (
<html>
<body>
<CustomError {...props} />
</body>
</html>
);
};
export default GlobalError;
+240 -6
View File
@@ -1,12 +1,246 @@
import React from "react";
import { _generateMetadata, getTranslate } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { headers } from "next/headers";
import Link from "next/link";
import { redirect } from "next/navigation";
import {
getOrgDomainConfigFromHostname,
subdomainSuffix,
} from "@calcom/features/ee/organizations/lib/orgDomains";
import { DOCS_URL, IS_CALCOM, WEBSITE_URL } from "@calcom/lib/constants";
import { Icon } from "@calcom/ui";
enum PageType {
ORG = "ORG",
TEAM = "TEAM",
USER = "USER",
OTHER = "OTHER",
}
function getPageInfo(pathname: string, host: string) {
const { isValidOrgDomain, currentOrgDomain } = getOrgDomainConfigFromHostname({ hostname: host });
const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/) ?? [];
if (!routerUsername || (isValidOrgDomain && currentOrgDomain)) {
return {
username: currentOrgDomain ?? "",
pageType: PageType.ORG,
url: `${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${
currentOrgDomain?.replace("/", "") ?? ""
}`,
};
}
const splitPath = routerUsername.split("/");
if (splitPath[1] === "team" && splitPath.length === 3) {
return {
username: splitPath[2],
pageType: PageType.TEAM,
url: `${WEBSITE_URL}/signup?callbackUrl=settings/teams/new%3Fslug%3D${splitPath[2].replace("/", "")}`,
};
}
return {
username: routerUsername,
pageType: PageType.USER,
url: `${WEBSITE_URL}/signup?username=${routerUsername.replace("/", "")}`,
};
}
async function NotFound() {
const t = await getTranslate();
const headersList = headers();
const host = headersList.get("x-forwarded-host") ?? "";
const pathname = headersList.get("x-pathname") ?? "";
if (!pathname) {
redirect("/500");
}
const { username, pageType, url } = getPageInfo(pathname, host);
const isBookingSuccessPage = pathname?.startsWith("/booking");
const isSubpage = pathname?.includes("/", 2) || isBookingSuccessPage;
const isInsights = pathname?.startsWith("/insights");
const links = [
{
title: t("enterprise"),
description: "Learn more about organizations and subdomains in our enterprise plan.",
icon: "shield" as const,
href: `${WEBSITE_URL}/enterprise`,
},
{
title: t("documentation"),
description: t("documentation_description"),
icon: "file-text" as const,
href: DOCS_URL,
},
{
title: t("blog"),
description: t("blog_description"),
icon: "book-open" as const,
href: `${WEBSITE_URL}/blog`,
},
];
/**
* If we're on 404 and the route is insights it means it is disabled
* TODO: Abstract this for all disabled features
**/
if (isInsights) {
return (
<div className="min-h-screen bg-white px-4" data-testid="404-page">
<main className="mx-auto max-w-xl pb-6 pt-16 sm:pt-24">
<div className="text-center">
<p className="text-sm font-semibold uppercase tracking-wide text-black">{t("error_404")}</p>
<h1 className="font-cal mt-2 text-4xl font-extrabold text-gray-900 sm:text-5xl">
{t("feature_currently_disabled") ?? "Feature is currently disabled"}
</h1>
</div>
<div className="mt-12">
<div className="mt-8">
<Link href={WEBSITE_URL} className="text-base font-medium text-black hover:text-gray-500">
{t("or_go_back_home")}
<span aria-hidden="true"> &rarr;</span>
</Link>
</div>
</div>
</main>
</div>
);
}
const NotFound = () => {
return (
<div data-testid="404-page">
<h1>404 - Page Not Found</h1>
<p>Sorry, the page you are looking for does not exist.</p>
<div className="bg-default min-h-screen px-4" data-testid="404-page">
<main className="mx-auto max-w-xl pb-6 pt-16 sm:pt-24">
<div className="text-center">
<p className="text-emphasis text-sm font-semibold uppercase tracking-wide">{t("error_404")}</p>
<h1 className="font-cal text-emphasis mt-2 text-4xl font-extrabold sm:text-5xl">
{isBookingSuccessPage ? "Booking not found" : t("page_doesnt_exist")}
</h1>
{isSubpage && pageType !== PageType.TEAM ? (
<span className="mt-2 inline-block text-lg">{t("check_spelling_mistakes_or_go_back")}</span>
) : IS_CALCOM ? (
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
{t(`404_the_${pageType.toLowerCase()}`)}
{username ? (
<>
<strong className="text-blue-500">{username}</strong> {t("is_still_available")}{" "}
<span className="text-blue-500">{t("register_now")}</span>.
</>
) : null}
</a>
) : (
<span className="mt-2 inline-block text-lg">
{t(`404_the_${pageType.toLowerCase()}`)}{" "}
{username ? (
<>
<strong className="text-lgtext-green-500 mt-2 inline-block">{username}</strong>{" "}
{t("is_still_available")}
</>
) : null}
</span>
)}
</div>
<div className="mt-12">
{((!isSubpage && IS_CALCOM) || pageType === PageType.ORG || pageType === PageType.TEAM) && (
<ul role="list" className="my-4">
<li className="border-2 border-green-500 px-4 py-2">
<a
href={url}
target="_blank"
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse"
rel="noreferrer">
<div className="flex-shrink-0">
<span className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-50">
<Icon name="check" className="h-6 w-6 text-green-500" aria-hidden="true" />
</span>
</div>
<div className="min-w-0 flex-1">
<h3 className="text-emphasis text-base font-medium">
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
<span className="focus:outline-none">
<span className="absolute inset-0" aria-hidden="true" />
{t("register")}{" "}
<strong className="text-green-500">{`${
pageType === PageType.TEAM ? `${new URL(WEBSITE_URL).host}/team/` : ""
}${username}${pageType === PageType.ORG ? `.${subdomainSuffix()}` : ""}`}</strong>
</span>
</span>
</h3>
<p className="text-subtle text-base">{t(`404_claim_entity_${pageType.toLowerCase()}`)}</p>
</div>
<div className="flex-shrink-0 self-center">
<Icon name="chevron-right" className="text-muted h-5 w-5" aria-hidden="true" />
</div>
</a>
</li>
</ul>
)}
<h2 className="text-subtle text-sm font-semibold uppercase tracking-wide">{t("popular_pages")}</h2>
<ul role="list" className="border-subtle divide-subtle divide-y">
{links
.filter((_, idx) => pageType === PageType.ORG || idx !== 0)
.map((link, linkIdx) => (
<li key={linkIdx} className="px-4 py-2">
<a
href={link.href}
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
<div className="flex-shrink-0">
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
<Icon name={link.icon} className="text-default h-6 w-6" aria-hidden="true" />
</span>
</div>
<div className="min-w-0 flex-1">
<h3 className="text-emphasis text-base font-medium">
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
<span className="absolute inset-0" aria-hidden="true" />
{link.title}
</span>
</h3>
<p className="text-subtle text-base">{link.description}</p>
</div>
<div className="flex-shrink-0 self-center">
<Icon name="chevron-right" className="text-muted h-5 w-5" aria-hidden="true" />
</div>
</a>
</li>
))}
</ul>
<div className="mt-8">
<Link href={WEBSITE_URL} className="hover:text-subtle text-emphasis text-base font-medium">
{t("or_go_back_home")}
<span aria-hidden="true"> &rarr;</span>
</Link>
</div>
</div>
</main>
</div>
);
}
export const generateMetadata = async () => {
const headersList = headers();
const pathname = headersList.get("x-pathname") ?? "";
const isInsights = pathname?.startsWith("/insights");
const metadata = await _generateMetadata(
(t) =>
isInsights
? t("feature_currently_disabled") ?? "Feature is currently disabled"
: t("404_page_not_found"),
(t) => t("404_page_not_found")
);
return {
...metadata,
robots: {
index: false,
follow: false,
},
};
};
export default NotFound;
export default WithLayout({
ServerPage: NotFound,
});
@@ -10,10 +10,10 @@ const paramsSchema = z
pages: [],
});
const Page = ({ params, searchParams }: PageProps) => {
const { pages } = paramsSchema.parse({ ...params, ...searchParams });
const Page = ({ params }: PageProps) => {
const { pages } = paramsSchema.parse(params);
redirect(`/apps/routing-forms/${pages.length ? pages.join("/") : ""}`);
redirect(`/routing/${pages.length ? pages.join("/") : ""}`);
};
export default Page;
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
const Page = () => {
redirect("/apps/routing-forms/forms");
const Page = async () => {
redirect("/routing/forms");
};
export default Page;
+70
View File
@@ -0,0 +1,70 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { GetServerSidePropsResult } from "next";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import z from "zod";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/routing/[...pages]/getServerSideProps";
import RoutingFormsPage, { getLayout } from "~/routing/[...pages]/pages-view";
const paramsSchema = z.object({
pages: z.array(z.string()),
});
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const p = paramsSchema.safeParse(params);
if (!p.success) {
return notFound();
}
const ctx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const data = await getData({
...ctx,
params: {
slug: "routing-forms",
pages: params.pages,
},
});
const form = "form" in data ? (data.form as { name?: string; description?: string }) : null;
const formName = form?.name;
const formDescription = form?.description;
const { pages } = p.data;
if (pages.includes("routing-link")) {
return await _generateMetadata(
() => `${formName} | Cal.com Forms`,
() => "",
true
);
}
return await _generateMetadata(
(t) => formName ?? t("routing_forms"),
(t) => formDescription ?? t("routing_forms_description")
);
};
const getData = withAppDirSsr<GetServerSidePropsResult<any>>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
const ctx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData({
...ctx,
params: {
slug: "routing-forms",
pages: params.pages,
},
});
return <RoutingFormsPage {...props} />;
};
export default WithLayout({
getLayout,
ServerPage,
});
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
const Page = async () => {
redirect("/routing/forms");
};
export default Page;
-6
View File
@@ -1,7 +1,6 @@
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import Shell from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HeadSeo } from "@calcom/ui";
import type { AppPageProps } from "./AppPage";
import { AppPage } from "./AppPage";
@@ -14,11 +13,6 @@ const ShellHeading = () => {
export default function WrappedApp(props: AppPageProps) {
return (
<Shell smallHeading isPublic hideHeadingOnMobile heading={<ShellHeading />} backPath="/apps" withoutSeo>
<HeadSeo
title={props.name}
description={props.description}
app={{ slug: props.logo, name: props.name, description: props.description }}
/>
{props.licenseRequired ? (
<LicenseRequired>
<AppPage {...props} />
+1 -1
View File
@@ -807,7 +807,7 @@ const RerouteDialogContentAndFooterWithFormResponse = ({
<div>
<Link
className="text-emphasis text-semibold font-cal mb-4 flex underline"
href={`/apps/routing-forms/form-edit/${form.id}`}
href={`/routing/form-edit/${form.id}`}
target="_blank">
{form.name}
</Link>
+29 -12
View File
@@ -1,6 +1,9 @@
"use client";
import React from "react";
import { HttpError } from "@calcom/lib/http-error";
import { Button } from "@calcom/ui";
type Props = {
statusCode?: number | null;
@@ -53,21 +56,35 @@ export const ErrorPage: React.FC<Props> = (props) => {
return (
<>
<div className="bg-default min-h-screen px-4">
<main className="mx-auto max-w-xl pb-6 pt-16 sm:pt-24">
<div className="text-center">
<p className="text-emphasis text-sm font-semibold uppercase tracking-wide">{statusCode}</p>
<h1 className="text-emphasis mt-2 text-4xl font-extrabold tracking-tight sm:text-5xl">
<div className="bg-subtle flex h-screen">
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
<h1 className="font-cal text-emphasis text-6xl">{statusCode}</h1>
<h2 className="text-emphasis mt-6 max-w-2xl text-2xl font-medium">It's not you, it's us.</h2>
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">
Something went wrong on our end. Get in touch with our support team, and we'll get it fixed right
away for you.
</p>
<div className="mb-8 flex flex-col">
<p className="text-default mb-4 max-w-2xl text-sm">
Please provide the following text when contacting support to better help you:
</p>
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal break-words rounded-md p-4">
{message}
</h1>
</pre>
</div>
</main>
{displayDebug && (
<div className="flex-wrap">
<ErrorDebugPanel error={error} />
</div>
)}
<Button href="mailto:support@cal.com">Contact Support</Button>
<Button color="secondary" href="javascript:history.back()" className="ml-2">
Go back
</Button>
</div>
</div>
{displayDebug && (
<div className="flex-wrap">
<ErrorDebugPanel error={error} />
</div>
)}
</>
);
};
@@ -2,7 +2,6 @@ import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
import { z } from "zod";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import RoutingFormsRoutingConfig from "@calcom/app-store/routing-forms/pages/app-routing.config";
import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
@@ -32,7 +31,6 @@ type NotFound = {
// TODO: It is a candidate for apps.*.generated.*
const AppsRouting = {
"routing-forms": RoutingFormsRoutingConfig,
typeform: TypeformRoutingConfig,
};
+11 -19
View File
@@ -1,7 +1,5 @@
import fs from "fs";
import matter from "gray-matter";
import MarkdownIt from "markdown-it";
import type { GetStaticPropsContext } from "next";
import path from "path";
import { z } from "zod";
@@ -10,8 +8,6 @@ import { getAppAssetFullPath } from "@calcom/app-store/getAppAssetFullPath";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
const md = new MarkdownIt("default", { html: true, breaks: true });
export const sourceSchema = z.object({
content: z.string(),
data: z.object({
@@ -29,15 +25,15 @@ export const sourceSchema = z.object({
}),
});
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
export type AppDataProps = NonNullable<Awaited<ReturnType<typeof getStaticProps>>>;
export const getStaticProps = async (slug: string) => {
const appMeta = await getAppWithMetadata({
slug: ctx.params?.slug,
slug,
});
const appFromDb = await prisma.app.findUnique({
where: { slug: ctx.params.slug.toLowerCase() },
where: { slug: slug.toLowerCase() },
});
const isAppAvailableInFileSystem = appMeta;
@@ -45,16 +41,14 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
if (!IS_PRODUCTION && isAppDisabled) {
return {
props: {
isAppDisabled: true as const,
data: {
...appMeta,
},
isAppDisabled: true as const,
data: {
...appMeta,
},
};
}
if (!appFromDb || !appMeta || isAppDisabled) return { notFound: true } as const;
if (!appFromDb || !appMeta || isAppDisabled) return null;
const isTemplate = appMeta.isTemplate;
const appDirname = path.join(isTemplate ? "templates" : "", appFromDb.dirName);
@@ -85,10 +79,8 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
});
}
return {
props: {
isAppDisabled: false as const,
source: { content, data },
data: appMeta,
},
isAppDisabled: false as const,
source: { content, data },
data: appMeta,
};
};
@@ -1,12 +1,10 @@
import type { GetStaticPropsContext } from "next";
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import prisma from "@calcom/prisma";
import type { AppCategories } from "@calcom/prisma/enums";
export const getStaticProps = async (context: GetStaticPropsContext) => {
const category = context.params?.category as AppCategories;
export type CategoryDataProps = NonNullable<Awaited<ReturnType<typeof getStaticProps>>>;
export const getStaticProps = async (category: AppCategories) => {
const appQuery = await prisma.app.findMany({
where: {
categories: {
@@ -24,8 +22,7 @@ export const getStaticProps = async (context: GetStaticPropsContext) => {
const apps = appStore.filter((app) => dbAppsSlugs.includes(app.slug));
return {
props: {
apps,
},
apps,
category,
};
};
@@ -1,78 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { AppCategories } from "@calcom/prisma/enums";
export type querySchemaType = z.infer<typeof querySchema>;
export const querySchema = z.object({
category: z.nativeEnum(AppCategories),
});
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
// get return-to cookie and redirect if needed
const { cookies } = ctx.req;
const returnTo = cookies["return-to"];
if (cookies && returnTo) {
ctx.res.setHeader("Set-Cookie", "return-to=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
const redirect = {
redirect: {
destination: `${returnTo}`,
permanent: false,
},
} as const;
return redirect;
}
const params = querySchema.safeParse(ctx.params);
if (!params.success) {
const notFound = { notFound: true } as const;
return notFound;
}
return {
props: {
category: params.data.category,
},
};
}
export async function getServerSidePropsAppDir(ctx: GetServerSidePropsContext) {
// get return-to cookie and redirect if needed
const { cookies } = ctx.req;
const returnTo = cookies["return-to"];
if (cookies && returnTo) {
const NextResponse = await import("next/server").then((mod) => mod.NextResponse);
const response = NextResponse.next();
response.headers.set("Set-Cookie", "return-to=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
const redirect = {
redirect: {
destination: `${returnTo}`,
permanent: false,
},
} as const;
return redirect;
}
const params = querySchema.safeParse(ctx.params);
if (!params.success) {
const notFound = { notFound: true } as const;
return notFound;
}
return {
props: {
category: params.data.category,
},
};
}
@@ -1,3 +0,0 @@
export async function getServerSideProps() {
return { redirect: { permanent: false, destination: "/apps/installed/calendar" } };
}
@@ -0,0 +1,118 @@
import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
import { z } from "zod";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import { routingServerSidePropsConfig } from "@calcom/app-store/routing-forms/pages/app-routing-server.config";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { prisma } from "@calcom/prisma";
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
import type { AppProps } from "@lib/app-providers";
import { ssrInit } from "@server/lib/ssr";
type RoutingPageType = {
getServerSideProps?: AppGetServerSideProps;
// A component than can accept any properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: ((props: any) => JSX.Element) &
Pick<AppProps["Component"], "isBookingPage" | "getLayout" | "PageWrapper">;
};
type Found = {
notFound: false;
getServerSideProps: RoutingPageType["getServerSideProps"];
};
type NotFound = {
notFound: true;
};
function getRoute(pages: string[]) {
const mainPage = pages[0];
const getServerSideProps = routingServerSidePropsConfig[mainPage] as RoutingPageType["getServerSideProps"];
if (!getServerSideProps || typeof getServerSideProps !== "function") {
return {
notFound: true,
} as NotFound;
}
return { notFound: false, getServerSideProps } as Found;
}
const paramsSchema = z.object({
slug: z.string(),
pages: z.array(z.string()),
});
export async function getServerSideProps(
context: GetServerSidePropsContext
): Promise<GetServerSidePropsResult<any>> {
const { params, req } = context;
if (!params) {
return {
notFound: true,
};
}
const parsedParams = paramsSchema.safeParse(params);
if (!parsedParams.success) {
return {
notFound: true,
};
}
const appName = parsedParams.data.slug;
const pages = parsedParams.data.pages;
const route = getRoute(pages);
if (route.notFound) {
return { notFound: true };
}
if (route.getServerSideProps) {
params.appPages = pages.slice(1);
const session = await getServerSession({ req });
const user = session?.user;
const app = await getAppWithMetadata({ slug: appName });
if (!app) {
return {
notFound: true,
};
}
const result = await route.getServerSideProps(
context as GetServerSidePropsContext<{
slug: string;
pages: string[];
appPages: string[];
}>,
prisma,
user,
ssrInit
);
if (result.notFound) {
return { notFound: true };
}
if (result.redirect) {
return { redirect: result.redirect };
}
return {
props: {
appName,
appUrl: app.simplePath,
...result.props,
},
};
} else {
return {
props: {
appName,
},
};
}
}
+29 -28
View File
@@ -22,7 +22,6 @@ const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const url = req.nextUrl;
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-url", req.url);
if (!url.pathname.startsWith("/api")) {
@@ -64,11 +63,9 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
requestHeaders.set("x-csp-enforce", "true");
}
if (url.pathname.startsWith("/future/apps/installed")) {
if (url.pathname.startsWith("/apps/installed")) {
const returnTo = req.cookies.get("return-to")?.value;
if (returnTo !== undefined) {
requestHeaders.set("Set-Cookie", "return-to=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
let validPathname = returnTo;
try {
@@ -77,8 +74,14 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const nextUrl = url.clone();
nextUrl.pathname = validPathname;
// TODO: Consider using responseWithHeaders here
return NextResponse.redirect(nextUrl, { headers: requestHeaders });
const response = NextResponse.redirect(nextUrl);
response.cookies.set("return-to", "", {
expires: new Date(0),
path: "/",
});
return response;
}
}
@@ -108,9 +111,10 @@ const routingForms = {
handleRewrite: (url: URL) => {
// Don't 404 old routing_forms links
if (url.pathname.startsWith("/apps/routing_forms")) {
url.pathname = url.pathname.replace(/^\/apps\/routing_forms($|\/)/, "/apps/routing-forms/");
url.pathname = url.pathname.replace(/^\/apps\/routing_forms($|\/)/, "/routing/");
return NextResponse.rewrite(url);
}
return null;
},
};
@@ -155,6 +159,7 @@ export const config = {
matcher: [
"/403",
"/500",
"/icons",
"/d/:path*",
"/more/:path*",
"/maintenance/:path*",
@@ -166,35 +171,31 @@ export const config = {
"/api/auth/signup",
"/api/trpc/:path*",
"/login",
"/auth/login",
"/future/auth/login",
/**
* Paths required by routingForms.handle
*/
"/apps/routing_forms/:path*",
"/auth/:path*",
"/apps/:path*",
"/event-types/:path*",
"/apps/installed/:category/",
"/future/apps/installed/:category/",
"/apps/:slug/",
"/future/apps/:slug/",
"/apps/:slug/setup/",
"/future/apps/:slug/setup/",
"/apps/categories/",
"/future/apps/categories/",
"/apps/categories/:category/",
"/future/apps/categories/:category/",
"/workflows/:path*",
"/getting-started/:path*",
"/apps",
"/bookings/:status/",
"/workflows/:path*",
"/getting-started/:path*",
"/event-types/:path*",
"/apps/:path*",
"/routing/:path*",
"/bookings/:path*",
"/video/:path*",
"/teams",
"/future/teams/",
"/teams/:path*",
"/signup/:path*",
"/settings/:path*",
"/reschedule/:path*",
"/availability/:path*",
"/booking/:path*",
"/payment/:path*",
"/router/:path*",
"/routing-forms/:path*",
"/org/:path*",
"/team/:path*",
"/:user/:type/",
"/:user/",
],
};
@@ -1,6 +1,5 @@
"use client";
import RoutingFormsRoutingConfig from "@calcom/app-store/routing-forms/pages/app-routing.config";
import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
@@ -29,7 +28,6 @@ type NotFound = {
// TODO: It is a candidate for apps.*.generated.*
const AppsRouting = {
"routing-forms": RoutingFormsRoutingConfig,
typeform: TypeformRoutingConfig,
};
@@ -7,7 +7,6 @@ import { AppSetupPage } from "@calcom/app-store/_pages/setup";
import type { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { HeadSeo } from "@calcom/ui";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
@@ -28,11 +27,5 @@ export default function SetupInformation(props: PageProps) {
router.replace(`/auth/login?${urlSearchParams.toString()}`);
}
return (
<>
{/* So that the set up page does not get indexed by search engines */}
<HeadSeo nextSeoProps={{ noindex: true, nofollow: true }} title={`${slug} | Cal.com`} description="" />
<AppSetupPage slug={slug} {...props} />
</>
);
return <AppSetupPage slug={slug} {...props} />;
}
+2 -8
View File
@@ -1,7 +1,5 @@
"use client";
import MarkdownIt from "markdown-it";
import type { InferGetStaticPropsType } from "next";
import Link from "next/link";
import { IS_PRODUCTION } from "@calcom/lib/constants";
@@ -9,16 +7,12 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { showToast } from "@calcom/ui";
import type { getStaticProps } from "@lib/apps/[slug]/getStaticProps";
import type { AppDataProps } from "@lib/apps/[slug]/getStaticProps";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import App from "@components/apps/App";
const md = new MarkdownIt("default", { html: true, breaks: true });
export type PageProps = InferGetStaticPropsType<typeof getStaticProps>;
function SingleAppPage(props: PageProps) {
function SingleAppPage(props: AppDataProps) {
const { error, setQuery: setError } = useRouterQuery("error");
const { t } = useLocale();
if (error === "account_already_linked") {
@@ -1,20 +1,15 @@
"use client";
import type { InferGetStaticPropsType } from "next";
import Link from "next/link";
import Shell from "@calcom/features/shell/Shell";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AppCard, SkeletonText } from "@calcom/ui";
import type { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps";
import type { CategoryDataProps } from "@lib/apps/categories/[category]/getStaticProps";
export type PageProps = InferGetStaticPropsType<typeof getStaticProps>;
export default function Apps({ apps }: PageProps) {
const searchParams = useCompatSearchParams();
export default function Apps({ apps, category }: CategoryDataProps) {
const { t, isLocaleReady } = useLocale();
const category = searchParams?.get("category");
return (
<>
@@ -1,6 +1,5 @@
"use client";
import Head from "next/head";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
@@ -226,12 +225,6 @@ const OnboardingPage = ({
key={pathname}
className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen px-4"
data-testid="onboarding">
<Head>
<title>
{t("install")} {appMetadata?.name ?? ""}
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="mx-auto py-6 sm:px-4 md:py-24">
<div className="relative">
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]" ref={formPortalRef}>
@@ -8,11 +8,9 @@ import type { UpdateUsersDefaultConferencingAppParams } from "@calcom/features/a
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import type { RemoveAppParams } from "@calcom/features/apps/components/DisconnectIntegrationModal";
import type { BulkUpdatParams } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AppCategories } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import type { Icon } from "@calcom/ui";
import {
AppSkeletonLoader as SkeletonLoader,
@@ -23,7 +21,6 @@ import {
} from "@calcom/ui";
import { QueryCell } from "@lib/QueryCell";
import type { querySchemaType, getServerSideProps } from "@lib/apps/installed/[category]/getServerSideProps";
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
@@ -183,13 +180,13 @@ type ModalState = {
teamId?: number;
};
export type PageProps = inferSSRProps<typeof getServerSideProps>;
type PageProps = {
category: AppCategories;
};
export default function InstalledApps(props: PageProps) {
const searchParams = useCompatSearchParams();
export default function InstalledApps({ category }: PageProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const category = searchParams?.get("category") as querySchemaType["category"];
const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => {
// Exclude calendar and other from categoryList, we handle those slightly differently below
return !(category in { other: null, calendar: null });
@@ -0,0 +1,76 @@
"use client";
import { useParams } from "next/navigation";
import RoutingFormsRoutingConfig from "@calcom/app-store/routing-forms/pages/app-routing.config";
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import type { AppProps } from "@lib/app-providers";
import type { getServerSideProps } from "@lib/apps/[slug]/[...pages]/getServerSideProps";
type RoutingPageType = {
getServerSideProps: AppGetServerSideProps;
// A component than can accept any properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: ((props: any) => JSX.Element) &
Pick<AppProps["Component"], "isBookingPage" | "getLayout" | "PageWrapper">;
};
type Found = {
notFound: false;
Component: RoutingPageType["default"];
getServerSideProps: RoutingPageType["getServerSideProps"];
};
type NotFound = {
notFound: true;
};
function getRoute(pages: string[]) {
const routingConfig = RoutingFormsRoutingConfig as unknown as Record<string, RoutingPageType>;
const mainPage = pages[0];
const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as RoutingPageType);
if (!appPage) {
return {
notFound: true,
} as NotFound;
}
return { notFound: false, Component: appPage.default, ...appPage } as Found;
}
export type PageProps = inferSSRProps<typeof getServerSideProps>;
const RoutingFormsPage: RoutingPageType["default"] = function RoutingFormsPage(props: PageProps) {
const params = useParams();
const _pages = params?.pages ?? [];
const pages = Array.isArray(_pages) ? _pages : _pages.split("/");
const route = getRoute(pages);
const componentProps = {
...props,
pages: pages.slice(1),
};
if (!route || route.notFound) {
throw new Error("Route can't be undefined");
}
return <route.Component {...componentProps} />;
};
export const getLayout: NonNullable<(typeof RoutingFormsPage)["getLayout"]> = (page) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const params = useParams();
const _pages = params?.pages ?? [];
const pages = Array.isArray(_pages) ? _pages : _pages.split("/");
const route = getRoute(pages as string[]);
if (route.notFound) {
return null;
}
if (!route.Component.getLayout) {
return page;
}
return route.Component.getLayout(page);
};
export default RoutingFormsPage;
+1 -1
View File
@@ -295,7 +295,7 @@ const nextConfig = {
const beforeFiles = [
{
source: "/forms/:formQuery*",
destination: "/apps/routing-forms/routing-link/:formQuery*",
destination: "/routing/routing-link/:formQuery*",
},
{
source: "/success/:path*",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.8.16",
"version": "4.8.18",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+2
View File
@@ -85,6 +85,8 @@ CustomError.getInitialProps = async (ctx: AugmentedNextPageContext) => {
log.debug(`server side logged this: ${err?.toString() ?? JSON.stringify(err)}`);
log.info("return props, ", errorInitialProps);
res.setHeader("x-pages-router-error", "true");
return errorInitialProps;
} else {
// Running on the client (browser).
+269 -26
View File
@@ -1,18 +1,63 @@
/* eslint-disable import/no-anonymous-default-export */
import type { Card } from "@pages/api/plain";
import dayjs from "@calcom/dayjs";
export default (
name: string,
email: string,
id: string,
username: string,
timeZone: string,
emailVerified: Date | null,
twoFactorEnabled: boolean | null
twoFactorEnabled: boolean | null,
identityProvider: string | null,
lastActiveAt: Date | null,
teamName: string | null,
teamSlug: string | null,
isOrganization: boolean | null,
stripeCustomerId: string | null
): Card => {
return {
key: "customer-card",
timeToLiveSeconds: null,
components: [
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentText: {
text: "Basic Information",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Name",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: name || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
@@ -42,31 +87,6 @@ export default (
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email Verified?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: emailVerified === null ? "No" : emailVerified ? "Yes" : "No",
badgeColor: emailVerified === null ? "RED" : emailVerified ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
@@ -115,6 +135,24 @@ export default (
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
// Account Settings Section Title
{
componentText: {
text: "Account Settings",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
@@ -139,6 +177,211 @@ export default (
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Identity Provider",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: identityProvider || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Last Active At",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: lastActiveAt ? dayjs(lastActiveAt).fromNow() : "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
// Team Information Section Title
{
componentText: {
text: "Team Information",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Team Name",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: teamName || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Team Slug",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: teamSlug || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Is Organization",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: isOrganization ? "Yes" : "No",
badgeColor: isOrganization ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
{
componentText: {
text: "Security & Billing",
textColor: "NORMAL",
textSize: "M",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Stripe Customer ID",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: stripeCustomerId || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email Verified?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: emailVerified === null ? "No" : emailVerified ? "Yes" : "No",
badgeColor: emailVerified === null ? "RED" : emailVerified ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
@@ -2,10 +2,17 @@ import type { Card } from "@pages/api/plain";
import customerCardDisplay from "@pages/api/plain/customer-card-display";
export const cardExamples: ((
name: string,
email: string,
id: string,
username: string,
timeZone: string,
emailVerified: Date | null,
twoFactorEnabled: boolean | null
twoFactorEnabled: boolean | null,
identityProvider: string | null,
lastActiveAt: Date | null,
teamName: string | null,
teamSlug: string | null,
isOrganization: boolean | null,
stripeCustomerId: string | null
) => Card)[] = [customerCardDisplay];
-15
View File
@@ -1,15 +0,0 @@
import { getServerSideProps } from "@lib/apps/[slug]/[...pages]/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import type { PageProps } from "~/apps/[slug]/[...pages]/pages-view";
import PagesView, { getLayout } from "~/apps/[slug]/[...pages]/pages-view";
const Page = (props: PageProps) => <PagesView {...props} />;
Page.PageWrapper = PageWrapper;
Page.getLayout = getLayout;
export { getServerSideProps };
export default Page;
-39
View File
@@ -1,39 +0,0 @@
import { Prisma } from "@prisma/client";
import type { GetStaticPaths } from "next";
import { AppRepository } from "@calcom/lib/server/repository/app";
import { getStaticProps } from "@lib/apps/[slug]/getStaticProps";
import PageWrapper from "@components/PageWrapper";
import type { PageProps } from "~/apps/[slug]/slug-view";
import SingleAppPage from "~/apps/[slug]/slug-view";
const Page = (props: PageProps) => <SingleAppPage {...props} />;
export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
let paths: { params: { slug: string } }[] = [];
try {
const appStore = await AppRepository.findAppStore();
paths = appStore.map(({ slug }) => ({ params: { slug } }));
} catch (e: unknown) {
if (e instanceof Prisma.PrismaClientInitializationError) {
// Database is not available at build time, but that's ok – we fall back to resolving paths on demand
} else {
throw e;
}
}
return {
paths,
fallback: "blocking",
};
};
export { getStaticProps };
Page.PageWrapper = PageWrapper;
export default Page;
-14
View File
@@ -1,14 +0,0 @@
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import type { PageProps } from "~/apps/[slug]/setup/setup-view";
import SetupView from "~/apps/[slug]/setup/setup-view";
const Page = (props: PageProps) => <SetupView {...props} />;
Page.PageWrapper = PageWrapper;
export { getServerSideProps };
export default Page;
@@ -1,33 +0,0 @@
import { AppCategories } from "@calcom/prisma/enums";
import { isPrismaAvailableCheck } from "@calcom/prisma/is-prisma-available-check";
import { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps";
import PageWrapper from "@components/PageWrapper";
import type { PageProps } from "~/apps/categories/[category]/category-view";
import CategoryView from "~/apps/categories/[category]/category-view";
const Page = (props: PageProps) => <CategoryView {...props} />;
Page.PageWrapper = PageWrapper;
export const getStaticPaths = async () => {
const paths = Object.keys(AppCategories);
const isPrismaAvailable = await isPrismaAvailableCheck();
if (!isPrismaAvailable) {
// Database is not available at build time. Make sure we fall back to building these pages on demand
return {
paths: [],
fallback: "blocking",
};
}
return {
paths: paths.map((category) => ({ params: { category } })),
fallback: false,
};
};
export default Page;
export { getStaticProps };
-12
View File
@@ -1,12 +0,0 @@
import { getServerSideProps } from "@lib/apps/categories/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import type { PageProps } from "~/apps/categories/categories-view";
import Apps from "~/apps/categories/categories-view";
const Page = (props: PageProps) => <Apps {...props} />;
Page.PageWrapper = PageWrapper;
export default Page;
export { getServerSideProps };
@@ -1,12 +0,0 @@
import PageWrapper from "@components/PageWrapper";
import type { OnboardingPageProps } from "~/apps/installation/[[...step]]/step-view";
import StepView from "~/apps/installation/[[...step]]/step-view";
const Page = (props: OnboardingPageProps) => <StepView {...props} />;
Page.PageWrapper = PageWrapper;
export { getServerSideProps } from "@lib/apps/installation/[[...step]]/getServerSideProps";
export default Page;
@@ -1,12 +0,0 @@
import PageWrapper from "@components/PageWrapper";
import type { PageProps } from "~/apps/installed/[category]/installed-category-view";
import InstalledApps from "~/apps/installed/[category]/installed-category-view";
const Page = (props: PageProps) => <InstalledApps {...props} />;
Page.PageWrapper = PageWrapper;
export { getServerSideProps } from "@lib/apps/installed/[category]/getServerSideProps";
export default Page;
-7
View File
@@ -1,7 +0,0 @@
export { getServerSideProps } from "@lib/apps/installed/getServerSideProps";
function RedirectPage() {
return;
}
export default RedirectPage;
@@ -1,25 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import z from "zod";
const paramsSchema = z
.object({
pages: z.array(z.string()),
})
.catch({
pages: [],
});
export default function RoutingForms() {
return null;
}
export const getServerSideProps = (context: GetServerSidePropsContext) => {
const { pages } = paramsSchema.parse(context.params);
return {
redirect: {
destination: `/apps/routing-forms/${pages.length ? pages.join("/") : ""}`,
permanent: false,
},
};
};
-12
View File
@@ -1,12 +0,0 @@
export default function RoutingFormsIndex() {
return null;
}
export const getServerSideProps = () => {
return {
redirect: {
destination: `/apps/routing-forms/forms`,
permanent: false,
},
};
};
@@ -0,0 +1,15 @@
import { expectPageToBeNotFound } from "playwright/lib/testUtils";
import { test } from "./lib/fixtures";
test.describe("App Router - error handling", () => {
test("404s must render App Router's not-found page regardless of whether pages router or app router is used", async ({
page,
}) => {
await expectPageToBeNotFound({ page, url: "/123491234" });
await expectPageToBeNotFound({ page, url: "/team/123491234" });
await expectPageToBeNotFound({ page, url: "/org/123491234" });
await expectPageToBeNotFound({ page, url: "/insights/123491234" });
await expectPageToBeNotFound({ page, url: "/login/123491234" });
});
});
@@ -31,6 +31,8 @@ function getOrgOrigin(orgSlug: string | null) {
return orgOrigin;
}
test.describe.configure({ mode: "parallel" });
test.describe("Bookings", () => {
test.afterEach(async ({ orgs, users, page }) => {
await users.deleteAll();
+6
View File
@@ -570,6 +570,12 @@
<path d="M4 17v2" ></path>
<path d="M5 18H3" ></path>
</symbol>
<symbol class="lucide lucide-split" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="split">
<path d="M16 3h5v5" ></path>
<path d="M8 3H3v5" ></path>
<path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3" ></path>
<path d="m15 9 6-6" ></path>
</symbol>
<symbol class="lucide lucide-square-check" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="square-check">
<rect width="18" height="18" x="3" y="3" rx="2" ></rect>
<path d="m9 12 2 2 4-4" ></path>

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

@@ -2874,6 +2874,8 @@
"rr_distribution_method_availability_description": "Allows bookers to book meetings whenever a host is available. Use this to maximize the number of potential meetings booked and when the even distribution of meetings across hosts is less important.",
"rr_distribution_method_balanced_title": "Load balancing",
"rr_distribution_method_balanced_description": "We will monitor how many bookings have been made with each host and compare this with others, disabling some hosts that are too far ahead so bookings are evenly distributed.",
"edit_team_member_weights": "Edit Weights",
"weights_updated_successfully": "Weights updated successfully",
"require_confirmation_for_free_email": "Only require confirmation for free email providers (Ex. @gmail.com, @outlook.com)",
"exclude_emails_that_contain": "Exclude emails that contain ...",
"require_emails_that_contain": "Require emails that contain ...",
@@ -2917,6 +2919,7 @@
"dry_run_mode_active": "You are doing a test booking.",
"icon_showcase": "Icon showcase",
"icons_showcase": "Icons showcase",
"feature_currently_disabled": "Feature is currently disabled",
"choose_an_option": "Choose an option",
"enter_option_value": "Enter option value",
"remove_option": "Remove option",
@@ -2924,4 +2927,4 @@
"managed_users_description": "See all the managed users created by your OAuth client",
"select_oAuth_client": "Select Oauth Client",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
}
@@ -6,11 +6,6 @@ checkRoute () {
# These conditionals are used to remove directories from the build that are not needed in production
# This is to reduce the size of the build and prevent OOM errors
checkRoute "$APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED" app/future/apps/installed
checkRoute "$APP_ROUTER_APPS_SLUG_ENABLED" app/future/apps/\[slug\]
checkRoute "$APP_ROUTER_APPS_SLUG_SETUP_ENABLED" app/future/apps/\[slug\]/setup
checkRoute "$APP_ROUTER_APPS_CATEGORIES_ENABLED" app/future/apps/categories
checkRoute "$APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED" app/future/apps/categories/\[category\]
checkRoute "$APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED" app/future/auth/forgot-password
checkRoute "$APP_ROUTER_AUTH_LOGIN_ENABLED" app/future/auth/login
checkRoute "$APP_ROUTER_AUTH_LOGOUT_ENABLED" app/future/auth/logout
@@ -23,7 +23,6 @@ describe("pagesAndRewritePaths", () => {
"video",
"workflows",
"403",
"404",
"500",
"bookings",
"event-types",
+130 -164
View File
@@ -44,169 +44,135 @@
"icon": "github",
"url": "https://github.com/calcom/cal.com"
},
"feedback": {
"thumbsRating": true,
"suggestEdit": true
},
"modeToggle": {
"default": "light",
"isHidden": true
},
"topbarCtaButton": {
"name": "Login",
"url": "https://app.cal.com/auth/login"
},
"anchors": [
{
"name": "Developer",
"icon": "code",
"url": "https://cal.com/docs"
},
{
"name": "GitHub",
"icon": "github",
"url": "https://github.com/calcom/cal.com"
},
{
"name": "Blog",
"icon": "newspaper",
"url": "https://cal.com/blog"
}
],
"navigation": [
{
"group": "Documentation",
"pages": [
"welcome",
{
"group": "Quick Start",
"icon": "play",
"iconType": "solid",
"pages": [
"quick-start/quickstart",
"quick-start/sign-up",
"quick-start/complete-onboarding",
"quick-start/customization",
"quick-start/calendar"
]
},
{
"group": "Event Types",
"icon": "calendar",
"iconType": "solid",
"pages": [
"event-types/event-types",
"event-types/booking-questions",
"event-types/add-events",
"event-types/managed-events",
"event-types/adding-zoom",
"event-types/booking-frequency",
"event-types/round-robin",
"event-types/dynamic",
"event-types/collective-events",
"event-types/create-first-event",
"event-types/create-recurring",
"event-types/create-secret",
"event-types/custom-name",
"event-types/custom-time-slots",
"event-types/disable-confirmation",
"event-types/disable-guests",
"event-types/display-issues",
"event-types/dynamic",
"event-types/event-buffer",
"event-types/hide-notes",
"event-types/how-to-add-location",
"event-types/how-to-receive-payments",
"event-types/how-to-requires",
"event-types/how-to-set-buffer",
"event-types/how-to-time-slot-intervals",
"event-types/limit-future-bookings",
"event-types/location-of-event",
"event-types/min-notice",
"event-types/multiple-durations",
"event-types/offer-seats",
"event-types/recurring-events",
"event-types/require-criteria",
"event-types/secret-events",
"event-types/setup-location",
"event-types/single-use",
"event-types/split-name",
"event-types/sponsor",
"event-types/symbolic-issues"
]
},
{
"group": "Availabilities",
"icon": "magnifying-glass",
"iconType": "solid",
"pages": [
"availabilities/date-overrides",
"availabilities/multiple-schedules",
"availabilities/multiple-slots"
]
},
{
"group": "Bookings",
"icon": "calendar-check",
"iconType": "solid",
"pages": [
"bookings/no-show",
"bookings/paid-bookings",
"bookings/prefill-fields"
]
},
{
"group": "Embedding",
"icon": "code",
"iconType": "solid",
"pages": [
"embedding/adding-embed",
"embedding/adding-slots",
"embedding/embed-events",
"embedding/embed-instructions",
"embedding/embed-snippet-generator",
"embedding/prefill-booking-form-embed"
]
},
{
"group": "Auth and Provisioning",
"icon": "user-lock",
"iconType": "solid",
"pages": [
"auth-and-provision/scim-okta",
"auth-and-provision/oidc-with-okta"
]
},
{
"group": "Apps and Integrations",
"icon": "code-branch",
"iconType": "solid",
"pages": [
"apps-and-integrations/apps",
"apps-and-integrations/app-store",
"apps-and-integrations/adding-first-app",
"apps-and-integrations/assign-people",
"apps-and-integrations/build-greeter-app",
"apps-and-integrations/how-to-build-an-app",
"apps-and-integrations/syncing-third-party"
]
},
{
"group": "Developers",
"icon": "terminal",
"iconType": "solid",
"pages": [
"apps-and-integrations/oauth",
"apps-and-integrations/open-source",
"webhooks"
]
}
]
}
],
"footerSocials": {
"x": "https://x.com/calcom",
"github": "https://github.com/calcom",
"linkedin": "https://www.linkedin.com/company/cal-com"
{
"name": "Blog",
"icon": "newspaper",
"url": "https://cal.com/blog"
}
],
"navigation": [
{
"group": "Documentation",
"pages": [
"welcome",
{
"group": "Quick Start",
"icon": "play",
"iconType": "solid",
"pages": [
"quick-start/quickstart",
"quick-start/sign-up",
"quick-start/complete-onboarding",
"quick-start/customization",
"quick-start/calendar"
]
},
{
"group": "Event Types",
"icon": "calendar",
"iconType": "solid",
"pages": [
"event-types/event-types",
"event-types/booking-questions",
"event-types/add-events",
"event-types/managed-events",
"event-types/adding-zoom",
"event-types/booking-frequency",
"event-types/round-robin",
"event-types/dynamic",
"event-types/collective-events",
"event-types/create-first-event",
"event-types/create-recurring",
"event-types/create-secret",
"event-types/custom-name",
"event-types/custom-time-slots",
"event-types/disable-confirmation",
"event-types/disable-guests",
"event-types/display-issues",
"event-types/dynamic",
"event-types/event-buffer",
"event-types/hide-notes",
"event-types/how-to-add-location",
"event-types/how-to-receive-payments",
"event-types/how-to-requires",
"event-types/how-to-set-buffer",
"event-types/how-to-time-slot-intervals",
"event-types/limit-future-bookings",
"event-types/location-of-event",
"event-types/min-notice",
"event-types/multiple-durations",
"event-types/offer-seats",
"event-types/recurring-events",
"event-types/require-criteria",
"event-types/secret-events",
"event-types/setup-location",
"event-types/single-use",
"event-types/split-name",
"event-types/sponsor",
"event-types/symbolic-issues"
]
},
{
"group": "Availabilities",
"icon": "magnifying-glass",
"iconType": "solid",
"pages": [
"availabilities/date-overrides",
"availabilities/multiple-schedules",
"availabilities/multiple-slots"
]
},
{
"group": "Bookings",
"icon": "calendar-check",
"iconType": "solid",
"pages": ["bookings/no-show", "bookings/paid-bookings", "bookings/prefill-fields"]
},
{
"group": "Embedding",
"icon": "code",
"iconType": "solid",
"pages": [
"embedding/adding-embed",
"embedding/adding-slots",
"embedding/embed-events",
"embedding/embed-instructions",
"embedding/embed-snippet-generator",
"embedding/prefill-booking-form-embed"
]
},
{
"group": "Auth and Provisioning",
"icon": "user-lock",
"iconType": "solid",
"pages": ["auth-and-provision/scim-okta", "auth-and-provision/oidc-with-okta"]
},
{
"group": "Apps and Integrations",
"icon": "code-branch",
"iconType": "solid",
"pages": [
"apps-and-integrations/apps",
"apps-and-integrations/app-store",
"apps-and-integrations/adding-first-app",
"apps-and-integrations/assign-people",
"apps-and-integrations/build-greeter-app",
"apps-and-integrations/how-to-build-an-app",
"apps-and-integrations/syncing-third-party"
]
},
{
"group": "Developers",
"icon": "terminal",
"iconType": "solid",
"pages": ["apps-and-integrations/oauth", "apps-and-integrations/open-source", "webhooks"]
}
]
}
],
"footerSocials": {
"x": "https://x.com/calcom",
"github": "https://github.com/calcom",
"linkedin": "https://www.linkedin.com/company/cal-com"
}
}
@@ -34,6 +34,7 @@ import ga4_config_json from "./ga4/config.json";
import { metadata as giphy__metadata_ts } from "./giphy/_metadata";
import { metadata as googlecalendar__metadata_ts } from "./googlecalendar/_metadata";
import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata";
import granola_config_json from "./granola/config.json";
import gtm_config_json from "./gtm/config.json";
import horizon_workrooms_config_json from "./horizon-workrooms/config.json";
import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata";
@@ -135,6 +136,7 @@ export const appStoreMetadata = {
giphy: giphy__metadata_ts,
googlecalendar: googlecalendar__metadata_ts,
googlevideo: googlevideo__metadata_ts,
granola: granola_config_json,
gtm: gtm_config_json,
"horizon-workrooms": horizon_workrooms_config_json,
hubspot: hubspot__metadata_ts,
@@ -34,6 +34,7 @@ export const apiHandlers = {
giphy: import("./giphy/api"),
googlecalendar: import("./googlecalendar/api"),
googlevideo: import("./googlevideo/api"),
granola: import("./granola/api"),
gtm: import("./gtm/api"),
"horizon-workrooms": import("./horizon-workrooms/api"),
hubspot: import("./hubspot/api"),
+30
View File
@@ -0,0 +1,30 @@
---
items:
- iframe: { src: https://www.youtube.com/embed/t-aE7xdT7Cs?si=975eoWL7ZL_zybXl }
- 1.jpeg
- 2.jpeg
- 3.jpeg
- 4.jpeg
- 5.jpeg
---
# The AI notepad for people in back-to-back meetings
Granola is an AI-powered notepad that makes taking & using meeting notes a breeze. It blends your own notes with smart transcription to capture every detail. With its simple design, advanced AI, Granola ensures your notes are not just recorded, but useful.
## Works on all platforms, no meeting bots
Granola transcribes your Mac's audio directly, with no meeting bots joining your call
## Customizable templates for your most common meeting types
Get notes in the exact format your team needs.
## Put your meetings to work
Granola has GPT-4 built in, so it can help you do your post-meeting action items
## Share your notes with one click
Granola makes it easy to share notes on the platforms you already use
+20
View File
@@ -0,0 +1,20 @@
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
redirect: {
newTab: true,
url: "https://go.cal.com/granola",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1
View File
@@ -0,0 +1 @@
export { default as add } from "./add";
+16
View File
@@ -0,0 +1,16 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Granola",
"slug": "granola",
"type": "granola_other",
"logo": "icon.svg",
"url": "https://go.cal.com/granola",
"variant": "other",
"categories": ["other"],
"publisher": "Granola inc",
"email": "hey@granola.so",
"description": "The AI notepad for people in back-to-back meetings for MacOS",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "link-as-an-app"
}
+1
View File
@@ -0,0 +1 @@
export * as api from "./api";
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/granola",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "The AI notepad for people in back-to-back meetings for MacOS"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

@@ -0,0 +1,51 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2979_236)">
<rect width="1024" height="1024" rx="126" fill="#FAFAFA"/>
<mask id="mask0_2979_236" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="1024" height="1024">
<path d="M1024 0H0V1024H1024V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_2979_236)">
<g filter="url(#filter0_diii_2979_236)">
<path d="M429.738 571.193C343.963 571.193 277.171 508.62 277.171 422.142C277.171 334.96 343.963 273.793 437.472 273.793H609.022V277.308L576.681 339.179H562.619C584.414 360.974 597.07 389.8 597.07 422.845C597.07 462.217 579.493 497.371 553.479 515.651V517.76C587.93 534.634 609.725 576.818 609.725 624.627C609.725 709.699 539.418 769.461 436.769 769.461C360.133 769.461 294.748 736.416 261 681.576L322.167 626.736C347.478 666.109 391.069 690.716 438.878 690.716C487.39 690.716 521.138 664.702 521.138 626.736C521.138 592.989 496.53 570.49 462.079 570.49C451.533 570.49 447.315 571.193 429.738 571.193ZM363.649 422.142C363.649 462.92 393.881 493.855 436.769 493.855C479.656 493.855 511.295 462.92 511.295 422.142C511.295 383.472 479.656 351.131 436.769 351.131C394.584 351.131 363.649 382.769 363.649 422.142Z" fill="url(#paint0_linear_2979_236)"/>
<path d="M760.537 259.549C760.537 232.736 738.801 211 711.989 211C685.176 211 663.44 232.736 663.44 259.549V763.795C663.44 790.608 685.176 812.344 711.989 812.344C738.801 812.344 760.537 790.608 760.537 763.795V259.549Z" fill="#5DCE5D"/>
</g>
</g>
</g>
<defs>
<filter id="filter0_diii_2979_236" x="259" y="210" width="503.537" height="609.344" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.13 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2979_236"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2979_236" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="7"/>
<feGaussianBlur stdDeviation="11"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.254167 0 0 0 0 0.147417 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_2979_236"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="effect2_innerShadow_2979_236" result="effect3_innerShadow_2979_236"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.65 0"/>
<feBlend mode="normal" in2="effect3_innerShadow_2979_236" result="effect4_innerShadow_2979_236"/>
</filter>
<linearGradient id="paint0_linear_2979_236" x1="435.363" y1="273.793" x2="435.363" y2="769.461" gradientUnits="userSpaceOnUse">
<stop stop-color="#005723"/>
<stop offset="1" stop-color="#004126"/>
</linearGradient>
<clipPath id="clip0_2979_236">
<rect width="1024" height="1024" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+1 -1
View File
@@ -20,7 +20,7 @@ const handler: AppDeclarativeHandler = {
});
},
redirect: {
url: "/apps/routing-forms/forms",
url: "/routing/forms",
},
};
+1 -1
View File
@@ -10,7 +10,7 @@
"variant": "other",
"categories": ["automation"],
"publisher": "Cal.com, Inc.",
"simplePath": "/apps/routing-forms",
"simplePath": "/routing",
"email": "help@cal.com",
"licenseRequired": true,
"teamsPlanRequired": {
@@ -0,0 +1,13 @@
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
import { getServerSidePropsForSingleFormView as getServerSidePropsSingleForm } from "../components/getServerSidePropsSingleForm";
import { getServerSideProps as getServerSidePropsForms } from "./forms/getServerSideProps";
import { getServerSideProps as getServerSidePropsRoutingLink } from "./routing-link/getServerSideProps";
export const routingServerSidePropsConfig: Record<string, AppGetServerSideProps> = {
forms: getServerSidePropsForms,
"form-edit": getServerSidePropsSingleForm,
"route-builder": getServerSidePropsSingleForm,
"routing-link": getServerSidePropsRoutingLink,
reporting: getServerSidePropsSingleForm,
};
@@ -1,16 +1,10 @@
//TODO: Generate this file automatically so that like in Next.js file based routing can work automatically
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
import { getServerSidePropsForSingleFormView as getServerSidePropsSingleForm } from "../components/getServerSidePropsSingleForm";
import * as formEdit from "./form-edit/[...appPages]";
import * as forms from "./forms/[...appPages]";
// extracts getServerSideProps function from the client component
import { getServerSideProps as getServerSidePropsForms } from "./forms/getServerSideProps";
import * as LayoutHandler from "./layout-handler/[...appPages]";
import * as Reporting from "./reporting/[...appPages]";
import * as RouteBuilder from "./route-builder/[...appPages]";
import * as RoutingLink from "./routing-link/[...appPages]";
import { getServerSideProps as getServerSidePropsRoutingLink } from "./routing-link/getServerSideProps";
const routingConfig = {
"form-edit": formEdit,
@@ -21,12 +15,4 @@ const routingConfig = {
layoutHandler: LayoutHandler,
};
export const serverSidePropsConfig: Record<string, AppGetServerSideProps> = {
forms: getServerSidePropsForms,
"form-edit": getServerSidePropsSingleForm,
"route-builder": getServerSidePropsSingleForm,
"routing-link": getServerSidePropsRoutingLink,
reporting: getServerSidePropsSingleForm,
};
export default routingConfig;
@@ -481,9 +481,5 @@ export default function FormEditPage({
}
FormEditPage.getLayout = (page: React.ReactElement) => {
return (
<Shell backPath="/apps/routing-forms/forms" withoutMain={true}>
{page}
</Shell>
);
return <Shell withoutMain={true}>{page}</Shell>;
};
@@ -293,9 +293,5 @@ export default function ReporterWrapper({
}
ReporterWrapper.getLayout = (page: React.ReactElement) => {
return (
<Shell backPath="/apps/routing-forms/forms" withoutMain={true}>
{page}
</Shell>
);
return <Shell withoutMain={true}>{page}</Shell>;
};
@@ -1092,11 +1092,7 @@ export default function RouteBuilder({
}
RouteBuilder.getLayout = (page: React.ReactElement) => {
return (
<Shell backPath="/apps/routing-forms/forms" withoutMain={true}>
{page}
</Shell>
);
return <Shell withoutMain={true}>{page}</Shell>;
};
export { getServerSideProps };
@@ -1,6 +1,5 @@
"use client";
import Head from "next/head";
import { useRouter } from "next/navigation";
import type { FormEvent } from "react";
import { useEffect, useRef, useState } from "react";
@@ -160,9 +159,6 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
<div>
{!customPageMessage ? (
<>
<Head>
<title>{`${form.name} | Cal.com Forms`}</title>
</Head>
<div className={classNames("mx-auto my-0 max-w-3xl", isEmbed ? "" : "md:my-24")}>
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="main border-booker md:border-booker-width dark:bg-muted bg-default mx-0 rounded-md p-4 py-6 sm:-mx-4 sm:px-8 ">
@@ -24,9 +24,6 @@ export const getServerSideProps = async function getServerSideProps(
const { currentOrgDomain } = orgDomainConfig(context.req);
const isEmbed = params.appPages[1] === "embed";
if (context.query["flag.coep"] === "true") {
context.res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
}
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
@@ -93,7 +93,7 @@ test.describe("Routing Forms", () => {
await gotoRoutingLink({ page, formId });
await expect(page.locator("text=Test Form Name")).toBeVisible();
await page.goto(`apps/routing-forms/route-builder/${formId}`);
await page.goto(`routing/route-builder/${formId}`);
await disableForm(page);
await gotoRoutingLink({ page, formId });
await expect(page.getByTestId(`404-page`)).toBeVisible();
@@ -101,12 +101,12 @@ test.describe("Routing Forms", () => {
test("recently added form appears first in the list", async ({ page }) => {
await addForm(page, { name: "Test Form 1" });
await page.goto(`apps/routing-forms/forms`);
await page.goto(`routing/forms`);
await page.waitForSelector('[data-testid="routing-forms-list"]');
await expect(page.locator('[data-testid="routing-forms-list"] > div h1')).toHaveCount(1);
await addForm(page, { name: "Test Form 2" });
await page.goto(`apps/routing-forms/forms`);
await page.goto(`routing/forms`);
await page.waitForSelector('[data-testid="routing-forms-list"]');
await expect(page.locator('[data-testid="routing-forms-list"] > div h1')).toHaveCount(2);
@@ -351,7 +351,7 @@ test.describe("Routing Forms", () => {
["event-routing", "Option-2", "Option-2", "Option-2", "Option-2", "", "", "", expect.any(String)],
]);
await page.goto(`apps/routing-forms/route-builder/${routingForm.id}`);
await page.goto(`routing/route-builder/${routingForm.id}`);
const [download] = await Promise.all([
// Start waiting for the download
page.waitForEvent("download"),
@@ -446,7 +446,7 @@ test.describe("Routing Forms", () => {
test("Test preview should return correct route", async ({ page, users }) => {
const user = await createUserAndLogin({ users, page });
const routingForm = user.routingForms[0];
await page.goto(`apps/routing-forms/form-edit/${routingForm.id}`);
await page.goto(`routing/form-edit/${routingForm.id}`);
await page.click('[data-testid="test-preview"]');
//event redirect
@@ -696,7 +696,7 @@ async function addAllTypesOfFieldsAndSaveForm(
page: Page,
form: { description: string; label: string }
) {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.goto(`routing/form-edit/${formId}`);
await page.click('[data-testid="add-field"]');
await page.fill('[data-testid="description"]', form.description);
@@ -752,7 +752,7 @@ async function addAllTypesOfFieldsAndSaveForm(
}
async function addShortTextFieldAndSaveForm({ page, formId }: { page: Page; formId: string }) {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.goto(`routing/form-edit/${formId}`);
await page.click('[data-testid="add-field"]');
await page.locator(".data-testid-field-type").nth(0).click();
await page.fill(`[name="fields.0.label"]`, "Short Text");
@@ -36,7 +36,7 @@ export async function addOneFieldAndDescriptionAndSaveForm(
page: Page,
form: { description?: string; field?: { typeIndex: number; label: string } }
) {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.goto(`routing/form-edit/${formId}`);
await page.click('[data-testid="add-field"]');
if (form.description) {
await page.fill('[data-testid="description"]', form.description);
@@ -17,7 +17,7 @@ export default function HowToUse() {
<ol className="mb-5 ml-5 mt-5 list-decimal ltr:mr-5 rtl:ml-5">
<li>
Make sure that you have{" "}
<Link href="/apps/routing-forms" className="text-subtle text-base">
<Link href="/routing" className="text-subtle text-base">
Routing Forms
</Link>{" "}
app installed
@@ -28,6 +28,7 @@ import { EventTypeMetaDataSchema, eventTypeAppMetadataOptionalSchema } from "@ca
import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
import { getCalEventResponses } from "./getCalEventResponses";
import { scheduleNoShowTriggers } from "./handleNewBooking/scheduleNoShowTriggers";
const log = logger.getSubLogger({ prefix: ["[handleConfirmation] book:user"] });
@@ -155,6 +156,8 @@ export async function handleConfirmation(args: {
cancellationReason?: string | null;
metadata: Prisma.JsonValue | null;
customInputs: Prisma.JsonValue;
title: string;
responses: Prisma.JsonValue;
eventType: {
bookingFields: Prisma.JsonValue | null;
slug: string;
@@ -232,7 +235,9 @@ export async function handleConfirmation(args: {
description: true,
cancellationReason: true,
attendees: true,
responses: true,
location: true,
title: true,
uid: true,
startTime: true,
metadata: true,
@@ -292,6 +297,8 @@ export async function handleConfirmation(args: {
},
uid: true,
startTime: true,
responses: true,
title: true,
metadata: true,
cancellationReason: true,
endTime: true,
@@ -393,8 +400,18 @@ export async function handleConfirmation(args: {
const scheduleTriggerPromises: Promise<unknown>[] = [];
const updatedBookingsWithCalEventResponses = updatedBookings.map((booking) => {
return {
...booking,
...getCalEventResponses({
bookingFields: booking.eventType?.bookingFields ?? null,
booking,
}),
};
});
subscribersMeetingStarted.forEach((subscriber) => {
updatedBookings.forEach((booking) => {
updatedBookingsWithCalEventResponses.forEach((booking) => {
scheduleTriggerPromises.push(
scheduleTrigger({
booking,
@@ -406,7 +423,7 @@ export async function handleConfirmation(args: {
});
});
subscribersMeetingEnded.forEach((subscriber) => {
updatedBookings.forEach((booking) => {
updatedBookingsWithCalEventResponses.forEach((booking) => {
scheduleTriggerPromises.push(
scheduleTrigger({
booking,
@@ -1852,10 +1852,14 @@ async function handler(
}
if (booking && booking.status === BookingStatus.ACCEPTED) {
const bookingWithCalEventResponses = {
...booking,
responses: reqBody.calEventResponses,
};
for (const subscriber of subscribersMeetingEnded) {
scheduleTriggerPromises.push(
scheduleTrigger({
booking,
booking: bookingWithCalEventResponses,
subscriberUrl: subscriber.subscriberUrl,
subscriber,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
@@ -1867,7 +1871,7 @@ async function handler(
for (const subscriber of subscribersMeetingStarted) {
scheduleTriggerPromises.push(
scheduleTrigger({
booking,
booking: bookingWithCalEventResponses,
subscriberUrl: subscriber.subscriberUrl,
subscriber,
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
@@ -2,7 +2,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { bulkShortenLinks } from "@calcom/ee/workflows/lib/reminders/utils";
import { getShortenLink } from "@calcom/ee/workflows/lib/reminders/utils";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import { defaultHandler } from "@calcom/lib/server";
@@ -127,8 +127,26 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
rescheduleLink: `${bookerUrl}/reschedule/${reminder.booking.uid}` || "",
};
const [{ shortLink: meetingUrl }, { shortLink: cancelLink }, { shortLink: rescheduleLink }] =
await bulkShortenLinks([urls.meetingUrl, urls.cancelLink, urls.rescheduleLink]);
const [meetingUrl, cancelLink, rescheduleLink] = await Promise.allSettled([
getShortenLink(urls.meetingUrl),
getShortenLink(urls.cancelLink),
getShortenLink(urls.rescheduleLink),
]).then((results) => {
return results.map((result) => {
let finalResult = "";
if (result.status === "fulfilled") {
const v = result.value;
if (typeof v === "string") {
finalResult = v;
} else {
finalResult = v.shortLink;
}
}
return finalResult;
});
});
const variables: VariablesType = {
eventName: reminder.booking?.eventType?.title,
@@ -6,6 +6,7 @@ import {
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { SENDER_NAME } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { SchedulingType, WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -106,7 +107,25 @@ const processWorkflowStep = async (
? [emailAttendeeSendToOverride]
: evt.attendees?.map((attendee) => attendee.email);
sendTo = attendees;
const limitGuestsDate = new Date("2025-01-13");
if (workflow.userId) {
const user = await prisma.user.findFirst({
where: {
id: workflow.userId,
},
select: {
createdDate: true,
},
});
if (user?.createdDate && user.createdDate > limitGuestsDate) {
sendTo = attendees.slice(0, 1);
} else {
sendTo = attendees;
}
} else {
sendTo = attendees;
}
break;
}
@@ -1,5 +1,5 @@
import dayjs from "@calcom/dayjs";
import { bulkShortenLinks } from "@calcom/ee/workflows/lib/reminders/utils";
import { getShortenLink } from "@calcom/ee/workflows/lib/reminders/utils";
import { SENDER_ID, WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import type { TimeFormat } from "@calcom/lib/timeFormat";
@@ -153,8 +153,26 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
rescheduleLink: `${evt.bookerUrl ?? WEBSITE_URL}/reschedule/${evt.uid}`,
};
const [{ shortLink: meetingUrl }, { shortLink: cancelLink }, { shortLink: rescheduleLink }] =
await bulkShortenLinks([urls.meetingUrl, urls.cancelLink, urls.rescheduleLink]);
const [meetingUrl, cancelLink, rescheduleLink] = await Promise.allSettled([
getShortenLink(urls.meetingUrl),
getShortenLink(urls.cancelLink),
getShortenLink(urls.rescheduleLink),
]).then((results) => {
return results.map((result) => {
let finalResult = "";
if (result.status === "fulfilled") {
const v = result.value;
if (typeof v === "string") {
finalResult = v;
} else {
finalResult = v.shortLink;
}
}
return finalResult;
});
});
const variables: VariablesType = {
eventName: evt.title,
@@ -1,15 +1,14 @@
import { dub } from "@calcom/features/auth/lib/dub";
export const bulkShortenLinks = async (links: string[]) => {
const results = await dub.links.createMany(links.map((link) => ({ domain: "sms.cal.com", url: link })));
return links.map((link) => {
const createdLink = results.find((result) => result.url === link);
if (createdLink) {
return { shortLink: createdLink.shortLink };
// if invalid link, return the original link as it is
} else {
return { shortLink: link };
}
});
export const getShortenLink = (link: string) => {
// don't hit dub with with empty string
if (!link.length) {
const pr: Promise<string> = new Promise((resolve) => resolve(link));
return pr;
} else {
return dub.links.create({
url: link,
domain: "sms.cal.com",
});
}
};
@@ -283,6 +283,7 @@ export function AddMembersWithSwitch({
onInactive={onAssignAllTeamMembersInactive}
customClassNames={customClassNames?.assingAllTeamMembers}
/>
{assignmentState !== AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE && (
<div className="mt-2">
<MembersSegmentWithToggle
@@ -0,0 +1,315 @@
"use client";
import { Trans } from "next-i18next";
import Link from "next/link";
import { useState, useEffect, useRef, useMemo } from "react";
import type { Host, TeamMember } from "@calcom/features/eventtypes/lib/types";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import "@calcom/ui";
import {
Avatar,
Button,
buttonClasses,
TextField,
Icon,
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
showToast,
} from "@calcom/ui";
type TeamMemberItemProps = {
member: TeamMember & { weight?: number };
onWeightChange: (memberId: string, weight: number) => void;
};
const TeamMemberItem = ({ member, onWeightChange }: TeamMemberItemProps) => {
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
}
}, [isEditing]);
return (
<div className="border-subtle flex h-12 items-center border-b px-3 py-1 last:border-b-0">
<Avatar size="sm" imageSrc={member.avatar} alt={member.label} className="min-w-10" />
<span className="text-emphasis ml-3 flex-grow text-sm">{member.label}</span>
<div className="ml-auto flex h-full items-center">
{isEditing ? (
<div className="flex h-full items-center">
<div className="relative flex h-full items-center">
<input
ref={inputRef}
type="number"
min="0"
inputMode="numeric"
className="bg-muted border-default text-emphasis h-7 w-12 rounded-l-sm border px-2 text-sm [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
defaultValue={member.weight ?? 100}
onBlur={(e) => {
const newWeight = parseInt(e.target.value);
if (!isNaN(newWeight)) {
onWeightChange(member.value, newWeight);
}
setIsEditing(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const input = e.target as HTMLInputElement;
const newWeight = parseInt(input.value);
if (!isNaN(newWeight)) {
onWeightChange(member.value, newWeight);
}
setIsEditing(false);
}
if (e.key === "Escape") {
setIsEditing(false);
}
}}
/>
<span className="text-default border-default bg-muted flex h-7 items-center rounded-r-sm border border-l-0 px-2 text-sm">
%
</span>
</div>
</div>
) : (
<button
className="text-emphasis hover:bg-subtle decoration-emphasis flex h-7 items-center rounded-sm px-2 text-sm underline underline-offset-4"
onClick={() => setIsEditing(true)}>
{member.weight ?? 100}%
</button>
)}
</div>
</div>
);
};
interface Props {
teamMembers: TeamMember[];
value: Host[];
onChange: (hosts: Host[]) => void;
assignAllTeamMembers: boolean;
}
export const EditWeightsForAllTeamMembers = ({
teamMembers,
value,
onChange,
assignAllTeamMembers,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { t } = useLocale();
const [searchQuery, setSearchQuery] = useState("");
const localWeightsInitialValues = teamMembers.reduce<Record<string, number>>((acc, member) => {
// When assignAllTeamMembers is false, only include members that exist in value array
// Find the member in the value array and use its weight if it exists
const memberInValue = value.find((host) => host.userId === parseInt(member.value, 10));
acc[member.value] = memberInValue?.weight ?? 100;
return acc;
}, {});
const [localWeights, setLocalWeights] = useState<Record<string, number>>(localWeightsInitialValues);
const [uploadErrors, setUploadErrors] = useState<Array<{ email: string; error: string }>>([]);
const [isErrorsExpanded, setIsErrorsExpanded] = useState(true);
const handleWeightChange = (memberId: string, weight: number) => {
setLocalWeights((prev) => ({ ...prev, [memberId]: weight }));
};
const handleSave = () => {
const updatedValue = value.map((host) => ({
...host,
weight: localWeights[host.userId.toString()] ?? host.weight ?? 100,
}));
onChange(updatedValue);
setIsOpen(false);
};
const handleDownloadCsv = () => {
const csvData = teamMembers.map((member) => ({
id: member.value,
name: member.label,
email: member.email,
weight: localWeights[member.value] ?? 100,
}));
downloadAsCsv(csvData, "team-members-weights.csv");
};
const handleUploadCsv = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) return;
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = async (event) => {
if (!event.target?.result) return;
const csvData = event.target.result as string;
const lines = csvData.split("\n");
const newWeights: Record<string, number> = { ...localWeights };
const newErrors: Array<{ email: string; error: string }> = [];
// Skip header row
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const [, , email, weightStr] = line.split(",");
if (!email || !weightStr) continue;
const member = teamMembers.find((m) => m.email === email);
if (!member) {
newErrors.push({ email, error: t("member_not_found") });
continue;
}
const weight = parseInt(weightStr);
if (isNaN(weight) || weight <= 0) {
newErrors.push({ email, error: t("invalid_weight") });
continue;
}
newWeights[member.value] = weight;
}
setLocalWeights(newWeights);
setUploadErrors(newErrors);
if (newErrors.length > 0) {
showToast(t("weights_updated_with_errors", { count: newErrors.length }), "warning");
} else {
showToast(t("weights_updated_from_csv"), "success");
}
e.target.value = "";
};
reader.readAsText(file);
};
const filteredMembers = useMemo(() => {
return teamMembers
.map((member) => ({
...member,
weight: localWeights[member.value],
}))
.filter(
(member) =>
member.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
member.email.toLowerCase().includes(searchQuery.toLowerCase())
)
.filter((member) => {
// When assignAllTeamMembers is false, only include members that exist in value array
return assignAllTeamMembers || value.some((host) => host.userId === parseInt(member.value, 10));
});
}, [teamMembers, localWeights, searchQuery, assignAllTeamMembers, value]);
return (
<>
<Button
color="secondary"
className="-ml-2 -mt-2 mb-2 w-fit"
onClick={() => {
setIsOpen(true);
}}>
{t("edit_team_member_weights")}
</Button>
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<form className="flex h-full flex-col">
<SheetContent>
<SheetHeader>
<SheetTitle>{t("edit_team_member_weights")}</SheetTitle>
<div className="text-subtle text-sm">
<Trans i18nKey="weights_description">
Weights determine how meetings are distributed among hosts.
<Link
className="underline underline-offset-2"
target="_blank"
href="https://cal.com/docs/enterprise-features/teams/round-robin-scheduling#weights">
Learn more
</Link>
</Trans>
</div>
</SheetHeader>
<SheetBody className="mt-4 flex h-full flex-col space-y-6 p-1">
<div className="flex justify-start gap-2">
<label className={buttonClasses({ color: "secondary" })}>
<Icon name="upload" className="mr-2 h-4 w-4" />
<input type="file" accept=".csv" className="hidden" onChange={handleUploadCsv} />
{t("upload")}
</label>
<Button color="secondary" StartIcon="download" onClick={handleDownloadCsv}>
{t("download")}
</Button>
</div>
<TextField
placeholder={t("search")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value.toLowerCase())}
addOnLeading={
<Icon name="search" className="text-subtle h-4 w-4" aria-hidden="true" focusable="false" />
}
/>
<div className="flex max-h-[80dvh] flex-col overflow-y-auto rounded-md border">
{filteredMembers.map((member) => (
<TeamMemberItem key={member.value} member={member} onWeightChange={handleWeightChange} />
))}
{filteredMembers.length === 0 && (
<div className="text-subtle py-4 text-center text-sm">{t("no_members_found")}</div>
)}
</div>
{uploadErrors.length > 0 && (
<div className="mt-4">
<button
className="flex w-full items-center justify-between rounded-md border bg-red-50 p-3 text-sm text-red-900"
onClick={() => setIsErrorsExpanded(!isErrorsExpanded)}>
<div className="flex items-center space-x-2">
<Icon name="info" className="h-4 w-4" />
<span>{t("csv_upload_errors", { count: uploadErrors.length })}</span>
</div>
<Icon name="chevron-down" className="h-4 w-4" />
</button>
{isErrorsExpanded && (
<div className="mt-2 space-y-2">
{uploadErrors.map((error, index) => (
<div
key={index}
className="rounded-md border border-red-100 bg-red-50 p-3 text-sm text-red-900">
<strong>{error.email}:</strong> {error.error}
</div>
))}
</div>
)}
</div>
)}
</SheetBody>
<SheetFooter>
<SheetClose asChild>
<Button
color="minimal"
onClick={() => {
// Restore to default weights from the original value
setLocalWeights(localWeightsInitialValues);
setSearchQuery("");
}}>
{t("cancel")}
</Button>
</SheetClose>
<Button onClick={handleSave}>{t("done")}</Button>
</SheetFooter>
</SheetContent>
</form>
</Sheet>
</>
);
};

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