Merge branch 'main' into teste2e-allQuestionsCollective

This commit is contained in:
GitStart-Cal.com 2023-11-24 01:19:02 +05:45 committed by GitHub
commit 4d457cb40e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
337 changed files with 8345 additions and 2498 deletions

View File

@ -25,6 +25,7 @@ CALCOM_LICENSE_KEY=
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
INSIGHTS_DATABASE_URL=
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
@ -106,6 +107,19 @@ NEXT_PUBLIC_HELPSCOUT_KEY=
NEXT_PUBLIC_FRESHCHAT_TOKEN=
NEXT_PUBLIC_FRESHCHAT_HOST=
# Google OAuth credentials
# To enable Login with Google you need to:
# 1. Set `GOOGLE_API_CREDENTIALS` above
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
GOOGLE_LOGIN_ENABLED=false
# - GOOGLE CALENDAR/MEET/LOGIN
# Needed to enable Google Calendar integration and Login with Google
# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
GOOGLE_API_CREDENTIALS=
# Inbox to send user feedback
SEND_FEEDBACK_EMAIL=
@ -236,6 +250,12 @@ AUTH_BEARER_TOKEN_VERCEL=
E2E_TEST_APPLE_CALENDAR_EMAIL=""
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
# - CALCOM QA ACCOUNT
# Used for E2E tests on Cal.com that require 3rd party integrations
E2E_TEST_CALCOM_QA_EMAIL="qa@example.com"
# Replace with your own password
E2E_TEST_CALCOM_QA_PASSWORD="password"
# - APP CREDENTIAL SYNC ***********************************************************************************
# Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations
# Under settings/admin/apps ensure that all app secrets are set the same as the parent application
@ -263,3 +283,9 @@ E2E_TEST_OIDC_USER_EMAIL=
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
# whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=1

View File

@ -13,4 +13,4 @@ jobs:
with:
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
organization-name: calcom
ignore-labels: "app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
ignore-labels: "app-store, ai, authentication, automated-testing, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"

View File

@ -1,7 +1,7 @@
import type { NextMiddleware } from "next-api-middleware";
import { CONSOLE_URL } from "@calcom/lib/constants";
import prisma, { customPrisma } from "@calcom/prisma";
import { customPrisma } from "@calcom/prisma";
const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL;
@ -12,41 +12,32 @@ export const customPrismaClient: NextMiddleware = async (req, res, next) => {
} = req;
// If no custom api Id is provided, attach to request the regular cal.com prisma client.
if (!key) {
req.prisma = prisma;
req.prisma = customPrisma();
await next();
return;
}
try {
// If we have a key, we check if the deployment matching the key, has a databaseUrl value set.
const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`)
.then((res) => res.json())
.then((res) => res.databaseUrl);
// If we have a key, we check if the deployment matching the key, has a databaseUrl value set.
const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`)
.then((res) => res.json())
.then((res) => res.databaseUrl);
if (!databaseUrl) {
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
return;
}
req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } });
/* @note:
if (!databaseUrl) {
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
return;
}
req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } });
/* @note:
In order to skip verifyApiKey for customPrisma requests,
we pass isAdmin true, and userId 0, if we detect them later,
we skip verifyApiKey logic and pass onto next middleware instead.
*/
req.isAdmin = true;
req.isCustomPrisma = true;
// We don't need the key from here and on. Prevents unrecognized key errors.
delete req.query.key;
await next();
await req.prisma.$disconnect();
// @ts-expect-error testing
delete req.prisma;
} catch (err) {
if (req.prisma) {
await req.prisma.$disconnect();
// @ts-expect-error testing
delete req.prisma;
}
throw err;
}
req.isAdmin = true;
req.isCustomPrisma = true;
// We don't need the key from here and on. Prevents unrecognized key errors.
delete req.query.key;
await next();
await req.prisma.$disconnect();
// @ts-expect-error testing
delete req.prisma;
};

View File

@ -26,6 +26,7 @@ const schemaAvailabilityCreateParams = z
startTime: z.date().or(z.string()),
endTime: z.date().or(z.string()),
days: z.array(z.number()).optional(),
date: z.date().or(z.string()).optional(),
})
.strict();
@ -34,6 +35,7 @@ const schemaAvailabilityEditParams = z
startTime: z.date().or(z.string()).optional(),
endTime: z.date().or(z.string()).optional(),
days: z.array(z.number()).optional(),
date: z.date().or(z.string()).optional(),
})
.strict();

View File

@ -21,7 +21,16 @@ export const schemaSchedulePublic = z
.merge(
z.object({
availability: z
.array(Availability.pick({ id: true, eventTypeId: true, days: true, startTime: true, endTime: true }))
.array(
Availability.pick({
id: true,
eventTypeId: true,
date: true,
days: true,
startTime: true,
endTime: true,
})
)
.transform((v) =>
v.map((item) => ({
...item,

View File

@ -316,8 +316,13 @@ async function checkPermissions(req: NextApiRequest) {
statusCode: 401,
message: "ADMIN required for `userId`",
});
if (!isAdmin && body.teamId)
throw new HttpError({
statusCode: 401,
message: "ADMIN required for `teamId`",
});
/* Admin users are required to pass in a userId or teamId */
if (isAdmin && (!body.userId || !body.teamId))
if (isAdmin && !body.userId && !body.teamId)
throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" });
}

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function CalcomApi(_: NextApiRequest, res: NextApiResponse) {
res.status(201).json({ message: "Welcome to Cal.com API - docs are at https://developer.cal.com/api" });
res.status(200).json({ message: "Welcome to Cal.com API - docs are at https://developer.cal.com/api" });
}

View File

@ -1,5 +1,6 @@
import type { NextApiRequest } from "next";
import { deleteUser } from "@calcom/features/users/lib/userDeletionService";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
@ -41,10 +42,18 @@ export async function deleteHandler(req: NextApiRequest) {
// Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
const user = await prisma.user.findUnique({ where: { id: query.userId } });
const user = await prisma.user.findUnique({
where: { id: query.userId },
select: {
id: true,
email: true,
metadata: true,
},
});
if (!user) throw new HttpError({ statusCode: 404, message: "User not found" });
await prisma.user.delete({ where: { id: user.id } });
await deleteUser(user);
return { message: `User with id: ${user.id} deleted successfully` };
}

View File

@ -8,6 +8,7 @@ import { describe, expect, test, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
import prisma from "@calcom/prisma";
@ -148,7 +149,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
expect(res._getStatusCode()).toBe(500);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({
message: "No available users found.",
message: ErrorCode.NoAvailableUsersFound,
})
);
});

7
apps/api/vercel.json Normal file
View File

@ -0,0 +1,7 @@
{
"functions": {
"pages/api/slots/*.ts": {
"memory": 512
}
}
}

View File

@ -10,7 +10,7 @@
"@calcom/config": "*",
"@calcom/dayjs": "*",
"@calcom/ui": "*",
"@radix-ui/react-avatar": "^1.0.0",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
@ -49,7 +49,7 @@
"storybook-addon-designs": "^6.3.1",
"storybook-addon-next": "^1.6.9",
"storybook-react-i18next": "^1.1.2",
"tailwindcss": "^3.3.1",
"tailwindcss": "^3.3.3",
"typescript": "^4.9.4",
"vite": "^4.1.2"
}

View File

@ -0,0 +1,68 @@
import { getBucket } from "abTest/utils";
import type { NextMiddleware, NextRequest } from "next/server";
import { NextResponse, URLPattern } from "next/server";
import z from "zod";
const ROUTES: [URLPattern, boolean][] = [
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
].map(([pathname, enabled]) => [
new URLPattern({
pathname,
}),
enabled,
]);
const FUTURE_ROUTES_OVERRIDE_COOKIE_NAME = "x-calcom-future-routes-override";
const FUTURE_ROUTES_ENABLED_COOKIE_NAME = "x-calcom-future-routes-enabled";
const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]);
export const abTestMiddlewareFactory =
(next: (req: NextRequest) => Promise<NextResponse<unknown>>): NextMiddleware =>
async (req: NextRequest) => {
const response = await next(req);
const { pathname } = req.nextUrl;
const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME);
const route = ROUTES.find(([regExp]) => regExp.test(req.url)) ?? null;
const enabled = route !== null ? route[1] || override : false;
if (pathname.includes("future") || !enabled) {
return response;
}
const safeParsedBucket = override
? { success: true as const, data: "future" as const }
: bucketSchema.safeParse(req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value);
if (!safeParsedBucket.success) {
// cookie does not exist or it has incorrect value
const bucket = getBucket();
response.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, bucket, {
expires: Date.now() + 1000 * 60 * 30,
httpOnly: true,
}); // 30 min in ms
if (bucket === "legacy") {
return response;
}
const url = req.nextUrl.clone();
url.pathname = `future${pathname}/`;
return NextResponse.rewrite(url, response);
}
if (safeParsedBucket.data === "legacy") {
return response;
}
const url = req.nextUrl.clone();
url.pathname = `future${pathname}/`;
return NextResponse.rewrite(url, response);
};

9
apps/web/abTest/utils.ts Normal file
View File

@ -0,0 +1,9 @@
import { AB_TEST_BUCKET_PROBABILITY } from "@calcom/lib/constants";
const cryptoRandom = () => {
return crypto.getRandomValues(new Uint8Array(1))[0] / 0xff;
};
export const getBucket = () => {
return cryptoRandom() * 100 < AB_TEST_BUCKET_PROBABILITY ? "future" : "legacy";
};

View File

@ -0,0 +1,8 @@
"use client";
import { createHydrateClient } from "app/_trpc/createHydrateClient";
import superjson from "superjson";
export const HydrateClient = createHydrateClient({
transformer: superjson,
});

View File

@ -0,0 +1,5 @@
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import { createTRPCReact } from "@trpc/react-query";
export const trpc = createTRPCReact<AppRouter>({});

View File

@ -0,0 +1,21 @@
"use client";
import { type DehydratedState, Hydrate } from "@tanstack/react-query";
import { useMemo } from "react";
import type { DataTransformer } from "@trpc/server";
export function createHydrateClient(opts: { transformer?: DataTransformer }) {
return function HydrateClient(props: { children: React.ReactNode; state: DehydratedState }) {
const { state, children } = props;
const transformedState: DehydratedState = useMemo(() => {
if (opts.transformer) {
return opts.transformer.deserialize(state);
}
return state;
}, [state]);
return <Hydrate state={transformedState}>{children}</Hydrate>;
};
}

View File

@ -0,0 +1,128 @@
import { type DehydratedState, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HydrateClient } from "app/_trpc/HydrateClient";
import { trpc } from "app/_trpc/client";
import { useState } from "react";
import superjson from "superjson";
import { httpBatchLink } from "@calcom/trpc/client/links/httpBatchLink";
import { httpLink } from "@calcom/trpc/client/links/httpLink";
import { loggerLink } from "@calcom/trpc/client/links/loggerLink";
import { splitLink } from "@calcom/trpc/client/links/splitLink";
const ENDPOINTS = [
"admin",
"apiKeys",
"appRoutingForms",
"apps",
"auth",
"availability",
"appBasecamp3",
"bookings",
"deploymentSetup",
"eventTypes",
"features",
"insights",
"payments",
"public",
"saml",
"slots",
"teams",
"organizations",
"users",
"viewer",
"webhook",
"workflows",
"appsRouter",
"googleWorkspace",
] as const;
export type Endpoint = (typeof ENDPOINTS)[number];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolveEndpoint = (links: any) => {
// TODO: Update our trpc routes so they are more clear.
// This function parses paths like the following and maps them
// to the correct API endpoints.
// - viewer.me - 2 segment paths like this are for logged in requests
// - viewer.public.i18n - 3 segments paths can be public or authed
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (ctx: any) => {
const parts = ctx.op.path.split(".");
let endpoint;
let path = "";
if (parts.length == 2) {
endpoint = parts[0] as keyof typeof links;
path = parts[1];
} else {
endpoint = parts[1] as keyof typeof links;
path = parts.splice(2, parts.length - 2).join(".");
}
return links[endpoint]({ ...ctx, op: { ...ctx.op, path } });
};
};
export const TrpcProvider: React.FC<{ children: React.ReactNode; dehydratedState?: DehydratedState }> = ({
children,
dehydratedState,
}) => {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: { queries: { staleTime: 5000 } },
})
);
const url =
typeof window !== "undefined"
? "/api/trpc"
: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}/api/trpc`
: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`;
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
// adds pretty logs to your console in development and logs errors in production
loggerLink({
enabled: (opts) =>
!!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error),
}),
splitLink({
// check for context property `skipBatch`
condition: (op) => !!op.context.skipBatch,
// when condition is true, use normal request
true: (runtime) => {
const links = Object.fromEntries(
ENDPOINTS.map((endpoint) => [
endpoint,
httpLink({
url: `${url}/${endpoint}`,
})(runtime),
])
);
return resolveEndpoint(links);
},
// when condition is false, use batch request
false: (runtime) => {
const links = Object.fromEntries(
ENDPOINTS.map((endpoint) => [
endpoint,
httpBatchLink({
url: `${url}/${endpoint}`,
})(runtime),
])
);
return resolveEndpoint(links);
},
}),
],
transformer: superjson,
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{dehydratedState ? <HydrateClient state={dehydratedState}>{children}</HydrateClient> : children}
</QueryClientProvider>
</trpc.Provider>
);
};

40
apps/web/app/_utils.tsx Normal file
View File

@ -0,0 +1,40 @@
import { type TFunction } from "i18next";
import { headers } from "next/headers";
import { constructGenericImage } from "@calcom/lib/OgImages";
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG } from "@calcom/lib/constants";
import { getFixedT } from "@calcom/lib/server/getFixedT";
import { preparePageMetadata } from "@lib/metadata";
export const _generateMetadata = async (
getTitle: (t: TFunction<string, undefined>) => string,
getDescription: (t: TFunction<string, undefined>) => string
) => {
const h = headers();
const canonical = h.get("x-pathname") ?? "";
const locale = h.get("x-locale") ?? "en";
const t = await getFixedT(locale, "common");
const title = getTitle(t);
const description = getDescription(t);
const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL);
const image =
SEO_IMG_OGIMG +
constructGenericImage({
title,
description,
});
return preparePageMetadata({
title,
canonical,
image,
description,
siteName: APP_NAME,
metadataBase,
});
};

64
apps/web/app/error.tsx Normal file
View File

@ -0,0 +1,64 @@
"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 { 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} />
);
};
export default CustomError;

View File

@ -0,0 +1,10 @@
import EventTypes from "@pages/event-types";
import { _generateMetadata } from "app/_utils";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("event_types_page_title"),
(t) => t("event_types_page_subtitle")
);
export default EventTypes;

View File

@ -0,0 +1,22 @@
// pages without layout (e.g., /availability/index.tsx) are supposed to go under (layout) folder
import { headers } from "next/headers";
import { type ReactElement } from "react";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import PageWrapper from "@components/PageWrapperAppDir";
type WrapperWithLayoutProps = {
children: ReactElement;
};
export default async function WrapperWithLayout({ children }: WrapperWithLayoutProps) {
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
return (
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null}>
{children}
</PageWrapper>
);
}

View File

@ -0,0 +1,17 @@
"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;

View File

