Compare commits

...
49 Commits
Author SHA1 Message Date
Keith Williams b724d367fc v3.4.5
Auto Comment Merge Conflicts / auto-comment-merge-conflicts (push) Canceled after 0s
release.yml / release (push) Canceled after 0s
2023-10-26 10:38:57 -03:00
07924751ad test: Create E2E tests for bookings with custom/required Address + other questions (teste2e-addresQuestion) (#11563)
* Add E2E tests for address action in a regular booking

* Remove unnecessary changes

* change all tests

* Fix type check failing

* Update addressQuestion.e2e.ts

* Update addressQuestion.e2e.ts

* Requested changes

---------

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
2023-10-26 15:22:09 +03:00
Siddharth Movaliyaandsean-brydon defa8df7ca fix:fixes the padding and position of tabs on the Team Availability page (#12078)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-10-26 08:29:57 +00:00
Hariom Balhara bf8580fa88 fix: Embed Plus Org - Adds missing embed route, also fixes infinite redirection for migrated user in embed (#12071) 2023-10-26 13:42:05 +05:30
DmytroHryshynandzomars c7b1e4dfa1 chore: [app dir bootstrapping 6] server-side translations (#11995)
Co-authored-by: zomars <zomars@me.com>
2023-10-25 14:43:48 -07:00
139a7c8249 chore: [app dir bootstrapping 5] add RootLayout (#11982)
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Greg Pabian <35925521+grzpab@users.noreply.github.com>
2023-10-25 13:58:08 -07:00
Omar López 158da51a5d fix: embed rewrites post dotted usernames (#12087) 2023-10-25 19:33:22 +00:00
Siddharth MovaliyaandCarinaWolli f9ad99e572 feat: Lock timezone on booking page (#11891)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-25 14:16:01 -04:00
Alex van Andel 1c65f5c150 v3.4.4
Auto Comment Merge Conflicts / auto-comment-merge-conflicts (push) Canceled after 0s
release.yml / release (push) Canceled after 0s
2023-10-25 19:00:14 +01:00
Aldrin 0fb75b715d fix: event type invalidation (#12077) 2023-10-25 18:29:41 +01:00
Syed Ali Shahbaz 9364055283 fix: typefix for webhook and rename oldBookingid to rescheduleId (#12084)
* add rescheduleId to type

* update oldBookingId to rescheduleId

* Remove old remnant
2023-10-25 18:26:22 +01:00
Syed Ali Shahbaz 1929b23ea8 Update handleNewBooking.ts (#12081) 2023-10-25 14:21:14 +01:00
Alex van Andel efc7be0b6b fix: Infinite loop in timezones on the negative side of UTC (#12063)
* fix: Infinite loop in timezones on the negative side of UTC

* Update packages/features/calendars/lib/getAvailableDatesInMonth.test.ts

* Revert back to real system time after test

* Handle all dates as local time, given this all happens in the browser
2023-10-25 17:18:25 +04:00
sean-brydonandUdit Takkar 327159c2ae fix/profile-dont-wait-for-avatar (#12080)
* fix/profile-dont-wait-for-avatar

* Update apps/web/pages/settings/my-account/profile.tsx

* Update apps/web/pages/settings/my-account/profile.tsx

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-10-25 10:41:25 +00:00
Hariom Balhara af801df421 Fixes in teams and avatar across org (#12070) 2023-10-25 15:57:29 +05:30
Morgan 79c1aa60a2 perf: database index on booking_status_starttime_endtime (#12066) 2023-10-24 14:34:27 -07:00
Greg PabianandOmar López a9535d3fd4 chore: [app dir bootstrapping 4.1] check nullability of navigation hook return values part 2 (#12065)
Co-authored-by: Omar López <zomars@me.com>
2023-10-24 20:52:59 +00:00
Omar López 0ae6506bc1 fix: prevents prisma idle connections (#12068) 2023-10-24 16:59:15 -03:00
sean-brydon a8c03262c2 fix: re-render on booker (#12058) 2023-10-24 12:15:17 -07:00
Crowdin Bot 687669ce17 New Crowdin translations by Github Action 2023-10-24 15:31:04 +00:00
Carina WollendorferandCarinaWolli bf6dd665f0 fix: response size scheduleEmailReminder (#12057)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-24 12:27:30 -03:00
Udit TakkarandPeer Richelsen 9250b91bb0 feat: remove location modal in event setup (#11796)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-24 17:59:54 +05:30
Hariom Balhara b934c74c30 fix: Avatar slug and cal links for cross org users (#12031) 2023-10-24 16:12:36 +05:30
96810b5ba1 test: Create E2E tests for bookings with custom/required Long Text + other questions (teste2e-longTextQuestion) (#11559)
* Add E2E tests for long test question in a regular booking

* Remove unnecessary changes

* change all tests

* Update longTextQuestion.e2e.ts

* refactor

* Update longTextQuestion.e2e.ts

* refactor: split cancelAndRescheduleBooking

---------

Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2023-10-24 07:22:52 -03:00
Peer Richelsen ee08118ed3 chore: removed vital.json (#12038) 2023-10-24 11:16:11 +01:00
Carina WollendorferandCarinaWolli 051353e7f1 fix: booking day wrong in booking list (#12007)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-24 11:05:53 +01:00
Matti Nannt 19f52429b0 fix: env.example requesting 24 bytes instead of 32 bytes encryption key (#12043) 2023-10-24 11:03:33 +01:00
4ed15d2755 fix: Event Type header layout issues (fix-headerLayout) (#12047)
Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-10-24 09:49:41 +01:00
zomars 154af1367a hotfix: unreachable rate limits 2023-10-23 15:14:01 -07:00
Crowdin Bot 1de60bcfeb New Crowdin translations by Github Action 2023-10-23 18:57:22 +00:00
Greg Pabian 3679854c43 chore: [app dir bootstrapping 3] check nullability in AppListCard (#11980) 2023-10-23 11:54:33 -07:00
Syed Ali Shahbaz ce64c494f4 fix: Allow passing secret for the webhooks via API (#12039) 2023-10-23 10:18:18 -03:00
Peer Richelsen df4aa24913 chore: improve cal.ai not-installed message (#12022) 2023-10-23 15:45:26 +03:00
Udit TakkarandPeer Richelsen aa54c013f8 fix: allow dots in username (#11706)
* fix: allow dots in username

* test: added unit tests for slugify

* test: add test for username change

* tests: add test  for username and dynamic booking

* fix: type error

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-23 13:37:30 +01:00
Crowdin Bot 0014ca6865 New Crowdin translations by Github Action 2023-10-23 12:06:24 +00:00
6c00c9b2b8 feat: km-localization-cambodia 🇰🇭 (#12027)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
2023-10-23 12:02:45 +00:00
Alex van Andel 46fc67f70d fix: Date add 1 day adds 24 hours, not 1 day (#12019)
Auto Comment Merge Conflicts / auto-comment-merge-conflicts (push) Canceled after 0s
release.yml / release (push) Canceled after 0s
* Date add 1 day adds 24 hours, not 1 day, causing the last date to be lost on dst change

* Alternate fix with tests

* Extract logic so test file doesnt register tsx
2023-10-23 01:21:06 +01:00
Greg Pabian e91fe12219 chore: [app dir bootstrapping 2] ensure tests do not have explicit timeouts (#11970) 2023-10-20 16:57:13 -07:00
Crowdin Bot 2a8f7412dd New Crowdin translations by Github Action 2023-10-20 23:51:24 +00:00
Greg Pabian 64d634e406 chore: [app dir bootstrapping 1] generate nonce with native crypto API (#11969) 2023-10-20 16:48:20 -07:00
Greg Pabian 39cfe18ffe chore: [app dir bootstrapping 4] check nullability of navigation hook return values (#12005) 2023-10-20 16:47:05 -07:00
Crowdin Bot eac45c5e23 New Crowdin translations by Github Action 2023-10-20 17:17:26 +00:00
Crowdin Bot 446c2b0f0e New Crowdin translations by Github Action 2023-10-20 17:14:23 +00:00
Crowdin Bot 92e5aae901 New Crowdin translations by Github Action 2023-10-20 17:11:15 +00:00
Crowdin Bot bf3db721e2 New Crowdin translations by Github Action 2023-10-20 17:08:38 +00:00
Crowdin Bot 8d4561c866 New Crowdin translations by Github Action 2023-10-20 17:05:38 +00:00
Crowdin Bot 6627a211d7 New Crowdin translations by Github Action 2023-10-20 17:02:38 +00:00
Crowdin Bot 0763a64b30 New Crowdin translations by Github Action 2023-10-20 16:59:15 +00:00
Crowdin Bot d333a31221 New Crowdin translations by Github Action 2023-10-20 16:56:42 +00:00
161 changed files with 4373 additions and 1090 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ CRON_ENABLE_APP_SYNC=false
# Application Key for symmetric encryption and decryption
# must be 32 bytes for AES256 encryption algorithm
# You can use: `openssl rand -base64 24` to generate one
# You can use: `openssl rand -base64 32` to generate one
CALENDSO_ENCRYPTION_KEY=
# Intercom Config
+2
View File
@@ -15,3 +15,5 @@ jobs:
- uses: ./.github/actions/yarn-install
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
- run: yarn test
# We could add different timezones here that we need to run our tests in
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly
@@ -0,0 +1,15 @@
diff --git a/index.cjs b/index.cjs
index b645707a3549fc298508726e404243499bbed499..f34b0891e99b275a9218e253f303f43d31ef3f73 100644
--- a/index.cjs
+++ b/index.cjs
@@ -13,8 +13,8 @@ function withMetadataArgument(func, _arguments) {
// https://github.com/babel/babel/issues/2212#issuecomment-131827986
// An alternative approach:
// https://www.npmjs.com/package/babel-plugin-add-module-exports
-exports = module.exports = min.parsePhoneNumberFromString
-exports['default'] = min.parsePhoneNumberFromString
+// exports = module.exports = min.parsePhoneNumberFromString
+// exports['default'] = min.parsePhoneNumberFromString
// `parsePhoneNumberFromString()` named export is now considered legacy:
// it has been promoted to a default export due to being too verbose.
@@ -0,0 +1,26 @@
diff --git a/dist/commonjs/serverSideTranslations.js b/dist/commonjs/serverSideTranslations.js
index bcad3d02fbdfab8dacb1d85efd79e98623a0c257..fff668f598154a13c4030d1b4a90d5d9c18214ad 100644
--- a/dist/commonjs/serverSideTranslations.js
+++ b/dist/commonjs/serverSideTranslations.js
@@ -36,7 +36,6 @@ var _fs = _interopRequireDefault(require("fs"));
var _path = _interopRequireDefault(require("path"));
var _createConfig = require("./config/createConfig");
var _node = _interopRequireDefault(require("./createClient/node"));
-var _appWithTranslation = require("./appWithTranslation");
var _utils = require("./utils");
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { (0, _defineProperty2["default"])(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
@@ -110,12 +109,8 @@ var serverSideTranslations = /*#__PURE__*/function () {
lng: initialLocale
}));
localeExtension = config.localeExtension, localePath = config.localePath, fallbackLng = config.fallbackLng, reloadOnPrerender = config.reloadOnPrerender;
- if (!reloadOnPrerender) {
- _context.next = 18;
- break;
- }
_context.next = 18;
- return _appWithTranslation.globalI18n === null || _appWithTranslation.globalI18n === void 0 ? void 0 : _appWithTranslation.globalI18n.reloadResources();
+ return void 0;
case 18:
_createClient = (0, _node["default"])(_objectSpread(_objectSpread({}, config), {}, {
lng: initialLocale
+1 -1
View File
@@ -88,7 +88,7 @@ export const POST = async (request: NextRequest) => {
// User is not a cal.com user or is using an unverified email.
if (!signature || !user) {
await sendEmail({
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address and then install Cal.ai here: <a href="https://go.cal.com/ai" target="_blank">go.cal.com/ai</a>.`,
subject: `Re: ${subject}`,
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
to: envelope.from,
+2
View File
@@ -20,6 +20,7 @@ export const schemaWebhookCreateParams = z
payloadTemplate: z.string().optional().nullable(),
eventTypeId: z.number().optional(),
userId: z.number().optional(),
secret: z.string().optional().nullable(),
// API shouldn't mess with Apps webhooks yet (ie. Zapier)
// appId: z.string().optional().nullable(),
})
@@ -31,6 +32,7 @@ export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams
.merge(
z.object({
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
secret: z.string().optional().nullable(),
})
)
.partial()
@@ -51,6 +51,9 @@ import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/vali
* eventTypeId:
* type: number
* description: The event type ID if this webhook should be associated with only that event type
* secret:
* type: string
* description: The secret to verify the authenticity of the received payload
* tags:
* - webhooks
* externalDocs:
+3
View File
@@ -49,6 +49,9 @@ import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/va
* eventTypeId:
* type: number
* description: The event type ID if this webhook should be associated with only that event type
* secret:
* type: string
* description: The secret to verify the authenticity of the received payload
* tags:
* - webhooks
* externalDocs:
+109
View File
@@ -0,0 +1,109 @@
import type { Metadata } from "next";
import { headers as nextHeaders, cookies as nextCookies } 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 "../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",
},
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 { 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);
}
return { isEmbed, embedColorScheme, locale: newLocale, direction };
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const headers = nextHeaders();
const cookies = nextCookies();
const fullUrl = headers.get("x-url") ?? "";
const nonce = headers.get("x-csp") ?? "";
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps(fullUrl, headers, cookies);
return (
<html
lang={locale}
dir={direction}
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
<head nonce={nonce}>
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
// eslint-disable-next-line @next/next/no-sync-scripts
<Script
data-project-id="KjpMrKTnXquJVKfeqmjdTffVPf1a6Unw2LZ58iE4"
src="https://snippet.meticulous.ai/v1/stagingMeticulousSnippet.js"
/>
)}
</head>
<body
className="dark:bg-darkgray-50 desktop-transparent bg-subtle antialiased"
style={
isEmbed
? {
background: "transparent",
// Keep the embed hidden till parent initializes and
// - gives it the appropriate styles if UI instruction is there.
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
visibility: "hidden",
}
: {}
}>
{children}
</body>
</html>
);
}
+10 -7
View File
@@ -60,14 +60,18 @@ export default function AppListCard(props: AppListCardProps) {
const pathname = usePathname();
useEffect(() => {
if (shouldHighlight && highlight) {
const timer = setTimeout(() => {
setHighlight(false);
if (shouldHighlight && highlight && searchParams !== null && pathname !== null) {
timeoutRef.current = setTimeout(() => {
const _searchParams = new URLSearchParams(searchParams);
_searchParams.delete("hl");
router.replace(`${pathname}?${_searchParams.toString()}`);
_searchParams.delete("category"); // this comes from params, not from search params
setHighlight(false);
const stringifiedSearchParams = _searchParams.toString();
router.replace(`${pathname}${stringifiedSearchParams !== "" ? `?${stringifiedSearchParams}` : ""}`);
}, 3000);
timeoutRef.current = timer;
}
return () => {
if (timeoutRef.current) {
@@ -75,8 +79,7 @@ export default function AppListCard(props: AppListCardProps) {
timeoutRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [highlight, pathname, router, searchParams, shouldHighlight]);
return (
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>
+88
View File
@@ -0,0 +1,88 @@
"use client";
import type { SSRConfig } from "next-i18next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
// import I18nLanguageHandler from "@components/I18nLanguageHandler";
import { usePathname } from "next/navigation";
import Script from "next/script";
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";
export interface CalPageWrapper {
(props?: AppProps): JSX.Element;
PageWrapper?: AppProps["Component"]["PageWrapper"];
}
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const calFont = localFont({
src: "../fonts/CalSans-SemiBold.woff2",
variable: "--font-cal",
preload: true,
display: "swap",
});
export type PageWrapperProps = Readonly<{
getLayout: (page: React.ReactElement) => ReactNode;
children: React.ReactElement;
requiresLicense: boolean;
isThemeSupported: boolean;
isBookingPage: boolean;
nonce: string | undefined;
themeBasis: string | null;
i18n?: SSRConfig;
}>;
function PageWrapper(props: PageWrapperProps) {
const pathname = usePathname();
let pageStatus = "200";
if (pathname === "/404") {
pageStatus = "404";
} else if (pathname === "/500") {
pageStatus = "500";
}
// On client side don't let nonce creep into DOM
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
// See https://github.com/kentcdodds/nonce-hydration-issues
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
const nonce = typeof window !== "undefined" ? (props.nonce ? "" : undefined) : props.nonce;
const providerProps: PageWrapperProps = {
...props,
nonce,
};
const getLayout: (page: React.ReactElement) => ReactNode = props.getLayout ?? ((page) => page);
return (
<AppProviders {...providerProps}>
{/* <I18nLanguageHandler locales={props.router.locales || []} /> */}
<>
<Script
nonce={nonce}
id="page-status"
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
/>
<style jsx global>{`
:root {
--font-inter: ${interFont.style.fontFamily};
--font-cal: ${calFont.style.fontFamily};
}
`}</style>
{getLayout(
props.requiresLicense ? <LicenseRequired>{props.children}</LicenseRequired> : props.children
)}
</>
</AppProviders>
);
}
export default trpc.withTRPC(PageWrapper);
@@ -226,6 +226,7 @@ function BookingListItem(booking: BookingItemProps) {
};
const startTime = dayjs(booking.startTime)
.tz(user?.timeZone)
.locale(language)
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
@@ -1,4 +1,4 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -26,9 +26,6 @@ type Props = {
};
export default function CancelBooking(props: Props) {
const pathname = usePathname();
const searchParams = useSearchParams();
const asPath = `${pathname}?${searchParams.toString()}`;
const [cancellationReason, setCancellationReason] = useState<string>("");
const { t } = useLocale();
const router = useRouter();
@@ -44,6 +41,7 @@ export default function CancelBooking(props: Props) {
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
{error && (
@@ -100,7 +98,8 @@ export default function CancelBooking(props: Props) {
});
if (res.status >= 200 && res.status < 300) {
router.replace(asPath);
// tested by apps/web/playwright/booking-pages.e2e.ts
router.refresh();
} else {
setLoading(false);
setError(
@@ -433,6 +433,23 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
</>
)}
/>
<Controller
name="lockTimeZoneToggleOnBookingPage"
control={formMethods.control}
defaultValue={eventType.lockTimeZoneToggleOnBookingPage}
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("lock_timezone_toggle_on_booking_page")}
{...shouldLockDisableProps("lockTimeZoneToggleOnBookingPage")}
description={t("description_lock_timezone_toggle_on_booking_page")}
checked={value}
onCheckedChange={(e) => onChange(e)}
/>
)}
/>
{allowDisablingAttendeeConfirmationEmails(workflows) && (
<>
<Controller
+270 -233
View File
@@ -1,27 +1,22 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import { ErrorMessage } from "@hookform/error-message";
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useEffect, useState } from "react";
import { Controller, useForm, useFormContext } from "react-hook-form";
import { Controller, useFormContext, useFieldArray } from "react-hook-form";
import type { MultiValue } from "react-select";
import { z } from "zod";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
import { getEventLocationType, LocationType, 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 { classNames } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import invertLogoOnDark from "@calcom/lib/invertLogoOnDark";
import { md } from "@calcom/lib/markdownIt";
import { slugify } from "@calcom/lib/slugify";
import turndown from "@calcom/lib/turndownService";
import {
Button,
Label,
Select,
SettingsToggle,
@@ -30,11 +25,16 @@ import {
Editor,
SkeletonContainer,
SkeletonText,
Input,
PhoneInput,
Button,
showToast,
} from "@calcom/ui";
import { Edit2, Check, X, Plus } from "@calcom/ui/components/icon";
import { Plus, X, Check } from "@calcom/ui/components/icon";
import { CornerDownRight } from "@calcom/ui/components/icon";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import type { SingleValueLocationOption, LocationOption } from "@components/ui/form/LocationSelect";
import CheckboxField from "@components/ui/form/CheckboxField";
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
import LocationSelect from "@components/ui/form/LocationSelect";
const getLocationFromType = (
@@ -114,9 +114,6 @@ export const EventSetupTab = (
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { eventType, team, destinationCalendar } = props;
const [showLocationModal, setShowLocationModal] = useState(false);
const [editingLocationType, setEditingLocationType] = useState<string>("");
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration);
const orgBranding = useOrgBranding();
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
@@ -150,83 +147,6 @@ export const EventSetupTab = (
selectedMultipleDuration.find((opt) => opt.value === eventType.length) ?? null
);
const openLocationModal = (type: EventLocationType["type"], address = "") => {
const option = getLocationFromType(type, locationOptions);
if (option && option.value === LocationType.InPerson) {
const inPersonOption = {
...option,
address,
};
setSelectedLocation(inPersonOption);
} else {
setSelectedLocation(option);
}
setShowLocationModal(true);
};
const removeLocation = (selectedLocation: (typeof eventType.locations)[number]) => {
formMethods.setValue(
"locations",
formMethods.getValues("locations").filter((location) => {
if (location.type === LocationType.InPerson) {
return location.address !== selectedLocation.address;
}
return location.type !== selectedLocation.type;
}),
{ shouldValidate: true }
);
};
const saveLocation = (newLocationType: EventLocationType["type"], details = {}) => {
const locationType = editingLocationType !== "" ? editingLocationType : newLocationType;
const existingIdx = formMethods.getValues("locations").findIndex((loc) => locationType === loc.type);
if (existingIdx !== -1) {
const copy = formMethods.getValues("locations");
if (editingLocationType !== "") {
copy[existingIdx] = {
...details,
type: newLocationType,
};
}
formMethods.setValue("locations", [
...copy,
...(newLocationType === LocationType.InPerson && editingLocationType === ""
? [{ ...details, type: newLocationType }]
: []),
]);
} else {
formMethods.setValue(
"locations",
formMethods.getValues("locations").concat({ type: newLocationType, ...details })
);
}
setEditingLocationType("");
setShowLocationModal(false);
};
const locationFormSchema = z.object({
locationType: z.string(),
locationAddress: z.string().optional(),
displayLocationPublicly: z.boolean().optional(),
locationPhoneNumber: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
locationLink: z.string().url().optional(), // URL validates as new URL() - which requires HTTPS:// In the input field
});
const locationFormMethods = useForm<{
locationType: EventLocationType["type"];
locationPhoneNumber?: string;
locationAddress?: string; // TODO: We should validate address or fetch the address from googles api to see if its valid?
locationLink?: string; // Currently this only accepts links that are HTTPS://
displayLocationPublicly?: boolean;
}>({
resolver: zodResolver(locationFormSchema),
});
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
useLockedFieldsManager(
eventType,
@@ -236,6 +156,15 @@ export const EventSetupTab = (
const Locations = () => {
const { t } = useLocale();
const {
fields: locationFields,
append,
remove,
update: updateLocationField,
} = useFieldArray({
control: formMethods.control,
name: "locations",
});
const [animationRef] = useAutoAnimate<HTMLUListElement>();
@@ -254,131 +183,266 @@ export const EventSetupTab = (
const { locationDetails, locationAvailable } = getLocationInfo(props);
const LocationInput = (props: {
eventLocationType: EventLocationType;
defaultValue?: string;
index: number;
}) => {
const { eventLocationType, index, ...remainingProps } = props;
if (eventLocationType?.organizerInputType === "text") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
control={formMethods.control}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<>
<Input
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
type="text"
required
onChange={onChange}
value={value}
className="my-0"
{...rest}
/>
<ErrorMessage
errors={formMethods.formState.errors.locations?.[index]}
name={eventLocationType.defaultValueVariable}
className="text-error my-1 text-sm"
as="div"
/>
</>
);
}}
/>
);
} else if (eventLocationType?.organizerInputType === "phone") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
control={formMethods.control}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<>
<PhoneInput
required
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
value={value}
onChange={onChange}
{...rest}
/>
<ErrorMessage
errors={formMethods.formState.errors.locations?.[index]}
name={eventLocationType.defaultValueVariable}
className="text-error my-1 text-sm"
as="div"
/>
</>
);
}}
/>
);
}
return null;
};
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(null);
return (
<div className="w-full">
{validLocations.length === 0 && (
<div className="flex">
<LocationSelect
placeholder={t("select")}
options={locationOptions}
isDisabled={shouldLockDisableProps("locations").disabled}
defaultValue={defaultValue}
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
if (e?.value) {
const newLocationType = e.value;
const eventLocationType = getEventLocationType(newLocationType);
if (!eventLocationType) {
return;
}
locationFormMethods.setValue("locationType", newLocationType);
if (eventLocationType.organizerInputType) {
openLocationModal(newLocationType);
} else {
saveLocation(newLocationType);
}
<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;
}
}}
/>
</div>
)}
{validLocations.length > 0 && (
<ul ref={animationRef}>
{validLocations.map((location, index) => {
const eventLocationType = getEventLocationType(location.type);
if (!eventLocationType) {
return null;
}
});
const eventLabel =
location[eventLocationType.defaultValueVariable] || t(eventLocationType.label);
return (
<li
key={`${location.type}${index}`}
className="border-default text-default mb-2 h-9 rounded-md border px-2 py-1.5 hover:cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center">
<img
src={eventLocationType.iconUrl}
className={classNames(
"h-4 w-4",
classNames(invertLogoOnDark(eventLocationType.iconUrl))
)}
alt={`${eventLocationType.label} logo`}
/>
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
location.teamName ? `(${location.teamName})` : ""
}`}</span>
const option = getLocationFromType(field.type, locationOptions);
return (
<li key={field.id}>
<div className="flex w-full items-center">
<LocationSelect
name={`locations[${index}].type`}
placeholder={t("select")}
options={locationOptions}
isDisabled={shouldLockDisableProps("locations").disabled}
defaultValue={option}
isSearchable={false}
className="block min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
if (e?.value) {
const newLocationType = e.value;
const eventLocationType = getEventLocationType(newLocationType);
if (!eventLocationType) {
return;
}
const canAddLocation =
eventLocationType.organizerInputType ||
!validLocations.find((location) => location.type === newLocationType);
if (canAddLocation) {
updateLocationField(index, { type: newLocationType });
} else {
updateLocationField(index, { type: field.type });
showToast(t("location_already_exists"), "warning");
}
}
}}
/>
<button
data-testid={`delete-locations.${index}.type`}
className="min-h-9 block h-9 px-2"
type="button"
onClick={() => remove(index)}
aria-label={t("remove")}>
<div className="h-4 w-4">
<X className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
</div>
<div className="flex">
<button
type="button"
onClick={() => {
locationFormMethods.setValue("locationType", location.type);
locationFormMethods.unregister("locationLink");
if (location.type === LocationType.InPerson) {
locationFormMethods.setValue("locationAddress", location.address);
} else {
locationFormMethods.unregister("locationAddress");
</button>
</div>
{eventLocationType?.organizerInputType && (
<div className="mt-2 space-y-2">
<div className="flex gap-2">
<div className="flex items-center justify-center">
<CornerDownRight className="h-4 w-4" />
</div>
<div className="w-full">
<LocationInput
defaultValue={
defaultLocation
? defaultLocation[eventLocationType.defaultValueVariable]
: undefined
}
locationFormMethods.unregister("locationPhoneNumber");
setEditingLocationType(location.type);
openLocationModal(location.type, location.address);
eventLocationType={eventLocationType}
index={index}
/>
</div>
</div>
<div className="ml-6">
<CheckboxField
data-testid="display-location"
defaultChecked={defaultLocation?.displayLocationPublicly}
description={t("display_location_label")}
onChange={(e) => {
const fieldValues = formMethods.getValues().locations[index];
updateLocationField(index, {
...fieldValues,
displayLocationPublicly: e.target.checked,
});
}}
aria-label={t("edit")}
className="hover:text-emphasis text-subtle mr-1 p-1">
<Edit2 className="h-4 w-4" />
</button>
<button type="button" onClick={() => removeLocation(location)} aria-label={t("remove")}>
<X className="border-l-1 hover:text-emphasis text-subtle h-6 w-6 pl-1 " />
</button>
informationIconText={t("display_location_info_badge")}
/>
</div>
</div>
</li>
);
})}
{validLocations.some(
(location) =>
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
) && (
<div className="text-default flex text-sm">
<Check className="mr-1.5 mt-0.5 h-2 w-2.5" />
<Trans i18nKey="event_type_requres_google_cal">
<p>
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
Change it{" "}
<Link
href={`${CAL_URL}/event-types/${eventType.id}?tabName=advanced`}
className="underline">
here.
</Link>{" "}
</p>
</Trans>
</div>
)}
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
<p className="pl-1 text-sm leading-none text-red-600">
{t("app_not_connected", { appName: locationDetails.name })}{" "}
<a className="underline" href={`${CAL_URL}/apps/${locationDetails.slug}`}>
{t("connect_now")}
</a>
</p>
)}
{validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && (
<li>
<Button
data-testid="add-location"
StartIcon={Plus}
color="minimal"
onClick={() => setShowLocationModal(true)}>
{t("add_location")}
</Button>
)}
</li>
)}
</ul>
)}
);
})}
{(validLocations.length === 0 || showEmptyLocationSelect) && (
<div className="flex">
<LocationSelect
defaultMenuIsOpen={showEmptyLocationSelect}
autoFocus
placeholder={t("select")}
options={locationOptions}
value={selectedNewOption}
isDisabled={shouldLockDisableProps("locations").disabled}
defaultValue={defaultValue}
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
if (e?.value) {
const newLocationType = e.value;
const eventLocationType = getEventLocationType(newLocationType);
if (!eventLocationType) {
return;
}
const canAppendLocation =
eventLocationType.organizerInputType ||
!validLocations.find((location) => location.type === newLocationType);
if (canAppendLocation) {
append({ type: newLocationType });
setSelectedNewOption(e);
} else {
showToast(t("location_already_exists"), "warning");
setSelectedNewOption(null);
}
}
}}
/>
</div>
)}
{validLocations.some(
(location) =>
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
) && (
<div className="text-default flex items-center text-sm">
<div className="mr-1.5 h-3 w-3">
<Check className="h-3 w-3" />
</div>
<Trans i18nKey="event_type_requres_google_cal">
<p>
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
Change it{" "}
<Link
href={`${CAL_URL}/event-types/${eventType.id}?tabName=advanced`}
className="underline">
here.
</Link>{" "}
</p>
</Trans>
</div>
)}
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
<p className="pl-1 text-sm leading-none text-red-600">
{t("app_not_connected", { appName: locationDetails.name })}{" "}
<a className="underline" href={`${CAL_URL}/apps/${locationDetails.slug}`}>
{t("connect_now")}
</a>
</p>
)}
{validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && (
<li>
<Button
data-testid="add-location"
StartIcon={Plus}
color="minimal"
onClick={() => setShowEmptyLocationSelect(true)}>
{t("add_location")}
</Button>
</li>
)}
</ul>
<p className="text-default mt-2 text-sm">
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
Can&apos;t find the right video app? Visit our
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
App Store
</Link>
.
</Trans>
</p>
</div>
);
};
@@ -542,33 +606,6 @@ export const EventSetupTab = (
/>
</div>
</div>
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
<EditLocationDialog
isOpenDialog={showLocationModal}
setShowLocationModal={setShowLocationModal}
saveLocation={saveLocation}
defaultValues={formMethods.getValues("locations")}
selection={
selectedLocation
? selectedLocation.address
? {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
address: selectedLocation.address,
}
: {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
}
: undefined
}
setSelectedLocation={setSelectedLocation}
setEditingLocationType={setEditingLocationType}
teamId={eventType.team?.id}
/>
</div>
</div>
);
@@ -3,12 +3,13 @@ import type { FormEvent } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
@@ -96,16 +97,19 @@ const UserProfile = () => {
},
];
const organization =
user.organization && user.organization.id
? {
...(user.organization as Ensure<typeof user.organization, "id">),
slug: user.organization.slug || null,
requestedSlug: user.organization.metadata?.requestedSlug || null,
}
: null;
return (
<form onSubmit={onSubmit}>
<div className="flex flex-row items-center justify-start rtl:justify-end">
{user && (
<OrganizationAvatar
alt={user.username || "user avatar"}
size="lg"
imageSrc={imageSrc}
organizationSlug={user.organization?.slug}
/>
<OrganizationMemberAvatar size="lg" user={user} previewSrc={imageSrc} organization={organization} />
)}
<input
ref={avatarRef}
+10 -4
View File
@@ -5,11 +5,15 @@ import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { md } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import { Avatar } from "@calcom/ui";
import { UserAvatar } from "@components/ui/avatar/UserAvatar";
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
type MembersType = TeamType["members"];
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username"> & { safeBio: string | null };
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username" | "organizationId"> & {
safeBio: string | null;
orgOrigin: string;
};
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
const routerQuery = useRouterQuery();
@@ -20,9 +24,11 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = routerQuery;
return (
<Link key={member.id} href={{ pathname: `/${member.username}`, query: queryParamsToForward }}>
<Link
key={member.id}
href={{ pathname: `${member.orgOrigin}/${member.username}`, query: queryParamsToForward }}>
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 hover:cursor-pointer">
<Avatar size="md" alt={member.name || ""} imageSrc={`/${member.username}/avatar.png`} />
<UserAvatar size="md" user={member} />
<section className="mt-2 line-clamp-4 w-full space-y-1">
<p className="text-default font-medium">{member.name}</p>
<div className="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">
@@ -222,9 +222,9 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
onChange={(event) => {
event.preventDefault();
// Reset payment status
const _searchParams = new URLSearchParams(searchParams);
const _searchParams = new URLSearchParams(searchParams ?? undefined);
_searchParams.delete("paymentStatus");
if (searchParams.toString() !== _searchParams.toString()) {
if (searchParams?.toString() !== _searchParams.toString()) {
router.replace(`${pathname}?${_searchParams.toString()}`);
}
setInputUsernameValue(event.target.value);
@@ -0,0 +1,19 @@
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import type { User } from "@calcom/prisma/client";
import { Avatar } from "@calcom/ui";
type UserAvatarProps = Omit<React.ComponentProps<typeof Avatar>, "alt" | "imageSrc"> & {
user: Pick<User, "organizationId" | "name" | "username">;
/**
* Useful when allowing the user to upload their own avatar and showing the avatar before it's uploaded
*/
previewSrc?: string | null;
};
/**
* 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)} />;
}
@@ -0,0 +1,20 @@
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import type { User } from "@calcom/prisma/client";
import { AvatarGroup } from "@calcom/ui";
type UserAvatarProps = Omit<React.ComponentProps<typeof AvatarGroup>, "items"> & {
users: Pick<User, "organizationId" | "name" | "username">[];
};
export function UserAvatarGroup(props: UserAvatarProps) {
const { users, ...rest } = props;
return (
<AvatarGroup
{...rest}
items={users.map((user) => ({
alt: user.name || "",
title: user.name || "",
image: getUserAvatarUrl(user),
}))}
/>
);
}
@@ -0,0 +1,30 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import type { Team, User } from "@calcom/prisma/client";
import { AvatarGroup } from "@calcom/ui";
type UserAvatarProps = Omit<React.ComponentProps<typeof AvatarGroup>, "items"> & {
users: Pick<User, "organizationId" | "name" | "username">[];
organization: Pick<Team, "slug" | "name">;
};
export function UserAvatarGroupWithOrg(props: UserAvatarProps) {
const { users, organization, ...rest } = props;
const items = [
{
image: `${WEBAPP_URL}/team/${organization.slug}/avatar.png`,
alt: organization.name || undefined,
title: organization.name,
},
].concat(
users.map((user) => {
return {
image: getUserAvatarUrl(user),
alt: user.name || undefined,
title: user.name || user.username || "",
};
})
);
users.unshift();
return <AvatarGroup {...rest} items={items} />;
}
@@ -52,7 +52,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded"
/>
</div>
<span className="ms-3 text-sm">{description}</span>
<span className="ms-2 text-sm">{description}</span>
</>
)}
{informationIconText && <InfoBadge content={informationIconText} />}
+291
View File
@@ -0,0 +1,291 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { dir } from "i18next";
import type { Session } from "next-auth";
import { SessionProvider, useSession } from "next-auth/react";
import { EventCollectionProvider } from "next-collect/client";
import { appWithTranslation, type SSRConfig } from "next-i18next";
import { ThemeProvider } from "next-themes";
import type { AppProps as NextAppProps } from "next/app";
import type { ReadonlyURLSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useEffect, type ReactNode } from "react";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
import { FeatureProvider } from "@calcom/features/flags/context/provider";
import { useFlags } from "@calcom/features/flags/hooks";
import { MetaProvider } from "@calcom/ui";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import type { WithNonceProps } from "@lib/withNonce";
import { useViewerI18n } from "@components/I18nLanguageHandler";
import type { PageWrapperProps } from "@components/PageWrapperAppDir";
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<
NextAppProps<
WithNonceProps<{
themeBasis?: string;
session: Session;
}>
>,
"Component"
> & {
Component: NextAppProps["Component"] & {
requiresLicense?: boolean;
isThemeSupported?: boolean;
isBookingPage?: boolean | ((arg: { router: NextAppProps["router"] }) => boolean);
getLayout?: (page: React.ReactElement) => ReactNode;
PageWrapper?: (props: AppProps) => JSX.Element;
};
/** Will be defined only is there was an error */
err?: Error;
};
const getEmbedNamespace = (searchParams: ReadonlyURLSearchParams) => {
// Mostly embed query param should be available on server. Use that there.
// Use the most reliable detection on client
return typeof window !== "undefined" ? window.getEmbedNamespace() : searchParams.get("embed") ?? null;
};
// @ts-expect-error appWithTranslation expects AppProps
const AppWithTranslationHoc = appWithTranslation(({ children }) => <>{children}</>);
const CustomI18nextProvider = (props: { children: React.ReactElement; i18n?: SSRConfig }) => {
/**
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
**/
// @TODO
const session = useSession();
const locale =
session?.data?.user.locale ?? typeof window !== "undefined" ? window.document.documentElement.lang : "en";
useEffect(() => {
try {
// @ts-expect-error TS2790: The operand of a 'delete' operator must be optional.
delete window.document.documentElement["lang"];
window.document.documentElement.lang = locale;
// Next.js writes the locale to the same attribute
// https://github.com/vercel/next.js/blob/1609da2d9552fed48ab45969bdc5631230c6d356/packages/next/src/shared/lib/router/router.ts#L1786
// which can result in a race condition
// this property descriptor ensures this never happens
Object.defineProperty(window.document.documentElement, "lang", {
configurable: true,
// value: locale,
set: function (this) {
// empty setter on purpose
},
get: function () {
return locale;
},
});
} catch (error) {
console.error(error);
window.document.documentElement.lang = locale;
}
window.document.dir = dir(locale);
}, [locale]);
const clientViewerI18n = useViewerI18n(locale);
const i18n = clientViewerI18n.data?.i18n ?? props.i18n;
if (!i18n || !i18n._nextI18Next) {
return null;
}
return (
// @ts-expect-error AppWithTranslationHoc expects AppProps
<AppWithTranslationHoc pageProps={{ _nextI18Next: i18n._nextI18Next }}>
{props.children}
</AppWithTranslationHoc>
);
};
const enum ThemeSupport {
// e.g. Login Page
None = "none",
// Entire App except Booking Pages
App = "systemOnly",
// Booking Pages(including Routing Forms)
Booking = "userConfigured",
}
type CalcomThemeProps = Readonly<{
isBookingPage: boolean;
themeBasis: string | null;
nonce: string | undefined;
isThemeSupported: boolean;
children: React.ReactNode;
}>;
const CalcomThemeProvider = (props: CalcomThemeProps) => {
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
// One such example is our Embeds Demo and Testing page at http://localhost:3100
// Having `getEmbedNamespace` defined on window before react initializes the app, ensures that embedNamespace is available on the first mount and can be used as part of storageKey
const searchParams = useSearchParams();
const embedNamespace = searchParams ? getEmbedNamespace(searchParams) : null;
const isEmbedMode = typeof embedNamespace === "string";
return (
<ThemeProvider {...getThemeProviderProps({ ...props, isEmbedMode, embedNamespace })}>
{/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */}
{/* color-scheme makes background:transparent not work in iframe which is required by embed. */}
{typeof window !== "undefined" && !isEmbedMode && (
<style jsx global>
{`
.dark {
color-scheme: dark;
}
`}
</style>
)}
{props.children}
</ThemeProvider>
);
};
/**
* The most important job for this fn is to generate correct storageKey for theme persistenc.
* `storageKey` is important because that key is listened for changes(using [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event) and any pages opened will change it's theme based on that(as part of next-themes implementation).
* Choosing the right storageKey avoids theme flickering caused by another page using different theme
* So, we handle all the cases here namely,
* - Both Booking Pages, /free/30min and /pro/30min but configured with different themes but being operated together.
* - Embeds using different namespace. They can be completely themed different on the same page.
* - Embeds using the same namespace but showing different cal.com links with different themes
* - Embeds using the same namespace and showing same cal.com links with different themes(Different theme is possible for same cal.com link in case of embed because of theme config available in embed)
* - App has different theme then Booking Pages.
*
* All the above cases have one thing in common, which is the origin and thus localStorage is shared and thus `storageKey` is critical to avoid theme flickering.
*
* Some things to note:
* - There is a side effect of so many factors in `storageKey` that many localStorage keys will be created if a user goes through all these scenarios(e.g like booking a lot of different users)
* - Some might recommend disabling localStorage persistence but that doesn't give good UX as then we would default to light theme always for a few seconds before switching to dark theme(if that's the user's preference).
* - We can't disable [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event handling as well because changing theme in one tab won't change the theme without refresh in other tabs. That's again a bad UX
* - Theme flickering becomes infinitely ongoing in case of embeds because of the browser's delay in processing `storage` event within iframes. Consider two embeds simulatenously opened with pages A and B. Note the timeline and keep in mind that it happened
* because 'setItem(A)' and 'Receives storageEvent(A)' allowed executing setItem(B) in b/w because of the delay.
* - t1 -> setItem(A) & Fires storageEvent(A) - On Page A) - Current State(A)
* - t2 -> setItem(B) & Fires storageEvent(B) - On Page B) - Current State(B)
* - t3 -> Receives storageEvent(A) & thus setItem(A) & thus fires storageEvent(A) (On Page B) - Current State(A)
* - t4 -> Receives storageEvent(B) & thus setItem(B) & thus fires storageEvent(B) (On Page A) - Current State(B)
* - ... and so on ...
*/
function getThemeProviderProps(props: {
isBookingPage: boolean;
themeBasis: string | null;
nonce: string | undefined;
isEmbedMode: boolean;
embedNamespace: string | null;
isThemeSupported: boolean;
}) {
const themeSupport = props.isBookingPage
? ThemeSupport.Booking
: // if isThemeSupported is explicitly false, we don't use theme there
props.isThemeSupported === false
? ThemeSupport.None
: ThemeSupport.App;
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
if ((isBookingPageThemeSupportRequired || props.isEmbedMode) && !props.themeBasis) {
console.warn(
"`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker."
);
}
const appearanceIdSuffix = props.themeBasis ? `:${props.themeBasis}` : "";
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
let embedExplicitlySetThemeSuffix = "";
if (typeof window !== "undefined") {
const embedTheme = window.getEmbedTheme();
if (embedTheme) {
embedExplicitlySetThemeSuffix = `:${embedTheme}`;
}
}
const storageKey = props.isEmbedMode
? // Same Namespace, Same Organizer but different themes would still work seamless and not cause theme flicker
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
`embed-theme-${props.embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
: themeSupport === ThemeSupport.App
? "app-theme"
: isBookingPageThemeSupportRequired
? `booking-theme${appearanceIdSuffix}`
: undefined;
return {
storageKey,
forcedTheme,
themeSupport,
nonce: props.nonce,
enableColorScheme: false,
enableSystem: themeSupport !== ThemeSupport.None,
// next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes
// This is how login to dashboard soft navigation changes theme from light to dark
key: storageKey,
attribute: "class",
};
}
function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
const flags = useFlags();
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
}
function useOrgBrandingValues() {
const session = useSession();
return session?.data?.user.org;
}
function OrgBrandProvider({ children }: { children: React.ReactNode }) {
const orgBrand = useOrgBrandingValues();
return <OrgBrandingProvider value={{ orgBrand }}>{children}</OrgBrandingProvider>;
}
const AppProviders = (props: PageWrapperProps) => {
// No need to have intercom on public pages - Good for Page Performance
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>
);
if (isBookingPage) {
return RemainingProviders;
}
return (
<DynamicHelpscoutProvider>
<DynamicIntercomProvider>{RemainingProviders}</DynamicIntercomProvider>
</DynamicHelpscoutProvider>
);
};
export default AppProviders;
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect } from "vitest";
import { buildNonce } from "./buildNonce";
describe("buildNonce", () => {
it("should return an empty string for an empty array", () => {
const nonce = buildNonce(new Uint8Array());
expect(nonce).toEqual("");
expect(atob(nonce).length).toEqual(0);
});
it("should return a base64 string for values from 0 to 63", () => {
const array = Array(22)
.fill(0)
.map((_, i) => i);
const nonce = buildNonce(new Uint8Array(array));
expect(nonce.length).toEqual(24);
expect(nonce).toEqual("ABCDEFGHIJKLMNOPQRSTQQ==");
expect(atob(nonce).length).toEqual(16);
});
it("should return a base64 string for values from 64 to 127", () => {
const array = Array(22)
.fill(0)
.map((_, i) => i + 64);
const nonce = buildNonce(new Uint8Array(array));
expect(nonce.length).toEqual(24);
expect(nonce).toEqual("ABCDEFGHIJKLMNOPQRSTQQ==");
expect(atob(nonce).length).toEqual(16);
});
it("should return a base64 string for values from 128 to 191", () => {
const array = Array(22)
.fill(0)
.map((_, i) => i + 128);
const nonce = buildNonce(new Uint8Array(array));
expect(nonce.length).toEqual(24);
expect(nonce).toEqual("ABCDEFGHIJKLMNOPQRSTQQ==");
expect(atob(nonce).length).toEqual(16);
});
it("should return a base64 string for values from 192 to 255", () => {
const array = Array(22)
.fill(0)
.map((_, i) => i + 192);
const nonce = buildNonce(new Uint8Array(array));
expect(nonce.length).toEqual(24);
expect(nonce).toEqual("ABCDEFGHIJKLMNOPQRSTQQ==");
expect(atob(nonce).length).toEqual(16);
});
it("should return a base64 string for values from 0 to 42", () => {
const array = Array(22)
.fill(0)
.map((_, i) => 2 * i);
const nonce = buildNonce(new Uint8Array(array));
expect(nonce.length).toEqual(24);
expect(nonce).toEqual("ACEGIKMOQSUWYacegikmgg==");
expect(atob(nonce).length).toEqual(16);
});
it("should return a base64 string for 0 values", () => {
const array = Array(22)
.fill(0)
.map(() => 0);
const nonce = buildNonce(new Uint8Array(array));
expect(nonce.length).toEqual(24);
expect(nonce).toEqual("AAAAAAAAAAAAAAAAAAAAAA==");
expect(atob(nonce).length).toEqual(16);
});
it("should return a base64 string for 0xFF values", () => {
const array = Array(22)
.fill(0)
.map(() => 0xff);
const nonce = buildNonce(new Uint8Array(array));
expect(nonce.length).toEqual(24);
expect(nonce).toEqual("////////////////////ww==");
expect(atob(nonce).length).toEqual(16);
});
});
+46
View File
@@ -0,0 +1,46 @@
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/*
The buildNonce array allows a randomly generated 22-unsigned-byte array
and returns a 24-ASCII character string that mimics a base64-string.
*/
export const buildNonce = (uint8array: Uint8Array): string => {
// the random uint8array should contain 22 bytes
// 22 bytes mimic the base64-encoded 16 bytes
// base64 encodes 6 bits (log2(64)) with 8 bits (64 allowed characters)
// thus ceil(16*8/6) gives us 22 bytes
if (uint8array.length != 22) {
return "";
}
// for each random byte, we take:
// a) only the last 6 bits (so we map them to the base64 alphabet)
// b) for the last byte, we are interested in two bits
// explaination:
// 16*8 bits = 128 bits of information (order: left->right)
// 22*6 bits = 132 bits (order: left->right)
// thus the last byte has 4 redundant (least-significant, right-most) bits
// it leaves the last byte with 2 bits of information before the redundant bits
// so the bitmask is 0x110000 (2 bits of information, 4 redundant bits)
const bytes = uint8array.map((value, i) => {
if (i < 20) {
return value & 0b111111;
}
return value & 0b110000;
});
const nonceCharacters: string[] = [];
bytes.forEach((value) => {
nonceCharacters.push(BASE64_ALPHABET.charAt(value));
});
// base64-encoded strings can be padded with 1 or 2 `=`
// since 22 % 4 = 2, we pad with two `=`
nonceCharacters.push("==");
// the end result has 22 information and 2 padding ASCII characters = 24 ASCII characters
return nonceCharacters.join("");
};
+3 -2
View File
@@ -1,10 +1,11 @@
import crypto from "crypto";
import type { IncomingMessage, OutgoingMessage } from "http";
import { z } from "zod";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { buildNonce } from "@lib/buildNonce";
function getCspPolicy(nonce: string) {
//TODO: Do we need to explicitly define it in turbo.json
const CSP_POLICY = process.env.CSP_POLICY;
@@ -59,7 +60,7 @@ export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) {
}
const CSP_POLICY = process.env.CSP_POLICY;
const cspEnabledForInstance = CSP_POLICY;
const nonce = crypto.randomBytes(16).toString("base64");
const nonce = buildNonce(crypto.getRandomValues(new Uint8Array(22)));
const parsedUrl = new URL(req.url, "http://base_url");
const cspEnabledForPage = cspEnabledForInstance && isPagePathRequest(parsedUrl);
@@ -0,0 +1,162 @@
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
import { describe, it, expect } from "vitest";
import { RedirectType } from "@calcom/prisma/client";
import { getTemporaryOrgRedirect } from "./getTemporaryOrgRedirect";
function mockARedirectInDB({
toUrl,
slug,
redirectType,
}: {
toUrl: string;
slug: string;
redirectType: RedirectType;
}) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
prismaMock.tempOrgRedirect.findUnique.mockImplementation(({ where }) => {
return new Promise((resolve) => {
if (
where.from_type_fromOrgId.type === redirectType &&
where.from_type_fromOrgId.from === slug &&
where.from_type_fromOrgId.fromOrgId === 0
) {
resolve({ toUrl });
} else {
resolve(null);
}
});
});
}
describe("getTemporaryOrgRedirect", () => {
it("should generate event-type URL without existing query params", async () => {
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
const redirect = await getTemporaryOrgRedirect({
slug: "slug",
redirectType: RedirectType.User,
eventTypeSlug: "30min",
currentQuery: {},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/30min",
},
});
});
it("should generate event-type URL with existing query params", async () => {
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
const redirect = await getTemporaryOrgRedirect({
slug: "slug",
redirectType: RedirectType.User,
eventTypeSlug: "30min",
currentQuery: {
abc: "1",
},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/30min?abc=1",
},
});
});
it("should generate User URL with existing query params", async () => {
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
const redirect = await getTemporaryOrgRedirect({
slug: "slug",
redirectType: RedirectType.User,
eventTypeSlug: null,
currentQuery: {
abc: "1",
},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com?abc=1",
},
});
});
it("should generate Team Profile URL with existing query params", async () => {
mockARedirectInDB({
slug: "seeded-team",
toUrl: "https://calcom.cal.com",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slug: "seeded-team",
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: {
abc: "1",
},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com?abc=1",
},
});
});
it("should generate Team Event URL with existing query params", async () => {
mockARedirectInDB({
slug: "seeded-team",
toUrl: "https://calcom.cal.com",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slug: "seeded-team",
redirectType: RedirectType.Team,
eventTypeSlug: "30min",
currentQuery: {
abc: "1",
},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/30min?abc=1",
},
});
});
it("should generate Team Event URL without query params", async () => {
mockARedirectInDB({
slug: "seeded-team",
toUrl: "https://calcom.cal.com",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slug: "seeded-team",
redirectType: RedirectType.Team,
eventTypeSlug: "30min",
currentQuery: {},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/30min",
},
});
});
});
+8 -1
View File
@@ -1,3 +1,6 @@
import type { ParsedUrlQuery } from "querystring";
import { stringify } from "querystring";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { RedirectType } from "@calcom/prisma/client";
@@ -7,10 +10,12 @@ export const getTemporaryOrgRedirect = async ({
slug,
redirectType,
eventTypeSlug,
currentQuery,
}: {
slug: string;
redirectType: RedirectType;
eventTypeSlug: string | null;
currentQuery: ParsedUrlQuery;
}) => {
const prisma = (await import("@calcom/prisma")).default;
log.debug(
@@ -33,10 +38,12 @@ export const getTemporaryOrgRedirect = async ({
if (redirect) {
log.debug(`Redirecting ${slug} to ${redirect.toUrl}`);
const newDestinationWithoutQuery = eventTypeSlug ? `${redirect.toUrl}/${eventTypeSlug}` : redirect.toUrl;
const currentQueryString = stringify(currentQuery);
return {
redirect: {
permanent: false,
destination: eventTypeSlug ? `${redirect.toUrl}/${eventTypeSlug}` : redirect.toUrl,
destination: `${newDestinationWithoutQuery}${currentQueryString ? `?${currentQueryString}` : ""}`,
},
} as const;
}
+4 -4
View File
@@ -1,12 +1,12 @@
import { usePathname, useSearchParams } from "next/navigation";
export default function useIsBookingPage() {
export default function useIsBookingPage(): boolean {
const pathname = usePathname();
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
const searchParams = useSearchParams();
const userParam = searchParams.get("user");
const teamParam = searchParams.get("team");
const userParam = Boolean(searchParams?.get("user"));
const teamParam = Boolean(searchParams?.get("team"));
return !!(isBookingPage || userParam || teamParam);
return isBookingPage || userParam || teamParam;
}
+10 -6
View File
@@ -1,17 +1,21 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
export default function useRouterQuery<T extends string>(name: T) {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const setQuery = (newValue: string | number | null | undefined) => {
const _searchParams = new URLSearchParams(searchParams);
_searchParams.set(name, newValue as string);
router.replace(`${pathname}?${_searchParams.toString()}`);
};
const setQuery = useCallback(
(newValue: string | number | null | undefined) => {
const _searchParams = new URLSearchParams(searchParams ?? undefined);
_searchParams.set(name, newValue as string);
router.replace(`${pathname}?${_searchParams.toString()}`);
},
[name, pathname, router, searchParams]
);
return { [name]: searchParams.get(name), setQuery } as {
return { [name]: searchParams?.get(name), setQuery } as {
[K in T]: string | undefined;
} & { setQuery: typeof setQuery };
}
+258
View File
@@ -0,0 +1,258 @@
import type { Request, Response } from "express";
import type { Redirect } from "next";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, it } from "vitest";
import withEmbedSsr from "./withEmbedSsr";
export type CustomNextApiRequest = NextApiRequest & Request;
export type CustomNextApiResponse = NextApiResponse & Response;
export function createMockNextJsRequest(...args: Parameters<typeof createMocks>) {
return createMocks<CustomNextApiRequest, CustomNextApiResponse>(...args);
}
function getServerSidePropsFnGenerator(
config:
| { redirectUrl: string }
| { props: Record<string, unknown> }
| {
notFound: true;
}
) {
if ("redirectUrl" in config)
return async () => {
return {
redirect: {
permanent: false,
destination: config.redirectUrl,
} satisfies Redirect,
};
};
if ("props" in config)
return async () => {
return {
props: config.props,
};
};
if ("notFound" in config)
return async () => {
return {
notFound: true as const,
};
};
throw new Error("Invalid config");
}
function getServerSidePropsContextArg({
embedRelatedParams,
}: {
embedRelatedParams?: Record<string, string>;
}) {
return {
...createMockNextJsRequest(),
query: {
...embedRelatedParams,
},
resolvedUrl: "/MOCKED_RESOLVED_URL",
};
}
describe("withEmbedSsr", () => {
describe("when gSSP returns redirect", () => {
describe("when redirect destination is relative, should add /embed to end of the path", () => {
it("should add layout and embed params from the current query", async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
redirectUrl: "/reschedule",
})
);
const ret = await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
);
expect(ret).toEqual({
redirect: {
destination: "/reschedule/embed?layout=week_view&embed=namespace1",
permanent: false,
},
});
});
it("should add layout and embed params without losing query params that were in redirect", async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
redirectUrl: "/reschedule?redirectParam=1",
})
);
const ret = await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
);
expect(ret).toEqual({
redirect: {
destination: "/reschedule/embed?redirectParam=1&layout=week_view&embed=namespace1",
permanent: false,
},
});
});
it("should add embed param even when it was empty(i.e. default namespace of embed)", async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
redirectUrl: "/reschedule?redirectParam=1",
})
);
const ret = await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
},
})
);
expect(ret).toEqual({
redirect: {
destination: "/reschedule/embed?redirectParam=1&layout=week_view&embed=",
permanent: false,
},
});
});
});
describe("when redirect destination is absolute, should add /embed to end of the path", () => {
it("should add layout and embed params from the current query when destination URL is HTTPS", async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
redirectUrl: "https://calcom.cal.local/owner",
})
);
const ret = await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
);
expect(ret).toEqual({
redirect: {
destination: "https://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1",
permanent: false,
},
});
});
it("should add layout and embed params from the current query when destination URL is HTTP", async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
redirectUrl: "http://calcom.cal.local/owner",
})
);
const ret = await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
);
expect(ret).toEqual({
redirect: {
destination: "http://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1",
permanent: false,
},
});
});
it("should correctly identify a URL as non absolute URL if protocol is missing", async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
redirectUrl: "httpcalcom.cal.local/owner",
})
);
const ret = await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
);
expect(ret).toEqual({
redirect: {
// FIXME: Note that it is adding a / in the beginning of the path, which might be fine for now, but could be an issue
destination: "/httpcalcom.cal.local/owner/embed?layout=week_view&embed=namespace1",
permanent: false,
},
});
});
});
});
describe("when gSSP returns props", () => {
it("should add isEmbed=true prop", async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
props: {
prop1: "value1",
},
})
);
const ret = await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
},
})
);
expect(ret).toEqual({
props: {
prop1: "value1",
isEmbed: true,
},
});
});
});
describe("when gSSP doesn't have props or redirect ", () => {
it("should return the result from gSSP as is", async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
notFound: true,
})
);
const ret = await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
},
})
);
expect(ret).toEqual({ notFound: true });
});
});
});
+19 -6
View File
@@ -1,5 +1,7 @@
import type { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
export type EmbedProps = {
isEmbed?: boolean;
};
@@ -11,14 +13,25 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
const layout = context.query.layout;
if ("redirect" in ssrResponse) {
// Use a dummy URL https://base as the fallback base URL so that URL parsing works for relative URLs as well.
const destinationUrlObj = new URL(ssrResponse.redirect.destination, "https://base");
const destinationUrl = ssrResponse.redirect.destination;
let urlPrefix = "";
// Get the URL parsed from URL so that we can reliably read pathname and searchParams from it.
const destinationUrlObj = new URL(ssrResponse.redirect.destination, WEBAPP_URL);
// If it's a complete URL, use the origin as the prefix to ensure we redirect to the same domain.
if (destinationUrl.search(/^(http:|https:).*/) !== -1) {
urlPrefix = destinationUrlObj.origin;
} else {
// Don't use any prefix for relative URLs to ensure we stay on the same domain
urlPrefix = "";
}
const destinationQueryStr = destinationUrlObj.searchParams.toString();
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
const newDestinationUrl = `${
destinationUrlObj.pathname
}/embed?${destinationUrlObj.searchParams.toString()}&layout=${layout}&embed=${embed}`;
const newDestinationUrl = `${urlPrefix}${destinationUrlObj.pathname}/embed?${
destinationQueryStr ? `${destinationQueryStr}&` : ""
}layout=${layout}&embed=${embed}`;
return {
...ssrResponse,
redirect: {
+8
View File
@@ -226,6 +226,14 @@ const nextConfig = {
},
async rewrites() {
const beforeFiles = [
{
/**
* Needed due to the introduction of dotted usernames
* @see https://github.com/calcom/cal.com/pull/11706
*/
source: "/embed.js",
destination: "/embed/embed.js",
},
{
source: "/login",
destination: "/auth/login",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.4.3",
"version": "3.4.5",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+6 -7
View File
@@ -51,8 +51,8 @@ export default function Custom404() {
const [url, setUrl] = useState(`${WEBSITE_URL}/signup`);
useEffect(() => {
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host);
const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/);
if (!isValidOrgDomain || !currentOrgDomain) {
const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/) ?? [];
if (routerUsername && (!isValidOrgDomain || !currentOrgDomain)) {
const splitPath = routerUsername.split("/");
if (splitPath[1] === "team" && splitPath.length === 3) {
// Accessing a non-existent team
@@ -66,13 +66,12 @@ export default function Custom404() {
setUrl(`${WEBSITE_URL}/signup?username=${routerUsername.replace("/", "")}`);
}
} else {
setUsername(currentOrgDomain);
setUsername(currentOrgDomain ?? "");
setCurrentPageType(pageType.ORG);
setUrl(
`${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${currentOrgDomain.replace(
"/",
""
)}`
`${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${
currentOrgDomain?.replace("/", "") ?? ""
}`
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
+36 -8
View File
@@ -11,7 +11,7 @@ import {
useEmbedStyles,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
@@ -25,7 +25,7 @@ import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { Verified, ArrowRight } from "@calcom/ui/components/icon";
@@ -99,11 +99,22 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
"max-w-3xl px-4 py-24"
)}>
<div className="mb-8 text-center">
<OrganizationAvatar
imageSrc={profile.image}
<OrganizationMemberAvatar
size="xl"
alt={profile.name}
organizationSlug={profile.organizationSlug}
user={{
organizationId: profile.organization?.id,
name: profile.name,
username: profile.username,
}}
organization={
profile.organization?.id
? {
id: profile.organization.id,
slug: profile.organization.slug,
requestedSlug: null,
}
: null
}
/>
<h1 className="font-cal text-emphasis mb-1 text-3xl" data-testid="name-title">
{profile.name}
@@ -226,8 +237,13 @@ export type UserPageProps = {
theme: string | null;
brandColor: string;
darkBrandColor: string;
organizationSlug: string | null;
organization: {
requestedSlug: string | null;
slug: string | null;
id: number | null;
};
allowSEOIndexing: boolean;
username: string | null;
};
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
themeBasis: string | null;
@@ -248,6 +264,7 @@ export type UserPageProps = {
| "slug"
| "length"
| "hidden"
| "lockTimeZoneToggleOnBookingPage"
| "requiresConfirmation"
| "requiresBookerEmailVerification"
| "price"
@@ -286,6 +303,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
select: {
slug: true,
name: true,
metadata: true,
},
},
theme: true,
@@ -313,6 +331,10 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
const users = usersWithoutAvatar.map((user) => ({
...user,
organization: {
...user.organization,
metadata: user.organization?.metadata ? teamMetadataSchema.parse(user.organization.metadata) : null,
},
avatar: `/${user.username}/avatar.png`,
}));
@@ -321,6 +343,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
slug: usernameList[0],
redirectType: RedirectType.User,
eventTypeSlug: null,
currentQuery: context.query,
});
if (redirect) {
@@ -344,8 +367,13 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
theme: user.theme,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
organizationSlug: user.organization?.slug ?? null,
allowSEOIndexing: user.allowSEOIndexing ?? true,
username: user.username,
organization: {
id: user.organizationId,
slug: user.organization?.slug ?? null,
requestedSlug: user.organization?.metadata?.requestedSlug ?? null,
},
};
const eventTypesWithHidden = await getEventTypesWithHiddenFromDB(user.id);
+1
View File
@@ -160,6 +160,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
slug: usernames[0],
redirectType: RedirectType.User,
eventTypeSlug: slug,
currentQuery: context.query,
});
if (redirect) {
+24 -26
View File
@@ -1,15 +1,23 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import {
orgDomainConfig,
whereClauseForOrgWithSlugOrRequestedSlug,
} from "@calcom/features/ee/organizations/lib/orgDomains";
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
const log = logger.getSubLogger({ prefix: ["team/[slug]"] });
const querySchema = z
.object({
username: z.string(),
teamname: z.string(),
/**
* Passed when we want to fetch avatar of a particular organization
*/
orgSlug: z.string(),
/**
* Allow fetching avatar of a particular organization
@@ -30,11 +38,11 @@ async function getIdentityData(req: NextApiRequest) {
id: orgId,
}
: org
? getSlugOrRequestedSlug(org)
? whereClauseForOrgWithSlugOrRequestedSlug(org)
: null;
if (username) {
let user = await prisma.user.findFirst({
const user = await prisma.user.findFirst({
where: {
username,
organization: orgQuery,
@@ -42,27 +50,6 @@ async function getIdentityData(req: NextApiRequest) {
select: { avatar: true, email: true },
});
/**
* TEMPORARY CODE STARTS - TO BE REMOVED after mono-user schema is implemented
* Try the non-org user temporarily to support users part of a team but not part of the organization
* This is needed because of a situation where we migrate a user and the team to ORG but not all the users in the team to the ORG.
* Eventually, all users will be migrated to the ORG but this is when user by user migration happens initially.
*/
// No user found in the org, try the non-org user that might be part of the team that's part of an org
if (!user && orgQuery) {
// The only side effect this code could have is that it could serve the avatar of a non-org member from the org domain but as long as the username isn't taken by an org member.
user = await prisma.user.findFirst({
where: {
username,
organization: null,
},
select: { avatar: true, email: true },
});
}
/**
* TEMPORARY CODE ENDS
*/
return {
name: username,
email: user?.email,
@@ -79,6 +66,7 @@ async function getIdentityData(req: NextApiRequest) {
},
select: { logo: true },
});
return {
org,
name: teamname,
@@ -86,15 +74,25 @@ async function getIdentityData(req: NextApiRequest) {
avatar: getPlaceholderAvatar(team?.logo, teamname),
};
}
if (orgSlug) {
const org = await prisma.team.findFirst({
where: getSlugOrRequestedSlug(orgSlug),
const orgs = await prisma.team.findMany({
where: {
...whereClauseForOrgWithSlugOrRequestedSlug(orgSlug),
},
select: {
slug: true,
logo: true,
name: true,
},
});
if (orgs.length > 1) {
// This should never happen, but instead of throwing error, we are just logging to be able to observe when it happens.
log.error("More than one organization found for slug", orgSlug);
}
const org = orgs[0];
return {
org: org?.slug,
name: org?.name,
+1 -1
View File
@@ -83,7 +83,7 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
const telemetry = useTelemetry();
let callbackUrl = searchParams.get("callbackUrl") || "";
let callbackUrl = searchParams?.get("callbackUrl") || "";
if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1);
+1 -1
View File
@@ -22,7 +22,7 @@ export default function Authorize() {
const state = searchParams?.get("state") as string;
const scope = searchParams?.get("scope") as string;
const queryString = searchParams.toString();
const queryString = searchParams?.toString();
const [selectedAccount, setSelectedAccount] = useState<{ value: string; label: string } | null>();
const scopes = scope ? scope.toString().split(",") : [];
+1 -1
View File
@@ -24,7 +24,7 @@ function useSetStep() {
const searchParams = useSearchParams();
const pathname = usePathname();
const setStep = (newStep = 1) => {
const _searchParams = new URLSearchParams(searchParams);
const _searchParams = new URLSearchParams(searchParams ?? undefined);
_searchParams.set("step", newStep.toString());
router.replace(`${pathname}?${_searchParams.toString()}`);
};
+1 -1
View File
@@ -164,7 +164,7 @@ export default function Verify() {
e.preventDefault();
setSecondsLeft(30);
// Update query params with t:timestamp, shallow: true doesn't re-render the page
const _searchParams = new URLSearchParams(searchParams.toString());
const _searchParams = new URLSearchParams(searchParams?.toString());
_searchParams.set("t", `${Date.now()}`);
router.replace(`${pathname}?${_searchParams.toString()}`);
return await sendVerificationLogin(customer.email, customer.username);
+1 -1
View File
@@ -155,7 +155,7 @@ export default function Success(props: SuccessProps) {
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
const { requiresLoginToUpdate } = props;
function setIsCancellationMode(value: boolean) {
const _searchParams = new URLSearchParams(searchParams);
const _searchParams = new URLSearchParams(searchParams ?? undefined);
if (value) {
_searchParams.set("cancel", "true");
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import type { GetServerSidePropsContext } from "next";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react";
@@ -86,6 +87,7 @@ export type FormValues = {
offsetStart: number;
description: string;
disableGuests: boolean;
lockTimeZoneToggleOnBookingPage: boolean;
requiresConfirmation: boolean;
requiresBookerEmailVerification: boolean;
recurringEvent: RecurringEvent | null;
@@ -299,6 +301,28 @@ const EventTypePage = (props: EventTypeSetupProps) => {
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
bookingFields: eventTypeBookingFields,
locations: z
.array(
z
.object({
type: z.string(),
address: z.string().optional(),
link: z.string().url().optional(),
phone: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
hostPhoneNumber: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
displayLocationPublicly: z.boolean().optional(),
credentialId: z.number().optional(),
teamName: z.string().optional(),
})
.passthrough()
)
.optional(),
})
// TODO: Add schema for other fields later.
.passthrough()
+4 -15
View File
@@ -72,6 +72,7 @@ import useMeQuery from "@lib/hooks/useMeQuery";
import PageWrapper from "@components/PageWrapper";
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
import { UserAvatarGroup } from "@components/ui/avatar/UserAvatarGroup";
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
@@ -298,7 +299,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
// inject selection data into url for correct router history
const openDuplicateModal = (eventType: EventType, group: EventTypeGroup) => {
const newSearchParams = new URLSearchParams(searchParams);
const newSearchParams = new URLSearchParams(searchParams ?? undefined);
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
if (value) newSearchParams.set(key, value.toString());
if (value === null) newSearchParams.delete(key);
@@ -398,23 +399,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<div className="mt-4 hidden sm:mt-0 sm:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
{type.team && !isManagedEventType && (
<AvatarGroup
<UserAvatarGroup
className="relative right-3 top-1"
size="sm"
truncateAfter={4}
items={
type?.users
? type.users.map(
(organizer: { name: string | null; username: string | null }) => ({
alt: organizer.name || "",
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
organizer.username
}/avatar.png`,
title: organizer.name || "",
})
)
: []
}
users={type?.users ?? []}
/>
)}
{isManagedEventType && type?.children && type.children?.length > 0 && (
@@ -0,0 +1,7 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[user]";
export { default } from "../[user]";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
+21 -8
View File
@@ -6,7 +6,7 @@ import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
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";
@@ -19,6 +19,7 @@ import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import type { Ensure } from "@calcom/types/utils";
import {
Alert,
Button,
@@ -77,8 +78,8 @@ type FormValues = {
bio: string;
};
const checkIfItFallbackImage = (fetchedImgSrc: string) => {
return fetchedImgSrc.endsWith(AVATAR_FALLBACK);
const checkIfItFallbackImage = (fetchedImgSrc?: string) => {
return !fetchedImgSrc || fetchedImgSrc.endsWith(AVATAR_FALLBACK);
};
const ProfileView = () => {
@@ -225,10 +226,11 @@ const ProfileView = () => {
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
};
if (isLoading || !user || fetchedImgSrc === undefined)
if (isLoading || !user) {
return (
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
);
}
const defaultValues = {
username: user.username || "",
@@ -251,6 +253,7 @@ const ProfileView = () => {
isLoading={updateProfileMutation.isLoading}
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
userAvatar={user.avatar}
user={user}
userOrganization={user.organization}
onSubmit={(values) => {
if (values.email !== user.email && isCALIdentityProvider) {
@@ -396,6 +399,7 @@ const ProfileForm = ({
isLoading = false,
isFallbackImg,
userAvatar,
user,
userOrganization,
}: {
defaultValues: FormValues;
@@ -404,6 +408,7 @@ const ProfileForm = ({
isLoading: boolean;
isFallbackImg: boolean;
userAvatar: string;
user: RouterOutputs["viewer"]["me"];
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
}) => {
const { t } = useLocale();
@@ -443,13 +448,21 @@ const ProfileForm = ({
name="avatar"
render={({ field: { value } }) => {
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
const organization =
userOrganization && userOrganization.id
? {
...(userOrganization as Ensure<typeof user.organization, "id">),
slug: userOrganization.slug || null,
requestedSlug: userOrganization.metadata?.requestedSlug || null,
}
: null;
return (
<>
<OrganizationAvatar
alt={formMethods.getValues("username")}
imageSrc={value}
<OrganizationMemberAvatar
previewSrc={value}
size="lg"
organizationSlug={userOrganization.slug}
user={user}
organization={organization}
/>
<div className="ms-4">
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
+2 -2
View File
@@ -8,7 +8,7 @@ import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
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";
@@ -159,7 +159,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
<TextField
addOnLeading={
orgSlug
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
{...register("username")}
+10 -16
View File
@@ -11,7 +11,7 @@ import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig, getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -27,7 +27,7 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { Avatar, AvatarGroup, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { Avatar, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
@@ -35,6 +35,7 @@ import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
import Team from "@components/team/screens/Team";
import { UserAvatarGroup } from "@components/ui/avatar/UserAvatarGroup";
import { ssrInit } from "@server/lib/ssr";
@@ -111,15 +112,11 @@ function TeamPage({
<EventTypeDescription className="text-sm" eventType={type} />
</div>
<div className="mt-1 self-center">
<AvatarGroup
<UserAvatarGroup
truncateAfter={4}
className="flex flex-shrink-0"
size="sm"
items={type.users.map((user) => ({
alt: user.name || "",
title: user.name || "",
image: `/${user.username}/avatar.png` || "",
}))}
users={type.users}
/>
</div>
</Link>
@@ -149,17 +146,11 @@ function TeamPage({
</span>
</div>
</div>
<AvatarGroup
<UserAvatarGroup
className="mr-6"
size="sm"
truncateAfter={4}
items={team.members
.filter((mem) => mem.subteams?.includes(ch.slug) && mem.accepted)
.map((member) => ({
alt: member.name || "",
image: `/${member.username}/avatar.png`,
title: member.name || "",
}))}
users={team.members.filter((mem) => mem.subteams?.includes(ch.slug) && mem.accepted)}
/>
</Link>
</li>
@@ -308,6 +299,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
slug: slug,
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: context.query,
});
if (redirect) {
@@ -373,7 +365,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
subteams: member.subteams,
username: member.username,
accepted: member.accepted,
organizationId: member.organizationId,
safeBio: markdownToSafeHTML(member.bio || ""),
orgOrigin: getOrgFullOrigin(member.organization?.slug || ""),
};
})
: [];
+1
View File
@@ -85,6 +85,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
slug: teamSlug,
redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug,
currentQuery: context.query,
});
if (redirect) {
+14
View File
@@ -0,0 +1,14 @@
import { test } from "./lib/fixtures";
test.describe("AppListCard", async () => {
test("should remove the highlight from the URL", async ({ page, users }) => {
const user = await users.create({});
await user.apiLogin();
await page.goto("/apps/installed/conferencing?hl=daily-video");
await page.waitForLoadState();
await page.waitForURL("/apps/installed/conferencing");
});
});
+1 -1
View File
@@ -53,7 +53,7 @@ test.describe("free user", () => {
// book same time spot again
await bookTimeSlot(page);
await expect(page.locator("[data-testid=booking-fail]")).toBeVisible({ timeout: 1000 });
await page.locator("[data-testid=booking-fail]").waitFor({ state: "visible" });
});
});
+2
View File
@@ -435,6 +435,8 @@ test.describe("Reschedule for booking with seats", () => {
await page.locator('[data-testid="confirm_cancel"]').click();
await page.waitForLoadState("networkidle");
const oldBooking = await prisma.booking.findFirst({
where: { uid: booking.uid },
select: {
@@ -0,0 +1,483 @@
import { loginUser } from "../../fixtures/regularBookings";
import { test } from "../../lib/fixtures";
test.describe("Booking With Address Question and Each Other Question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test.beforeEach(async ({ page, users }) => {
await loginUser(users);
await page.goto("/event-types");
});
test.describe("Booking With Address Question and Checkbox Group Question", () => {
test("Address required and checkbox group required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Checkbox Group question (both required)",
secondQuestion: "checkbox",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Address and checkbox group not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Checkbox Group question (only address required)",
secondQuestion: "checkbox",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Address Question and Checkbox Question", () => {
test("Address required and checkbox required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Checkbox question (both required)",
secondQuestion: "boolean",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Addres and checkbox not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Checkbox question (only address required)",
secondQuestion: "boolean",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Address Question and Long text Question", () => {
test("Addres required and Long Text required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Long Text question (both required)",
secondQuestion: "textarea",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Address and Long Text not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Long Text question (only address required)",
secondQuestion: "textarea",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Address Question and Multi email Question", () => {
test("Address required and Multi email required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion(
"multiemail",
"multiemail-test",
"multiemail test",
true,
"multiemail test"
);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Multiemail question (both required)",
secondQuestion: "multiemail",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Address and Multi email not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion(
"multiemail",
"multiemail-test",
"multiemail test",
false,
"multiemail test"
);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Multiemail question (only address required)",
secondQuestion: "multiemail",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Address Question and multiselect Question", () => {
test("Address required and multiselect text required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Multi Select question (both required)",
secondQuestion: "multiselect",
options: { ...bookingOptions, isMultiSelect: true },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Address and multiselect text not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Multi Select question (only address required)",
secondQuestion: "multiselect",
options: { ...bookingOptions, isMultiSelect: true, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Address Question and Number Question", () => {
test("Address required and Number required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("number", "number-test", "number test", true, "number test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Number question (both required)",
secondQuestion: "number",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Address and Number not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("number", "number-test", "number test", false, "number test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Number question (only address required)",
secondQuestion: "number",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Address Question and Phone Question", () => {
test("Address required and Phone required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone-test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Multi Select question (both required)",
secondQuestion: "phone",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Address and Phone not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("phone", "phone-test", "phone test", false, "phone-test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Multi Select question (only address required)",
secondQuestion: "phone",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Address Question and Radio group Question", () => {
test("Address required and Radio group required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Radio question (both required)",
secondQuestion: "radio",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Address and Radio group not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Radio question (only address required)",
secondQuestion: "radio",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Address Question and select Question", () => {
test("Address required and select required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Select question (both required)",
secondQuestion: "select",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Address and select not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("select", "select-test", "select test", false, "select test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Select question (both required)",
secondQuestion: "select",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Address Question and Short text question", () => {
test("Address required and Short text required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Multi Select question (both required)",
secondQuestion: "text",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Address and Short text not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "address",
fillText: "Test Address question and Multi Select question (only address required)",
secondQuestion: "text",
options: { ...bookingOptions, isRequired: true },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
});
@@ -0,0 +1,483 @@
import { loginUser } from "../fixtures/regularBookings";
import { test } from "../lib/fixtures";
test.describe("Booking With Long Text Question and Each Other Question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test.beforeEach(async ({ page, users }) => {
await loginUser(users);
await page.goto("/event-types");
});
test("Long Text and Address required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Address question (both required)",
secondQuestion: "address",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Long Text required and Address not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("address", "address-test", "address test", false, "address test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Address question (only Long Text required)",
secondQuestion: "address",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test.describe("Booking With Long Text Question and Checkbox Group Question", () => {
test("Long Text and Checkbox Group required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Checkbox Group question (both required)",
secondQuestion: "checkbox",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Long Text required and Checkbox Group not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Checkbox Group question (only Long Text required)",
secondQuestion: "checkbox",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Long Text Question and checkbox Question", () => {
test("Long Text and checkbox required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Checkbox question (only Long Text required)",
secondQuestion: "boolean",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Long Text required and checkbox not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Checkbox question (only Long Text required)",
secondQuestion: "boolean",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Long Text Question and Multiple email Question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test("Long Text and Multiple email required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion(
"multiemail",
"multiemail-test",
"multiemail test",
true,
"multiemail test"
);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Multiple email question (both required)",
secondQuestion: "multiemail",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Long Text required and Multiple email not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion(
"multiemail",
"multiemail-test",
"multiemail test",
false,
"multiemail test"
);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Multiple email question (only Long Text required)",
secondQuestion: "multiemail",
options: { hasPlaceholder: true, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Long Text Question and multiselect Question", () => {
test("Long Text and multiselect text required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and multiselect question (both required)",
secondQuestion: "multiselect",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Long Text required and multiselect text not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and multiselect question (only long text required)",
secondQuestion: "multiselect",
options: { hasPlaceholder: false, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Long Text Question and Number Question", () => {
test("Long Text and Number required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Number question (both required)",
secondQuestion: "multiselect",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test("Long Text required and Number not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Number question (only Long Textß required)",
secondQuestion: "multiselect",
options: { hasPlaceholder: false, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test.describe("Booking With Long Text Question and Phone Question", () => {
test("Long Text and Phone required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Phone question (both required)",
secondQuestion: "phone",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Long Text required and Phone not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("phone", "phone-test", "phone test", false, "phone test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Phone question (only Long Text required)",
secondQuestion: "phone",
options: { hasPlaceholder: false, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Long Text Question and Radio group Question", () => {
test("Long Text and Radio group required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Radio Group question (both required)",
secondQuestion: "radio",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Long Text required and Radio group not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Radio Group question (only Long Text required)",
secondQuestion: "radio",
options: { hasPlaceholder: false, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Long Text Question and select Question", () => {
test("Long Text and select required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("select", "select-test", "select test", true);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Select question (both required)",
secondQuestion: "select",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Long Text required and select not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("select", "select-test", "select test", false);
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Select question (only Long Text required)",
secondQuestion: "select",
options: { hasPlaceholder: false, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Long Text Question and Short text question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test("Long Text and Short text required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Text question (both required)",
secondQuestion: "text",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Long Text required and Short text not required", async ({ bookingPage }) => {
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
await bookingPage.updateEventType();
const eventTypePage = await bookingPage.previewEventType();
await bookingPage.selectTimeSlot(eventTypePage);
await bookingPage.fillAndConfirmBooking({
eventTypePage,
placeholderText: "Please share anything that will help prepare for our meeting.",
question: "textarea",
fillText: "Test Long Text question and Text question (only Long Text required)",
secondQuestion: "text",
options: { hasPlaceholder: false, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
});
@@ -26,10 +26,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "address",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Phone and Address not required", async ({ bookingPage }) => {
test("Phone required and Address not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("address", "address-test", "address test", false, "address test");
await bookingPage.updateEventType();
@@ -43,7 +46,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "address",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test.describe("Booking With Phone Question and checkbox group Question", () => {
@@ -62,10 +68,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "checkbox",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Phone and checkbox group not required", async ({ bookingPage }) => {
test("Phone required and checkbox group not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
await bookingPage.updateEventType();
@@ -79,7 +88,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "checkbox",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
@@ -98,9 +110,12 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "boolean",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Phone and checkbox not required", async ({ bookingPage }) => {
test("Phone required and checkbox not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
await bookingPage.updateEventType();
@@ -114,7 +129,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "boolean",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
@@ -133,10 +151,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "textarea",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Phone and Long text not required", async ({ bookingPage }) => {
test("Phone required and Long text not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test");
await bookingPage.updateEventType();
@@ -150,7 +171,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "textarea",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
@@ -176,10 +200,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "multiemail",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Phone and Multi email not required", async ({ bookingPage }) => {
test("Phone required and Multi email not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion(
"multiemail",
@@ -199,7 +226,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "multiemail",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
@@ -218,10 +248,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "multiselect",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Phone and multiselect text not required", async ({ bookingPage }) => {
test("Phone required and multiselect text not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
await bookingPage.updateEventType();
@@ -235,7 +268,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "multiselect",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
@@ -254,10 +290,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "number",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Phone and Number not required", async ({ bookingPage }) => {
test("Phone required and Number not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("number", "number-test", "number test", false, "number test");
await bookingPage.updateEventType();
@@ -271,7 +310,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "number",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
@@ -290,10 +332,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "radio",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Phone and Radio group not required", async ({ bookingPage }) => {
test("Phone required and Radio group not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
await bookingPage.updateEventType();
@@ -307,7 +352,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "radio",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
@@ -326,10 +374,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "select",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Phone and select not required", async ({ bookingPage }) => {
test("Phone required and select not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("select", "select-test", "select test", false, "select test");
await bookingPage.updateEventType();
@@ -343,7 +394,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "select",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
@@ -363,10 +417,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "text",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Phone and Short text not required", async ({ bookingPage }) => {
test("Phone required and Short text not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
await bookingPage.updateEventType();
@@ -380,7 +437,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => {
secondQuestion: "text",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
});
@@ -43,9 +43,40 @@ test.describe("Change username on settings", () => {
id: user.id,
},
});
expect(newUpdatedUser.username).toBe("demousernamex");
});
test("User can change username to include periods(or dots)", async ({ page, users, prisma }) => {
const user = await users.create();
await user.apiLogin();
// Try to go homepage
await page.goto("/settings/my-account/profile");
// Change username from normal to normal
const usernameInput = page.locator("[data-testid=username-input]");
// User can change username to include dots(or periods)
await usernameInput.fill("demo.username");
await page.click("[data-testid=update-username-btn]");
await Promise.all([
page.click("[data-testid=save-username]"),
page.getByTestId("toast-success").waitFor(),
]);
await page.waitForLoadState("networkidle");
const updatedUser = await prisma.user.findUniqueOrThrow({
where: {
id: user.id,
},
});
expect(updatedUser.username).toBe("demo.username");
// Check if user avatar can be accessed and response headers contain 'image/' in the content type
const response = await page.goto("/demo.username/avatar.png");
expect(response?.headers()?.["content-type"]).toContain("image/");
});
test("User can update to PREMIUM username", async ({ page, users }, testInfo) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
@@ -13,7 +13,7 @@ test("dynamic booking", async ({ page, users }) => {
const pro = await users.create();
await pro.apiLogin();
const free = await users.create({ username: "free" });
const free = await users.create({ username: "free.example" });
await page.goto(`/${pro.username}+${free.username}`);
await test.step("book an event first day in next month", async () => {
+113 -13
View File
@@ -115,23 +115,13 @@ test.describe("Event Types tests", () => {
const locationData = ["location 1", "location 2", "location 3"];
const fillLocation = async (inputText: string) => {
await page.locator("#location-select").click();
await page.locator("text=In Person (Organizer Address)").click();
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await page.locator('input[name="locationAddress"]').fill(inputText);
await page.locator("[data-testid=display-location]").check();
await page.locator("[data-testid=update-location]").click();
};
await fillLocation(locationData[0]);
await fillLocation(page, locationData[0], 0);
await page.locator("[data-testid=add-location]").click();
await fillLocation(locationData[1]);
await fillLocation(page, locationData[1], 1);
await page.locator("[data-testid=add-location]").click();
await fillLocation(locationData[2]);
await fillLocation(page, locationData[2], 2);
await page.locator("[data-testid=update-eventtype]").click();
@@ -177,6 +167,93 @@ test.describe("Event Types tests", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("text=+19199999999")).toBeVisible();
});
test("Can add Organzer Phone Number location and book with it", async ({ page }) => {
await gotoFirstEventType(page);
await page.locator("#location-select").click();
await page.locator(`text="Organizer Phone Number"`).click();
const locationInputName = "locations[0].hostPhoneNumber";
await page.locator(`input[name="${locationInputName}"]`).waitFor();
await page.locator(`input[name="${locationInputName}"]`).fill("9199999999");
await saveEventType(page);
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("text=+19199999999")).toBeVisible();
});
test("Can add Cal video location and book with it", async ({ page }) => {
await gotoFirstEventType(page);
await page.locator("#location-select").click();
await page.locator(`text="Cal Video (Global)"`).click();
await saveEventType(page);
await page.getByTestId("toast-success").waitFor();
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("[data-testid=where] ")).toContainText("Cal Video");
});
test("Can add Link Meeting as location and book with it", async ({ page }) => {
await gotoFirstEventType(page);
await page.locator("#location-select").click();
await page.locator(`text="Link meeting"`).click();
const locationInputName = `locations[0].link`;
const testUrl = "https://cal.ai/";
await page.locator(`input[name="${locationInputName}"]`).fill(testUrl);
await saveEventType(page);
await page.getByTestId("toast-success").waitFor();
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
const linkElement = await page.locator("[data-testid=where] > a");
expect(await linkElement.getAttribute("href")).toBe(testUrl);
});
test("Can remove location from multiple locations that are saved", async ({ page }) => {
await gotoFirstEventType(page);
// Add Attendee Phone Number location
await selectAttendeePhoneNumber(page);
// Add Cal Video location
await addAnotherLocation(page, "Cal Video (Global)");
await saveEventType(page);
await page.waitForLoadState("networkidle");
// Remove Attendee Phone Number Location
const removeButtomId = "delete-locations.0.type";
await page.getByTestId(removeButtomId).click();
await saveEventType(page);
await page.waitForLoadState("networkidle");
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("[data-testid=where]")).toHaveText(/Cal Video/);
});
});
});
});
@@ -205,3 +282,26 @@ async function gotoBookingPage(page: Page) {
await page.goto(previewLink ?? "");
}
/**
* Adds n+1 location to the event type
*/
async function addAnotherLocation(page: Page, locationOptionText: string) {
await page.locator("[data-testid=add-location]").click();
// When adding another location, the dropdown opens automatically. So, we don't need to open it here.
//
await page.locator(`text="${locationOptionText}"`).click();
}
const fillLocation = async (page: Page, inputText: string, index: number) => {
// Except the first location, dropdown automatically opens when adding another location
if (index == 0) {
await page.locator("#location-select").last().click();
}
await page.locator("text=In Person (Organizer Address)").last().click();
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();
};
@@ -13,6 +13,7 @@ type BookingOptions = {
hasPlaceholder?: boolean;
isReschedule?: boolean;
isRequired?: boolean;
isMultiSelect?: boolean;
};
interface QuestionActions {
@@ -174,18 +175,17 @@ export function createBookingPageFixture(page: Page) {
await page.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule");
await page.getByTestId("confirm-reschedule-button").click();
},
verifyReschedulingSuccess: async () => {
await expect(page.getByText(scheduleSuccessfullyText)).toBeVisible();
},
cancelBookingWithReason: async () => {
cancelBookingWithReason: async (page: Page) => {
await page.getByTestId("cancel").click();
await page.getByTestId("cancel_reason").fill("Test cancel");
await page.getByTestId("confirm_cancel").click();
},
verifyBookingCancellation: async () => {
assertBookingCanceled: async (page: Page) => {
await expect(page.getByTestId("cancelled-headline")).toBeVisible();
},
cancelAndRescheduleBooking: async (eventTypePage: Page) => {
rescheduleBooking: async (eventTypePage: Page) => {
await eventTypePage.getByText("Reschedule").click();
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
await eventTypePage.getByRole("button", { name: "View next" }).click();
@@ -194,7 +194,13 @@ export function createBookingPageFixture(page: Page) {
await eventTypePage.getByPlaceholder(reschedulePlaceholderText).click();
await eventTypePage.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule");
await eventTypePage.getByTestId("confirm-reschedule-button").click();
await expect(eventTypePage.getByText(scheduleSuccessfullyText)).toBeVisible();
},
assertBookingRescheduled: async (page: Page) => {
await expect(page.getByText(scheduleSuccessfullyText)).toBeVisible();
},
cancelBooking: async (eventTypePage: Page) => {
await eventTypePage.getByTestId("cancel").click();
await eventTypePage.getByTestId("cancel_reason").fill("Test cancel");
await eventTypePage.getByTestId("confirm_cancel").click();
+24 -20
View File
@@ -1,5 +1,6 @@
import type { Frame, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import EventEmitter from "events";
import type { IncomingMessage, ServerResponse } from "http";
import { createServer } from "http";
// eslint-disable-next-line no-restricted-imports
@@ -35,7 +36,27 @@ export function createHttpServer(opts: { requestHandler?: RequestHandler } = {})
res.end();
},
} = opts;
const eventEmitter = new EventEmitter();
const requestList: Request[] = [];
const waitForRequestCount = (count: number) =>
new Promise<void>((resolve) => {
if (requestList.length === count) {
resolve();
return;
}
const pushHandler = () => {
if (requestList.length !== count) {
return;
}
eventEmitter.off("push", pushHandler);
resolve();
};
eventEmitter.on("push", pushHandler);
});
const server = createServer((req, res) => {
const buffer: unknown[] = [];
@@ -49,6 +70,7 @@ export function createHttpServer(opts: { requestHandler?: RequestHandler } = {})
_req.body = json;
requestList.push(_req);
eventEmitter.emit("push");
requestHandler({ req: _req, res });
});
});
@@ -58,34 +80,16 @@ export function createHttpServer(opts: { requestHandler?: RequestHandler } = {})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const port: number = (server.address() as any).port;
const url = `http://localhost:${port}`;
return {
port,
close: () => server.close(),
requestList,
url,
waitForRequestCount,
};
}
/**
* When in need to wait for any period of time you can use waitFor, to wait for your expectations to pass.
*/
export async function waitFor(fn: () => Promise<unknown> | unknown, opts: { timeout?: number } = {}) {
let finished = false;
const timeout = opts.timeout ?? 5000; // 5s
const timeStart = Date.now();
while (!finished) {
try {
await fn();
finished = true;
} catch {
if (Date.now() - timeStart >= timeout) {
throw new Error("waitFor timed out");
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
}
export async function selectFirstAvailableTimeSlotNextMonth(page: Page | Frame) {
// Let current month dates fully render.
await page.click('[data-testid="incrementMonth"]');
+4 -3
View File
@@ -150,14 +150,14 @@ test.describe("unauthorized user sees correct translations (pt)", async () => {
test.describe("unauthorized user sees correct translations (pt-br)", async () => {
test.use({
locale: "pt-br",
locale: "pt-BR",
});
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
@@ -181,7 +181,8 @@ test.describe("unauthorized user sees correct translations (es-419)", async () =
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=es-419]").waitFor({ state: "attached" });
// es-419 is disabled in i18n config, so es should be used as fallback
await page.locator("html[lang=es]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
@@ -8,7 +8,7 @@ import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { test } from "./lib/fixtures";
import { createHttpServer, waitFor, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
async function getLabelText(field: Locator) {
return await field.locator("label").first().locator("span").first().innerText();
@@ -215,13 +215,7 @@ test.describe("Manage Booking Questions", () => {
async function runTestStepsCommonForTeamAndUserEventType(
page: Page,
context: PlaywrightTestArgs["context"],
webhookReceiver: {
port: number;
close: () => import("http").Server;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
requestList: (import("http").IncomingMessage & { body?: any })[];
url: string;
}
webhookReceiver: Awaited<ReturnType<typeof addWebhook>>
) {
await page.click('[href$="tabName=advanced"]');
@@ -311,12 +305,11 @@ async function runTestStepsCommonForTeamAndUserEventType(
await page.locator('[data-testid="field-response"][data-fob-field="how-are-you"]').innerText()
).toBe("I am great!");
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
// @ts-expect-error body is unknown
const payload = request.body.payload;
expect(payload.responses).toMatchObject({
@@ -667,9 +660,7 @@ async function expectWebhookToBeCalled(
};
}
) {
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
const body = request.body;
+13 -27
View File
@@ -10,7 +10,6 @@ import {
bookOptinEvent,
createHttpServer,
selectFirstAvailableTimeSlotNextMonth,
waitFor,
gotoRoutingLink,
createUserWithSeatedEventAndAttendees,
} from "./lib/testUtils";
@@ -78,10 +77,7 @@ test.describe("BOOKING_CREATED", async () => {
await page.fill('[name="email"]', "test@example.com");
await page.press('[name="email"]', "Enter");
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -209,10 +205,8 @@ test.describe("BOOKING_REJECTED", async () => {
await page.click('[data-testid="rejection-confirm"]');
await page.waitForResponse((response) => response.url().includes("/api/trpc/bookings/confirm"));
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any;
@@ -332,9 +326,8 @@ test.describe("BOOKING_REQUESTED", async () => {
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any;
@@ -442,9 +435,7 @@ test.describe("BOOKING_RESCHEDULED", async () => {
expect(newBooking).not.toBeNull();
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
@@ -520,9 +511,7 @@ test.describe("BOOKING_RESCHEDULED", async () => {
expect(newBooking).not.toBeNull();
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
await webhookReceiver.waitForRequestCount(1);
const [firstRequest] = webhookReceiver.requestList;
@@ -541,9 +530,7 @@ test.describe("BOOKING_RESCHEDULED", async () => {
await expect(page).toHaveURL(/.*booking/);
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(2);
});
await webhookReceiver.waitForRequestCount(2);
const [_, secondRequest] = webhookReceiver.requestList;
@@ -597,9 +584,8 @@ test.describe("FORM_SUBMITTED", async () => {
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
page.click('button[type="submit"]');
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any;
@@ -656,9 +642,9 @@ test.describe("FORM_SUBMITTED", async () => {
const fieldName = "name";
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
page.click('button[type="submit"]');
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any;
@@ -1,13 +0,0 @@
{
"connected_vital_app": "متصل باستخدام",
"vital_app_sleep_automation": "أتمتة إعادة الجدولة بناءً على بيانات نومك",
"vital_app_automation_description": "يمكنك تحديد معلمات مختلفة لتشغيل إعادة الجدولة بناءً على مقاييس النوم الخاصة بك.",
"vital_app_parameter": "المعلمات",
"vital_app_trigger": "Trigger عندما يساوي أو يكون أقل من",
"vital_app_save_button": "حفظ التكوين",
"vital_app_total_label": "المجموع (المجموع = نوم حركة العين السريعة + النوم الخفيف + النوم العميق)",
"vital_app_duration_label": "المدة (المدة= نهاية وقت النوم - بداية وقت النوم)",
"vital_app_hours": "ساعة",
"vital_app_save_success": "تم حفظ Vital Configurations بنجاح",
"vital_app_save_error": "حدث خطأ أثناء حفظ Vital Configurations الخاصة بك"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Propojeno s aplikací",
"vital_app_sleep_automation": "Automatizace přeplánování spánku",
"vital_app_automation_description": "Na základě měření délky spánku můžete zvolit různé parametry, které spustí přeplánování.",
"vital_app_parameter": "Parametr",
"vital_app_trigger": "Spustit při hodnotě menší nebo rovné",
"vital_app_save_button": "Uložit konfiguraci",
"vital_app_total_label": "Celková doba (celková doba = REM + lehký spánek + hluboký spánek)",
"vital_app_duration_label": "Délka (délka = konec uložení ke spánku − začátek uložení ke spánku)",
"vital_app_hours": "h",
"vital_app_save_success": "Uložení konfigurace aplikace Vital se zdařilo",
"vital_app_save_error": "Při ukládání konfigurace aplikace Vital se vyskytla chyba"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Verbunden mit",
"vital_app_sleep_automation": "Schlaf Terminumbuchunsautomatisierung",
"vital_app_automation_description": "Sie können verschiedene Parameter auswählen, um die Umbuchung basierend auf Ihren Schlafmetriken auszulösen.",
"vital_app_parameter": "Parameter",
"vital_app_trigger": "Auslöser kleiner oder gleich",
"vital_app_save_button": "Einstellungen speichern",
"vital_app_total_label": "Gesamt (Gesamt= rem + leichter Schlaf + tiefer Schlaf)",
"vital_app_duration_label": "Dauer (Dauer = Schlafzeitende - Schlafzeit start)",
"vital_app_hours": "Stunden",
"vital_app_save_success": "Erfolgreich Ihre Vital-Konfigurationen wurden Erfolgreich gespeichert",
"vital_app_save_error": "Ein Fehler ist aufgetreten beim Speichern Ihrer Vital Einstellungen"
}
@@ -1605,6 +1605,7 @@
"options": "Options",
"enter_option": "Enter Option {{index}}",
"add_an_option": "Add an option",
"location_already_exists": "This Location already exists. Please select a new location",
"radio": "Radio",
"google_meet_warning": "In order to use Google Meet you must set your destination calendar to a Google Calendar",
"individual": "Individual",
@@ -2093,5 +2094,7 @@
"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.",
"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.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Conectado con",
"vital_app_sleep_automation": "Automatización de reprogramación del sueño",
"vital_app_automation_description": "Puede seleccionar diferentes parámetros para activar la reprogramación automática del sueño.",
"vital_app_parameter": "Parámetro",
"vital_app_trigger": "Activar cuando sea igual o menor que",
"vital_app_save_button": "Guardar configuración",
"vital_app_total_label": "Total (total = rem + sueño ligero + sueño profundo)",
"vital_app_duration_label": "Duración (duración = horario que se levanta de la cama - horario que se acuesta en la cama)",
"vital_app_hours": "horas",
"vital_app_save_success": "Guardado exitoso de sus configuraciones de Vital App ",
"vital_app_save_error": "Ocurrió un error al intentar guardar sus configuraciones de Vital App "
}
@@ -155,6 +155,12 @@
"rejected": "Baztertua",
"unconfirmed": "Baieztatu gabea",
"guests": "Gonbidatuak",
"guest": "Gonbidatua",
"404_the_user": "Erabiltzaile izena",
"username": "Erabiltzaile izena",
"is_still_available": "orandik eskuragarri dago.",
"documentation": "Dokumentazioa",
"blog": "Bloga",
"404_claim_entity_user": "Erreklamatu zure erabiltzaile-izena eta programatu gertaerak",
"popular_pages": "Orrialde ospetsuak",
"register_now": "Izena eman orain",
@@ -192,7 +198,31 @@
"all_done": "Dena eginda!",
"all": "Guztia",
"yours": "Zure kontua",
"finish": "Amaitu",
"organization_general_description": "Kudeatu zure taldearen hizkuntza eta ordu-eremuko ezarpenak",
"few_sentences_about_yourself": "Esaldi gutxi batzuk zeuri buruz. Zure orrialde pertsonalean agertuko dira.",
"nearly_there": "Ia bukatuta!",
"set_availability": "Ezarri zein ordutan zauden libre",
"continue_without_calendar": "Jarraitu egutegirik gabe",
"connect_your_calendar": "Konektatu zure egutegia",
"connect_your_video_app": "Konektatu zure bideo-aplikazioak",
"set_up_later": "Konfiguratu geroago",
"current_time": "Uneko ordua",
"details": "Xehetasunak",
"welcome": "Ongi etorri",
"welcome_back": "Ongi etorri",
"welcome_to_calcom": "Ongi etorri {{appName}}(e)ra",
"connect": "Konektatu",
"try_for_free": "Proba ezazu doan",
"create_booking_link_with_calcom": "Sor ezazu zeure erreserba-esteka {{appName}}(e)kin",
"who": "Nor(k)",
"what": "Zer",
"when": "Noiz",
"where": "Non",
"add_to_calendar": "Gehitu egutegira",
"add_events_to": "Gehitu gertaerak hona:",
"add_another_calendar": "Gehitu beste egutegi bat",
"other": "Besterik",
"user_needs_to_confirm_or_reject_booking": "{{user}}(e)k erreserba baieztatu edo errefusatu behar du oraindik.",
"meeting_is_scheduled": "Bilera hau programatuta dago",
"meeting_is_scheduled_recurring": "Gertaera errepikariak programatuta daude",
@@ -216,25 +246,521 @@
"bookerlayout_month_view": "Hilabetea",
"bookerlayout_week_view": "Astero",
"bookerlayout_column_view": "Zutabea",
"bookerlayout_error_min_one_enabled": "Gutxienez ikuspegi bat gaituta egotea behar da.",
"bookerlayout_error_default_not_enabled": "Lehenetsitako ikuspegi bezala hautatu duzun diseinua ez dago gaitutako diseinuen artean.",
"bookerlayout_error_unknown_layout": "Hautatu duzun diseinua ez da baliozko diseinu bat.",
"sunday_time_error": "Ordu baliogabea igandean",
"monday_time_error": "Ordu baliogabea astelehenean",
"tuesday_time_error": "Ordu baliogabea asteartean",
"wednesday_time_error": "Ordu baliogabea asteazkenean",
"thursday_time_error": "Ordu baliogabea ostegunean",
"friday_time_error": "Ordu baliogabea ostiralean",
"saturday_time_error": "Ordu baliogabea larunbatean",
"error_end_time_before_start_time": "Amaiera-orduak ezin du hasiera-ordua baino lehenago izan",
"error_end_time_next_day": "Amaiera-denborak ezin du 24 ordu baino gehiago izan",
"back_to_bookings": "Itzuli erreserbatara",
"cancelled": "Bertan behera",
"really_cancel_booking": "Benetan bertan behera utzi nahi duzu zure erreserba?",
"cannot_cancel_booking": "Ezin duzu erreserba hau bertan behera utzi",
"booking_already_accepted_rejected": "Erreserba hau onartu edo errefusatu da dagoeneko",
"go_back_home": "Itzuli hasierara",
"or_go_back_home": "Edo itzuli hasierara",
"no_meeting_found": "Ez da bilerarik aurkitu",
"no_meeting_found_description": "Bilera hau ez dago. Jarri harremanetan bileraren jabearekin eguneratutako esteka lortzeko.",
"bookings": "Erreserbak",
"booking_not_found": "Erreserba ez da aurkitu",
"past_bookings": "Zure iraganeko erreserbak agertuko dira hemen.",
"unconfirmed_bookings": "Zure baieztatu gabeko erreserbak agertuko dira hemen.",
"unconfirmed_bookings_tooltip": "Baieztatu gabeko erreserbak",
"start_time": "Hasiera-ordua",
"end_time": "Amaiera-orduan",
"buffer_time": "Tarteko denbora",
"before_event": "Gertaeraren aurretik",
"after_event": "Gertaeraren ondoren",
"event_buffer_default": "Tarteko denborarik ez",
"buffer": "Tarteko denbora",
"your_day_starts_at": "Zure eguna hasten den ordua:",
"your_day_ends_at": "Zure eguna amaitzen den ordua:",
"change_available_times": "Aldatu libre zauden orduan",
"change_your_available_times": "Aldatu libre zauden orduak",
"change_start_end": "Aldatu zure egunaren hasiera- eta amaiera-orduak",
"change_start_end_buffer": "Ezarri zure egunaren hasiera- eta amaiera-ordua eta gutxieneko denbora tarte bat zure bileren artean.",
"current_start_date": "Unean, zure eguna honako orduan hasteko ezarrita dago:",
"start_end_changed_successfully": "Zure egunaren hasiera- eta amaiera-orduak egoki aldatu dira.",
"light": "Argia",
"dark": "Iluna",
"email": "Emaila",
"email_placeholder": "izena@adibidea.eus",
"full_name": "Izen osoa",
"booking_cancelled": "Erreserba bertan behera",
"booking_rescheduled": "Erreserbaren programazioa aldatuta",
"booking_created": "Erreserba sortuta",
"booking_rejected": "Erreserba ez onartua",
"booking_requested": "Erreserba eskatua",
"meeting_ended": "Bilera amaitu da",
"form_submitted": "Galdetegia bidali da",
"uh_oh": "Ai ama!",
"no_event_types_have_been_setup": "Erabiltzaile honek ez du gertaera-motarik konfiguratu oraindik.",
"edit_logo": "Editatu logoa",
"upload_a_logo": "Kargatu logo bat",
"upload_logo": "Kargatu logoa",
"remove_logo": "Ezabatu logoa",
"enable": "Gaitu",
"code": "Kodea",
"code_is_incorrect": "Kodea ez da zuzena.",
"add_time_availability": "Gehitu denbora-tarte berri bat",
"security": "Segurtasuna",
"manage_account_security": "Kudeatu zure kontuaren segurtasuna.",
"password": "Pasahitza",
"password_updated_successfully": "Pasahitza egoki eguneratu da",
"password_has_been_changed": "Zure pasahitza egoki aldatu da.",
"error_changing_password": "Errorea pasahitza aldatzean",
"session_timeout_change_error": "Errorea saioaren konfigurazioa eguneratzerakoan",
"something_went_wrong": "Zerbait gaizki joan da.",
"something_doesnt_look_right": "Zerbaitek ez du itxura onik?",
"please_try_again": "Saia zaitez berriro, mesedez.",
"super_secure_new_password": "Zure pasahitz berri super segurua",
"new_password": "Pasahitz berria",
"your_old_password": "Zure pasahitz zaharra",
"current_password": "Uneko pasahitza",
"change_password": "Aldatu pasahitza",
"change_secret": "Aldatu sekretua",
"new_password_matches_old_password": "Pasahitz berria zure pasahitz zaharrarekin bat dator. Aukeratu ezazu pasahitz ezberdin bat, mesedez.",
"current_incorrect_password": "Uneko pasahitza ez da zuzena",
"password_hint_caplow": "Maiuskulak eta minuskulak nahasian",
"password_hint_min": "Gutxienez 7 karaktereko luzera",
"password_hint_admin_min": "Gutxienez 15 karaktereko luzera",
"password_hint_num": "Gutxienez zenbaki bat",
"max_limit_allowed_hint": "{{limit}} karaktere edo gutxiagoko luzera izan behar du",
"invalid_password_hint": "Pasahitzak gutxienez {{passwordLength}} karaktereko luzera behar du gutxienez zenbaki bat eta letra maiuskula zein minuskulak nahasten dituela",
"incorrect_password": "Pasahitza ez da zuzena.",
"incorrect_email_password": "Emaila edo pasahitza ez dira zuzenak.",
"am_pm": "am/pm",
"january": "Urtarrila",
"february": "Otsaila",
"march": "Martxoa",
"april": "Apirila",
"may": "Maiatza",
"june": "Ekaina",
"july": "Uztaila",
"august": "Abuztua",
"september": "Iraila",
"october": "Urria",
"november": "Azaroa",
"december": "Abendua",
"monday": "Astelehena",
"tuesday": "Asteartea",
"wednesday": "Asteazkena",
"thursday": "Osteguna",
"friday": "Ostirala",
"saturday": "Larunbata",
"sunday": "Igandea",
"all_booked_today": "Dena erreserbatuta.",
"additional_guests": "Gehitu gonbidatuak",
"your_name": "Zure izena",
"your_full_name": "Zure izen osoa",
"no_name": "Izenik ez",
"enter_number_between_range": "Mesedez sartu 1 eta {{maxOccurences}} arteko zenbaki bat",
"email_address": "Email helbidea",
"enter_valid_email": "Mesedez, adierazi baliozko email helbide bat",
"location": "Kokapena",
"address": "Helbidea",
"enter_address": "Sartu helbidea",
"in_person_attendee_address": "Aurrez aurre (partaidearen helbidean)",
"yes": "bai",
"no": "ez",
"additional_notes": "Ohar gehigarriak",
"booking_fail": "Ezin izan da bilera erreserbatu.",
"reschedule_fail": "Ezin izan da bilera berrantolatu.",
"in_person_meeting": "Aurrez aurreko bilera",
"in_person": "Aurrez aurre (antolatzailearen helbidean)",
"phone_number": "Telefono zenbakia",
"attendee_phone_number": "Partaidearen telefono zenbakia",
"organizer_phone_number": "Antolatzailearen telefono zenbakia",
"enter_phone_number": "Sartu telefono zenbakia",
"reschedule": "Berrantolatu",
"or": "EDO",
"go_back": "Atzera",
"email_or_username": "Emaila edo erabiltzaile izena",
"send_invite_email": "Bidali gonbidapen-email bat",
"role": "Eginkizuna",
"edit_role": "Editatu eginkizuna",
"edit_team": "Editatu taldea",
"reject": "Baztertu",
"reject_all": "Baztertu guztiak",
"accept": "Onartu",
"profile": "Profila",
"my_team_url": "Nire taldearen URLa",
"my_teams": "Nire taldeak",
"team_name": "Taldearen izena",
"your_team_name": "Zure taldearen izena",
"team_updated_successfully": "Taldea egoki eguneratu da",
"your_team_updated_successfully": "Zure taldea egoki eguneratu da.",
"your_org_updated_successfully": "Zure erakundea egoki eguneratu da.",
"about": "Honi buruz",
"team_description": "Esaldi gutxi batzuk zure taldeari buruz. Zure taldearen orrialdean agertuko dira.",
"org_description": "Esaldi gutxi batzuk zure erakundeari buruz. Zure erakundearen orrialdean agertuko dira.",
"members": "Kideak",
"organization_members": "Erakundeko kideak",
"member": "Kidea",
"number_member_one": "{{count}} kide",
"danger_zone": "Arrisku gunea",
"account_deletion_cannot_be_undone": "Kontuz. Kontuak ezabatzea ezin da desegin.",
"back": "Atzera",
"cancel_event": "Bertan behera utzi gertaera",
"continue": "Jarraitu",
"confirm": "Baieztatu",
"confirm_all": "Baieztatu guztiak",
"confirm_remove_member": "Bai, ezabatu kidea",
"remove_member": "Ezabatu kidea",
"manage_your_team": "Kudeatu zure taldea",
"no_teams": "Oraindik ez daukazu talderik.",
"submit": "Bidali",
"delete": "Ezabatu",
"update": "Eguneratu",
"save": "Gorde",
"pending": "Egiteke",
"open_options": "Ireki aukerak",
"copy_link": "Kopiatu gertaerarako esteka",
"share": "Partekatu",
"copy_link_team": "Kopiatu talderako esteka",
"leave_team": "Utzi taldea",
"confirm_leave_team": "Bai, utzi taldea",
"leave_team_confirmation_message": "Ziur al zaude talde hau utzi nahi duzula? Ezingo duzu erreserbarik egin taldea erabiliz hemendik aurrera.",
"preview": "Aurreikusi",
"link_copied": "Esteka kopiatuta!",
"private_link_copied": "Esteka pribatua kopiatuta!",
"link_shared": "Esteka partekatuta!",
"title": "Izenburua",
"description": "Deskribapena",
"preview_team": "Aurreikusi taldea",
"duration": "Iraupena",
"available_durations": "Iraupen aukerak",
"default_duration": "Lehenetsitako iraupena",
"minutes": "minutu",
"username_placeholder": "erabiltzaile izena",
"count_members_one": "kide {{count}}",
"count_members_other": "{{count}} kide",
"url": "URLa",
"hidden": "Ezkutuan",
"readonly": "Irakurtzeko bakarrik",
"one_time_link": "Aldi bakarreko esteka",
"upload_avatar": "Kargatu abatarra",
"language": "Hizkuntza",
"timezone": "Ordu-eremua",
"first_day_of_week": "Asteko lehen eguna",
"plus_more": "{{count}} gehiago",
"create_team": "Sortu taldea",
"name": "Izena",
"create_new_team_description": "Sortu talde berri bat erabiltzaileekin elkarlanean aritzeko.",
"create_new_team": "Sortu talde berri bat",
"open_invitations": "Gonbidapen irekiak",
"new_team": "Talde berria",
"create_first_team_and_invite_others": "Sortu zure lehen taldea eta gonbidatu beste erabiltzaileak elkarlanean aritzera.",
"create_team_to_get_started": "Sortu talde bat hasteko",
"teams": "Taldeak",
"team": "Taldea",
"organization": "Erakundea",
"change_email_tip": "Saioa itxi eta berriro hasi beharko duzu aldaketa ikusi ahal izateko.",
"little_something_about": "Kontatu zuri buruzko zerbait.",
"profile_updated_successfully": "Profila egoki eguneratu da",
"your_user_profile_updated_successfully": "Zure erabiltzaile profila egoki eguneratu da.",
"enabled": "Gaituta",
"disabled": "Ezgaituta",
"disable": "Ezgaitu",
"billing": "Fakturazioa",
"manage_your_billing_info": "Kudeatu zure fakturaziorako informazioa eta amaitu zure harpidetza.",
"logo": "Logoa",
"error": "Errorea",
"team_logo": "Taldearen logoa",
"add_location": "Gehitu kokapena",
"attendees": "Partaideak",
"add_attendees": "Gehitu partaideak",
"label": "Etiketa",
"type": "Mota",
"edit": "Editatu",
"disable_notes": "Ezkutatu oharrak egutegian",
"recurring_event": "Gertaera errepikaria",
"disable_guests": "Ezgaitu gonbidatuak",
"private_link": "Sortu esteka pribatua",
"enable_private_url": "Gaitu URL pribatua",
"private_link_label": "Esteka pribatua",
"private_link_hint": "Zure esteka pribatua birsortu egingo da erabilera bakoitzaren ondoren",
"copy_private_link": "Kopiatu esteka pribatua",
"invitees_can_schedule": "Gobnidatuek programatu dezakete",
"set_address_place": "Ezarri helbide edo toki bat",
"set_link_meeting": "Ezarri esteka bat bilerarako",
"you_need_to_add_a_name": "Izena gehitu behar duzu",
"hide_event_type": "Ezkutatu gertaera mota",
"edit_location": "Editatu kokapena",
"quick_chat": "Elkarrizketa azkarra",
"add_new_event_type": "Gehitu gertaera mota berri bat",
"length": "Luzera",
"delete_event_type": "Ezabatu gertaera mota?",
"confirm_delete_event_type": "Bai, ezabatu",
"delete_account": "Ezabatu kontua",
"confirm_delete_account": "Bai, ezabatu kontua",
"settings": "Ezarpenak",
"event_type_moved_successfully": "Gertaera mota zuzen mugitu da",
"next_step_text": "Hurrengo pausoa",
"next_step": "Saltatu pausoa",
"prev_step": "Aurreko pausoa",
"install": "Instalatu",
"installed": "Instalatua",
"disconnect": "Deskonektatu",
"automation": "Automatizazioa",
"connect_additional_calendar": "Konektatu egutegi bat gehiago",
"calendar_updated_successfully": "Egutegia zuzen eguneratu da",
"calendar": "Egutegia",
"payments": "Ordainketak",
"not_installed": "Instalatu gabe",
"error_password_mismatch": "Pasahitzak ez datoz bat.",
"error_required_field": "Eremu hau nahitaezkoa da.",
"status": "Egoera",
"signin_with_google": "Hasi saioa Googlerekin",
"signin_with_saml": "Hasi saioa SAMLrekin",
"signin_with_saml_oidc": "Hasi saioa SAML/OIDCrekin",
"import": "Inportatu",
"import_from": "Inportatu hemendik:",
"featured_categories": "Nabarmendutako kategoriak",
"popular_categories": "Kategoria ospetsuak",
"most_popular": "Ospetsuenak",
"permissions": "Baimenak",
"terms_and_privacy": "Baldintzak eta pribatutasuna",
"subscribe": "Harpidetu",
"buy": "Erosi",
"categories": "Kategoriak",
"pricing": "Prezioak",
"learn_more": "Gehiago ikasi",
"privacy_policy": "Pribatutasun politika",
"terms_of_service": "Erabilera-baldintzak",
"remove": "Ezabatu",
"add": "Gehitu",
"installed_other": "{{count}} instalatuta",
"next_steps": "Hurrengo pausoak",
"error_404": "404 errorea",
"default": "Lehenetsitakoa",
"set_to_default": "Ezarri lehenetsitako gisa",
"new_schedule_btn": "Programazio berria",
"add_new_schedule": "Gehitu programazio berria",
"add_new_calendar": "Gehitu egutegi berria",
"delete_schedule": "Ezabatu programazioa",
"default_schedule_name": "Lanorduak",
"example_name": "Mikel Biteri",
"time_format": "Ordu-formatua",
"12_hour": "12 ordu",
"24_hour": "24 ordu",
"12_hour_short": "12o",
"24_hour_short": "24o",
"redirect_success_booking": "Birbideratu erreserbatzean ",
"create": "Sortu",
"copy_to_clipboard": "Kopiatu arbelera",
"copy": "Kopiatu",
"request_reschedule_booking": "Eskatu zure erreserba berrantolatzeko",
"reason_for_reschedule": "Berrantolatzeko arrazoia",
"book_a_new_time": "Erreserbatu momentu berri bat",
"reschedule_request_sent": "Berrantolatzeko eskaera bidalita",
"reschedule_modal_description": "Honek programatutako bilera bertan behera utziko du, programatzaileari jakinaraziko dio eta momentu berri bat hautatzeko eskatu.",
"reason_for_reschedule_request": "Berrantolatzea eskatzeko arrazoia",
"send_reschedule_request": "Berrantolatzeko eskatu ",
"edit_booking": "Aldatu erreserba",
"reschedule_booking": "Aldatu erreserbaren programazioa",
"former_time": "Lehengo ordua",
"confirmation_page_gif": "Gehitu GIF bat zure baieztapen-orrialdera",
"search": "Bilatu",
"make_team_private": "Bihurtu taldea pribatua",
"location_changed_event_type_subject": "Kokapena aldatu da: {{eventType}} {{name}}(r)ekin {{date}}(e)an",
"current_location": "Uneko kokapena",
"new_location": "Kokapen berria",
"session": "Saioa",
"session_description": "Kontrolatu zure kontuaren saioa",
"no_location": "Ez dago kokapenik definituta",
"set_location": "Ezarri kokapena",
"update_location": "Eguneratu kokapena",
"location_updated": "Kokapena eguneratuta",
"email_validation_error": "Honek ez du email helbide baten itxurarik",
"copy_code": "Kopiatu kodea",
"code_copied": "Kodea kopiatuta!",
"calendar_url": "Egutegiaren URLa",
"set_your_phone_number": "Ezarri telefono zenbaki bat bilerarako",
"display_location_label": "Erakutsi erreserba orrialdean",
"display_location_info_badge": "Kokapena ikusgarri egongo da erreserba baieztatu aurretik",
"add_gif": "Gehitu GIF bat",
"search_giphy": "Bilatu Giphyn",
"add_link_from_giphy": "Gehitu Giphyko esteka bat",
"add_gif_to_confirmation": "Zure baieztapen-orrialdera GIF bat gehitzen",
"find_gif_spice_confirmation": "Aurkitu GIF bat zure baieztapen-orrialdea alaitzeko",
"resources": "Baliabideak",
"support_documentation": "Laguntzako dokumentazioa",
"developer_documentation": "Garatzaileentzako dokumentazioa",
"get_in_touch": "Jar zaitez harremanetan",
"booking_details": "Erreserbaren xehetasunak",
"or_lowercase": "edo",
"go_to": "Joan hona: ",
"event_location": "Gertaeraren kokapena",
"reschedule_optional": "Berrantolatzeko arrazoia (aukerakoa)",
"reschedule_placeholder": "Jakinarazi besteei zergatik behar duzun berrantolatzea",
"event_cancelled": "Gertaera hau bertan behera geratu da",
"emailed_information_about_cancelled_event": "Email bat bidali diegu guztiei jakinaren gainean egon daitezen.",
"meeting_url_in_confirmation_email": "Bileraren URLa baieztapen emailean dago",
"url_start_with_https": "URLak http:// edo https:// hasi behar du",
"number_provided": "Telefono zenbakia emango da",
"before_event_trigger": "gertaera hasi aurretik",
"event_cancelled_trigger": "gertaera bertan behera geratzen denean",
"new_event_trigger": "gertaera berri bat erreserbatzen denean",
"email_host_action": "bidali emaila anfitrioiari",
"email_attendee_action": "bidali emaila partaideei",
"sms_attendee_action": "Bidali SMSa partaideari",
"sms_number_action": "bidali SMSa zenbaki jakin batera",
"whatsapp_number_action": "bidali Whatsapp mezua zenbaki jakin batera",
"whatsapp_attendee_action": "bidali Whatsapp mezua partaideari",
"reschedule_event_trigger": "gertaera berrantolatzen denean",
"day_timeUnit": "egun",
"hour_timeUnit": "ordu",
"minute_timeUnit": "minutu",
"current": "Unekoa",
"confirm_username_change_dialog_title": "Baieztatu erabiltzaile-izenaren aldaketa",
"requires_confirmation": "Baieztapena behar du",
"always_requires_confirmation": "Beti",
"email_body": "Emailaren gorputza",
"text_message": "Testu mezua",
"choose_template": "Hautatu txantiloi bat",
"reminder": "Gogorarazlea",
"rescheduled": "Berrantolatua",
"completed": "Osatuta",
"reminder_email": "Gogorarazpena: {{eventType}} {{name}}(r)ekin {{date}}(e)an",
"minute_one": "minutu {{count}}",
"minute_other": "{{count}} minutu",
"hour_one": "ordu {{count}}",
"hour_other": "{{count}} ordu",
"attendee_name": "Partaidearen izena",
"scheduler_full_name": "Erreserba egiten duen pertsonaren izen osoa",
"no_active_event_types": "Ez dago gertaera mota aktiborik",
"new_seat_subject": "{{name}} parte-hartzaile berria {{eventType}}(e)n {{date}}(e)an",
"new_seat_title": "Norbaitek bere burua gehitu du gertaera batera",
"variable": "Aldagaia",
"event_name_variable": "Gertaeraren izena",
"attendee_name_variable": "Partaidea",
"event_date_variable": "Gertaeraren data",
"event_time_variable": "Gertaeraren ordua",
"timezone_variable": "Ordu-eremua",
"location_variable": "Kokapena",
"additional_notes_variable": "Ohar gehigarriak",
"organizer_name_variable": "Antolatzailearen izena",
"invalid_number": "Telefono zenbaki baliogabea",
"navigate": "Nabigatu",
"open": "Ireki",
"close": "Itxi",
"upgrade": "Bertsio-berritu",
"upgrade_to_access_recordings_title": "Bertsio-berritu grabaketetarako sarbidea izateko",
"show_eventtype_on_profile": "Erakutsi profilean",
"new_username": "Erabiltzaile izen berria",
"current_username": "Uneko erabiltzaile izena",
"example_1": "1. adibidea",
"example_2": "2. adibidea",
"company_size": "Enpresaren tamaina",
"what_help_needed": "Zerekin behar duzu laguntza?",
"notification_sent": "Jakinarazpena bidalita",
"event_advanced_tab_title": "Aurreratua",
"do_this": "Egin honakoa",
"turn_off": "Itzali",
"turn_on": "Piztu",
"settings_updated_successfully": "Ezarpenak egoki eguneratu dira",
"error_updating_settings": "Errorea gertatu da ezarpenak eguneratzerakoan",
"bio_hint": "Esaldi gutxi batzuk zeuri buruz. Zure orrialde pertsonalean agertuko dira.",
"user_has_no_bio": "Erabiltzaile honek ez du bio bat gehitu oraindik.",
"bio": "Bio",
"delete_account_modal_title": "Ezabatu kontua",
"delete_my_account": "Ezabatu nire kontua",
"start_of_week": "Astearen hasiera",
"recordings_title": "Grabaketak",
"recording": "Grabaketa",
"happy_scheduling": "Programazio zoriontsua",
"select_calendars": "Hautatu zein egutegitan egiaztatu nahi duzun talkarik ote dagoen, erreserba bikoitzak saihesteko.",
"check_for_conflicts": "Egiaztatu talkak",
"view_recordings": "Grabaketak ikusi",
"adding_events_to": "Gertaerak hona gehitzen:",
"pro": "Pro",
"profile_picture": "Profileko irudia",
"upload": "Kargatu",
"add_profile_photo": "Gehitu profileko argazkia",
"web3": "Web3",
"old_password": "Pasahitz zaharra",
"secure_password": "Zure pasahitz berri super segurua",
"error_updating_password": "Errorea pasahitza eguneratzean",
"today": "gaur",
"appearance": "Itxura",
"my_account": "Nire kontua",
"general": "Orokorra",
"calendars": "Egutegiak",
"invoices": "Fakturak",
"users": "Erabiltzaileak",
"user": "Erabiltzailea",
"users_description": "Hemen erabiltzaile guztien zerrenda aurkituko duzu",
"add_variable": "Gehitu aldagaia",
"message_template": "Mezuen txantiloia",
"email_subject": "Emailaren gaia",
"event_name_info": "Gertaeraren motaren izena",
"event_date_info": "Gertaeraren data",
"event_time_info": "Gertaeraren hasiera-ordua",
"location_info": "Gertaeraren kokapena",
"additional_notes_info": "Erreserbaren ohar gehigarriak",
"organizer_name_info": "Antolatzailearen izena",
"download_responses": "Deskargatu erantzunak",
"download": "Deskargatu",
"download_recording": "Deskargatu grabaketa",
"create_your_first_form": "Sortu zure lehen galdetegia",
"profile_team_description": "Kudeatu zure talde-profilaren ezarpenak",
"profile_org_description": "Kudeatu zure erakunde-profilaren ezarpenak",
"members_team_description": "Talde honetan diren erabiltzaileak",
"organization_description": "Kudeatu zure erakundeko administrari eta kideak",
"team_url": "Taldearen URLa",
"team_members": "Taldekideak",
"more": "Gehiago",
"workflow_example_1": "Bidali SMS gogorarazlea gertaera hasi baino 24 ordu lehenago partaideari",
"workflow_example_4": "Bidali email gogorarazlea gertaerak hasi baino ordubete lehenago partaideari",
"edit_form_later_subtitle": "Geroago editatu ahal izango duzu.",
"connect_calendar_later": "Geroago konektatuko dut nire egutegia",
"booking_appearance": "Erreserba-orriaren itxura",
"add_a_team": "Gehitu taldea",
"password_updated": "Pasahitza eguneratuta!",
"pending_payment": "Ordainketa egiteke",
"pending_invites": "Zain dauden gonbidapenak",
"no_calendar_installed": "Ez dago egutegirik instalatuta",
"no_calendar_installed_description": "Oraindik ez duzu zure egutegietako bat ere konektatu",
"add_a_calendar": "Gehitu egutegia",
"change_email_hint": "Saioa itxi eta berriro hasi beharko duzu edozein aldaketa ikusi ahal izateko",
"confirm_password_change_email": "Mesedez, baieztatu zure pasahitza, email helbidea aldatu aurretik",
"seats": "eserleku",
"limit_booking_frequency": "Mugatu erreserbatzeko maiztasuna",
"calendar_connection_fail": "Egutegia konektatzeak huts egin du",
"booking_confirmation_success": "Erreserba egoki baieztatu da",
"booking_rejection_success": "Erreserba baztertzea zuzen egin da",
"booking_tentative": "Erreserba hau behin-behinekoa da",
"booking_accept_intent": "Iepa, onartu egin nahi dut",
"we_wont_show_again": "Ez dugu hau berriro erakutsiko",
"couldnt_update_timezone": "Ezin izan dugu ordu-eremua eguneratu",
"updated_timezone_to": "Ordu-eremua eguneratua honakora: {{formattedCurrentTz}}",
"update_timezone": "Eguneratu ordu-eremua",
"update_timezone_question": "Eguneratu ordu-eremua?",
"dont_update": "Ez eguneratu",
"email_address_action": "bidali emaila email helbide jakin batera",
"after_event_trigger": "gertaera bukatu ondoren",
"how_long_after": "Gertaera bukatzen denetik zenbat denborara?",
"add_calendar": "Gehitu egutegia",
"limit_future_bookings": "Mugatu etorkizuneko erreserbak",
"no_event_types": "Ez dago gertaera motarik ezarrita",
"no_event_types_description": "{{name}}(e)k ez du erreserbatu dezakezun gertaera motarik ezarri.",
"error_creating_team": "Errorea taldea sortzean",
"you": "Zu",
"resend_email": "Berbidali emaila",
"member_already_invited": "Kidea gonbidatua izan da lehendik ere",
"enter_email_or_username": "Sartu email edo erabiltzaile izen bat",
"team_name_taken": "Izen hau dagoeneko hartua dago",
"must_enter_team_name": "Taldearentzat izen bat behar da",
"fill_this_field": "Mesedez, bete ezazu eremu hau",
"options": "Aukerak",
"add_an_option": "Gehitu aukera bat",
"radio": "Irratia",
"all_bookings_filter_label": "Erreserba guztiak"
}
@@ -268,6 +268,7 @@
"set_availability": "Définissez vos disponibilités",
"availability_settings": "Paramètres de disponibilité",
"continue_without_calendar": "Continuer sans calendrier",
"continue_with": "Continuer avec {{appName}}",
"connect_your_calendar": "Connectez votre calendrier",
"connect_your_video_app": "Connectez vos applications vidéo",
"connect_your_video_app_instructions": "Connectez vos applications vidéo pour les utiliser sur vos types d'événements.",
@@ -599,6 +600,7 @@
"hide_book_a_team_member": "Masquer le bouton Réserver un membre d'équipe",
"hide_book_a_team_member_description": "Masquez le bouton Réserver un membre d'équipe de vos pages publiques.",
"danger_zone": "Zone de danger",
"account_deletion_cannot_be_undone": "Attention, la suppression de compte est irréversible.",
"back": "Retour",
"cancel": "Annuler",
"cancel_all_remaining": "Annuler tous les événements restants",
@@ -688,6 +690,7 @@
"people": "Personnes",
"your_email": "Votre adresse e-mail",
"change_avatar": "Changer d'avatar",
"upload_avatar": "Télécharger un avatar",
"language": "Langue",
"timezone": "Fuseau horaire",
"first_day_of_week": "Premier jour de la semaine",
@@ -778,6 +781,7 @@
"disable_guests": "Désactiver les invités",
"disable_guests_description": "Désactivez l'ajout d'invités supplémentaires lors de la réservation.",
"private_link": "Générer un lien privé",
"enable_private_url": "Rendre le lien privé",
"private_link_label": "Lien privé",
"private_link_hint": "Votre lien privé sera régénéré après chaque utilisation",
"copy_private_link": "Copier le lien privé",
@@ -1276,6 +1280,7 @@
"personal_cal_url": "Mon lien {{appName}} personnel",
"bio_hint": "Quelques mots à propos de vous. Ces informations apparaîtront sur votre page publique.",
"user_has_no_bio": "Cet utilisateur n'a pas encore ajouté de description.",
"bio": "Bio",
"delete_account_modal_title": "Supprimer le compte",
"confirm_delete_account_modal": "Voulez-vous vraiment supprimer votre compte {{appName}} ?",
"delete_my_account": "Supprimer mon compte",
@@ -1595,6 +1600,7 @@
"options": "Options",
"enter_option": "Entrer l'option {{index}}",
"add_an_option": "Ajouter une option",
"location_already_exists": "Ce lieu existe déjà. Veuillez en sélectionner un nouveau.",
"radio": "Radio",
"google_meet_warning": "Pour utiliser Google Meet, vous devez définir votre calendrier de destination sur un calendrier Google",
"individual": "Particulier",
@@ -1879,6 +1885,7 @@
"edit_invite_link": "Modifier les paramètres du lien",
"invite_link_copied": "Lien d'invitation copié",
"invite_link_deleted": "Lien d'invitation supprimé",
"api_key_deleted": "Clé API supprimée",
"invite_link_updated": "Paramètres de lien d'invitation enregistrés",
"link_expires_after": "Les liens ont été définis pour expirer après...",
"one_day": "1 jour",
@@ -2051,5 +2058,18 @@
"no_members_found": "Aucun membre trouvé",
"event_setup_length_error": "Configuration de l'événement : la durée doit être d'au moins 1 minute.",
"availability_schedules": "Horaires de disponibilité",
"unauthorized": "Non autorisé",
"select_account_team": "Sélectionner un compte ou une équipe",
"access_event_type": "Lire, modifier, supprimer vos types d'événements",
"access_availability": "Lire, modifier, supprimer vos disponibilités",
"access_bookings": "Lire, modifier, supprimer vos réservations",
"allow_client_to_do": "Autoriser {{clientName}} à faire cela ?",
"allow": "Autoriser",
"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",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Connecté avec",
"vital_app_sleep_automation": "Automatisation de la reprogrammation du sommeil",
"vital_app_automation_description": "Vous pouvez sélectionner différents paramètres pour déclencher la reprogrammation en fonction de vos paramètres de sommeil.",
"vital_app_parameter": "Paramètre",
"vital_app_trigger": "Trigger inférieur ou égal à",
"vital_app_save_button": "Enregistrer la configuration",
"vital_app_total_label": "Total (total = rem + sommeil léger + sommeil profond)",
"vital_app_duration_label": "Durée (durée = heure de fin du coucher - début du sommeil)",
"vital_app_hours": "heures",
"vital_app_save_success": "Enregistrement de vos configurations Vital réussi",
"vital_app_save_error": "Une erreur est survenu lors de l'enregistrement de vos configurations Vital"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "מחובר/ת דרך",
"vital_app_sleep_automation": "תזמון מחדש באופן אוטומטי על סמך נתוני השינה שלך",
"vital_app_automation_description": "ניתן לבחור פרמטרים שונים כדי להפעיל את קביעת המועד מחדש על סמך מדדי השינה שלך.",
"vital_app_parameter": "פרמטר",
"vital_app_trigger": "להפעיל כאשר הערך הוא פחות מ- או שווה ל-",
"vital_app_save_button": "שמירת התצורה",
"vital_app_total_label": "סה\"כ (סה\"כ = שנת REM + שינה קלה + שינה עמוקה)",
"vital_app_duration_label": "משך זמן (משך זמן = שעת סיום זמן שינה פחות שעת תחילת זמן שינה)",
"vital_app_hours": "שעות",
"vital_app_save_success": "שמירת ה-Vital Configurations שלך בוצעה בהצלחה",
"vital_app_save_error": "אירעה שגיאה במהלך שמירת ה-Vital Configurations שלך"
}
@@ -1 +0,0 @@
{}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Connesso con",
"vital_app_sleep_automation": "Automazione della riprogrammazione in base ai dati del tuo sonno",
"vital_app_automation_description": "Puoi scegliere vari parametri per attivare la riprogrammazione in base ai parametri del tuo sonno.",
"vital_app_parameter": "Parametro",
"vital_app_trigger": "Attiva se inferiore o uguale a",
"vital_app_save_button": "Salva configurazione",
"vital_app_total_label": "Totale (totale = REM + sonno leggero + sonno profondo)",
"vital_app_duration_label": "Durata (durata = fine del sonno - inizio del sonno)",
"vital_app_hours": "ore",
"vital_app_save_success": "Configurazioni di Vital salvate correttamente",
"vital_app_save_error": "Si è verificato un errore durante il salvataggio delle configurazioni di Vital"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "接続先",
"vital_app_sleep_automation": "Sleeping リスケジュールの自動設定",
"vital_app_automation_description": "リスケジュールのトリガーとなるパラメータは、スリーピングメトリクスに基づいてさまざまに選択できます。",
"vital_app_parameter": "パラメータ",
"vital_app_trigger": "以下でトリガー",
"vital_app_save_button": "設定を保存",
"vital_app_total_label": "合計(合計 = レム + 軽い睡眠 + 深い睡眠)",
"vital_app_duration_label": "持続時間(持続時間 = 起床時間 - 就寝時間)",
"vital_app_hours": "時間",
"vital_app_save_success": "Vital 設定の保存に成功しました",
"vital_app_save_error": "Vital 設定の保存中にエラーが発生しました"
}
@@ -0,0 +1,255 @@
{
"identity_provider": "អ្នកផ្តល់អត្តសញ្ញាណ",
"trial_days_left": "អ្នកនៅសល់ $t(day, {\"count\": {{days}} }) ទៀតក្នុងការសាកល្បង PRO របស់អ្នក។",
"day_one": "{{count}} ថ្ងៃ",
"day_other": "{{count}} ថ្ងៃ",
"second_one": "{{count}} នាទី",
"second_other": "{{count}} នាទី",
"upgrade_now": "ធ្វើបច្ចុប្បន្នភាពឥឡូវនេះ",
"accept_invitation": "ទទួលយកការអញ្ជើញ",
"calcom_explained": "{{appName}} ផ្តល់ហេដ្ឋារចនាសម្ព័ន្ធកំណត់ពេលសម្រាប់មនុស្សគ្រប់គ្នា។",
"calcom_explained_new_user": "បញ្ចប់ការកំណត់របស់អ្នក {{appName}} គណនី! អ្នកនៅសល់តែប៉ុន្មានជំហានទៀតប៉ុណ្ណោះ ក្នុងការដោះស្រាយបញ្ហាការកំណត់កាលវិភាគរបស់អ្នក។",
"have_any_questions": "អ្នកមានសំណួរឬ? យើងនៅទីនេះដើម្បីជួយ។",
"reset_password_subject": "{{appName}}: កំណត់ការណែនាំពាក្យសម្ងាត់ឡើងវិញ",
"verify_email_subject": "{{appName}}: ផ្ទៀងផ្ទាត់គណនីរបស់អ្នក។",
"check_your_email": "ពិនិត្យអ៊ីមែលរបស់អ្នក។",
"verify_email_page_body": "យើងបានផ្ញើអ៊ីមែលទៅ {{email}}។ វាមានសារៈសំខាន់ណាស់ក្នុងការផ្ទៀងផ្ទាត់អាសយដ្ឋានអ៊ីមែលរបស់អ្នក ដើម្បីធានានូវលទ្ធភាពចែកចាយអ៊ីមែល និងប្រតិទិនដ៏ល្អបំផុតពី {{appName}}.",
"verify_email_banner_body": "ផ្ទៀងផ្ទាត់អាសយដ្ឋានអ៊ីមែលរបស់អ្នក ដើម្បីធានានូវលទ្ធភាពចែកចាយអ៊ីមែល និងប្រតិទិនដ៏ល្អបំផុត",
"verify_email_email_header": "ផ្ទៀងផ្ទាត់អាសយដ្ឋានអ៊ីមែលរបស់អ្នក។",
"verify_email_email_button": "ផ្ទៀងផ្ទាត់អ៊ីមែល",
"copy_somewhere_safe": "រក្សាទុក API key នេះនៅកន្លែងណាដែលមានសុវត្ថិភាព។ អ្នកនឹងមិនអាចមើលវាម្តងទៀតបានទេ។",
"verify_email_email_body": "សូមផ្ទៀងផ្ទាត់អ៊ីមែលរបស់អ្នកដោយចុចប៊ូតុងខាងក្រោម។",
"verify_email_by_code_email_body": "សូមផ្ទៀងផ្ទាត់អាសយដ្ឋានអ៊ីមែលរបស់អ្នកដោយប្រើលេខកូដខាងក្រោម។",
"verify_email_email_link_text": "នេះជាតំណភ្ជាប់ក្នុងករណីដែលអ្នកមិនចូលចិត្តចុចប៊ូតុង៖",
"email_verification_code": "សូម​បញ្ចូល​កូដ",
"email_verification_code_placeholder": "បញ្ចូលលេខកូដផ្ទៀងផ្ទាត់ដែលបានផ្ញើទៅអ៊ីមែលរបស់អ្នក។",
"incorrect_email_verification_code": "លេខកូដផ្ទៀងផ្ទាត់មិនត្រឹមត្រូវទេ។",
"email_sent": "អ៊ីមែលត្រូវបានផ្ញើដោយជោគជ័យ",
"email_not_sent": "កំហុសបានកើតឡើងនៅពេលផ្ញើអ៊ីមែល",
"event_declined_subject": "បានបដិសេធ៖ {{title}} នៅ {{date}}",
"event_cancelled_subject": "បានលុបចោល៖ {{title}} នៅ {{date}}",
"event_request_declined": "សំណើព្រឹត្តិការណ៍របស់អ្នកត្រូវបានបដិសេធ",
"event_request_declined_recurring": "សំណើព្រឹត្តិការណ៍កើតឡើងដដែលៗរបស់អ្នកត្រូវបានបដិសេធ",
"event_request_cancelled": "ព្រឹត្តិការណ៍ដែលបានគ្រោងទុករបស់អ្នកត្រូវបានលុបចោល",
"organizer": "អ្នករៀបចំ",
"need_to_reschedule_or_cancel": "ត្រូវ​ការ​កំណត់​ពេល​វេលា​ឡើងវិញ​ឬ​បោះបង់?",
"no_options_available": "មិនមានជម្រើសទេ។",
"cancellation_reason": "ហេតុផលសម្រាប់ការលុបចោល (មិនចាំបាច់)",
"cancellation_reason_placeholder": "ហេតុអ្វីបានជាអ្នកលុបចោល?",
"rejection_reason": "ហេតុផលសម្រាប់ការបដិសេធ",
"rejection_reason_title": "បដិសេធសំណើកក់?",
"rejection_reason_description": "តើអ្នកប្រាកដថាចង់បដិសេធការកក់នេះទេ? យើង​នឹង​ឲ្យ​អ្នក​ដែល​ព្យាយាម​កក់​នោះ​ដឹង។ អ្នកអាចផ្តល់ហេតុផលខាងក្រោម។",
"rejection_confirmation": "បដិសេធការកក់",
"manage_this_event": "គ្រប់គ្រងព្រឹត្តិការណ៍នេះ។",
"invite_team_member": "អញ្ជើញសមាជិកក្រុម",
"invite_team_individual_segment": "អញ្ជើញជាបុគ្គល",
"invite_team_bulk_segment": "ការនាំចូលច្រើន",
"your_event_has_been_scheduled": "ព្រឹត្តិការណ៍របស់អ្នកត្រូវបានកំណត់ពេល",
"your_event_has_been_scheduled_recurring": "ព្រឹត្តិការណ៍កើតឡើងដដែលៗរបស់អ្នកត្រូវបានកំណត់ពេល",
"accept_our_license": "ទទួលយកអាជ្ញាប័ណ្ណរបស់យើងដោយការផ្លាស់ប្តូរ .env អថេរ <1>NEXT_PUBLIC_LICENSE_CONSENT</1> ទៅ '{{agree}}'.",
"remove_banner_instructions": "ដើម្បីលុបបដានេះ សូមបើកឯកសារ .env របស់អ្នក ហើយផ្លាស់ប្តូរ <1>NEXT_PUBLIC_LICENSE_CONSENT</1> អថេរទៅ '{{agree}}'.",
"error_message": "សារកំហុសគឺ៖ '{{errorMessage}}'",
"refund_failed_subject": "ការសងប្រាក់វិញបានបរាជ័យ៖ {{name}} - {{date}} - {{eventType}}",
"refund_failed": "ការសងប្រាក់វិញសម្រាប់ព្រឹត្តិការណ៍ {{eventType}} សម្រាប់ {{userName}} នៅថ្ងៃ {{date}} បរាជ័យ។",
"check_with_provider_and_user": "សូមពិនិត្យជាមួយអ្នកផ្តល់សេវាទូទាត់របស់អ្នក និង {{user}} របៀបដោះស្រាយនេះ។",
"a_refund_failed": "ការសងប្រាក់វិញបានបរាជ័យ",
"awaiting_payment_subject": "កំពុងរង់ចាំការទូទាត់៖ {{title}} នៅថ្ងៃ {{date}}",
"meeting_awaiting_payment": "ការប្រជុំរបស់អ្នកកំពុងរង់ចាំការបង់ប្រាក់",
"help": "ជំនួយ",
"price": "តម្លៃ",
"paid": "បង់ប្រាក់",
"refunded": "សងប្រាក់វិញ។",
"payment": "ការទូទាត់",
"missing_card_fields": "ប្រអប់ កាត ត្រូវតែបំពេញ",
"pay_now": "បង់ប្រាក់ឥឡូវនេះ",
"codebase_has_to_stay_opensource": "មូលដ្ឋានកូដត្រូវតែរក្សាប្រភពបើកចំហ ទោះបីជាវាត្រូវបានកែប្រែឬអត់ក៏ដោយ។",
"cannot_repackage_codebase": "អ្នកមិនអាចវេចខ្ចប់ឡើងវិញ ឬលក់មូលដ្ឋានកូដបានទេ។",
"acquire_license": "ទទួលបានអាជ្ញាប័ណ្ណពាណិជ្ជកម្ម ដើម្បីលុបលក្ខខណ្ឌទាំងនេះដោយការផ្ញើអ៊ីមែល",
"terms_summary": "សេចក្តីសង្ខេបនៃលក្ខខណ្ឌ",
"open_env": "បើកឯកសារ .env ហើយយល់ព្រមនឹងអាជ្ញាប័ណ្ណរបស់យើង។",
"env_changed": "ខ្ញុំបានផ្លាស់ប្តូរឯកសារ .env របស់ខ្ញុំ",
"accept_license": "ទទួលយកអាជ្ញាប័ណ្ណ",
"still_waiting_for_approval": "ព្រឹត្តិការណ៍មួយនៅតែរង់ចាំការយល់ព្រម",
"event_is_still_waiting": "សំណើព្រឹត្តិការណ៍កំពុងរង់ចាំ៖ {{attendeeName}} - {{date}} - {{eventType}}",
"no_more_results": "មិនមានលទ្ធផលទៀតទេ",
"no_results": "គ្មាន​លទ្ធផល",
"load_more_results": "ផ្ទុកលទ្ធផលបន្ថែមទៀត",
"integration_meeting_id": "{{integrationName}} លេខសម្គាល់ការប្រជុំ៖ {{meetingId}}",
"confirmed_event_type_subject": "បញ្ជាក់៖ {{eventType}} ជាមួយ {{name}} នៅ {{date}}",
"new_event_request": "សំណើព្រឹត្តិការណ៍ថ្មី៖ {{attendeeName}} - {{date}} - {{eventType}}",
"confirm_or_reject_request": "បញ្ជាក់ ឬបដិសេធសំណើ",
"check_bookings_page_to_confirm_or_reject": "ពិនិត្យមើលទំព័រកក់របស់អ្នក ដើម្បីបញ្ជាក់ ឬបដិសេធការកក់។",
"event_awaiting_approval": "ព្រឹត្តិការណ៍មួយកំពុងរង់ចាំការយល់ព្រមរបស់អ្នក។",
"event_awaiting_approval_recurring": "ព្រឹត្តិការណ៍កើតឡើងដដែលៗកំពុងរង់ចាំការយល់ព្រមរបស់អ្នក។",
"someone_requested_an_event": "មាននរណាម្នាក់បានស្នើសុំរៀបចំព្រឹត្តិការណ៍មួយនៅលើប្រតិទិនរបស់អ្នក។",
"someone_requested_password_reset": "មាននរណាម្នាក់បានស្នើសុំតំណដើម្បីផ្លាស់ប្តូរពាក្យសម្ងាត់របស់អ្នក។",
"password_reset_email_sent": "ប្រសិនបើអ៊ីមែលនេះមាននៅក្នុងប្រព័ន្ធរបស់យើង អ្នកគួរតែទទួលបានអ៊ីមែលកំណត់ឡើងវិញ។",
"password_reset_instructions": "ប្រសិនបើអ្នកមិនបានស្នើសុំវាទេ អ្នកអាចមិនអើពើអ៊ីមែលនេះដោយសុវត្ថិភាព ហើយពាក្យសម្ងាត់របស់អ្នកនឹងមិនត្រូវបានផ្លាស់ប្តូរទេ។",
"event_awaiting_approval_subject": "កំពុងរង់ចាំការអនុម័ត៖ {{title}} នៅថ្ងៃ {{date}}",
"event_still_awaiting_approval": "ព្រឹត្តិការណ៍មួយកំពុងរង់ចាំការយល់ព្រមរបស់អ្នក។",
"booking_submitted_subject": "បានដាក់ស្នើការកក់ទុក៖ {{title}} នៅថ្ងៃ {{date}}",
"download_recording_subject": "ទាញយកការថត៖ {{title}} នៅថ្ងៃ {{date}}",
"download_your_recording": "ទាញយកការថតរបស់អ្នក។",
"your_meeting_has_been_booked": "ការប្រជុំរបស់អ្នកត្រូវបានកក់ទុក",
"event_type_has_been_rescheduled_on_time_date": "{{title}} របស់អ្នកត្រូវបានកំណត់ពេលវេលាឡើងវិញ ទៅថ្ងៃ {{date}}.",
"event_has_been_rescheduled": "បានធ្វើបច្ចុប្បន្នភាព - ព្រឹត្តិការណ៍របស់អ្នកត្រូវបានកំណត់ពេលឡើងវិញ",
"request_reschedule_subtitle": "{{organizer}} បានលុបចោលការកក់ ហើយស្នើឱ្យអ្នកជ្រើសរើសពេលផ្សេងទៀត។",
"request_reschedule_title_organizer": "អ្នកបានស្នើសុំ {{attendee}} ដើម្បីរៀបចំកាលវិភាគឡើងវិញ",
"request_reschedule_subtitle_organizer": "អ្នកបានលុបចោលការកក់ហើយ {{attendee}} គួរតែជ្រើសរើសពេលវេលាកក់ថ្មីជាមួយអ្នក។",
"rescheduled_event_type_subject": "សំណើសម្រាប់កាលវិភាគត្រូវបានផ្ញើឡើងវិញ៖ {{eventType}} ជាមួយ {{name}} នៅថ្ងៃ {{date}}",
"requested_to_reschedule_subject_attendee": "ត្រូវតែកាលវិភាគឡើងវិញ: សូមកក់ពេលវេលាថ្មីសម្រាប់ {{eventType}} ជាមួយ {{name}}",
"hi_user_name": "សួស្តី {{name}}",
"ics_event_title": "{{eventType}} ជាមួយ {{name}}",
"new_event_subject": "ព្រឹត្តិការណ៍ថ្មី៖ {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "ចូលរួមដោយ {{entryPoint}}",
"notes": "កំណត់ចំណាំ",
"manage_my_bookings": "គ្រប់គ្រងការកក់របស់ខ្ញុំ",
"need_to_make_a_change": "ត្រូវ​ការ​ផ្លាស់​ប្តូ​រ​?",
"new_event_scheduled": "ព្រឹត្តិការណ៍ថ្មីមួយត្រូវបានកំណត់ពេល។",
"new_event_scheduled_recurring": "ព្រឹត្តិការណ៍កើតឡើងម្តងទៀតត្រូវបានកំណត់ពេល។",
"invitee_email": "អ៊ីមែលអញ្ជើញ",
"invitee_timezone": "តំបន់ពេលវេលាអញ្ជើញ",
"time_left": "ពេលវេលានៅសល់",
"event_type": "ប្រភេទព្រឹត្តិការណ៍",
"enter_meeting": "ចូលប្រជុំ",
"video_call_provider": "អ្នកផ្តល់សេវាហៅជាវីដេអូ",
"meeting_id": "លេខសម្គាល់ការប្រជុំ",
"meeting_password": "ពាក្យសម្ងាត់ការប្រជុំ",
"meeting_url": "URL ការប្រជុំ",
"meeting_request_rejected": "សំណើប្រជុំរបស់អ្នកត្រូវបានបដិសេធ",
"rejected_event_type_with_organizer": "ច្រានចោល៖ {{eventType}} ជាមួយ {{organizer}} នៅថ្ងៃ {{date}}",
"hi": "សួស្តី",
"join_team": "ចូលរួមក្រុម",
"manage_this_team": "គ្រប់គ្រងក្រុមនេះ",
"team_info": "ព័ត៌មានក្រុម",
"request_another_invitation_email": "ប្រសិនបើអ្នកមិនចង់ប្រើ {{toEmail}} ជា {{appName}} របស់អ្នក អ៊ីម៉ែលឬ គណនី {{appName}} មានរួចហើយ, សូមស្នើសុំការអញ្ជើញមួយផ្សេងទៀតទៅកាន់អ៊ីមែលនោះ។",
"you_have_been_invited": "អ្នកត្រូវបានអញ្ជើញឱ្យចូលរួមក្រុម {{teamName}}",
"user_invited_you": "{{user}} បានអញ្ជើញអ្នកឱ្យចូលរួម {{entity}} {{team}} នៅលើ {{appName}}",
"hidden_team_member_title": "អ្នកត្រូវបានលាក់នៅក្នុងក្រុមនេះ។",
"hidden_team_member_message": "កៅអីរបស់អ្នកមិនត្រូវបានបង់ទេ ទាំង Upgrade ទៅ PRO ឬអនុញ្ញាតឱ្យម្ចាស់ក្រុមដឹងថាពួកគេអាចបង់ប្រាក់សម្រាប់កៅអីរបស់អ្នក។",
"hidden_team_owner_message": "អ្នក​ត្រូវ​ការ​គណនី​គាំទ្រ​ដើម្បី​ប្រើ​ក្រុម អ្នក​ត្រូវ​បាន​លាក់​រហូត​ដល់​អ្នក Upgrade",
"link_expires": "p.s. វាផុតកំណត់នៅក្នុង {{expiresIn}} ម៉ោង",
"upgrade_to_per_seat": "Upgrade ទៅ Per-Seat",
"seat_options_doesnt_support_confirmation": "ជម្រើសកៅអីមិនគាំទ្រតម្រូវការបញ្ជាក់ទេ។",
"team_upgrade_seats_details": "ក្នុងចំណោមសមាជិក {{memberCount}} នាក់នៅក្នុងក្រុមរបស់អ្នក។, {{unpaidCount}} កន្លែងអង្គុយមិនបង់ប្រាក់ទេ។ នៅ ${{seatPrice}}/ខែក្នុងមួយកៅអី តម្លៃសរុបប៉ាន់ស្មាននៃសមាជិកភាពរបស់អ្នកគឺ ${{totalCost}}/ខែ.",
"team_upgrade_banner_description": "អ្នកមិនទាន់បានបញ្ចប់ការរៀបចំក្រុមរបស់អ្នកទេ។ ក្រុមរបស់អ្នក \"{{teamName}}\" ត្រូវការធ្វើឱ្យប្រសើរឡើង។",
"upgrade_banner_action": "Upgrade ទីនេះ",
"team_upgraded_successfully": "ក្រុមរបស់អ្នកត្រូវបាន upgrade ដោយជោគជ័យ!",
"org_upgrade_banner_description": "សូមអរគុណសម្រាប់ការសាកល្បងគម្រោង Organization របស់យើង។ យើងកត់សំគាល់ Organization របស់អ្នក \"{{teamName}}\" ត្រូវការធ្វើឱ្យប្រសើរឡើង។",
"org_upgraded_successfully": "Organization របស់អ្នកត្រូវបានដំឡើងកំណែដោយជោគជ័យ!",
"use_link_to_reset_password": "ប្រើតំណខាងក្រោមដើម្បីកំណត់ពាក្យសម្ងាត់របស់អ្នកឡើងវិញ",
"hey_there": "ហេ!",
"forgot_your_password_calcom": "ភ្លេចពាក្យសម្ងាត់? - {{appName}}",
"delete_webhook_confirmation_message": "តើអ្នកប្រាកដថាចង់លុប webhook នេះទេ? អ្នកនឹងលែងទទួលបានទិន្នន័យប្រជុំ {{appName}} តាម URL ដែលបានបញ្ជាក់, ក្នុងពេលវេលាជាក់ស្តែង នៅពេលដែលព្រឹត្តិការណ៍មួយត្រូវបានកំណត់ពេល ឬលុបចោល។",
"confirm_delete_webhook": "បាទ/ចាស លុប webhook",
"edit_webhook": "កែសម្រួល Webhook",
"delete_webhook": "លុប Webhook",
"webhook_status": "ស្ថានភាព Webhook",
"webhook_enabled": "Webhook បានបើក",
"webhook_disabled": "Webhook បានបិទ",
"webhook_response": "ការឆ្លើយតប Webhook",
"webhook_test": "តេស្ត Webhook",
"manage_your_webhook": "គ្រប់គ្រង webhook របស់អ្នក។",
"webhook_created_successfully": "Webhook បានបង្កើតដោយជោគជ័យ!",
"webhook_updated_successfully": "Webhook បានធ្វើបច្ចុប្បន្នភាពដោយជោគជ័យ!",
"webhook_removed_successfully": "Webhook ត្រូវបានដកចេញដោយជោគជ័យ!",
"payload_template": "គំរូនៃការផ្ទុក",
"dismiss": "ច្រានចោល",
"no_data_yet": "មិនមានទិន្នន័យនៅឡើយទេ",
"ping_test": "ការធ្វើតេស្តភីង(Ping)",
"add_to_homescreen": "បន្ថែមកម្មវិធីនេះទៅអេក្រង់ដើមរបស់អ្នកសម្រាប់ការចូលប្រើកាន់តែលឿន និងបទពិសោធន៍ប្រសើរឡើង។",
"upcoming": "នាពេលខាងមុខ",
"recurring": "កើតឡើងម្តងទៀត",
"past": "អតីតកាល",
"choose_a_file": "ជ្រើសរើសឯកសារ...",
"upload_image": "បង្ហោះរូបភាព",
"upload_target": "បង្ហោះ {{target}}",
"no_target": "គ្មាន {{target}}",
"slide_zoom_drag_instructions": "អូសដើម្បីពង្រីក អូសដើម្បីដាក់ទីតាំងឡើងវិញ",
"view_notifications": "មើលការជូនដំណឹង",
"view_public_page": "មើលទំព័រសាធារណៈ",
"copy_public_page_link": "ចម្លងតំណទំព័រសាធារណៈ",
"sign_out": "ចាកចេញ",
"add_another": "បន្ថែមមួយទៀត",
"install_another": "ដំឡើងមួយផ្សេងទៀត",
"until": "រហូតដល់",
"powered_by": "ដំណើរការដោយ",
"unavailable": "មិន​មាន",
"set_work_schedule": "កំណត់កាលវិភាគការងាររបស់អ្នក។",
"change_bookings_availability": "ផ្លាស់ប្តូរនៅពេលដែលអ្នកមានសម្រាប់ការកក់",
"select": "ជ្រើសរើស...",
"2fa_confirm_current_password": "បញ្ជាក់ពាក្យសម្ងាត់បច្ចុប្បន្នរបស់អ្នក ដើម្បីចាប់ផ្តើម។",
"2fa_scan_image_or_use_code": "ស្កេនរូបភាពខាងក្រោមដោយប្រើកម្មវិធីផ្ទៀងផ្ទាត់(Authenticator App) ឬបញ្ចូលលេខកូដអត្ថបទដោយដៃជំនួសវិញ។",
"text": "អត្ថបទ",
"multiline_text": "អត្ថបទច្រើនបន្ទាត់",
"number": "ចំនួន",
"checkbox": "ប្រអប់ធីក",
"is_required": "គឺ​តំរូវ​អោយ​មាន",
"required": "ទាមទារ",
"optional": "ស្រេចចិត្ត",
"input_type": "ប្រភេទបញ្ចូល",
"rejected": "បដិសេធ",
"unconfirmed": "មិន​បាន​បញ្ជាក់",
"guests": "ភ្ញៀវ",
"guest": "ភ្ញៀវ",
"email": "អ៊ីមែល",
"full_name": "ឈ្មោះ​ពេញ",
"january": "មករា",
"february": "កុម្ភៈ",
"march": "មីនា",
"april": "មេសា",
"may": "ឧសភា",
"june": "មិថុនា",
"july": "កក្កដា",
"august": "សីហា",
"september": "កញ្ញា",
"october": "តុលា",
"november": "វិច្ឆិកា",
"december": "ធ្នូ",
"monday": "ច័ន្ទ",
"tuesday": "អង្គារ",
"wednesday": "ពុធ",
"thursday": "ព្រហស្បតិ៍",
"friday": "សុក្រ",
"saturday": "សៅរ៍",
"sunday": "អាទិត្យ",
"submit": "ដាក់ស្នើ",
"delete": "លុប",
"update": "ធ្វើបច្ចុប្បន្នភាព",
"save": "រក្សាទុក",
"pending": "កំពុងរង់ចាំ",
"share": "ចែករំលែក",
"event_types_page_title": "ប្រភេទព្រឹត្តិការណ៍",
"event_types_page_subtitle": "បង្កើតព្រឹត្តិការណ៍ដើម្បីចែករំលែកសម្រាប់មនុស្សដើម្បីកក់នៅលើប្រតិទិនរបស់អ្នក។",
"new": "ថ្មី",
"new_event_type_btn": "ប្រភេទព្រឹត្តិការណ៍ថ្មី",
"new_event_type_heading": "បង្កើតប្រភេទព្រឹត្តិការណ៍ដំបូងរបស់អ្នក",
"new_event_type_description": "ប្រភេទព្រឹត្តិការណ៍អនុញ្ញាតឱ្យអ្នកចែករំលែកតំណដែលបង្ហាញពេលវេលាដែលមាននៅលើប្រតិទិនរបស់អ្នក និងអនុញ្ញាតឱ្យអ្នកផ្សេងធ្វើការកក់ជាមួយអ្នក។",
"event_type_created_successfully": "ប្រភេទព្រឹត្តិការណ៍ {{eventTypeTitle}} ត្រូវបានបង្កើតដោយជោគជ័យ",
"event_type_updated_successfully": "ប្រភេទព្រឹត្តិការណ៍ {{eventTypeTitle}} បានធ្វើបច្ចុប្បន្នភាពដោយជោគជ័យ",
"event_type_deleted_successfully": "ប្រភេទព្រឹត្តិការណ៍ត្រូវបានលុបដោយជោគជ័យ",
"hours": "ម៉ោង",
"your_email": "អ៊ីមែល​របស់​អ្នក",
"change_avatar": "ផ្លាស់ប្តូរ Avatar",
"upload_avatar": "បង្ហោះ Avatar",
"language": "ភាសា",
"timezone": "ល្វែងម៉ោង",
"first_day_of_week": "ថ្ងៃដំបូងនៃសប្តាហ៍",
"repeats_up_to_one": "ធ្វើម្តងទៀតរហូតដល់ {{count}} ដង",
"repeats_up_to_other": "ធ្វើម្តងទៀតរហូតដល់ {{count}} ដង",
"event_remaining_one": "{{count}} ព្រឹត្តិការណ៍ដែលនៅសល់",
"event_remaining_other": "{{count}} ព្រឹត្តិការណ៍ដែលនៅសល់",
"repeats_every": "ធ្វើម្តងទៀតរៀងរាល់",
"time_format": "ទម្រង់ពេលវេលា",
"timeformat_profile_hint": "នេះគឺជាការកំណត់ខាងក្នុង ហើយនឹងមិនប៉ះពាល់ដល់របៀបដែលពេលវេលាត្រូវបានបង្ហាញនៅលើទំព័រកក់សាធារណៈសម្រាប់អ្នក ឬនរណាម្នាក់ដែលកក់អ្នក។",
"start_of_week": "ការចាប់ផ្តើមនៃសប្តាហ៍",
"today": "ថ្ងៃនេះ",
"appearance": "រូបរាង",
"my_account": "គណនី​របស់ខ្ញុំ",
"general": "ទូទៅ",
"calendars": "ប្រតិទិន",
"invoices": "វិក្កយបត្រ",
"users": "អ្នកប្រើប្រាស់",
"user": "អ្នកប្រើប្រាស់",
"general_description": "គ្រប់គ្រងការកំណត់សម្រាប់ភាសា និងល្វែងម៉ោងរបស់អ្នក។"
}
@@ -1,5 +1,5 @@
{
"connected_vital_app": "Connected with",
"connected_vital_app": "ភ្ជាប់ជាមួយ",
"vital_app_sleep_automation": "Sleeping reschedule automation",
"vital_app_automation_description": "You can select different parameters to trigger the reschedule based on your sleeping metrics.",
"vital_app_parameter": "Parameter",
@@ -1,13 +0,0 @@
{
"connected_vital_app": "연결된 대상",
"vital_app_sleep_automation": "수면 재예약 자동화",
"vital_app_automation_description": "수면 지표에 따라 다른 매개변수를 선택하여 일정을 변경할 수 있습니다.",
"vital_app_parameter": "매개변수",
"vital_app_trigger": "같거나 미만인 범위에서 트리거",
"vital_app_save_button": "구성 저장",
"vital_app_total_label": "총계(총계 = 렘 + 가벼운 수면 + 깊은 수면)",
"vital_app_duration_label": "지속 시간(지속 시간 = 취침 시간 끝 - 취침 시간 시작)",
"vital_app_hours": "시간",
"vital_app_save_success": "주요 구성을 저장함",
"vital_app_save_error": "주요 구성을 저장하는 중 오류가 발생했습니다"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Verbonden met",
"vital_app_sleep_automation": "Automatisering opnieuw plannen van slaap",
"vital_app_automation_description": "U kunt verschillende parameters selecteren om het opnieuw plannen te activeren, op basis van uw slaapmetriek.",
"vital_app_parameter": "Parameter",
"vital_app_trigger": "Activatie bij minder dan of gelijk aan",
"vital_app_save_button": "Configuratie opslaan",
"vital_app_total_label": "Totaal (totaal = remslaap + lichte slaap + diepe slaap)",
"vital_app_duration_label": "Duur (duur = einde bedtijd - start bedtijd)",
"vital_app_hours": "uur",
"vital_app_save_success": "Uw Vital-configuraties zijn opgeslagen",
"vital_app_save_error": "Er is een fout opgetreden bij het opslaan van uw Vital-configuraties"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Połączono z",
"vital_app_sleep_automation": "Automatyczna zmiana harmonogramu snu",
"vital_app_automation_description": "Możesz wybrać różne parametry, które spowodują zmianę harmonogramu w zależności od jakości Twojego snu.",
"vital_app_parameter": "Parametr",
"vital_app_trigger": "Wyzwalaj przy wartości mniejszej lub równej",
"vital_app_save_button": "Zapisz konfigurację",
"vital_app_total_label": "Łącznie (łącznie = REM + płytki sen + głęboki sen)",
"vital_app_duration_label": "Czas trwania (czas trwania = koniec snu - początek snu)",
"vital_app_hours": "godz.",
"vital_app_save_success": "Pomyślnie zapisano Twoje Vital Configurations",
"vital_app_save_error": "Podczas zapisywania ustawień parametrów życiowych wystąpił błąd"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Conectou-se com",
"vital_app_sleep_automation": "Automação de reagendamento de sono",
"vital_app_automation_description": "Você pode selecionar parâmetros diferentes para acionar o reagendamento conforme suas métricas de sono.",
"vital_app_parameter": "Parâmetro",
"vital_app_trigger": "Aciona quando for menor ou igual a",
"vital_app_save_button": "Salvar configuração",
"vital_app_total_label": "Total (total = rem + sono leve + sono profundo)",
"vital_app_duration_label": "Duração (duração = fim do horário de dormir - início do horário de dormir)",
"vital_app_hours": "horas",
"vital_app_save_success": "Sucesso ao salvar suas configurações do Vital",
"vital_app_save_error": "Ocorreu um erro ao salvar suas configurações do Vital"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Ligado a",
"vital_app_sleep_automation": "Reagendar automaticamente durante o sono",
"vital_app_automation_description": "Pode seleccionar diferentes parâmetros para accionar o reagendamento com base nas suas métricas de sono.",
"vital_app_parameter": "Parâmetro",
"vital_app_trigger": "Executar abaixo ou igual a",
"vital_app_save_button": "Guardar configuração",
"vital_app_total_label": "Total (total = rem + sono leve + sono profundo)",
"vital_app_duration_label": "Duração (duração = fim do sono - início do sono)",
"vital_app_hours": "horas",
"vital_app_save_success": "As suas configurações vitais foram guardadas com sucesso",
"vital_app_save_error": "Ocorreu um erro ao guardar as suas configurações vitais"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Conectat cu",
"vital_app_sleep_automation": "Automatizare reprogramare somn",
"vital_app_automation_description": "Puteți selecta diferiți parametri pentru a declanșa reprogramarea în funcție de valorile dvs. legate de somn.",
"vital_app_parameter": "Parametru",
"vital_app_trigger": "Declanșare la valori de sau mai mici de",
"vital_app_save_button": "Salvare configurație",
"vital_app_total_label": "Total (total = rem + somn ușor + somn profund)",
"vital_app_duration_label": "Durată (durata = finalul orei de somn - începutul orei de somn)",
"vital_app_hours": "ore",
"vital_app_save_success": "Configurațiile dvs. pentru semnele vitale au fost salvate cu succes",
"vital_app_save_error": "A intervenit o eroare la salvarea configurațiilor dvs. pentru semnele vitale"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Соединено с аккаунтом",
"vital_app_sleep_automation": "Автоматический перенос с учетом режима сна",
"vital_app_automation_description": "Вы можете настроить параметры переноса событий с учетом ваших показателей сна.",
"vital_app_parameter": "Параметр",
"vital_app_trigger": "Запускать, если меньше или равно",
"vital_app_save_button": "Сохранить конфигурацию",
"vital_app_total_label": "Всего (всего = быстрый сон + легкий сон + глубокий сон)",
"vital_app_duration_label": "Продолжительность (продолжительность = время окончания сна – время начала сна)",
"vital_app_hours": "ч.",
"vital_app_save_success": "Конфигурации Vital сохранены",
"vital_app_save_error": "Ошибка при сохранении конфигураций Vital"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Povezan sa",
"vital_app_sleep_automation": "Automatizacija promene termina na osnovu parametara spavanja",
"vital_app_automation_description": "Možete izabrati različite parametre koji će aktivirati promenu termina na osnovu vaših parametara spavanja.",
"vital_app_parameter": "Parametar",
"vital_app_trigger": "Aktiviraj kada je jednako ili manje od",
"vital_app_save_button": "Sačuvaj konfiguraciju",
"vital_app_total_label": "Ukupno (ukupno = REM + lagani san + dubok san)",
"vital_app_duration_label": "Trajanje (trajanje = kraj spavanja - početak spavanja)",
"vital_app_hours": "sati",
"vital_app_save_success": "Vaše konfiguracije vitalnih znakova su uspešno sačuvane",
"vital_app_save_error": "Došlo je do greške pri čuvanju vaših konfiguracija vitalnih znakova"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Ansluten till",
"vital_app_sleep_automation": "Automatisering för att schemalägga sömn på nytt",
"vital_app_automation_description": "Du kan välja olika parametrar för att utlösa tidsplanen baserat på dina sömnmått.",
"vital_app_parameter": "Parameter",
"vital_app_trigger": "Utlösare under eller lika med",
"vital_app_save_button": "Spara konfiguration",
"vital_app_total_label": "Totalt (totalt = rem + lätt sömn + djupsömn)",
"vital_app_duration_label": "Varaktighet (varaktighet = sängdags slut - sängdags start)",
"vital_app_hours": "timmar",
"vital_app_save_success": "Dina grundläggande konfigurationer sparades",
"vital_app_save_error": "Det gick inte att spara dina grundläggande konfigurationer"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Şununla bağlan:",
"vital_app_sleep_automation": "Uykuyu yeniden programlama otomasyonu",
"vital_app_automation_description": "Uyku değerlerinize göre yeniden programlamayı tetiklemek için farklı parametreler seçebilirsiniz.",
"vital_app_parameter": "Parametre",
"vital_app_trigger": "Şu değerin altında veya eşit olduğunda tetikle:",
"vital_app_save_button": "Yapılandırmayı kaydet",
"vital_app_total_label": "Toplam (toplam = REM + hafif uyku + derin uyku)",
"vital_app_duration_label": "Süre (süre = uyku saatinin bitişi - uyku saatinin başlangıcı)",
"vital_app_hours": "saat",
"vital_app_save_success": "Önemli Yapılandırmalarınız başarıyla kaydedildi",
"vital_app_save_error": "Önemli Yapılandırmalarınız kaydedilirken bir hata oluştu"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Підключено до",
"vital_app_sleep_automation": "Автоматизація переналаштування розкладу",
"vital_app_automation_description": "Ви можете вибирати різні параметри, щоб виконувати переналаштування розкладу на основі показників сну.",
"vital_app_parameter": "Параметр",
"vital_app_trigger": "Ініціювати, якщо показник не перевищує",
"vital_app_save_button": "Зберегти конфігурацію",
"vital_app_total_label": "Усього (усього = швидкий сон + поверхневий сон + глибокий сон)",
"vital_app_duration_label": "Тривалість (тривалість = час, коли ви встали з ліжка - час, коли ви лягли в ліжко)",
"vital_app_hours": "год",
"vital_app_save_success": "Ваші показники Vital Configurations збережено",
"vital_app_save_error": "Сталася помилка під час збереження ваших показників Vital Configurations"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "Đã kết nối với",
"vital_app_sleep_automation": "Tự động sắp lại lịch ngủ",
"vital_app_automation_description": "Bạn có thể chọn những tham số khác nhau để kích hoạt sắp lịch lại dựa trên số liệu thống kê giấc ngủ.",
"vital_app_parameter": "Tham số",
"vital_app_trigger": "Trigger ở mức dưới hoặc bằng",
"vital_app_save_button": "Lưu cấu hình",
"vital_app_total_label": "Tổng (tổng = giấc ngủ rem + ngủ nông + ngủ sâu)",
"vital_app_duration_label": "Thời lượng (thời lượng = giờ dậy - giờ bắt đầu ngủ)",
"vital_app_hours": "giờ",
"vital_app_save_success": "Lưu thành công cấu hình Vital của bạn",
"vital_app_save_error": "Có lỗi xảy ra khi lưu cấu hình Vital"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "已连接到",
"vital_app_sleep_automation": "睡眠重新安排自动化",
"vital_app_automation_description": "您可以选择不同的参数以根据您的睡眠指标触发重新安排。",
"vital_app_parameter": "参数",
"vital_app_trigger": "低于或等于时触发",
"vital_app_save_button": "保存配置",
"vital_app_total_label": "总合(总合 = 快速眼动睡眠 + 轻度睡眠 + 深度睡眠)",
"vital_app_duration_label": "时长(时长 = 就寝结束时间 - 就寝开始时间)",
"vital_app_hours": "小时",
"vital_app_save_success": "成功保存您的 Vital 配置",
"vital_app_save_error": "保存您的 Vital 配置时出错"
}
@@ -1,13 +0,0 @@
{
"connected_vital_app": "已連至",
"vital_app_sleep_automation": "睡眠重新預定自動化",
"vital_app_automation_description": "您可以選取不同的參數,以根據您的睡眠指標來觸發重新預定。",
"vital_app_parameter": "參數",
"vital_app_trigger": "低於下列值或相等時觸發",
"vital_app_save_button": "儲存設定",
"vital_app_total_label": "總計 (總計 = 快速動眼期 + 淺層睡眠 + 深層睡眠)",
"vital_app_duration_label": "時間長度 (時間長度 = 起床時間 - 上床時間)",
"vital_app_hours": "小時",
"vital_app_save_success": "成功儲存您的 Vital 設定",
"vital_app_save_error": "儲存您的 Vital 設定時發生錯誤"
}
+8 -2
View File
@@ -37,8 +37,14 @@ export async function ssgInit<TParams extends { locale?: string }>(opts: GetStat
},
});
// always preload i18n
await ssg.viewer.public.i18n.fetch({ locale, CalComVersion: CALCOM_VERSION });
// i18n translations are already retrieved from serverSideTranslations call, there is no need to run a i18n.fetch
// we can set query data directly to the queryClient
const queryKey = [
["viewer", "public", "i18n"],
{ input: { locale, CalComVersion: CALCOM_VERSION }, type: "query" },
];
ssg.queryClient.setQueryData(queryKey, { i18n: _i18n });
return ssg;
}
+10 -4
View File
@@ -25,11 +25,17 @@ export async function ssrInit(context: GetServerSidePropsContext, options?: { no
ctx: { ...ctx, locale, i18n },
});
// i18n translations are already retrieved from serverSideTranslations call, there is no need to run a i18n.fetch
// we can set query data directly to the queryClient
const queryKey = [
["viewer", "public", "i18n"],
{ input: { locale, CalComVersion: CALCOM_VERSION }, type: "query" },
];
if (!options?.noI18nPreload) {
ssr.queryClient.setQueryData(queryKey, { i18n });
}
await Promise.allSettled([
// always preload "viewer.public.i18n"
!options?.noI18nPreload
? ssr.viewer.public.i18n.prefetch({ locale, CalComVersion: CALCOM_VERSION })
: Promise.resolve({}),
// So feature flags are available on first render
ssr.viewer.features.map.prefetch(),
// Provides a better UX to the users who have already upgraded.
@@ -98,12 +98,19 @@ describe("handleChildrenEventTypes", () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line
const { schedulingType, id, teamId, timeZone, users, requiresBookerEmailVerification, ...evType } =
mockFindFirstEventType({
id: 123,
metadata: { managedEventConfig: {} },
locations: [],
});
const {
schedulingType,
id,
teamId,
timeZone,
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
...evType
} = mockFindFirstEventType({
id: 123,
metadata: { managedEventConfig: {} },
locations: [],
});
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [], team: { name: "" } },
@@ -145,6 +152,7 @@ describe("handleChildrenEventTypes", () => {
userId,
scheduleId,
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -230,12 +238,19 @@ describe("handleChildrenEventTypes", () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line
const { schedulingType, id, teamId, timeZone, users, requiresBookerEmailVerification, ...evType } =
mockFindFirstEventType({
id: 123,
metadata: { managedEventConfig: {} },
locations: [],
});
const {
schedulingType,
id,
teamId,
timeZone,
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
...evType
} = mockFindFirstEventType({
id: 123,
metadata: { managedEventConfig: {} },
locations: [],
});
prismaMock.eventType.deleteMany.mockResolvedValue([123] as unknown as Prisma.BatchPayload);
const result = await updateChildrenEventTypes({
eventTypeId: 1,
@@ -277,6 +292,7 @@ describe("handleChildrenEventTypes", () => {
parentId,
userId,
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -327,6 +343,7 @@ describe("handleChildrenEventTypes", () => {
userId: _userId,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
requiresBookerEmailVerification,
lockTimeZoneToggleOnBookingPage,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -635,7 +635,7 @@ export const TestData = {
example: {
name: "Example",
email: "example@example.com",
username: "example",
username: "example.username",
defaultScheduleId: 1,
timeZone: Timezones["+5:30"],
},
+3 -1
View File
@@ -107,7 +107,9 @@
"@apidevtools/json-schema-ref-parser": "9.0.9",
"@types/node": "16.9.1",
"@types/react": "18.0.26",
"@types/react-dom": "^18.0.9"
"@types/react-dom": "^18.0.9",
"libphonenumber-js@^1.10.12": "patch:libphonenumber-js@npm%3A1.10.12#./.yarn/patches/libphonenumber-js-npm-1.10.12-51c84f8bf1.patch",
"next-i18next@^13.2.2": "patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch"
},
"lint-staged": {
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
@@ -134,7 +134,15 @@ function PaymentChecker(props: PaymentCheckerProps) {
const bookingSuccessRedirect = useBookingSuccessRedirect();
const utils = trpc.useContext();
const { t } = useLocale();
useEffect(() => {
if (searchParams === null) {
return;
}
// use closure to ensure non-nullability
const sp = searchParams;
const interval = setInterval(() => {
(async () => {
if (props.booking.status === "ACCEPTED") {
@@ -153,7 +161,7 @@ function PaymentChecker(props: PaymentCheckerProps) {
location: string;
} = {
uid: props.booking.uid,
email: searchParams.get("email"),
email: sp.get("email"),
location: t("web_conferencing_details_to_follow"),
};
@@ -165,6 +173,7 @@ function PaymentChecker(props: PaymentCheckerProps) {
}
})();
}, 1000);
return () => clearInterval(interval);
}, [
bookingSuccessRedirect,
@@ -178,5 +187,6 @@ function PaymentChecker(props: PaymentCheckerProps) {
t,
utils.viewer.bookings,
]);
return null;
}
@@ -30,15 +30,20 @@ export default function AlbySetup(props: IAlbySetupProps) {
function AlbySetupCallback() {
const [error, setError] = useState<string | null>(null);
const params = useSearchParams();
const searchParams = useSearchParams();
useEffect(() => {
if (!searchParams) {
return;
}
if (!window.opener) {
setError("Something went wrong. Opener not available. Please contact support@getalby.com");
return;
}
const code = params.get("code");
const error = params.get("error");
const code = searchParams?.get("code");
const error = searchParams?.get("error");
if (!code) {
setError("declined");
@@ -54,7 +59,7 @@ function AlbySetupCallback() {
payload: { code },
});
window.close();
}, []);
}, [searchParams]);
return (
<div>
@@ -48,7 +48,7 @@ export const useOpenModal = () => {
const pathname = usePathname();
const searchParams = useSearchParams();
const openModal = (option: z.infer<typeof newFormModalQuerySchema>) => {
const newQuery = new URLSearchParams(searchParams);
const newQuery = new URLSearchParams(searchParams ?? undefined);
newQuery.set("dialog", "new-form");
Object.keys(option).forEach((key) => {
newQuery.set(key, option[key as keyof typeof option] || "");

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