Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f88a91dad0 | ||
|
|
35059d7727 | ||
|
|
68f76e2acc | ||
|
|
80aac06ff3 | ||
|
|
0f47528ba2 | ||
|
|
113dc5fca6 | ||
|
|
0befdde5d7 | ||
|
|
b3ed6e39e3 | ||
|
|
e07b1eda32 | ||
|
|
8262894bde | ||
|
|
a3d7bcd22e | ||
|
|
62b753afba | ||
|
|
bb40b2f6bf | ||
|
|
f628dc83a8 | ||
|
|
2fd2578504 | ||
|
|
a29407349d | ||
|
|
e7df1ef493 | ||
|
|
b652ae7714 | ||
|
|
cec1c9ee46 |
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "app/routing-forms/page";
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
@@ -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;
|
||||
@@ -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"> →</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"> →</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;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Page = async () => {
|
||||
redirect("/routing/forms");
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.8.16",
|
||||
"version": "4.8.18",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as add } from "./add";
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as api from "./api";
|
||||
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
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 |
@@ -20,7 +20,7 @@ const handler: AppDeclarativeHandler = {
|
||||
});
|
||||
},
|
||||
redirect: {
|
||||
url: "/apps/routing-forms/forms",
|
||||
url: "/routing/forms",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||