Compare commits

..
75 Commits
Author SHA1 Message Date
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
Keith Williams 5e3c0cdea1 v3.4.2
Auto Comment Merge Conflicts / auto-comment-merge-conflicts (push) Canceled after 0s
release.yml / release (push) Canceled after 0s
2023-10-19 12:31:00 -03:00
sean-brydon 6b6d3d90e4 feat/use-primary-by-default-overlay (#11935) 2023-10-19 19:39:06 +05:30
9b348adb6a fix: added loading state to button (#11624)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2023-10-19 13:52:10 +00:00
Nafees NazikandPeer Richelsen feda420f0c fix: org team page not found with uppercase letters (#11737)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-19 14:44:43 +01:00
Syed Ali Shahbaz 4b818de0c8 feat: Allow hideBranding via public API (#11978) 2023-10-19 10:31:13 -03:00
614741d207 test: Create E2E tests for bookings with custom/required Phone + other questions (#11502)
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>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2023-10-19 09:27:32 -03:00
2550485c49 feat: Shows link location and respective icon in /bookings (#11866)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-19 11:06:48 +02:00
Hariom BalharaandPeer Richelsen efc3e864bb fix: Missing avatar for non-migrated users on team booking page (#11977)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-19 08:32:56 +05:30
Joe Au-Yeung 1bf56fbe93 fix: When GCal OAuth Canceled, Do Not Create A Credential (#11987) 2023-10-18 18:35:24 -07:00
Hariom BalharaandSyed Ali Shahbaz 629629cb9e Add /embed route for booking/[uid] (#11976)
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2023-10-18 18:43:12 +00:00
Peer Richelsen 8c0751b186 fix: location dropdown overflow (#11967) 2023-10-18 11:36:34 +00:00
sean-brydon 0b46f61a23 feat: (Overlay) Persist toggle option (#11961) 2023-10-18 11:16:02 +01:00
Carina WollendorferandCarinaWolli 0c92fbe11d fix: failing scheduleEmailReminder cron job (#11960)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-18 15:17:39 +05:30
Alex van Andel 59fa713549 v3.4.1
Auto Comment Merge Conflicts / auto-comment-merge-conflicts (push) Canceled after 0s
release.yml / release (push) Canceled after 0s
2023-10-17 20:56:46 +01:00
Benny Jooandzomars 63416d4f33 chore: update tslog from 3.2.1 to 4.9.2 (#11717)
Co-authored-by: zomars <zomars@me.com>
2023-10-17 19:00:48 +00:00
DexterStoreyandtedspare 9e927af813 feat: add rate limiting and more error handling to Cal.ai (#11898)
Co-authored-by: tedspare <ted.spare@gmail.com>
2023-10-17 18:26:49 +00:00
Carina WollendorferandCarinaWolli 4b8bdeba74 fix: scheduleEmailReminders cron job (#11929)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-17 11:19:39 -07:00
Peer Richelsen 4e4d67c8c0 chore: updated AI readme (#11926) 2023-10-17 16:08:20 +01:00
Udit Takkar ffda234b3c chore: updat ai port number in readme (#11946) 2023-10-17 16:07:58 +01:00
Peer Richelsen 0bc44c36db feat: added example messages to cal ai post-install (#11944)
* added example messages to cal ai post-install

* Revert yarn.lock changes

* added search plceholder to app store
2023-10-17 16:03:12 +01:00
Crowdin Bot f18bee5c3d New Crowdin translations by Github Action 2023-10-17 14:40:42 +00:00
Alex van Andel 73e16215bd chore: Decouple API from tRPC handlers (#11947) 2023-10-17 15:37:04 +01:00
Peer Richelsen 75d7d2f172 chore: workflow for unapproved issues (#11937)
* chore: workflow for unapproved issues

* Update .github/workflows/comment-unapproved-issues
2023-10-17 12:34:40 +01:00
Crowdin Bot 269dca5b6d New Crowdin translations by Github Action 2023-10-17 11:32:49 +00:00
Alex van Andel 0fd6bed813 chore: Remove users from getSchedule return (#11940) 2023-10-17 13:29:21 +02:00
Joe Au-Yeung 33bef6acd0 Add conditional (#11862) 2023-10-17 11:27:10 +00:00
Crowdin Bot a6c4b31845 New Crowdin translations by Github Action 2023-10-17 11:25:34 +00:00
Crowdin Bot ea8ba8defc New Crowdin translations by Github Action 2023-10-17 11:22:37 +00:00
Crowdin Bot 94cb78491a New Crowdin translations by Github Action 2023-10-17 11:19:55 +00:00
Hariom Balhara d12a5c5883 fix: videoCallUrl not updating when rescheduling with a broken Calendar integration (#11923) 2023-10-17 16:46:24 +05:30
7a014761dc unlock all fields in limits tab (#11936)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-17 09:37:37 +00:00
Manpreet SinghandCarina Wollendorfer 91ac952a72 feat: Adds missing bookingId to Cancel Webhook payload (#11906)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2023-10-17 09:02:35 +00:00
Hariom Balhara 225055fb0c feat: Support moving a user and it's teams to an org as temporary approach (#11892) 2023-10-17 08:36:46 +05:30
Omar López d46e80c2ac fix: inconsistent timezones in e2e tests (#11841)
* fix: inconsistent timezones in e2e tests

* Update users.ts

* Update playwright.config.ts

* Update playwright.config.ts

* Update users.ts

* fix: timezone sensitive tests
2023-10-17 08:23:54 +05:30
Crowdin Bot fe364bd2da New Crowdin translations by Github Action 2023-10-17 00:12:06 +00:00
Syed Ali Shahbaz 2756dff735 fix: Adds mandatory credentiaId in Destination Calendar API endpoint POST (#11880) 2023-10-16 17:09:22 -07:00
Peer Richelsen 401f64b986 Create README.md 2023-10-16 18:57:06 +01:00
Alex van Andel a02dcf485f fix: Add username idx (#11918)
* Add index on 'users.username'

* Bring back fields that are in main
2023-10-16 17:21:01 +00:00
Greg Pabian f2ecd9818a fix: build locale based on validated codes and regions (#11912)
* fix: build locale based on validated codes and regions

* keep html lang stable

* fix type error
2023-10-16 17:29:35 +01:00
sean-brydonandKeith Williams bc81f659aa test: Tests the order of middleware in API (#11852)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-10-16 10:00:34 -03:00
sean-brydonandUdit Takkar f8f038c5e9 stack-troubleshooter-border-color-support (#11907)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-10-16 11:45:16 +00:00
alannncandCarinaWolli fefb6acc57 feat: download insights raw data as csv (#11645)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-16 13:27:25 +02:00
Chiranjeev Vishnoi 461120ad84 fix: [CAL-2593] Uploading a CSV File does not comma separate the values (#11756)
* csv email validation added

* most common csv delimeters added
2023-10-16 10:39:05 +01:00
Udit TakkarandPeer Richelsen bab72a5d2e test: Non admin members cannot create team in org (#11525)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-16 11:06:08 +02:00
Meenu Yadav e4011b4a23 fix: Webhook settings list not having enough padding on the top (#11878) 2023-10-16 10:00:15 +01:00
212 changed files with 4502 additions and 1048 deletions
+5 -1
View File
@@ -16,6 +16,10 @@ Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making them hard to spoof.
## Recognition
<a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-cal&#0045;ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=419860&theme=light&period=daily" alt="Cal&#0046;ai - World&#0039;s&#0032;first&#0032;open&#0032;source&#0032;AI&#0032;scheduling&#0032;assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cal&#0045;ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=419860&theme=light" alt="Cal&#0046;ai - World&#0039;s&#0032;first&#0032;open&#0032;source&#0032;AI&#0032;scheduling&#0032;assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
## Getting Started
### Development
@@ -44,7 +48,7 @@ Here is the full architecture:
### Email Router
To expose the AI app, run `ngrok http 3000` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
To expose the AI app, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/ai",
"version": "1.2.0",
"version": "1.2.1",
"private": true,
"author": "Cal.com Inc.",
"dependencies": {
+7
View File
@@ -40,6 +40,13 @@ export const POST = async (request: NextRequest) => {
return new NextResponse("ok");
} catch (error) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.",
to: user.email,
from: agentEmail,
});
return new NextResponse(
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
{ status: 500 }
+33 -10
View File
@@ -3,6 +3,7 @@ import { simpleParser } from "mailparser";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import prisma from "@calcom/prisma";
import { env } from "../../../env.mjs";
@@ -31,18 +32,37 @@ export const POST = async (request: NextRequest) => {
const formData = await request.formData();
const body = Object.fromEntries(formData);
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
const signature = (body.dkim as string).includes(" : pass");
const envelope = JSON.parse(body.envelope as string);
const aiEmail = envelope.to[0];
const subject = body.subject || "";
try {
await checkRateLimitAndThrowError({
identifier: `ai:email:${envelope.from}`,
rateLimitingType: "ai",
});
} catch (error) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.",
to: envelope.from,
from: aiEmail,
});
return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid
}
// Parse email from mixed MIME type
const parsed: ParsedMail = await simpleParser(body.email as Source);
if (!parsed.text && !parsed.subject) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.",
to: envelope.from,
from: aiEmail,
});
return new NextResponse("Email missing text and subject", { status: 400 });
}
@@ -62,11 +82,14 @@ export const POST = async (request: NextRequest) => {
where: { email: envelope.from },
});
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
const signature = (body.dkim as string).includes(" : pass");
// 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.`,
subject: `Re: ${body.subject}`,
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,
from: aiEmail,
@@ -83,7 +106,7 @@ export const POST = async (request: NextRequest) => {
await sendEmail({
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
to: envelope.from,
from: aiEmail,
@@ -110,7 +133,7 @@ export const POST = async (request: NextRequest) => {
if ("error" in availability) {
await sendEmail({
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: "Sorry, there was an error fetching your availability. Please try again.",
to: user.email,
from: aiEmail,
@@ -121,7 +144,7 @@ export const POST = async (request: NextRequest) => {
if ("error" in eventTypes) {
await sendEmail({
subject: `Re: ${body.subject}`,
subject: `Re: ${subject}`,
text: "Sorry, there was an error fetching your event types. Please try again.",
to: user.email,
from: aiEmail,
@@ -139,8 +162,8 @@ export const POST = async (request: NextRequest) => {
body: JSON.stringify({
apiKey,
userId: user.id,
message: parsed.text,
subject: parsed.subject,
message: parsed.text || "",
subject: parsed.subject || "",
replyTo: aiEmail,
user: {
email: user.email,
+1 -1
View File
@@ -4,7 +4,7 @@ import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { isAdminGuard } from "~/lib/utils/isAdmin";
import { isAdminGuard } from "../utils/isAdmin";
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
export const dateNotInPast = function (date: Date) {
+23 -19
View File
@@ -16,22 +16,25 @@ import { rateLimitApiKey } from "./rateLimitApiKey";
import { verifyApiKey } from "./verifyApiKey";
import { withPagination } from "./withPagination";
const withMiddleware = label(
{
HTTP_GET_OR_POST,
HTTP_GET_DELETE_PATCH,
HTTP_GET,
HTTP_PATCH,
HTTP_POST,
HTTP_DELETE,
addRequestId,
verifyApiKey,
rateLimitApiKey,
customPrismaClient,
extendRequest,
pagination: withPagination,
captureErrors,
},
const middleware = {
HTTP_GET_OR_POST,
HTTP_GET_DELETE_PATCH,
HTTP_GET,
HTTP_PATCH,
HTTP_POST,
HTTP_DELETE,
addRequestId,
verifyApiKey,
rateLimitApiKey,
customPrismaClient,
extendRequest,
pagination: withPagination,
captureErrors,
};
type Middleware = keyof typeof middleware;
const middlewareOrder =
// The order here, determines the order of execution
[
"extendRequest",
@@ -41,7 +44,8 @@ const withMiddleware = label(
"verifyApiKey",
"rateLimitApiKey",
"addRequestId",
] // <-- Provide a list of middleware to call automatically
);
] as Middleware[]; // <-- Provide a list of middleware to call automatically
export { withMiddleware };
const withMiddleware = label(middleware, middlewareOrder);
export { withMiddleware, middleware, middlewareOrder };
@@ -3,6 +3,7 @@ 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,
@@ -14,6 +15,7 @@ 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(),
@@ -45,4 +47,5 @@ export const schemaDestinationCalendarReadPublic = DestinationCalendar.pick({
eventTypeId: true,
bookingId: true,
userId: true,
credentialId: true,
});
+4
View File
@@ -75,6 +75,7 @@ export const schemaUserBaseBodyParams = User.pick({
theme: true,
defaultScheduleId: true,
locale: true,
hideBranding: true,
timeFormat: true,
brandColor: true,
darkBrandColor: true,
@@ -95,6 +96,7 @@ const schemaUserEditParams = z.object({
weekStart: z.nativeEnum(weekdays).optional(),
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
hideBranding: z.boolean().optional(),
timeZone: timeZone.optional(),
theme: z.nativeEnum(theme).optional().nullable(),
timeFormat: z.nativeEnum(timeFormat).optional(),
@@ -115,6 +117,7 @@ const schemaUserCreateParams = z.object({
weekStart: z.nativeEnum(weekdays).optional(),
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
hideBranding: z.boolean().optional(),
timeZone: timeZone.optional(),
theme: z.nativeEnum(theme).optional().nullable(),
timeFormat: z.nativeEnum(timeFormat).optional(),
@@ -157,6 +160,7 @@ export const schemaUserReadPublic = User.pick({
defaultScheduleId: true,
locale: true,
timeFormat: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,
@@ -30,6 +30,7 @@ import {
* required:
* - integration
* - externalId
* - credentialId
* properties:
* integration:
* type: string
@@ -37,12 +38,18 @@ 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'
* bookingId:
* type: integer
* description: 'The booking ID it is associated with'
* userId:
* type: integer
* description: 'The user it is associated with'
* tags:
* - destination-calendars
* responses:
@@ -55,17 +62,33 @@ import {
*/
async function postHandler(req: NextApiRequest) {
const { userId, isAdmin, prisma, body } = req;
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
await checkPermissions(req, userId);
if (!parsedBody.eventTypeId) {
parsedBody.userId = userId;
}
const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
if (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 },
});
if (!credential)
throw new HttpError({
statusCode: 400,
message: "Bad request, credential id invalid",
});
if (parsedBody.eventTypeId) {
const eventType = await prisma.eventType.findFirst({
where: { id: parsedBody.eventTypeId, userId: parsedBody.userId },
});
if (!eventType)
throw new HttpError({
statusCode: 400,
message: "Bad request, eventTypeId invalid",
});
parsedBody.userId = undefined;
}
const destination_calendar = await prisma.destinationCalendar.create({ data: { ...parsedBody } });
+4 -5
View File
@@ -3,18 +3,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { createContext } from "@calcom/trpc/server/createContext";
import { slotsRouter } from "@calcom/trpc/server/routers/viewer/slots/_router";
import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
import { TRPCError } from "@trpc/server";
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
async function handler(req: NextApiRequest, res: NextApiResponse) {
/** @see https://trpc.io/docs/server-side-calls */
const ctx = await createContext({ req, res });
const caller = slotsRouter.createCaller(ctx);
try {
const input = getScheduleSchema.parse(req.query);
return await getAvailableSlots({ ctx: await createContext({ req, res }), input });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await caller.getSchedule(req.query as any /* Let tRPC handle this */);
} catch (cause) {
if (cause instanceof TRPCError) {
const statusCode = getHTTPStatusCodeFromError(cause);
+8 -4
View File
@@ -53,6 +53,9 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
* timeZone:
* description: The user's time zone
* type: string
* hideBranding:
* description: Remove branding from the user's calendar page
* type: boolean
* theme:
* description: Default theme for the user. Acceptable values are one of [DARK, LIGHT]
* type: string
@@ -79,7 +82,7 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
* - users
* responses:
* 200:
* description: OK, user edited successfuly
* description: OK, user edited successfully
* 400:
* description: Bad request. User body is invalid.
* 401:
@@ -94,9 +97,10 @@ export async function patchHandler(req: NextApiRequest) {
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
const body = await schemaUserEditBodyParams.parseAsync(req.body);
// disable role changes unless admin.
if (!isAdmin && body.role) {
body.role = undefined;
// disable role or branding changes unless admin.
if (!isAdmin) {
if (body.role) body.role = undefined;
if (body.hideBranding) body.hideBranding = undefined;
}
const userSchedules = await prisma.schedule.findMany({
+3
View File
@@ -42,6 +42,9 @@ import { schemaUserCreateBodyParams } from "~/lib/validations/user";
* darkBrandColor:
* description: The new user's brand color for dark mode
* type: string
* hideBranding:
* description: Remove branding from the user's calendar page
* type: boolean
* weekStart:
* description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY]
* type: string
@@ -0,0 +1,17 @@
import { describe, vi, it, expect, afterEach } from "vitest";
import { middlewareOrder } from "../../../lib/helpers/withMiddleware";
afterEach(() => {
vi.resetAllMocks();
});
// Not sure if there is much point testing this order is actually applied via an integration test:
// It is tested internally https://github.com/htunnicliff/next-api-middleware/blob/368b12aa30e79f4bd7cfe7aacc18da263cc3de2f/lib/label.spec.ts#L62
describe("API - withMiddleware test", () => {
it("Custom prisma should be before verifyApiKey", async () => {
const customPrismaClientIndex = middlewareOrder.indexOf("customPrismaClient");
const verifyApiKeyIndex = middlewareOrder.indexOf("verifyApiKey");
expect(customPrismaClientIndex).toBeLessThan(verifyApiKeyIndex);
});
});
+1
View File
@@ -0,0 +1 @@
Hello World
+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
@@ -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(
@@ -7,7 +7,6 @@ import type { UseFormRegisterReturn } from "react-hook-form";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { SingleValue } from "react-select";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { classNames } from "@calcom/lib";
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
@@ -141,17 +140,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
defaultValue: periodType?.type,
});
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
const periodTypeLocked = shouldLockDisableProps("periodType");
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
const optionsPeriod = [
{ value: 1, label: t("calendar_days") },
{ value: 0, label: t("business_days") },
@@ -174,10 +162,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<div className="border-subtle space-y-6 rounded-lg border p-6">
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
<div className="w-full">
<Label htmlFor="beforeBufferTime">
{t("before_event")}
{shouldLockIndicator("bookingLimits")}
</Label>
<Label htmlFor="beforeBufferTime">{t("before_event")}</Label>
<Controller
name="beforeBufferTime"
control={formMethods.control}
@@ -196,7 +181,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
onChange={(val) => {
if (val) onChange(val.value);
}}
@@ -210,10 +194,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
/>
</div>
<div className="w-full">
<Label htmlFor="afterBufferTime">
{t("after_event")}
{shouldLockIndicator("bookingLimits")}
</Label>
<Label htmlFor="afterBufferTime">{t("after_event")}</Label>
<Controller
name="afterBufferTime"
control={formMethods.control}
@@ -232,7 +213,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
onChange={(val) => {
if (val) onChange(val.value);
}}
@@ -248,20 +228,11 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
</div>
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
<div className="w-full">
<Label htmlFor="minimumBookingNotice">
{t("minimum_booking_notice")}
{shouldLockIndicator("minimumBookingNotice")}
</Label>
<MinimumBookingNoticeInput
disabled={shouldLockDisableProps("minimumBookingNotice").disabled}
{...formMethods.register("minimumBookingNotice")}
/>
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")}</Label>
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
</div>
<div className="w-full">
<Label htmlFor="slotInterval">
{t("slot_interval")}
{shouldLockIndicator("slotInterval")}
</Label>
<Label htmlFor="slotInterval">{t("slot_interval")}</Label>
<Controller
name="slotInterval"
control={formMethods.control}
@@ -279,7 +250,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("slotInterval").disabled}
onChange={(val) => {
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
}}
@@ -305,7 +275,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
toggleSwitchAtTheEnd={true}
labelClassName="text-sm"
title={t("limit_booking_frequency")}
{...bookingLimitsLocked}
description={t("limit_booking_frequency_description")}
checked={isChecked}
onCheckedChange={(active) => {
@@ -323,12 +292,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
)}
childrenClassName="lg:ml-0">
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<IntervalLimitsManager
disabled={bookingLimitsLocked.disabled}
propertyName="bookingLimits"
defaultLimit={1}
step={1}
/>
<IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
</div>
</SettingsToggle>
);
@@ -350,7 +314,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
childrenClassName="lg:ml-0"
title={t("limit_total_booking_duration")}
description={t("limit_total_booking_duration_description")}
{...durationLimitsLocked}
checked={isChecked}
onCheckedChange={(active) => {
if (active) {
@@ -365,7 +328,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<IntervalLimitsManager
propertyName="durationLimits"
defaultLimit={60}
disabled={durationLimitsLocked.disabled}
step={15}
textFieldSuffix={t("minutes")}
/>
@@ -391,7 +353,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
childrenClassName="lg:ml-0"
title={t("limit_future_bookings")}
description={t("limit_future_bookings_description")}
{...periodTypeLocked}
checked={isChecked}
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
@@ -399,9 +360,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
{PERIOD_TYPES.filter((opt) =>
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
).map((period) => {
{PERIOD_TYPES.map((period) => {
if (period.type === "UNLIMITED") return null;
return (
<div
@@ -410,14 +369,13 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
)}
key={period.type}>
{!periodTypeLocked.disabled && (
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
)}
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
{period.type === "ROLLING" && (
<div className="flex items-center">
@@ -426,14 +384,12 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
type="number"
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
placeholder="30"
disabled={periodTypeLocked.disabled}
{...formMethods.register("periodDays", { valueAsNumber: true })}
defaultValue={eventType.periodDays || 30}
/>
<Select
options={optionsPeriod}
isSearchable={false}
isDisabled={periodTypeLocked.disabled}
onChange={(opt) => {
formMethods.setValue(
"periodCountCalendarDays",
@@ -458,7 +414,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
disabled={periodTypeLocked.disabled}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
startDate,
@@ -490,7 +445,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
childrenClassName="lg:ml-0"
title={t("offset_toggle")}
description={t("offset_toggle_description")}
{...offsetStartLockedProps}
checked={offsetToggle}
onCheckedChange={(active) => {
setOffsetToggle(active);
@@ -503,7 +457,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
required
type="number"
containerClassName="max-w-80"
{...offsetStartLockedProps}
label={t("offset_start")}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}
@@ -425,7 +425,7 @@ function EventTypeSingleLayout({
<div className="p-2 md:mx-0 md:p-0 xl:hidden">
<HorizontalTabs tabs={EventTypeTabs} linkShallow />
</div>
<div className="w-full overflow-hidden ltr:mr-2 rtl:ml-2">
<div className="w-full ltr:mr-2 rtl:ml-2">
<div
className={classNames(
"bg-default border-subtle mt-4 rounded-md sm:mx-0 xl:mt-0",
@@ -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);
+23 -8
View File
@@ -1,4 +1,5 @@
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";
@@ -77,19 +78,33 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
const locale = session?.data?.user.locale ?? props.pageProps.newLocale;
useEffect(() => {
window.document.documentElement.lang = locale;
let direction = window.document.dir || "ltr";
try {
const intlLocale = new Intl.Locale(locale);
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
direction = intlLocale.textInfo?.direction;
// @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 = direction;
window.document.dir = dir(locale);
}, [locale]);
const clientViewerI18n = useViewerI18n(locale);
+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);
+44
View File
@@ -0,0 +1,44 @@
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { RedirectType } from "@calcom/prisma/client";
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
export const getTemporaryOrgRedirect = async ({
slug,
redirectType,
eventTypeSlug,
}: {
slug: string;
redirectType: RedirectType;
eventTypeSlug: string | null;
}) => {
const prisma = (await import("@calcom/prisma")).default;
log.debug(
`Looking for redirect for`,
safeStringify({
slug,
redirectType,
eventTypeSlug,
})
);
const redirect = await prisma.tempOrgRedirect.findUnique({
where: {
from_type_fromOrgId: {
type: redirectType,
from: slug,
fromOrgId: 0,
},
},
});
if (redirect) {
log.debug(`Redirecting ${slug} to ${redirect.toUrl}`);
return {
redirect: {
permanent: false,
destination: eventTypeSlug ? `${redirect.toUrl}/${eventTypeSlug}` : redirect.toUrl,
},
} as const;
}
return null;
};
+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 };
}
+1 -1
View File
@@ -235,7 +235,7 @@ const nextConfig = {
? [
{
...matcherConfigRootPath,
destination: "/team/:orgSlug",
destination: "/team/:orgSlug?isOrgProfile=1",
},
{
...matcherConfigUserRoute,
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.4.0",
"version": "3.4.3",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -166,6 +166,7 @@
"env-cmd": "^10.1.0",
"module-alias": "^2.2.2",
"msw": "^0.42.3",
"node-html-parser": "^6.1.10",
"postcss": "^8.4.18",
"tailwindcss": "^3.3.1",
"tailwindcss-animate": "^1.0.6",
+6 -7
View File
@@ -51,8 +51,8 @@ export default function Custom404() {
const [url, setUrl] = useState(`${WEBSITE_URL}/signup`);
useEffect(() => {
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host);
const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/);
if (!isValidOrgDomain || !currentOrgDomain) {
const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/) ?? [];
if (routerUsername && (!isValidOrgDomain || !currentOrgDomain)) {
const splitPath = routerUsername.split("/");
if (splitPath[1] === "team" && splitPath.length === 3) {
// Accessing a non-existent team
@@ -66,13 +66,12 @@ export default function Custom404() {
setUrl(`${WEBSITE_URL}/signup?username=${routerUsername.replace("/", "")}`);
}
} else {
setUsername(currentOrgDomain);
setUsername(currentOrgDomain ?? "");
setCurrentPageType(pageType.ORG);
setUrl(
`${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${currentOrgDomain.replace(
"/",
""
)}`
`${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${
currentOrgDomain?.replace("/", "") ?? ""
}`
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
+18 -2
View File
@@ -23,7 +23,7 @@ import useTheme from "@calcom/lib/hooks/useTheme";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
import type { EventType, User } from "@calcom/prisma/client";
import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
@@ -35,6 +35,8 @@ import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr";
import { getTemporaryOrgRedirect } from "../lib/getTemporaryOrgRedirect";
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
@@ -261,13 +263,14 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
context.params?.orgSlug
);
const usernameList = getUsernameList(context.query.user as string);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const dataFetchStart = Date.now();
const usersWithoutAvatar = await prisma.user.findMany({
where: {
username: {
in: usernameList,
},
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
organization: isOrgContext ? getSlugOrRequestedSlug(currentOrgDomain) : null,
},
select: {
id: true,
@@ -275,6 +278,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
email: true,
name: true,
bio: true,
metadata: true,
brandColor: true,
darkBrandColor: true,
organizationId: true,
@@ -312,6 +316,18 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
avatar: `/${user.username}/avatar.png`,
}));
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slug: usernameList[0],
redirectType: RedirectType.User,
eventTypeSlug: null,
});
if (redirect) {
return redirect;
}
}
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
return {
notFound: true,
+21 -4
View File
@@ -15,12 +15,15 @@ import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations
import { getUsernameList } from "@calcom/lib/defaultEvents";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export default function Type({
@@ -93,7 +96,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
if (!users.length) {
return {
notFound: true,
};
} as const;
}
const org = isValidOrgDomain ? currentOrgDomain : null;
@@ -115,7 +118,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
if (!eventData) {
return {
notFound: true,
};
} as const;
}
return {
@@ -150,6 +153,20 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
context.params?.orgSlug
);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slug: usernames[0],
redirectType: RedirectType.User,
eventTypeSlug: slug,
});
if (redirect) {
return redirect;
}
}
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const user = await prisma.user.findFirst({
@@ -167,7 +184,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
if (!user) {
return {
notFound: true,
};
} as const;
}
let booking: GetBookingType | null = null;
@@ -189,7 +206,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
if (!eventData) {
return {
notFound: true,
};
} as const;
}
return {
+3 -8
View File
@@ -1,4 +1,5 @@
import type { IncomingMessage } from "http";
import { dir } from "i18next";
import type { NextPageContext } from "next";
import type { DocumentContext, DocumentProps } from "next/document";
import Document, { Head, Html, Main, NextScript } from "next/document";
@@ -50,21 +51,15 @@ class MyDocument extends Document<Props> {
render() {
const { isEmbed, embedColorScheme } = this.props;
const newLocale = this.props.newLocale || "en";
const newDir = dir(newLocale);
const nonceParsed = z.string().safeParse(this.props.nonce);
const nonce = nonceParsed.success ? nonceParsed.data : "";
const intlLocale = new Intl.Locale(newLocale);
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
const direction = intlLocale.textInfo?.direction;
if (!direction) {
throw new Error("NodeJS major breaking change detected, use getTextInfo() instead.");
}
return (
<Html
lang={newLocale}
dir={direction}
dir={newDir}
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
<Head nonce={nonce}>
<script
+1 -1
View File
@@ -26,7 +26,7 @@ type AugmentedNextPageContext = Omit<NextPageContext, "err"> & {
err: AugmentedError;
};
const log = logger.getChildLogger({ prefix: ["[error]"] });
const log = logger.getSubLogger({ prefix: ["[error]"] });
const CustomError: NextPage<CustomErrorProps> = (props) => {
const { statusCode, err, message, hasGetInitialPropsRun } = props;
+1 -1
View File
@@ -6,7 +6,7 @@ import { prisma } from "@calcom/prisma";
import type { AppCategories, Prisma } from "@calcom/prisma/client";
const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true";
const log = logger.getChildLogger({
const log = logger.getSubLogger({
prefix: ["[api/cron/syncAppMeta]", ...(isDryRun ? ["(dry-run)"] : [])],
});
+1 -1
View File
@@ -16,7 +16,7 @@ import {
} from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
const log = logger.getChildLogger({ prefix: ["[api/logo]"] });
const log = logger.getSubLogger({ prefix: ["[api/logo]"] });
function removePort(url: string) {
return url.replace(/:\d+$/, "");
+24 -1
View File
@@ -34,13 +34,35 @@ async function getIdentityData(req: NextApiRequest) {
: null;
if (username) {
const user = await prisma.user.findFirst({
let user = await prisma.user.findFirst({
where: {
username,
organization: orgQuery,
},
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,
@@ -48,6 +70,7 @@ async function getIdentityData(req: NextApiRequest) {
org,
};
}
if (teamname) {
const team = await prisma.team.findFirst({
where: {
+2
View File
@@ -45,6 +45,7 @@ function AppsSearch({
onChange: ChangeEventHandler<HTMLInputElement>;
className?: string;
}) {
const { t } = useLocale();
return (
<TextField
className="bg-subtle !border-muted !pl-0 focus:!ring-offset-0"
@@ -54,6 +55,7 @@ function AppsSearch({
type="search"
autoComplete="false"
onChange={onChange}
placeholder={t("search")}
/>
);
}
+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);
+12 -2
View File
@@ -1,7 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -18,6 +18,7 @@ import { ssrInit } from "@server/lib/ssr";
type Props = inferSSRProps<typeof getServerSideProps>;
export function Logout(props: Props) {
const [btnLoading, setBtnLoading] = useState<boolean>(false);
const { status } = useSession();
if (status === "authenticated") signOut({ redirect: false });
const router = useRouter();
@@ -35,6 +36,11 @@ export function Logout(props: Props) {
return "hope_to_see_you_soon";
};
const navigateToLogin = () => {
setBtnLoading(true);
router.push("/auth/login");
};
return (
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo>
<div className="mb-4">
@@ -50,7 +56,11 @@ export function Logout(props: Props) {
</div>
</div>
</div>
<Button href="/auth/login" className="flex w-full justify-center">
<Button
data-testid="logout-btn"
onClick={navigateToLogin}
className="flex w-full justify-center"
loading={btnLoading}>
{t("go_back_login")}
</Button>
</AuthContainer>
+1 -1
View File
@@ -22,7 +22,7 @@ export default function Authorize() {
const state = searchParams?.get("state") as string;
const scope = searchParams?.get("scope") as string;
const queryString = searchParams.toString();
const queryString = searchParams?.toString();
const [selectedAccount, setSelectedAccount] = useState<{ value: string; label: string } | null>();
const scopes = scope ? scope.toString().split(",") : [];
+1 -1
View File
@@ -24,7 +24,7 @@ function useSetStep() {
const searchParams = useSearchParams();
const pathname = usePathname();
const setStep = (newStep = 1) => {
const _searchParams = new URLSearchParams(searchParams);
const _searchParams = new URLSearchParams(searchParams ?? undefined);
_searchParams.set("step", newStep.toString());
router.replace(`${pathname}?${_searchParams.toString()}`);
};
+1 -1
View File
@@ -164,7 +164,7 @@ export default function Verify() {
e.preventDefault();
setSecondsLeft(30);
// Update query params with t:timestamp, shallow: true doesn't re-render the page
const _searchParams = new URLSearchParams(searchParams.toString());
const _searchParams = new URLSearchParams(searchParams?.toString());
_searchParams.set("t", `${Date.now()}`);
router.replace(`${pathname}?${_searchParams.toString()}`);
return await sendVerificationLogin(customer.email, customer.username);
+62 -16
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">
@@ -467,18 +481,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>
</>
@@ -1042,7 +1088,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const parsedQuery = querySchema.safeParse(context.query);
if (!parsedQuery.success) return { notFound: true };
if (!parsedQuery.success) return { notFound: true } as const;
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid);
@@ -1100,7 +1146,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!bookingInfoRaw) {
return {
notFound: true,
};
} as const;
}
const eventTypeRaw = !bookingInfoRaw.eventTypeId
@@ -1109,7 +1155,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!eventTypeRaw) {
return {
notFound: true,
};
} as const;
}
if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) {
@@ -1130,7 +1176,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!eventTypeRaw.owner)
return {
notFound: true,
};
} as const;
eventTypeRaw.users.push({
...eventTypeRaw.owner,
});
+7
View File
@@ -0,0 +1,7 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[uid]";
export { default } from "../[uid]";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
+1 -31
View File
@@ -65,7 +65,6 @@ import {
MoreHorizontal,
Trash,
Upload,
User as UserIcon,
Users,
} from "@calcom/ui/components/icon";
@@ -299,7 +298,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);
@@ -821,34 +820,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 +955,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
@@ -1,19 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[type]";
export { default } from "../[type]";
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssrResponse = await _getServerSideProps(context);
if (ssrResponse.notFound) {
return ssrResponse;
}
return {
...ssrResponse,
props: {
...ssrResponse.props,
isEmbed: true,
},
};
};
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
+40 -9
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";
@@ -12,12 +18,14 @@ 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";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
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 { ArrowRight } from "@calcom/ui/components/icon";
@@ -30,8 +38,10 @@ import Team from "@components/team/screens/Team";
import { ssrInit } from "@server/lib/ssr";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
const log = logger.getSubLogger({ prefix: ["team/[slug]"] });
function TeamPage({
team,
isUnpublished,
@@ -272,26 +282,47 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
context.req.headers.host ?? "",
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) {
const redirect = await getTemporaryOrgRedirect({
slug: slug,
redirectType: RedirectType.Team,
eventTypeSlug: null,
});
if (redirect) {
return redirect;
}
}
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;
}
+16
View File
@@ -11,12 +11,15 @@ import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/or
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
import { getTemporaryOrgRedirect } from "../../../lib/getTemporaryOrgRedirect";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export default function Type({
@@ -75,6 +78,19 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
context.req.headers.host ?? "",
context.params?.orgSlug
);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slug: teamSlug,
redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug,
});
if (redirect) {
return redirect;
}
}
const team = await prisma.team.findFirst({
where: {
+1
View File
@@ -26,6 +26,7 @@ function Teams() {
CTA={
(!user.organizationId || user.organization.isOrgAdmin) && (
<Button
data-testid="new-team-btn"
variant="fab"
StartIcon={Plus}
type="button"
+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" });
});
});
@@ -0,0 +1,387 @@
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 Phone Question and Address Question", () => {
test("Phone and Address required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone 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: "phone",
fillText: "Test Phone question and Address question (both required)",
secondQuestion: "address",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone 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();
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: "phone",
fillText: "Test Phone question and Address question (only phone required)",
secondQuestion: "address",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test.describe("Booking With Phone Question and checkbox group Question", () => {
const bookingOptions = { hasPlaceholder: false, isRequired: true };
test("Phone and checkbox group required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone 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: "phone",
fillText: "Test Phone question and checkbox group question (both required)",
secondQuestion: "checkbox",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone 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();
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: "phone",
fillText: "Test Phone question and checkbox group question (only phone required)",
secondQuestion: "checkbox",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and checkbox Question", () => {
test("Phone and checkbox required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone 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: "phone",
fillText: "Test Phone question and checkbox question (both required)",
secondQuestion: "boolean",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone 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();
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: "phone",
fillText: "Test Phone question and checkbox (only phone required)",
secondQuestion: "boolean",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Long text Question", () => {
test("Phone and Long text required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone 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: "phone",
fillText: "Test Phone question and Long Text question (both required)",
secondQuestion: "textarea",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone 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();
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: "phone",
fillText: "Test Phone question and Long Text question (only phone required)",
secondQuestion: "textarea",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Multi email Question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test("Phone and Multi email required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone 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: "phone",
fillText: "Test Phone question and Multi Email question (both required)",
secondQuestion: "multiemail",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone and Multi email not required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone 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: "phone",
fillText: "Test Phone question and Multi Email question (only phone required)",
secondQuestion: "multiemail",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and multiselect Question", () => {
test("Phone and multiselect text required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone 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: "phone",
fillText: "Test Phone question and Multi Select question (both required)",
secondQuestion: "multiselect",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone 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();
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: "phone",
fillText: "Test Phone question and Multi Select question (only phone required)",
secondQuestion: "multiselect",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Number Question", () => {
test("Phone and Number required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone 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: "phone",
fillText: "Test Phone question and Number question (both required)",
secondQuestion: "number",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone 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();
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: "phone",
fillText: "Test Phone question and Number question (only phone required)",
secondQuestion: "number",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Radio group Question", () => {
test("Phone and Radio group required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone 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: "phone",
fillText: "Test Phone question and Radio question (both required)",
secondQuestion: "radio",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone 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();
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: "phone",
fillText: "Test Phone question and Radio question (only phone required)",
secondQuestion: "radio",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and select Question", () => {
test("Phone and select required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone 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: "phone",
fillText: "Test Phone question and Select question (both required)",
secondQuestion: "select",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone 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();
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: "phone",
fillText: "Test Phone question and Select question (only phone required)",
secondQuestion: "select",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
test.describe("Booking With Phone Question and Short text question", () => {
const bookingOptions = { hasPlaceholder: true, isRequired: true };
test("Phone and Short text required", async ({ bookingPage }) => {
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone 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: "phone",
fillText: "Test Phone question and Text question (both required)",
secondQuestion: "text",
options: bookingOptions,
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
test("Phone 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();
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: "phone",
fillText: "Test Phone question and Text question (only phone required)",
secondQuestion: "text",
options: { ...bookingOptions, isRequired: false },
});
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
});
});
});
});
+4 -1
View File
@@ -3,13 +3,16 @@ import type { Booking, Prisma } from "@prisma/client";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import dayjs from "@calcom/dayjs";
import _dayjs from "@calcom/dayjs";
import { prisma } from "@calcom/prisma";
const translator = short();
type BookingFixture = ReturnType<typeof createBookingFixture>;
// We default all dayjs calls to use Europe/London timezone
const dayjs = (...args: Parameters<typeof _dayjs>) => _dayjs(...args).tz("Europe/London");
// creates a user fixture instance and stores the collection
export const createBookingsFixture = (page: Page) => {
const store = { bookings: [], page } as { bookings: BookingFixture[]; page: typeof page };
@@ -0,0 +1,250 @@
import { expect, type Page } from "@playwright/test";
import type { createUsersFixture } from "./users";
const reschedulePlaceholderText = "Let others know why you need to reschedule";
export const scheduleSuccessfullyText = "This meeting is scheduled";
const EMAIL = "test@test.com";
const EMAIL2 = "test2@test.com";
const PHONE = "+55 (32) 983289947";
type BookingOptions = {
hasPlaceholder?: boolean;
isReschedule?: boolean;
isRequired?: boolean;
};
interface QuestionActions {
[key: string]: () => Promise<void>;
}
type customLocators = {
shouldChangeSelectLocator: boolean;
shouldUseLastRadioGroupLocator: boolean;
shouldUseFirstRadioGroupLocator: boolean;
shouldChangeMultiSelectLocator: boolean;
};
type fillAndConfirmBookingParams = {
eventTypePage: Page;
placeholderText: string;
question: string;
fillText: string;
secondQuestion: string;
options: BookingOptions;
};
type UserFixture = ReturnType<typeof createUsersFixture>;
const fillQuestion = async (eventTypePage: Page, questionType: string, customLocators: customLocators) => {
const questionActions: QuestionActions = {
phone: async () => {
await eventTypePage.locator('input[name="phone-test"]').clear();
await eventTypePage.locator('input[name="phone-test"]').fill(PHONE);
},
multiemail: async () => {
await eventTypePage.getByRole("button", { name: `${questionType} test` }).click();
await eventTypePage.getByPlaceholder(`${questionType} test`).fill(EMAIL);
await eventTypePage.getByTestId("add-another-guest").last().click();
await eventTypePage.getByPlaceholder(`${questionType} test`).last().fill(EMAIL2);
},
checkbox: async () => {
if (customLocators.shouldUseLastRadioGroupLocator || customLocators.shouldChangeMultiSelectLocator) {
await eventTypePage.getByLabel("Option 1").last().click();
await eventTypePage.getByLabel("Option 2").last().click();
} else if (customLocators.shouldUseFirstRadioGroupLocator) {
await eventTypePage.getByLabel("Option 1").first().click();
await eventTypePage.getByLabel("Option 2").first().click();
} else {
await eventTypePage.getByLabel("Option 1").click();
await eventTypePage.getByLabel("Option 2").click();
}
},
multiselect: async () => {
if (customLocators.shouldChangeMultiSelectLocator) {
await eventTypePage.locator("form svg").nth(1).click();
await eventTypePage.getByTestId("select-option-Option 1").click();
} else {
await eventTypePage.locator("form svg").last().click();
await eventTypePage.getByTestId("select-option-Option 1").click();
}
},
boolean: async () => {
await eventTypePage.getByLabel(`${questionType} test`).check();
},
radio: async () => {
await eventTypePage.locator('[id="radio-test\\.option\\.0\\.radio"]').click();
},
select: async () => {
if (customLocators.shouldChangeSelectLocator) {
await eventTypePage.locator("form svg").nth(1).click();
await eventTypePage.getByTestId("select-option-Option 1").click();
} else {
await eventTypePage.locator("form svg").last().click();
await eventTypePage.getByTestId("select-option-Option 1").click();
}
},
number: async () => {
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("123");
},
address: async () => {
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("address test");
},
textarea: async () => {
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("textarea test");
},
text: async () => {
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("text test");
},
};
if (questionActions[questionType]) {
await questionActions[questionType]();
}
};
export async function loginUser(users: UserFixture) {
const pro = await users.create({ name: "testuser" });
await pro.apiLogin();
}
export function createBookingPageFixture(page: Page) {
return {
goToEventType: async (eventType: string) => {
await page.getByRole("link", { name: eventType }).click();
},
goToTab: async (tabName: string) => {
await page.getByTestId(`vertical-tab-${tabName}`).click();
},
addQuestion: async (
questionType: string,
identifier: string,
label: string,
isRequired: boolean,
placeholder?: string
) => {
await page.getByTestId("add-field").click();
await page.locator("#test-field-type > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId(`select-option-${questionType}`).click();
await page.getByLabel("Identifier").dblclick();
await page.getByLabel("Identifier").fill(identifier);
await page.getByLabel("Label").click();
await page.getByLabel("Label").fill(label);
if (placeholder) {
await page.getByLabel("Placeholder").click();
await page.getByLabel("Placeholder").fill(placeholder);
}
if (!isRequired) {
await page.getByRole("radio", { name: "No" }).click();
}
await page.getByTestId("field-add-save").click();
},
updateEventType: async () => {
await page.getByTestId("update-eventtype").click();
},
previewEventType: async () => {
const eventtypePromise = page.waitForEvent("popup");
await page.getByTestId("preview-button").click();
return eventtypePromise;
},
selectTimeSlot: async (eventTypePage: Page) => {
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
await eventTypePage.getByRole("button", { name: "View next" }).click();
}
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();
},
fillRescheduleReasonAndConfirm: async () => {
await page.getByPlaceholder(reschedulePlaceholderText).click();
await page.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule");
await page.getByTestId("confirm-reschedule-button").click();
},
verifyReschedulingSuccess: async () => {
await expect(page.getByText(scheduleSuccessfullyText)).toBeVisible();
},
cancelBookingWithReason: async () => {
await page.getByTestId("cancel").click();
await page.getByTestId("cancel_reason").fill("Test cancel");
await page.getByTestId("confirm_cancel").click();
},
verifyBookingCancellation: async () => {
await expect(page.getByTestId("cancelled-headline")).toBeVisible();
},
cancelAndRescheduleBooking: async (eventTypePage: Page) => {
await eventTypePage.getByText("Reschedule").click();
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
await eventTypePage.getByRole("button", { name: "View next" }).click();
}
await eventTypePage.getByTestId("time").first().click();
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();
await eventTypePage.getByTestId("cancel").click();
await eventTypePage.getByTestId("cancel_reason").fill("Test cancel");
await eventTypePage.getByTestId("confirm_cancel").click();
await expect(eventTypePage.getByTestId("cancelled-headline")).toBeVisible();
},
fillAndConfirmBooking: async ({
eventTypePage,
placeholderText,
question,
fillText,
secondQuestion,
options,
}: fillAndConfirmBookingParams) => {
const confirmButton = options.isReschedule ? "confirm-reschedule-button" : "confirm-book-button";
await expect(eventTypePage.getByText(`${secondQuestion} test`).first()).toBeVisible();
await eventTypePage.getByPlaceholder(placeholderText).fill(fillText);
// Change the selector for specifics cases related to select question
const shouldChangeSelectLocator = (question: string, secondQuestion: string): boolean =>
question === "select" && ["multiemail", "multiselect"].includes(secondQuestion);
const shouldUseLastRadioGroupLocator = (question: string, secondQuestion: string): boolean =>
question === "radio" && secondQuestion === "checkbox";
const shouldUseFirstRadioGroupLocator = (question: string, secondQuestion: string): boolean =>
question === "checkbox" && secondQuestion === "radio";
const shouldChangeMultiSelectLocator = (question: string, secondQuestion: string): boolean =>
question === "multiselect" &&
["address", "checkbox", "multiemail", "select"].includes(secondQuestion);
const customLocators = {
shouldChangeSelectLocator: shouldChangeSelectLocator(question, secondQuestion),
shouldUseLastRadioGroupLocator: shouldUseLastRadioGroupLocator(question, secondQuestion),
shouldUseFirstRadioGroupLocator: shouldUseFirstRadioGroupLocator(question, secondQuestion),
shouldChangeMultiSelectLocator: shouldChangeMultiSelectLocator(question, secondQuestion),
};
// Fill the first question
await fillQuestion(eventTypePage, question, customLocators);
// Fill the second question if is required
options.isRequired && (await fillQuestion(eventTypePage, secondQuestion, customLocators));
await eventTypePage.getByTestId(confirmButton).click();
const scheduleSuccessfullyPage = eventTypePage.getByText(scheduleSuccessfullyText);
await scheduleSuccessfullyPage.waitFor({ state: "visible" });
await expect(scheduleSuccessfullyPage).toBeVisible();
},
};
}
+20 -4
View File
@@ -4,14 +4,14 @@ import { Prisma as PrismaType } from "@prisma/client";
import { hashSync as hash } from "bcryptjs";
import type { API } from "mailhog";
import dayjs from "@calcom/dayjs";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
import type { TimeZoneEnum } from "./types";
import { TimeZoneEnum } from "./types";
// Don't import hashPassword from app as that ends up importing next-auth and initializing it before NEXTAUTH_URL can be updated during tests.
export function hashPassword(password: string) {
@@ -288,6 +288,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
const teamEvent = await createTeamEventType(user, team, scenario);
if (scenario.teammates) {
// Create Teammate users
const teamMatesIds = [];
for (const teammateObj of scenario.teammates) {
const teamUser = await prisma.user.create({
data: createUser(workerInfo, teammateObj),
@@ -319,8 +320,22 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
}),
store.page
);
teamMatesIds.push(teamUser.id);
store.users.push(teammateFixture);
}
// Add Teammates to OrgUsers
if (scenario.isOrg) {
await prisma.team.update({
where: {
id: team.id,
},
data: {
orgUsers: {
connect: teamMatesIds.map((userId) => ({ id: userId })).concat([{ id: user.id }]),
},
},
});
}
}
}
const userFixture = createUserFixture(user, store.page);
@@ -461,13 +476,14 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
password: hashPassword(uname),
emailVerified: new Date(),
completedOnboarding: opts?.completedOnboarding ?? true,
timeZone: opts?.timeZone ?? dayjs.tz.guess(),
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
locale: opts?.locale ?? "en",
schedules:
opts?.completedOnboarding ?? true
? {
create: {
name: "Working Hours",
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
availability: {
createMany: {
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
@@ -543,7 +559,7 @@ export async function apiLogin(
const data = {
email: user.email ?? `${user.username}@example.com`,
password: user.password ?? user.username,
callbackURL: "http://localhost:3000/",
callbackURL: WEBAPP_URL,
redirect: "false",
json: "true",
csrfToken,
+30
View File
@@ -236,4 +236,34 @@ test.describe("Insights", async () => {
// expect for "Team: test-insight" text in page
expect(await page.locator("text=Team: test-insights").isVisible()).toBeTruthy();
});
test("should test download button", async ({ page, users }) => {
const owner = await users.create();
const member = await users.create();
await createTeamsAndMembership(owner.id, member.id);
await owner.apiLogin();
await page.goto("/insights");
await page.waitForLoadState("networkidle");
const downloadPromise = page.waitForEvent("download");
// Expect download button to be visible
expect(await page.locator("text=Download").isVisible()).toBeTruthy();
// Click on Download button
await page.getByText("Download").click();
// Expect as csv option to be visible
expect(await page.locator("text=as CSV").isVisible()).toBeTruthy();
// Start waiting for download before clicking. Note no await.
await page.getByText("as CSV").click();
const download = await downloadPromise;
// Wait for the download process to complete and save the downloaded file somewhere.
await download.saveAs("./" + "test-insights.csv");
});
});
+6
View File
@@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings";
import { createEmbedsFixture } from "../fixtures/embeds";
import { createPaymentsFixture } from "../fixtures/payments";
import { createBookingPageFixture } from "../fixtures/regularBookings";
import { createRoutingFormsFixture } from "../fixtures/routingForms";
import { createServersFixture } from "../fixtures/servers";
import { createUsersFixture } from "../fixtures/users";
@@ -24,6 +25,7 @@ export interface Fixtures {
prisma: typeof prisma;
emails?: API;
routingForms: ReturnType<typeof createRoutingFormsFixture>;
bookingPage: ReturnType<typeof createBookingPageFixture>;
}
declare global {
@@ -80,4 +82,8 @@ export const test = base.extend<Fixtures>({
await use(undefined);
}
},
bookingPage: async ({ page }, use) => {
const bookingPage = createBookingPageFixture(page);
await use(bookingPage);
},
});
+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"]');
+282 -4
View File
@@ -52,7 +52,151 @@ test.describe("unauthorized user sees correct translations (ar)", async () => {
});
});
test.describe("authorized user sees correct translations (de) [locale1]", async () => {
test.describe("unauthorized user sees correct translations (zh)", async () => {
test.use({
locale: "zh",
});
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=zh]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("欢迎回来", { exact: true });
expect(await locator.count()).toEqual(1);
}
{
const locator = page.getByText("Welcome back", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
test.describe("unauthorized user sees correct translations (zh-CN)", async () => {
test.use({
locale: "zh-CN",
});
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=zh-CN]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("欢迎回来", { exact: true });
expect(await locator.count()).toEqual(1);
}
{
const locator = page.getByText("Welcome back", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
test.describe("unauthorized user sees correct translations (zh-TW)", async () => {
test.use({
locale: "zh-TW",
});
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=zh-TW]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("歡迎回來", { exact: true });
expect(await locator.count()).toEqual(1);
}
{
const locator = page.getByText("Welcome back", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
test.describe("unauthorized user sees correct translations (pt)", async () => {
test.use({
locale: "pt",
});
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=pt]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Olá novamente", { exact: true });
expect(await locator.count()).toEqual(1);
}
{
const locator = page.getByText("Welcome back", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
test.describe("unauthorized user sees correct translations (pt-br)", async () => {
test.use({
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[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Bem-vindo(a) novamente", { exact: true });
expect(await locator.count()).toEqual(1);
}
{
const locator = page.getByText("Welcome back", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
test.describe("unauthorized user sees correct translations (es-419)", async () => {
test.use({
locale: "es-419",
});
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.locator("html[lang=es-419]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Bienvenido de nuevo", { exact: true });
expect(await locator.count()).toEqual(1);
}
{
const locator = page.getByText("Welcome back", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
test.describe("authorized user sees correct translations (de)", async () => {
test.use({
locale: "en",
});
@@ -124,6 +268,78 @@ test.describe("authorized user sees correct translations (de) [locale1]", async
});
});
test.describe("authorized user sees correct translations (pt-br)", async () => {
test.use({
locale: "en",
});
test("should return correct translations and html attributes", async ({ page, users }) => {
await test.step("should create a pt-br user", async () => {
const user = await users.create({
locale: "pt-br",
});
await user.apiLogin();
});
await test.step("should navigate to /event-types and show Brazil-Portuguese translations", async () => {
await page.goto("/event-types");
await page.waitForLoadState("networkidle");
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Tipos de Eventos", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
}
{
const locator = page.getByText("Event Types", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
await test.step("should navigate to /bookings and show Brazil-Portuguese translations", async () => {
await page.goto("/bookings");
await page.waitForLoadState("networkidle");
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Reservas", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
}
{
const locator = page.getByText("Bookings", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
await test.step("should reload the /bookings and show Brazil-Portuguese translations", async () => {
await page.reload();
await page.waitForLoadState("networkidle");
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Reservas", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
}
{
const locator = page.getByText("Bookings", { exact: true });
expect(await locator.count()).toEqual(0);
}
});
});
});
test.describe("authorized user sees correct translations (ar)", async () => {
test.use({
locale: "en",
@@ -137,7 +353,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
await user.apiLogin();
});
await test.step("should navigate to /event-types and show German translations", async () => {
await test.step("should navigate to /event-types and show Arabic translations", async () => {
await page.goto("/event-types");
await page.waitForLoadState("networkidle");
@@ -156,7 +372,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
}
});
await test.step("should navigate to /bookings and show German translations", async () => {
await test.step("should navigate to /bookings and show Arabic translations", async () => {
await page.goto("/bookings");
await page.waitForLoadState("networkidle");
@@ -175,7 +391,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
}
});
await test.step("should reload the /bookings and show German translations", async () => {
await test.step("should reload the /bookings and show Arabic translations", async () => {
await page.reload();
await page.waitForLoadState("networkidle");
@@ -257,3 +473,65 @@ test.describe("authorized user sees changed translations (de->ar)", async () =>
});
});
});
test.describe("authorized user sees changed translations (de->pt-BR) [locale1]", async () => {
test.use({
locale: "en",
});
test("should return correct translations and html attributes", async ({ page, users }) => {
await test.step("should create a de user", async () => {
const user = await users.create({
locale: "de",
});
await user.apiLogin();
});
await test.step("should change the language and show Brazil-Portuguese translations", async () => {
await page.goto("/settings/my-account/general");
await page.waitForLoadState("networkidle");
await page.locator(".bg-default > div > div:nth-child(2)").first().click();
await page.locator("#react-select-2-option-14").click();
await page.getByRole("button", { name: "Aktualisieren" }).click();
await page
.getByRole("button", { name: "Einstellungen erfolgreich aktualisiert" })
.waitFor({ state: "visible" });
await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Geral", { exact: true }); // "general"
expect(await locator.count()).toBeGreaterThanOrEqual(1);
}
{
const locator = page.getByText("Allgemein", { exact: true }); // "general"
expect(await locator.count()).toEqual(0);
}
});
await test.step("should reload and show Brazil-Portuguese translations", async () => {
await page.reload();
await page.waitForLoadState("networkidle");
await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Geral", { exact: true }); // "general"
expect(await locator.count()).toBeGreaterThanOrEqual(1);
}
{
const locator = page.getByText("Allgemein", { exact: true }); // "general"
expect(await locator.count()).toEqual(0);
}
});
});
});
+1 -1
View File
@@ -35,7 +35,7 @@ test.describe("user can login & logout succesfully", async () => {
const signOutBtn = await page.locator(`text=${signOutLabel}`);
await signOutBtn.click();
await page.locator('a[href="/auth/login"]').click();
await page.locator("[data-testid=logout-btn]").click();
// Reroute to the home page to check if the login form shows up
await expect(page.locator(`[data-testid=login-form]`)).toBeVisible();
@@ -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;
+34
View File
@@ -135,6 +135,40 @@ 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";
const owner = await users.create(undefined, {
hasTeam: true,
isOrg: true,
teammates: [{ name: teamMateName }],
});
const allUsers = await users.get();
const memberUser = allUsers.find((user) => user.name === teamMateName);
// eslint-disable-next-line playwright/no-conditional-in-test
if (memberUser) {
await memberUser.apiLogin();
await page.goto("/teams");
await expect(page.locator("[data-testid=new-team-btn]")).toBeHidden();
await expect(page.locator("[data-testid=create-team-btn]")).toHaveAttribute("disabled", "");
const uniqueName = "test-unique-team-name";
// Go directly to the create team page
await page.goto("/settings/teams/new");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(uniqueName);
await page.locator("text=Continue").click();
await expect(page.locator("[data-testid=alert]")).toBeVisible();
// cleanup
const org = await owner.getOrg();
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";
+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;
+4 -1
View File
@@ -1,6 +1,6 @@
import { expect } from "@playwright/test";
import dayjs from "@calcom/dayjs";
import _dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import { test } from "./lib/fixtures";
@@ -9,6 +9,9 @@ test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
// We default all dayjs calls to use Europe/London timezone
const dayjs = (...args: Parameters<typeof _dayjs>) => _dayjs(...args).tz("Europe/London");
test.describe("Wipe my Cal App Test", () => {
test("Browse upcoming bookings and validate button shows and triggering wipe my cal button", async ({
page,
@@ -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",
@@ -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",
@@ -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",
@@ -1972,8 +1972,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",
@@ -2091,6 +2089,7 @@
"copy_client_secret_info": "After copying the secret you won't be able to view it anymore",
"add_new_client": "Add new Client",
"this_app_is_not_setup_already": "This app has not been setup yet",
"as_csv": "as CSV",
"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.",
@@ -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",
@@ -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"
}
@@ -1959,8 +1959,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",
@@ -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",
@@ -1957,8 +1957,6 @@
"org_no_teams_yet": "Questa organizzazione non ha ancora alcun team",
"org_no_teams_yet_description": "Se sei un amministratore, assicurati di creare dei team da mostrare qui.",
"set_up": "Imposta",
"set_up_your_profile": "Imposta il tuo profilo",
"set_up_your_profile_description": "Fai sapere agli utenti la tua posizione all'interno di {{orgName}} quando interagiscono con il tuo link pubblico.",
"my_profile": "Il mio profilo",
"my_settings": "Le mie impostazioni",
"crm": "CRM",
@@ -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",
@@ -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",
+128 -1
View File
@@ -1 +1,128 @@
{}
{
"identity_provider": "Identitātes nodrošinātājs",
"trial_days_left": "Jūsu PRO izmēģinājuma ir atlikušas $t(day, {\"count\": {{days}} })",
"day_one": "{{count}} diena",
"day_other": "{{count}} dienas",
"second_one": "{{count}} sekunde",
"second_other": "{{count}} sekundes",
"upgrade_now": "Atjaunināt tagad",
"accept_invitation": "Apstiprināt Uzaicinājumu",
"calcom_explained": "{{appName}} nodrošina plānošanas infrastruktūru pilnīgi visiem.",
"calcom_explained_new_user": "Pabeidziet sava {{appName}} konta iestatīšanu! Jūs esat tikai dažu soļu attālumā no visu plānošanas problēmu atrisināšanas.",
"have_any_questions": "Jums ir jautājumi? Mēs esam šeit, lai palīdzētu.",
"reset_password_subject": "{{appName}}: Paroles atiestatīšanas instrukcijas",
"verify_email_subject": "{{appName}}: Verificējiet savu kontu",
"check_your_email": "Pārbaudiet savu e-pastu",
"verify_email_page_body": "Mēs nosūtījām e-pastu uz {{email}}. Ir svarīgi verificēt jūsu e-pastu, lai garantētu labāko e-pastu un kalendāru saņemšanu no {{appName}}.",
"verify_email_banner_body": "Verificējiet savu e-pasta adresi, lai garantētu labāko e-pastu un kalendāru saņemšanu",
"verify_email_email_header": "Verificējiet savu e-pasta adresi",
"verify_email_email_button": "Verificēt e-pastu",
"copy_somewhere_safe": "Saglabājiet šo API atslēgu drošā vietā. Jūs to nevarēsiet skatīt vēlreiz.",
"verify_email_email_body": "Lūdzu, apstipriniet savu e-pasta adresi, noklikšķinot uz zemāk esošās pogas.",
"verify_email_by_code_email_body": "Lūdzu, apstipriniet savu e-pasta adresi, izmantojot zemāk esošo kodu.",
"verify_email_email_link_text": "Šeit ir saite gadījumam, ja jums nepatīk klikšķināt uz pogām:",
"email_verification_code": "Ievadiet verifikācijas kodu",
"email_verification_code_placeholder": "Ievadiet verifikācijas kodu, kas tika nosūtīts uz jūsu e-pastu",
"incorrect_email_verification_code": "Verifikācijas kods ir nepareizs.",
"email_sent": "E-Pasts veiksmīgi nosūtīts",
"email_not_sent": "Notika kļūme, sūtot e-pastu",
"event_declined_subject": "Noraidīts: {{title}} ({{date}})",
"event_cancelled_subject": "Atcelts: {{title}} ({{date}})",
"event_request_declined": "Jūsu notikuma pieprasījums tika noraidīts",
"event_request_declined_recurring": "Jūsu aktārtotā notikuma pieprasījums tika noraidīts",
"event_request_cancelled": "Jūsu ieplānotais notikums tika atcelts",
"organizer": "Organizētājs",
"need_to_reschedule_or_cancel": "Nepieciešams pārlikt vai atcelt?",
"no_options_available": "Nav pieejamu opciju",
"cancellation_reason": "Iemesls atcelšanai (neobligāts)",
"cancellation_reason_placeholder": "Kāpēc jūs atceļat?",
"rejection_reason": "Iemesls noraidīšanai",
"rejection_reason_title": "Vai noraidīt rezervācijas pieprasījumu?",
"rejection_reason_description": "Vai tiešām vēlaties noraidīt rezervāciju? Mēs informēsim personu, kas mēģināja rezervēt. Zemāk varat norādīt iemeslu.",
"rejection_confirmation": "Noraidīt rezervāciju",
"manage_this_event": "Pārvaldīt šo notikumu",
"invite_team_member": "Uzaicināt komandas biedru",
"invite_team_individual_segment": "Uzaicināt personu",
"invite_team_bulk_segment": "Lielapjoma importēšana",
"invite_team_notifcation_badge": "Uza.",
"your_event_has_been_scheduled": "Jūsu notikums ir ieplānots",
"your_event_has_been_scheduled_recurring": "Jūsu atkārtotais notikums ir ieplānots",
"accept_our_license": "Akceptējiet mūsu licensi, izmainot .env mainīgo <1>NEXT_PUBLIC_LICENSE_CONSENT</1> uz '{{agree}}'.",
"remove_banner_instructions": "Lai noņemtu šo baneri, lūdzu atveriet .env failu un nomainat <1>NEXT_PUBLIC_LICENSE_CONSENT</1> mainīgo uz '{{agree}}'.",
"error_message": "Kļūmes paziņojums bija: '{{errorMessage}}'",
"refund_failed_subject": "Naudas atgriešana neizdevās: {{name}} - {{date}} - {{eventType}}",
"refund_failed": "Naudas atgriešana notikumam {{eventType}} ar {{userName}}, šādā datumā: {{date}} neizdevās.",
"check_with_provider_and_user": "Lūdzu, sazinieties ar savu maksājumu pakalpojumu sniedzēju un {{user}}, kā ar šo rīkoties.",
"a_refund_failed": "Naudas atgriešana neizdevās",
"awaiting_payment_subject": "Gaida maksājumu: {{title}} datumā {{date}}",
"meeting_awaiting_payment": "Jūsu tikšanās gaida maksājumu",
"help": "Palīdzība",
"price": "Cena",
"paid": "Apmaksāts",
"refunded": "Nauda atgriezta",
"payment": "Maksājums",
"missing_card_fields": "Trūkst karšu lauku",
"enter_meeting": "Pievienoties Tikšanās",
"video_call_provider": "Video zvanu nodrošinātājs",
"meeting_id": "Tikšanās ID",
"meeting_password": "Tikšanās Parole",
"meeting_url": "Tikšanās URL",
"meeting_request_rejected": "Jūsu tikšanās pieprasījums tika noraidīts",
"rejected_event_type_with_organizer": "Noraidīts: {{eventType}} ar {{organizer}} datumā {{date}}",
"hi": "Sveiki",
"join_team": "Pievienoties komandai",
"manage_this_team": "Pārvaldīt šo komandu",
"team_info": "Komandas Info",
"request_another_invitation_email": "Ja nevēlaties izmantot {{toEmail}} kā savu {{appName}} e-pasta adresi vai jums jau ir {{appName}} konts, lūdzu, pieprasiet citu ielūgumu uz to e-pastu.",
"you_have_been_invited": "Jūs esat uzaicināts pievienoties komandai {{teamName}}",
"user_invited_you": "{{user}} uzaicināja jūs pievienoties {{entity}} {{team}} iekš {{appName}}",
"hidden_team_member_title": "Jūs esat paslēpts šajā komandā",
"hidden_team_member_message": "Jūsu vieta nav apmaksāta. Vai nu Jauniniet savu plānu uz PRO, vai paziņojat komandas īpašniekam, lai apmaksā jūsu vietu.",
"hidden_team_owner_message": "Jums ir nepieciešams Pro konts, lai izmantotu komandas. Līdz jaunināšanai, jūs esat paslēpts.",
"link_expires": "p.s. Tas beidzas pēc {{expiresIn}} stundām.",
"upgrade_to_per_seat": "Jauniniet uz Par-Vietu",
"seat_options_doesnt_support_confirmation": "Vietu opcija neatbalsta apstiprinājuma prasību",
"team_upgrade_seats_details": "No {{memberCount}} dalībniekiem jūsu komandā, {{unpaidCount}} vieta(-as) nav apmaksāta(-as). Ar cenu ${{seatPrice}}/mēnesī par vietu, jūsu kopējās izmaksas ir aptuveni ${{totalCost}}/mēnesī.",
"team_upgrade_banner_description": "Jūs neesat pabeidzis savas komandas iestatīšanu. Jūsu komanda “{{teamName}}” ir jājaunina.",
"upgrade_banner_action": "Jauniniet šeit",
"team_upgraded_successfully": "Jūsu komanda tika veiksmīgi jaunināta!",
"org_upgrade_banner_description": "Paldies, ka izmēģinājāt mūsu Organization plānu. Mēs pamanījām, ka jūsu Organization “{{teamName}}” ir jājaunina.",
"org_upgraded_successfully": "Jūsu Organization tika veiksmīgi jaunināta!",
"use_link_to_reset_password": "Izmantojiet saiti zemāk, lai atiestatītu savu paroli",
"hey_there": "Sveiks,",
"forgot_your_password_calcom": "Aizmirsi savu paroli? - {{appName}}",
"delete_webhook_confirmation_message": "Vai tiešām vēlaties dzēst šo webhook? Ja notikums tiks pārlikts vai atcelts, jūs vairs nesaņemsiet {{appName}} reāllaika tikšanās datus norādītajā URL.",
"confirm_delete_webhook": "Jā, dzēst webhook",
"edit_webhook": "Rediģēt Webhook",
"delete_webhook": "Dzēst Webhook",
"webhook_status": "Webhook Statuss",
"webhook_enabled": "Webhook Iespējots",
"webhook_disabled": "Webhook Atspējots",
"webhook_response": "Webhook atbilde",
"webhook_test": "Webhooket tests",
"manage_your_webhook": "Pārvaldīt savu webhook",
"webhook_created_successfully": "Webhook veiksmīgi izveidots!",
"webhook_updated_successfully": "Webhook veiksmīgi atjaunots!",
"webhook_removed_successfully": "Webhook veiksmīgi dzēsts!",
"payload_template": "Payload šablons",
"dismiss": "Noraidīt",
"no_data_yet": "Datu vēl nav",
"ping_test": "Ping tests",
"add_to_homescreen": "Pievienojiet šo lietotni savam sākuma ekrānam, lai iegūtu ātrāku piekļuvi un uzlabotu pieredzi.",
"upcoming": "Gaidāms",
"recurring": "Atkārtots",
"past": "Pagātne",
"booker_event_not_found": "Mēs nevarējām atrast notikumu, kuru mēģināt rezervēt.",
"getting_started": "Darba sākšana",
"15min_meeting": "15 Min Tikšanās",
"30min_meeting": "30 Min Tikšanās",
"secret": "Noslēpums",
"leave_blank_to_remove_secret": "Atstājiet tukšu, lai noņemtu noslēpumu",
"webhook_secret_key_description": "Pārliecinieties, ka jūsu serveris saņem tikai paredzētos {{appName}} pieprasījumus, drošības apsvērumu dēļ",
"secret_meeting": "Slepenā Tikšanās",
"login_instead": "Tā vietā Autorizēties",
"already_have_an_account": "Vai jums jau ir konts?",
"create_account": "Izveidot Kontu",
"confirm_password": "Apstiprināt paroli",
"confirm_auth_change": "Šis mainīs veidu, kā jūs autorizējaties"
}
@@ -1957,8 +1957,6 @@
"org_no_teams_yet": "Deze organisatie heeft nog geen teams",
"org_no_teams_yet_description": "Als u een beheerder bent, zorg er dan voor dat u teams maakt die hier weergegeven worden.",
"set_up": "Instellen",
"set_up_your_profile": "Stel uw profiel in",
"set_up_your_profile_description": "Laat mensen weten wie u bent binnen {{orgName}} en wanneer ze uw openbare link gebruiken.",
"my_profile": "Mijn profiel",
"my_settings": "Mijn instellingen",
"crm": "CRM",
@@ -1957,8 +1957,6 @@
"org_no_teams_yet": "W tej organizacji nie ma jeszcze żadnych zespołów",
"org_no_teams_yet_description": "Jeśli jesteś administratorem, pamiętaj o utworzeniu zespołów, które będą wyświetlane tutaj.",
"set_up": "Skonfiguruj",
"set_up_your_profile": "Skonfiguruj profil",
"set_up_your_profile_description": "Określ informacje o Twojej roli w organizacji {{orgName}} wyświetlane osobom, które klikną Twój link publiczny.",
"my_profile": "Mój profil",
"my_settings": "Moje ustawienia",
"crm": "CRM",
@@ -1957,8 +1957,6 @@
"org_no_teams_yet": "Esta organização ainda não tem nenhuma equipe",
"org_no_teams_yet_description": "Se você for administrador, não se esqueça de criar equipes para mostrar aqui.",
"set_up": "Configurar",
"set_up_your_profile": "Configurar seu perfil",
"set_up_your_profile_description": "Deixe que as pessoas saibam quem você é em {{orgName}} e quando interagirem com seu link público.",
"my_profile": "Meu perfil",
"my_settings": "Minhas configurações",
"crm": "CRM",
@@ -1958,8 +1958,6 @@
"org_no_teams_yet": "Esta organização ainda não tem equipas",
"org_no_teams_yet_description": "Se é um administrador, deve criar equipas para serem apresentadas aqui.",
"set_up": "Configurar",
"set_up_your_profile": "Configurar o seu perfil",
"set_up_your_profile_description": "Permita que as pessoas saibam quem você é em {{orgName}} e quando se envolvem com o seu link público.",
"my_profile": "O meu perfil",
"my_settings": "As minhas definições",
"crm": "CRM",
@@ -1957,8 +1957,6 @@
"org_no_teams_yet": "Această organizație nu are încă echipe",
"org_no_teams_yet_description": "Dacă sunteți administrator, creați echipe pentru a fi afișate aici.",
"set_up": "Configurare",
"set_up_your_profile": "Configurați-vă profilul",
"set_up_your_profile_description": "Spuneți-le celorlalți care este rolul dvs. în cadrul {{orgName}} și atunci când interacționează cu linkul dvs. public.",
"my_profile": "Profilul meu",
"my_settings": "Setările mele",
"crm": "CRM",
@@ -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",
@@ -1957,8 +1957,6 @@
"org_no_teams_yet": "Ova organizacija još uvek nema timove",
"org_no_teams_yet_description": "Ako ste administrator, obavezno kreirajte timove koji će ovde biti prikazani.",
"set_up": "Podesi",
"set_up_your_profile": "Podesite svoj profil",
"set_up_your_profile_description": "Unesite informacije o svojoj ulozi u organizaciji {{orgName}}. Te informacije će videti osobe koje kliknu na vaš javni link.",
"my_profile": "Moj profil",
"my_settings": "Moja podešavanja",
"crm": "CRM",
@@ -1957,8 +1957,6 @@
"org_no_teams_yet": "Denna organisation har inga team än",
"org_no_teams_yet_description": "Om du är administratör ska du skapa team som visas här.",
"set_up": "Konfigurera",
"set_up_your_profile": "Konfigurera din profil",
"set_up_your_profile_description": "Meddela andra vem du är inom {{orgName}} och när de engagerar sig i din offentliga länk.",
"my_profile": "Min profil",
"my_settings": "Mina inställningar",
"crm": "CRM",
@@ -1957,8 +1957,6 @@
"org_no_teams_yet": "Bu kuruluşun henüz ekibi yok",
"org_no_teams_yet_description": "Yöneticiyseniz burada gösterilecek ekipler oluşturduğunuzdan emin olun.",
"set_up": "Düzenle",
"set_up_your_profile": "Profilinizi düzenleyin",
"set_up_your_profile_description": "{{orgName}} adlı kuruluş dâhilinde ve herkese açık bağlantınızla etkileşim kurduklarında insanların kim olduğunuzu bilmelerini sağlayın.",
"my_profile": "Profilim",
"my_settings": "Ayarlarım",
"crm": "CRM",
@@ -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",
+14 -2
View File
@@ -268,6 +268,7 @@
"set_availability": "Đặt lịch khả dụng của bạn",
"availability_settings": "Thiết lập tình trạng trống lịch",
"continue_without_calendar": "Tiếp tục mà không có lịch",
"continue_with": "Tiếp tục với {{appName}}",
"connect_your_calendar": "Kết nối lịch của bạn",
"connect_your_video_app": "Kết nối các ứng dụng video của bạn",
"connect_your_video_app_instructions": "Kết nối những ứng dụng video để dùng chúng ở những loại sự kiện của bạn.",
@@ -288,6 +289,8 @@
"when": "Khi nào",
"where": "Ở đâu",
"add_to_calendar": "Thêm vào lịch",
"add_to_calendar_description": "Chọn chỗ cần thêm sự kiện mới khi bạn được đặt lịch.",
"add_events_to": "Thêm các sự kiện vào",
"add_another_calendar": "Thêm lịch khác",
"other": "Khác",
"email_sign_in_subject": "Liên kết đăng nhập cho {{appName}}",
@@ -422,6 +425,7 @@
"booking_created": "Lịch hẹn đã được tạo",
"booking_rejected": "Lịch hẹn đã bị từ chối",
"booking_requested": "Lịch hẹn đã được yêu cầu",
"booking_payment_initiated": "Đã bắt đầu Thanh Toán Đặt Chỗ",
"meeting_ended": "Cuộc họp đã kết thúc",
"form_submitted": "Biểu mẫu đã được gửi",
"booking_paid": "Lịch hẹn đã thanh toán",
@@ -456,6 +460,7 @@
"no_event_types_have_been_setup": "Người dùng này chưa thiết lập bất kỳ loại sự kiện nào.",
"edit_logo": "Chỉnh sửa logo",
"upload_a_logo": "Tải lên logo",
"upload_logo": "Tải lên logo",
"remove_logo": "Xóa logo",
"enable": "Bật",
"code": "Mã",
@@ -568,6 +573,7 @@
"your_team_name": "Tên nhóm của bạn",
"team_updated_successfully": "Đã cập nhật nhóm thành công",
"your_team_updated_successfully": "Nhóm của bạn đã được cập nhật thành công.",
"your_org_updated_successfully": "Tổ Chức của bạn đã được cập nhật thành công.",
"about": "Về",
"team_description": "Một vài câu về nhóm của bạn. Thông tin này sẽ xuất hiện trên trang URL của nhóm của bạn.",
"org_description": "Một vài câu về tổ chức của bạn. Thông tin này sẽ xuất hiện trên trang URL của tổ chức của bạn.",
@@ -599,6 +605,7 @@
"hide_book_a_team_member": "Giấu nút Đặt lịch hẹn thành viên nhóm",
"hide_book_a_team_member_description": "Giấu nút Đặt lịch hẹn thành viên nhóm trên trang công khai của bạn.",
"danger_zone": "Khu vực nguy hiểm",
"account_deletion_cannot_be_undone": "Cẩn thận. Tài khoản đã xóa không thể phục hồi.",
"back": "Trở lại",
"cancel": "Hủy bỏ",
"cancel_all_remaining": "Hủy tất cả những gì còn lại",
@@ -688,6 +695,7 @@
"people": "Mọi người",
"your_email": "Email của bạn",
"change_avatar": "Thay đổi hình đại diện",
"upload_avatar": "Tải lên Avatar",
"language": "Ngôn ngữ",
"timezone": "Múi giờ",
"first_day_of_week": "Ngày đầu tuần",
@@ -778,6 +786,7 @@
"disable_guests": "Vô hiệu hóa Khách",
"disable_guests_description": "Vô hiệu hóa việc thêm khách bổ sung trong khi đặt lịch.",
"private_link": "Tạo liên kết riêng tư",
"enable_private_url": "Bật URL Riêng Tư",
"private_link_label": "Liên kết riêng",
"private_link_hint": "Liên kết riêng của bạn sẽ tạo lại sau mỗi lần sử dụng",
"copy_private_link": "Sao chép liên kết riêng",
@@ -1276,6 +1285,7 @@
"personal_cal_url": "URL {{appName}} cá nhân của tôi",
"bio_hint": "Nói mấy lời về bản thân. Thông tin này sẽ xuất hiện trên trang url cá nhân của bạn.",
"user_has_no_bio": "Người dùng này chưa thêm vào tiểu sử.",
"bio": "Bio",
"delete_account_modal_title": "Xoá tài khoản",
"confirm_delete_account_modal": "Bạn có chắc chắn muốn xoá tài khoản {{appName}}?",
"delete_my_account": "Xoá tài khoản của tôi",
@@ -1530,6 +1540,7 @@
"problem_registering_domain": "Có vấn đề với việc đăng ký tên miền phụ, vui lòng thử lại hoặc liên lạc với quản trị viên",
"team_publish": "Đăng nhóm",
"number_text_notifications": "Số điện thoại (thông báo tin nhắn văn bản)",
"number_sms_notifications": "Số điện thoại (thông báo SMS)",
"attendee_email_variable": "Email người tham dự",
"attendee_email_info": "Email người tham gia lịch hẹn",
"kbar_search_placeholder": "Nhập một lệnh hoặc tìm kiếm...",
@@ -1639,6 +1650,7 @@
"minimum_round_robin_hosts_count": "Số chủ tọa cần thiết để tham dự",
"hosts": "Chủ tọa",
"upgrade_to_enable_feature": "Bạn cần tạo một nhóm để bật tính năng này. Nhấp để tạo một nhóm.",
"orgs_upgrade_to_enable_feature": "Bạn cần nâng cấp gói Enterprise để bật tính năng này.",
"new_attendee": "Người tham dự mới",
"awaiting_approval": "Đang chờ duyệt",
"requires_google_calendar": "Ứng dụng này yêu cầu bạn liên kết với Google Calendar",
@@ -1743,6 +1755,7 @@
"show_on_booking_page": "Hiện trên trang lịch hẹn",
"get_started_zapier_templates": "Bắt đầu với các mẫu Zapier",
"team_is_unpublished": "{{team}} chưa được công bố",
"org_is_unpublished_description": "Liên kết tổ chức này hiện không khả dụng. Hãy liên lạc với chủ tổ chức hoặc yêu cầu họ công bố liên kết đó.",
"team_is_unpublished_description": "Liên kết {{entity}} này hiện không khả dụng. Hãy liên lạc với chủ {{entity}} hoặc yêu cầu họ công bố liên kết đó.",
"team_member": "Thành viên nhóm",
"a_routing_form": "Một biểu mẫu định hướng",
@@ -1877,6 +1890,7 @@
"edit_invite_link": "Sửa cài đặt liên kết",
"invite_link_copied": "Liên kết lời mời đã được sao chép",
"invite_link_deleted": "Liên kết lời mời đã được xoá",
"api_key_deleted": "Đã xóa API Key",
"invite_link_updated": "Đã lưu cài đặt cho liên kết lời mời",
"link_expires_after": "Các liên kết được đặt hết hạn sau...",
"one_day": "1 ngày",
@@ -1957,8 +1971,6 @@
"org_no_teams_yet": "Tổ chức này chưa có nhóm nào",
"org_no_teams_yet_description": "Nếu bạn là một quản trị viên, hãy bảo đảm tạo nhóm các nhóm cần được hiển thị tại đây.",
"set_up": "Thiết lập",
"set_up_your_profile": "Thiết lập hồ sơ của bạn",
"set_up_your_profile_description": "Hãy để mọi người biết bạn là ai trong {{orgName}}, và cho họ biết khi nhấp vào liên kết công cộng của bạn.",
"my_profile": "Hồ sơ của tôi",
"my_settings": "Cài đặt của tôi",
"crm": "CRM",
@@ -1958,8 +1958,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",
@@ -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",
@@ -2,7 +2,7 @@ import appStoreMock from "../../../../../tests/libs/__mocks__/app-store";
import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n";
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import type { BookingReference, Attendee } from "@prisma/client";
import type { BookingReference, Attendee, Booking } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import type { WebhookTriggerEvents } from "@prisma/client";
import type Stripe from "stripe";
@@ -23,8 +23,8 @@ import type { EventBusyDate } from "@calcom/types/Calendar";
import { getMockPaymentService } from "./MockPaymentService";
logger.setSettings({ minLevel: "silly" });
const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] });
logger.settings.minLevel = 0;
const log = logger.getSubLogger({ prefix: ["[bookingScenario]"] });
type InputWebhook = {
appId: string | null;
@@ -102,7 +102,7 @@ export type InputEventType = {
schedule?: InputUser["schedules"][number];
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
type InputBooking = {
type WhiteListedBookingProps = {
id?: number;
uid?: string;
userId?: number;
@@ -118,6 +118,8 @@ type InputBooking = {
})[];
};
type InputBooking = Partial<Omit<Booking, keyof WhiteListedBookingProps>> & WhiteListedBookingProps;
export const Timezones = {
"+5:30": "Asia/Kolkata",
"+6:00": "Asia/Dhaka",
@@ -1203,3 +1205,35 @@ export const enum BookingLocations {
CalVideo = "integrations:daily",
ZoomVideo = "integrations:zoom",
}
const getMockAppStatus = ({
slug,
failures,
success,
}: {
slug: string;
failures: number;
success: number;
}) => {
const foundEntry = Object.entries(appStoreMetadata).find(([, app]) => {
return app.slug === slug;
});
if (!foundEntry) {
throw new Error("App not found for the slug");
}
const foundApp = foundEntry[1];
return {
appName: foundApp.slug,
type: foundApp.type,
failures,
success,
errors: [],
};
};
export const getMockFailingAppStatus = ({ slug }: { slug: string }) => {
return getMockAppStatus({ slug, failures: 1, success: 0 });
};
export const getMockPassingAppStatus = ({ slug }: { slug: string }) => {
return getMockAppStatus({ slug, failures: 0, success: 1 });
};
+103 -47
View File
@@ -1,6 +1,7 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client";
import { parse } from "node-html-parser";
import ical from "node-ical";
import { expect } from "vitest";
import "vitest-fetch-mock";
@@ -8,6 +9,7 @@ import "vitest-fetch-mock";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { BookingStatus } from "@calcom/prisma/enums";
import type { AppsStatus } from "@calcom/types/Calendar";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
@@ -19,14 +21,14 @@ declare global {
interface Matchers<R> {
toHaveEmail(
expectedEmail: {
//TODO: Support email HTML parsing to target specific elements
htmlToContain?: string;
title?: string;
to: string;
noIcs?: true;
ics?: {
filename: string;
iCalUID: string;
};
appsStatus?: AppsStatus[];
},
to: string
): R;
@@ -38,21 +40,23 @@ expect.extend({
toHaveEmail(
emails: Fixtures["emails"],
expectedEmail: {
//TODO: Support email HTML parsing to target specific elements
htmlToContain?: string;
title?: string;
to: string;
ics: {
filename: string;
iCalUID: string;
};
noIcs: true;
appsStatus: AppsStatus[];
},
to: string
) {
const { isNot } = this;
const testEmail = emails.get().find((email) => email.to.includes(to));
const emailsToLog = emails
.get()
.map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent }));
if (!testEmail) {
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
return {
@@ -63,48 +67,93 @@ expect.extend({
const ics = testEmail.icalEvent;
const icsObject = ics?.content ? ical.sync.parseICS(ics?.content) : null;
let isHtmlContained = true;
let isToAddressExpected = true;
const isIcsFilenameExpected = expectedEmail.ics ? ics?.filename === expectedEmail.ics.filename : true;
const isIcsUIDExpected = expectedEmail.ics
? !!(icsObject ? icsObject[expectedEmail.ics.iCalUID] : null)
: true;
const emailDom = parse(testEmail.html);
if (expectedEmail.htmlToContain) {
isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain);
const actualEmailContent = {
title: emailDom.querySelector("title")?.innerText,
subject: emailDom.querySelector("subject")?.innerText,
};
const expectedEmailContent = {
title: expectedEmail.title,
};
const isEmailContentMatched = this.equals(
actualEmailContent,
expect.objectContaining(expectedEmailContent)
);
if (!isEmailContentMatched) {
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
return {
pass: false,
message: () => `Email content ${isNot ? "is" : "is not"} matching`,
actual: actualEmailContent,
expected: expectedEmailContent,
};
}
isToAddressExpected = expectedEmail.to === testEmail.to;
if (!isHtmlContained || !isToAddressExpected) {
if (!isToAddressExpected) {
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
return {
pass: false,
message: () => `To address ${isNot ? "is" : "is not"} matching`,
actual: testEmail.to,
expected: expectedEmail.to,
};
}
if (!expectedEmail.noIcs && !isIcsFilenameExpected) {
return {
pass: false,
actual: ics?.filename,
expected: expectedEmail.ics.filename,
message: () => `ICS Filename ${isNot ? "is" : "is not"} matching`,
};
}
if (!expectedEmail.noIcs && !isIcsUIDExpected) {
return {
pass: false,
actual: JSON.stringify(icsObject),
expected: expectedEmail.ics.iCalUID,
message: () => `Expected ICS UID ${isNot ? "is" : "isn't"} present in actual`,
};
}
if (expectedEmail.appsStatus) {
const actualAppsStatus = emailDom.querySelectorAll('[data-testid="appsStatus"] li').map((li) => {
return li.innerText.trim();
});
const expectedAppStatus = expectedEmail.appsStatus.map((appStatus) => {
if (appStatus.success && !appStatus.failures) {
return `${appStatus.appName} ✅`;
}
return `${appStatus.appName} ❌`;
});
const isAppsStatusCorrect = this.equals(actualAppsStatus, expectedAppStatus);
if (!isAppsStatusCorrect) {
return {
pass: false,
actual: actualAppsStatus,
expected: expectedAppStatus,
message: () => `AppsStatus ${isNot ? "is" : "isn't"} matching`,
};
}
}
return {
pass:
isHtmlContained &&
isToAddressExpected &&
(expectedEmail.noIcs ? true : isIcsFilenameExpected && isIcsUIDExpected),
message: () => {
if (!isHtmlContained) {
return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`;
}
if (!isToAddressExpected) {
return `Email To address is not as expected. Expected:${expectedEmail.to} isn't equal to ${testEmail.to}`;
}
if (!isIcsFilenameExpected) {
return `ICS Filename is not as expected. Expected:${expectedEmail.ics.filename} isn't equal to ${ics?.filename}`;
}
if (!isIcsUIDExpected) {
return `ICS UID is not as expected. Expected:${
expectedEmail.ics.iCalUID
} isn't present in ${JSON.stringify(icsObject)}`;
}
throw new Error("Unknown error");
},
pass: true,
message: () => `Email ${isNot ? "is" : "isn't"} correct`,
};
},
});
@@ -139,9 +188,10 @@ export function expectWebhookToHaveBeenCalledWith(
const parsedBody = JSON.parse((body as string) || "{}");
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
if (parsedBody.payload.metadata?.videoCallUrl) {
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID")
? parsedBody.payload.metadata.videoCallUrl
: parsedBody.payload.metadata.videoCallUrl;
}
if (data.payload) {
@@ -195,7 +245,7 @@ export function expectSuccessfulBookingCreationEmails({
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
title: "confirmed_event_type_subject",
to: `${organizer.email}`,
ics: {
filename: "event.ics",
@@ -207,7 +257,7 @@ export function expectSuccessfulBookingCreationEmails({
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
title: "confirmed_event_type_subject",
to: `${booker.name} <${booker.email}>`,
ics: {
filename: "event.ics",
@@ -221,7 +271,7 @@ export function expectSuccessfulBookingCreationEmails({
otherTeamMembers.forEach((otherTeamMember) => {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
title: "confirmed_event_type_subject",
// Don't know why but organizer and team members of the eventType don'thave their name here like Booker
to: `${otherTeamMember.email}`,
ics: {
@@ -238,7 +288,7 @@ export function expectSuccessfulBookingCreationEmails({
guests.forEach((guest) => {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
title: "confirmed_event_type_subject",
to: `${guest.email}`,
ics: {
filename: "event.ics",
@@ -261,7 +311,7 @@ export function expectBrokenIntegrationEmails({
// Broken Integration email is only sent to the Organizer
expect(emails).toHaveEmail(
{
htmlToContain: "<title>broken_integration</title>",
title: "broken_integration",
to: `${organizer.email}`,
// No ics goes in case of broken integration email it seems
// ics: {
@@ -274,7 +324,7 @@ export function expectBrokenIntegrationEmails({
// expect(emails).toHaveEmail(
// {
// htmlToContain: "<title>confirmed_event_type_subject</title>",
// title: "confirmed_event_type_subject",
// to: `${booker.name} <${booker.email}>`,
// },
// `${booker.name} <${booker.email}>`
@@ -294,7 +344,7 @@ export function expectCalendarEventCreationFailureEmails({
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>broken_integration</title>",
title: "broken_integration",
to: `${organizer.email}`,
ics: {
filename: "event.ics",
@@ -306,7 +356,7 @@ export function expectCalendarEventCreationFailureEmails({
expect(emails).toHaveEmail(
{
htmlToContain: "<title>calendar_event_creation_failure_subject</title>",
title: "calendar_event_creation_failure_subject",
to: `${booker.name} <${booker.email}>`,
ics: {
filename: "event.ics",
@@ -322,27 +372,30 @@ export function expectSuccessfulBookingRescheduledEmails({
organizer,
booker,
iCalUID,
appsStatus,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
iCalUID: string;
appsStatus: AppsStatus[];
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>",
title: "event_type_has_been_rescheduled_on_time_date",
to: `${organizer.email}`,
ics: {
filename: "event.ics",
iCalUID,
},
appsStatus,
},
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>",
title: "event_type_has_been_rescheduled_on_time_date",
to: `${booker.name} <${booker.email}>`,
ics: {
filename: "event.ics",
@@ -362,7 +415,7 @@ export function expectAwaitingPaymentEmails({
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>awaiting_payment_subject</title>",
title: "awaiting_payment_subject",
to: `${booker.name} <${booker.email}>`,
noIcs: true,
},
@@ -381,7 +434,7 @@ export function expectBookingRequestedEmails({
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_awaiting_approval_subject</title>",
title: "event_awaiting_approval_subject",
to: `${organizer.email}`,
noIcs: true,
},
@@ -390,7 +443,7 @@ export function expectBookingRequestedEmails({
expect(emails).toHaveEmail(
{
htmlToContain: "<title>booking_submitted_subject</title>",
title: "booking_submitted_subject",
to: `${booker.email}`,
noIcs: true,
},
@@ -509,6 +562,7 @@ export function expectBookingRescheduledWebhookToHaveBeenFired({
location,
subscriberUrl,
videoCallUrl,
payload,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
@@ -516,10 +570,12 @@ export function expectBookingRescheduledWebhookToHaveBeenFired({
location: string;
paidEvent?: boolean;
videoCallUrl?: string;
payload?: Record<string, unknown>;
}) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_RESCHEDULED",
payload: {
...payload,
metadata: {
...(videoCallUrl ? { videoCallUrl } : null),
},
+1 -1
View File
@@ -10,7 +10,7 @@ interface CalendarApp {
};
}
const log = logger.getChildLogger({ prefix: ["CalendarManager"] });
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
/**
* @see [Using type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
@@ -16,6 +16,8 @@ export type ParseRefreshTokenResponse<S extends z.ZodTypeAny> =
const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
let refreshTokenResponse;
const credentialSyncingEnabled =
APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT;
if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT) {
refreshTokenResponse = minimumTokenResponseSchema.safeParse(response);
} else {
@@ -26,7 +28,7 @@ const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
throw new Error("Invalid refreshed tokens were returned");
}
if (!refreshTokenResponse.data.refresh_token) {
if (!refreshTokenResponse.data.refresh_token && credentialSyncingEnabled) {
refreshTokenResponse.data.refresh_token = "refresh_token";
}
@@ -134,7 +134,15 @@ function PaymentChecker(props: PaymentCheckerProps) {
const bookingSuccessRedirect = useBookingSuccessRedirect();
const utils = trpc.useContext();
const { t } = useLocale();
useEffect(() => {
if (searchParams === null) {
return;
}
// use closure to ensure non-nullability
const sp = searchParams;
const interval = setInterval(() => {
(async () => {
if (props.booking.status === "ACCEPTED") {
@@ -153,7 +161,7 @@ function PaymentChecker(props: PaymentCheckerProps) {
location: string;
} = {
uid: props.booking.uid,
email: searchParams.get("email"),
email: sp.get("email"),
location: t("web_conferencing_details_to_follow"),
};
@@ -165,6 +173,7 @@ function PaymentChecker(props: PaymentCheckerProps) {
}
})();
}, 1000);
return () => clearInterval(interval);
}, [
bookingSuccessRedirect,
@@ -178,5 +187,6 @@ function PaymentChecker(props: PaymentCheckerProps) {
t,
utils.viewer.bookings,
]);
return null;
}
@@ -30,15 +30,20 @@ export default function AlbySetup(props: IAlbySetupProps) {
function AlbySetupCallback() {
const [error, setError] = useState<string | null>(null);
const params = useSearchParams();
const searchParams = useSearchParams();
useEffect(() => {
if (!searchParams) {
return;
}
if (!window.opener) {
setError("Something went wrong. Opener not available. Please contact support@getalby.com");
return;
}
const code = params.get("code");
const error = params.get("error");
const code = searchParams?.get("code");
const error = searchParams?.get("error");
if (!code) {
setError("declined");
@@ -54,7 +59,7 @@ function AlbySetupCallback() {
payload: { code },
});
window.close();
}, []);
}, [searchParams]);
return (
<div>
@@ -11,6 +11,7 @@ export const InstallAppButtonMap = {
vital: dynamic(() => import("./vital/components/InstallAppButton")),
};
export const AppSettingsComponentsMap = {
"cal-ai": dynamic(() => import("./cal-ai/components/AppSettingsInterface")),
"general-app-settings": dynamic(() =>
import("./templates/general-app-settings/components/AppSettingsInterface")
),
@@ -63,7 +63,7 @@ export default class BasecampCalendarService implements Calendar {
this.userAgent = user_agent as string;
});
this.auth = this.basecampAuth(credential).then((c) => c);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
private basecampAuth = async (credential: CredentialPayload) => {
@@ -0,0 +1,73 @@
import { useSession } from "next-auth/react";
export function MailLink({ subject, body }: { subject: string; body?: string }) {
const { data } = useSession();
return (
<a href={`mailto:${data?.user?.username}@cal.ai?subject=${subject}&body=${body ? body : ""}`}>
{subject ? subject : body}
</a>
);
}
export default function AppSettings() {
return (
<>
<div className="prose-sm prose prose-a:text-default prose-headings:text-emphasis prose-code:text-default prose-strong:text-default text-default">
<h2>Example questions:</h2>
<ul>
<li>
<MailLink subject="Book a meeting with @cal-user this week at a good time" />
</li>
<li>
<MailLink subject="Find a time to meet with not-a-cal-user@gmail.com this week" />
</li>
<li>
<MailLink subject="Move my meeting with [Someone's Name] to next week" />
</li>
<li>
<MailLink subject="What does my week look like?" />
</li>
<li>
<MailLink subject="When is my next meeting?" />
</li>
<li>
<MailLink subject="What is my least busy day next week?" />
</li>
<li>
<MailLink subject="Cancel the meeting with [Someone's Name]" />
</li>
<li>
<MailLink subject="Reschedule the meeting with not-a-cal-user@gmail.com" />
</li>
<li>
<MailLink subject="Is @cal-user busy on Friday at noon?" />
</li>
<li>
<MailLink subject="Move my meeting at 2pm tomorrow to next week on Friday at 5pm" />
</li>
</ul>
<h3>Or, any of that in a thread:</h3>
<ul>
<li>
What meetings do I have this week?
<ul>
<li>Meeting 1, Meeting 2</li>
</ul>
</li>
<li>Cancel that first meeting</li>
</ul>
<h3>Or, doing multiple things:</h3>
<ul>
<li>
<MailLink
subject=""
body="Cancel all my meetings tomorrow then book a quick chat with @joe at noon, a long meeting with
@jane at 1 (both tomorrow). Also see if Toby is down to grab dinner this week (toby@gmail.com).
Finally send me my updated agenda of everything I have coming up in the next couple days."
/>
</li>
</ul>
</div>
</>
);
}
@@ -3,7 +3,7 @@ items:
- 1.jpg
---
> 🚨 A slow Caldav server can cause a slow booking page for you
> 🚨 <span class="text-red-500">A slow Caldav server can cause a slow booking page for you</span>
Caldav is a protocol that allows different clients/servers to access scheduling information on remote servers as well as schedule meetings with other users on the same server or other servers. It extends WebDAV specification and uses iCalendar format for the data.
@@ -1,3 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="3004" height="3004" fill="none">
<path fill="#DFE4E4" stroke="#fff" stroke-width="100" d="m2809.88 802.052 12.75 14.238v1801.24c0 168.34-136.45 304.8-304.8 304.8H485.801c-168.343 0-304.801-136.46-304.801-304.8V825.541l3.742-9.121 28.646-69.814 12.728-31.02H2732.48l14.91 16.652 62.49 69.814Z"/>
<path fill="#CD2E47" stroke="#fff" stroke-width="100" d="M2822.63 835.4v50H181V585.507c0-168.345 136.457-304.814 304.801-304.814H2517.83c168.35 0 304.8 136.47 304.8 304.814V835.4Z"/>
<path fill="#1C202E" stroke="#fff" stroke-width="70" d="M593.032 611.307h-3.084c-53.065 0-96.079-43.018-96.079-96.08V192.08c0-53.062 43.015-96.08 96.079-96.08h3.084c53.064 0 96.078 43.018 96.078 96.08v323.147c0 53.062-43.014 96.08-96.078 96.08ZM1050.09 611.307h-3.08c-53.065 0-96.081-43.018-96.081-96.08V192.08c0-53.063 43.016-96.08 96.081-96.08h3.08c53.07 0 96.08 43.017 96.08 96.08v323.147c0 53.062-43.01 96.08-96.08 96.08ZM1507.15 611.307h-3.08c-53.06 0-96.08-43.017-96.08-96.08V192.08c0-53.063 43.02-96.08 96.08-96.08h3.08c53.06 0 96.08 43.017 96.08 96.08v323.147c0 53.063-43.02 96.08-96.08 96.08ZM1964.22 611.307h-3.08c-53.07 0-96.08-43.017-96.08-96.08V192.08c0-53.063 43.01-96.08 96.08-96.08h3.08c53.06 0 96.08 43.017 96.08 96.08v323.147c0 53.063-43.02 96.08-96.08 96.08ZM2421.27 611.307h-3.08c-53.06 0-96.08-43.017-96.08-96.08V192.08c0-53.064 43.02-96.08 96.08-96.08h3.08c53.06 0 96.08 43.017 96.08 96.08v323.147c0 53.063-43.02 96.08-96.08 96.08Z"/>
<path fill="#DFE4E4" d="M2772.63 835.4v1782.13c0 140.72-114.07 254.8-254.8 254.8H485.801c-140.728 0-254.801-114.08-254.801-254.8V835.4l28.645-69.813H2710.14l62.49 69.813Z"/>
<path fill="#CD2E47" d="M2772.63 585.507V835.4H231V585.507c0-140.734 114.073-254.814 254.801-254.814H2517.83c140.73 0 254.8 114.08 254.8 254.814Z"/>
<path fill="#9B213B" d="M684.276 632.147H491.114c-21.767 0-39.414-17.64-39.414-39.414V510.72c0-21.773 17.647-39.413 39.414-39.413h193.162c21.768 0 39.414 17.64 39.414 39.413v82.013c0 21.774-17.646 39.414-39.414 39.414Z"/>
<path fill="#1C202E" d="M593.032 576.307h-3.084c-33.733 0-61.079-27.347-61.079-61.08V192.08c0-33.733 27.346-61.08 61.079-61.08h3.084c33.733 0 61.078 27.347 61.078 61.08v323.147c0 33.733-27.345 61.08-61.078 61.08Z"/>
<path fill="#9B213B" d="M1141.34 632.147H948.174c-21.768 0-39.414-17.64-39.414-39.414V510.72c0-21.773 17.646-39.413 39.414-39.413h193.166c21.76 0 39.41 17.64 39.41 39.413v82.013c0 21.774-17.65 39.414-39.41 39.414Z"/>
<path fill="#1C202E" d="M1050.09 576.307h-3.08c-33.73 0-61.081-27.347-61.081-61.08V192.08c0-33.733 27.351-61.08 61.081-61.08h3.08c33.74 0 61.08 27.347 61.08 61.08v323.147c0 33.733-27.34 61.08-61.08 61.08Z"/>
<path fill="#9B213B" d="M1598.4 632.147h-193.17c-21.76 0-39.41-17.64-39.41-39.414V510.72c0-21.773 17.65-39.413 39.41-39.413h193.17c21.76 0 39.42 17.64 39.42 39.413v82.013c0 21.774-17.66 39.414-39.42 39.414Z"/>
<path fill="#1C202E" d="M1507.15 576.307h-3.08c-33.73 0-61.08-27.347-61.08-61.08V192.08c0-33.733 27.35-61.08 61.08-61.08h3.08c33.73 0 61.08 27.347 61.08 61.08v323.147c0 33.733-27.35 61.08-61.08 61.08Z"/>
<path fill="#9B213B" d="M2055.46 632.147H1862.3c-21.78 0-39.42-17.64-39.42-39.414V510.72c0-21.773 17.64-39.413 39.42-39.413h193.16c21.77 0 39.41 17.64 39.41 39.413v82.013c0 21.774-17.64 39.414-39.41 39.414Z"/>
<path fill="#1C202E" d="M1964.22 576.307h-3.08c-33.74 0-61.08-27.347-61.08-61.08V192.08c0-33.733 27.34-61.08 61.08-61.08h3.08c33.73 0 61.08 27.347 61.08 61.08v323.147c0 33.733-27.35 61.08-61.08 61.08Z"/>
<path fill="#9B213B" d="M2512.52 632.147h-193.16c-21.77 0-39.42-17.64-39.42-39.414V510.72c0-21.773 17.65-39.413 39.42-39.413h193.16c21.76 0 39.42 17.64 39.42 39.413v82.013c0 21.774-17.66 39.414-39.42 39.414Z"/>
<path fill="#1C202E" d="M2421.27 576.307h-3.08c-33.73 0-61.08-27.347-61.08-61.08V192.08c0-33.733 27.35-61.08 61.08-61.08h3.08c33.73 0 61.08 27.347 61.08 61.08v323.147c0 33.733-27.35 61.08-61.08 61.08Z"/>
<path fill="#B9C3C4" d="M842.32 959.6h154.945c21.575 0 39.065 17.48 39.065 39.053v154.957c0 21.56-17.49 39.06-39.065 39.06H842.32c-21.573 0-39.063-17.5-39.063-39.06V998.653c0-21.573 17.49-39.053 39.063-39.053ZM1618.35 959.6h154.95c21.57 0 39.06 17.48 39.06 39.053v154.957c0 21.56-17.49 39.06-39.06 39.06h-154.95c-21.57 0-39.07-17.5-39.07-39.06V998.653c0-21.573 17.5-39.053 39.07-39.053ZM2588.39 998.653v154.957c0 21.56-17.49 39.06-39.07 39.06h-154.94c-21.58 0-39.06-17.5-39.06-39.06V998.653c0-21.573 17.48-39.053 39.06-39.053h154.94c21.58 0 39.07 17.48 39.07 39.053ZM454.304 959.6h154.945c21.574 0 39.063 17.48 39.063 39.053v154.957c0 21.56-17.489 39.06-39.063 39.06H454.304c-21.573 0-39.063-17.5-39.063-39.06V998.653c0-21.573 17.49-39.053 39.063-39.053ZM2006.36 959.6h154.95c21.57 0 39.07 17.48 39.07 39.053v154.957c0 21.56-17.5 39.06-39.07 39.06h-154.95c-21.57 0-39.05-17.5-39.05-39.06V998.653c0-21.573 17.48-39.053 39.05-39.053ZM1230.33 959.6h154.95c21.58 0 39.07 17.48 39.07 39.053v154.957c0 21.56-17.49 39.06-39.07 39.06h-154.95c-21.57 0-39.06-17.5-39.06-39.06V998.653c0-21.573 17.49-39.053 39.06-39.053ZM1618.35 2092.4h154.95c21.57 0 39.06 17.48 39.06 39.05v154.95c0 21.57-17.49 39.07-39.06 39.07h-154.95c-21.57 0-39.07-17.5-39.07-39.07v-154.95c0-21.57 17.5-39.05 39.07-39.05ZM2588.39 2131.45v154.95c0 21.57-17.49 39.07-39.07 39.07h-154.94c-21.58 0-39.06-17.5-39.06-39.07v-154.95c0-21.57 17.48-39.05 39.06-39.05h154.94c21.58 0 39.07 17.48 39.07 39.05ZM2006.36 2092.4h154.95c21.57 0 39.07 17.48 39.07 39.05v154.95c0 21.57-17.5 39.07-39.07 39.07h-154.95c-21.57 0-39.05-17.5-39.05-39.07v-154.95c0-21.57 17.48-39.05 39.05-39.05ZM1230.33 2092.4h154.95c21.58 0 39.07 17.48 39.07 39.05v154.95c0 21.57-17.49 39.07-39.07 39.07h-154.95c-21.57 0-39.06-17.5-39.06-39.07v-154.95c0-21.57 17.49-39.05 39.06-39.05ZM842.32 2092.4h154.945c21.575 0 39.065 17.48 39.065 39.05v154.95c0 21.57-17.49 39.07-39.065 39.07H842.32c-21.573 0-39.063-17.5-39.063-39.07v-154.95c0-21.57 17.49-39.05 39.063-39.05ZM454.304 2092.4h154.945c21.574 0 39.063 17.48 39.063 39.05v154.95c0 21.57-17.489 39.07-39.063 39.07H454.304c-21.573 0-39.063-17.5-39.063-39.07v-154.95c0-21.57 17.49-39.05 39.063-39.05ZM454.304 2470h154.945c21.574 0 39.063 17.48 39.063 39.05V2664c0 21.57-17.489 39.07-39.063 39.07H454.304c-21.573 0-39.063-17.5-39.063-39.07v-154.95c0-21.57 17.49-39.05 39.063-39.05ZM842.32 2470h154.945c21.575 0 39.065 17.48 39.065 39.05V2664c0 21.57-17.49 39.07-39.065 39.07H842.32c-21.573 0-39.063-17.5-39.063-39.07v-154.95c0-21.57 17.49-39.05 39.063-39.05ZM1424.35 2509.05V2664c0 21.57-17.49 39.07-39.07 39.07h-154.95c-21.57 0-39.06-17.5-39.06-39.07v-154.95c0-21.57 17.49-39.05 39.06-39.05h154.95c21.58 0 39.07 17.48 39.07 39.05Z"/>
<path fill="#1C202E" d="M2178.1 1401.28h114.89l113.48 315.59c8.03 23.17 13.94 43.97 17.73 62.41 3.78-19.38 9.69-40.19 17.73-62.41l113.47-315.59h112.76l-196.45 496.44h-95.74l-197.87-496.44ZM1994.44 1401.28h85.81l202.13 496.44h-107.8l-34.04-94.33h-207.09l-34.04 94.33h-107.8l202.83-496.44Zm113.47 316.3-53.19-135.46c-8.03-20.8-13.94-39.71-17.73-56.73-6.14 23.64-12.05 42.55-17.73 56.73l-53.19 135.46h141.84ZM1391.06 1401.28h177.3c55.79 0 102.6 10.63 140.42 31.91 38.3 21.28 66.9 50.59 85.81 87.94 18.92 37.35 28.37 80.38 28.37 129.08 0 48.22-9.45 91.01-28.37 128.36-18.91 37.35-47.51 66.67-85.81 87.94-37.82 20.81-84.63 31.21-140.42 31.21h-177.3v-496.44Zm165.24 407.08c55.79 0 96.93-14.19 123.4-42.55 26.95-28.84 40.43-67.38 40.43-115.6 0-48.23-13.48-86.76-40.43-115.6-26.47-29.32-67.61-43.97-123.4-43.97h-65.95v317.72h65.95ZM1231.6 1380h95.74v517.72h-95.74V1380ZM962.661 1906.94c-33.096 0-63.592-8.51-91.487-25.53-27.895-17.5-50.117-40.9-66.665-70.21-16.075-29.32-24.113-61.47-24.113-96.46 0-34.98 8.038-67.13 24.113-96.45 16.548-29.78 38.77-53.42 66.665-70.92 27.895-17.49 58.391-26.24 91.487-26.24 30.26 0 54.609 5.67 73.049 17.02 18.91 11.35 33.57 27.66 43.97 48.94v-58.16h95.03v368.79h-92.9v-60.28c-10.88 22.22-25.77 39.47-44.68 51.77-18.44 11.82-43.264 17.73-74.469 17.73Zm-86.523-192.91c0 18.92 4.256 36.65 12.766 53.19 8.983 16.08 21.04 29.08 36.169 39.01 15.603 9.46 33.333 14.18 53.191 14.18 20.33 0 38.296-4.72 53.896-14.18 16.08-9.46 28.37-22.22 36.88-38.3 8.51-16.07 12.77-33.8 12.77-53.19 0-19.38-4.26-37.11-12.77-53.19-8.51-16.54-20.8-29.55-36.88-39-15.6-9.93-33.566-14.9-53.896-14.9-19.858 0-37.588 4.97-53.191 14.9-15.602 9.45-27.659 22.22-36.169 38.29-8.51 16.08-12.766 33.81-12.766 53.19ZM594.008 1906.94c-48.225 0-92.196-11.35-131.911-34.04-39.243-23.17-70.211-54.38-92.906-93.62-22.694-39.24-34.042-82.27-34.042-129.07 0-47.28 10.875-90.55 32.624-129.79 22.221-39.71 52.954-70.92 92.196-93.61 39.243-23.17 83.922-34.75 134.039-34.75 40.189 0 74.703 6.38 103.544 19.14 29.314 12.3 55.318 31.92 78.012 58.87l-70.211 60.99c-29.786-31.2-66.901-46.81-111.345-46.81-31.204 0-58.627 7.57-82.267 22.7-23.64 14.65-41.843 34.75-54.609 60.28-12.765 25.06-19.148 52.72-19.148 82.98 0 30.26 6.383 57.91 19.148 82.97 13.239 25.06 31.678 44.92 55.318 59.58 24.113 14.65 52.008 21.98 83.686 21.98 46.335 0 84.159-16.07 113.473-48.22l72.338 61.7c-19.857 24.11-45.862 43.26-78.012 57.44-31.678 14.19-68.32 21.28-109.927 21.28Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 8.9 KiB

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