@ -1,84 +1,63 @@
import type { Metadata } from "next";
import { headers as nextHeaders, cookies as nextCookies } from "next/headers";
import { dir } from "i18next";
import { headers, cookies } from "next/headers";
import Script from "next/script";
import React from "react";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { prepareRootMetadata } from "@lib/metadata";
import "../styles/globals.css";
export const metadata: Metadata = {
icons: {
icon: [
{
sizes: "32x32",
url: "/api/logo?type=favicon-32",
},
{
sizes: "16x16",
url: "/api/logo?type=favicon-16",
},
],
apple: {
sizes: "180x180",
url: "/api/logo?type=apple-touch-icon",
export const generateMetadata = () =>
prepareRootMetadata({
twitterCreator: "@calcom",
twitterSite: "@calcom",
robots: {
index: false,
follow: false,
},
other: [
{
url: "/safari-pinned-tab.svg",
rel: "mask-icon",
},
],
},
manifest: "/site.webmanifest",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f9fafb" },
{ media: "(prefers-color-scheme: dark)", color: "#1C1C1C" },
],
other: {
"msapplication-TileColor": "#000000",
},
};
});
const getInitialProps = async (
url: string,
headers: ReturnType<typeof nextHeaders>,
cookies: ReturnType<typeof nextCookies>
) => {
const getInitialProps = async (url: string) => {
const { pathname, searchParams } = new URL(url);
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
const embedColorScheme = searchParams?.get("ui.color-scheme");
// @ts-expect-error we cannot access ctx.req in app dir, however headers and cookies are only properties needed to extract the locale
const newLocale = await getLocale({ headers, cookies });
let direction = "ltr";
try {
const intlLocale = new Intl.Locale(newLocale);
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
direction = intlLocale.textInfo?.direction;
} catch (e) {
console.error(e);
}
const req = { headers: headers(), cookies: cookies() };
const newLocale = await getLocale(req);
const direction = dir(newLocale);
return { isEmbed, embedColorScheme, locale: newLocale, direction };
};
const getFallbackProps = () => ({
locale: "en",
direction: "ltr",
isEmbed: false,
embedColorScheme: false,
});
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const headers = nextHeaders();
const cookies = nextCookies();
const h = headers();
const fullUrl = headers.get("x-url") ?? "";
const nonce = headers.get("x-csp") ?? "";
const fullUrl = h.get("x-url") ?? "";
const nonce = h.get("x-csp") ?? "";
const isSSG = !fullUrl;
const { locale, direction, isEmbed, embedColorScheme } = isSSG
? getFallbackProps()
: await getInitialProps(fullUrl);
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps(fullUrl, headers, cookies);
return (
<html
lang={locale}
dir={direction}
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}
data-nextjs-router="app">
<head nonce={nonce}>
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
// eslint-disable-next-line @next/next/no-sync-scripts

View File

@ -1,4 +1,4 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
@ -6,6 +6,7 @@ import { z } from "zod";
import type { CredentialOwner } from "@calcom/app-store/types";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { Badge, ListItemText, Avatar } from "@calcom/ui";
@ -56,7 +57,7 @@ export default function AppListCard(props: AppListCardProps) {
const router = useRouter();
const [highlight, setHighlight] = useState(shouldHighlight && hl === slug);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const pathname = usePathname();
useEffect(() => {

View File

@ -1,5 +1,6 @@
"use client";
import { type DehydratedState } from "@tanstack/react-query";
import type { SSRConfig } from "next-i18next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
@ -10,7 +11,6 @@ import type { ReactNode } from "react";
import "@calcom/embed-core/src/embed-iframe";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { trpc } from "@calcom/trpc/react";
import type { AppProps } from "@lib/app-providers-app-dir";
import AppProviders from "@lib/app-providers-app-dir";
@ -29,13 +29,14 @@ const calFont = localFont({
});
export type PageWrapperProps = Readonly<{
getLayout: (page: React.ReactElement) => ReactNode;
getLayout: ((page: React.ReactElement) => ReactNode) | null;
children: React.ReactElement;
requiresLicense: boolean;
isThemeSupported: boolean;
isBookingPage: boolean;
nonce: string | undefined;
themeBasis: string | null;
dehydratedState?: DehydratedState;
isThemeSupported?: boolean;
isBookingPage?: boolean;
i18n?: SSRConfig;
}>;
@ -85,4 +86,4 @@ function PageWrapper(props: PageWrapperProps) {
);
}
export default trpc.withTRPC(PageWrapper);
export default PageWrapper;

View File

@ -42,6 +42,7 @@ export type AppPageProps = {
disableInstall?: boolean;
dependencies?: string[];
concurrentMeetings: AppType["concurrentMeetings"];
paid?: AppType["paid"];
};
export const AppPage = ({
@ -67,6 +68,7 @@ export const AppPage = ({
isTemplate,
dependencies,
concurrentMeetings,
paid,
}: AppPageProps) => {
const { t, i18n } = useLocale();
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
@ -163,6 +165,19 @@ export const AppPage = ({
className="bg-subtle text-emphasis rounded-md p-1 text-xs capitalize">
{categories[0]}
</Link>{" "}
{paid && (
<>
<Badge className="mr-1">
{Intl.NumberFormat(i18n.language, {
style: "currency",
currency: "USD",
useGrouping: false,
maximumFractionDigits: 0,
}).format(paid.priceInUsd)}
/{t("month")}
</Badge>
</>
)}
{" "}
<a target="_blank" rel="noreferrer" href={website}>
{t("published_by", { author })}
@ -206,6 +221,7 @@ export const AppPage = ({
addAppMutationInput={{ type, variant, slug }}
multiInstall
concurrentMeetings={concurrentMeetings}
paid={paid}
{...props}
/>
);
@ -244,6 +260,7 @@ export const AppPage = ({
addAppMutationInput={{ type, variant, slug }}
credentials={appDbQuery.data?.credentials}
concurrentMeetings={concurrentMeetings}
paid={paid}
{...props}
/>
);
@ -263,7 +280,7 @@ export const AppPage = ({
<SkeletonButton className="mt-6 h-20 grow" />
))}
{price !== 0 && (
{price !== 0 && !paid && (
<span className="block text-right">
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
{feeType === "monthly" && `/${t("month")}`}
@ -273,23 +290,27 @@ export const AppPage = ({
<div className="prose-sm prose prose-a:text-default prose-headings:text-emphasis prose-code:text-default prose-strong:text-default text-default mt-8">
{body}
</div>
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
<span className="text-default">
{teamsPlanRequired ? (
t("teams_plan_required")
) : price === 0 ? (
t("free_to_use_apps")
) : (
<>
{Intl.NumberFormat(i18n.language, {
style: "currency",
currency: "USD",
useGrouping: false,
}).format(price)}
{feeType === "monthly" && `/${t("month")}`}
</>
)}
</span>
{!paid && (
<>
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
<span className="text-default">
{teamsPlanRequired ? (
t("teams_plan_required")
) : price === 0 ? (
t("free_to_use_apps")
) : (
<>
{Intl.NumberFormat(i18n.language, {
style: "currency",
currency: "USD",
useGrouping: false,
}).format(price)}
{feeType === "monthly" && `/${t("month")}`}
</>
)}
</span>
</>
)}
<h4 className="text-emphasis mb-2 mt-8 font-semibold ">{t("contact")}</h4>
<ul className="prose-sm -ml-1 -mr-1 leading-5">

View File

@ -26,6 +26,7 @@ export const InstallAppButtonChild = ({
multiInstall,
credentials,
concurrentMeetings,
paid,
...props
}: {
userAdminTeams?: UserAdminTeams;
@ -34,6 +35,7 @@ export const InstallAppButtonChild = ({
multiInstall?: boolean;
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
concurrentMeetings?: boolean;
paid?: AppFrontendPayload["paid"];
} & ButtonProps) => {
const { t } = useLocale();
@ -46,8 +48,27 @@ export const InstallAppButtonChild = ({
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
},
});
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
// Paid apps don't support team installs at the moment
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
if (paid) {
return (
<Button
data-testid="install-app-button"
{...props}
disabled={shouldDisableInstallation}
color="primary"
size="base">
{paid.trial ? t("start_paid_trial") : t("install_paid_app")}
</Button>
);
}
if (
!userAdminTeams?.length ||
!doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
) {
return (
<Button
data-testid="install-app-button"
@ -55,6 +76,7 @@ export const InstallAppButtonChild = ({
// @TODO: Overriding color and size prevent us from
// having to duplicate InstallAppButton for now.
color="primary"
disabled={shouldDisableInstallation}
size="base">
{multiInstall ? t("install_another") : t("install_app")}
</Button>

View File

@ -47,7 +47,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
};
};
const getAppDataSetter = (appId: EventTypeAppsList): SetAppData => {
const getAppDataSetter = (appId: EventTypeAppsList, credentialId?: number): SetAppData => {
return function (key, value) {
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
const allAppsDataFromForm = methods.getValues("metadata")?.apps || {};
@ -57,6 +57,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
[appId]: {
...appData,
[key]: value,
credentialId,
},
});
};
@ -76,7 +77,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
appCards.push(
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.userCredentialIds[0])}
key={app.slug}
app={app}
eventType={eventType}
@ -90,7 +91,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
appCards.push(
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, team.credentialId)}
key={app.slug + team?.credentialId}
app={{
...app,
@ -147,7 +148,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
return (
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList, app.userCredentialIds[0])}
key={app.slug}
app={app}
eventType={eventType}

View File

@ -8,7 +8,7 @@ import { Controller, useFormContext, useFieldArray } from "react-hook-form";
import type { MultiValue } from "react-select";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, LocationType, MeetLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { CAL_URL } from "@calcom/lib/constants";
@ -247,15 +247,7 @@ export const EventSetupTab = (
<ul ref={animationRef} className="space-y-2">
{locationFields.map((field, index) => {
const eventLocationType = getEventLocationType(field.type);
const defaultLocation = formMethods
.getValues("locations")
?.find((location: { type: EventLocationType["type"]; address?: string }) => {
if (location.type === LocationType.InPerson) {
return location.type === eventLocationType?.type && location.address === field?.address;
} else {
return location.type === eventLocationType?.type;
}
});
const defaultLocation = field;
const option = getLocationFromType(field.type, locationOptions);
@ -337,10 +329,12 @@ export const EventSetupTab = (
name={eventLocationType.defaultValueVariable}
className="text-error my-1 ml-6 text-sm"
as="div"
id="location-error"
/>
</div>
<div className="ml-6">
<CheckboxField
name={`locations[${index}].displayLocationPublicly`}
data-testid="display-location"
defaultChecked={defaultLocation?.displayLocationPublicly}
description={t("display_location_label")}

View File

@ -76,6 +76,8 @@ const ChildrenEventTypesList = ({
<div>
<Label>{t("assign_to")}</Label>
<ChildrenEventTypeSelect
aria-label="assignment-dropdown"
data-testid="assignment-dropdown"
onChange={(options) => {
onChange &&
onChange(

View File

@ -14,6 +14,6 @@ type UserAvatarProps = Omit<React.ComponentProps<typeof Avatar>, "alt" | "imageS
* It is aware of the user's organization to correctly show the avatar from the correct URL
*/
export function UserAvatar(props: UserAvatarProps) {
const { user, previewSrc, ...rest } = props;
return <Avatar {...rest} alt={user.name || ""} imageSrc={previewSrc ?? getUserAvatarUrl(user)} />;
const { user, previewSrc = getUserAvatarUrl(user), ...rest } = props;
return <Avatar {...rest} alt={user.name || "Nameless User"} imageSrc={previewSrc} />;
}

View File

@ -1,4 +1,5 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { TrpcProvider } from "app/_trpc/trpc-provider";
import { dir } from "i18next";
import type { Session } from "next-auth";
import { SessionProvider, useSession } from "next-auth/react";
@ -255,26 +256,28 @@ const AppProviders = (props: PageWrapperProps) => {
const isBookingPage = useIsBookingPage();
const RemainingProviders = (
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<SessionProvider>
<CustomI18nextProvider i18n={props.i18n}>
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
themeBasis={props.themeBasis}
nonce={props.nonce}
isThemeSupported={props.isThemeSupported}
isBookingPage={props.isBookingPage || isBookingPage}>
<FeatureFlagsProvider>
<OrgBrandProvider>
<MetaProvider>{props.children}</MetaProvider>
</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
</SessionProvider>
</EventCollectionProvider>
<TrpcProvider dehydratedState={props.dehydratedState}>
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<SessionProvider>
<CustomI18nextProvider i18n={props.i18n}>
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
themeBasis={props.themeBasis}
nonce={props.nonce}
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported ?? true}
isBookingPage={props.isBookingPage || isBookingPage}>
<FeatureFlagsProvider>
<OrgBrandProvider>
<MetaProvider>{props.children}</MetaProvider>
</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
</SessionProvider>
</EventCollectionProvider>
</TrpcProvider>
);
if (isBookingPage) {

View File

@ -1,10 +1,12 @@
import { usePathname, useSearchParams } from "next/navigation";
import { usePathname } from "next/navigation";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
export default function useIsBookingPage(): boolean {
const pathname = usePathname();
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const userParam = Boolean(searchParams?.get("user"));
const teamParam = Boolean(searchParams?.get("team"));

View File

@ -1,8 +1,10 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useCallback } from "react";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
export default function useRouterQuery<T extends string>(name: T) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const pathname = usePathname();
const router = useRouter();

View File

@ -1,7 +1,7 @@
import { useSearchParams } from "next/navigation";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
export function useToggleQuery(name: string) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
return {
isOn: searchParams?.get(name) === "1",

82
apps/web/lib/metadata.ts Normal file
View File

@ -0,0 +1,82 @@
import type { Metadata } from "next";
import { truncateOnWord } from "@calcom/lib/text";
type RootMetadataRecipe = Readonly<{
twitterCreator: string;
twitterSite: string;
robots: {
index: boolean;
follow: boolean;
};
}>;
export type PageMetadataRecipe = Readonly<{
title: string;
canonical: string;
image: string;
description: string;
siteName: string;
metadataBase: URL;
}>;
export const prepareRootMetadata = (recipe: RootMetadataRecipe): Metadata => ({
icons: {
icon: "/favicon.icon",
apple: "/api/logo?type=apple-touch-icon",
other: [
{
rel: "icon-mask",
url: "/safari-pinned-tab.svg",
color: "#000000",
},
{
url: "/api/logo?type=favicon-16",
sizes: "16x16",
type: "image/png",
},
{
url: "/api/logo?type=favicon-32",
sizes: "32x32",
type: "image/png",
},
],
},
manifest: "/site.webmanifest",
viewport: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0",
robots: recipe.robots,
other: {
"application-TileColor": "#ff0000",
},
themeColor: [
{
media: "(prefers-color-scheme: light)",
color: "#f9fafb",
},
{
media: "(prefers-color-scheme: dark)",
color: "#1C1C1C",
},
],
twitter: {
site: recipe.twitterSite,
creator: recipe.twitterCreator,
card: "summary_large_image",
},
});
export const preparePageMetadata = (recipe: PageMetadataRecipe): Metadata => ({
title: recipe.title,
alternates: {
canonical: recipe.canonical,
},
openGraph: {
description: truncateOnWord(recipe.description, 158),
url: recipe.canonical,
type: "website",
siteName: recipe.siteName,
title: recipe.title,
images: [recipe.image],
},
metadataBase: recipe.metadataBase,
});

View File

@ -1,13 +1,16 @@
import { get } from "@vercel/edge-config";
import { collectEvents } from "next-collect/server";
import type { NextMiddleware } from "next/server";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
import { csp } from "@lib/csp";
const middleware: NextMiddleware = async (req) => {
import { abTestMiddlewareFactory } from "./abTest/middlewareFactory";
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const url = req.nextUrl;
const requestHeaders = new Headers(req.headers);
@ -61,6 +64,12 @@ const middleware: NextMiddleware = async (req) => {
requestHeaders.set("x-csp-enforce", "true");
}
requestHeaders.set("x-pathname", url.pathname);
const locale = await getLocale(req);
requestHeaders.set("x-locale", locale);
return NextResponse.next({
request: {
headers: requestHeaders,
@ -90,11 +99,13 @@ export const config = {
* Paths required by routingForms.handle
*/
"/apps/routing_forms/:path*",
"/event-types",
"/future/event-types/",
],
};
export default collectEvents({
middleware,
middleware: abTestMiddlewareFactory(middleware),
...nextCollectBasicSettings,
cookieName: "__clnds",
extend: extendEventData,

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.4.8",
"version": "3.5.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@ -45,7 +45,7 @@
"@hookform/resolvers": "^2.9.7",
"@next-auth/prisma-adapter": "^1.0.4",
"@next/bundle-analyzer": "^13.1.6",
"@radix-ui/react-avatar": "^1.0.0",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
@ -168,8 +168,9 @@
"module-alias": "^2.2.2",
"msw": "^0.42.3",
"node-html-parser": "^6.1.10",
"node-mocks-http": "^1.11.0",
"postcss": "^8.4.18",
"tailwindcss": "^3.3.1",
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.6",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"

View File

@ -1,7 +1,7 @@
import Head from "next/head";
import { useSearchParams } from "next/navigation";
import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, showToast } from "@calcom/ui";
import { Copy } from "@calcom/ui/components/icon";
@ -9,7 +9,7 @@ import { Copy } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
export default function Error500() {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { t } = useLocale();
return (

View File

@ -82,7 +82,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
description={markdownStrippedBio}
meeting={{
title: markdownStrippedBio,
profile: { name: `${profile.name}`, image: null },
profile: { name: `${profile.name}`, image: user.avatarUrl || null },
users: [{ username: `${user.username}`, name: `${user.name}` }],
}}
nextSeoProps={{
@ -245,7 +245,7 @@ export type UserPageProps = {
allowSEOIndexing: boolean;
username: string | null;
};
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
users: Pick<User, "away" | "name" | "username" | "bio" | "verified" | "avatarUrl">[];
themeBasis: string | null;
markdownStrippedBio: string;
safeBio: string;
@ -295,6 +295,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
metadata: true,
brandColor: true,
darkBrandColor: true,
avatarUrl: true,
organizationId: true,
organization: {
select: {
@ -363,6 +364,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
image: user.avatar,
theme: user.theme,
brandColor: user.brandColor,
avatarUrl: user.avatarUrl,
darkBrandColor: user.darkBrandColor,
allowSEOIndexing: user.allowSEOIndexing ?? true,
username: user.username,
@ -397,6 +399,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
name: user.name,
username: user.username,
bio: user.bio,
avatarUrl: user.avatarUrl,
away: user.away,
verified: user.verified,
})),

View File

@ -0,0 +1,61 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
const querySchema = z.object({
uuid: z.string().transform((objectKey) => objectKey.split(".")[0]),
});
const handleValidationError = (res: NextApiResponse, error: z.ZodError): void => {
const errors = error.errors.map((err) => ({
path: err.path.join("."),
errorCode: `error.validation.${err.code}`,
}));
res.status(400).json({
message: "VALIDATION_ERROR",
errors,
});
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const result = querySchema.safeParse(req.query);
if (!result.success) {
return handleValidationError(res, result.error);
}
const { uuid: objectKey } = result.data;
let img;
try {
const { data } = await prisma.avatar.findUniqueOrThrow({
where: {
objectKey,
},
select: {
data: true,
},
});
img = data;
} catch (e) {
// If anything goes wrong or avatar is not found, use default avatar
res.writeHead(302, {
Location: AVATAR_FALLBACK,
});
res.end();
return;
}
const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", "");
const imageResp = Buffer.from(decoded, "base64");
res.writeHead(200, {
"Content-Type": "image/png",
"Content-Length": imageResp.length,
});
res.end(imageResp);
}

View File

@ -73,7 +73,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
}
return res.status(200);
if (!res.writableEnded) return res.status(200);
return res;
} catch (error) {
console.error(error);
if (error instanceof HttpError) {

View File

@ -0,0 +1,116 @@
import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";
import type Stripe from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
export const config = {
api: {
bodyParser: false,
},
};
// This file is a catch-all for any integration related subscription/paid app.
const handleSubscriptionUpdate = async (event: Stripe.Event) => {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription.id) throw new HttpCode({ statusCode: 400, message: "Subscription ID not found" });
const app = await prisma.credential.findFirst({
where: {
subscriptionId: subscription.id,
},
});
if (!app) {
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
}
await prisma.credential.update({
where: {
id: app.id,
},
data: {
paymentStatus: subscription.status,
},
});
};
const handleSubscriptionDeleted = async (event: Stripe.Event) => {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription.id) throw new HttpCode({ statusCode: 400, message: "Subscription ID not found" });
const app = await prisma.credential.findFirst({
where: {
subscriptionId: subscription.id,
},
});
if (!app) {
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
}
// should we delete the credential here rather than marking as inactive?
await prisma.credential.update({
where: {
id: app.id,
},
data: {
paymentStatus: "inactive",
billingCycleStart: null,
},
});
};
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
"customer.subscription.updated": handleSubscriptionUpdate,
"customer.subscription.deleted": handleSubscriptionDeleted,
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const sig = req.headers["stripe-signature"];
if (!sig) {
throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" });
}
if (!process.env.STRIPE_WEBHOOK_SECRET) {
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
}
const requestBuffer = await buffer(req);
const payload = requestBuffer.toString();
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
const handler = webhookHandlers[event.type];
if (handler) {
await handler(event);
} else {
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */
throw new HttpCode({
statusCode: 202,
message: `Unhandled Stripe Webhook event type ${event.type}`,
});
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
res.status(err.statusCode ?? 500).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
return;
}
// Return a response to acknowledge receipt of the event
res.json({ received: true });
}

View File

@ -79,6 +79,7 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
isTemplate={data.isTemplate}
dependencies={data.dependencies}
concurrentMeetings={data.concurrentMeetings}
paid={data.paid}
// tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy"
body={

View File

@ -1,15 +1,16 @@
import type { InferGetServerSidePropsType } from "next";
import { useSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { HeadSeo } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const router = useRouter();
const slug = searchParams?.get("slug") as string;
const { status } = useSession();

View File

@ -1,10 +1,10 @@
import { Prisma } from "@prisma/client";
import type { GetStaticPropsContext, InferGetStaticPropsType } from "next";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import Shell from "@calcom/features/shell/Shell";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { AppCategories } from "@calcom/prisma/enums";
@ -13,7 +13,7 @@ import { AppCard, SkeletonText } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { t, isLocaleReady } = useLocale();
const category = searchParams?.get("category");

View File

@ -1,8 +1,8 @@
import { useSearchParams } from "next/navigation";
import { useReducer } from "react";
import { z } from "zod";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
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";
@ -122,7 +122,7 @@ type ModalState = {
};
export default function InstalledApps() {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const category = searchParams?.get("category") as querySchemaType["category"];
const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => {

View File

@ -1,8 +1,8 @@
import type { GetStaticPropsContext } from "next";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import z from "zod";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
import { X } from "@calcom/ui/components/icon";
@ -18,7 +18,7 @@ const querySchema = z.object({
export default function Error() {
const { t } = useLocale();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { error } = querySchema.parse(searchParams);
const isTokenVerificationError = error?.toLowerCase() === "verification";
const errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login");

View File

@ -4,7 +4,7 @@ import { jwtVerify } from "jose";
import type { GetServerSidePropsContext } from "next";
import { getCsrfToken, signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import type { CSSProperties } from "react";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
@ -17,6 +17,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
import { WEBAPP_URL, WEBSITE_URL, HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
@ -53,7 +54,7 @@ export default function Login({
totpEmail,
}: // eslint-disable-next-line @typescript-eslint/ban-types
inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const router = useRouter();
const formSchema = z
@ -278,7 +279,7 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
// TODO: Once we understand how to retrieve prop types automatically from getServerSideProps, remove this temporary variable
const _getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
const { req, res } = context;
const { req, res, query } = context;
const session = await getServerSession({ req, res });
const ssr = await ssrInit(context);
@ -318,6 +319,24 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
}
if (session) {
const { callbackUrl } = query;
if (callbackUrl) {
try {
const destination = getSafeRedirectUrl(callbackUrl as string);
if (destination) {
return {
redirect: {
destination,
permanent: false,
},
};
}
} catch (e) {
console.warn(e);
}
}
return {
redirect: {
destination: "/",

View File

@ -1,9 +1,9 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useState, useEffect } from "react";
import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Avatar, Button, Select } from "@calcom/ui";
@ -16,7 +16,7 @@ export default function Authorize() {
const { status } = useSession();
const router = useRouter();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const client_id = searchParams?.get("client_id") as string;
const state = searchParams?.get("state") as string;

View File

@ -1,12 +1,13 @@
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import PageWrapper from "@components/PageWrapper";
// To handle the IdP initiated login flow callback
export default function Page() {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
useEffect(() => {
const code = searchParams?.get("code");

View File

@ -1,11 +1,12 @@
import type { GetServerSidePropsContext } from "next";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getDeploymentKey } from "@calcom/features/ee/deployment/lib/getDeploymentKey";
import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
@ -21,7 +22,7 @@ import { ssrInit } from "@server/lib/ssr";
function useSetStep() {
const router = useRouter();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const pathname = usePathname();
const setStep = (newStep = 1) => {
const _searchParams = new URLSearchParams(searchParams ?? undefined);

View File

@ -1,6 +1,6 @@
import type { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
@ -9,6 +9,7 @@ import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomain
import stripe from "@calcom/features/ee/payments/server/stripe";
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import prisma from "@calcom/prisma";
@ -22,7 +23,7 @@ import { ssrInit } from "@server/lib/ssr";
export type SSOProviderPageProps = inferSSRProps<typeof getServerSideProps>;
export default function Provider(props: SSOProviderPageProps) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const router = useRouter();
useEffect(() => {

View File

@ -1,10 +1,11 @@
import { signIn } from "next-auth/react";
import Head from "next/head";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import z from "zod";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { trpc } from "@calcom/trpc/react";
import { Button, showToast } from "@calcom/ui";
@ -54,7 +55,7 @@ const querySchema = z.object({
});
export default function Verify() {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const pathname = usePathname();
const router = useRouter();
const routerQuery = useRouterQuery();

View File

@ -1,4 +1,4 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
@ -8,6 +8,7 @@ import Schedule from "@calcom/features/schedules/components/Schedule";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { availabilityAsString } from "@calcom/lib/availability";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
@ -83,7 +84,7 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
};
export default function Availability() {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { t, i18n } = useLocale();
const router = useRouter();
const utils = trpc.useContext();

View File

@ -1,11 +1,12 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import { useCallback } from "react";
import { getLayout } from "@calcom/features/MainLayout";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import { ShellMain } from "@calcom/features/shell/Shell";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import type { RouterOutputs } from "@calcom/trpc/react";
@ -131,7 +132,7 @@ const WithQuery = withQuery(trpc.viewer.availability.list as any);
export default function AvailabilityPage() {
const { t } = useLocale();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const router = useRouter();
const pathname = usePathname();

View File

@ -1,139 +1,20 @@
import dayjs from "@calcom/dayjs";
import Shell from "@calcom/features/shell/Shell";
import { Troubleshooter } from "@calcom/features/troubleshooter/Troubleshooter";
import { getLayout } from "@calcom/features/troubleshooter/layout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { SkeletonText } from "@calcom/ui";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import { HeadSeo } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
type User = RouterOutputs["viewer"]["me"];
export interface IBusySlot {
start: string | Date;
end: string | Date;
title?: string;
source?: string | null;
}
const AvailabilityView = ({ user }: { user: User }) => {
const { t } = useLocale();
const { date, setQuery: setSelectedDate } = useRouterQuery("date");
const selectedDate = dayjs(date);
const formattedSelectedDate = selectedDate.format("YYYY-MM-DD");
const { data, isLoading } = trpc.viewer.availability.user.useQuery(
{
username: user.username || "",
dateFrom: selectedDate.startOf("day").utc().format(),
dateTo: selectedDate.endOf("day").utc().format(),
withSource: true,
},
{
enabled: !!user.username,
}
);
const overrides =
data?.dateOverrides.reduce((acc, override) => {
if (
formattedSelectedDate !== dayjs(override.start).format("YYYY-MM-DD") &&
formattedSelectedDate !== dayjs(override.end).format("YYYY-MM-DD")
)
return acc;
acc.push({ ...override, source: "Date override" });
return acc;
}, [] as IBusySlot[]) || [];
return (
<div className="bg-default max-w-xl overflow-hidden rounded-md shadow">
<div className="px-4 py-5 sm:p-6">
{t("overview_of_day")}{" "}
<input
type="date"
className="inline h-8 border-none bg-inherit p-0"
defaultValue={formattedSelectedDate}
onChange={(e) => {
if (e.target.value) setSelectedDate(e.target.value);
}}
/>
<small className="text-muted block">{t("hover_over_bold_times_tip")}</small>
<div className="mt-4 space-y-4">
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div>
</div>
{(() => {
if (isLoading)
return (
<>
<SkeletonText className="block h-16 w-full" />
<SkeletonText className="block h-16 w-full" />
</>
);
if (data && (data.busy.length > 0 || overrides.length > 0))
return [...data.busy, ...overrides]
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
.map((slot: IBusySlot) => (
<div
key={dayjs(slot.start).format("HH:mm")}
className="bg-subtle overflow-hidden rounded-md"
data-testid="troubleshooter-busy-time">
<div className="text-emphasis px-4 py-5 sm:p-6">
{t("calendar_shows_busy_between")}{" "}
<span className="text-default font-medium" title={dayjs(slot.start).format("HH:mm")}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{t("and")}{" "}
<span className="text-default font-medium" title={dayjs(slot.end).format("HH:mm")}>
{dayjs(slot.end).format("HH:mm")}
</span>{" "}
{t("on")} {dayjs(slot.start).format("D")}{" "}
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")}
{slot.title && ` - (${slot.title})`}
{slot.source && <small>{` - (source: ${slot.source})`}</small>}
</div>
</div>
));
return (
<div className="bg-subtle overflow-hidden rounded-md">
<div className="text-emphasis px-4 py-5 sm:p-6">{t("calendar_no_busy_slots")}</div>
</div>
);
})()}
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
</div>
</div>
</div>
</div>
</div>
);
};
export default function Troubleshoot() {
const { data, isLoading } = trpc.viewer.me.useQuery();
function TroubleshooterPage() {
const { t } = useLocale();
return (
<div>
<Shell heading={t("troubleshoot")} hideHeadingOnMobile subtitle={t("troubleshoot_description")}>
{!isLoading && data && <AvailabilityView user={data} />}
</Shell>
</div>
<>
<HeadSeo title={t("troubleshoot")} description={t("troubleshoot_availability")} />
<Troubleshooter month={null} />
</>
);
}
Troubleshoot.PageWrapper = PageWrapper;
function convertMinsToHrsMins(mins: number) {
const h = Math.floor(mins / 60);
const m = mins % 60;
const hs = h < 10 ? `0${h}` : h;
const ms = m < 10 ? `0${m}` : m;
return `${hs}:${ms}`;
}
TroubleshooterPage.getLayout = getLayout;
TroubleshooterPage.PageWrapper = PageWrapper;
export default TroubleshooterPage;

View File

@ -4,7 +4,7 @@ import { createEvent } from "ics";
import type { GetServerSidePropsContext } from "next";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { RRule } from "rrule";
import { z } from "zod";
@ -36,6 +36,7 @@ import {
} from "@calcom/lib/date-fns";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
@ -98,7 +99,7 @@ export default function Success(props: SuccessProps) {
const router = useRouter();
const routerQuery = useRouterQuery();
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const {
allRemainingBookings,
isSuccessBookingPage,

View File

@ -0,0 +1,74 @@
import { getLayout } from "@calcom/features/MainLayout";
import { ShellMain } from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, ButtonGroup } from "@calcom/ui";
import { BarChart, CreditCard, Globe, Lock, Paintbrush, Users } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
export default function EnterprisePage() {
const { t } = useLocale();
const features = [
{
icon: <Globe className="h-5 w-5 text-red-500" />,
title: t("branded_subdomain"),
description: t("branded_subdomain_description"),
},
{
icon: <BarChart className="h-5 w-5 text-blue-500" />,
title: t("org_insights"),
description: t("org_insights_description"),
},
{
icon: <Paintbrush className="h-5 w-5 text-pink-500" />,
title: t("extensive_whitelabeling"),
description: t("extensive_whitelabeling_description"),
},
{
icon: <Users className="h-5 w-5 text-orange-500" />,
title: t("unlimited_teams"),
description: t("unlimited_teams_description"),
},
{
icon: <CreditCard className="h-5 w-5 text-green-500" />,
title: t("unified_billing"),
description: t("unified_billing_description"),
},
{
icon: <Lock className="h-5 w-5 text-purple-500" />,
title: t("advanced_managed_events"),
description: t("advanced_managed_events_description"),
},
];
return (
<div>
<ShellMain heading="Enterprise" subtitle={t("enterprise_description")}>
<UpgradeTip
plan="enterprise"
title={t("create_your_org")}
description={t("create_your_org_description")}
features={features}
background="/tips/enterprise"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href="https://i.cal.com/sales/enterprise?duration=25" target="_blank">
{t("contact_sales")}
</Button>
<Button color="minimal" href="https://cal.com/enterprise" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
<>Create Org</>
</UpgradeTip>
</ShellMain>
</div>
);
}
EnterprisePage.PageWrapper = PageWrapper;
EnterprisePage.getLayout = getLayout;

View File

@ -1,8 +1,10 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { User } from "@prisma/client";
import { Trans } from "next-i18next";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import type { FC } from "react";
import { memo, useEffect, useState } from "react";
import { z } from "zod";
@ -19,6 +21,7 @@ import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsF
import { ShellMain } from "@calcom/features/shell/Shell";
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
@ -208,7 +211,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
const { t } = useLocale();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
const [parent] = useAutoAnimate<HTMLUListElement>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -840,7 +843,7 @@ const Main = ({
filters: ReturnType<typeof getTeamsFiltersFromQuery>;
}) => {
const isMobile = useMediaQuery("(max-width: 768px)");
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
if (!data || status === "loading") {
@ -904,7 +907,7 @@ const Main = ({
const EventTypesPage = () => {
const { t } = useLocale();
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const { open } = useIntercom();
const { data: user } = useMeQuery();
const [showProfileBanner, setShowProfileBanner] = useState(false);

View File

@ -46,6 +46,7 @@ export default function InsightsPage() {
<div>
<ShellMain heading="Insights" subtitle={t("insights_subtitle")}>
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}

View File

@ -250,10 +250,7 @@ const AppearanceView = ({
/>
{lightModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
<Alert severity="warning" message={t("light_theme_contrast_error")} />
</div>
) : null}
</div>
@ -282,10 +279,7 @@ const AppearanceView = ({
/>
{darkModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
<Alert severity="warning" message={t("dark_theme_contrast_error")} />
</div>
) : null}
</div>

View File

@ -10,7 +10,7 @@ import OrganizationMemberAvatar from "@calcom/features/ee/organizations/componen
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import turndown from "@calcom/lib/turndownService";
@ -72,37 +72,22 @@ interface DeleteAccountValues {
type FormValues = {
username: string;
avatar: string | null;
avatar: string;
name: string;
email: string;
bio: string;
};
const checkIfItFallbackImage = (fetchedImgSrc?: string) => {
return !fetchedImgSrc || fetchedImgSrc.endsWith(AVATAR_FALLBACK);
};
const ProfileView = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const { update } = useSession();
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const [fetchedImgSrc, setFetchedImgSrc] = useState<string | undefined>(undefined);
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
onSuccess: async (userData) => {
try {
if (!userData.organization) {
const res = await fetch(userData.avatar);
if (res.url) setFetchedImgSrc(res.url);
} else {
setFetchedImgSrc("");
}
} catch (err) {
setFetchedImgSrc("");
}
},
const { data: avatarData } = trpc.viewer.avatar.useQuery(undefined, {
enabled: !isLoading && !user?.avatarUrl,
});
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
@ -234,7 +219,7 @@ const ProfileView = () => {
const defaultValues = {
username: user.username || "",
avatar: user.avatar || "",
avatar: getUserAvatarUrl(user),
name: user.name || "",
email: user.email || "",
bio: user.bio || "",
@ -251,8 +236,7 @@ const ProfileView = () => {
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
isLoading={updateProfileMutation.isLoading}
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
userAvatar={user.avatar}
isFallbackImg={!user.avatarUrl && !avatarData?.avatar}
user={user}
userOrganization={user.organization}
onSubmit={(values) => {
@ -398,7 +382,6 @@ const ProfileForm = ({
extraField,
isLoading = false,
isFallbackImg,
userAvatar,
user,
userOrganization,
}: {
@ -407,7 +390,6 @@ const ProfileForm = ({
extraField?: React.ReactNode;
isLoading: boolean;
isFallbackImg: boolean;
userAvatar: string;
user: RouterOutputs["viewer"]["me"];
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
}) => {
@ -416,7 +398,7 @@ const ProfileForm = ({
const profileFormSchema = z.object({
username: z.string(),
avatar: z.string().nullable(),
avatar: z.string(),
name: z
.string()
.trim()
@ -438,7 +420,6 @@ const ProfileForm = ({
} = formMethods;
const isDisabled = isSubmitting || !isDirty;
return (
<Form form={formMethods} handleSubmit={onSubmit}>
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
@ -447,7 +428,7 @@ const ProfileForm = ({
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => {
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
const showRemoveAvatarButton = value === null ? false : !isFallbackImg;
const organization =
userOrganization && userOrganization.id
? {
@ -474,7 +455,7 @@ const ProfileForm = ({
handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
imageSrc={value}
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
/>
@ -482,7 +463,7 @@ const ProfileForm = ({
<Button
color="secondary"
onClick={() => {
formMethods.setValue("avatar", null, { shouldDirty: true });
formMethods.setValue("avatar", "", { shouldDirty: true });
}}>
{t("remove")}
</Button>

View File

@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import type { CSSProperties } from "react";
import type { SubmitHandler } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
@ -13,6 +12,7 @@ import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
@ -37,7 +37,7 @@ type SignupProps = inferSSRProps<typeof getServerSideProps>;
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
const [emailUser, emailDomain] = email.split("@");
const [emailUser, emailDomain = ""] = email.split("@");
const username =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
@ -53,7 +53,7 @@ function addOrUpdateQueryParam(url: string, key: string, value: string) {
}
export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoAcceptEmail }: SignupProps) {
const searchParams = useSearchParams();
const searchParams = useCompatSearchParams();
const telemetry = useTelemetry();
const { t, i18n } = useLocale();
const flags = useFlagMap();
@ -143,7 +143,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
methods.clearErrors("apiError");
}
if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) {
if (!methods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) {
methods.setValue(
"username",
getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail)

View File

@ -11,10 +11,6 @@ let pages = (exports.pages = glob
)
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]")));
// Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns
// Make sure to keep it upto date as more nonExistingRouteRewrites are added.
const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
// It would also not match /free/30min/embed because we are ensuring just two slashes
@ -27,11 +23,26 @@ let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
));
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes);
exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`;
exports.orgUserTypeRoutePath = `/:user((?!${beforeRewriteExcludePages.join(
"/|"
)}|_next/|public/)[^/]+)/:type((?!avatar\.png)[^/]+)`;
exports.orgUserTypeEmbedRoutePath = `/:user((?!${beforeRewriteExcludePages.join(
"/|"
)}|_next/|public/)[^/]+)/:type/embed`;
/**
* Returns a regex that matches all existing routes, virtual routes (like /forms, /router, /success etc) and nextjs special paths (_next, public)
*/
function getRegExpMatchingAllReservedRoutes(suffix) {
// Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns
// Make sure to keep it upto date as more nonExistingRouteRewrites are added.
const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
const nextJsSpecialPaths = ["_next", "public"];
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes).concat(nextJsSpecialPaths);
return beforeRewriteExcludePages.join(`${suffix}|`) + suffix;
}
// To handle /something
exports.orgUserRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes("/?$")})[a-zA-Z0-9\-_]+)`;
// To handle /something/somethingelse
exports.orgUserTypeRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes(
"/"
)})[^/]+)/:type((?!avatar\.png)[^/]+)`;
// To handle /something/somethingelse/embed
exports.orgUserTypeEmbedRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes("/")})[^/]+)/:type/embed`;

View File

@ -40,6 +40,7 @@ test.describe("Availablity tests", () => {
const date = json[0].result.data.json.schedule.availability.find((a) => !!a.date);
const troubleshooterURL = `/availability/troubleshoot?date=${dayjs(date.date).format("YYYY-MM-DD")}`;
await page.goto(troubleshooterURL);
await page.waitForLoadState("networkidle");
await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1);
});
});

View File

@ -1,6 +1,8 @@
import { expect } from "@playwright/test";
import { randomString } from "@calcom/lib/random";
import { SchedulingType } from "@calcom/prisma/client";
import type { Schedule, TimeRange } from "@calcom/types/schedule";
import { test } from "./lib/fixtures";
import {
@ -342,3 +344,92 @@ test.describe("Booking on different layouts", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
});
test.describe("Booking round robin event", () => {
test.beforeEach(async ({ page, users }) => {
const teamMatesObj = [{ name: "teammate-1" }];
const dateRanges: TimeRange = {
start: new Date(new Date().setUTCHours(10, 0, 0, 0)), //one hour after default schedule (teammate-1's schedule)
end: new Date(new Date().setUTCHours(17, 0, 0, 0)),
};
const schedule: Schedule = [[], [dateRanges], [dateRanges], [dateRanges], [dateRanges], [dateRanges], []];
const testUser = await users.create(
{ username: "test-user", name: "Test User", email: "testuser@example.com", schedule },
{
hasTeam: true,
schedulingType: SchedulingType.ROUND_ROBIN,
teamEventLength: 120,
teammates: teamMatesObj,
}
);
const team = await testUser.getFirstTeam();
await page.goto(`/team/${team.team.slug}`);
});
test("Does not book round robin host outside availability with date override", async ({ page, users }) => {
const [testUser] = users.get();
testUser.apiLogin();
const team = await testUser.getFirstTeam();
// Click first event type (round robin)
await page.click('[data-testid="event-type-link"]');
await page.click('[data-testid="incrementMonth"]');
// books 9AM slots for 120 minutes (test-user is not available at this time, availability starts at 10)
await page.locator('[data-testid="time"]').nth(0).click();
await page.waitForLoadState("networkidle");
await page.locator('[name="name"]').fill("Test name");
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
await page.click('[data-testid="confirm-book-button"]');
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking");
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
const host = await page.locator('[data-testid="booking-host-name"]');
const hostName = await host.innerText();
//expect teammate-1 to be booked, test-user is not available at this time
expect(hostName).toBe("teammate-1");
// make another booking to see if also for the second booking teammate-1 is booked
await page.goto(`/team/${team.team.slug}`);
await page.click('[data-testid="event-type-link"]');
await page.click('[data-testid="incrementMonth"]');
await page.click('[data-testid="incrementMonth"]');
// Again book a 9AM slot for 120 minutes where test-user is not available
await page.locator('[data-testid="time"]').nth(0).click();
await page.waitForLoadState("networkidle");
await page.locator('[name="name"]').fill("Test name");
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
await page.click('[data-testid="confirm-book-button"]');
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking");
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
const hostSecondBooking = await page.locator('[data-testid="booking-host-name"]');
const hostNameSecondBooking = await hostSecondBooking.innerText();
expect(hostNameSecondBooking).toBe("teammate-1"); // teammate-1 should be booked again
});
});

View File

@ -5,16 +5,47 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { randomString } from "@calcom/lib/random";
import { test } from "./lib/fixtures";
import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes";
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("Event Types A/B tests", () => {
test("should point to the /future/event-types page", async ({ page, users, context }) => {
await context.addCookies([
{
name: "x-calcom-future-routes-override",
value: "1",
url: "http://localhost:3000",
},
]);
const user = await users.create();
await user.apiLogin();
await page.goto("/event-types");
await page.waitForLoadState();
const dataNextJsRouter = await page.evaluate(() =>
window.document.documentElement.getAttribute("data-nextjs-router")
);
expect(dataNextJsRouter).toEqual("app");
const locator = page.getByRole("heading", { name: "Event Types" });
await expect(locator).toBeVisible();
});
});
test.describe("Event Types tests", () => {
test.describe("user", () => {
testBothFutureAndLegacyRoutes.describe("user", () => {
test.beforeEach(async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await page.goto("/event-types");
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
});
@ -254,6 +285,87 @@ test.describe("Event Types tests", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("[data-testid=where]")).toHaveText(/Cal Video/);
});
test("can add single organizer address location without display location public option", async ({
page,
}) => {
const $eventTypes = page.locator("[data-testid=event-types] > li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
await page.waitForURL((url) => {
return !!url.pathname.match(/\/event-types\/.+/);
});
const locationAddress = "New Delhi";
await fillLocation(page, locationAddress, 0, false);
await page.locator("[data-testid=update-eventtype]").click();
await page.goto("/event-types");
const previewLink = await page
.locator("[data-testid=preview-link-button]")
.first()
.getAttribute("href");
await page.goto(previewLink ?? "");
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator(`[data-testid="where"]`)).toHaveText(locationAddress);
});
test("can select 'display on booking page' option when multiple organizer input type are present", async ({
page,
}) => {
await gotoFirstEventType(page);
await page.locator("#location-select").click();
await page.locator(`text="Link meeting"`).click();
const locationInputName = (idx: number) => `locations[${idx}].link`;
const testUrl1 = "https://cal.ai/";
await page.locator(`input[name="${locationInputName(0)}"]`).fill(testUrl1);
await page.locator("[data-testid=display-location]").last().check();
await checkDisplayLocation(page);
await unCheckDisplayLocation(page);
await page.locator("[data-testid=add-location]").click();
const testUrl2 = "https://cal.com/ai";
await page.locator(`text="Link meeting"`).last().click();
await page.locator(`input[name="${locationInputName(1)}"]`).waitFor();
await page.locator(`input[name="${locationInputName(1)}"]`).fill(testUrl2);
await checkDisplayLocation(page);
await unCheckDisplayLocation(page);
// Remove Both of the locations
const removeButtomId = "delete-locations.0.type";
await page.getByTestId(removeButtomId).click();
await page.getByTestId(removeButtomId).click();
// Add Multiple Organizer Phone Number options
await page.locator("#location-select").click();
await page.locator(`text="Organizer Phone Number"`).click();
const organizerPhoneNumberInputName = (idx: number) => `locations[${idx}].hostPhoneNumber`;
const testPhoneInputValue1 = "9199999999";
await page.locator(`input[name="${organizerPhoneNumberInputName(0)}"]`).waitFor();
await page.locator(`input[name="${organizerPhoneNumberInputName(0)}"]`).fill(testPhoneInputValue1);
await page.locator("[data-testid=display-location]").last().check();
await checkDisplayLocation(page);
await unCheckDisplayLocation(page);
await page.locator("[data-testid=add-location]").click();
const testPhoneInputValue2 = "9188888888";
await page.locator(`text="Organizer Phone Number"`).last().click();
await page.locator(`input[name="${organizerPhoneNumberInputName(1)}"]`).waitFor();
await page.locator(`input[name="${organizerPhoneNumberInputName(1)}"]`).fill(testPhoneInputValue2);
await checkDisplayLocation(page);
await unCheckDisplayLocation(page);
});
});
});
});
@ -293,7 +405,7 @@ async function addAnotherLocation(page: Page, locationOptionText: string) {
await page.locator(`text="${locationOptionText}"`).click();
}
const fillLocation = async (page: Page, inputText: string, index: number) => {
const fillLocation = async (page: Page, inputText: string, index: number, selectDisplayLocation = true) => {
// Except the first location, dropdown automatically opens when adding another location
if (index == 0) {
await page.locator("#location-select").last().click();
@ -303,5 +415,17 @@ const fillLocation = async (page: Page, inputText: string, index: number) => {
const locationInputName = `locations[${index}].address`;
await page.locator(`input[name="${locationInputName}"]`).waitFor();
await page.locator(`input[name="locations[${index}].address"]`).fill(inputText);
await page.locator("[data-testid=display-location]").last().check();
if (selectDisplayLocation) {
await page.locator("[data-testid=display-location]").last().check();
}
};
const checkDisplayLocation = async (page: Page) => {
await page.locator("[data-testid=display-location]").last().check();
await expect(page.locator("[data-testid=display-location]").last()).toBeChecked();
};
const unCheckDisplayLocation = async (page: Page) => {
await page.locator("[data-testid=display-location]").last().uncheck();
await expect(page.locator("[data-testid=display-location]").last()).toBeChecked({ checked: false });
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -0,0 +1,34 @@
import type { Page } from "@playwright/test";
declare global {
interface Window {
E2E_CLIPBOARD_VALUE?: string;
}
}
export type Window = typeof window;
// creates the single server fixture
export const createClipboardFixture = (page: Page) => {
return {
reset: async () => {
await page.evaluate(() => {
delete window.E2E_CLIPBOARD_VALUE;
});
},
get: async () => {
return getClipboardValue({ page });
},
};
};
function getClipboardValue({ page }: { page: Page }) {
return page.evaluate(() => {
return new Promise<string>((resolve, reject) => {
setInterval(() => {
if (!window.E2E_CLIPBOARD_VALUE) return;
resolve(window.E2E_CLIPBOARD_VALUE);
}, 500);
setTimeout(() => reject(new Error("Timeout")), 1000);
});
});
}

View File

@ -221,7 +221,7 @@ export function createBookingPageFixture(page: Page) {
placeholder?: string
) => {
await page.getByTestId("add-field").click();
await page.locator("#test-field-type > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("test-field-type").click();
await page.getByTestId(`select-option-${questionType}`).click();
await page.getByLabel("Identifier").dblclick();
await page.getByLabel("Identifier").fill(identifier);

View File

@ -10,6 +10,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { Schedule } from "@calcom/types/schedule";
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
import { TimeZoneEnum } from "./types";
@ -46,6 +47,7 @@ const createTeamEventType = async (
schedulingType?: SchedulingType;
teamEventTitle?: string;
teamEventSlug?: string;
teamEventLength?: number;
}
) => {
return await prisma.eventType.create({
@ -65,10 +67,16 @@ const createTeamEventType = async (
id: user.id,
},
},
hosts: {
create: {
userId: user.id,
isFixed: scenario?.schedulingType === SchedulingType.COLLECTIVE ? true : false,
},
},
schedulingType: scenario?.schedulingType ?? SchedulingType.COLLECTIVE,
title: scenario?.teamEventTitle ?? `${teamEventTitle}-team-id-${team.id}`,
slug: scenario?.teamEventSlug ?? `${teamEventSlug}-team-id-${team.id}`,
length: 30,
length: scenario?.teamEventLength ?? 30,
},
});
};
@ -78,12 +86,14 @@ const createTeamAndAddUser = async (
user,
isUnpublished,
isOrg,
isOrgVerified,
hasSubteam,
organizationId,
}: {
user: { id: number; username: string | null; role?: MembershipRole };
user: { id: number; email: string; username: string | null; role?: MembershipRole };
isUnpublished?: boolean;
isOrg?: boolean;
isOrgVerified?: boolean;
hasSubteam?: true;
organizationId?: number | null;
},
@ -95,7 +105,14 @@ const createTeamAndAddUser = async (
};
data.metadata = {
...(isUnpublished ? { requestedSlug: slug } : {}),
...(isOrg ? { isOrganization: true } : {}),
...(isOrg
? {
isOrganization: true,
isOrganizationVerified: !!isOrgVerified,
orgAutoAcceptEmail: user.email.split("@")[1],
isOrganizationConfigured: false,
}
: {}),
};
data.slug = !isUnpublished ? slug : undefined;
if (isOrg && hasSubteam) {
@ -135,7 +152,9 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
schedulingType?: SchedulingType;
teamEventTitle?: string;
teamEventSlug?: string;
teamEventLength?: number;
isOrg?: boolean;
isOrgVerified?: boolean;
hasSubteam?: true;
isUnpublished?: true;
} = {}
@ -283,9 +302,10 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
if (scenario.hasTeam) {
const team = await createTeamAndAddUser(
{
user: { id: user.id, username: user.username, role: "OWNER" },
user: { id: user.id, email: user.email, username: user.username, role: "OWNER" },
isUnpublished: scenario.isUnpublished,
isOrg: scenario.isOrg,
isOrgVerified: scenario.isOrgVerified,
hasSubteam: scenario.hasSubteam,
organizationId: opts?.organizationId,
},
@ -376,6 +396,15 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
await prisma.user.delete({ where: { id } });
store.users = store.users.filter((b) => b.id !== id);
},
set: async (email: string) => {
const user = await prisma.user.findUniqueOrThrow({
where: { email },
include: userIncludes,
});
const userFixture = createUserFixture(user, store.page);
store.users.push(userFixture);
return userFixture;
},
};
};
@ -400,7 +429,11 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
eventTypes: user.eventTypes,
routingForms: user.routingForms,
self,
apiLogin: async () => apiLogin({ ...(await self()), password: user.username }, store.page),
apiLogin: async (password?: string) =>
apiLogin({ ...(await self()), password: password || user.username }, store.page),
/**
* @deprecated use apiLogin instead
*/
login: async () => login({ ...(await self()), password: user.username }, store.page),
logout: async () => {
await page.goto("/auth/logout");
@ -489,6 +522,7 @@ type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
// ignores adding the worker-index after username
useExactUsername?: boolean;
roleInOrganization?: MembershipRole;
schedule?: Schedule;
};
// creates the actual user in the db.
@ -520,7 +554,7 @@ const createUser = (
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
availability: {
createMany: {
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
data: getAvailabilityFromSchedule(opts?.schedule ?? DEFAULT_SCHEDULE),
},
},
},
@ -641,7 +675,7 @@ export async function apiLogin(
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
await page.locator("div > .ml-auto").first().click();
await page.locator("[data-testid='app-switch']").first().click();
await page.getByPlaceholder("Price").fill("100");
await page.getByTestId("update-eventtype").click();
}

View File

@ -1,6 +1,10 @@
import { expect } from "@playwright/test";
import type Prisma from "@prisma/client";
import prisma from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { test } from "./lib/fixtures";
import type { Fixtures } from "./lib/fixtures";
import { todo, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
@ -34,6 +38,95 @@ test.describe("Stripe integration", () => {
});
});
test("when enabling Stripe, credentialId is included", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await page.goto("/apps/installed");
await user.getPaymentCredential();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.setupEventWithPrice(eventType);
// Need to wait for the DB to be updated with the metadata
await page.waitForResponse((res) => res.url().includes("update") && res.status() === 200);
// Check event type metadata to see if credentialId is included
const eventTypeMetadata = await prisma.eventType.findFirst({
where: {
id: eventType.id,
},
select: {
metadata: true,
},
});
const metadata = EventTypeMetaDataSchema.parse(eventTypeMetadata?.metadata);
const stripeAppMetadata = metadata?.apps?.stripe;
expect(stripeAppMetadata).toHaveProperty("credentialId");
expect(typeof stripeAppMetadata?.credentialId).toBe("number");
});
test("when enabling Stripe, team credentialId is included", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(ownerObj, {
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
});
await owner.apiLogin();
const { team } = await owner.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
const teamEvent = await owner.getFirstTeamEvent(team.id);
await page.goto("/apps/stripe");
/** We start the Stripe flow */
await Promise.all([
page.waitForURL("https://connect.stripe.com/oauth/v2/authorize?*"),
page.click('[data-testid="install-app-button"]'),
page.click('[data-testid="anything else"]'),
]);
await Promise.all([
page.waitForURL("/apps/installed/payment?hl=stripe"),
/** We skip filling Stripe forms (testing mode only) */
page.click('[id="skip-account-app"]'),
]);
await owner.setupEventWithPrice(teamEvent);
// Need to wait for the DB to be updated with the metadata
await page.waitForResponse((res) => res.url().includes("update") && res.status() === 200);
// Check event type metadata to see if credentialId is included
const eventTypeMetadata = await prisma.eventType.findFirst({
where: {
id: teamEvent.id,
},
select: {
metadata: true,
},
});
const metadata = EventTypeMetaDataSchema.parse(eventTypeMetadata?.metadata);
const stripeAppMetadata = metadata?.apps?.stripe;
expect(stripeAppMetadata).toHaveProperty("credentialId");
expect(typeof stripeAppMetadata?.credentialId).toBe("number");
});
test("Can book a paid booking", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
@ -174,7 +267,7 @@ test.describe("Stripe integration", () => {
await page.getByTestId("price-input-stripe").fill("200");
// Select currency in dropdown
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("stripe-currency-select").click();
await page.locator("#react-select-2-input").fill("mexi");
await page.locator("#react-select-2-option-81").click();

View File

@ -4,10 +4,12 @@ import type { API } from "mailhog";
import mailhog from "mailhog";
import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings";
import { createClipboardFixture } from "../fixtures/clipboard";
import { createEmbedsFixture } from "../fixtures/embeds";
import { createOrgsFixture } from "../fixtures/orgs";
import { createPaymentsFixture } from "../fixtures/payments";
@ -28,6 +30,7 @@ export interface Fixtures {
emails?: API;
routingForms: ReturnType<typeof createRoutingFormsFixture>;
bookingPage: ReturnType<typeof createBookingPageFixture>;
clipboard: ReturnType<typeof createClipboardFixture>;
}
declare global {
@ -85,6 +88,8 @@ export const test = base.extend<Fixtures>({
const mailhogAPI = mailhog();
await use(mailhogAPI);
} else {
//FIXME: Ideally we should error out here. If someone is running tests with mailhog disabled, they should be aware of it
logger.warn("Mailhog is not enabled - Skipping Emails verification");
await use(undefined);
}
},
@ -92,4 +97,8 @@ export const test = base.extend<Fixtures>({
const bookingPage = createBookingPageFixture(page);
await use(bookingPage);
},
clipboard: async ({ page }, use) => {
const clipboard = createClipboardFixture(page);
await use(clipboard);
},
});

View File

@ -0,0 +1,33 @@
import { test } from "./fixtures";
export type RouteVariant = "future" | "legacy";
const routeVariants = ["future", "legacy"];
/**
* Small wrapper around test.describe().
* When using testbothFutureLegacyRoutes.describe() instead of test.describe(), this will run the specified
* tests twice. One with the pages route, and one with the new app dir "future" route. It will also add the route variant
* name to the test name for easier debugging.
* Finally it also adds a parameter routeVariant to your testBothFutureAndLegacyRoutes.describe() callback, which
* can be used to do any conditional rendering in the test for a specific route variant (should be as little
* as possible).
*
* See apps/web/playwright/event-types.e2e.ts for an example.
*/
export const testBothFutureAndLegacyRoutes = {
describe: (testName: string, testFn: (routeVariant: RouteVariant) => void) => {
routeVariants.forEach((routeVariant) => {
test.describe(`${testName} -- ${routeVariant}`, () => {
if (routeVariant === "future") {
test.beforeEach(({ context }) => {
context.addCookies([
{ name: "x-calcom-future-routes-override", value: "1", url: "http://localhost:3000" },
]);
});
}
testFn(routeVariant as RouteVariant);
});
});
},
};

View File

@ -1,11 +1,13 @@
import type { Frame, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { createHash } from "crypto";
import EventEmitter from "events";
import type { IncomingMessage, ServerResponse } from "http";
import { createServer } from "http";
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import type { API, Messages } from "mailhog";
import { totp } from "otplib";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
@ -278,3 +280,12 @@ export async function createUserWithSeatedEventAndAttendees(
});
return { user, eventType, booking };
}
export function generateTotpCode(email: string) {
const secret = createHash("md5")
.update(email + process.env.CALENDSO_ENCRYPTION_KEY)
.digest("hex");
totp.options = { step: 90 };
return totp.generate(secret);
}

View File

@ -24,7 +24,7 @@ test.describe("2FA Tests", async () => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const userPassword = user.username!;
await user.login();
await user.apiLogin();
// expects the home page for an authorized user
await page.goto("/settings/security/two-factor-auth");
@ -94,7 +94,7 @@ test.describe("2FA Tests", async () => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const userPassword = user.username!;
await user.login();
await user.apiLogin();
// expects the home page for an authorized user
await page.goto("/settings/security/two-factor-auth");

View File

@ -1,6 +1,9 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
import { localize } from "./lib/testUtils";
test.afterEach(({ users }) => users.deleteAll());
@ -69,15 +72,34 @@ test.describe("Managed Event Types tests", () => {
await page.goto("/event-types");
await page.getByTestId("event-types").locator('a[title="managed"]').click();
await page.getByTestId("vertical-tab-assignment").click();
await page.locator('[class$="control"]').filter({ hasText: "Select..." }).click();
await page.getByTestId("assignment-dropdown").click();
await page.getByTestId(`select-option-${memberUser.id}`).click();
await page.locator('[type="submit"]').click();
await page.getByTestId("toast-success").waitFor();
});
await adminUser.logout();
await test.step("Managed event type can use Organizer's default app as location", async () => {
await page.getByTestId("vertical-tab-event_setup_tab_title").click();
await page.locator("#location-select").click();
const optionText = (await localize("en"))("organizer_default_conferencing_app");
await page.locator(`text=${optionText}`).click();
await page.locator("[data-testid=update-eventtype]").click();
await page.getByTestId("toast-success").waitFor();
await page.waitForLoadState("networkidle");
await page.getByTestId("vertical-tab-assignment").click();
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
});
await test.step("Managed event type has locked fields for added member", async () => {
await adminUser.logout();
// Coming back as member user to see if there is a managed event present after assignment
await memberUser.apiLogin();
await page.goto("/event-types");
@ -91,3 +113,9 @@ test.describe("Managed Event Types tests", () => {
});
});
});
async function gotoBookingPage(page: Page) {
const previewLink = await page.getByTestId("preview-button").getAttribute("href");
await page.goto(previewLink ?? "");
}

View File

@ -0,0 +1,28 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
// eslint-disable-next-line no-restricted-imports
import type { API, Messages } from "mailhog";
import { getEmailsReceivedByUser } from "../lib/testUtils";
export async function expectInvitationEmailToBeReceived(
page: Page,
emails: API | undefined,
userEmail: string,
subject: string,
returnLink?: string
) {
if (!emails) return null;
// We need to wait for the email to go through, otherwise it will fail
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(5000);
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
expect(receivedEmails?.total).toBe(1);
const [firstReceivedEmail] = (receivedEmails as Messages).items;
expect(firstReceivedEmail.subject).toBe(subject);
if (!returnLink) return;
const dom = new JSDOM(firstReceivedEmail.html);
const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`);
return anchor?.getAttribute("href");
}

View File

@ -0,0 +1,143 @@
import { expect } from "@playwright/test";
import path from "path";
import { test } from "../lib/fixtures";
import { generateTotpCode } from "../lib/testUtils";
import { expectInvitationEmailToBeReceived } from "./expects";
test.afterAll(({ users, emails }) => {
users.deleteAll();
emails?.deleteAll();
});
function capitalize(text: string) {
if (!text) {
return text;
}
return text.charAt(0).toUpperCase() + text.slice(1);
}
test.describe("Organization", () => {
test("should be able to create an organization and complete onboarding", async ({
page,
users,
emails,
}) => {
const orgOwner = await users.create();
const orgDomain = `${orgOwner.username}-org`;
const orgName = capitalize(`${orgOwner.username}-org`);
await orgOwner.apiLogin();
await page.goto("/settings/organizations/new");
await page.waitForLoadState("networkidle");
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.locator(".text-red-700")).toHaveCount(3);
// Happy path
await page.locator("input[name=adminEmail]").fill(`john@${orgDomain}.com`);
expect(await page.locator("input[name=name]").inputValue()).toEqual(orgName);
expect(await page.locator("input[name=slug]").inputValue()).toEqual(orgDomain);
await page.locator("button[type=submit]").click();
await page.waitForLoadState("networkidle");
// Check admin email about code verification
await expectInvitationEmailToBeReceived(
page,
emails,
`john@${orgOwner.username}-org.com`,
"Verify your email to create an organization"
);
await test.step("Verification", async () => {
// Code verification
await expect(page.locator("#modal-title")).toBeVisible();
await page.locator("input[name='2fa1']").fill(generateTotpCode(`john@${orgDomain}.com`));
await page.locator("button:text('Verify')").click();
// Check admin email about DNS pending action
await expectInvitationEmailToBeReceived(
page,
emails,
"admin@example.com",
"New organization created: pending action"
);
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/set-password");
});
});
await test.step("Admin password", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.locator(".text-red-700")).toHaveCount(3); // 3 password hints
// Happy path
await page.locator("input[name='password']").fill("ADMIN_user2023$");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/about");
});
await test.step("About the organization", async () => {
// Choosing an avatar
await page.locator('button:text("Upload")').click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Choose a file...").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
await page.locator('button:text("Save")').click();
// About text
await page.locator('textarea[name="about"]').fill("This is a testing org");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/onboard-admins");
});
await test.step("On-board administrators", async () => {
// Required field
await page.locator("button[type=submit]").click();
// Happy path
await page.locator('textarea[name="emails"]').fill(`rick@${orgDomain}.com`);
await page.locator("button[type=submit]").click();
// Check if invited admin received the invitation email
await expectInvitationEmailToBeReceived(
page,
emails,
`rick@${orgDomain}.com`,
`${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
);
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/add-teams");
});
await test.step("Create teams", async () => {
// Initial state
await expect(page.locator('input[name="teams.0.name"]')).toHaveCount(1);
await expect(page.locator('button:text("Continue")')).toBeDisabled();
// Filling one team
await page.locator('input[name="teams.0.name"]').fill("Marketing");
await expect(page.locator('button:text("Continue")')).toBeEnabled();
// Adding another team
await page.locator('button:text("Add a team")').click();
await expect(page.locator('button:text("Continue")')).toBeDisabled();
await expect(page.locator('input[name="teams.1.name"]')).toHaveCount(1);
await page.locator('input[name="teams.1.name"]').fill("Sales");
await expect(page.locator('button:text("Continue")')).toBeEnabled();
// Finishing the creation wizard
await page.locator('button:text("Continue")').click();
await page.waitForURL("/event-types");
});
});
});

View File

@ -0,0 +1,119 @@
import { expect } from "@playwright/test";
import { test } from "../lib/fixtures";
import { expectInvitationEmailToBeReceived } from "./expects";
test.describe.configure({ mode: "parallel" });
test.afterEach(async ({ users, emails, clipboard }) => {
clipboard.reset();
await users.deleteAll();
emails?.deleteAll();
});
test.describe("Organization", () => {
test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => {
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true });
const { team: org } = await orgOwner.getOrg();
await orgOwner.apiLogin();
await page.goto("/settings/organizations/members");
await page.waitForLoadState("networkidle");
await test.step("To the organization by email (external user)", async () => {
const invitedUserEmail = `rick@domain-${Date.now()}.com`;
await page.locator('button:text("Add")').click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator('button:text("Send invite")').click();
await page.waitForLoadState("networkidle");
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
"signup?token"
);
// Check newly invited member exists and is pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(1);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!inviteLink) return null;
// Follow invite link in new window
const context = await browser.newContext();
const newPage = await context.newPage();
newPage.goto(inviteLink);
await newPage.waitForLoadState("networkidle");
// Check required fields
await newPage.locator("button[type=submit]").click();
await expect(newPage.locator(".text-red-700")).toHaveCount(3); // 3 password hints
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await newPage.locator("button[type=submit]").click();
await newPage.waitForURL("/getting-started?from=signup");
await context.close();
await newPage.close();
// Check newly invited member is not pending anymore
await page.bringToFront();
await page.goto("/settings/organizations/members");
page.locator(`[data-testid="login-form"]`);
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
});
await test.step("To the organization by invite link", async () => {
// Get the invite link
await page.locator('button:text("Add")').click();
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
const inviteLink = await clipboard.get();
await page.waitForLoadState("networkidle");
// Follow invite link in new window
const context = await browser.newContext();
const inviteLinkPage = await context.newPage();
await inviteLinkPage.goto(inviteLink);
await inviteLinkPage.waitForLoadState("networkidle");
// Check required fields
await inviteLinkPage.locator("button[type=submit]").click();
await expect(inviteLinkPage.locator(".text-red-700")).toHaveCount(4); // email + 3 password hints
// Happy path
await inviteLinkPage.locator("input[name=email]").fill(`rick@domain-${Date.now()}.com`);
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await inviteLinkPage.locator("button[type=submit]").click();
await inviteLinkPage.waitForURL("/getting-started");
});
});
test("Invitation (verified)", async ({ browser, page, users, emails }) => {
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true });
const { team: org } = await orgOwner.getOrg();
await orgOwner.apiLogin();
await page.goto("/settings/organizations/members");
await page.waitForLoadState("networkidle");
await test.step("To the organization by email (internal user)", async () => {
const invitedUserEmail = `rick@example.com`;
await page.locator('button:text("Add")').click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator('button:text("Send invite")').click();
await page.waitForLoadState("networkidle");
await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`
);
// Check newly invited member exists and is pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
});
});
});

View File

@ -77,7 +77,7 @@ test.describe("Payment app", () => {
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("stripe-currency-select").click();
await page.getByTestId("select-option-usd").click();
await page.getByTestId("price-input-stripe").click();
@ -123,10 +123,10 @@ test.describe("Payment app", () => {
await page.getByPlaceholder("Price").click();
await page.getByPlaceholder("Price").fill("150");
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("paypal-currency-select").click();
await page.locator("#react-select-2-option-13").click();
await page.locator(".mb-1 > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("paypal-payment-option-select").click();
await page.getByText("$MXNCurrencyMexican pesoPayment option").click();
await page.getByTestId("update-eventtype").click();

View File

@ -0,0 +1,56 @@
import { expect } from "@playwright/test";
import path from "path";
import { prisma } from "@calcom/prisma";
import { test } from "../lib/fixtures";
test.describe("UploadAvatar", async () => {
test("can upload an image", async ({ page, users }) => {
const user = await users.create({});
await user.apiLogin();
await test.step("Can upload an initial picture", async () => {
await page.goto("/settings/my-account/profile");
await page.getByTestId("open-upload-avatar-dialog").click();
const [fileChooser] = await Promise.all([
// It is important to call waitForEvent before click to set up waiting.
page.waitForEvent("filechooser"),
// Opens the file chooser.
page.getByTestId("open-upload-image-filechooser").click(),
]);
await fileChooser.setFiles(`${path.dirname(__filename)}/../fixtures/cal.png`);
await page.getByTestId("upload-avatar").click();
await page.locator("input[name='name']").fill(user.email);
await page.getByText("Update").click();
await page.waitForSelector("text=Settings updated successfully");
const response = await prisma.avatar.findUniqueOrThrow({
where: {
teamId_userId: {
userId: user.id,
teamId: 0,
},
},
});
// todo: remove this; ideally the organization-avatar is updated the moment
// 'Settings updated succesfully' is saved.
await page.waitForLoadState("networkidle");
await expect(await page.getByTestId("organization-avatar").innerHTML()).toContain(response.objectKey);
const urlResponse = await page.request.get(`/api/avatar/${response.objectKey}.png`, {
maxRedirects: 0,
});
await expect(urlResponse?.status()).toBe(200);
});
});
});

View File

@ -0,0 +1,29 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
import type { API, Messages } from "mailhog";
import { getEmailsReceivedByUser } from "../lib/testUtils";
export async function expectInvitationEmailToBeReceived(
page: Page,
emails: API | undefined,
userEmail: string,
subject: string,
returnLink?: string
) {
if (!emails) return null;
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(10000);
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
expect(receivedEmails?.total).toBe(1);
const [firstReceivedEmail] = (receivedEmails as Messages).items;
expect(firstReceivedEmail.subject).toBe(subject);
if (!returnLink) return;
const dom = new JSDOM(firstReceivedEmail.html);
const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`);
return anchor?.getAttribute("href");
}

View File

@ -0,0 +1,124 @@
import { expect } from "@playwright/test";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { test } from "../lib/fixtures";
import { localize } from "../lib/testUtils";
import { expectInvitationEmailToBeReceived } from "./expects";
test.describe.configure({ mode: "parallel" });
test.afterEach(async ({ users, emails, clipboard }) => {
clipboard.reset();
await users.deleteAll();
emails?.deleteAll();
});
test.describe("Team", () => {
test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => {
const t = await localize("en");
const teamOwner = await users.create(undefined, { hasTeam: true });
const { team } = await teamOwner.getFirstTeam();
await teamOwner.apiLogin();
await page.goto(`/settings/teams/${team.id}/members`);
await page.waitForLoadState("networkidle");
await test.step("To the team by email (external user)", async () => {
const invitedUserEmail = `rick_${Date.now()}@domain-${Date.now()}.com`;
await page.locator(`button:text("${t("add")}")`).click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator(`button:text("${t("send_invite")}")`).click();
await page.waitForLoadState("networkidle");
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${team.name}'s admin invited you to join the team ${team.name} on Cal.com`,
"signup?token"
);
//Check newly invited member exists and is pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(1);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!inviteLink) return null;
// Follow invite link to new window
const context = await browser.newContext();
const newPage = await context.newPage();
await newPage.goto(inviteLink);
await newPage.waitForLoadState("networkidle");
// Check required fields
await newPage.locator("button[type=submit]").click();
await expect(newPage.locator('[data-testid="hint-error"]')).toHaveCount(3);
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await newPage.locator("button[type=submit]").click();
await newPage.waitForURL("/getting-started?from=signup");
await newPage.close();
await context.close();
// Check newly invited member is not pending anymore
await page.bringToFront();
await page.goto(`/settings/teams/${team.id}/members`);
await page.waitForLoadState("networkidle");
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
});
await test.step("To the team by invite link", async () => {
const user = await users.create({
email: `user-invite-${Date.now()}@domain.com`,
password: "P4ssw0rd!",
});
await page.locator(`button:text("${t("add")}")`).click();
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
const inviteLink = await clipboard.get();
await page.waitForLoadState("networkidle");
const context = await browser.newContext();
const inviteLinkPage = await context.newPage();
await inviteLinkPage.goto(inviteLink);
await inviteLinkPage.waitForLoadState("domcontentloaded");
await inviteLinkPage.locator("button[type=submit]").click();
await expect(inviteLinkPage.locator('[data-testid="field-error"]')).toHaveCount(2);
await inviteLinkPage.locator("input[name=email]").fill(user.email);
await inviteLinkPage.locator("input[name=password]").fill(user.username || "P4ssw0rd!");
await inviteLinkPage.locator("button[type=submit]").click();
await inviteLinkPage.waitForURL(`${WEBAPP_URL}/teams**`);
});
});
test("Invitation (verified)", async ({ browser, page, users, emails }) => {
const t = await localize("en");
const teamOwner = await users.create({ name: `team-owner-${Date.now()}` }, { hasTeam: true });
const { team } = await teamOwner.getFirstTeam();
await teamOwner.apiLogin();
await page.goto(`/settings/teams/${team.id}/members`);
await page.waitForLoadState("networkidle");
await test.step("To the organization by email (internal user)", async () => {
const invitedUserEmail = `rick@example.com`;
await page.locator(`button:text("${t("add")}")`).click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator(`button:text("${t("send_invite")}")`).click();
await page.waitForLoadState("networkidle");
await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${teamOwner.name} invited you to join the team ${team.name} on Cal.com`
);
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(1);
});
});
});

View File

@ -134,7 +134,7 @@ test.describe("Teams - NonOrg", () => {
// Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull();
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
@ -351,7 +351,7 @@ test.describe("Teams - Org", () => {
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await expect(page.locator('[data-testid="404-page"]')).toBeVisible();
await expect(page.locator("text=This page could not be found")).toBeVisible();
await doOnOrgDomain(
{
orgSlug: org.slug,
@ -370,7 +370,7 @@ test.describe("Teams - Org", () => {
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
// All the teammates should be in the booking
for (const teammate of teamMatesObj) {
for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) {
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
}
}
@ -412,7 +412,7 @@ test.describe("Teams - Org", () => {
// Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull();
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
});

View File

@ -103,12 +103,11 @@ test.describe("BOOKING_CREATED", async () => {
body.payload.videoCallData = dynamic;
body.payload.appsStatus = dynamic;
body.payload.metadata.videoCallUrl = dynamic;
expect(body).toMatchObject({
triggerEvent: "BOOKING_CREATED",
createdAt: "[redacted/dynamic]",
payload: {
type: "30 min",
type: "30-min",
title: "30 min between Nameless and Test Testson",
description: "",
additionalNotes: "",
@ -236,7 +235,7 @@ test.describe("BOOKING_REJECTED", async () => {
triggerEvent: "BOOKING_REJECTED",
createdAt: "[redacted/dynamic]",
payload: {
type: "Opt in",
type: "opt-in",
title: "Opt in between Nameless and Test Testson",
customInputs: {},
startTime: "[redacted/dynamic]",
@ -357,7 +356,7 @@ test.describe("BOOKING_REQUESTED", async () => {
triggerEvent: "BOOKING_REQUESTED",
createdAt: "[redacted/dynamic]",
payload: {
type: "Opt in",
type: "opt-in",
title: "Opt in between Nameless and Test Testson",
customInputs: {},
startTime: "[redacted/dynamic]",

View File

@ -17,7 +17,7 @@ test.describe("Workflow tests", () => {
async ({ page, users }) => {
const user = await users.create();
const [eventType] = user.eventTypes;
await user.login();
await user.apiLogin();
await page.goto(`/workflows`);
await page.click('[data-testid="create-button"]');

View File

@ -849,6 +849,7 @@
"next_step": "تخطي الخطوة",
"prev_step": "الخطوة السابقة",
"install": "تثبيت",
"install_paid_app": "اشتراك",
"installed": "تم التثبيت",
"active_install_one": "{{count}} تثبيت نشط",
"active_install_other": "{{count}} تثبيت نشط",
@ -1097,6 +1098,7 @@
"developer_documentation": "مستندات المطور",
"get_in_touch": "تواصل معنا",
"contact_support": "الاتصال بالدعم",
"community_support": "الدعم المجتمعي",
"feedback": "الملاحظات",
"submitted_feedback": "نشكرك على ملاحظاتك!",
"feedback_error": "حدث خطأ عند إرسال الملاحظات",
@ -2098,5 +2100,6 @@
"view_overlay_calendar_events": "طالع أحداث تقويمك لمنع التضارب بين الحجوزات.",
"lock_timezone_toggle_on_booking_page": "قفل المنطقة الزمنية في صفحة الحجز",
"description_lock_timezone_toggle_on_booking_page": "تقفل المنطقة الزمنية على صفحة الحجز، وهذا مفيد للأحداث وجهاً لوجه.",
"extensive_whitelabeling": "عملية انضمام ودعم هندسي مخصصين",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -268,6 +268,7 @@
"set_availability": "Nastavte, jak jste dostupní",
"availability_settings": "Nastavení dostupnosti",
"continue_without_calendar": "Pokračovat bez kalendáře",
"continue_with": "Pokračovat přes {{appName}}",
"connect_your_calendar": "Připojit svůj kalendář",
"connect_your_video_app": "Propojte své video aplikace",
"connect_your_video_app_instructions": "Propojte své video aplikace a používejte je ve svých typech událostí.",
@ -288,6 +289,8 @@
"when": "Kdy",
"where": "Kde",
"add_to_calendar": "Přidat do kalendáře",
"add_to_calendar_description": "Vyberte, kam se mají přidávat události, pokud jste zarezervováni.",
"add_events_to": "Přidat události do",
"add_another_calendar": "Přidat další kalendář",
"other": "Ostatní",
"email_sign_in_subject": "Váš přihlašovací odkaz pro {{appName}}",
@ -422,6 +425,7 @@
"booking_created": "Rezervace vytvořena",
"booking_rejected": "Rezervace byla zamítnuta",
"booking_requested": "Váš požadavek na rezervaci byl úspěšně odeslán",
"booking_payment_initiated": "Platba za rezervaci zahájena",
"meeting_ended": "Schůzka skončila",
"form_submitted": "Formulář byl odeslán",
"booking_paid": "Rezervace uhrazena",
@ -456,6 +460,7 @@
"no_event_types_have_been_setup": "Tento uživatel zatím nezaložil žádné typy událostí.",
"edit_logo": "Upravit logo",
"upload_a_logo": "Nahrát logo",
"upload_logo": "Nahrát logo",
"remove_logo": "Odstranit logo",
"enable": "Povolit",
"code": "Kód",
@ -568,6 +573,7 @@
"your_team_name": "Název vašeho týmu",
"team_updated_successfully": "Tým byl úspěšně aktualizován",
"your_team_updated_successfully": "Váš tým byl úspěšně aktualizován.",
"your_org_updated_successfully": "Vaše organizace byla aktualizována.",
"about": "O aplikaci",
"team_description": "Pár vět o vašem týmu. Objeví se na URL stránce vašeho týmu.",
"org_description": "Několik vět o vaší organizaci. Zobrazí se na adrese URL vaší organizace.",
@ -599,6 +605,7 @@
"hide_book_a_team_member": "Skrýt tlačítko Rezervovat člena týmu",
"hide_book_a_team_member_description": "Skrýt tlačítko Rezervovat člena týmu na veřejných stránkách.",
"danger_zone": "Nebezpečná zóna",
"account_deletion_cannot_be_undone": "Pozor. Odstranění účtu nelze vrátit.",
"back": "Zpět",
"cancel": "Zrušit",
"cancel_all_remaining": "Zrušit vše zbývající",
@ -688,6 +695,7 @@
"people": "Lidé",
"your_email": "Váš e-mail",
"change_avatar": "Změnit avatar",
"upload_avatar": "Nahrát avatar",
"language": "Jazyk",
"timezone": "Časová zóna",
"first_day_of_week": "První den v týdnu",
@ -778,6 +786,7 @@
"disable_guests": "Zakázat hosty",
"disable_guests_description": "Zakázat přidávání dalších hostů během rezervace.",
"private_link": "Vygenerovat soukromou adresu URL",
"enable_private_url": "Zapnout soukromou adresu URL",
"private_link_label": "Soukromý odkaz",
"private_link_hint": "Váš soukromý odkaz se po každém použití obnoví",
"copy_private_link": "Zkopírovat soukromý odkaz",
@ -840,6 +849,7 @@
"next_step": "Přeskočit krok",
"prev_step": "Předchozí krok",
"install": "Nainstalovat",
"install_paid_app": "Odebírat",
"installed": "Nainstalováno",
"active_install_one": "Aktivní instalace: {{count}}",
"active_install_other": "Aktivní instalace: {{count}}",
@ -1088,6 +1098,7 @@
"developer_documentation": "Dokumentace vývojáře",
"get_in_touch": "Kontaktujte nás",
"contact_support": "Kontaktujte podporu",
"community_support": "Podpora komunity",
"feedback": "Zpětná vazba",
"submitted_feedback": "Děkujeme za vaši zpětnou vazbu!",
"feedback_error": "Chyba při odesílání zpětné vazby",
@ -1213,6 +1224,7 @@
"organizer_name_variable": "Jméno organizátora",
"app_upgrade_description": "Pokud chcete použít tuto funkci, musíte provést aktualizaci na účet Pro.",
"invalid_number": "Neplatné telefonní číslo",
"invalid_url_error_message": "Neplatná adresa URL pro {{label}}. Příklad URL: {{sampleUrl}}",
"navigate": "Navigace",
"open": "Otevřít",
"close": "Zavřít",
@ -1276,6 +1288,7 @@
"personal_cal_url": "Moje osobní adresa URL {{appName}}",
"bio_hint": "Několik vět o vás. Obsah se zobrazí se na vaší osobní stránce URL.",
"user_has_no_bio": "Tento uživatel zatím nepřidal životopis.",
"bio": "Bio",
"delete_account_modal_title": "Odstranit účet",
"confirm_delete_account_modal": "Opravdu chcete odstranit svůj účet {{appName}}?",
"delete_my_account": "Odstranit můj účet",
@ -1286,6 +1299,7 @@
"select_calendars": "Vyberte kalendáře, u kterých chcete kontrolovat konflikty v zájmu prevence dvojích rezervací.",
"check_for_conflicts": "Zkontrolovat konflikty",
"view_recordings": "Zobrazit nahrávky",
"check_for_recordings": "Zkontrolovat nahrávky",
"adding_events_to": "Přidání událostí do:",
"follow_system_preferences": "Řídit se předvolbami systému",
"custom_brand_colors": "Vlastní barvy značky",
@ -1530,6 +1544,7 @@
"problem_registering_domain": "Při registraci subdomény došlo k problému, zkuste to prosím znovu nebo kontaktujte správce",
"team_publish": "Zveřejnit tým",
"number_text_notifications": "Telefonní číslo (SMS oznámení)",
"number_sms_notifications": "Telefonní číslo (SMS oznámení)",
"attendee_email_variable": "E-mail účastníka",
"attendee_email_info": "E-mail osoby provádějící rezervaci",
"kbar_search_placeholder": "Zadejte příkaz nebo vyhledejte...",
@ -1594,6 +1609,7 @@
"options": "Možnosti",
"enter_option": "Zadejte možnost {{index}}",
"add_an_option": "Přidat možnost",
"location_already_exists": "Toto místo již existuje. Vyberte prosím nové místo",
"radio": "Přepínač",
"google_meet_warning": "Abyste mohli používat službu Google Meet, musíte jako cílový kalendář nastavit Kalendář Google",
"individual": "Jedinec",
@ -1613,6 +1629,7 @@
"date_overrides_mark_all_day_unavailable_other": "Zadat nedostupnost ve vybraných datech",
"date_overrides_add_btn": "Přidat změnu",
"date_overrides_update_btn": "Aktualizovat změnu",
"date_successfully_added": "Přidána změna dnů",
"event_type_duplicate_copy_text": "{{slug}} kopie",
"set_as_default": "Nastavit jako výchozí",
"hide_eventtype_details": "Skrýt podrobnosti o typu události",
@ -1639,6 +1656,7 @@
"minimum_round_robin_hosts_count": "Počet hostitelů, kteří se musí zúčastnit",
"hosts": "Hostitelé",
"upgrade_to_enable_feature": "K povolení této funkce je třeba vytvořit tým. Tým vytvoříte kliknutím.",
"orgs_upgrade_to_enable_feature": "Pokud chcete zapnout tuto funkci, musíte upgradovat na náš tarif Enterprise.",
"new_attendee": "Nový účastník",
"awaiting_approval": "Čeká na schválení",
"requires_google_calendar": "Tato aplikace vyžaduje připojení ke Kalendáři Google",
@ -1743,6 +1761,7 @@
"show_on_booking_page": "Zobrazit na stránce rezervace",
"get_started_zapier_templates": "Začněte používat šablony Zapier",
"team_is_unpublished": "Tým {{team}} není zveřejněn",
"org_is_unpublished_description": "Tento odkaz organizace není v současné době k dispozici. Kontaktujte prosím vlastníka organizace nebo ho požádejte o jeho zveřejnění.",
"team_is_unpublished_description": "Tento odkaz subjektu ({{entity}}) není v současné době k dispozici. Kontaktujte prosím vlastníka subjektu ({{entity}}) nebo ho požádejte o jeho zveřejnění.",
"team_member": "Člen týmu",
"a_routing_form": "Směrovací formulář",
@ -1877,6 +1896,7 @@
"edit_invite_link": "Upravit nastavení odkazu",
"invite_link_copied": "Odkaz pozvánky byl zkopírován",
"invite_link_deleted": "Odkaz pozvánky byl odstraněn",
"api_key_deleted": "Klíč API odstraněn",
"invite_link_updated": "Nastavení odkazu pozvánky bylo uloženo",
"link_expires_after": "Platnost odkazů vyprší za...",
"one_day": "1 den",
@ -2009,7 +2029,13 @@
"attendee_last_name_variable": "Příjmení účastníka",
"attendee_first_name_info": "Jméno rezervující osoby",
"attendee_last_name_info": "Příjmení rezervující osoby",
"your_monthly_digest": "Váš měsíční přehled",
"member_name": "Jméno člena",
"most_popular_events": "Nejoblíbenější události",
"summary_of_events_for_your_team_for_the_last_30_days": "Zde je přehled oblíbených událostí vašeho týmu {{teamName}} za posledních 30 dní",
"me": "Já",
"monthly_digest_email": "E-mail s měsíčním přehledem",
"monthly_digest_email_for_teams": "Měsíční e-mail s přehledem pro týmy",
"verify_team_tooltip": "Proveďte ověření svého týmu a zapněte odesílání zpráv účastníkům",
"member_removed": "Člen odstraněn",
"my_availability": "Moje dostupnost",
@ -2039,12 +2065,41 @@
"team_no_event_types": "Tento tým nemá žádné typy událostí",
"seat_options_doesnt_multiple_durations": "Volba místa nepodporuje více dob trvání",
"include_calendar_event": "Zahrnout událost kalendáře",
"oAuth": "OAuth",
"recently_added": "Nedávno přidáno",
"no_members_found": "Nenalezeni žádní členové",
"event_setup_length_error": "Nastavení události: Doba trvání musí být alespoň 1 minuta.",
"availability_schedules": "Plány dostupnosti",
"unauthorized": "Neautorizováno",
"access_cal_account": "{{clientName}} žádá o přístup k vašemu účtu {{appName}}",
"select_account_team": "Vyberte účet nebo tým",
"allow_client_to": "To umožní klientovi {{clientName}}",
"associate_with_cal_account": "Přiřadit vám vaše osobní údaje z klienta {{clientName}}",
"see_personal_info": "Zobrazit vaše osobní údaje, včetně všech osobních údajů, které jste zveřejnili",
"see_primary_email_address": "Zobrazit vaši primární e-mailovou adresu",
"connect_installed_apps": "Připojit se k vašim nainstalovaným aplikacím",
"access_event_type": "Číst, upravovat a odstraňovat vaše typy událostí",
"access_availability": "Číst, upravovat a odstraňovat vaši dostupnost",
"access_bookings": "Číst, upravovat a odstraňovat rezervace",
"allow_client_to_do": "Chcete povolit klientovi {{clientName}} provádět uvedené akce?",
"oauth_access_information": "Kliknutím na tlačítko Povolit této aplikaci umožníte používat vaše údaje v souladu s podmínkami služby a zásadami ochrany osobních údajů. Přístup k aplikaci {{appName}} můžete odebrat přes App Store.",
"allow": "Povolit",
"view_only_edit_availability_not_onboarded": "Tento uživatel ještě nedokončil onboarding. Dokud nedokončí onboarding, dostupnost nebude možné nastavit.",
"view_only_edit_availability": "Právě máte zobrazenou dostupnost tohoto uživatele. Upravovat lze pouze vlastní dostupnost.",
"you_can_override_calendar_in_advanced_tab": "Tuto možnost můžete zrušit pro každou událost zvlášť v pokročilém nastavení jednotlivých typů událostí.",
"edit_users_availability": "Upravte dostupnost uživatele: {{username}}",
"resend_invitation": "Znovu odeslat pozvánku",
"invitation_resent": "Pozvánka byla odeslána znovu.",
"add_client": "Přidat klienta",
"copy_client_secret_info": "Po zkopírování již nebude možné tajný klíč zobrazit",
"add_new_client": "Přidat nového klienta",
"this_app_is_not_setup_already": "Tato aplikace ještě nebyla nastavena",
"as_csv": "jako CSV",
"overlay_my_calendar": "Překryv mého kalendáře",
"overlay_my_calendar_toc": "Připojením ke svému kalendáři přijímáte naše zásady ochrany osobních údajů a podmínky používání. Přístup můžete kdykoli odvolat.",
"view_overlay_calendar_events": "Zobrazení událostí v kalendáři, aby se zabránilo kolidujícím rezervacím.",
"lock_timezone_toggle_on_booking_page": "Uzamčení časového pásma na stránce rezervace",
"description_lock_timezone_toggle_on_booking_page": "Uzamčení časového pásma na stránce rezervace (užitečné pro osobní události).",
"extensive_whitelabeling": "Vyhrazená podpora zaškolovací a inženýrská podpora",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -717,6 +717,7 @@
"next_step": "Spring trin over",
"prev_step": "Forrige trin",
"install": "Installér",
"install_paid_app": "Abonnér",
"installed": "Installeret",
"active_install_one": "{{count}} aktiv installation",
"active_install_other": "{{count}} aktive installationer",

View File

@ -268,6 +268,7 @@
"set_availability": "Verfügbarkeit festlegen",
"availability_settings": "Verfügbarkeitseinstellungen",
"continue_without_calendar": "Ohne Kalender fortfahren",
"continue_with": "Mit {{appName}} fortfahren",
"connect_your_calendar": "Kalender verbinden",
"connect_your_video_app": "Verbinden Sie Ihre Video-Apps",
"connect_your_video_app_instructions": "Verbinden Sie Ihre Video-Apps, um sie für Ihre Termintypen zu verwenden.",
@ -289,6 +290,7 @@
"where": "Wo",
"add_to_calendar": "Zum Kalender hinzufügen",
"add_to_calendar_description": "Legen Sie fest, wo neue Termine hinzugefügt werden sollen, wenn Sie gebucht werden.",
"add_events_to": "Termine hinzufügen zu",
"add_another_calendar": "Einen weiteren Kalender hinzufügen",
"other": "Sonstige",
"email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}",
@ -423,6 +425,7 @@
"booking_created": "Termin erstellt",
"booking_rejected": "Termin abgelehnt",
"booking_requested": "Buchung angefragt",
"booking_payment_initiated": "Buchungszahlung eingeleitet",
"meeting_ended": "Meeting beendet",
"form_submitted": "Formular gesendet",
"booking_paid": "Buchung bezahlt",
@ -457,6 +460,7 @@
"no_event_types_have_been_setup": "Dieser Benutzer hat noch keine Termintypen eingerichtet.",
"edit_logo": "Logo bearbeiten",
"upload_a_logo": "Logo hochladen",
"upload_logo": "Logo hochladen",
"remove_logo": "Logos entfernen",
"enable": "Aktivieren",
"code": "Code",
@ -569,6 +573,7 @@
"your_team_name": "Ihr Teamname",
"team_updated_successfully": "Team erfolgreich aktualisiert",
"your_team_updated_successfully": "Ihr Team wurde erfolgreich aktualisiert.",
"your_org_updated_successfully": "Ihre Org wurde erfolgreich aktualisiert.",
"about": "Beschreibung",
"team_description": "Ein paar Sätze über Ihr Team auf der öffentlichen Teamseite.",
"org_description": "Ein paar Sätze zu Ihrer Organisation. Dies wird auf der URL-Seite Ihrer Organisation erscheinen.",
@ -781,6 +786,7 @@
"disable_guests": "Gäste deaktivieren",
"disable_guests_description": "Das Hinzufügen zusätzlicher Gäste deaktivieren.",
"private_link": "Privaten Link generieren",
"enable_private_url": "Private URL aktivieren",
"private_link_label": "Privater Link",
"private_link_hint": "Ihr privater Link wird nach jeder Nutzung neu generiert",
"copy_private_link": "Privaten Link kopieren",
@ -843,6 +849,7 @@
"next_step": "Schritt überspringen",
"prev_step": "Vorheriger Schritt",
"install": "Installieren",
"install_paid_app": "Abonnieren",
"installed": "Installiert",
"active_install_one": "{{count}} aktive Installation",
"active_install_other": "{{count}} aktive Installationen",
@ -1091,6 +1098,7 @@
"developer_documentation": "Entwickler-Dokumentation",
"get_in_touch": "Kontakt aufnehmen",
"contact_support": "Support kontaktieren",
"community_support": "Community Support",
"feedback": "Feedback",
"submitted_feedback": "Vielen Dank für Ihr Feedback!",
"feedback_error": "Fehler beim Senden des Feedbacks",
@ -1216,6 +1224,7 @@
"organizer_name_variable": "Organisator Name",
"app_upgrade_description": "Um diese Funktion nutzen zu können, müssen Sie ein Upgrade auf einen Pro-Account durchführen.",
"invalid_number": "Ungültige Telefonnummer",
"invalid_url_error_message": "Ungültige URL für {{label}}. Beispiel-URL: {{sampleUrl}}",
"navigate": "Navigieren",
"open": "Öffnen",
"close": "Schließen",
@ -1290,6 +1299,7 @@
"select_calendars": "Wählen Sie aus, in welchen Kalendern Sie nach Konflikten suchen wollen, um Doppelbuchungen zu vermeiden.",
"check_for_conflicts": "Auf Konflikte prüfen",
"view_recordings": "Aufnahmen anzeigen",
"check_for_recordings": "Nach Aufnahmen suchen",
"adding_events_to": "Termine hinzufügen zu",
"follow_system_preferences": "Systemeinstellungen folgen",
"custom_brand_colors": "Eigene Marken-Farben",
@ -1599,6 +1609,7 @@
"options": "Optionen",
"enter_option": "Option {{index}} eingeben",
"add_an_option": "Option hinzufügen",
"location_already_exists": "Dieser Standort existiert bereits. Bitte wählen Sie einen neuen Standort",
"radio": "Radio",
"google_meet_warning": "Um Google Meet nutzen zu können, müssen Sie Ihren Zielkalender zu einem Google Calendar ändern",
"individual": "Person",
@ -1618,6 +1629,7 @@
"date_overrides_mark_all_day_unavailable_other": "An ausgewählten Daten als „nicht verfügbar“ markieren",
"date_overrides_add_btn": "Überschreibung hinzufügen",
"date_overrides_update_btn": "Überschreiben aktualisieren",
"date_successfully_added": "Datumsüberbrückung erfolgreich hinzugefügt",
"event_type_duplicate_copy_text": "{{slug}}-Kopie",
"set_as_default": "Als Standard festlegen",
"hide_eventtype_details": "Ereignistyp-Einzelheiten ausblenden",
@ -2062,6 +2074,7 @@
"access_cal_account": "{{clientName}} möchte auf Ihr {{appName}} Konto zugreifen",
"select_account_team": "Konto oder Team auswählen",
"allow_client_to": "Dies wird {{clientName}} erlauben",
"associate_with_cal_account": "Verknüpfen Sie sich mit Ihren persönlichen Daten von {{clientName}}",
"see_personal_info": "Ihre persönlichen Daten einzusehen, einschließlich persönlicher Informationen, die Sie öffentlich zugänglich gemacht haben",
"see_primary_email_address": "Ihre primäre E-Mail-Adresse einzusehen",
"connect_installed_apps": "Sich mit Ihren installierten Apps zu verbinden",
@ -2069,12 +2082,24 @@
"access_availability": "Lesen, Bearbeiten, Löschen Ihrer Verfügbarkeiten",
"access_bookings": "Lesen, Bearbeiten, Löschen Ihrer Termine",
"allow_client_to_do": "{{clientName}} zulassen, dies zu tun?",
"oauth_access_information": "Indem Sie auf „Erlauben“ klicken, erlauben Sie dieser App, Ihre Informationen gemäß ihrer Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können den Zugriff im {{appName}} App Store aufheben.",
"allow": "Zulassen",
"view_only_edit_availability_not_onboarded": "Dieser Benutzer hat das Onboarding noch nicht abgeschlossen. Sie können seine Verfügbarkeit erst festlegen, wenn er das Onboarding abgeschlossen hat.",
"view_only_edit_availability": "Sie sehen die Verfügbarkeit dieses Benutzers. Sie können nur Ihre eigene Verfügbarkeit bearbeiten.",
"you_can_override_calendar_in_advanced_tab": "Sie können dies in den erweiterten Einstellungen pro Termin für jeden Termintyp überschreiben.",
"edit_users_availability": "Benutzerverfügbarkeit bearbeiten: {{username}}",
"resend_invitation": "Einladung erneut senden",
"invitation_resent": "Die Einladung wurde erneut gesendet.",
"add_client": "Kunde hinzufügen",
"copy_client_secret_info": "Nach dem Kopieren des Geheimnisses können Sie es nicht mehr ansehen",
"add_new_client": "Neuen Kunden hinzufügen",
"this_app_is_not_setup_already": "Diese App wurde noch nicht eingerichtet",
"as_csv": "als CSV",
"overlay_my_calendar": "Meinen Kalender überlagern",
"overlay_my_calendar_toc": "Durch das Verbinden mit Ihrem Kalender akzeptieren Sie unsere Datenschutzerklärung und Nutzungsbedingungen. Sie können den Zugriff jederzeit widerrufen.",
"view_overlay_calendar_events": "Sehen Sie sich Ihre Kalendertermine an, um Buchungskonflikte zu vermeiden.",
"lock_timezone_toggle_on_booking_page": "Zeitzone auf der Buchungsseite sperren",
"description_lock_timezone_toggle_on_booking_page": "Um die Zeitzone auf der Buchungsseite zu sperren, nützlich für Termine in Person.",
"extensive_whitelabeling": "Dedizierte Onboarding- und Engineeringsupport",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -56,6 +56,17 @@
"a_refund_failed": "A refund failed",
"awaiting_payment_subject": "Awaiting Payment: {{title}} on {{date}}",
"meeting_awaiting_payment": "Your meeting is awaiting payment",
"dark_theme_contrast_error":"Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.",
"light_theme_contrast_error":"Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.",
"payment_not_created_error": "Payment could not be created",
"couldnt_charge_card_error": "Could not charge card for Payment",
"no_available_users_found_error": "No available users found. Could you try another time slot?",
"request_body_end_time_internal_error": "Internal Error. Request body does not contain end time",
"create_calendar_event_error": "Unable to create Calendar event in Organizer's calendar",
"update_calendar_event_error": "Unable to update Calendar event.",
"delete_calendar_event_error": "Unable to delete Calendar event.",
"already_signed_up_for_this_booking_error": "You are already signed up for this booking.",
"hosts_unavailable_for_booking": "Some of the hosts are unavailable for booking.",
"help": "Help",
"price": "Price",
"paid": "Paid",
@ -606,6 +617,7 @@
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
"danger_zone": "Danger zone",
"account_deletion_cannot_be_undone":"Be Careful. Account deletion cannot be undone.",
"team_deletion_cannot_be_undone":"Be Careful. Team deletion cannot be undone",
"back": "Back",
"cancel": "Cancel",
"cancel_all_remaining": "Cancel all remaining",
@ -849,6 +861,8 @@
"next_step": "Skip step",
"prev_step": "Prev step",
"install": "Install",
"install_paid_app": "Subscribe",
"start_paid_trial": "Start free Trial",
"installed": "Installed",
"active_install_one": "{{count}} active install",
"active_install_other": "{{count}} active installs",
@ -1097,6 +1111,8 @@
"developer_documentation": "Developer Documentation",
"get_in_touch": "Get in touch",
"contact_support": "Contact Support",
"premium_support": "Premium Support",
"community_support": "Community Support",
"feedback": "Feedback",
"submitted_feedback": "Thank you for your feedback!",
"feedback_error": "Error sending feedback",
@ -1361,6 +1377,7 @@
"event_name_info": "The event type name",
"event_date_info": "The event date",
"event_time_info": "The event start time",
"event_type_not_found": "EventType not Found",
"location_info": "The location of the event",
"additional_notes_info": "The additional notes of booking",
"attendee_name_info": "The person booking's name",
@ -1401,6 +1418,7 @@
"slot_length": "Slot length",
"booking_appearance": "Booking Appearance",
"appearance_team_description": "Manage settings for your team's booking appearance",
"appearance_org_description": "Manage settings for your organization's booking appearance",
"only_owner_change": "Only the owner of this team can make changes to the team's booking ",
"team_disable_cal_branding_description": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}'",
"invited_by_team": "{{teamName}} has invited you to join their team as a {{role}}",
@ -2096,7 +2114,28 @@
"overlay_my_calendar":"Overlay my calendar",
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
"troubleshooting":"Troubleshooting",
"calendars_were_checking_for_conflicts":"Calendars were checking for conflicts",
"availabilty_schedules":"Availability schedules",
"manage_calendars":"Manage calendars",
"manage_availability_schedules":"Manage availability schedules",
"lock_timezone_toggle_on_booking_page": "Lock timezone on booking page",
"description_lock_timezone_toggle_on_booking_page" : "To lock the timezone on booking page, useful for in-person events.",
"install_calendar":"Install Calendar",
"branded_subdomain": "Branded Subdomain",
"branded_subdomain_description": "Get your own branded subdomain, such as acme.cal.com",
"org_insights": "Organization-wide Insights",
"org_insights_description": "Understand how your entire organization is spending time",
"extensive_whitelabeling": "Extensive Whitelabeling",
"extensive_whitelabeling_description": "Customize your scheduling experience with your own logo, colors, and more",
"unlimited_teams": "Unlimited Teams",
"unlimited_teams_description": "Add as many subteams as you need to your organization",
"unified_billing": "Unified Billing",
"unified_billing_description": "Add a single credit card to pay for all your team's subscriptions",
"advanced_managed_events": "Advanced Managed Event Types",
"advanced_managed_events_description": "Add a single credit card to pay for all your team's subscriptions",
"enterprise_description": "Upgrade to Enterprise to create your Organization",
"create_your_org": "Create your Organization",
"create_your_org_description": "Upgrade to Enterprise and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -268,6 +268,7 @@
"set_availability": "Establecer Disponibilidad",
"availability_settings": "Configuración de disponibilidad",
"continue_without_calendar": "Continuar sin calendario",
"continue_with": "Continuar con {{appName}}",
"connect_your_calendar": "Conecta tu calendario",
"connect_your_video_app": "Conecte sus aplicaciones favoritas",
"connect_your_video_app_instructions": "Conecte sus aplicaciones de vídeo para usarlas en sus tipos de eventos.",
@ -288,6 +289,8 @@
"when": "Cuándo",
"where": "Donde",
"add_to_calendar": "Añadir al Calendario",
"add_to_calendar_description": "Seleccione dónde se añadirán los eventos cuando haya reservado.",
"add_events_to": "Agregar eventos a",
"add_another_calendar": "Añadir otro calendario",
"other": "Otro",
"email_sign_in_subject": "Su enlace de inicio de sesión para {{appName}}",
@ -422,6 +425,7 @@
"booking_created": "Reserva Creada",
"booking_rejected": "Reserva rechazada",
"booking_requested": "Reserva solicitada",
"booking_payment_initiated": "Pago de la reserva iniciado",
"meeting_ended": "Reunión finalizada",
"form_submitted": "Formulario enviado",
"booking_paid": "Reserva pagada",
@ -456,6 +460,7 @@
"no_event_types_have_been_setup": "Este usuario aún no ha configurado ningún tipo de evento.",
"edit_logo": "Cambiar la marca",
"upload_a_logo": "Subir una marca",
"upload_logo": "Cargar logo",
"remove_logo": "Quitar logo",
"enable": "Habilitar",
"code": "Código",
@ -568,6 +573,7 @@
"your_team_name": "Nombre de tu Equipo",
"team_updated_successfully": "Equipo Actualizado Correctamente",
"your_team_updated_successfully": "Tu equipo ha sido actualizado con éxito.",
"your_org_updated_successfully": "Su organización se ha actualizado correctamente.",
"about": "Acerca de",
"team_description": "Comentarios sobre tu equipo. Esta información aparecerá en la página de la URL de tu equipo.",
"org_description": "Algunas frases sobre su organización. Esto aparecerá en la página de la URL de su organización.",
@ -599,6 +605,7 @@
"hide_book_a_team_member": "Ocultar el botón Reservar un miembro del equipo",
"hide_book_a_team_member_description": "Ocultar el botón Reservar un miembro del equipo de sus páginas públicas.",
"danger_zone": "Paso definitivo",
"account_deletion_cannot_be_undone": "Tenga cuidado. La eliminación de la cuenta no se puede deshacer.",
"back": "Atrás",
"cancel": "Cancelar",
"cancel_all_remaining": "Cancelar todos los restantes",
@ -688,6 +695,7 @@
"people": "Personas",
"your_email": "Tu Email",
"change_avatar": "Cambiar Avatar",
"upload_avatar": "Cargar avatar",
"language": "Lenguaje",
"timezone": "Zona Horaria",
"first_day_of_week": "Primer dia de la semana",
@ -778,6 +786,7 @@
"disable_guests": "Desactivar Invitados",
"disable_guests_description": "Desactiva agregar invitados adicionales al hacer la reserva.",
"private_link": "Generar una URL privada",
"enable_private_url": "Activar URL privada",
"private_link_label": "Enlace privado",
"private_link_hint": "Su enlace privado se regenera después de cada uso",
"copy_private_link": "Copiar enlace privado",
@ -840,6 +849,7 @@
"next_step": "Saltar paso",
"prev_step": "Paso anterior",
"install": "Instalar",
"install_paid_app": "Suscribirse",
"installed": "Instalado",
"active_install_one": "{{count}} instalación activa",
"active_install_other": "{{count}} instalaciones activas",
@ -1088,6 +1098,7 @@
"developer_documentation": "Documentación del desarrollador",
"get_in_touch": "Póngase en contacto",
"contact_support": "Contactar con Soporte",
"community_support": "Soporte comunitario",
"feedback": "Comentarios",
"submitted_feedback": "¡Gracias por sus comentarios!",
"feedback_error": "Error al enviar comentarios",
@ -1213,6 +1224,7 @@
"organizer_name_variable": "Nombre del organizador",
"app_upgrade_description": "Para poder usar esta función, necesita actualizarse a una cuenta Pro.",
"invalid_number": "Número de teléfono no válido",
"invalid_url_error_message": "URL no válida para {{label}}. URL de ejemplo: {{sampleUrl}}",
"navigate": "Navegar",
"open": "Abrir",
"close": "Cerrar",
@ -1276,6 +1288,7 @@
"personal_cal_url": "Mi URL personal de {{appName}}",
"bio_hint": "Algunas frases sobre usted, esta información aparecerá en la página de su URL personal.",
"user_has_no_bio": "Este usuario no ha añadido una biografía todavía.",
"bio": "Biografía",
"delete_account_modal_title": "Eliminar cuenta",
"confirm_delete_account_modal": "¿Está seguro que desea eliminar su cuenta de {{appName}}?",
"delete_my_account": "Eliminar mi cuenta",
@ -1286,6 +1299,7 @@
"select_calendars": "Seleccione los calendarios en los que desee comprobar conflictos para evitar reservas dobles.",
"check_for_conflicts": "Comprobar conflictos",
"view_recordings": "Ver grabaciones",
"check_for_recordings": "Comprobar si hay grabaciones",
"adding_events_to": "Agregando eventos a",
"follow_system_preferences": "Siga las preferencias del sistema",
"custom_brand_colors": "Colores de marca personalizados",
@ -1530,6 +1544,7 @@
"problem_registering_domain": "Hubo un problema con el registro del subdominio, intente nuevamente o comuníquese con un administrador",
"team_publish": "Publicar equipo",
"number_text_notifications": "Número de teléfono (Notificaciones de texto)",
"number_sms_notifications": "Número de teléfono (notificaciones SMS)",
"attendee_email_variable": "Correo electrónico del asistente",
"attendee_email_info": "El correo electrónico de la persona que reserva",
"kbar_search_placeholder": "Escriba un comando o búsqueda...",
@ -1594,6 +1609,7 @@
"options": "Opciones",
"enter_option": "Introduzca la opción {{index}}",
"add_an_option": "Agregue una opción",
"location_already_exists": "Esta ubicación ya existe. Seleccione una ubicación nueva",
"radio": "Botón radial",
"google_meet_warning": "Para usar Google Meet, debe establecer un Google Calendar como calendario de destino",
"individual": "Individuo",
@ -1613,6 +1629,7 @@
"date_overrides_mark_all_day_unavailable_other": "Marcar como no disponible en las fechas seleccionadas",
"date_overrides_add_btn": "Agregar anulación",
"date_overrides_update_btn": "Actualizar anulación",
"date_successfully_added": "Sustitución de fechas añadida correctamente",
"event_type_duplicate_copy_text": "{{slug}}-copia",
"set_as_default": "Establecer como predeterminado",
"hide_eventtype_details": "Ocultar detalles del tipo de evento",
@ -1639,6 +1656,7 @@
"minimum_round_robin_hosts_count": "Número de anfitriones requeridos para asistir",
"hosts": "Anfitriones",
"upgrade_to_enable_feature": "Debe crear un equipo para activar esta función. Haga clic para crear un equipo.",
"orgs_upgrade_to_enable_feature": "Debe pasarse a nuestro plan Enterprise para habilitar esta función.",
"new_attendee": "Nuevo asistente",
"awaiting_approval": "En espera de aprobación",
"requires_google_calendar": "Esta aplicación requiere una conexión con Google Calendar",
@ -1743,6 +1761,7 @@
"show_on_booking_page": "Mostrar en la página de reserva",
"get_started_zapier_templates": "Comience con las plantillas de Zapier",
"team_is_unpublished": "{{team}} no está publicado",
"org_is_unpublished_description": "El enlace de esta organización no está disponible actualmente. Comuníquese con el propietario de la organización o pídale que lo publique.",
"team_is_unpublished_description": "Este enlace de {{entity}} no está disponible actualmente. Póngase en contacto con el propietario de {{entity}} o pídale que lo publique.",
"team_member": "Miembro del equipo",
"a_routing_form": "Un formulario de enrutamiento",
@ -1877,6 +1896,7 @@
"edit_invite_link": "Editar ajustes de enlace",
"invite_link_copied": "Enlace de invitación copiado",
"invite_link_deleted": "Enlace de invitación eliminado",
"api_key_deleted": "Clave API eliminada",
"invite_link_updated": "Configuración de enlace de invitación guardada",
"link_expires_after": "Enlaces establecidos para expirar después de...",
"one_day": "1 día",
@ -2009,7 +2029,13 @@
"attendee_last_name_variable": "Apellido del asistente",
"attendee_first_name_info": "Nombre de la persona que reserva",
"attendee_last_name_info": "Apellido de la persona que reserva",
"your_monthly_digest": "Su resumen mensual",
"member_name": "Nombre del miembro",
"most_popular_events": "Eventos más populares",
"summary_of_events_for_your_team_for_the_last_30_days": "Este es el resumen de los eventos populares de su equipo {{teamName}} durante los últimos 30 días",
"me": "Yo",
"monthly_digest_email": "Correo electrónico del resumen mensual",
"monthly_digest_email_for_teams": "Correo electrónico de resumen mensual para equipos",
"verify_team_tooltip": "Verifique su equipo para activar el envío de mensajes a los asistentes",
"member_removed": "Miembro eliminado",
"my_availability": "Mi disponibilidad",
@ -2039,12 +2065,41 @@
"team_no_event_types": "Este equipo no tiene tipos de eventos",
"seat_options_doesnt_multiple_durations": "La opción Cupo no soporta múltiples duraciones",
"include_calendar_event": "Incluir evento del calendario",
"oAuth": "OAuth",
"recently_added": "Añadido recientemente",
"no_members_found": "No se encontraron miembros",
"event_setup_length_error": "Configuración del evento: la duración debe ser de al menos 1 minuto.",
"availability_schedules": "Horarios de disponibilidad",
"unauthorized": "Sin autorización",
"access_cal_account": "{{clientName}} quiere acceder a su cuenta de {{appName}}",
"select_account_team": "Seleccionar cuenta o equipo",
"allow_client_to": "Esto permitirá que {{clientName}}",
"associate_with_cal_account": "Lo asocie con su información personal de {{clientName}}",
"see_personal_info": "Consulte su información personal, incluida la información personal que haya hecho pública",
"see_primary_email_address": "Consulte su dirección de correo electrónico principal",
"connect_installed_apps": "Se conecte a sus aplicaciones instaladas",
"access_event_type": "Lea, edite y elimine sus tipos de eventos",
"access_availability": "Lea, edite y elimine su disponibilidad",
"access_bookings": "Lea, edite y elimine sus reservas",
"allow_client_to_do": "¿Desea permitir que {{clientName}} haga esto?",
"oauth_access_information": "Al hacer clic en permitir, permite que esta aplicación utilice su información de acuerdo con sus términos de servicio y política de privacidad. Puede eliminar el acceso en la App Store de {{appName}}.",
"allow": "Permitir",
"view_only_edit_availability_not_onboarded": "Este usuario no ha completado la incorporación. No podrá establecer su disponibilidad hasta que haya completado la incorporación.",
"view_only_edit_availability": "Está viendo la disponibilidad de este usuario. Sólo puede editar su propia disponibilidad.",
"you_can_override_calendar_in_advanced_tab": "Puede anular esto por evento en la Configuración avanzada de cada tipo de evento.",
"edit_users_availability": "Editar disponibilidad del usuario: {{username}}",
"resend_invitation": "Reenviar invitación",
"invitation_resent": "Se reenvió la invitación.",
"add_client": "Agregar cliente",
"copy_client_secret_info": "Después de copiar el secreto, ya no podrá volver a verlo",
"add_new_client": "Agregar nuevo cliente",
"this_app_is_not_setup_already": "Esta aplicación aún no se ha configurado",
"as_csv": "como CSV",
"overlay_my_calendar": "Superponer mi calendario",
"overlay_my_calendar_toc": "Al conectarse a su calendario, acepta nuestra política de privacidad y nuestros términos de uso. Puede revocar el acceso en cualquier momento.",
"view_overlay_calendar_events": "Consulte los eventos de su calendario para evitar el conflicto de reservas.",
"lock_timezone_toggle_on_booking_page": "Bloquear la zona horaria en la página de reserva",
"description_lock_timezone_toggle_on_booking_page": "Bloquear la zona horaria en la página de reserva, es útil para eventos en persona.",
"extensive_whitelabeling": "Asistencia dedicada en materia de incorporación e ingeniería",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agregue sus nuevas cadenas arriba ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -506,6 +506,7 @@
"next_step": "Saltatu pausoa",
"prev_step": "Aurreko pausoa",
"install": "Instalatu",
"install_paid_app": "Harpidetu",
"installed": "Instalatua",
"disconnect": "Deskonektatu",
"automation": "Automatizazioa",

View File

@ -289,6 +289,8 @@
"when": "Quand",
"where": "Où",
"add_to_calendar": "Ajouter au calendrier",
"add_to_calendar_description": "Sélectionnez l'endroit où ajouter des événements lorsque vous êtes réservé.",
"add_events_to": "Ajouter les événements à",
"add_another_calendar": "Ajouter un autre calendrier",
"other": "Autre",
"email_sign_in_subject": "Votre lien de connexion pour {{appName}}",
@ -407,7 +409,7 @@
"automatically_adjust_theme": "Ajuster automatiquement l'apparence en fonction des préférences de l'invité",
"user_dynamic_booking_disabled": "Certains utilisateurs du groupe ont actuellement désactivé les réservations de groupe dynamiques",
"allow_dynamic_booking_tooltip": "Les liens de réservation de groupe peuvent être créés dynamiquement en ajoutant plusieurs noms d'utilisateur séparés par un « + ». Exemple : « {{appName}}/bailey+peer ».",
"allow_dynamic_booking": "Autoriser les participants à prendre rendez-vous avec vous via des réservations de groupe dynamiques",
"allow_dynamic_booking": "Autorisez les participants à prendre rendez-vous avec vous via des réservations de groupe dynamiques.",
"dynamic_booking": "Liens de groupe dynamiques",
"allow_seo_indexing": "Autorisez les moteurs de recherche à accéder à votre contenu public.",
"seo_indexing": "Autoriser l'indexation SEO",
@ -450,13 +452,14 @@
"go_to_billing_portal": "Accéder au portail de facturation",
"need_anything_else": "Besoin d'autre chose ?",
"further_billing_help": "Si vous avez besoin d'aide pour la facturation, notre équipe d'assistance est là pour vous aider.",
"contact": "Contact",
"contact": "Contacter",
"our_support_team": "notre équipe d'assistance",
"contact_our_support_team": "Contactez notre équipe d'assistance",
"uh_oh": "Oups !",
"no_event_types_have_been_setup": "Cet utilisateur n'a pas encore configuré de type d'événement.",
"edit_logo": "Modifier le logo",
"upload_a_logo": "Télécharger un logo",
"upload_logo": "Télécharger un logo",
"remove_logo": "Supprimer le logo",
"enable": "Activer",
"code": "Code",
@ -569,6 +572,7 @@
"your_team_name": "Nom de votre équipe",
"team_updated_successfully": "Équipe mise à jour avec succès",
"your_team_updated_successfully": "Votre équipe a été mise à jour avec succès.",
"your_org_updated_successfully": "Votre organisation a été mise à jour avec succès.",
"about": "À propos",
"team_description": "Quelques mots à propos de votre équipe. Ces informations apparaîtront sur la page publique de votre équipe.",
"org_description": "Quelques phrases à propos de votre organisation. Elles apparaîtront sur la page de profil public de votre organisation.",
@ -844,6 +848,8 @@
"next_step": "Passer l'étape",
"prev_step": "Étape précédente",
"install": "Installer",
"install_paid_app": "S'abonner",
"start_paid_trial": "Démarrer l'essai gratuit",
"installed": "Installée",
"active_install_one": "{{count}} installation active",
"active_install_other": "{{count}} installations actives",
@ -1092,6 +1098,8 @@
"developer_documentation": "Documentation pour développeurs",
"get_in_touch": "Contactez-nous",
"contact_support": "Contacter l'assistance",
"premium_support": "Assistance Premium",
"community_support": "Aide communautaire",
"feedback": "Commentaires",
"submitted_feedback": "Merci pour vos commentaires !",
"feedback_error": "Erreur lors de l'envoi du commentaire",
@ -2024,7 +2032,7 @@
"summary_of_events_for_your_team_for_the_last_30_days": "Voici votre résumé des événements populaires pour votre équipe {{teamName}} au cours des 30 derniers jours",
"me": "Moi",
"monthly_digest_email": "E-mail de résumé mensuel",
"monthly_digest_email_for_teams": "E-mail de résumé mensuel pour les équipes",
"monthly_digest_email_for_teams": "E-mail de résumé mensuel pour les équipes.",
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
"member_removed": "Membre supprimé",
"my_availability": "Mes disponibilités",
@ -2065,11 +2073,19 @@
"access_bookings": "Lire, modifier, supprimer vos réservations",
"allow_client_to_do": "Autoriser {{clientName}} à faire cela ?",
"allow": "Autoriser",
"you_can_override_calendar_in_advanced_tab": "Vous pouvez modifier ceci pour chaque événement dans les paramètres avancés de chaque type d'événement.",
"edit_users_availability": "Modifier la disponibilité de l'utilisateur : {{username}}",
"resend_invitation": "Renvoyer l'invitation",
"invitation_resent": "L'invitation a été renvoyée.",
"add_client": "Ajouter un client",
"add_new_client": "Ajouter un nouveau client",
"as_csv": "au format CSV",
"overlay_my_calendar": "Superposer mon calendrier",
"overlay_my_calendar_toc": "En vous connectant à votre calendrier, vous acceptez notre politique de confidentialité et nos conditions d'utilisation. Vous pouvez révoquer cet accès à tout moment.",
"view_overlay_calendar_events": "Consultez les événements de votre calendrier afin d'éviter les réservations incompatibles.",
"lock_timezone_toggle_on_booking_page": "Verrouiller le fuseau horaire sur la page de réservation",
"description_lock_timezone_toggle_on_booking_page": "Pour verrouiller le fuseau horaire sur la page de réservation, utile pour les événements en personne.",
"extensive_whitelabeling": "Marque blanche étendue",
"unlimited_teams": "Équipes illimitées",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -268,6 +268,7 @@
"set_availability": "ציין את הזמינות שלך",
"availability_settings": "הגדרות זמינוּת",
"continue_without_calendar": "להמשיך בלי לוח שנה",
"continue_with": "להמשיך עם {{appName}}",
"connect_your_calendar": "קשר את לוח השנה שלך",
"connect_your_video_app": "חיבור אפליקציות הווידאו שלך",
"connect_your_video_app_instructions": "חבר/י את אפליקציות הווידאו שלך כדי להשתמש בהן עבור סוגי האירועים שלך.",
@ -288,6 +289,8 @@
"when": "מתי",
"where": "היכן",
"add_to_calendar": "הוספה ללוח השנה",
"add_to_calendar_description": "בחר/י להיכן יש להוסיף אירועים כשאת/ה עסוק/ה.",
"add_events_to": "הוספת אירועים ל",
"add_another_calendar": "להוסיף לוח שנה אחר",
"other": "אחר",
"email_sign_in_subject": "קישור הכניסה שלך אל {{appName}}",
@ -422,6 +425,7 @@
"booking_created": "ההזמנה נוצרה",
"booking_rejected": "ההזמנה נדחתה",
"booking_requested": "התקבלה בקשת הזמנה",
"booking_payment_initiated": "הופעל תשלום על ההזמנה",
"meeting_ended": "הפגישה הסתיימה",
"form_submitted": "הטופס נשלח",
"booking_paid": "בוצע תשלום עבור ההזמנה",
@ -456,6 +460,7 @@
"no_event_types_have_been_setup": "משתמש זה עדיין לא הגדיר סוג אירוע.",
"edit_logo": "עריכת לוגו",
"upload_a_logo": "העלאת לוגו",
"upload_logo": "העלאת לוגו",
"remove_logo": "הסרת לוגו",
"enable": "הפעלה",
"code": "קוד",
@ -568,6 +573,7 @@
"your_team_name": "שם הצוות שלך",
"team_updated_successfully": "עדכון הצוות בוצע בהצלחה",
"your_team_updated_successfully": "הצוות שלך עודכן בהצלחה.",
"your_org_updated_successfully": "הארגון שלך עודכן בהצלחה.",
"about": "אודות",
"team_description": "מספר משפטים אודות הצוות. המידע הזה יופיע בדף ה-URL של הצוות.",
"org_description": "מספר משפטים אודות הארגון. הם יופיעו בדף עם כתובת ה-URL של הארגון.",
@ -599,6 +605,7 @@
"hide_book_a_team_member": "הסתרת הלחצן לשריון זמן של חבר/ת צוות",
"hide_book_a_team_member_description": "הסתר/י את הלחצן לשריון זמן של חבר/ת צוות מהדפים הציבוריים שלך.",
"danger_zone": "אזור מסוכן",
"account_deletion_cannot_be_undone": "יש לנקוט זהירות. מחיקת חשבון היא פעולה בלתי הפיכה.",
"back": "הקודם",
"cancel": "ביטול",
"cancel_all_remaining": "לבטל את כל הנותרים",
@ -688,6 +695,7 @@
"people": "אנשים",
"your_email": "הדוא\"ל שלך",
"change_avatar": "שינוי אווטאר",
"upload_avatar": "העלאת אווטאר",
"language": "שפה",
"timezone": "אזור זמן",
"first_day_of_week": "היום הראשון בשבוע",
@ -778,6 +786,7 @@
"disable_guests": "השבתת אורחים",
"disable_guests_description": "השבת את האפשרות להוסיף אורחים נוספים בעת ביצוע הזמנה.",
"private_link": "יצירת קישור פרטי",
"enable_private_url": "לאפשר כתובת URL פרטית",
"private_link_label": "קישור פרטי",
"private_link_hint": "הקישור הפרטי שלך ייווצר מחדש לאחר כל שימוש",
"copy_private_link": "העתקת קישור פרטי",
@ -840,6 +849,7 @@
"next_step": "לדלג על שלב זה",
"prev_step": "לשלב הקודם",
"install": "התקנה",
"install_paid_app": "הרשמה למינוי",
"installed": "מותקן",
"active_install_one": "התקנה פעילה {{count}}",
"active_install_other": "{{count}} התקנות פעילות",
@ -1088,6 +1098,7 @@
"developer_documentation": "מסמכי מפתחים",
"get_in_touch": "יצירת קשר",
"contact_support": "פנייה לתמיכה",
"community_support": "תמיכת קהילה",
"feedback": "משוב",
"submitted_feedback": "תודה על המשוב!",
"feedback_error": "שגיאה בעת שליחת משוב",
@ -1213,6 +1224,7 @@
"organizer_name_variable": "שם המארגן/ת",
"app_upgrade_description": "כדי להשתמש בתכונה זו, עליך לשדרג לחשבון Pro.",
"invalid_number": "מספר טלפון לא תקין",
"invalid_url_error_message": "כתובת URL לא חוקית עבור {{label}}. כתובת URL לדוגמה: {{sampleUrl}}",
"navigate": "ניווט",
"open": "פתח",
"close": "סגירה",
@ -1276,6 +1288,7 @@
"personal_cal_url": "כתובת ה-URL האישית שלי של {{appName}}",
"bio_hint": "מספר משפטים אודותיך. המידע הזה יופיע בדף ה-URL האישי שלך.",
"user_has_no_bio": "משתמש זה עדיין לא הוסיף ביוגרפיה.",
"bio": "ביוגרפיה",
"delete_account_modal_title": "מחיקת החשבון",
"confirm_delete_account_modal": "בטוח שברצונך למחוק את חשבון {{appName}} שלך?",
"delete_my_account": "מחיקת החשבון שלי",
@ -1286,6 +1299,7 @@
"select_calendars": "בחר את לוחות השנה שבהם ברצונך לבדוק אם יש התנגשויות, כדי למנוע כפל הזמנות.",
"check_for_conflicts": "בדיקת התנגשויות",
"view_recordings": "צפייה בהקלטות",
"check_for_recordings": "חיפוש הקלטות",
"adding_events_to": "הוספת אירועים ל",
"follow_system_preferences": "פעל לפי העדפות המערכת",
"custom_brand_colors": "צבעי מותג בהתאמה אישית",
@ -1530,6 +1544,7 @@
"problem_registering_domain": "הייתה בעיה ברישום תת-הדומיין; אפשר לנסות שוב או לפנות למנהל/ת מערכת",
"team_publish": "פרסום צוות",
"number_text_notifications": "מספר טלפון (להודעות טקסט)",
"number_sms_notifications": "מספר טלפון (להודעות SMS)",
"attendee_email_variable": "כתובת הדוא\"ל של המשתתף",
"attendee_email_info": "כתובת הדוא\"ל של האדם שביצע את ההזמנה",
"kbar_search_placeholder": "הקלד/י פקודה או חפש/י...",
@ -1594,6 +1609,7 @@
"options": "אפשרויות",
"enter_option": "הזנת ה-{{index}} של האפשרות",
"add_an_option": "הוספת אפשרות",
"location_already_exists": "המיקום הזה כבר קיים, יש לבחור מיקום חדש",
"radio": "רדיו",
"google_meet_warning": "כדי להשתמש ב-Google Meet, יש להגדיר את Google Calendar כלוח השנה של המארח",
"individual": "משתמש בודד",
@ -1613,6 +1629,7 @@
"date_overrides_mark_all_day_unavailable_other": "סימון אי-זמינות בתאריכים מסוימים",
"date_overrides_add_btn": "הוספת מעקף",
"date_overrides_update_btn": "עדכון מעקף",
"date_successfully_added": "עקיפת תאריך נוספה בהצלחה",
"event_type_duplicate_copy_text": "{{slug}}-עותק",
"set_as_default": "להגדיר כברירת מחדל",
"hide_eventtype_details": "הסתרת פרטי סוג האירוע",
@ -1639,6 +1656,7 @@
"minimum_round_robin_hosts_count": "מספר המארחים שחייבים להשתתף",
"hosts": "מארחים",
"upgrade_to_enable_feature": "אתה צריך לייצר צוות כדי להפעיל את היכולת. לחץ ליצירת צוות.",
"orgs_upgrade_to_enable_feature": "כדי לאפשר שימוש בתכונה הזו, יש לשדרג לתוכנית שלנו לארגונים.",
"new_attendee": "משתתף/ת חדש/ה",
"awaiting_approval": "בהמתנה לאישור",
"requires_google_calendar": "האפליקציה הזו מחייבת חיבור ל-Google Calendar",
@ -1743,6 +1761,7 @@
"show_on_booking_page": "להציג בדף ההזמנות",
"get_started_zapier_templates": "התחל עם תבניות Zapier",
"team_is_unpublished": "צוות {{team}} אינו מפורסם",
"org_is_unpublished_description": "הקישור לארגון הזה אינו זמין כעת. יש ליצור קשר עם הבעלים של הארגון או לבקש מהם לפרסם אותו.",
"team_is_unpublished_description": "קישור ה-{{entity}} הזה אינו זמין כעת. יש ליצור קשר עם הבעלים של ה-{{entity}} או לבקש מהם לפרסם אותו.",
"team_member": "חבר צוות",
"a_routing_form": "טופס ניתוב",
@ -1877,6 +1896,7 @@
"edit_invite_link": "עריכת הגדרות הקישור",
"invite_link_copied": "קישור ההזמנה הועתק",
"invite_link_deleted": "קישור ההזמנה נמחק",
"api_key_deleted": "מפתח API נמחק",
"invite_link_updated": "הגדרות קישור ההזמנה נשמרו",
"link_expires_after": "הקישורים מוגדרים לפוג לאחר...",
"one_day": "יום אחד",
@ -2009,7 +2029,13 @@
"attendee_last_name_variable": "שם המשפחה של המשתתף",
"attendee_first_name_info": "השם הפרטי של האדם שביצע את ההזמנה",
"attendee_last_name_info": "שם המשפחה של האדם שביצע את ההזמנה",
"your_monthly_digest": "הסיכום החודשי שלך",
"member_name": "שם החבר/ה",
"most_popular_events": "האירועים הפופולריים ביותר",
"summary_of_events_for_your_team_for_the_last_30_days": "הנה הסיכום של האירועים הפופולריים של הצוות שלך, {{teamName}}, ל-30 הימים האחרונים",
"me": "אני",
"monthly_digest_email": "אימייל עם סיכום חודשי",
"monthly_digest_email_for_teams": "אימייל עם סיכום חודשי עבור צוותים",
"verify_team_tooltip": "אמת/י את הצוות שלך כדי לאפשר שליחת הודעות למשתתפים",
"member_removed": "החבר הוסר",
"my_availability": "הזמינות שלי",
@ -2039,12 +2065,41 @@
"team_no_event_types": "אין לצוות זה אף סוג של אירוע",
"seat_options_doesnt_multiple_durations": "האפשרויות של הושבה במקומות לא תומכות במשכי זמן שונים",
"include_calendar_event": "כלילת אירוע מלוח השנה",
"oAuth": "OAuth",
"recently_added": "נוספו לאחרונה",
"no_members_found": "לא נמצא אף חבר",
"event_setup_length_error": "הגדרת אירוע: משך הזמן חייב להיות לפחות דקה אחת.",
"availability_schedules": "לוחות זמנים לזמינוּת",
"unauthorized": "אין הרשאה",
"access_cal_account": "{{clientName}} רוצה לקבל גישה לחשבון {{appName}} שלך",
"select_account_team": "בחירת חשבון או צוות",
"allow_client_to": "הדבר יאפשר ל-{{clientName}}:",
"associate_with_cal_account": "לשייך בינך לבין הפרטים האישיים שלך מ-{{clientName}}",
"see_personal_info": "לראות את הפרטים האישיים שלך, כולל פרטים אישיים שהגדרת כגלויים לכולם",
"see_primary_email_address": "לראות את כתובת הדוא\"ל הראשית שלך",
"connect_installed_apps": "להתחבר לאפליקציות המותקנות שלך",
"access_event_type": "לקרוא, לערוך ולמחוק את סוגי האירועים שלך",
"access_availability": "לקרוא, לערוך ולמחוק את הזמינות שלך",
"access_bookings": "לקרוא, לערוך ולמחוק את ההזמנות שלך",
"allow_client_to_do": "האם לאפשר ל-{{clientName}} לעשות זאת?",
"oauth_access_information": "לחיצה על 'אפשר' מהווה מתן הרשאה מצידך ליישום זה להשתמש במידע שלך בהתאם לתנאי השירות ולמדיניות הפרטיות שלו. ניתן לשלול את הגישה בחנות האפליקציות של {{appName}}.",
"allow": "אפשר",
"view_only_edit_availability_not_onboarded": "משתמש זה לא השלים תהליך הטמעה. לא תהיה לך אפשרות להגדיר את הזמינות שלו עד שהוא יעשה זאת.",
"view_only_edit_availability": "את/ה צופה בזמינות של משתמש זה. יש לך אפשרות לערוך רק את פרטי הזמינות שלך.",
"you_can_override_calendar_in_advanced_tab": "ניתן לעקוף זאת על בסיס כל אירוע לגופו בהגדרות המתקדמות בכל סוג אירוע.",
"edit_users_availability": "עריכת הזמינות של משתמש: {{username}}",
"resend_invitation": "שליחת ההזמנה מחדש",
"invitation_resent": "ההזמנה נשלחה מחדש.",
"add_client": "הוספת לקוח",
"copy_client_secret_info": "לאחר העתקת הסוד, כבר לא תהיה לך אפשרות לראות אותו",
"add_new_client": "הוספת לקוח חדש",
"this_app_is_not_setup_already": "האפליקציה הזו עדיין לא הוגדרה",
"as_csv": "כ-CSV",
"overlay_my_calendar": "הצג את לוח השנה שלי בשכבת-על",
"overlay_my_calendar_toc": "על ידי חיבור אל לוח השנה שלך, את/ה מקבל/ת את מדיניות הפרטיות ואת תנאי השימוש שלנו. אפשר לשלול את הגישה בכל שלב.",
"view_overlay_calendar_events": "ראה/י את האירועים שלך בלוח השנה כדי למנוע התנגשות בהזמנות.",
"lock_timezone_toggle_on_booking_page": "נעילת אזור הזמן בדף ההזמנות",
"description_lock_timezone_toggle_on_booking_page": "כדי לנעול את אזור הזמן בדף ההזמנות שימושי לאירועים אישיים.",
"extensive_whitelabeling": "תהליך הטמעה והנדסת תמיכה אישי",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -268,6 +268,7 @@
"set_availability": "Imposta la tua disponibilità",
"availability_settings": "Impostazioni di disponibilità",
"continue_without_calendar": "Continua senza calendario",
"continue_with": "Continua con {{appName}}",
"connect_your_calendar": "Collega il tuo calendario",
"connect_your_video_app": "Collega le tue applicazioni video",
"connect_your_video_app_instructions": "Collega le tue applicazioni video per utilizzarle per i tuoi tipi di evento.",
@ -288,6 +289,8 @@
"when": "Quando",
"where": "Dove",
"add_to_calendar": "Aggiungi al calendario",
"add_to_calendar_description": "Seleziona dove aggiungere eventi quando c'è una prenotazione.",
"add_events_to": "Aggiungi eventi a",
"add_another_calendar": "Aggiungi un altro calendario",
"other": "Altro",
"email_sign_in_subject": "Link di accesso a {{appName}}",
@ -422,6 +425,7 @@
"booking_created": "Prenotazione Creata",
"booking_rejected": "Prenotazione Rifiutata",
"booking_requested": "Richiesta di prenotazione inviata",
"booking_payment_initiated": "Pagamento della prenotazione iniziato",
"meeting_ended": "Riunione terminata",
"form_submitted": "Modulo inviato",
"booking_paid": "Prenotazione pagata",
@ -456,6 +460,7 @@
"no_event_types_have_been_setup": "Questo utente non ha ancora impostato alcun tipo di evento.",
"edit_logo": "Modifica logo",
"upload_a_logo": "Carica un logo",
"upload_logo": "Carica logo",
"remove_logo": "Rimuovi logo",
"enable": "Abilita",
"code": "Codice",
@ -568,6 +573,7 @@
"your_team_name": "Nome del tuo team",
"team_updated_successfully": "Team aggiornato con successo",
"your_team_updated_successfully": "Il tuo team è stato aggiornato con successo.",
"your_org_updated_successfully": "La tua organizzazione è stata aggiornata con successo.",
"about": "Informazioni",
"team_description": "Alcune frasi sul tuo team. Appariranno nella pagina URL del team.",
"org_description": "Qualche informazione sulla tua organizzazione. Le informazioni appariranno nella pagina di profilo dell'organizzazione.",
@ -599,6 +605,7 @@
"hide_book_a_team_member": "Nascondi pulsante Prenota un membro del team",
"hide_book_a_team_member_description": "Nasconde il pulsante Prenota un membro del team nelle pagine pubbliche.",
"danger_zone": "Zona pericolosa",
"account_deletion_cannot_be_undone": "Attenzione. Non è possibile annullare l'eliminazione dell'account.",
"back": "Indietro",
"cancel": "Annulla",
"cancel_all_remaining": "Annulla tutti i rimanenti",
@ -688,6 +695,7 @@
"people": "Persone",
"your_email": "La Tua Email",
"change_avatar": "Cambia Avatar",
"upload_avatar": "Carica avatar",
"language": "Lingua",
"timezone": "Timezone",
"first_day_of_week": "Primo giorno della settimana",
@ -778,6 +786,7 @@
"disable_guests": "Disabilita Ospiti",
"disable_guests_description": "Disabilita l'aggiunta di ulteriori ospiti durante la prenotazione.",
"private_link": "Genera URL privato",
"enable_private_url": "Abilita URL privato",
"private_link_label": "Link privato",
"private_link_hint": "Il tuo link privato verrà rigenerato dopo ogni utilizzo",
"copy_private_link": "Copia link privato",
@ -840,6 +849,7 @@
"next_step": "Salta passo",
"prev_step": "Passo precedente",
"install": "Installa",
"install_paid_app": "Abbonati",
"installed": "Installato",
"active_install_one": "{{count}} installazione attiva",
"active_install_other": "{{count}} installazioni attive",
@ -1088,6 +1098,7 @@
"developer_documentation": "Documentazione sviluppatore",
"get_in_touch": "Contattaci",
"contact_support": "Contatta il supporto",
"community_support": "Supporto della community",
"feedback": "Feedback",
"submitted_feedback": "Grazie per il tuo feedback!",
"feedback_error": "Errore durante l'invio del feedback",
@ -1213,6 +1224,7 @@
"organizer_name_variable": "Nome organizzatore",
"app_upgrade_description": "Per poter utilizzare questa funzionalità, è necessario passare a un account Pro.",
"invalid_number": "Numero di telefono non valido",
"invalid_url_error_message": "URL non valido per {{label}}. URL di esempio: {{sampleUrl}}",
"navigate": "Esplora",
"open": "Apri",
"close": "Chiudi",
@ -1276,6 +1288,7 @@
"personal_cal_url": "Il mio URL personale di {{appName}}",
"bio_hint": "Scrivi qualcosa di te. Queste informazioni appariranno nella tua pagina personale.",
"user_has_no_bio": "Questo utente non ha ancora aggiunto una biografia.",
"bio": "Biografia",
"delete_account_modal_title": "Elimina account",
"confirm_delete_account_modal": "Eliminare il tuo account {{appName}}?",
"delete_my_account": "Elimina il mio account",
@ -1286,6 +1299,7 @@
"select_calendars": "Seleziona su quali calendari desideri controllare i conflitti per evitare doppie prenotazioni.",
"check_for_conflicts": "Controlla conflitti",
"view_recordings": "Visualizza registrazioni",
"check_for_recordings": "Controlla le registrazioni",
"adding_events_to": "Aggiungendo eventi a",
"follow_system_preferences": "Segui le preferenze di sistema",
"custom_brand_colors": "Colori del marchio personalizzati",
@ -1530,6 +1544,7 @@
"problem_registering_domain": "Si è verificato un problema durante la registrazione del sottodominio, riprova o contatta un amministratore",
"team_publish": "Pubblica team",
"number_text_notifications": "Numero di telefono (notifiche di testo)",
"number_sms_notifications": "Numero di telefono (notifiche SMS)",
"attendee_email_variable": "E-mail partecipante",
"attendee_email_info": "E-mail della persona che prenota",
"kbar_search_placeholder": "Digita un comando o esegui una ricerca...",
@ -1594,6 +1609,7 @@
"options": "Opzioni",
"enter_option": "Immetti opzione {{index}}",
"add_an_option": "Aggiungi un'opzione",
"location_already_exists": "Questa posizione esiste già. Seleziona una nuova posizione",
"radio": "Pulsante di opzione",
"google_meet_warning": "Per usare Google Meet, è necessario impostare Google Calendar come calendario di destinazione",
"individual": "Persona",
@ -1613,6 +1629,7 @@
"date_overrides_mark_all_day_unavailable_other": "Segna come non disponibile nelle date selezionate",
"date_overrides_add_btn": "Aggiungi configurazione data specifica",
"date_overrides_update_btn": "Aggiorna configurazione data specifica",
"date_successfully_added": "Configurazione delle date aggiunta correttamente",
"event_type_duplicate_copy_text": "{{slug}}-copia",
"set_as_default": "Imposta come predefinito",
"hide_eventtype_details": "Nascondi dettagli del tipo di evento",
@ -1639,6 +1656,7 @@
"minimum_round_robin_hosts_count": "Numero di organizzatori necessario per partecipare",
"hosts": "Organizzatori",
"upgrade_to_enable_feature": "Per abilitare questa funzione, è necessario creare un team. Fai clic per creare un team.",
"orgs_upgrade_to_enable_feature": "Per abilitare questa funzione, è necessario eseguire l'upgrade al nostro piano Enterprise.",
"new_attendee": "Nuovo partecipante",
"awaiting_approval": "In attesa di approvazione",
"requires_google_calendar": "L'app richiede una connessione a Google Calendar",
@ -1743,6 +1761,7 @@
"show_on_booking_page": "Mostra nella pagina di prenotazione",
"get_started_zapier_templates": "Inizia con i modelli Zapier",
"team_is_unpublished": "{{team}} non è pubblicato",
"org_is_unpublished_description": "Il link di questa organizzazione non è attualmente disponibile. Contatta il proprietario dell'organizzazione o chiedigli di pubblicarlo.",
"team_is_unpublished_description": "Questo link di {{entity}} non è attualmente disponibile. Contatta il proprietario di {{entity}} o chiedigli di pubblicarlo.",
"team_member": "Membro del team",
"a_routing_form": "Un modulo di instradamento",
@ -1877,6 +1896,7 @@
"edit_invite_link": "Modifica impostazioni link",
"invite_link_copied": "Link d'invito copiato",
"invite_link_deleted": "Link d'invito eliminato",
"api_key_deleted": "Chiave API eliminata",
"invite_link_updated": "Impostazioni link d'invito salvate",
"link_expires_after": "La scadenza dei link è impostata dopo...",
"one_day": "1 giorno",
@ -2009,7 +2029,13 @@
"attendee_last_name_variable": "Cognome del partecipante",
"attendee_first_name_info": "Nome della persona che prenota",
"attendee_last_name_info": "Cognome della persona che prenota",
"your_monthly_digest": "Riepilogo mensile",
"member_name": "Nome del membro",
"most_popular_events": "Eventi più popolari",
"summary_of_events_for_your_team_for_the_last_30_days": "Ecco il riepilogo degli eventi popolari per il tuo team {{teamName}} negli ultimi 30 giorni",
"me": "Io",
"monthly_digest_email": "E-mail riepilogo mensile",
"monthly_digest_email_for_teams": "E-mail di riepilogo mensili per i team",
"verify_team_tooltip": "Effettua la verifica del tuo team per abilitare l'invio di messaggi ai partecipanti",
"member_removed": "Membro rimosso",
"my_availability": "La mia disponibilità",
@ -2039,12 +2065,41 @@
"team_no_event_types": "Questo team non ha nessun tipo di evento",
"seat_options_doesnt_multiple_durations": "L'opzione di prenotazione dei posti non supporta durate multiple",
"include_calendar_event": "Includi evento del calendario",
"oAuth": "OAuth",
"recently_added": "Aggiunti di recente",
"no_members_found": "Nessun membro trovato",
"event_setup_length_error": "Impostazione evento: la durata deve essere di almeno 1 minuto.",
"availability_schedules": "Calendario disponibilità",
"unauthorized": "Non autorizzato",
"access_cal_account": "{{clientName}} vorrebbe accedere al tuo account {{appName}}",
"select_account_team": "Seleziona un account o un team",
"allow_client_to": "Ciò consentirà a {{clientName}} di",
"associate_with_cal_account": "Associarti con i tuoi dati personali da {{clientName}}",
"see_personal_info": "Vedere i tuoi dati personali, inclusi i dati personali che hai reso disponibili al pubblico",
"see_primary_email_address": "Vedere il tuo indirizzo e-mail principale",
"connect_installed_apps": "Connettersi alle tue applicazioni installate",
"access_event_type": "Leggere, modificare, eliminare i tuoi tipi di eventi",
"access_availability": "Leggere, modificare, eliminare la tua disponibilità",
"access_bookings": "Leggere, modificare, eliminare le tue prenotazioni",
"allow_client_to_do": "Consentire a {{clientName}} di farlo?",
"oauth_access_information": "Facendo clic su Consenti, consentirai a questa applicazione di usare i tuoi dati in conformità ai suoi termini di servizio e informativa sulla privacy. Puoi revocare l'accesso nelle impostazioni di {{appName}} nell'App Store.",
"allow": "Consenti",
"view_only_edit_availability_not_onboarded": "Questo utente non ha completato l'onboarding. Non sarai in grado di impostare la sua disponibilità fino a quando non avrà completato l'onboarding.",
"view_only_edit_availability": "Stai visualizzando la disponibilità di questo utente. Puoi solo modificare la tua disponibilità.",
"you_can_override_calendar_in_advanced_tab": "Puoi configurare queste impostazioni su base per evento nelle impostazioni avanzate di ciascun tipo di evento.",
"edit_users_availability": "Modifica la disponibilità dell'utente: {{username}}",
"resend_invitation": "Invia di nuovo l'invito",
"invitation_resent": "L'invito è stato inviato di nuovo.",
"add_client": "Aggiungi cliente",
"copy_client_secret_info": "Dopo aver copiato questa parola segreta non sarai più in grado di vederla",
"add_new_client": "Aggiungi nuovo cliente",
"this_app_is_not_setup_already": "Questa applicazione non è stata ancora impostata",
"as_csv": "come CSV",
"overlay_my_calendar": "Sovrapponi il mio calendario",
"overlay_my_calendar_toc": "Collegando il tuo calendario, accetti la nostra informativa sulla privacy e i termini di servizio. Puoi revocare l'accesso in qualsiasi momento.",
"view_overlay_calendar_events": "Visualizza gli eventi del tuo calendario per prevenire prenotazioni in conflitto.",
"lock_timezone_toggle_on_booking_page": "Blocca fuso orario nella pagina di prenotazione",
"description_lock_timezone_toggle_on_booking_page": "Per bloccare il fuso orario nella pagina di prenotazione, utile per gli eventi di persona.",
"extensive_whitelabeling": "Assistenza per l'onboarding e supporto tecnico dedicati",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Aggiungi le tue nuove stringhe qui sopra ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -268,6 +268,7 @@
"set_availability": "利用可否の設定",
"availability_settings": "利用できる設定",
"continue_without_calendar": "カレンダーなしで続行",
"continue_with": "{{appName}} で続行",
"connect_your_calendar": "カレンダーに接続",
"connect_your_video_app": "ビデオアプリを接続",
"connect_your_video_app_instructions": "ご利用のイベントの種類で動画アプリを使用するには、ビデオアプリを接続してください。",
@ -288,6 +289,8 @@
"when": "日時",
"where": "参加方法",
"add_to_calendar": "カレンダーに追加",
"add_to_calendar_description": "予約時にイベントを追加する場所を選びます。",
"add_events_to": "イベントの追加先",
"add_another_calendar": "別のカレンダーを追加",
"other": "その他",
"email_sign_in_subject": "{{appName}} のサインインリンク",
@ -422,6 +425,7 @@
"booking_created": "予約を作成しました",
"booking_rejected": "予約が拒否されました",
"booking_requested": "予約がリクエストされました",
"booking_payment_initiated": "予約に関するお支払いを開始しました",
"meeting_ended": "ミーティングが終了しました",
"form_submitted": "フォームが送信されました",
"booking_paid": "予約の支払いが済みました",
@ -456,6 +460,7 @@
"no_event_types_have_been_setup": "このユーザーはまだイベントタイプを設定していません。",
"edit_logo": "ロゴを編集",
"upload_a_logo": "ロゴをアップロード",
"upload_logo": "ロゴをアップロード",
"remove_logo": "ロゴを削除する",
"enable": "有効にする",
"code": "コード",
@ -568,6 +573,7 @@
"your_team_name": "チーム名",
"team_updated_successfully": "チームが正常に更新されました",
"your_team_updated_successfully": "チームが正常に更新されました。",
"your_org_updated_successfully": "組織は正常に更新されました。",
"about": "このアプリについて",
"team_description": "あなたのチームについての簡単な説明文です。あなたのチームの URL ページに表示されます。",
"org_description": "あなたの組織についての簡単な説明文です。あなたの組織の URL ページに表示されます。",
@ -599,6 +605,7 @@
"hide_book_a_team_member": "「チームメンバーを予約する」ボタンを非表示にする",
"hide_book_a_team_member_description": "公開ページから「チームメンバーを予約する」ボタンを非表示にします。",
"danger_zone": "危険ゾーン",
"account_deletion_cannot_be_undone": "ご注意ください。アカウントの削除は元に戻せません。",
"back": "戻る",
"cancel": "キャンセル",
"cancel_all_remaining": "残りをすべてキャンセル",
@ -688,6 +695,7 @@
"people": "ユーザー",
"your_email": "あなたのメールアドレス",
"change_avatar": "アバターを変更",
"upload_avatar": "アバターをアップロード",
"language": "言語",
"timezone": "タイムゾーン",
"first_day_of_week": "週の最初の日",
@ -778,6 +786,7 @@
"disable_guests": "ゲストを無効化",
"disable_guests_description": "予約中のゲストの追加を無効にします。",
"private_link": "プライベートリンクを生成",
"enable_private_url": "プライベート URL を有効にする",
"private_link_label": "プライベートリンク",
"private_link_hint": "プライベートリンクは使用するたびに再生成されます",
"copy_private_link": "プライベートリンクをコピー",
@ -840,6 +849,7 @@
"next_step": "手順をスキップ",
"prev_step": "前の手順",
"install": "インストール",
"install_paid_app": "サブスクライブ",
"installed": "インストール済み",
"active_install_one": "{{count}} 件のアクティブなインストール",
"active_install_other": "{{count}} 件のアクティブなインストール",
@ -1088,6 +1098,7 @@
"developer_documentation": "開発者向けドキュメント",
"get_in_touch": "お問い合わせ",
"contact_support": "サポートに連絡",
"community_support": "コミュニティサポート",
"feedback": "フィードバック",
"submitted_feedback": "フィードバックをありがとうございます!",
"feedback_error": "フィードバックの送信エラー",
@ -1213,6 +1224,7 @@
"organizer_name_variable": "主催者名",
"app_upgrade_description": "この機能を利用するには、Pro アカウントへのアップグレードが必要です。",
"invalid_number": "電話番号が無効です",
"invalid_url_error_message": "{{label}} の無効な URL です。サンプル URL{{sampleUrl}}",
"navigate": "ナビゲート",
"open": "開く",
"close": "閉じる",
@ -1276,6 +1288,7 @@
"personal_cal_url": "私の個人 {{appName}} URL",
"bio_hint": "あなたに関する簡潔な説明。これはあなたの個人 URL ページに表示されます。",
"user_has_no_bio": "このユーザーはまだ経歴を追加していません。",
"bio": "経歴",
"delete_account_modal_title": "アカウントを削除する",
"confirm_delete_account_modal": "{{appName}} アカウントを削除してもよろしいですか?",
"delete_my_account": "アカウントを削除する",
@ -1286,6 +1299,7 @@
"select_calendars": "ダブルブッキングを防ぐために、スケジュールの重なりをチェックするカレンダーを選択してください。",
"check_for_conflicts": "スケジュールの重なりをチェック",
"view_recordings": "録音を表示",
"check_for_recordings": "レコーディングを確認",
"adding_events_to": "イベントの追加先",
"follow_system_preferences": "システム環境設定に従う",
"custom_brand_colors": "カスタムブランドカラー",
@ -1530,6 +1544,7 @@
"problem_registering_domain": "サブドメインの登録時に問題が発生しました。もう一度お試しいただくか、管理者までお問い合せください",
"team_publish": "チームを公開",
"number_text_notifications": "電話番号 (テキスト通知)",
"number_sms_notifications": "電話番号SMS 通知)",
"attendee_email_variable": "出席者のメールアドレス",
"attendee_email_info": "予約者のメールアドレス",
"kbar_search_placeholder": "コマンドを入力するか、検索してください...",
@ -1594,6 +1609,7 @@
"options": "オプション",
"enter_option": "オプション {{index}} を入力してください",
"add_an_option": "オプションを追加",
"location_already_exists": "この場所はすでに存在します。新しい場所を選んでください",
"radio": "ラジオボタン",
"google_meet_warning": "Google Meet を使用するには、目的のカレンダーを Google カレンダーに設定する必要があります",
"individual": "個人",
@ -1613,6 +1629,7 @@
"date_overrides_mark_all_day_unavailable_other": "選択した日付を参加不可としてマーク",
"date_overrides_add_btn": "上書きを追加",
"date_overrides_update_btn": "上書きを更新",
"date_successfully_added": "日付の上書きが正常に追加されました",
"event_type_duplicate_copy_text": "{{slug}}-copy",
"set_as_default": "デフォルトとして設定",
"hide_eventtype_details": "イベントの種類の詳細を非表示にする",
@ -1639,6 +1656,7 @@
"minimum_round_robin_hosts_count": "出席が必要なホストの数",
"hosts": "ホスト",
"upgrade_to_enable_feature": "この機能を有効にするには、チームを作成する必要があります。クリックしてチームを作成してください。",
"orgs_upgrade_to_enable_feature": "この機能を有効にするには Enterprise プランにアップグレードする必要があります。",
"new_attendee": "新規参加者",
"awaiting_approval": "承認を待っています",
"requires_google_calendar": "このアプリは Google カレンダーとの接続が必要です",
@ -1743,6 +1761,7 @@
"show_on_booking_page": "予約ページに表示",
"get_started_zapier_templates": "Zapier テンプレートの使用を開始する",
"team_is_unpublished": "{{team}} は公開されていません",
"org_is_unpublished_description": "この組織のリンクは現在利用できません。組織の所有者に連絡するか、リンクを公開するよう依頼してください。",
"team_is_unpublished_description": "この {{entity}} のリンクは現在利用できません。{{entity}} の所有者に問い合わせるか、リンクを公開するように依頼してください。",
"team_member": "チームメンバー",
"a_routing_form": "ルーティングフォーム",
@ -1877,6 +1896,7 @@
"edit_invite_link": "リンクの設定を編集する",
"invite_link_copied": "招待リンクをコピーしました",
"invite_link_deleted": "招待リンクを削除しました",
"api_key_deleted": "API キーを削除しました",
"invite_link_updated": "招待リンクの設定を保存しました",
"link_expires_after": "リンクの期限切れまで...",
"one_day": "1 日",
@ -2009,7 +2029,13 @@
"attendee_last_name_variable": "出席者の姓",
"attendee_first_name_info": "予約者の名",
"attendee_last_name_info": "予約者の姓",
"your_monthly_digest": "月 1 回のダイジェスト",
"member_name": "メンバーの名前",
"most_popular_events": "最も人気のイベント",
"summary_of_events_for_your_team_for_the_last_30_days": "こちらはこの 30 日間、チーム {{teamName}} で最も人気があったイベントのサマリーです",
"me": "私",
"monthly_digest_email": "月 1 回のダイジェストのメール",
"monthly_digest_email_for_teams": "チームのための月 1 回のダイジェストのメール",
"verify_team_tooltip": "出席者へのメッセージ送信ができるようにするには、チームを確認してください",
"member_removed": "メンバーが削除されました",
"my_availability": "私の空き状況",
@ -2039,12 +2065,41 @@
"team_no_event_types": "このチームにはイベントタイプはありません",
"seat_options_doesnt_multiple_durations": "座席オプションは複数の期間をサポートしていません",
"include_calendar_event": "カレンダーのイベントを含める",
"oAuth": "OAuth",
"recently_added": "最近追加されました",
"no_members_found": "メンバーが見つかりません",
"event_setup_length_error": "イベント設定:時間は 1 分以上でなくてはいけません。",
"availability_schedules": "空き状況一覧",
"unauthorized": "権限がありません",
"access_cal_account": "{{clientName}}があなたの {{appName}} アカウントへのアクセスを求めています",
"select_account_team": "アカウントまたはチームを選択",
"allow_client_to": "これにより {{clientName}} は",
"associate_with_cal_account": "あなたと {{clientName}} からのあなたの個人情報を関連づけられます",
"see_personal_info": "あなたの個人情報(あなたがこれまでに公開したあなたの個人情報など)を表示",
"see_primary_email_address": "プライマリメールアドレスを表示",
"connect_installed_apps": "インストールしたアプリに接続",
"access_event_type": "イベントタイプを読み取り、編集し、削除する",
"access_availability": "空き状況を読み取り、編集し、削除する",
"access_bookings": "予約を読み取り、編集し、削除する",
"allow_client_to_do": "{{clientName}} にこれの実行を許可しますか?",
"oauth_access_information": "「許可」をクリックすると、このアプリのサービス利用規約とプライバシーポリシーに従って、アプリにあなたの個人情報の使用を許可することになります。{{appName}} の App Store でアクセスを削除できます。",
"allow": "許可",
"view_only_edit_availability_not_onboarded": "このユーザーはオンボーディングを完了していません。オンボーディングを完了するまで、ユーザーの空き状況は設定できません。",
"view_only_edit_availability": "このユーザーの空き状況を表示しています。編集できるのは自分の空き状況だけです。",
"you_can_override_calendar_in_advanced_tab": "各イベントタイプの詳細設定で、イベントごとにこれを上書きすることができます。",
"edit_users_availability": "ユーザーの空き状況を編集:{{username}}",
"resend_invitation": "招待を再送",
"invitation_resent": "招待は再送されました。",
"add_client": "顧客を追加",
"copy_client_secret_info": "このシークレットをコピーすると、もう表示できなくなります",
"add_new_client": "新しい顧客を追加",
"this_app_is_not_setup_already": "このアプリはまだ設定されていません",
"as_csv": "CSV として",
"overlay_my_calendar": "カレンダーを重ね合わせる",
"overlay_my_calendar_toc": "カレンダーに接続することで、弊社のプライバシーポリシーと利用規約に同意することになります。アクセスはいつでも取り消せます。",
"view_overlay_calendar_events": "カレンダーのイベントを表示して予約が重ならないようにします。",
"lock_timezone_toggle_on_booking_page": "予約ページのタイムゾーンを固定する",
"description_lock_timezone_toggle_on_booking_page": "予約ページのタイムゾーンを固定するためのもので、対面のイベントに役立ちます。",
"extensive_whitelabeling": "専用のオンボーディングサポートとエンジニアリングサポート",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ この上に新しい文字列を追加してください ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -849,6 +849,7 @@
"next_step": "건너뛰기",
"prev_step": "이전 단계",
"install": "설치",
"install_paid_app": "구독",
"installed": "설치됨",
"active_install_one": "{{count}}개 활성 설치",
"active_install_other": "{{count}}개 활성 설치",
@ -1097,6 +1098,7 @@
"developer_documentation": "개발자 문서",
"get_in_touch": "연락하기",
"contact_support": "지원 문의",
"community_support": "커뮤니티 지원",
"feedback": "피드백",
"submitted_feedback": "피드백을 주셔서 감사합니다!",
"feedback_error": "피드백을 보내는 중 오류 발생",
@ -2098,5 +2100,6 @@
"view_overlay_calendar_events": "예약 충돌을 방지하려면 캘린더 이벤트를 확인하십시오.",
"lock_timezone_toggle_on_booking_page": "예약 페이지의 시간대 잠금",
"description_lock_timezone_toggle_on_booking_page": "예약 페이지에서 시간대를 잠그는 기능은 대면 이벤트에 유용합니다.",
"extensive_whitelabeling": "전담 온보딩 및 엔지니어링 지원",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 여기에 새 문자열을 추가하세요 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

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