Compare commits

..
93 Commits
Author SHA1 Message Date
Keith Williams 51fd4102ae v3.4.6
release.yml / release (push) Canceled after 0s
2023-10-31 17:25:46 -03:00
Ritesh KumarandPeer Richelsen 9d1ef0a649 chore: [CAL-2654] Broken Icons in org invitation email (#12119)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-31 20:00:07 +00:00
f80dc0738a test: Create E2E tests for bookings with custom/required Select + other questions (teste2e-selectQuestion) (#11564)
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: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2023-10-31 19:44:18 +00:00
Peer Richelsen 678ab3f453 chore: remove merge conflicts workflow (#12173) 2023-10-31 19:43:38 +00:00
Syed Ali ShahbazandMorgan 199d3e4c3f fix: Auto-link credentials internally on destination calendar (#12055)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2023-10-31 13:54:09 -03:00
Siddharth Movaliya 79a6aef0e7 fix: text colour for no-show acknowledgment (#12132) 2023-10-31 16:41:25 +00:00
Udit TakkarandPeer Richelsen 4d49fb0636 fix: cal video recording email (#12079)
* fix: cal video recording email

* fix: add check for recording

* chore: remove logs

* chore: change message

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-31 13:35:23 -03:00
Udit Takkar 0be1387d0f fix: org settings for member improvements (#12161) 2023-10-31 16:24:51 +00:00
Morgan 58ab278813 fix(e2e): failsafe if no availabilities (#12168) 2023-10-31 16:10:08 +00:00
Alex van Andel 4de142cb7c fix: Check for same day, not with <= (#12167)
* fix: Check for same day, not with <=

* Cover this situation with a test

* Make the test timezone-independent
2023-10-31 14:48:47 +00:00
Hariom Balhara b4d27a9326 Fix Google spam policy alert layout in embed (#12153) 2023-10-31 10:46:35 +00:00
sean-brydon 31f3d9778e Use correct typing for totalTeamMembers (#12152) 2023-10-30 17:29:09 +02:00
sean-brydon 0a59c95b93 fix: impersonation for orgs (#12113) 2023-10-30 12:20:48 -03:00
Hariom BalharaandUdit Takkar 9e3465eeb6 fix: Support embedding org profile page (#12116)
* support embedding org profile page

* Add checkly tests

* Fix test titles

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-10-30 17:49:13 +05:30
Hariom Balhara 31fc4724e0 fix: request reschedule link in email for an Org event (#12125) 2023-10-30 16:25:12 +05:30
Hariom Balhara f81f0a26ec fix: Prevent possible reason behind avatar infinite redirect (#12143) 2023-10-30 09:19:06 +00:00
Hariom Balhara 9a80bb6194 fix: Skip failing tests (#12144) 2023-10-30 09:05:05 +00:00
Udit Takkar 901fc36c97 fix: padding in footer in profile (#12101) 2023-10-28 18:17:41 +01:00
Joe Au-Yeung 2831fb2b57 refactor: Falling Back to FirstCalendarCredential (#11986) 2023-10-27 11:52:56 -04:00
Hariom Balhara 426d31712e test: E2E for Orgs - The beginning (#12095) 2023-10-27 18:14:16 +05:30
Carina WollendorferandCarinaWolli 09ecd445bb fix adding managed event type to workflow (#12111)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-27 17:30:34 +05:30
Carina WollendorferandCarinaWolli 08d65c85de fix: cron scheduleEmailRemider time out (#12108)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-27 08:53:53 -03:00
Peer Richelsen b9cef10ef2 chore: new cal.ai tip (#12096) 2023-10-27 11:25:28 +00:00
Manish Singh BishtandPeer Richelsen 0dc41592f2 fix: use app.slug not hard coded zoom (#11963)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-27 12:05:30 +01:00
Leo Giovanetti aabf3c54ea Update Avatar.tsx (#12110) 2023-10-26 21:28:30 +00:00
Carina WollendorferandCarinaWolli c2a57fd72b split date ranges for calling /freebusy endpoint (#11962)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-26 10:29:08 -04:00
Carina WollendorferandCarinaWolli 52386e08f2 fix: pull managed event type bookings in zapier (#12106)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-26 10:18:37 -04:00
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
Keith Williams 55a8a0d2d3 v3.4.3
Auto Comment Merge Conflicts / auto-comment-merge-conflicts (push) Canceled after 0s
release.yml / release (push) Canceled after 0s
2023-10-20 13:53:48 -03:00
Crowdin Bot 3093216534 New Crowdin translations by Github Action 2023-10-20 16:52:30 +00:00
Crowdin Bot 99a1c36ffc New Crowdin translations by Github Action 2023-10-20 16:49:37 +00:00
Crowdin Bot f4e48f5fc1 New Crowdin translations by Github Action 2023-10-20 16:47:04 +00:00
c352dc647e fix: broken layout email embed generator (CALCOM-11779) (#11951)
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: Peer Richelsen <peeroke@gmail.com>
2023-10-20 16:44:26 +00:00
Udit Takkar 34bb069b4a revert: feat: Shows link location (#12024) 2023-10-20 12:43:45 -03:00
Peer Richelsen 39ea9c112d chore: delete "unapproved issue" workflow (#12008) 2023-10-20 14:39:28 +00:00
e32d4648af fix: webhook overflow (#11968)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-20 14:38:52 +00:00
Crowdin Bot 20b7633ab5 New Crowdin translations by Github Action 2023-10-20 14:35:26 +00:00
6f017a7972 fix: fixed caldav app icon and text (#12016)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-20 14:32:07 +00:00
Peer Richelsen cbd0e2d287 chore: removed "set up your public profile" banner (#12001) 2023-10-20 15:31:59 +01:00
Axl ff739bf9be fix: pwa nav improvements (#11972) 2023-10-20 15:31:03 +01:00
Siddharth Movaliya ff3541910b fix: team availability slider overflow (#12020) 2023-10-20 15:27:03 +01:00
sean-brydon d043de7724 fix: overlay calendar modal (#12021) 2023-10-20 15:02:08 +01:00
sean-brydon be1517facd fix: get correct count for team members in slider (#12017) 2023-10-20 09:42:49 -03:00
kremedev 19eced00f5 fix: Unable to modify the location of a booking when rescheduling (#11651) 2023-10-20 09:00:00 +02:00
Hariom Balhara e2414b174a Handle non-org team with same slug as the organizations requestedSlug (#11996) 2023-10-20 00:05:34 +05:30
243 changed files with 7729 additions and 1823 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
@@ -1,18 +0,0 @@
name: Add comment
on:
issues:
types:
- labeled
jobs:
add-comment:
if: github.event.label.name == '🚨 needs approval'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Add comment
uses: peter-evans/create-or-update-comment@5f728c3dae25f329afbe34ee4d08eef25569d79f
with:
issue-number: ${{ github.event.issue.number }}
body: |
This feature request has not been reviewed yet by the Product Team and needs approval beforehand. Once approved, this issue is available for anyone to work on. **Make sure to reference this issue in your pull request.** :sparkles: Thank you for your contribution! :sparkles:
-18
View File
@@ -1,18 +0,0 @@
name: Auto Comment Merge Conflicts
on: push
permissions:
pull-requests: write
jobs:
auto-comment-merge-conflicts:
runs-on: ubuntu-latest
steps:
- uses: codytseng/auto-comment-merge-conflicts@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
comment-body: "Hey there, there is a merge conflict, can you take a look?"
wait-ms: 3000
max-retries: 5
label-name: "🚨 merge conflict"
ignore-authors: dependabot,otherAuthor
+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
+4
View File
@@ -0,0 +1,4 @@
# Checkly Tests
Run as `yarn checkly test`
Deploy the tests as `yarn checkly deploy`
+53
View File
@@ -0,0 +1,53 @@
import type { Page } from "@playwright/test";
import { test, expect } from "@playwright/test";
test.describe("Org", () => {
// Because these pages involve next.config.js rewrites, it's better to test them on production
test.describe("Embeds - i.cal.com", () => {
test("Org Profile Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/embed");
expect(response?.status()).toBe(200);
await page.screenshot({ path: "screenshot.jpg" });
await expectPageToBeServerSideRendered(page);
});
test("Org User(Peer) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/peer/embed");
expect(response?.status()).toBe(200);
await expect(page.locator("text=Peer Richelsen")).toBeVisible();
await expectPageToBeServerSideRendered(page);
});
test("Org User Event(peer/meet) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/peer/meet/embed");
expect(response?.status()).toBe(200);
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
await expectPageToBeServerSideRendered(page);
});
test("Org Team Profile(/sales) page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/sales/embed");
expect(response?.status()).toBe(200);
await expect(page.locator("text=Cal.com Sales")).toBeVisible();
await expectPageToBeServerSideRendered(page);
});
test("Org Team Event page(/sales/hippa) should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/sales/hipaa/embed");
expect(response?.status()).toBe(200);
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
await expectPageToBeServerSideRendered(page);
});
});
});
// This ensures that the route is actually mapped to a page that is using withEmbedSsr
async function expectPageToBeServerSideRendered(page: Page) {
expect(
await page.evaluate(() => {
return window.__NEXT_DATA__.props.pageProps.isEmbed;
})
).toBe(true);
}
+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,
@@ -3,7 +3,6 @@ import { z } from "zod";
import { _DestinationCalendarModel as DestinationCalendar } from "@calcom/prisma/zod";
export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({
credentialId: true,
integration: true,
externalId: true,
eventTypeId: true,
@@ -15,7 +14,6 @@ const schemaDestinationCalendarCreateParams = z
.object({
integration: z.string(),
externalId: z.string(),
credentialId: z.number(),
eventTypeId: z.number().optional(),
bookingId: z.number().optional(),
userId: z.number().optional(),
@@ -47,5 +45,4 @@ export const schemaDestinationCalendarReadPublic = DestinationCalendar.pick({
eventTypeId: true,
bookingId: true,
userId: true,
credentialId: true,
});
+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()
@@ -1,6 +1,12 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next";
import type { z } from "zod";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import type { PrismaClient } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import {
schemaDestinationCalendarEditBodyParams,
@@ -56,16 +62,251 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
* 404:
* description: Destination calendar not found
*/
type DestinationCalendarType = {
userId?: number | null;
eventTypeId?: number | null;
credentialId: number | null;
};
type UserCredentialType = {
id: number;
appId: string | null;
type: string;
userId: number | null;
user: {
email: string;
} | null;
teamId: number | null;
key: Prisma.JsonValue;
invalid: boolean | null;
};
export async function patchHandler(req: NextApiRequest) {
const { prisma, query, body } = req;
const { userId, isAdmin, prisma, query, body } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body);
const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
validateIntegrationInput(parsedBody);
const destinationCalendarObject: DestinationCalendarType = await getDestinationCalendar(id, prisma);
await validateRequestAndOwnership({ destinationCalendarObject, parsedBody, assignedUserId, prisma });
const userCredentials = await getUserCredentials({
credentialId: destinationCalendarObject.credentialId,
userId: assignedUserId,
prisma,
});
const credentialId = await verifyCredentialsAndGetId({
parsedBody,
userCredentials,
currentCredentialId: destinationCalendarObject.credentialId,
});
// If the user has passed eventTypeId, we need to remove userId from the update data to make sure we don't link it to user as well
if (parsedBody.eventTypeId) parsedBody.userId = undefined;
const destinationCalendar = await prisma.destinationCalendar.update({
where: { id },
data: parsedBody,
data: { ...parsedBody, credentialId },
});
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destinationCalendar) };
}
/**
* Retrieves user credentials associated with a given credential ID and user ID and validates if the credentials belong to this user
*
* @param credentialId - The ID of the credential to fetch. If not provided, an error is thrown.
* @param userId - The user ID against which the credentials need to be verified.
* @param prisma - An instance of PrismaClient for database operations.
*
* @returns - An array containing the matching user credentials.
*
* @throws HttpError - If `credentialId` is not provided or no associated credentials are found in the database.
*/
async function getUserCredentials({
credentialId,
userId,
prisma,
}: {
credentialId: number | null;
userId: number;
prisma: PrismaClient;
}) {
if (!credentialId) {
throw new HttpError({
statusCode: 404,
message: `Destination calendar missing credential id`,
});
}
const userCredentials = await prisma.credential.findMany({
where: { id: credentialId, userId },
select: credentialForCalendarServiceSelect,
});
if (!userCredentials || userCredentials.length === 0) {
throw new HttpError({
statusCode: 400,
message: `Bad request, no associated credentials found`,
});
}
return userCredentials;
}
/**
* Verifies the provided credentials and retrieves the associated credential ID.
*
* This function checks if the `integration` and `externalId` properties from the parsed body are present.
* If both properties exist, it fetches the connected calendar credentials using the provided user credentials
* and checks for a matching external ID and integration from the list of connected calendars.
*
* If a match is found, it updates the `credentialId` with the one from the connected calendar.
* Otherwise, it throws an HTTP error with a 400 status indicating an invalid credential ID.
*
* If the parsed body does not contain the necessary properties, the function
* returns the `credentialId` from the destination calendar object.
*
* @param parsedBody - The parsed body from the incoming request, validated against a predefined schema.
* Checked if it contain properties like `integration` and `externalId`.
* @param userCredentials - An array of user credentials used to fetch the connected calendar credentials.
* @param destinationCalendarObject - An object representing the destination calendar. Primarily used
* to fetch the default `credentialId`.
*
* @returns - The verified `credentialId` either from the matched connected calendar in case of updating the destination calendar,
* or the provided destination calendar object in other cases.
*
* @throws HttpError - If no matching connected calendar is found for the given `integration` and `externalId`.
*/
async function verifyCredentialsAndGetId({
parsedBody,
userCredentials,
currentCredentialId,
}: {
parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>;
userCredentials: UserCredentialType[];
currentCredentialId: number | null;
}) {
if (parsedBody.integration && parsedBody.externalId) {
const calendarCredentials = getCalendarCredentials(userCredentials);
const { connectedCalendars } = await getConnectedCalendars(
calendarCredentials,
[],
parsedBody.externalId
);
const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly);
const calendar = eligibleCalendars?.find(
(c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration
);
if (!calendar?.credentialId)
throw new HttpError({
statusCode: 400,
message: "Bad request, credential id invalid",
});
return calendar?.credentialId;
}
return currentCredentialId;
}
/**
* Validates the request for updating a destination calendar.
*
* This function checks the validity of the provided eventTypeId against the existing destination calendar object
* in the sense that if the destination calendar is not linked to an event type, the eventTypeId can not be provided.
*
* It also ensures that the eventTypeId, if provided, belongs to the assigned user.
*
* @param destinationCalendarObject - An object representing the destination calendar.
* @param parsedBody - The parsed body from the incoming request, validated against a predefined schema.
* @param assignedUserId - The user ID assigned for the operation, which might be an admin or a regular user.
* @param prisma - An instance of PrismaClient for database operations.
*
* @throws HttpError - If the validation fails or inconsistencies are detected in the request data.
*/
async function validateRequestAndOwnership({
destinationCalendarObject,
parsedBody,
assignedUserId,
prisma,
}: {
destinationCalendarObject: DestinationCalendarType;
parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>;
assignedUserId: number;
prisma: PrismaClient;
}) {
if (parsedBody.eventTypeId) {
if (!destinationCalendarObject.eventTypeId) {
throw new HttpError({
statusCode: 400,
message: `The provided destination calendar can not be linked to an event type`,
});
}
const userEventType = await prisma.eventType.findFirst({
where: { id: parsedBody.eventTypeId },
select: { userId: true },
});
if (!userEventType || userEventType.userId !== assignedUserId) {
throw new HttpError({
statusCode: 404,
message: `Event type with ID ${parsedBody.eventTypeId} not found`,
});
}
}
if (!parsedBody.eventTypeId) {
if (destinationCalendarObject.eventTypeId) {
throw new HttpError({
statusCode: 400,
message: `The provided destination calendar can only be linked to an event type`,
});
}
if (destinationCalendarObject.userId !== assignedUserId) {
throw new HttpError({
statusCode: 403,
message: `Forbidden`,
});
}
}
}
/**
* Fetches the destination calendar based on the provided ID as the path parameter, specifically `credentialId` and `eventTypeId`.
*
* If no matching destination calendar is found for the provided ID, an HTTP error with a 404 status
* indicating that the desired destination calendar was not found is thrown.
*
* @param id - The ID of the destination calendar to be retrieved.
* @param prisma - An instance of PrismaClient for database operations.
*
* @returns - An object containing details of the matching destination calendar, specifically `credentialId` and `eventTypeId`.
*
* @throws HttpError - If no destination calendar matches the provided ID.
*/
async function getDestinationCalendar(id: number, prisma: PrismaClient) {
const destinationCalendarObject = await prisma.destinationCalendar.findFirst({
where: {
id,
},
select: { userId: true, eventTypeId: true, credentialId: true },
});
if (!destinationCalendarObject) {
throw new HttpError({
statusCode: 404,
message: `Destination calendar with ID ${id} not found`,
});
}
return destinationCalendarObject;
}
function validateIntegrationInput(parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>) {
if (parsedBody.integration && !parsedBody.externalId) {
throw new HttpError({ statusCode: 400, message: "External Id is required with integration value" });
}
if (!parsedBody.integration && parsedBody.externalId) {
throw new HttpError({ statusCode: 400, message: "Integration value is required with external ID" });
}
}
export default defaultResponder(patchHandler);
@@ -1,7 +1,9 @@
import type { NextApiRequest } from "next";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import {
schemaDestinationCalendarReadPublic,
@@ -38,9 +40,6 @@ import {
* externalId:
* type: string
* description: 'The external ID of the integration'
* credentialId:
* type: integer
* description: 'The credential ID it is associated with'
* eventTypeId:
* type: integer
* description: 'The ID of the eventType it is associated with'
@@ -65,20 +64,38 @@ async function postHandler(req: NextApiRequest) {
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
await checkPermissions(req, userId);
const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
const assignedUserId = isAdmin && parsedBody.userId ? parsedBody.userId : userId;
/* Check if credentialId data matches the ownership and integration passed in */
const credential = await prisma.credential.findFirst({
where: { type: parsedBody.integration, userId: assignedUserId },
select: { id: true, type: true, userId: true },
const userCredentials = await prisma.credential.findMany({
where: {
type: parsedBody.integration,
userId: assignedUserId,
},
select: credentialForCalendarServiceSelect,
});
if (!credential)
if (userCredentials.length === 0)
throw new HttpError({
statusCode: 400,
message: "Bad request, credential id invalid",
});
const calendarCredentials = getCalendarCredentials(userCredentials);
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, [], parsedBody.externalId);
const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly);
const calendar = eligibleCalendars?.find(
(c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration
);
if (!calendar?.credentialId)
throw new HttpError({
statusCode: 400,
message: "Bad request, credential id invalid",
});
const credentialId = calendar.credentialId;
if (parsedBody.eventTypeId) {
const eventType = await prisma.eventType.findFirst({
where: { id: parsedBody.eventTypeId, userId: parsedBody.userId },
@@ -91,7 +108,9 @@ async function postHandler(req: NextApiRequest) {
parsedBody.userId = undefined;
}
const destination_calendar = await prisma.destinationCalendar.create({ data: { ...parsedBody } });
const destination_calendar = await prisma.destinationCalendar.create({
data: { ...parsedBody, credentialId },
});
return {
destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar),
@@ -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")}>
+1 -1
View File
@@ -58,7 +58,7 @@ function PageWrapper(props: AppProps) {
<Head>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover"
/>
</Head>
<DefaultSeo
+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);
+19 -42
View File
@@ -88,10 +88,6 @@ function BookingListItem(booking: BookingItemProps) {
const isRecurring = booking.recurringEventId !== null;
const isTabRecurring = booking.listingStatus === "recurring";
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
const eventLocationType = getEventLocationType(booking.location);
const meetingLink = booking.references[0]?.meetingUrl
? booking.references[0]?.meetingUrl
: booking.location;
const paymentAppData = getPaymentAppData(booking.eventType);
@@ -145,17 +141,6 @@ function BookingListItem(booking: BookingItemProps) {
: []),
];
const showRecordingActions: ActionType[] = [
{
id: "view_recordings",
label: t("view_recordings"),
onClick: () => {
setViewRecordingsDialogIsOpen(true);
},
disabled: mutation.isLoading,
},
];
let bookedActions: ActionType[] = [
{
id: "cancel",
@@ -230,6 +215,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);
@@ -273,11 +259,21 @@ function BookingListItem(booking: BookingItemProps) {
const bookingLink = buildBookingLink();
const title = booking.title;
// To be used after we run query on legacy bookings
// const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed;
const showRecordingsButtons =
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
const showRecordingsButtons = !!(booking.isRecorded && isPast && isConfirmed);
const checkForRecordingsButton =
!showRecordingsButtons && (booking.location === "integrations:daily" || booking?.location?.trim() === "");
const showRecordingActions: ActionType[] = [
{
id: checkForRecordingsButton ? "check_for_recordings" : "view_recordings",
label: checkForRecordingsButton ? t("check_for_recordings") : t("view_recordings"),
onClick: () => {
setViewRecordingsDialogIsOpen(true);
},
disabled: mutation.isLoading,
},
];
return (
<>
@@ -302,7 +298,7 @@ function BookingListItem(booking: BookingItemProps) {
paymentCurrency={booking.payment[0].currency}
/>
)}
{showRecordingsButtons && (
{(showRecordingsButtons || checkForRecordingsButton) && (
<ViewRecordingsDialog
booking={booking}
isOpenDialog={viewRecordingsDialogIsOpen}
@@ -357,27 +353,6 @@ function BookingListItem(booking: BookingItemProps) {
attendees={booking.attendees}
/>
</div>
{!isPending && (eventLocationType || booking.location?.startsWith("https://")) && (
<Link
href={meetingLink ? meetingLink.toString() : ""}
className="text-sm leading-6 text-blue-400 hover:underline">
<div className="flex items-center gap-2">
{eventLocationType ? (
<>
<img
src={eventLocationType.iconUrl}
className="h-4 w-4 rounded-sm"
alt={`${eventLocationType.label} logo`}
/>
{t("join_event_location", { eventLocationType: eventLocationType.label })}
</>
) : (
t("join_meeting")
)}
</div>
</Link>
)}
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("unconfirmed")}
@@ -493,7 +468,9 @@ function BookingListItem(booking: BookingItemProps) {
</>
) : null}
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
{showRecordingsButtons && <TableActions actions={showRecordingActions} />}
{(showRecordingsButtons || checkForRecordingsButton) && (
<TableActions actions={showRecordingActions} />
)}
{isCancelled && booking.rescheduled && (
<div className="hidden h-full items-center md:flex">
<RequestSentMessage />
@@ -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: {
+23 -1
View File
@@ -102,6 +102,16 @@ const matcherConfigRootPath = {
source: "/",
};
const matcherConfigRootPathEmbed = {
has: [
{
type: "host",
value: orgHostPath,
},
],
source: "/embed",
};
const matcherConfigUserRoute = {
has: [
{
@@ -226,6 +236,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",
@@ -235,7 +253,11 @@ const nextConfig = {
? [
{
...matcherConfigRootPath,
destination: "/team/:orgSlug",
destination: "/team/:orgSlug?isOrgProfile=1",
},
{
...matcherConfigRootPathEmbed,
destination: "/team/:orgSlug/embed?isOrgProfile=1",
},
{
...matcherConfigUserRoute,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.4.2",
"version": "3.4.6",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+14 -9
View File
@@ -3,7 +3,10 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import {
getOrgDomainConfigFromHostname,
subdomainSuffix,
} from "@calcom/features/ee/organizations/lib/orgDomains";
import { DOCS_URL, IS_CALCOM, JOIN_DISCORD, WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HeadSeo } from "@calcom/ui";
@@ -50,9 +53,12 @@ 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 { isValidOrgDomain, currentOrgDomain } = getOrgDomainConfigFromHostname({
hostname: window.location.host,
});
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 +72,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
+37 -12
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"
@@ -258,10 +275,7 @@ export type UserPageProps = {
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
context.req.headers.host ?? "",
context.params?.orgSlug
);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const usernameList = getUsernameList(context.query.user as string);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const dataFetchStart = Date.now();
@@ -286,6 +300,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
select: {
slug: true,
name: true,
metadata: true,
},
},
theme: true,
@@ -313,6 +328,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 +340,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
slug: usernameList[0],
redirectType: RedirectType.User,
eventTypeSlug: null,
currentQuery: context.query,
});
if (redirect) {
@@ -344,8 +364,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);
+3 -8
View File
@@ -72,10 +72,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
context.req.headers.host ?? "",
context.params?.orgSlug
);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const users = await prisma.user.findMany({
where: {
@@ -148,10 +145,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const username = usernames[0];
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
context.req.headers.host ?? "",
context.params?.orgSlug
);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
@@ -160,6 +154,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
slug: usernames[0],
redirectType: RedirectType.User,
eventTypeSlug: slug,
currentQuery: context.query,
});
if (redirect) {
+1 -1
View File
@@ -154,7 +154,7 @@ async function getTeamLogos(subdomain: string, isValidOrgDomain: boolean) {
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { query } = req;
const parsedQuery = logoApiSchema.parse(query);
const { isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
const { isValidOrgDomain } = orgDomainConfig(req);
const hostname = req?.headers["host"];
if (!hostname) throw new Error("No hostname");
+55 -5
View File
@@ -62,6 +62,46 @@ const triggerWebhook = async ({
await Promise.all(promises);
};
const checkIfUserIsPartOfTheSameTeam = async (
teamId: number | undefined | null,
userId: number,
userEmail: string | undefined | null
) => {
if (!teamId) return false;
const getUserQuery = () => {
if (!!userEmail) {
return {
OR: [
{
id: userId,
},
{
email: userEmail,
},
],
};
} else {
return {
id: userId,
};
}
};
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
user: getUserQuery(),
},
},
},
});
return !!team;
};
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
return res.status(405).json({ message: "No SendGrid API key or email" });
@@ -137,12 +177,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const isUserAttendeeOrOrganiser =
booking?.user?.id === session.user.id ||
attendeesList.find((attendee) => attendee.id === session.user.id);
attendeesList.find(
(attendee) => attendee.id === session.user.id || attendee.email === session.user.email
);
if (!isUserAttendeeOrOrganiser) {
return res.status(403).send({
message: "Unauthorised",
});
const isUserMemberOfTheTeam = checkIfUserIsPartOfTheSameTeam(
booking?.eventType?.teamId,
session.user.id,
session.user.email
);
if (!isUserMemberOfTheTeam) {
return res.status(403).send({
message: "Unauthorised",
});
}
}
await prisma.booking.update({
@@ -202,7 +252,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return res.status(403).json({ message: "User does not have team plan to send out emails" });
} catch (err) {
console.warn("something_went_wrong", err);
console.warn("Error in /recorded-daily-video", err);
return res.status(500).json({ message: "something went wrong" });
}
}
+25 -27
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
@@ -21,7 +29,7 @@ const querySchema = z
async function getIdentityData(req: NextApiRequest) {
const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req);
const org = isValidOrgDomain ? currentOrgDomain : null;
@@ -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
@@ -9,7 +9,7 @@ type Response = {
};
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
const { currentOrgDomain } = orgDomainConfig(req.headers.host ?? "");
const { currentOrgDomain } = orgDomainConfig(req);
const result = await checkUsername(req.body.username, currentOrgDomain);
return res.status(200).json(result);
}
+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
@@ -65,7 +65,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const session = await getServerSession({ req, res });
const ssr = await ssrInit(context);
const { currentOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const { currentOrgDomain } = orgDomainConfig(context.req);
if (session) {
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
+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);
+64 -15
View File
@@ -115,6 +115,13 @@ export default function Success(props: SuccessProps) {
const tz = props.tz ? props.tz : isSuccessBookingPage && attendeeTimeZone ? attendeeTimeZone : timeZone();
const location = props.bookingInfo.location as ReturnType<typeof getEventLocationValue>;
let rescheduleLocation: string | undefined;
if (
typeof props.bookingInfo.responses.location === "object" &&
"optionValue" in props.bookingInfo.responses.location
) {
rescheduleLocation = props.bookingInfo.responses.location.optionValue;
}
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
props?.bookingInfo?.metadata || {}
@@ -148,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");
@@ -295,7 +302,14 @@ export default function Success(props: SuccessProps) {
bookingInfo.status
);
const rescheduleLocationToDisplay = getSuccessPageLocationMessage(
rescheduleLocation ?? "",
t,
bookingInfo.status
);
const providerName = guessEventLocationType(location)?.label;
const rescheduleProviderName = guessEventLocationType(rescheduleLocation)?.label;
return (
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
@@ -328,14 +342,17 @@ export default function Success(props: SuccessProps) {
<div
className={classNames(
shouldAlignCentrally ? "text-center" : "",
"flex items-end justify-center px-4 pb-20 pt-4 sm:block sm:p-0"
"flex items-end justify-center px-4 pb-20 pt-4 sm:flex sm:p-0"
)}>
<div
className={classNames("my-4 transition-opacity sm:my-0", isEmbed ? "" : " inset-0")}
className={classNames(
"main my-4 flex flex-col transition-opacity sm:my-0 ",
isEmbed ? "" : " inset-0"
)}
aria-hidden="true">
<div
className={classNames(
"main inline-block transform overflow-hidden rounded-lg border sm:my-8 sm:max-w-xl",
"inline-block transform overflow-hidden rounded-lg border sm:my-8 sm:max-w-xl",
!isBackgroundTransparent && " bg-default dark:bg-muted border-booker border-booker-width",
"px-8 pb-4 pt-5 text-left align-bottom transition-all sm:w-full sm:py-8 sm:align-middle"
)}
@@ -467,18 +484,50 @@ export default function Success(props: SuccessProps) {
<>
<div className="mt-3 font-medium">{t("where")}</div>
<div className="col-span-2 mt-3" data-testid="where">
{locationToDisplay.startsWith("http") ? (
<a
href={locationToDisplay}
target="_blank"
title={locationToDisplay}
className="text-default flex items-center gap-2 underline"
rel="noreferrer">
{providerName || "Link"}
<ExternalLink className="text-default inline h-4 w-4" />
</a>
{!rescheduleLocation || locationToDisplay === rescheduleLocationToDisplay ? (
locationToDisplay.startsWith("http") ? (
<a
href={locationToDisplay}
target="_blank"
title={locationToDisplay}
className="text-default flex items-center gap-2"
rel="noreferrer">
{providerName || "Link"}
<ExternalLink className="text-default inline h-4 w-4" />
</a>
) : (
locationToDisplay
)
) : (
locationToDisplay
<>
{!!formerTime &&
(locationToDisplay.startsWith("http") ? (
<a
href={locationToDisplay}
target="_blank"
title={locationToDisplay}
className="text-default flex items-center gap-2 line-through"
rel="noreferrer">
{providerName || "Link"}
<ExternalLink className="text-default inline h-4 w-4" />
</a>
) : (
<p className="line-through">{locationToDisplay}</p>
))}
{rescheduleLocationToDisplay.startsWith("http") ? (
<a
href={rescheduleLocationToDisplay}
target="_blank"
title={rescheduleLocationToDisplay}
className="text-default flex items-center gap-2"
rel="noreferrer">
{rescheduleProviderName || "Link"}
<ExternalLink className="text-default inline h-4 w-4" />
</a>
) : (
rescheduleLocationToDisplay
)}
</>
)}
</div>
</>
+1 -1
View File
@@ -61,7 +61,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context);
const { link, slug } = paramsSchema.parse(context.params);
const { rescheduleUid, duration: queryDuration } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
const org = isValidOrgDomain ? currentOrgDomain : null;
const { ssrInit } = await import("@server/lib/ssr");
@@ -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 -45
View File
@@ -65,7 +65,6 @@ import {
MoreHorizontal,
Trash,
Upload,
User as UserIcon,
Users,
} from "@calcom/ui/components/icon";
@@ -73,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"];
@@ -299,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);
@@ -399,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 && (
@@ -821,34 +809,6 @@ const Actions = () => {
);
};
const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
const { t } = useLocale();
const orgBranding = useOrgBranding();
return (
<Alert
className="my-4"
severity="info"
title={t("set_up_your_profile")}
message={t("set_up_your_profile_description", { orgName: orgBranding?.name })}
CustomIcon={UserIcon}
actions={
<div className="flex gap-1">
<Button color="minimal" className="text-sky-700 hover:bg-sky-100" onClick={closeAction}>
{t("dismiss")}
</Button>
<Button
color="secondary"
className="border-sky-700 bg-sky-50 text-sky-700 hover:border-sky-900 hover:bg-sky-200"
href="/getting-started">
{t("set_up")}
</Button>
</div>
}
/>
);
};
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
const { t } = useLocale();
return (
@@ -984,7 +944,6 @@ const EventTypesPage = () => {
heading={t("event_types_page_title")}
hideHeadingOnMobile
subtitle={t("event_types_page_subtitle")}
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
beforeCTAactions={<Actions />}
CTA={<CTA data={data} />}>
<HeadSeo
@@ -0,0 +1,7 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[user]";
export { default } from "../[user]";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
+7
View File
@@ -0,0 +1,7 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "./index";
export { default } from "./index";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
+23 -10
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) {
@@ -280,8 +283,8 @@ const ProfileView = () => {
/>
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
<Label className="mb-0 text-base font-semibold text-red-700">{t("danger_zone")}</Label>
<p className="text-subtle text-sm">{t("account_deletion_cannot_be_undone")}</p>
</div>
{/* Delete account Dialog */}
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
@@ -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>
@@ -164,14 +164,13 @@ const PasswordView = ({ user }: PasswordViewProps) => {
<>
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
{user && user.identityProvider !== IdentityProvider.CAL ? (
<div>
<div className="mt-6">
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
{t("account_managed_by_identity_provider", {
provider: identityProviderNameMap[user.identityProvider],
})}
</h2>
</div>
<div className="border-subtle rounded-b-xl border border-t-0 px-4 py-6 sm:px-6">
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
{t("account_managed_by_identity_provider", {
provider: identityProviderNameMap[user.identityProvider],
})}
</h2>
<p className="text-subtle mt-1 text-sm">
{t("account_managed_by_identity_provider_description", {
provider: identityProviderNameMap[user.identityProvider],
@@ -180,7 +179,7 @@ const PasswordView = ({ user }: PasswordViewProps) => {
</div>
) : (
<Form form={formMethods} handleSubmit={handleSubmit}>
<div className="border-x px-4 py-6 sm:px-6">
<div className="border-subtle border-x px-4 py-6 sm:px-6">
{formMethods.formState.errors.apiError && (
<div className="pb-6">
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
+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")}
+33 -29
View File
@@ -1,3 +1,9 @@
// This route is reachable by
// 1. /team/[slug]
// 2. / (when on org domain e.g. http://calcom.cal.com/. This is through a rewrite from next.config.js)
// Also the getServerSideProps and default export are reused by
// 1. org/[orgSlug]/team/[slug]
// 2. org/[orgSlug]/[user]/[type]
import classNames from "classnames";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -5,13 +11,14 @@ 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";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import slugify from "@calcom/lib/slugify";
@@ -20,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";
@@ -28,13 +35,14 @@ 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";
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
const log = logger.getSubLogger({ prefix: ["team/[slug]"] });
function TeamPage({
team,
isUnpublished,
@@ -104,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>
@@ -142,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>
@@ -271,18 +269,26 @@ function TeamPage({
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(
context.req.headers.host ?? "",
context.params?.orgSlug
);
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
// Provided by Rewrite from next.config.js
const isOrgProfile = context.query?.isOrgProfile === "1";
const flags = await getFeatureFlagMap(prisma);
const isOrganizationFeatureEnabled = flags["organizations"];
log.debug("getServerSideProps", {
isOrgProfile,
isOrganizationFeatureEnabled,
isValidOrgDomain,
currentOrgDomain,
});
const team = await getTeamWithMembers({
slug: slugify(slug ?? ""),
orgSlug: currentOrgDomain,
isTeamView: true,
isOrgView: isValidOrgDomain && context.resolvedUrl === "/",
isOrgView: isValidOrgDomain && isOrgProfile,
});
if (!isOrgContext && slug) {
@@ -290,6 +296,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
slug: slug,
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: context.query,
});
if (redirect) {
@@ -299,17 +306,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const ssr = await ssrInit(context);
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
console.info("gSSP, team/[slug] - ", {
isValidOrgDomain,
currentOrgDomain,
ALLOWED_HOSTNAMES: process.env.ALLOWED_HOSTNAMES,
flags: JSON.stringify(flags),
});
// Taking care of sub-teams and orgs
if (
(!isValidOrgDomain && team?.parent) ||
(!isValidOrgDomain && !!metadata?.isOrganization) ||
flags["organizations"] !== true
!isOrganizationFeatureEnabled
) {
return { notFound: true } as const;
}
@@ -360,7 +362,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 || ""),
};
})
: [];
+2 -4
View File
@@ -74,10 +74,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { rescheduleUid, duration: queryDuration } = context.query;
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
context.req.headers.host ?? "",
context.params?.orgSlug
);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
@@ -85,6 +82,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);
});
});
});
@@ -0,0 +1,441 @@
import { loginUser } from "../fixtures/regularBookings";
import { test } from "../lib/fixtures";
test.describe("Booking With Phone Question and Each Other Question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test.beforeEach(async ({ page, users, bookingPage }) => {
await loginUser(users);
await page.goto("/event-types");
await bookingPage.goToEventType("30 min");
await bookingPage.goToTab("event_advanced_tab_title");
});
test.describe("Booking With Select Question and Address Question", () => {
test("Select required and Address required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select 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("Select and Address not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and Address question (only select 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 Select Question and checkbox group Question", () => {
test("Select required and checkbox group required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and Checkbox question (both required)",
secondQuestion: "checkbox",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Select and checkbox group not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and Checkbox question (only Select 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 Select Question and checkbox Question", () => {
test("Select required and checkbox required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and boolean question (both required)",
secondQuestion: "boolean",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Select and checkbox not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and boolean question (only select 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 Select Question and Long text Question", () => {
test("Select required and Long text required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and textarea question (both required)",
secondQuestion: "textarea",
options: bookingOptions,
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
test("Select and Long text not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and textarea question (only select 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 Select Question and Multi email Question", () => {
test("Select required and Multi email required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select 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("Select and Multi email not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and multiemail question (only select 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 Select Question and multiselect Question", () => {
test("Select required and multiselect text required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select 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("Select and multiselect text not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and multiselect question (only select required)",
secondQuestion: "multiselect",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.rescheduleBooking(eventTypePage);
await bookingPage.assertBookingRescheduled(eventTypePage);
await bookingPage.cancelBooking(eventTypePage);
await bookingPage.assertBookingCanceled(eventTypePage);
});
});
test.describe("Booking With Select Question and Number Question", () => {
test("Select required and Number required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select 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("Select and Number not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and number question (only select 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 Select Question and Phone Question", () => {
test("Select required and select required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select 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("Select and Phone not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and phone question (only select required)",
secondQuestion: "phone",
options: { ...bookingOptions, isRequired: false },
});
});
});
test.describe("Booking With Select Question and Radio group Question", () => {
test("Select required and Radio group required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select 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("Select and Radio group not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and radio question (only select 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 Select Question and Short text question", () => {
test("Select required and Short text required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select 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("Select and Short text not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("select", "select-test", "select test", true, "select 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: "select",
fillText: "Test Select question and text question (only select required)",
secondQuestion: "text",
options: { ...bookingOptions, isRequired: false },
});
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();
};
+56
View File
@@ -0,0 +1,56 @@
import type { Page } from "@playwright/test";
import type { Team } from "@prisma/client";
import { prisma } from "@calcom/prisma";
const getRandomSlug = () => `org-${Math.random().toString(36).substring(7)}`;
// creates a user fixture instance and stores the collection
export const createOrgsFixture = (page: Page) => {
const store = { orgs: [], page } as { orgs: Team[]; page: typeof page };
return {
create: async (opts: { name: string; slug?: string; requestedSlug?: string }) => {
const org = await createOrgInDb({
name: opts.name,
slug: opts.slug || getRandomSlug(),
requestedSlug: opts.requestedSlug,
});
store.orgs.push(org);
return org;
},
get: () => store.orgs,
deleteAll: async () => {
await prisma.team.deleteMany({ where: { id: { in: store.orgs.map((org) => org.id) } } });
store.orgs = [];
},
delete: async (id: number) => {
await prisma.team.delete({ where: { id } });
store.orgs = store.orgs.filter((b) => b.id !== id);
},
};
};
async function createOrgInDb({
name,
slug,
requestedSlug,
}: {
name: string;
slug: string | null;
requestedSlug?: string;
}) {
return await prisma.team.create({
data: {
name: name,
slug: slug,
metadata: {
isOrganization: true,
...(requestedSlug
? {
requestedSlug,
}
: null),
},
},
});
}
+36 -17
View File
@@ -1,5 +1,7 @@
import { expect, type Page } from "@playwright/test";
import dayjs from "@calcom/dayjs";
import type { createUsersFixture } from "./users";
const reschedulePlaceholderText = "Let others know why you need to reschedule";
@@ -13,6 +15,7 @@ type BookingOptions = {
hasPlaceholder?: boolean;
isReschedule?: boolean;
isRequired?: boolean;
isMultiSelect?: boolean;
};
interface QuestionActions {
@@ -37,6 +40,12 @@ type fillAndConfirmBookingParams = {
type UserFixture = ReturnType<typeof createUsersFixture>;
function isLastDayOfMonth(): boolean {
const today = dayjs();
const endOfMonth = today.endOf("month");
return today.isSame(endOfMonth, "day");
}
const fillQuestion = async (eventTypePage: Page, questionType: string, customLocators: customLocators) => {
const questionActions: QuestionActions = {
phone: async () => {
@@ -102,7 +111,6 @@ const fillQuestion = async (eventTypePage: Page, questionType: string, customLoc
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("text test");
},
};
if (questionActions[questionType]) {
await questionActions[questionType]();
}
@@ -113,6 +121,17 @@ export async function loginUser(users: UserFixture) {
await pro.apiLogin();
}
const goToNextMonthIfNoAvailabilities = async (eventTypePage: Page) => {
try {
if (isLastDayOfMonth()) {
await eventTypePage.getByTestId("view_next_month").waitFor({ timeout: 6000 });
await eventTypePage.getByTestId("view_next_month").click();
}
} catch (err) {
console.info("No need to click on view next month button");
}
};
export function createBookingPageFixture(page: Page) {
return {
goToEventType: async (eventType: string) => {
@@ -153,19 +172,13 @@ export function createBookingPageFixture(page: Page) {
return eventtypePromise;
},
selectTimeSlot: async (eventTypePage: Page) => {
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
await eventTypePage.getByRole("button", { name: "View next" }).click();
}
await goToNextMonthIfNoAvailabilities(eventTypePage);
await eventTypePage.getByTestId("time").first().click();
},
clickReschedule: async () => {
await page.getByText("Reschedule").click();
},
navigateToAvailableTimeSlot: async () => {
while (await page.getByRole("button", { name: "View next" }).isVisible()) {
await page.getByRole("button", { name: "View next" }).click();
}
},
selectFirstAvailableTime: async () => {
await page.getByTestId("time").first().click();
},
@@ -174,18 +187,18 @@ 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 goToNextMonthIfNoAvailabilities(eventTypePage);
await eventTypePage.getByText("Reschedule").click();
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
await eventTypePage.getByRole("button", { name: "View next" }).click();
@@ -194,7 +207,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();
@@ -216,7 +235,7 @@ export function createBookingPageFixture(page: Page) {
// Change the selector for specifics cases related to select question
const shouldChangeSelectLocator = (question: string, secondQuestion: string): boolean =>
question === "select" && ["multiemail", "multiselect"].includes(secondQuestion);
question === "select" && ["multiemail", "multiselect", "address"].includes(secondQuestion);
const shouldUseLastRadioGroupLocator = (question: string, secondQuestion: string): boolean =>
question === "radio" && secondQuestion === "checkbox";
+74 -4
View File
@@ -9,6 +9,7 @@ import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/avail
import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
import { TimeZoneEnum } from "./types";
@@ -78,11 +79,13 @@ const createTeamAndAddUser = async (
isUnpublished,
isOrg,
hasSubteam,
organizationId,
}: {
user: { id: number; username: string | null; role?: MembershipRole };
isUnpublished?: boolean;
isOrg?: boolean;
hasSubteam?: true;
organizationId?: number | null;
},
workerInfo: WorkerInfo
) => {
@@ -101,6 +104,7 @@ const createTeamAndAddUser = async (
data.children = { connect: [{ id: team.id }] };
}
data.orgUsers = isOrg ? { connect: [{ id: user.id }] } : undefined;
data.parent = organizationId ? { connect: { id: organizationId } } : undefined;
const team = await prisma.team.create({
data,
});
@@ -114,6 +118,7 @@ const createTeamAndAddUser = async (
accepted: true,
},
});
return team;
};
@@ -282,6 +287,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
isUnpublished: scenario.isUnpublished,
isOrg: scenario.isOrg,
hasSubteam: scenario.hasSubteam,
organizationId: opts?.organizationId,
},
workerInfo
);
@@ -399,11 +405,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
logout: async () => {
await page.goto("/auth/logout");
},
getTeam: async () => {
return prisma.membership.findFirstOrThrow({
getFirstTeam: async () => {
const memberships = await prisma.membership.findMany({
where: { userId: user.id },
include: { team: true },
});
const membership = memberships
.map((membership) => {
return {
...membership,
team: {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
},
};
})
.find((membership) => !membership.team?.metadata?.isOrganization);
if (!membership) {
throw new Error("No team found for user");
}
return membership;
},
getOrg: async () => {
return prisma.membership.findFirstOrThrow({
@@ -453,16 +475,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
_bookings?: PrismaType.BookingCreateInput[];
};
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name" | "email";
type CustomUserOptsKeys =
| "username"
| "password"
| "completedOnboarding"
| "locale"
| "name"
| "email"
| "organizationId";
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
timeZone?: TimeZoneEnum;
eventTypes?: SupportedTestEventTypes[];
// ignores adding the worker-index after username
useExactUsername?: boolean;
roleInOrganization?: MembershipRole;
};
// creates the actual user in the db.
const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => {
const createUser = (
workerInfo: WorkerInfo,
opts?: CustomUserOpts | null
): PrismaType.UserUncheckedCreateInput => {
// build a unique name for our user
const uname =
opts?.useExactUsername && opts?.username
@@ -478,6 +511,7 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
completedOnboarding: opts?.completedOnboarding ?? true,
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
locale: opts?.locale ?? "en",
...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }),
schedules:
opts?.completedOnboarding ?? true
? {
@@ -493,6 +527,42 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
}
: undefined,
};
function getOrganizationRelatedProps({
organizationId,
role,
}: {
organizationId: number | null | undefined;
role: MembershipRole | undefined;
}) {
if (!organizationId) {
return null;
}
if (!role) {
throw new Error("Missing role for user in organization");
}
return {
organizationId: organizationId || null,
...(organizationId
? {
teams: {
// Create membership
create: [
{
team: {
connect: {
id: organizationId,
},
},
accepted: true,
role: MembershipRole.ADMIN,
},
],
},
}
: null),
};
}
};
async function confirmPendingPayment(page: Page) {
+6
View File
@@ -9,6 +9,7 @@ import prisma from "@calcom/prisma";
import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings";
import { createEmbedsFixture } from "../fixtures/embeds";
import { createOrgsFixture } from "../fixtures/orgs";
import { createPaymentsFixture } from "../fixtures/payments";
import { createBookingPageFixture } from "../fixtures/regularBookings";
import { createRoutingFormsFixture } from "../fixtures/routingForms";
@@ -17,6 +18,7 @@ import { createUsersFixture } from "../fixtures/users";
export interface Fixtures {
page: Page;
orgs: ReturnType<typeof createOrgsFixture>;
users: ReturnType<typeof createUsersFixture>;
bookings: ReturnType<typeof createBookingsFixture>;
payments: ReturnType<typeof createPaymentsFixture>;
@@ -48,6 +50,10 @@ declare global {
* @see https://playwright.dev/docs/test-fixtures
*/
export const test = base.extend<Fixtures>({
orgs: async ({ page }, use) => {
const orgsFixture = createOrgsFixture(page);
await use(orgsFixture);
},
users: async ({ page, context, emails }, use, workerInfo) => {
const usersFixture = createUsersFixture(page, emails, workerInfo);
await use(usersFixture);
+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;
+189 -7
View File
@@ -1,16 +1,16 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { prisma } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { test } from "./lib/fixtures";
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth, testName, todo } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Teams", () => {
test.describe("Teams - NonOrg", () => {
test.afterEach(({ users }) => users.deleteAll());
test("Can create teams via Wizard", async ({ page, users }) => {
const user = await users.create();
const inviteeEmail = `${user.username}+invitee@example.com`;
@@ -64,6 +64,7 @@ test.describe("Teams", () => {
// await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
});
});
test("Can create a booking for Collective EventType", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
@@ -78,7 +79,7 @@ test.describe("Teams", () => {
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
});
const { team } = await owner.getTeam();
const { team } = await owner.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
@@ -99,6 +100,7 @@ test.describe("Teams", () => {
// TODO: Assert whether the user received an email
});
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
@@ -113,7 +115,7 @@ test.describe("Teams", () => {
schedulingType: SchedulingType.ROUND_ROBIN,
});
const { team } = await owner.getTeam();
const { team } = await owner.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
@@ -135,6 +137,7 @@ test.describe("Teams", () => {
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
test("Non admin team members cannot create team in org", async ({ page, users }) => {
const teamMateName = "teammate-1";
@@ -169,6 +172,7 @@ test.describe("Teams", () => {
await prisma.team.delete({ where: { id: org.teamId } });
}
});
test("Can create team with same name as user", async ({ page, users }) => {
// Name to be used for both user and team
const uniqueName = "test-unique-name";
@@ -210,6 +214,7 @@ test.describe("Teams", () => {
await prisma.team.delete({ where: { id: team?.id } });
});
});
test("Can create a private team", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
@@ -226,7 +231,7 @@ test.describe("Teams", () => {
});
await owner.apiLogin();
const { team } = await owner.getTeam();
const { team } = await owner.getFirstTeam();
// Mark team as private
await page.goto(`/settings/teams/${team.id}/members`);
@@ -247,3 +252,180 @@ test.describe("Teams", () => {
todo("Reschedule a Collective EventType booking");
todo("Reschedule a Round Robin EventType booking");
});
test.describe("Teams - Org", () => {
test.afterEach(({ orgs, users }) => {
orgs.deleteAll();
users.deleteAll();
});
test("Can create teams via Wizard", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const user = await users.create({
organizationId: org.id,
roleInOrganization: MembershipRole.ADMIN,
});
const inviteeEmail = `${user.username}+invitee@example.com`;
await user.apiLogin();
await page.goto("/teams");
await test.step("Can create team", async () => {
// Click text=Create Team
await page.locator("text=Create a new Team").click();
await page.waitForURL((url) => url.pathname === "/settings/teams/new");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
// Click text=Continue
await page.locator("text=Continue").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
await page.waitForSelector('[data-testid="pending-member-list"]');
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
});
await test.step("Can add members", async () => {
// Click [data-testid="new-member-button"]
await page.locator('[data-testid="new-member-button"]').click();
// Fill [placeholder="email\@example\.com"]
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
// Click [data-testid="invite-new-member-button"]
await page.locator('[data-testid="invite-new-member-button"]').click();
await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible();
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
});
await test.step("Can remove members", async () => {
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
const lastRemoveMemberButton = page.locator('[data-testid="remove-member-button"]').last();
await lastRemoveMemberButton.click();
await page.waitForLoadState("networkidle");
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
// Cleanup here since this user is created without our fixtures.
await prisma.user.delete({ where: { email: inviteeEmail } });
});
await test.step("Can finish team creation", async () => {
await page.locator("text=Finish").click();
await page.waitForURL("/settings/teams");
});
await test.step("Can disband team", async () => {
await page.locator('[data-testid="team-list-item-link"]').click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
await page.locator("text=Disband Team").click();
await page.locator("text=Yes, disband team").click();
await page.waitForURL("/teams");
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
});
});
test("Can create a booking for Collective EventType", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await expect(page.locator('[data-testid="404-page"]')).toBeVisible();
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// The title of the booking
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
// The booker should be in the attendee list
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
// All the teammates should be in the booking
for (const teammate of teamMatesObj) {
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
}
}
);
// TODO: Assert whether the user received an email
});
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(ownerObj, {
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
});
const { team } = await owner.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// The person who booked the meeting should be in the attendee list
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
// The title of the booking
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
// Since all the users have the same leastRecentlyBooked value
// Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull();
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
});
async function doOnOrgDomain(
{ orgSlug, page }: { orgSlug: string | null; page: Page },
callback: ({ page }: { page: Page }) => Promise<void>
) {
if (!orgSlug) {
throw new Error("orgSlug is not available");
}
page.setExtraHTTPHeaders({
"x-cal-force-slug": orgSlug,
});
await callback({ page });
}
+2 -2
View File
@@ -18,7 +18,7 @@ test.afterAll(async ({ users }) => {
test.describe("Unpublished", () => {
test("Regular team profile", async ({ page, users }) => {
const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true });
const { team } = await owner.getTeam();
const { team } = await owner.getFirstTeam();
const { requestedSlug } = team.metadata as { requestedSlug: string };
await page.goto(`/team/${requestedSlug}`);
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
@@ -33,7 +33,7 @@ test.describe("Unpublished", () => {
isUnpublished: true,
schedulingType: SchedulingType.COLLECTIVE,
});
const { team } = await owner.getTeam();
const { team } = await owner.getFirstTeam();
const { requestedSlug } = team.metadata as { requestedSlug: string };
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${requestedSlug}/${teamEventSlug}`);
+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;
@@ -1957,8 +1957,6 @@
"org_no_teams_yet": "هذه المنظمة ليس لديها فرق بعد",
"org_no_teams_yet_description": "إذا كنت مسؤول، تأكد من إنشاء فرق لعرضها هنا.",
"set_up": "الإعداد",
"set_up_your_profile": "إعداد ملفك الشخصي",
"set_up_your_profile_description": "دع الناس يعرفون من أنت داخل {{orgName}}، ومتى يتعاملون مع الرابط العام الخاص بك.",
"my_profile": "الملف الشخصي",
"my_settings": "الإعدادات",
"crm": "CRM",
@@ -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 الخاصة بك"
}
@@ -1957,8 +1957,6 @@
"org_no_teams_yet": "Tato organizace zatím neobsahuje žádné týmy",
"org_no_teams_yet_description": "Pokud jste správce, nezapomeňte vytvořit týmy, které se zde budou zobrazovat.",
"set_up": "Nastavení",
"set_up_your_profile": "Nastavení profilu",
"set_up_your_profile_description": "Dejte lidem vědět, co v rámci organizace {{orgName}} děláte, pokud si otevřou váš veřejný odkaz.",
"my_profile": "Můj profil",
"my_settings": "Moje nastavení",
"crm": "CRM",
@@ -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"
}
@@ -1965,8 +1965,6 @@
"org_no_teams_yet": "Diese Organization hat noch keine Teams",
"org_no_teams_yet_description": "Falls Sie ein Administrator sind, sollten Sie unbedingt Teams erstellen, welche hier angezeigt werden können.",
"set_up": "Einrichten",
"set_up_your_profile": "Richten Sie Ihr Profil ein",
"set_up_your_profile_description": "Lassen Sie Leute beim Klicken auf Ihren öffentlichen Link wissen, welche Funktion Sie innerhalb von {{orgName}} haben.",
"my_profile": "Mein Profil",
"my_settings": "Meine Einstellungen",
"crm": "CRM",
@@ -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"
}
@@ -605,7 +605,7 @@
"hide_book_a_team_member": "Hide Book a Team Member Button",
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
"danger_zone": "Danger zone",
"account_deletion_cannot_be_undone":"Careful. Account deletion cannot be undone.",
"account_deletion_cannot_be_undone":"Be Careful. Account deletion cannot be undone.",
"back": "Back",
"cancel": "Cancel",
"cancel_all_remaining": "Cancel all remaining",
@@ -1296,6 +1296,7 @@
"select_calendars": "Select which calendars you want to check for conflicts to prevent double bookings.",
"check_for_conflicts": "Check for conflicts",
"view_recordings": "View recordings",
"check_for_recordings":"Check for recordings",
"adding_events_to": "Adding events to",
"follow_system_preferences": "Follow system preferences",
"custom_brand_colors": "Custom brand colors",
@@ -1605,6 +1606,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",
@@ -1972,8 +1974,6 @@
"org_no_teams_yet": "This organization has no teams yet",
"org_no_teams_yet_description": "If you are an administrator, be sure to create teams to be shown here.",
"set_up": "Set up",
"set_up_your_profile": "Set up your profile",
"set_up_your_profile_description": "Let people know who you are within {{orgName}}, and when they engage with your public link.",
"my_profile": "My Profile",
"my_settings": "My Settings",
"crm": "CRM",
@@ -2095,7 +2095,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.",
"join_event_location": "Join {{eventLocationType}}",
"join_meeting": "Join Meeting",
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1957,8 +1957,6 @@
"org_no_teams_yet": "Esta organización aún no tiene equipos",
"org_no_teams_yet_description": "Si usted es un administrador, asegúrese de crear equipos para que se muestren aquí.",
"set_up": "Configurar",
"set_up_your_profile": "Configure su perfil",
"set_up_your_profile_description": "Informe a las personas quién es usted dentro de {{orgName}} y cuándo interactúen con su enlace público.",
"my_profile": "Mi perfil",
"my_settings": "Mi configuración",
"crm": "CRM",
@@ -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 "
}
@@ -17,6 +17,7 @@
"verify_email_banner_body": "Egiaztatu zure email helbidea mezuak eta egutegiko eguneratzeak ahalik eta hobekien jasoko dituzula bermatzeko",
"verify_email_email_header": "Egiaztatu zure email helbidea",
"verify_email_email_button": "Egiaztatu emaila",
"copy_somewhere_safe": "Gorde API gako hau toki seguru batean. Ezingo duzu berriro ikusi.",
"verify_email_email_body": "Mesedez, egiaztatu zure email helbidea beheko botoia sakatuz.",
"verify_email_by_code_email_body": "Mesedez, egiaztatu zure email helbidea beheko kodea erabiliz.",
"verify_email_email_link_text": "Hemen duzu esteka, botoiak sakatzea gustuko ez baduzu:",
@@ -45,17 +46,25 @@
"invite_team_notifcation_badge": "Gon.",
"your_event_has_been_scheduled": "Zure gertaera programatu da",
"your_event_has_been_scheduled_recurring": "Zure gertaera errepikaria programatu da",
"accept_our_license": "Onartu gure lizentzia <1>NEXT_PUBLIC_LICENSE_CONSENT</1> .env aldagaia aldatuz honakora: '{{agree}}'.",
"remove_banner_instructions": "Baner hau ezabatzeko, mesedez zabaldu zure .env fitxategia eta aldatu <1>NEXT_PUBLIC_LICENSE_CONSENT</1> aldagaia honakora: '{{agree}}'.",
"error_message": "Errore-mezua honakoa ian da: '{{errorMessage}}'",
"refund_failed_subject": "Itzulketak huts egin du: {{name}} - {{date}} - {{eventType}}",
"refund_failed": "Huts egin du itzulketak {{eventType}} gertaerarako, {{userName}}(r)ekin {{date}}(e)an.",
"a_refund_failed": "Itzulketa batek huts egin du",
"awaiting_payment_subject": "Ordainketaren zain: {{title}} {{date}}(e)an",
"meeting_awaiting_payment": "Zure bilera ordainketa zain dago",
"help": "Laguntza",
"price": "Prezioa",
"paid": "Ordainduta",
"refunded": "Itzulita",
"payment": "Ordainketa",
"missing_card_fields": "Txartelaren eremuak falta dira",
"pay_now": "Ordaindu orain",
"terms_summary": "Terminoen laburpena",
"open_env": "Ireki .env eta onartu gure Lizentzia",
"env_changed": "Nire .env aldatu dut",
"accept_license": "Onart Lizentzia",
"still_waiting_for_approval": "Gertaera bat onarpenaren zain dago",
"event_is_still_waiting": "Gertaera-eskaera oraindik zain dago: {{attendeeName}} - {{date}} - {{eventType}}",
"no_more_results": "Emaitza gehiagorik ez",
@@ -82,12 +91,36 @@
"event_has_been_rescheduled": "Eguneratuta - Zure gertaeraren programazioa aldatu egin da",
"request_reschedule_subtitle": "{{organizer}}(e)k erreserba bertan behera utzi du eta beste denbora-tarte bat hautatzeko eskatu dizu.",
"request_reschedule_title_organizer": "Beste denbora-tarte bat hautatzeko eskatu diozu {{attendee}}(r)i",
"request_reschedule_subtitle_organizer": "Erreserba bertan behera utzi duzu eta {{attendee}}(e)k erreserba-ordu berri bat hautatu beharko du.",
"rescheduled_event_type_subject": "Berrantolatzeko eskaera bidalita: {{eventType}} {{name}}(r)ekin {{date}}(e)an",
"hi_user_name": "Kaixo {{name}}",
"ics_event_title": "{{eventType}} {{name}}(r)ekin",
"new_event_subject": "Gertaera berria: {{attendeeName}} - {{date}} - {{eventType}}",
"notes": "Oharrak",
"manage_my_bookings": "Kudeatu nire erreserbak",
"need_to_make_a_change": "Aldaketaren bat egin behar duzu?",
"new_event_scheduled": "Gertaera berri bat programatu da.",
"new_event_scheduled_recurring": "Gertaera errepikari berri bat programatu da.",
"invitee_email": "Gonbidatuaren emaila",
"invitee_timezone": "Gonbidatuaren ordu-eremua",
"event_type": "Gertaera mota",
"enter_meeting": "Sartu bilerara",
"video_call_provider": "Bideodeiaren hornitzailea",
"meeting_id": "Bileraren IDa",
"meeting_password": "Bileraren pasahitza",
"meeting_url": "Bilerarako URLa",
"meeting_request_rejected": "Zure bilera eskaera ez da onartu",
"rejected_event_type_with_organizer": "Errefusatua: {{eventType}} {{organizer}}(r)ekin {{date}}(e)an",
"hi": "Kaixo",
"join_team": "Batu taldera",
"manage_this_team": "Kudeatu talde hau",
"team_info": "Taldearen informazioa",
"you_have_been_invited": "{{teamName}} taldera batzeko gonbidatua izan zara",
"hidden_team_member_title": "Ezkutuan zaude talde honetan",
"hidden_team_owner_message": "Pro kontu bat behar duzu taldeak erabiltzeko, ezkutuan geratuko zara bitartean.",
"team_upgrade_banner_description": "Ez duzu taldea konfiguratzen bukatu. Zure \"{{teamName}}\" taldea bertsio-berritu behar duzu.",
"upgrade_banner_action": "Bertsio-berritu hemen",
"team_upgraded_successfully": "Zure taldea zuzen bertsio-berritu da!",
"use_link_to_reset_password": "Erabili beheko esteka pasahitza berrezartzeko",
"hey_there": "Kaixo,",
"forgot_your_password_calcom": "Pasahitza ahaztu duzu? - {{appName}}",
@@ -122,32 +155,612 @@
"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",
"register": "Izena eman",
"page_doesnt_exist": "Orrialde hau ez dago.",
"check_spelling_mistakes_or_go_back": "Egiaztatu akats ortografikorik ez dagoela edo joan aurreko orrira.",
"404_page_not_found": "404: Orrialde hau ezin izan da aurkitu.",
"booker_event_not_found": "Ezin izan dugu aurkitu erreserbatu nahi izan duzun gertaera.",
"getting_started": "Nola hasi",
"15min_meeting": "15 minutuko bilera",
"30min_meeting": "30 minutuko bilera",
"secret": "Sekretua",
"leave_blank_to_remove_secret": "Zuri utzi sekretua ezabatzeko",
"secret_meeting": "Bilera sekretua",
"already_have_an_account": "Baduzu kontua dagoeneko?",
"create_account": "Sortu kontua",
"confirm_password": "Baieztatu pasahitza",
"reset_your_password": "Ezarri zure pasahitz berria zure email helbidera bidalitako argibideak jarraituz.",
"create_your_account": "Sortu zure kontua",
"sign_up": "Izena eman",
"youve_been_logged_out": "Saioa amaitu duzu",
"hope_to_see_you_soon": "Laster ikusiko zaitugula espero dugu!",
"logged_out": "Saioa amaituta",
"no_account_exists": "Ez dago konturik email helbide horrekin bat datorrenik.",
"create_an_account": "Sortu kontu bat",
"dont_have_an_account": "Ez duzu konturik?",
"sign_in_account": "Hasi saioa zure kontuan",
"sign_in": "Hasi saioa",
"go_back_login": "Itzuli saio-hasiera orrialdera",
"request_password_reset": "Bidali berrezartzeko emaila",
"send_invite": "Bidali gonbidapena",
"forgot_password": "Pasahitza ahaztu duzu?",
"forgot": "Ahaztuta?",
"done": "Eginda",
"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",
"booking_submitted": "Zure erreserba bidali da",
"booking_submitted_recurring": "Zure bilera errepikaria bidali da",
"booking_confirmed": "Zure erreserba baieztatu da",
"booking_confirmed_recurring": "Zure bilera errepikaria baieztatu da",
"reset_password": "Berrezarri pasahitza",
"change_your_password": "Aldatu zure pasahitza",
"show_password": "Erakutsi pasahitza",
"hide_password": "Ezkutatu pasahitza",
"try_again": "Saiatu berriro",
"whoops": "Hara",
"login": "Saioa hasi",
"success": "Arrakasta",
"failed": "Huts egin du",
"password_has_been_reset_login": "Zure pasahitza berrezarri da. Orain saioa has dezakezu sortu berri duzun pasahitzarekin.",
"layout": "Diseinua",
"bookerlayout_default_title": "Lehenetsitako ikuspegia",
"bookerlayout_user_settings_title": "Erreserbatarako diseinua",
"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"
}
+20 -2
View File
@@ -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",
@@ -1959,8 +1966,6 @@
"org_no_teams_yet": "Cette organisation n'a pas encore d'équipe",
"org_no_teams_yet_description": "Si vous êtes un administrateur, assurez-vous de créer des équipes à afficher ici.",
"set_up": "Configurer",
"set_up_your_profile": "Configurer votre profil",
"set_up_your_profile_description": "Faites savoir aux gens qui vous êtes au sein de {{orgName}} et quand ils interagissent avec votre lien public.",
"my_profile": "Mon profil",
"my_settings": "Mes paramètres",
"crm": "CRM",
@@ -2053,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"
}
@@ -1957,8 +1957,6 @@
"org_no_teams_yet": "בארגון הזה עדיין אין צוותים",
"org_no_teams_yet_description": "אם את/ה מנהל/ת מערכת, צור/צרי צוותים שיוצגו כאן.",
"set_up": "הגדרה",
"set_up_your_profile": "הגדרת הפרופיל שלך",
"set_up_your_profile_description": "אפשר/י לאנשים לדעת מי את/ה ב-{{orgName}} וכשהם מקיימים איתך אינטראקציה באמצעות הקישור הציבורי שלך.",
"my_profile": "הפרופיל שלי",
"my_settings": "ההגדרות שלי",
"crm": "CRM",
@@ -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 @@
{}

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