Compare commits

...
73 Commits
Author SHA1 Message Date
Omar López b4f44e9a60 v3.3.4
Auto Comment Merge Conflicts / auto-comment-merge-conflicts (push) Canceled after 0s
release.yml / release (push) Canceled after 0s
2023-09-28 12:39:00 -07:00
Omar López 9c448e3b87 Revert "fix: #10789 Prevent Double Booking (for requests that require confirmation)" (#11594) 2023-09-28 12:31:43 -07:00
5deda33712 fix: focus on textarea (#11522)
* Border change on hover on textarea

* Fixed focus on textarea

* Fixed focus on textarea

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Update packages/ui/components/editor/stylesEditor.css

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Update packages/ui/components/editor/Editor.tsx

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 18:50:07 +00:00
2335948099 fixed #11561 dropdown is too large (#11562)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 18:25:14 +00:00
James George 8bbb38d2be chore: cleanup .husky directory (#11583) 2023-09-28 19:13:05 +01:00
a4035aef98 test: Tests for AppCard component (#11500)
Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-09-28 19:11:43 +01:00
Peer Richelsen c57de8f43f chore: more cal.ai changes (#11590) 2023-09-28 18:09:17 +00:00
Hariom BalharaandPeer Richelsen 2b1918f40a fix: Embed Snippet Generator, hard crash on reload (#11363)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 19:08:42 +01:00
Crowdin Bot fb2c966e6c New Crowdin translations by Github Action 2023-09-28 18:08:27 +00:00
Udit TakkarandPeer Richelsen 4f3cf4f948 refactor: Setting redesign (#11124)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 19:04:51 +01:00
Crowdin Bot 4613ac34c9 New Crowdin translations by Github Action 2023-09-28 16:24:28 +00:00
Crowdin Bot d69ba825fe New Crowdin translations by Github Action 2023-09-28 16:21:26 +00:00
Peer Richelsen 92a221197a added new logo and screenshot (#11585) 2023-09-28 12:18:27 -04:00
Hariom Balhara b54f716fd7 chore: Improved logging booking flow (#11543) 2023-09-28 13:01:24 +00:00
Somay ChauhanandUdit Takkar 57e712dd9e fix: event-type filters (#11552)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-09-28 13:33:31 +01:00
2021b641ce feat: Alby integration (#11495)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 12:03:01 +00:00
ef45cbfb3f refactor: event type settings (#11539)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-28 12:59:06 +01:00
Crowdin Bot ab17cb216f New Crowdin translations by Github Action 2023-09-28 11:33:29 +00:00
Aldrin b04c870a3d fix: remove cal.com before team name (#11544) 2023-09-28 12:30:32 +01:00
Crowdin Bot 09abb90c8b New Crowdin translations by Github Action 2023-09-28 09:54:13 +00:00
Hariom Balhara b8e3b549f8 Fix failing bookings (#11574) 2023-09-28 09:51:18 +00:00
alannnc 403d08de41 fix: update-stripe-presented-currency (#11431)
* Edit stripe currency

* add simple test for stripe change currency

* Undo import removed
2023-09-27 21:30:22 +00:00
d7c3132fa5 fix: #10789 Prevent Double Booking (for requests that require confirmation) (#10882)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2023-09-27 20:54:22 +00:00
Keith Williams a2df323cf8 fix: credential sync app lookup (#11530) 2023-09-27 17:50:16 +01:00
fe82d88adf fix: change localhost:3000 to Gitpod workspace URL (#10470)
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-27 15:54:45 +00:00
Nafees Nazik 01e2ba3fd9 fix: app crashing due to invalid hex (#11542) 2023-09-27 10:58:52 -03:00
c9b87bbf85 feat: Paid and confirmed booking should be able to be rescheduled v1 (#11417)
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-27 13:49:13 +00:00
19e6dbb781 fix: removed duplicate account delete confirmation text (#11520)
Co-authored-by: Nitin Panghal <nitin.panghal@unthinkable.co>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-27 13:46:24 +00:00
Syed Ali Shahbaz a3a49f9069 add team availability doc (#11554) 2023-09-27 15:46:07 +04:00
Nafees NazikandUdit Takkar 468da1f35a feat: ability to reorder fields in multiple select in Routing Forms (#10927)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-09-27 12:22:57 +01:00
Alex van Andel ec4e2f5996 fix: Blank state wouldn't set 'schedule'; resulting toggle had no effect (#11335) 2023-09-27 11:26:55 +02:00
Denzil Samuel 8a413a97b4 fix: allow different casing for team (#11546) 2023-09-27 10:50:04 +02:00
Chiranjeev Vishnoi c275981be6 fix: profile slug added on create your first event type dialog (#11494) 2023-09-27 10:00:17 +02:00
Nafees NazikandHariom Balhara ce13350d5d fix: form input not being saved after "back" (#11540)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-09-27 04:39:37 +00:00
919066222b fix: Dynamic events prevent fetching all users that share a username [CAL-2409] (#11288)
* For dynamic search users based on org

* Type fix

---------

Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-26 22:50:10 +01:00
Carina WollendorferandCarinaWolli 79b60adf9b fix: timezone of default schedule should change if profile timezone is updated (#11533)
* change tz of default schedule

* make sure user has at least one schedule

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-09-26 20:41:16 +01:00
Peer Richelsen 1d882e6b3b Update semantic-pull-requests.yml 2023-09-26 17:51:02 +01:00
Peer Richelsen 7f7841fabc Update welcome-new-contributors.yml 2023-09-26 17:49:49 +01:00
Peer Richelsen 260064c836 Rename merge-conflict to merge-conflict.yml 2023-09-26 17:47:20 +01:00
Richard Poelderl 8c8be1efa3 add preview token to website (#11535) 2023-09-26 15:28:57 +00:00
Crowdin Bot 9084cc6677 New Crowdin translations by Github Action 2023-09-26 15:05:48 +00:00
Richard Poelderlandsajanlamsal fda3192d53 fix: changed Japanese translations on booking page to better match the scenario. (#10416) (#11529)
Co-authored-by: sajanlamsal <saznlamsal@gmail.com>
2023-09-26 16:01:06 +01:00
Hariom Balhara 4acfb4b28c fix: Prefill issue when name isn't present and add unit tests (#11418)
* Fix yarn.lock

* Add tests for getBookingResponsesSchema

* Fix prefill failing if name isnt provided

* More tests and another fix
2023-09-26 16:16:31 +05:30
Udit Takkar a0642598ab test: private team test (#11523) 2023-09-26 10:48:43 +02:00
alannnc 20282a16f7 fix: verify-rerender-checkout-stripe-session (#11493) 2023-09-25 10:57:02 -07:00
Shivam Kalra 69120095e4 fix: username change test (#11511) 2023-09-25 13:35:02 +00:00
Peer Richelsen bfc137c349 fix: icons for dark mode (#11309) 2023-09-25 15:20:28 +02:00
Udit TakkarandAlex van Andel 13caf6f2cd fix: add a default timezone (#11475)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-09-25 15:08:41 +02:00
Alex van Andel 1d4eeab33e fix: Salesforce by wrapping the refresh logic in a try/catch (#11508) 2023-09-25 11:33:53 +00:00
Peer Richelsen 3bee2d705f chore: removed ring from org avatar (#11498) 2023-09-25 10:50:08 +00:00
Alex van Andel 9c621bf726 Update the oauth imports to _utils/oauth (#11507) 2023-09-25 09:50:17 +00:00
70461b2718 feat: Zoho Calendar (#10429)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: aar2dee2 <85004512+aar2dee2@users.noreply.github.com>
2023-09-25 14:04:37 +05:30
Crowdin Bot efb04d04ab New Crowdin translations by Github Action 2023-09-25 05:20:27 +00:00
538e3bf07c chore: refactor app list into common component (#9754)
Co-authored-by: Ty Kerr <tykerr@Tys-MacBook-Pro.local>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-09-25 10:47:21 +05:30
AldrinandUdit Takkar 798707a553 fix: incorrect client_id parameter assignment in generating access token for webex (#11472)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-09-22 19:25:00 -04:00
harsh11101andUdit Takkar 5940f9bccd fix: Tickets are being shown in user preferred time format (#11349)
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-09-22 19:24:37 -04:00
5e449670ea feat: Cal AI V1.1.0 (#11446)
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
Co-authored-by: tedspare <ted.spare@gmail.com>
2023-09-22 19:23:19 -04:00
GitStart-Cal.comandgitstart-calcom 82d7a3db92 fix: The clickable area of the 'x' in add guests section is not user friendly (#11434)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-09-22 19:15:14 -04:00
Vijay 0b7e3510a5 fix: #11397 request for reschedule did not cancel initial meeting (#11411) 2023-09-22 19:13:17 -04:00
e0c87cf664 feat: Add ability to edit avatar (#11399)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-09-22 19:12:35 -04:00
Leo Giovanetti 0d653afd0c fix: remove stripe metadata and more logos (#11489) 2023-09-22 14:44:45 -03:00
Richard Poelderl f414428dfe fix: auto-approve crowdin translations uploaded from GitHub (#11490) 2023-09-22 16:33:37 +00:00
2a171e9284 Implementation (#11481)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
2023-09-22 11:14:04 +01:00
Udit Takkar faf974ca4c chore: convert isCreateTeamButtonDisabled to bool (#11473)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-09-22 10:36:52 +01:00
Crowdin Bot a78a7a1fa0 New Crowdin translations by Github Action 2023-09-22 07:52:43 +00:00
Udit Takkar 5943763726 fix: server side error on /apps (#11485) 2023-09-22 13:19:30 +05:30
Hitesh Sisara 2648e2770f Update workflow.tsx to add Loading/Progress indicator while saving the workflow (#11484)
add Loading/Progress indicator while saving the workflow
2023-09-22 11:16:45 +05:30
Crowdin Bot 4e7ab391ff New Crowdin translations by Github Action 2023-09-21 21:11:13 +00:00
Leo Giovanetti 578495de87 chore: reduce team data footprint (#11478) 2023-09-21 18:08:34 -03:00
Udit TakkarandCarinaWolli 86b3a33ff6 fix: disable create team button for non admins (#11470)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-09-21 06:36:40 +00:00
Crowdin Bot 94476565ad New Crowdin translations by Github Action 2023-09-21 06:25:11 +00:00
Carina WollendorferandCarinaWolli 3b50fe075d fix: remove kyc and make text messages to attendee org only (#11389)
* add orgs upgrade badge to select

* don't allow updating to if no teams or orgs plan

* make sure only paying users can request verification codes

* add new action helper function

* remove kyc code

* code clean up

* fix verify button UI

* fix type errors

* add eventTypeRequiresConfirmation everywhere

* address feedback

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-09-20 23:22:05 -07:00
Udit TakkarandLeo Giovanetti 96263b0cf7 fix: team members not showing up in org members list (#11456)
* fix: team members not showing up in org members list

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: use upsert

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Update apps/web/pages/api/auth/signup.ts

Co-authored-by: Leo Giovanetti <hello@leog.me>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Leo Giovanetti <hello@leog.me>
2023-09-20 22:02:10 -03:00
312 changed files with 8739 additions and 3601 deletions
+1
View File
@@ -125,4 +125,5 @@ SALESFORCE_CONSUMER_SECRET=""
ZOHOCRM_CLIENT_ID=""
ZOHOCRM_CLIENT_SECRET=""
# *********************************************************************************************************
+9 -1
View File
@@ -21,10 +21,18 @@ jobs:
- name: crowdin action
uses: crowdin/github-action@v1.13.0
with:
# upload sources
upload_sources: true
# upload translations (& auto-approve 'em)
upload_translations_args: '--auto-approve-imported'
upload_translations: true
download_translations: true
push_translations: true
# download translations
download_translations: true
# GH config
commit_message: "New Crowdin translations by Github Action"
localization_branch_name: main
create_pull_request: false
+2 -1
View File
@@ -44,4 +44,5 @@ jobs:
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions! 🙏
Thank you for following the naming conventions! 🙏 Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
@@ -22,6 +22,6 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
Feel free to join the conversation at [discord](https://go.cal.com/discord)
Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
issue-message: |
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
+3 -1
View File
@@ -5,7 +5,9 @@ tasks:
next_auth_secret=$(openssl rand -base64 32) &&
calendso_encryption_key=$(openssl rand -base64 24) &&
sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$next_auth_secret|" \
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" .env
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" \
-e "s|http://localhost:3000|https://localhost:3000|" \
-e "s|localhost:3000|3000-$GITPOD_WORKSPACE_ID.$GITPOD_WORKSPACE_CLUSTER_HOST|" .env
command: yarn dx
ports:
-1
View File
@@ -1 +0,0 @@
_
+3
View File
@@ -504,6 +504,9 @@ For example, `Cal.com (support@cal.com)`.
9. Click the "Save"/ "UPDATE" button at the bottom footer.
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
### Obtaining Zoho Calendar Client ID and Secret
[Follow these steps](./packages/app-store/zohocalendar/)
### Obtaining Zoho Bigin Client ID and Secret
[Follow these steps](./packages/app-store/zoho-bigin/)
+3
View File
@@ -1,4 +1,7 @@
BACKEND_URL=http://localhost:3002/api
# BACKEND_URL=https://api.cal.com/v1
FRONTEND_URL=http://localhost:3000
# FRONTEND_URL=https://cal.com
APP_ID=cal-ai
APP_URL=http://localhost:3000/apps/cal-ai
+2 -2
View File
@@ -1,6 +1,6 @@
# Cal.com Email Assistant
Welcome to the first stage of Cal AI!
Welcome to the first stage of Cal.ai!
This app lets you chat with your calendar via email:
@@ -27,7 +27,7 @@ Before running the app, please see [env.mjs](./src/env.mjs) for all required env
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
- A default sender email (for example, `ai@cal.dev`)
- The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
- The Cal.ai's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/ai",
"version": "1.0.1",
"version": "1.1.0",
"private": true,
"author": "Cal.com Inc.",
"dependencies": {
+3 -3
View File
@@ -18,21 +18,21 @@ export const POST = async (request: NextRequest) => {
const json = await request.json();
const { apiKey, userId, message, subject, user, replyTo } = json;
const { apiKey, userId, message, subject, user, users, replyTo: agentEmail } = json;
if ((!message && !subject) || !user) {
return new NextResponse("Missing fields", { status: 400 });
}
try {
const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId);
const response = await agent(`${subject}\n\n${message}`, { ...user }, users, apiKey, userId, agentEmail);
// Send response to user
await sendEmail({
subject: `Re: ${subject}`,
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
to: user.email,
from: replyTo,
from: agentEmail,
});
return new NextResponse("ok");
+11 -6
View File
@@ -8,6 +8,7 @@ import prisma from "@calcom/prisma";
import { env } from "../../../env.mjs";
import { fetchAvailability } from "../../../tools/getAvailability";
import { fetchEventTypes } from "../../../tools/getEventTypes";
import { extractUsers } from "../../../utils/extractUsers";
import getHostFromHeaders from "../../../utils/host";
import now from "../../../utils/now";
import sendEmail from "../../../utils/sendEmail";
@@ -45,6 +46,7 @@ export const POST = async (request: NextRequest) => {
select: {
email: true,
id: true,
username: true,
timeZone: true,
credentials: {
select: {
@@ -53,15 +55,15 @@ export const POST = async (request: NextRequest) => {
},
},
},
where: { email: envelope.from },
where: { email: envelope.from, credentials: { some: { appId: env.APP_ID } } },
});
// User is not a cal.com user or is using an unverified email.
if (!signature || !user) {
await sendEmail({
html: `Thanks for your interest in Cal AI! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
subject: `Re: ${body.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`,
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,
});
@@ -76,9 +78,9 @@ export const POST = async (request: NextRequest) => {
const url = env.APP_URL;
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.`,
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}`,
text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`,
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,
});
@@ -89,7 +91,7 @@ export const POST = async (request: NextRequest) => {
const { apiKey } = credential as { apiKey: string };
// Pre-fetch data relevant to most bookings.
const [eventTypes, availability] = await Promise.all([
const [eventTypes, availability, users] = await Promise.all([
fetchEventTypes({
apiKey,
}),
@@ -99,6 +101,7 @@ export const POST = async (request: NextRequest) => {
dateFrom: now(user.timeZone),
dateTo: now(user.timeZone),
}),
extractUsers(`${parsed.text} ${parsed.subject}`),
]);
if ("error" in availability) {
@@ -138,9 +141,11 @@ export const POST = async (request: NextRequest) => {
user: {
email: user.email,
eventTypes,
username: user.username,
timeZone: user.timeZone,
workingHours,
},
users,
}),
headers: {
"Content-Type": "application/json",
+2
View File
@@ -17,6 +17,7 @@ export const env = createEnv({
*/
runtimeEnv: {
BACKEND_URL: process.env.BACKEND_URL,
FRONTEND_URL: process.env.FRONTEND_URL,
APP_ID: process.env.APP_ID,
APP_URL: process.env.APP_URL,
PARSE_KEY: process.env.PARSE_KEY,
@@ -32,6 +33,7 @@ export const env = createEnv({
*/
server: {
BACKEND_URL: z.string().url(),
FRONTEND_URL: z.string().url(),
APP_ID: z.string().min(1),
APP_URL: z.string().url(),
PARSE_KEY: z.string().min(1),
@@ -1,6 +1,8 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import type { UserList } from "~/src/types/user";
import { env } from "../env.mjs";
/**
@@ -9,21 +11,23 @@ import { env } from "../env.mjs";
const createBooking = async ({
apiKey,
userId,
users,
eventTypeId,
start,
end,
timeZone,
language,
responses,
invite,
}: {
apiKey: string;
userId: number;
users: UserList;
eventTypeId: number;
start: string;
end: string;
timeZone: string;
language: string;
responses: { name?: string; email?: string; location?: string };
invite: number;
title?: string;
status?: string;
}): Promise<string | Error | { error: string }> => {
@@ -36,6 +40,18 @@ const createBooking = async ({
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
const user = users.find((u) => u.id === invite);
if (!user) {
return { error: `User with id ${invite} not found to invite` };
}
const responses = {
id: invite,
name: user.username,
email: user.email,
};
const response = await fetch(url, {
body: JSON.stringify({
end,
@@ -66,19 +82,19 @@ const createBooking = async ({
return "Booking created";
};
const createBookingTool = (apiKey: string, userId: number) => {
const createBookingTool = (apiKey: string, userId: number, users: UserList) => {
return new DynamicStructuredTool({
description:
"Tries to create a booking. If the user is unavailable, it will return availability that day, allowing you to avoid the getAvailability step in many cases.",
func: async ({ eventTypeId, start, end, timeZone, language, responses, title, status }) => {
description: "Creates a booking on the primary user's calendar.",
func: async ({ eventTypeId, start, end, timeZone, language, invite, title, status }) => {
return JSON.stringify(
await createBooking({
apiKey,
userId,
users,
end,
eventTypeId,
language,
responses,
invite,
start,
status,
timeZone,
@@ -86,19 +102,14 @@ const createBookingTool = (apiKey: string, userId: number) => {
})
);
},
name: "createBookingIfAvailable",
name: "createBooking",
schema: z.object({
end: z
.string()
.describe("This should correspond to the event type's length, unless otherwise specified."),
eventTypeId: z.number(),
language: z.string(),
responses: z
.object({
email: z.string().optional(),
name: z.string().optional(),
})
.describe("External invited user. Not the user making the request."),
invite: z.number().describe("External user id to invite."),
start: z.string(),
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
timeZone: z.string(),
+15 -20
View File
@@ -12,13 +12,11 @@ export const fetchAvailability = async ({
userId,
dateFrom,
dateTo,
eventTypeId,
}: {
apiKey: string;
userId: number;
dateFrom: string;
dateTo: string;
eventTypeId?: number;
}): Promise<Partial<Availability> | { error: string }> => {
const params: { [k: string]: string } = {
apiKey,
@@ -27,8 +25,6 @@ export const fetchAvailability = async ({
dateTo,
};
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
@@ -51,30 +47,29 @@ export const fetchAvailability = async ({
};
};
const getAvailabilityTool = (apiKey: string, userId: number) => {
const getAvailabilityTool = (apiKey: string) => {
return new DynamicStructuredTool({
description: "Get availability within range.",
func: async ({ dateFrom, dateTo, eventTypeId }) => {
description: "Get availability of users within range.",
func: async ({ userIds, dateFrom, dateTo }) => {
return JSON.stringify(
await fetchAvailability({
apiKey,
userId,
dateFrom,
dateTo,
eventTypeId,
})
await Promise.all(
userIds.map(
async (userId) =>
await fetchAvailability({
userId: userId,
apiKey,
dateFrom,
dateTo,
})
)
)
);
},
name: "getAvailability",
schema: z.object({
userIds: z.array(z.number()).describe("The users to fetch availability for."),
dateFrom: z.string(),
dateTo: z.string(),
eventTypeId: z
.number()
.optional()
.describe(
"The ID of the event type to filter availability for if you've called getEventTypes, otherwise do not include."
),
}),
});
};
+1 -1
View File
@@ -60,7 +60,7 @@ const fetchBookings = async ({
const getBookingsTool = (apiKey: string, userId: number) => {
return new DynamicStructuredTool({
description: "Get bookings for a user between two dates.",
description: "Get bookings for the primary user between two dates.",
func: async ({ from, to }) => {
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
},
+13 -5
View File
@@ -7,11 +7,15 @@ import type { EventType } from "../types/eventType";
/**
* Fetches event types by user ID.
*/
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
const params = {
export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; userId?: number }) => {
const params: Record<string, string> = {
apiKey,
};
if (userId) {
params["userId"] = userId.toString();
}
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
@@ -28,6 +32,7 @@ export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
return data.event_types.map((eventType: EventType) => ({
id: eventType.id,
slug: eventType.slug,
length: eventType.length,
title: eventType.title,
}));
@@ -35,16 +40,19 @@ export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
const getEventTypesTool = (apiKey: string) => {
return new DynamicStructuredTool({
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
func: async () => {
description: "Get a user's event type IDs. Usually necessary to book a meeting.",
func: async ({ userId }) => {
return JSON.stringify(
await fetchEventTypes({
apiKey,
userId,
})
);
},
name: "getEventTypes",
schema: z.object({}),
schema: z.object({
userId: z.number().optional().describe("The user ID. Defaults to the primary user's ID."),
}),
});
};
+81
View File
@@ -0,0 +1,81 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import { env } from "~/src/env.mjs";
import type { User, UserList } from "~/src/types/user";
import sendEmail from "~/src/utils/sendEmail";
export const sendBookingLink = async ({
user,
agentEmail,
subject,
to,
message,
eventTypeSlug,
date,
}: {
apiKey: string;
user: User;
users: UserList;
agentEmail: string;
subject: string;
to: string[];
message: string;
eventTypeSlug: string;
date: string;
}) => {
const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`;
await sendEmail({
subject,
to,
cc: user.email,
from: agentEmail,
text: message.split("[[[Booking Link]]]").join(url),
html: message
.split("\n")
.join("<br>")
.split("[[[Booking Link]]]")
.join(`<a href="${url}">Booking Link</a>`),
});
return "Booking link sent";
};
const sendBookingLinkTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => {
return new DynamicStructuredTool({
description: "Send a booking link via email. Useful for scheduling with non cal users.",
func: async ({ message, subject, to, eventTypeSlug, date }) => {
return JSON.stringify(
await sendBookingLink({
apiKey,
user,
users,
agentEmail,
subject,
to,
message,
eventTypeSlug,
date,
})
);
},
name: "sendBookingLink",
schema: z.object({
message: z
.string()
.describe(
"Make sure to nicely format the message and introduce yourself as the primary user's booking assistant. Make sure to include a spot for the link using: [[[Booking Link]]]"
),
subject: z.string(),
to: z
.array(z.string())
.describe("array of emails to send the booking link to. Primary user is automatically CC'd"),
eventTypeSlug: z.string().describe("the slug of the event type to book"),
date: z.string().describe("the date (yyyy-mm-dd) to suggest for the booking"),
}),
});
};
export default sendBookingLinkTool;
+9
View File
@@ -2,8 +2,17 @@ import type { EventType } from "./eventType";
import type { WorkingHours } from "./workingHours";
export type User = {
id: number;
email: string;
username: string;
timeZone: string;
eventTypes: EventType[];
workingHours: WorkingHours[];
};
export type UserList = {
id?: number;
email?: string;
username?: string;
type: "fromUsername" | "fromEmail";
}[];
+56 -23
View File
@@ -2,30 +2,40 @@ import { initializeAgentExecutorWithOptions } from "langchain/agents";
import { ChatOpenAI } from "langchain/chat_models/openai";
import { env } from "../env.mjs";
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
import createBookingIfAvailable from "../tools/createBooking";
import deleteBooking from "../tools/deleteBooking";
import getAvailability from "../tools/getAvailability";
import getBookings from "../tools/getBookings";
import sendBookingLink from "../tools/sendBookingLink";
import updateBooking from "../tools/updateBooking";
import type { EventType } from "../types/eventType";
import type { User } from "../types/user";
import type { User, UserList } from "../types/user";
import type { WorkingHours } from "../types/workingHours";
import now from "./now";
const gptModel = "gpt-4";
/**
* Core of the Cal AI booking agent: a LangChain Agent Executor.
* Core of the Cal.ai booking agent: a LangChain Agent Executor.
* Uses a toolchain to book meetings, list available slots, etc.
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
*/
const agent = async (input: string, user: User, apiKey: string, userId: number) => {
const agent = async (
input: string,
user: User,
users: UserList,
apiKey: string,
userId: number,
agentEmail: string
) => {
const tools = [
createBookingIfAvailable(apiKey, userId),
getAvailability(apiKey, userId),
// getEventTypes(apiKey),
getAvailability(apiKey),
getBookings(apiKey, userId),
createBookingIfAvailable(apiKey, userId, users),
updateBooking(apiKey, userId),
deleteBooking(apiKey),
sendBookingLink(apiKey, user, users, agentEmail),
];
const model = new ChatOpenAI({
@@ -39,24 +49,47 @@ const agent = async (input: string, user: User, apiKey: string, userId: number)
*/
const executor = await initializeAgentExecutorWithOptions(tools, model, {
agentArgs: {
prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email.
Make sure your final answers are definitive, complete and well formatted.
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
Tools will always handle times in UTC, but times sent to the user should be formatted per that user's timezone.
prefix: `You are Cal.ai - a bleeding edge scheduling assistant that interfaces via email.
Make sure your final answers are definitive, complete and well formatted.
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
The current time in the user's timezone is: ${now(user.timeZone)}
The user's time zone is: ${user.timeZone}
The user's event types are: ${user.eventTypes
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
.join("\n")}
The user's working hours are: ${user.workingHours
.map(
(w: WorkingHours) =>
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
w.startTime
}, End Time (minutes in UTC): ${w.endTime}`
)
.join("\n")}
The primary user's id is: ${userId}
The primary user's username is: ${user.username}
The current time in the primary user's timezone is: ${now(user.timeZone)}
The primary user's time zone is: ${user.timeZone}
The primary user's event types are: ${user.eventTypes
.map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`)
.join("\n")}
The primary user's working hours are: ${user.workingHours
.map(
(w: WorkingHours) =>
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
w.startTime
}, End Time (minutes in UTC): ${w.endTime};`
)
.join("\n")}
${
users.length
? `The email references the following @usernames and emails: ${users
.map(
(u) =>
(u.id ? `, id: ${u.id}` : "id: (non user)") +
(u.username
? u.type === "fromUsername"
? `, username: @${u.username}`
: ", username: REDACTED"
: ", (no username)") +
(u.email
? u.type === "fromEmail"
? `, email: ${u.email}`
: ", email: REDACTED"
: ", (no email)") +
";"
)
.join("\n")}`
: ""
}
`,
},
agentType: "openai-functions",
+1
View File
@@ -0,0 +1 @@
export const context = { apiKey: "", userId: "" };
+79
View File
@@ -0,0 +1,79 @@
/*
* Extracts usernames (@Example) and emails (hi@example.com) from a string
*/
import type { UserList } from "../types/user";
export const extractUsers = async (text: string) => {
const usernames = text.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)?.map((username) => username.slice(1));
const emails = text.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g);
const dbUsersFromUsernames = usernames
? await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
},
where: {
username: {
in: usernames,
},
},
})
: [];
const usersFromUsernames = usernames
? usernames.map((username) => {
const user = dbUsersFromUsernames.find((u) => u.username === username);
return user
? {
username,
id: user.id,
email: user.email,
type: "fromUsername",
}
: {
username,
id: null,
email: null,
type: "fromUsername",
};
})
: [];
const dbUsersFromEmails = emails
? await prisma.user.findMany({
select: {
id: true,
email: true,
username: true,
},
where: {
email: {
in: emails,
},
},
})
: [];
const usersFromEmails = emails
? emails.map((email) => {
const user = dbUsersFromEmails.find((u) => u.email === email);
return user
? {
email,
id: user.id,
username: user.username,
type: "fromEmail",
}
: {
email,
id: null,
username: null,
type: "fromEmail",
};
})
: [];
return [...usersFromUsernames, ...usersFromEmails] as UserList;
};
+5 -2
View File
@@ -8,12 +8,14 @@ const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
const send = async ({
subject,
to,
cc,
from,
text,
html,
}: {
subject: string;
to: string;
to: string | string[];
cc?: string | string[];
from: string;
text: string;
html?: string;
@@ -22,9 +24,10 @@ const send = async ({
const msg = {
to,
cc,
from: {
email: from,
name: "Cal AI",
name: "Cal.ai",
},
text,
html,
+88 -17
View File
@@ -10,9 +10,9 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
/**
* @swagger
* /availability:
* /teams/{teamId}/availability:
* get:
* summary: Find user or team availability
* summary: Find team availability
* parameters:
* - in: query
* name: apiKey
@@ -21,25 +21,14 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
* type: string
* example: "1234abcd5678efgh"
* description: Your API key
* - in: query
* name: userId
* schema:
* type: integer
* example: 101
* description: ID of the user to fetch the availability for
* - in: query
* - in: path
* name: teamId
* required: true
* schema:
* type: integer
* example: 123
* description: ID of the team to fetch the availability for
* - in: query
* name: username
* schema:
* type: string
* example: "alice"
* description: username of the user to fetch the availability for
* - in: query
* name: dateFrom
* schema:
* type: string
@@ -59,7 +48,7 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
* type: integer
* example: 123
* description: Event Type ID of the event type to fetch the availability for
* operationId: availability
* operationId: team-availability
* tags:
* - availability
* responses:
@@ -96,7 +85,89 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: User not found | Team not found | Team has no members
* description: Team not found | Team has no members
*
* /availability:
* get:
* summary: Find user availability
* parameters:
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* example: "1234abcd5678efgh"
* description: Your API key
* - in: query
* name: userId
* schema:
* type: integer
* example: 101
* description: ID of the user to fetch the availability for
* - in: query
* name: username
* schema:
* type: string
* example: "alice"
* description: username of the user to fetch the availability for
* - in: query
* name: dateFrom
* schema:
* type: string
* format: date
* example: "2023-05-14 00:00:00"
* description: Start Date of the availability query
* - in: query
* name: dateTo
* schema:
* type: string
* format: date
* example: "2023-05-20 00:00:00"
* description: End Date of the availability query
* - in: query
* name: eventTypeId
* schema:
* type: integer
* example: 123
* description: Event Type ID of the event type to fetch the availability for
* operationId: user-availability
* tags:
* - availability
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* example:
* busy:
* - start: "2023-05-14T10:00:00.000Z"
* end: "2023-05-14T11:00:00.000Z"
* title: "Team meeting between Alice and Bob"
* - start: "2023-05-15T14:00:00.000Z"
* end: "2023-05-15T15:00:00.000Z"
* title: "Project review between Carol and Dave"
* - start: "2023-05-16T09:00:00.000Z"
* end: "2023-05-16T10:00:00.000Z"
* - start: "2023-05-17T13:00:00.000Z"
* end: "2023-05-17T14:00:00.000Z"
* timeZone: "America/New_York"
* workingHours:
* - days: [1, 2, 3, 4, 5]
* startTime: 540
* endTime: 1020
* userId: 101
* dateOverrides:
* - date: "2023-05-15"
* startTime: 600
* endTime: 960
* userId: 101
* currentSeats: 4
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: User not found
*/
interface MemberRoles {
[userId: number | string]: MembershipRole;
+2 -2
View File
@@ -79,8 +79,8 @@ export default function AppListCard(props: AppListCardProps) {
}, []);
return (
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
<div className="flex items-center gap-x-3 px-5 py-4">
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
{logo ? (
<img
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
+232
View File
@@ -0,0 +1,232 @@
import { useCallback, useState } from "react";
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
import { InstallAppButton } from "@calcom/app-store/components";
import { getEventLocationTypeFromApp, type EventLocationType } from "@calcom/app-store/locations";
import type { CredentialOwner } from "@calcom/app-store/types";
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums";
import { trpc, type RouterOutputs } from "@calcom/trpc";
import type { App } from "@calcom/types/App";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuTrigger,
List,
showToast,
Button,
DropdownMenuItem,
Alert,
} from "@calcom/ui";
import { MoreHorizontal, Trash, Video } from "@calcom/ui/components/icon";
import AppListCard from "@components/AppListCard";
interface AppListProps {
variant?: AppCategories;
data: RouterOutputs["viewer"]["integrations"];
handleDisconnect: (credentialId: number) => void;
listClassName?: string;
}
export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppListProps) => {
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
const utils = trpc.useContext();
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
undefined
);
const onSuccessCallback = useCallback(() => {
setBulkUpdateModal(true);
showToast("Default app updated successfully", "success");
}, []);
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
onSuccess: () => {
showToast("Default app updated successfully", "success");
utils.viewer.getUsersDefaultConferencingApp.invalidate();
},
onError: (error) => {
showToast(`Error: ${error.message}`, "error");
},
});
const ChildAppCard = ({
item,
}: {
item: RouterOutputs["viewer"]["integrations"]["items"][number] & {
credentialOwner?: CredentialOwner;
};
}) => {
const appSlug = item?.slug;
const appIsDefault =
appSlug === defaultConferencingApp?.appSlug ||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
return (
<AppListCard
key={item.name}
description={item.description}
title={item.name}
logo={item.logo}
isDefault={appIsDefault}
shouldHighlight
slug={item.slug}
invalidCredential={item?.invalidCredentialIds ? item.invalidCredentialIds.length > 0 : false}
credentialOwner={item?.credentialOwner}
actions={
!item.credentialOwner?.readOnly ? (
<div className="flex justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="secondary"
StartIcon={Video}
onClick={() => {
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
if (locationType?.linkType === "static") {
setLocationType({ ...locationType, slug: appSlug });
} else {
updateDefaultAppMutation.mutate({
appSlug,
});
setBulkUpdateModal(true);
}
}}>
{t("set_as_default")}
</DropdownItem>
</DropdownMenuItem>
)}
<ConnectOrDisconnectIntegrationMenuItem
credentialId={item.credentialOwner?.credentialId || item.userCredentialIds[0]}
type={item.type}
isGlobal={item.isGlobal}
installed
invalidCredentialIds={item.invalidCredentialIds}
handleDisconnect={handleDisconnect}
teamId={item.credentialOwner ? item.credentialOwner?.teamId : undefined}
/>
</DropdownMenuContent>
</Dropdown>
</div>
) : null
}>
<AppSettings slug={item.slug} />
</AppListCard>
);
};
const appsWithTeamCredentials = data.items.filter((app) => app.teams.length);
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
const appCards = [];
if (app.userCredentialIds.length) {
appCards.push(<ChildAppCard item={app} />);
}
for (const team of app.teams) {
if (team) {
appCards.push(
<ChildAppCard
item={{
...app,
credentialOwner: {
name: team.name,
avatar: team.logo,
teamId: team.teamId,
credentialId: team.credentialId,
readOnly: !team.isAdmin,
},
}}
/>
);
}
}
return appCards;
});
const { t } = useLocale();
return (
<>
<List className={listClassName}>
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
{data.items
.filter((item) => item.invalidCredentialIds)
.map((item) => {
if (!item.teams.length) return <ChildAppCard item={item} />;
})}
</List>
{locationType && (
<AppSetDefaultLinkDialog
locationType={locationType}
setLocationType={() => setLocationType(undefined)}
onSuccess={onSuccessCallback}
/>
)}
{bulkUpdateModal && (
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
)}
</>
);
};
function ConnectOrDisconnectIntegrationMenuItem(props: {
credentialId: number;
type: App["type"];
isGlobal?: boolean;
installed?: boolean;
invalidCredentialIds?: number[];
teamId?: number;
handleDisconnect: (credentialId: number, teamId?: number) => void;
}) {
const { type, credentialId, isGlobal, installed, handleDisconnect, teamId } = props;
const { t } = useLocale();
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.viewer.integrations.invalidate();
};
if (credentialId || type === "stripe_payment" || isGlobal) {
return (
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => handleDisconnect(credentialId, teamId)}
disabled={isGlobal}
StartIcon={Trash}>
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
);
}
if (!installed) {
return (
<div className="flex items-center truncate">
<Alert severity="warning" title={t("not_installed")} />
</div>
);
}
return (
<InstallAppButton
type={type}
render={(buttonProps) => (
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
{t("install")}
</Button>
)}
onChanged={handleOpenChange}
/>
);
}
+1
View File
@@ -89,6 +89,7 @@ export const AppPage = ({
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
{ appType: type },
{
@@ -76,6 +76,7 @@ export const InstallAppButtonChild = ({
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
className="max-w-min"
onInteractOutside={(event) => {
if (mutation.isLoading) event.preventDefault();
}}>
+148 -112
View File
@@ -34,7 +34,7 @@ import {
TextField,
Tooltip,
} from "@calcom/ui";
import { Copy, Edit } from "@calcom/ui/components/icon";
import { Copy, Edit, Info } from "@calcom/ui/components/icon";
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
import RequiresConfirmationController from "./RequiresConfirmationController";
@@ -124,79 +124,81 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
const setEventName = (value: string) => formMethods.setValue("eventName", value);
return (
<div className="flex flex-col space-y-8">
<div className="flex flex-col space-y-4">
{/**
* Only display calendar selector if user has connected calendars AND if it's not
* a team event. Since we don't have logic to handle each attendee calendar (for now).
* This will fallback to each user selected destination calendar.
*/}
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
<div className="flex flex-col">
<div className="flex justify-between">
<Label>{t("add_to_calendar")}</Label>
<Link
href="/apps/categories/calendar"
target="_blank"
className="hover:text-emphasis text-default text-sm">
{t("add_another_calendar")}
</Link>
<div className="border-subtle space-y-6 rounded-md border p-6">
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
<div className="flex flex-col">
<div className="flex justify-between">
<Label className="font-medium">{t("add_to_calendar")}</Label>
<Link
href="/apps/categories/calendar"
target="_blank"
className="hover:text-emphasis text-default text-sm">
{t("add_another_calendar")}
</Link>
</div>
<div className="-mt-1 w-full">
<Controller
control={formMethods.control}
name="destinationCalendar"
defaultValue={eventType.destinationCalendar || undefined}
render={({ field: { onChange, value } }) => (
<DestinationCalendarSelector
destinationCalendar={eventType.destinationCalendar}
value={value ? value.externalId : undefined}
onChange={onChange}
hidePlaceholder
/>
)}
/>
</div>
<p className="text-subtle text-sm">{t("select_which_cal")}</p>
</div>
<div className="-mt-1 w-full">
<Controller
control={formMethods.control}
name="destinationCalendar"
defaultValue={eventType.destinationCalendar || undefined}
render={({ field: { onChange, value } }) => (
<DestinationCalendarSelector
destinationCalendar={eventType.destinationCalendar}
value={value ? value.externalId : undefined}
onChange={onChange}
hidePlaceholder
/>
)}
/>
</div>
<p className="text-default text-sm">{t("select_which_cal")}</p>
)}
<div className="w-full">
<TextField
label={t("event_name_in_calendar")}
type="text"
{...shouldLockDisableProps("eventName")}
placeholder={eventNamePlaceholder}
defaultValue={eventType.eventName || ""}
{...formMethods.register("eventName")}
addOnSuffix={
<Button
color="minimal"
size="sm"
aria-label="edit custom name"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
onClick={() => setShowEventNameTip((old) => !old)}>
<Edit className="h-4 w-4" />
</Button>
}
/>
</div>
)}
<div className="w-full">
<TextField
label={t("event_name_in_calendar")}
type="text"
{...shouldLockDisableProps("eventName")}
placeholder={eventNamePlaceholder}
defaultValue={eventType.eventName || ""}
{...formMethods.register("eventName")}
addOnSuffix={
<Button
color="minimal"
size="sm"
aria-label="edit custom name"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
onClick={() => setShowEventNameTip((old) => !old)}>
<Edit className="h-4 w-4" />
</Button>
}
</div>
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
<div className="border-subtle space-y-6 rounded-md border p-6">
<FormBuilder
title={t("booking_questions_title")}
description={t("booking_questions_description")}
addFieldLabel={t("add_a_booking_question")}
formProp="bookingFields"
{...shouldLockDisableProps("bookingFields")}
dataStore={{
options: {
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
},
}}
/>
</div>
<hr className="border-subtle [&:has(+div:empty)]:hidden" />
<div>
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
</div>
<hr className="border-subtle" />
<FormBuilder
title={t("booking_questions_title")}
description={t("booking_questions_description")}
addFieldLabel={t("add_a_booking_question")}
formProp="bookingFields"
{...shouldLockDisableProps("bookingFields")}
dataStore={{
options: {
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
},
}}
/>
<hr className="border-subtle" />
<RequiresConfirmationController
eventType={eventType}
seatsEnabled={seatsEnabled}
@@ -204,13 +206,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
requiresConfirmation={requiresConfirmation}
onRequiresConfirmation={setRequiresConfirmation}
/>
<hr className="border-subtle" />
<Controller
name="requiresBookerEmailVerification"
control={formMethods.control}
defaultValue={eventType.requiresBookerEmailVerification}
render={({ field: { value, onChange } }) => (
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
title={t("requires_booker_email_verification")}
{...shouldLockDisableProps("requiresBookerEmailVerification")}
description={t("description_requires_booker_email_verification")}
@@ -219,13 +223,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
/>
)}
/>
<hr className="border-subtle" />
<Controller
name="hideCalendarNotes"
control={formMethods.control}
defaultValue={eventType.hideCalendarNotes}
render={({ field: { value, onChange } }) => (
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
title={t("disable_notes")}
{...shouldLockDisableProps("hideCalendarNotes")}
description={t("disable_notes_description")}
@@ -234,13 +240,19 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
/>
)}
/>
<hr className="border-subtle" />
<Controller
name="successRedirectUrl"
control={formMethods.control}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
redirectUrlVisible && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("redirect_success_booking")}
{...successRedirectUrlLocked}
description={t("redirect_url_description")}
@@ -249,8 +261,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
setRedirectUrlVisible(e);
onChange(e ? value : "");
}}>
{/* Textfield has some margin by default we remove that so we can keep consistent alignment */}
<div className="lg:-mb-2 lg:-ml-2">
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<TextField
className="w-full"
label={t("redirect_success_booking")}
@@ -274,10 +285,24 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
</>
)}
/>
<hr className="border-subtle" />
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
hashedLinkVisible && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
data-testid="hashedLinkCheck"
title={t("private_link")}
Badge={
<a
target="_blank"
rel="noreferrer"
href="https://cal.com/docs/core-features/event-types/single-use-private-links">
<Info className="mb-2 ml-1.5 h-4 w-4 cursor-pointer" />
</a>
}
{...shouldLockDisableProps("hashedLinkCheck")}
description={t("private_link_description", { appName: APP_NAME })}
checked={hashedLinkVisible}
@@ -285,8 +310,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
setHashedLinkVisible(e);
}}>
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
<div className="lg:-ml-2">
<div className="border-subtle rounded-b-md border border-t-0 p-6">
{!IS_VISUAL_REGRESSION_TESTING && (
<TextField
disabled
@@ -321,7 +345,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
)}
</div>
</SettingsToggle>
<hr className="border-subtle" />
<Controller
name="seatsPerTimeSlotEnabled"
control={formMethods.control}
@@ -329,6 +353,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
value && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
data-testid="offer-seats-toggle"
title={t("offer_seats")}
{...seatsLocked}
@@ -349,45 +379,49 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
}
onChange(e);
}}>
<Controller
name="seatsPerTimeSlot"
control={formMethods.control}
defaultValue={eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<div className="lg:-ml-2">
<TextField
required
name="seatsPerTimeSlot"
labelSrOnly
label={t("number_of_seats")}
type="number"
disabled={seatsLocked.disabled}
defaultValue={value || 2}
min={1}
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
}}
/>
<div className="mt-2">
<CheckboxField
description={t("show_attendees")}
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<Controller
name="seatsPerTimeSlot"
control={formMethods.control}
defaultValue={eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<div className="lg:-ml-2">
<TextField
required
name="seatsPerTimeSlot"
labelSrOnly
label={t("number_of_seats")}
type="number"
disabled={seatsLocked.disabled}
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
defaultChecked={!!eventType.seatsShowAttendees}
defaultValue={value || 2}
min={1}
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
}}
/>
<div className="mt-2">
<CheckboxField
description={t("show_attendees")}
disabled={seatsLocked.disabled}
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
defaultChecked={!!eventType.seatsShowAttendees}
/>
</div>
<div className="mt-2">
<CheckboxField
description={t("show_available_seats_count")}
disabled={seatsLocked.disabled}
onChange={(e) =>
formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)
}
defaultChecked={!!eventType.seatsShowAvailabilityCount}
/>
</div>
</div>
<div className="mt-2">
<CheckboxField
description={t("show_available_seats_count")}
disabled={seatsLocked.disabled}
onChange={(e) => formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)}
defaultChecked={!!eventType.seatsShowAvailabilityCount}
/>
</div>
</div>
)}
/>
)}
/>
</div>
</SettingsToggle>
{noShowFeeEnabled && <Alert severity="warning" title={t("seats_and_no_show_fee_error")} />}
</>
@@ -395,13 +429,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
/>
{allowDisablingAttendeeConfirmationEmails(workflows) && (
<>
<hr className="border-subtle" />
<Controller
name="metadata.disableStandardEmails.confirmation.attendee"
control={formMethods.control}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
title={t("disable_attendees_confirmation_emails")}
description={t("disable_attendees_confirmation_emails_description")}
checked={value || false}
@@ -417,7 +452,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
)}
{allowDisablingHostConfirmationEmails(workflows) && (
<>
<hr className="border-subtle" />
<Controller
name="metadata.disableStandardEmails.confirmation.host"
control={formMethods.control}
@@ -425,6 +459,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
title={t("disable_host_confirmation_emails")}
description={t("disable_host_confirmation_emails_description")}
checked={value || false}
@@ -158,7 +158,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
</div>
</div>
{!shouldLockDisableProps("apps").disabled && (
<div className="bg-muted rounded-md p-8">
<div className="bg-muted mt-6 rounded-md p-8">
{!isLoading && notInstalledApps?.length ? (
<>
<h2 className="text-emphasis mb-2 text-xl font-semibold leading-5 tracking-[0.01em]">
@@ -166,7 +166,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
</h2>
<p className="text-default mb-6 text-sm font-normal">
<Trans i18nKey="available_apps_desc">
You have no apps installed. View popular apps below and explore more in our &nbsp;
View popular apps below and explore more in our &nbsp;
<Link className="cursor-pointer underline" href="/apps">
App Store
</Link>
@@ -1,5 +1,5 @@
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
import { useState, memo } from "react";
import { useState, memo, useEffect } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { OptionProps, SingleValueProps } from "react-select";
import { components } from "react-select";
@@ -98,42 +98,43 @@ const EventTypeScheduleDetails = memo(
schedule?.schedule.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
return (
<div className="border-default space-y-4 rounded border px-6 pb-4">
<ol className="table border-collapse text-sm">
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
const isAvailable = !!filterDays(index).length;
return (
<li key={day} className="my-6 flex border-transparent last:mb-2">
<span
className={classNames(
"w-20 font-medium sm:w-32 ",
!isAvailable ? "text-subtle line-through" : "text-default"
)}>
{day}
</span>
{isLoading ? (
<SkeletonText className="block h-5 w-60" />
) : isAvailable ? (
<div className="space-y-3 text-right">
{filterDays(index).map((dayRange, i) => (
<div key={i} className="text-default flex items-center leading-4">
<span className="w-16 sm:w-28 sm:text-left">
{format(dayRange.startTime, timeFormat === 12)}
</span>
<span className="ms-4">-</span>
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
</div>
))}
</div>
) : (
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
)}
</li>
);
})}
</ol>
<hr className="border-subtle" />
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
<div>
<div className="border-subtle space-y-4 border-x p-6">
<ol className="table border-collapse text-sm">
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
const isAvailable = !!filterDays(index).length;
return (
<li key={day} className="my-6 flex border-transparent last:mb-2">
<span
className={classNames(
"w-20 font-medium sm:w-32 ",
!isAvailable ? "text-subtle line-through" : "text-default"
)}>
{day}
</span>
{isLoading ? (
<SkeletonText className="block h-5 w-60" />
) : isAvailable ? (
<div className="space-y-3 text-right">
{filterDays(index).map((dayRange, i) => (
<div key={i} className="text-default flex items-center leading-4">
<span className="w-16 sm:w-28 sm:text-left">
{format(dayRange.startTime, timeFormat === 12)}
</span>
<span className="ms-4">-</span>
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
</div>
))}
</div>
) : (
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
)}
</li>
);
})}
</ol>
</div>
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
@@ -164,9 +165,8 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const { watch } = useFormContext<FormValues>();
const { watch, setValue, getValues } = useFormContext<FormValues>();
const watchSchedule = watch("schedule");
const formMethods = useFormContext<FormValues>();
const [options, setOptions] = useState<AvailabilityOption[]>([]);
const { isLoading } = trpc.viewer.availability.list.useQuery(undefined, {
@@ -214,7 +214,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
setOptions(options);
const scheduleId = formMethods.getValues("schedule");
const scheduleId = getValues("schedule");
const value = options.find((option) =>
scheduleId
? option.value === scheduleId
@@ -223,15 +223,20 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
);
formMethods.setValue("availability", value);
setValue("availability", value);
},
});
const availabilityValue = formMethods.watch("availability");
const availabilityValue = watch("availability");
useEffect(() => {
if (!availabilityValue?.value) return;
setValue("schedule", availabilityValue.value);
}, [availabilityValue, setValue]);
return (
<div className="space-y-4">
<div>
<div>
<div className="border-subtle rounded-t-md border p-6">
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
{t("availability")}
{shouldLockIndicator("availability")}
@@ -248,7 +253,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
isSearchable={false}
onChange={(selected) => {
field.onChange(selected?.value || null);
if (selected?.value) formMethods.setValue("availability", selected);
if (selected?.value) setValue("availability", selected);
}}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
value={availabilityValue}
@@ -276,7 +281,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
const { t } = useLocale();
const { resetField, setValue } = useFormContext<FormValues>();
const { setValue } = useFormContext<FormValues>();
return (
<Controller
name="metadata.config.useHostSchedulesForTeamEvent"
@@ -285,9 +290,7 @@ const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSe
checked={!value}
onCheckedChange={(checked) => {
onChange(!checked);
if (checked) {
resetField("schedule");
} else {
if (!checked) {
setValue("schedule", null);
}
}}
+203 -158
View File
@@ -17,7 +17,7 @@ import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/interval
import type { PeriodType } from "@calcom/prisma/enums";
import type { IntervalLimit } from "@calcom/types/Calendar";
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
import { Plus, Trash } from "@calcom/ui/components/icon";
import { Plus, Trash2 } from "@calcom/ui/components/icon";
const MinimumBookingNoticeInput = React.forwardRef<
HTMLInputElement,
@@ -83,14 +83,14 @@ const MinimumBookingNoticeInput = React.forwardRef<
type="number"
placeholder="0"
min={0}
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
className="mb-0 h-9 rounded-[4px] ltr:mr-2 rtl:ml-2"
/>
<input type="hidden" ref={ref} {...passThroughProps} />
</div>
<Select
isSearchable={false}
isDisabled={passThroughProps.disabled}
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
className="mb-0 ml-2 h-9 w-full capitalize md:min-w-[150px] md:max-w-[200px]"
defaultValue={durationTypeOptions.find(
(option) => option.value === minimumBookingNoticeDisplayValues.type
)}
@@ -170,8 +170,8 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + offsetStartValue * 60 * 1000);
return (
<div className="space-y-8">
<div className="space-y-4 lg:space-y-8">
<div>
<div className="border-subtle space-y-6 rounded-md 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">
@@ -295,159 +295,195 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
</div>
</div>
</div>
<hr className="border-subtle" />
<Controller
name="bookingLimits"
control={formMethods.control}
render={({ field: { value } }) => (
<SettingsToggle
title={t("limit_booking_frequency")}
{...bookingLimitsLocked}
description={t("limit_booking_frequency_description")}
checked={Object.keys(value ?? {}).length > 0}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("bookingLimits", {
PER_DAY: 1,
});
} else {
formMethods.setValue("bookingLimits", {});
}
}}>
<IntervalLimitsManager
disabled={bookingLimitsLocked.disabled}
propertyName="bookingLimits"
defaultLimit={1}
step={1}
/>
</SettingsToggle>
)}
render={({ field: { value } }) => {
const isChecked = Object.keys(value ?? {}).length > 0;
return (
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("limit_booking_frequency")}
{...bookingLimitsLocked}
description={t("limit_booking_frequency_description")}
checked={isChecked}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("bookingLimits", {
PER_DAY: 1,
});
} else {
formMethods.setValue("bookingLimits", {});
}
}}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}
childrenClassName="lg:ml-0">
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<IntervalLimitsManager
disabled={bookingLimitsLocked.disabled}
propertyName="bookingLimits"
defaultLimit={1}
step={1}
/>
</div>
</SettingsToggle>
);
}}
/>
<hr className="border-subtle" />
<Controller
name="durationLimits"
control={formMethods.control}
render={({ field: { value } }) => (
<SettingsToggle
title={t("limit_total_booking_duration")}
description={t("limit_total_booking_duration_description")}
{...durationLimitsLocked}
checked={Object.keys(value ?? {}).length > 0}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("durationLimits", {
PER_DAY: 60,
});
} else {
formMethods.setValue("durationLimits", {});
}
}}>
<IntervalLimitsManager
propertyName="durationLimits"
defaultLimit={60}
disabled={durationLimitsLocked.disabled}
step={15}
textFieldSuffix={t("minutes")}
/>
</SettingsToggle>
)}
render={({ field: { value } }) => {
const isChecked = Object.keys(value ?? {}).length > 0;
return (
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("limit_total_booking_duration")}
description={t("limit_total_booking_duration_description")}
{...durationLimitsLocked}
checked={isChecked}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("durationLimits", {
PER_DAY: 60,
});
} else {
formMethods.setValue("durationLimits", {});
}
}}>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<IntervalLimitsManager
propertyName="durationLimits"
defaultLimit={60}
disabled={durationLimitsLocked.disabled}
step={15}
textFieldSuffix={t("minutes")}
/>
</div>
</SettingsToggle>
);
}}
/>
<hr className="border-subtle" />
<Controller
name="periodType"
control={formMethods.control}
render={({ field: { value } }) => (
<SettingsToggle
title={t("limit_future_bookings")}
description={t("limit_future_bookings_description")}
{...periodTypeLocked}
checked={value && value !== "UNLIMITED"}
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
<RadioGroup.Root
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
{PERIOD_TYPES.filter((opt) =>
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
).map((period) => {
if (period.type === "UNLIMITED") return null;
return (
<div
className={classNames(
"text-default mb-2 flex flex-wrap items-center text-sm",
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>
)}
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
{period.type === "ROLLING" && (
<div className="flex items-center">
<TextField
labelSrOnly
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",
opt?.value.toString() as "0" | "1"
);
}}
defaultValue={
optionsPeriod.find(
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
) ?? optionsPeriod[0]
}
/>
</div>
)}
{period.type === "RANGE" && (
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
<Controller
name="periodDates"
control={formMethods.control}
defaultValue={periodDates}
render={() => (
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
render={({ field: { value } }) => {
const isChecked = value && value !== "UNLIMITED";
return (
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}
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-md border border-t-0 p-6">
<RadioGroup.Root
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
{PERIOD_TYPES.filter((opt) =>
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
).map((period) => {
if (period.type === "UNLIMITED") return null;
return (
<div
className={classNames(
"text-default mb-2 flex flex-wrap items-center text-sm",
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>
)}
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
{period.type === "ROLLING" && (
<div className="flex items-center">
<TextField
labelSrOnly
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}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
startDate,
endDate,
});
}}
{...formMethods.register("periodDays", { valueAsNumber: true })}
defaultValue={eventType.periodDays || 30}
/>
)}
/>
<Select
options={optionsPeriod}
isSearchable={false}
isDisabled={periodTypeLocked.disabled}
onChange={(opt) => {
formMethods.setValue(
"periodCountCalendarDays",
opt?.value.toString() as "0" | "1"
);
}}
defaultValue={
optionsPeriod.find(
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
) ?? optionsPeriod[0]
}
/>
</div>
)}
{period.type === "RANGE" && (
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
<Controller
name="periodDates"
control={formMethods.control}
defaultValue={periodDates}
render={() => (
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
disabled={periodTypeLocked.disabled}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
startDate,
endDate,
});
}}
/>
)}
/>
</div>
)}
{period.suffix ? <span className="me-2 ms-2">&nbsp;{period.suffix}</span> : null}
</div>
)}
{period.suffix ? <span className="me-2 ms-2">&nbsp;{period.suffix}</span> : null}
</div>
);
})}
</RadioGroup.Root>
</SettingsToggle>
)}
);
})}
</RadioGroup.Root>
</div>
</SettingsToggle>
);
}}
/>
<hr className="border-subtle" />
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
offsetToggle && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("offset_toggle")}
description={t("offset_toggle_description")}
{...offsetStartLockedProps}
@@ -458,18 +494,20 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
formMethods.setValue("offsetStart", 0);
}
}}>
<TextField
required
type="number"
{...offsetStartLockedProps}
label={t("offset_start")}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}
hint={t("offset_start_description", {
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
})}
/>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<TextField
required
type="number"
{...offsetStartLockedProps}
label={t("offset_start")}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}
hint={t("offset_start_description", {
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
})}
/>
</div>
</SettingsToggle>
</div>
);
@@ -509,19 +547,19 @@ const IntervalLimitItem = ({
onIntervalSelect,
}: IntervalLimitItemProps) => {
return (
<div className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
<div className="mb-4 flex max-h-9 items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
<TextField
required
type="number"
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
className="mb-0 !h-auto"
className="mb-0"
placeholder={`${value}`}
disabled={disabled}
min={step}
step={step}
defaultValue={value}
addOnSuffix={textFieldSuffix}
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value))}
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value || "0", 10))}
/>
<Select
options={selectOptions}
@@ -529,9 +567,16 @@ const IntervalLimitItem = ({
isDisabled={disabled}
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
onChange={onIntervalSelect}
className="w-36"
/>
{hasDeleteButton && !disabled && (
<Button variant="icon" StartIcon={Trash} color="destructive" onClick={() => onDelete(limitKey)} />
<Button
variant="icon"
StartIcon={Trash2}
color="destructive"
className="border-none"
onClick={() => onDelete(limitKey)}
/>
)}
</div>
);
+171 -164
View File
@@ -387,178 +387,185 @@ export const EventSetupTab = (
return (
<div>
<div className="space-y-8">
<TextField
required
label={t("title")}
{...shouldLockDisableProps("title")}
defaultValue={eventType.title}
{...formMethods.register("title")}
/>
<div>
<Label>
{t("description")}
{shouldLockIndicator("description")}
</Label>
<DescriptionEditor
description={eventType?.description}
editable={!descriptionLockedProps.disabled}
/>
</div>
<TextField
required
label={t("URL")}
{...shouldLockDisableProps("slug")}
defaultValue={eventType.slug}
addOnLeading={
<>
{urlPrefix}/
{!isManagedEventType
? team
? (orgBranding ? "" : "team/") + team.slug
: eventType.users[0].username
: t("username_placeholder")}
/
</>
}
{...formMethods.register("slug", {
setValueAs: (v) => slugify(v),
})}
/>
{multipleDuration ? (
<div className="space-y-4">
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("available_durations")}
</Skeleton>
<Select
isMulti
defaultValue={selectedMultipleDuration}
name="metadata.multipleDuration"
isSearchable={false}
className="h-auto !min-h-[36px] text-sm"
options={multipleDurationOptions}
value={selectedMultipleDuration}
onChange={(options) => {
let newOptions = [...options];
newOptions = newOptions.sort((a, b) => {
return a?.value - b?.value;
});
const values = newOptions.map((opt) => opt.value);
setMultipleDuration(values);
setSelectedMultipleDuration(newOptions);
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
if (newOptions.length > 0) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
} else {
setDefaultDuration(null);
}
}
if (newOptions.length === 1 && defaultDuration === null) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
}
formMethods.setValue("metadata.multipleDuration", values);
}}
/>
</div>
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("default_duration")}
{shouldLockIndicator("length")}
</Skeleton>
<Select
value={defaultDuration}
isSearchable={false}
name="length"
className="text-sm"
isDisabled={lengthLockedProps.disabled}
noOptionsMessage={() => t("default_duration_no_options")}
options={selectedMultipleDuration}
onChange={(option) => {
setDefaultDuration(
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
);
if (option) formMethods.setValue("length", option.value);
}}
/>
</div>
</div>
) : (
<div className="space-y-4">
<div className="border-subtle space-y-6 rounded-md border p-6">
<TextField
required
type="number"
{...lengthLockedProps}
label={t("duration")}
defaultValue={eventType.length ?? 15}
{...formMethods.register("length")}
addOnSuffix={<>{t("minutes")}</>}
min={1}
label={t("title")}
{...shouldLockDisableProps("title")}
defaultValue={eventType.title}
{...formMethods.register("title")}
/>
)}
{!lengthLockedProps.disabled && (
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
<SettingsToggle
title={t("allow_booker_to_select_duration")}
checked={multipleDuration !== undefined}
disabled={seatsEnabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
onCheckedChange={() => {
if (multipleDuration !== undefined) {
setMultipleDuration(undefined);
formMethods.setValue("metadata.multipleDuration", undefined);
formMethods.setValue("length", eventType.length);
} else {
setMultipleDuration([]);
formMethods.setValue("metadata.multipleDuration", []);
formMethods.setValue("length", 0);
}
}}
<div>
<Label>
{t("description")}
{shouldLockIndicator("description")}
</Label>
<DescriptionEditor
description={eventType?.description}
editable={!descriptionLockedProps.disabled}
/>
</div>
)}
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("location")}
{shouldLockIndicator("locations")}
</Skeleton>
<Controller
name="locations"
control={formMethods.control}
defaultValue={eventType.locations || []}
render={() => <Locations />}
<TextField
required
label={t("URL")}
{...shouldLockDisableProps("slug")}
defaultValue={eventType.slug}
addOnLeading={
<>
{urlPrefix}/
{!isManagedEventType
? team
? (orgBranding ? "" : "team/") + team.slug
: eventType.users[0].username
: t("username_placeholder")}
/
</>
}
{...formMethods.register("slug", {
setValueAs: (v) => slugify(v),
})}
/>
</div>
</div>
<div className="border-subtle rounded-md border p-6">
{multipleDuration ? (
<div className="space-y-6">
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("available_durations")}
</Skeleton>
<Select
isMulti
defaultValue={selectedMultipleDuration}
name="metadata.multipleDuration"
isSearchable={false}
className="h-auto !min-h-[36px] text-sm"
options={multipleDurationOptions}
value={selectedMultipleDuration}
onChange={(options) => {
let newOptions = [...options];
newOptions = newOptions.sort((a, b) => {
return a?.value - b?.value;
});
const values = newOptions.map((opt) => opt.value);
setMultipleDuration(values);
setSelectedMultipleDuration(newOptions);
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
if (newOptions.length > 0) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
} else {
setDefaultDuration(null);
}
}
if (newOptions.length === 1 && defaultDuration === null) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
}
formMethods.setValue("metadata.multipleDuration", values);
}}
/>
</div>
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("default_duration")}
{shouldLockIndicator("length")}
</Skeleton>
<Select
value={defaultDuration}
isSearchable={false}
name="length"
className="text-sm"
isDisabled={lengthLockedProps.disabled}
noOptionsMessage={() => t("default_duration_no_options")}
options={selectedMultipleDuration}
onChange={(option) => {
setDefaultDuration(
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
);
if (option) formMethods.setValue("length", option.value);
}}
/>
</div>
</div>
) : (
<TextField
required
type="number"
{...lengthLockedProps}
label={t("duration")}
defaultValue={eventType.length ?? 15}
{...formMethods.register("length")}
addOnSuffix={<>{t("minutes")}</>}
min={1}
/>
)}
{!lengthLockedProps.disabled && (
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
<SettingsToggle
title={t("allow_booker_to_select_duration")}
checked={multipleDuration !== undefined}
disabled={seatsEnabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
onCheckedChange={() => {
if (multipleDuration !== undefined) {
setMultipleDuration(undefined);
formMethods.setValue("metadata.multipleDuration", undefined);
formMethods.setValue("length", eventType.length);
} else {
setMultipleDuration([]);
formMethods.setValue("metadata.multipleDuration", []);
formMethods.setValue("length", 0);
}
}}
/>
</div>
)}
</div>
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
<EditLocationDialog
isOpenDialog={showLocationModal}
setShowLocationModal={setShowLocationModal}
saveLocation={saveLocation}
defaultValues={formMethods.getValues("locations")}
selection={
selectedLocation
? selectedLocation.address
? {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
address: selectedLocation.address,
}
: {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
}
: undefined
}
setSelectedLocation={setSelectedLocation}
setEditingLocationType={setEditingLocationType}
teamId={eventType.team?.id}
/>
<div className="border-subtle rounded-md border p-6">
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("location")}
{shouldLockIndicator("locations")}
</Skeleton>
<Controller
name="locations"
control={formMethods.control}
defaultValue={eventType.locations || []}
render={() => <Locations />}
/>
</div>
</div>
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
<EditLocationDialog
isOpenDialog={showLocationModal}
setShowLocationModal={setShowLocationModal}
saveLocation={saveLocation}
defaultValues={formMethods.getValues("locations")}
selection={
selectedLocation
? selectedLocation.address
? {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
address: selectedLocation.address,
}
: {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
}
: undefined
}
setSelectedLocation={setSelectedLocation}
setEditingLocationType={setEditingLocationType}
teamId={eventType.team?.id}
/>
</div>
</div>
);
};
@@ -1,5 +1,7 @@
import type { Webhook } from "@prisma/client";
import { Webhook as TbWebhook } from "lucide-react";
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useState } from "react";
@@ -8,6 +10,7 @@ import { WebhookForm } from "@calcom/features/webhooks/components";
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
@@ -115,23 +118,40 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
)}
{webhooks.length ? (
<>
<div className="mb-2 rounded-md border">
{webhooks.map((webhook, index) => {
return (
<WebhookListItem
key={webhook.id}
webhook={webhook}
lastItem={webhooks.length === index + 1}
canEditWebhook={!webhookLockedStatus.disabled}
onEditWebhook={() => {
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
/>
);
})}
<div className="border-subtle mb-2 rounded-md border p-8">
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
{t("add_webhook_description", { appName: APP_NAME })}
</p>
<div className="border-subtle mt-8 rounded-md border">
{webhooks.map((webhook, index) => {
return (
<WebhookListItem
key={webhook.id}
webhook={webhook}
lastItem={webhooks.length === index + 1}
canEditWebhook={!webhookLockedStatus.disabled}
onEditWebhook={() => {
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
/>
);
})}
</div>
<p className="text-default mt-8 text-sm font-normal">
<Trans i18nKey="edit_or_manage_webhooks">
If you wish to edit or manage your web hooks, please head over to &nbsp;
<Link
className="cursor-pointer font-semibold underline"
href="/settings/developer/webhooks">
webhooks settings
</Link>
</Trans>
</p>
</div>
<NewWebhookButton />
</>
) : (
<EmptyScreen
@@ -3,6 +3,7 @@ import { useState } from "react";
import { useFormContext } from "react-hook-form";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Frequency } from "@calcom/prisma/zod-utils";
import type { RecurringEvent } from "@calcom/types/Calendar";
@@ -47,6 +48,12 @@ export default function RecurringEventController({
) : (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
recurringEventState !== null && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("recurring_event")}
{...recurringLocked}
description={t("recurring_event_description")}
@@ -66,68 +73,70 @@ export default function RecurringEventController({
setRecurringEventState(newVal);
}
}}>
{recurringEventState && (
<div data-testid="recurring-event-collapsible" className="text-sm">
<div className="flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
className="mb-0"
defaultValue={recurringEventState.interval}
onChange={(event) => {
const newVal = {
...recurringEventState,
interval: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<Select
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventState.freq]}
isSearchable={false}
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
isDisabled={recurringLocked.disabled}
onChange={(event) => {
const newVal = {
...recurringEventState,
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
{recurringEventState && (
<div data-testid="recurring-event-collapsible" className="text-sm">
<div className="flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
className="mb-0"
defaultValue={recurringEventState.interval}
onChange={(event) => {
const newVal = {
...recurringEventState,
interval: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<Select
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventState.freq]}
isSearchable={false}
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
isDisabled={recurringLocked.disabled}
onChange={(event) => {
const newVal = {
...recurringEventState,
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
</div>
<div className="mt-4 flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
defaultValue={recurringEventState.count}
className="mb-0"
onChange={(event) => {
const newVal = {
...recurringEventState,
count: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
{t("events", {
count: recurringEventState.count,
})}
</p>
</div>
</div>
<div className="mt-4 flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
defaultValue={recurringEventState.count}
className="mb-0"
onChange={(event) => {
const newVal = {
...recurringEventState,
count: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
{t("events", {
count: recurringEventState.count,
})}
</p>
</div>
</div>
)}
)}
</div>
</SettingsToggle>
</>
)}
@@ -67,6 +67,12 @@ export default function RequiresConfirmationController({
control={formMethods.control}
render={() => (
<SettingsToggle
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-md border py-6 px-4 sm:px-6",
requiresConfirmation && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("requires_confirmation")}
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
@@ -77,107 +83,111 @@ export default function RequiresConfirmationController({
formMethods.setValue("requiresConfirmation", val);
onRequiresConfirmation(val);
}}>
<RadioGroup.Root
defaultValue={
requiresConfirmation
? requiresConfirmationSetup === undefined
? "always"
: "notice"
: undefined
}
onValueChange={(val) => {
if (val === "always") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
setRequiresConfirmationSetup(undefined);
} else if (val === "notice") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue(
"metadata.requiresConfirmationThreshold",
requiresConfirmationSetup || defaultRequiresConfirmationSetup
);
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<RadioGroup.Root
defaultValue={
requiresConfirmation
? requiresConfirmationSetup === undefined
? "always"
: "notice"
: undefined
}
}}>
<div className="flex flex-col flex-wrap justify-start gap-y-2">
{(requiresConfirmationSetup === undefined || !requiresConfirmationLockedProps.disabled) && (
<RadioField
label={t("always_requires_confirmation")}
disabled={requiresConfirmationLockedProps.disabled}
id="always"
value="always"
/>
)}
{(requiresConfirmationSetup !== undefined || !requiresConfirmationLockedProps.disabled) && (
<RadioField
disabled={requiresConfirmationLockedProps.disabled}
className="items-center"
label={
<>
<Trans
i18nKey="when_booked_with_less_than_notice"
defaults="When booked with less than <time></time> notice"
components={{
time: (
<div className="mx-2 inline-flex">
<Input
type="number"
min={1}
disabled={requiresConfirmationLockedProps.disabled}
onChange={(evt) => {
const val = Number(evt.target?.value);
setRequiresConfirmationSetup({
unit:
requiresConfirmationSetup?.unit ??
defaultRequiresConfirmationSetup.unit,
time: val,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.time",
val
);
}}
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
/>
<label
className={classNames(
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
)}>
<Select
inputId="notice"
options={options}
isSearchable={false}
isDisabled={requiresConfirmationLockedProps.disabled}
className="ml-2"
onChange={(opt) => {
onValueChange={(val) => {
if (val === "always") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
setRequiresConfirmationSetup(undefined);
} else if (val === "notice") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue(
"metadata.requiresConfirmationThreshold",
requiresConfirmationSetup || defaultRequiresConfirmationSetup
);
}
}}>
<div className="flex flex-col flex-wrap justify-start gap-y-2">
{(requiresConfirmationSetup === undefined ||
!requiresConfirmationLockedProps.disabled) && (
<RadioField
label={t("always_requires_confirmation")}
disabled={requiresConfirmationLockedProps.disabled}
id="always"
value="always"
/>
)}
{(requiresConfirmationSetup !== undefined ||
!requiresConfirmationLockedProps.disabled) && (
<RadioField
disabled={requiresConfirmationLockedProps.disabled}
className="items-center"
label={
<>
<Trans
i18nKey="when_booked_with_less_than_notice"
defaults="When booked with less than <time></time> notice"
components={{
time: (
<div className="mx-2 inline-flex">
<Input
type="number"
min={1}
disabled={requiresConfirmationLockedProps.disabled}
onChange={(evt) => {
const val = Number(evt.target?.value);
setRequiresConfirmationSetup({
time:
requiresConfirmationSetup?.time ??
defaultRequiresConfirmationSetup.time,
unit: opt?.value as UnitTypeLongPlural,
unit:
requiresConfirmationSetup?.unit ??
defaultRequiresConfirmationSetup.unit,
time: val,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.unit",
opt?.value as UnitTypeLongPlural
"metadata.requiresConfirmationThreshold.time",
val
);
}}
defaultValue={defaultValue}
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield]"
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
/>
</label>
</div>
),
}}
/>
</>
}
id="notice"
value="notice"
/>
)}
</div>
</RadioGroup.Root>
<label
className={classNames(
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
)}>
<Select
inputId="notice"
options={options}
isSearchable={false}
isDisabled={requiresConfirmationLockedProps.disabled}
innerClassNames={{ control: "rounded-l-none bg-subtle" }}
onChange={(opt) => {
setRequiresConfirmationSetup({
time:
requiresConfirmationSetup?.time ??
defaultRequiresConfirmationSetup.time,
unit: opt?.value as UnitTypeLongPlural,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.unit",
opt?.value as UnitTypeLongPlural
);
}}
defaultValue={defaultValue}
/>
</label>
</div>
),
}}
/>
</>
}
id="notice"
value="notice"
/>
)}
</div>
</RadioGroup.Root>
</div>
</SettingsToggle>
)}
/>
@@ -1,9 +1,10 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
@@ -22,7 +23,7 @@ const UserSettings = (props: IUserSettingsProps) => {
const { nextStep } = props;
const [user] = trpc.viewer.me.useSuspenseQuery();
const { t } = useLocale();
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
const telemetry = useTelemetry();
const userSettingsSchema = z.object({
name: z
+5 -4
View File
@@ -10,7 +10,6 @@ import { Avatar } from "@calcom/ui";
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
type MembersType = TeamType["members"];
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username"> & { safeBio: string | null };
type TeamTypeWithSafeHtml = Omit<TeamType, "members" | "inviteToken"> & { members: MemberType[] };
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
const routerQuery = useRouterQuery();
@@ -50,7 +49,9 @@ const Members = ({ members, teamName }: { members: MemberType[]; teamName: strin
}
return (
<section className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
<section
data-testid="team-members-container"
className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
{members.map((member) => {
return member.username !== null && <Member key={member.id} member={member} teamName={teamName} />;
})}
@@ -58,10 +59,10 @@ const Members = ({ members, teamName }: { members: MemberType[]; teamName: strin
);
};
const Team = ({ team }: { team: TeamTypeWithSafeHtml }) => {
const Team = ({ members, teamName }: { members: MemberType[]; teamName: string | null }) => {
return (
<div>
<Members members={team.members} teamName={team.name} />
<Members members={members} teamName={teamName} />
</div>
);
};
+2 -1
View File
@@ -26,7 +26,8 @@ const initClock = () => {
// This only sets browser locale if there's no preference on localStorage.
if (getIs24hClockFromLocalStorage() === null) set24hClock(isBrowserLocale24h());
timeOptions.is24hClock = !!getIs24hClockFromLocalStorage();
timeOptions.inviteeTimeZone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
timeOptions.inviteeTimeZone =
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London";
};
const is24h = (is24hClock?: boolean) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.3.3",
"version": "3.3.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+2 -5
View File
@@ -250,11 +250,8 @@ export default function Custom404() {
) : IS_CALCOM ? (
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
<strong className="text-blue-500">
{new URL(WEBSITE_URL).hostname}
{username}
</strong>{" "}
{t("is_still_available")} <span className="text-blue-500">{t("register_now")}</span>.
<strong className="text-blue-500">{username}</strong> {t("is_still_available")}{" "}
<span className="text-blue-500">{t("register_now")}</span>.
</a>
) : (
<span className="mt-2 inline-block text-lg">
+2 -3
View File
@@ -10,8 +10,7 @@ import {
getMultipleDurationValue,
} from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
@@ -152,7 +151,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const user = await prisma.user.findFirst({
where: {
username,
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
organization: userOrgQuery(context.req.headers.host ?? "", context.params?.orgSlug),
},
select: {
away: true,
+5 -1
View File
@@ -29,7 +29,11 @@ class MyDocument extends Document<Props> {
const asPath = ctx.asPath || "";
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
const parsedUrl = new URL(asPath, "https://dummyurl");
const isEmbed = parsedUrl.pathname.endsWith("/embed") || parsedUrl.searchParams.get("embedType") !== null;
const isEmbedSnippetGeneratorPath = parsedUrl.pathname.startsWith("/event-types");
// FIXME: Revisit this logic to remove embedType query param check completely. Ideally, /embed should always be there at the end of the URL. Test properly and then remove it.
const isEmbed =
(parsedUrl.pathname.endsWith("/embed") || parsedUrl.searchParams.get("embedType") !== null) &&
!isEmbedSnippetGeneratorPath;
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
const initialProps = await Document.getInitialProps(ctx);
return { isEmbed, embedColorScheme, nonce, ...initialProps };
+16
View File
@@ -130,6 +130,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Accept any child team invites for orgs and create a membership for the org itself
if (team.parentId) {
// Create (when invite link is used) or Update (when regular email invitation is used) membership for the organization itself
await prisma.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.parentId },
},
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.parentId,
accepted: true,
role: MembershipRole.MEMBER,
},
});
// We do a membership update twice so we can join the ORG invite if the user is invited to a team witin a ORG
await prisma.membership.updateMany({
where: {
@@ -0,0 +1 @@
export { default, config } from "@calcom/app-store/alby/api/webhook";
@@ -1 +1 @@
export { default, config } from "@calcom/features/ee/payments/api/paypal-webhook";
export { default, config } from "@calcom/app-store/paypal/api/webhook";
@@ -1,4 +0,0 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { kycVerificationRouter } from "@calcom/trpc/server/routers/viewer/kycVerification/_router";
export default createNextApiHandler(kycVerificationRouter);
+3 -8
View File
@@ -15,12 +15,10 @@ const appCredentialWebhookRequestBodySchema = z.object({
});
/** */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Check that credential sharing is enabled
if (!APP_CREDENTIAL_SHARING_ENABLED) {
return res.status(403).json({ message: "Credential sharing is not enabled" });
}
// Check that the webhook secret matches
if (
req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
process.env.CALCOM_WEBHOOK_SECRET
@@ -30,7 +28,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);
// Check that the user exists
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
if (!user) {
@@ -39,26 +36,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const app = await prisma.app.findUnique({
where: { slug: reqBody.appSlug },
select: { slug: true },
select: { dirName: true },
});
if (!app) {
return res.status(404).json({ message: "App not found" });
}
// Search for the app's slug and type
const appMetadata = appStoreMetadata[app.slug as keyof typeof appStoreMetadata];
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
if (!appMetadata) {
return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" });
}
// Decrypt the keys
const keys = JSON.parse(
symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
// Can't use prisma upsert as we don't know the id of the credential
// INFO: Can't use prisma upsert as we don't know the id of the credential
const appCredential = await prisma.credential.findFirst({
where: {
userId: reqBody.userId,
+4 -11
View File
@@ -1,14 +1,14 @@
import type { GetStaticPaths, InferGetStaticPropsType } from "next";
import type { InferGetServerSidePropsType } from "next";
import { useSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
import { getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import { HeadSeo } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const searchParams = useSearchParams();
const router = useRouter();
const slug = searchParams?.get("slug") as string;
@@ -36,11 +36,4 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
SetupInformation.PageWrapper = PageWrapper;
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: [],
fallback: "blocking",
};
};
export { getStaticProps };
export { getServerSideProps };
+6 -234
View File
@@ -1,35 +1,13 @@
import { useSearchParams } from "next/navigation";
import { useCallback, useReducer, useState } from "react";
import z from "zod";
import { useReducer } from "react";
import { z } from "zod";
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
import { InstallAppButton } from "@calcom/app-store/components";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
import type { CredentialOwner } from "@calcom/app-store/types";
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AppCategories } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { App } from "@calcom/types/App";
import type { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProps";
import {
Alert,
Button,
EmptyScreen,
List,
AppSkeletonLoader as SkeletonLoader,
ShellSubHeading,
DropdownMenuTrigger,
DropdownMenuContent,
Dropdown,
DropdownMenuItem,
DropdownItem,
showToast,
} from "@calcom/ui";
import { Button, EmptyScreen, AppSkeletonLoader as SkeletonLoader, ShellSubHeading } from "@calcom/ui";
import type { LucideIcon } from "@calcom/ui/components/icon";
import {
BarChart,
@@ -38,231 +16,24 @@ import {
CreditCard,
Grid,
Mail,
MoreHorizontal,
Plus,
Share2,
Trash,
Video,
} from "@calcom/ui/components/icon";
import { QueryCell } from "@lib/QueryCell";
import AppListCard from "@components/AppListCard";
import PageWrapper from "@components/PageWrapper";
import { AppList } from "@components/apps/AppList";
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
function ConnectOrDisconnectIntegrationMenuItem(props: {
credentialId: number;
type: App["type"];
isGlobal?: boolean;
installed?: boolean;
invalidCredentialIds?: number[];
teamId?: number;
handleDisconnect: (credentialId: number, teamId?: number) => void;
}) {
const { type, credentialId, isGlobal, installed, handleDisconnect, teamId } = props;
const { t } = useLocale();
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.viewer.integrations.invalidate();
};
if (credentialId || type === "stripe_payment" || isGlobal) {
return (
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => handleDisconnect(credentialId, teamId)}
disabled={isGlobal}
StartIcon={Trash}>
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
);
}
if (!installed) {
return (
<div className="flex items-center truncate">
<Alert severity="warning" title={t("not_installed")} />
</div>
);
}
return (
<InstallAppButton
type={type}
render={(buttonProps) => (
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
{t("install")}
</Button>
)}
onChanged={handleOpenChange}
/>
);
}
interface IntegrationsContainerProps {
variant?: AppCategories;
exclude?: AppCategories[];
handleDisconnect: (credentialId: number) => void;
}
interface IntegrationsListProps {
variant?: IntegrationsContainerProps["variant"];
data: RouterOutputs["viewer"]["integrations"];
handleDisconnect: (credentialId: number) => void;
}
const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListProps) => {
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
const utils = trpc.useContext();
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
undefined
);
const onSuccessCallback = useCallback(() => {
setBulkUpdateModal(true);
showToast("Default app updated successfully", "success");
}, []);
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
onSuccess: () => {
showToast("Default app updated successfully", "success");
utils.viewer.getUsersDefaultConferencingApp.invalidate();
},
onError: (error) => {
showToast(`Error: ${error.message}`, "error");
},
});
const ChildAppCard = ({
item,
}: {
item: RouterOutputs["viewer"]["integrations"]["items"][number] & {
credentialOwner?: CredentialOwner;
};
}) => {
const appSlug = item?.slug;
const appIsDefault =
appSlug === defaultConferencingApp?.appSlug ||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
return (
<AppListCard
key={item.name}
description={item.description}
title={item.name}
logo={item.logo}
isDefault={appIsDefault}
shouldHighlight
slug={item.slug}
invalidCredential={item?.invalidCredentialIds ? item.invalidCredentialIds.length > 0 : false}
credentialOwner={item?.credentialOwner}
actions={
!item.credentialOwner?.readOnly ? (
<div className="flex justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="secondary"
StartIcon={Video}
onClick={() => {
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
if (locationType?.linkType === "static") {
setLocationType({ ...locationType, slug: appSlug });
} else {
updateDefaultAppMutation.mutate({
appSlug,
});
setBulkUpdateModal(true);
}
}}>
{t("set_as_default")}
</DropdownItem>
</DropdownMenuItem>
)}
<ConnectOrDisconnectIntegrationMenuItem
credentialId={item.credentialOwner?.credentialId || item.userCredentialIds[0]}
type={item.type}
isGlobal={item.isGlobal}
installed
invalidCredentialIds={item.invalidCredentialIds}
handleDisconnect={handleDisconnect}
teamId={item.credentialOwner ? item.credentialOwner?.teamId : undefined}
/>
</DropdownMenuContent>
</Dropdown>
</div>
) : null
}>
<AppSettings slug={item.slug} />
</AppListCard>
);
};
const appsWithTeamCredentials = data.items.filter((app) => app.teams.length);
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
const appCards = [];
if (app.userCredentialIds.length) {
appCards.push(<ChildAppCard item={app} />);
}
for (const team of app.teams) {
if (team) {
appCards.push(
<ChildAppCard
item={{
...app,
credentialOwner: {
name: team.name,
avatar: team.logo,
teamId: team.teamId,
credentialId: team.credentialId,
readOnly: !team.isAdmin,
},
}}
/>
);
}
}
return appCards;
});
const { t } = useLocale();
return (
<>
<List>
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
{data.items
.filter((item) => item.invalidCredentialIds)
.map((item) => {
if (!item.teams.length) return <ChildAppCard item={item} />;
})}
</List>
{locationType && (
<AppSetDefaultLinkDialog
locationType={locationType}
setLocationType={() => setLocationType(undefined)}
onSuccess={onSuccessCallback}
/>
)}
{bulkUpdateModal && (
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
)}
</>
);
};
const IntegrationsContainer = ({
variant,
exclude,
@@ -329,7 +100,8 @@ const IntegrationsContainer = ({
</Button>
}
/>
<IntegrationsList handleDisconnect={handleDisconnect} data={data} variant={variant} />
<AppList handleDisconnect={handleDisconnect} data={data} variant={variant} />
</div>
);
}}
+14 -8
View File
@@ -60,10 +60,16 @@ export default function Verify() {
const routerQuery = useRouterQuery();
const { t, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
const [secondsLeft, setSecondsLeft] = useState(30);
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery({
stripeCustomerId,
checkoutSessionId: sessionId,
});
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery(
{
stripeCustomerId,
checkoutSessionId: sessionId,
},
{
enabled: !!stripeCustomerId || !!sessionId,
staleTime: Infinity,
}
);
useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username });
// @note: check for t=timestamp and apply disabled state and secondsLeft accordingly
// to avoid refresh to skip waiting 30 seconds to re-send email
@@ -123,12 +129,12 @@ export default function Verify() {
{hasPaymentFailed ? (
<AlertTriangle className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
) : sessionId ? (
<Check className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
<Check className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight dark:text-white" />
) : (
<MailOpen className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
)}
</div>
<h3 className="font-cal my-6 text-3xl font-normal">
<h3 className="font-cal text-inverted my-6 text-3xl font-normal dark:text-white">
{hasPaymentFailed
? "Your payment failed"
: sessionId
@@ -138,7 +144,7 @@ export default function Verify() {
{hasPaymentFailed && (
<p className="my-6">Your account has been created, but your premium has not been reserved.</p>
)}
<p>
<p className="text-inverted dark:text-white">
We have sent an email to <b>{customer?.email} </b>with a link to activate your account.{" "}
{hasPaymentFailed &&
"Once you activate your account you will be able to try purchase your premium username again or select a different one."}
@@ -158,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);
const _searchParams = new URLSearchParams(searchParams.toString());
_searchParams.set("t", `${Date.now()}`);
router.replace(`${pathname}?${_searchParams.toString()}`);
return await sendVerificationLogin(customer.email, customer.username);
+15 -10
View File
@@ -23,6 +23,7 @@ import {
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import { SMS_REMINDER_NUMBER_FIELD, SystemField } from "@calcom/features/bookings/lib/SystemField";
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
@@ -93,7 +94,7 @@ const querySchema = z.object({
});
export default function Success(props: SuccessProps) {
const { t, i18n } = useLocale();
const { t } = useLocale();
const router = useRouter();
const routerQuery = useRouterQuery();
const pathname = usePathname();
@@ -132,7 +133,9 @@ export default function Success(props: SuccessProps) {
const isGmail = !!attendees.find((attendee) => attendee.email.includes("gmail.com"));
const [is24h, setIs24h] = useState(isBrowserLocale24h());
const [is24h, setIs24h] = useState(
props?.userTimeFormat ? props.userTimeFormat === 24 : isBrowserLocale24h()
);
const { data: session } = useSession();
const [date, setDate] = useState(dayjs.utc(props.bookingInfo.startTime));
@@ -214,8 +217,10 @@ export default function Success(props: SuccessProps) {
confirmed: !needsConfirmation,
// TODO: Add payment details
});
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
setIs24h(!!getIs24hClockFromLocalStorage());
setDate(
date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London")
);
setIs24h(props?.userTimeFormat ? props.userTimeFormat === 24 : !!getIs24hClockFromLocalStorage());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventType, needsConfirmation]);
@@ -353,10 +358,10 @@ export default function Success(props: SuccessProps) {
<img src={giphyImage} alt="Gif from Giphy" />
)}
{!giphyImage && !needsConfirmation && !isCancelled && (
<Check className="h-5 w-5 text-green-600" />
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
)}
{needsConfirmation && !isCancelled && <Calendar className="text-emphasis h-5 w-5" />}
{isCancelled && <X className="h-5 w-5 text-red-600" />}
{isCancelled && <X className="h-5 w-5 text-red-600 dark:text-red-200" />}
</div>
<div className="mb-8 mt-6 text-center last:mb-0">
<h3
@@ -486,10 +491,7 @@ export default function Success(props: SuccessProps) {
: t("payment")}
</div>
<div className="col-span-2 mb-2 mt-3">
{new Intl.NumberFormat(i18n.language, {
style: "currency",
currency: props.paymentStatus.currency,
}).format(props.paymentStatus.amount / 100.0)}
<Price currency={props.paymentStatus.currency} price={props.paymentStatus.amount} />
</div>
</>
)}
@@ -1006,10 +1008,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const session = await getServerSession(context);
let tz: string | null = null;
let userTimeFormat: number | null = null;
if (session) {
const user = await ssr.viewer.me.fetch();
tz = user.timeZone;
userTimeFormat = user.timeFormat;
}
const parsedQuery = querySchema.safeParse(context.query);
@@ -1150,6 +1154,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
bookingInfo,
paymentStatus: payment,
...(tz && { tz }),
userTimeFormat,
},
};
}
+2 -1
View File
@@ -449,7 +449,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
availability={availability}
isUpdateMutationLoading={updateMutation.isLoading}
formMethods={formMethods}
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
// disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
disableBorder={true}
currentUserMembership={currentUserMembership}
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
<Form
+10 -6
View File
@@ -127,7 +127,7 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
readOnly={events[0].metadata.readOnly}
/>
) : (
<CreateFirstEventTypeView />
<CreateFirstEventTypeView slug={eventTypeGroups[0].profile.slug ?? ""} />
)}
</div>
);
@@ -363,7 +363,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
}, []);
if (!types.length) {
return group.teamId ? <EmptyEventTypeList group={group} /> : <CreateFirstEventTypeView />;
return group.teamId ? (
<EmptyEventTypeList group={group} />
) : (
<CreateFirstEventTypeView slug={group.profile.slug ?? ""} />
);
}
const firstItem = types[0];
@@ -764,7 +768,7 @@ const EventTypeListHeading = ({
);
};
const CreateFirstEventTypeView = () => {
const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
const { t } = useLocale();
return (
@@ -774,7 +778,7 @@ const CreateFirstEventTypeView = () => {
description={t("new_event_type_description")}
className="mb-16"
buttonRaw={
<Button href="?dialog=new" variant="button">
<Button href={`?dialog=new&eventPage=${slug}`} variant="button">
{t("create")}
</Button>
}
@@ -915,7 +919,7 @@ const Main = ({
) : group.teamId ? (
<EmptyEventTypeList group={group} />
) : (
<CreateFirstEventTypeView />
<CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />
)}
</div>
))
@@ -931,7 +935,7 @@ const Main = ({
/>
)
)}
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />}
<EventTypeEmbedDialog />
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
</>
@@ -1,11 +0,0 @@
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "@components/auth/layouts/AdminLayout";
import KYCVerificationView from "./kycVerificationView";
const KYCVerificationPage = () => <KYCVerificationView />;
KYCVerificationPage.getLayout = getLayout;
KYCVerificationPage.PageWrapper = PageWrapper;
export default KYCVerificationPage;
@@ -1,80 +0,0 @@
import { useForm } from "react-hook-form";
import { trpc } from "@calcom/trpc";
import { Meta, Form, Button, TextField, showToast } from "@calcom/ui";
type FormValues = {
name?: string | null;
};
export default function KYCVerificationView() {
const teamForm = useForm<FormValues>();
const userForm = useForm<FormValues>();
const mutation = trpc.viewer.kycVerification.verify.useMutation({
onSuccess: async (data) => {
showToast(`Successfully verified ${data.isTeam ? "team" : "user"} ${data.name}`, "success");
},
onError: (error) => {
showToast(`Verification failed: ${error.message}`, "error");
},
});
return (
<div>
<Meta
title="KYC Verification"
description="Here you can verify users and teams. This verification is needed for sending sms/whatsapp messages to attendees."
/>
<div>
<div className="mb-2 font-medium">Verify Team</div>
<Form
form={teamForm}
handleSubmit={(values) => {
mutation.mutate({
name: values.name || "",
isTeam: true,
});
}}>
<div className="flex space-x-2">
<TextField
{...teamForm.register("name")}
label=""
type="text"
id="name"
placeholder="team slug"
className="-mt-2 "
required
/>
<Button type="submit">Verify</Button>
</div>
</Form>
</div>
<div>
<div className="mb-2 mt-6 font-medium">Verify User</div>
<Form
form={userForm}
handleSubmit={(values) => {
mutation.mutate({
name: values.name || "",
isTeam: false,
});
}}>
<div className="flex space-x-2">
<TextField
{...userForm.register("name")}
label=""
type="text"
id="name"
placeholder="user name"
className="-mt-2"
required
/>
<Button type="submit">Verify</Button>
</div>
</Form>
</div>
</div>
);
}
+5 -4
View File
@@ -22,12 +22,11 @@ const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
<>
<section className={classNames("text-default flex flex-col sm:flex-row", className)}>
<div>
<h2 className="font-medium">{title}</h2>
<h2 className="text-base font-semibold">{title}</h2>
<p>{description}</p>
</div>
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">{children}</div>
</section>
<hr className="border-subtle" />
</>
);
};
@@ -45,14 +44,16 @@ const BillingView = () => {
return (
<>
<Meta title={t("billing")} description={t("manage_billing_description")} />
<div className="space-y-6 text-sm sm:space-y-8">
<Meta title={t("billing")} description={t("manage_billing_description")} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-8 text-sm sm:space-y-8">
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
{t("billing_portal")}
</Button>
</CtaRow>
<hr className="border-subtle" />
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
<Button color="secondary" onClick={onContactSupportClick}>
{t("contact_support")}
+53 -32
View File
@@ -14,12 +14,25 @@ import {
DialogContent,
EmptyScreen,
Meta,
AppSkeletonLoader as SkeletonLoader,
SkeletonContainer,
SkeletonText,
} from "@calcom/ui";
import { Link as LinkIcon, Plus } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
);
};
const ApiKeysView = () => {
const { t } = useLocale();
@@ -39,49 +52,57 @@ const ApiKeysView = () => {
setApiKeyToEdit(undefined);
setApiKeyModal(true);
}}>
{t("new_api_key")}
{t("add")}
</Button>
);
};
if (isLoading || !data) {
return (
<SkeletonLoader
title={t("api_keys")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
/>
);
}
return (
<>
<Meta
title={t("api_keys")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
CTA={<NewApiKeyButton />}
borderInShellHeader={true}
/>
<LicenseRequired>
<>
{isLoading && <SkeletonLoader />}
<div>
{isLoading ? null : data?.length ? (
<>
<div className="border-subtle mb-8 mt-6 rounded-md border">
{data.map((apiKey, index) => (
<ApiKeyListItem
key={apiKey.id}
apiKey={apiKey}
lastItem={data.length === index + 1}
onEditClick={() => {
setApiKeyToEdit(apiKey);
setApiKeyModal(true);
}}
/>
))}
</div>
<NewApiKeyButton />
</>
) : (
<EmptyScreen
Icon={LinkIcon}
headline={t("create_first_api_key")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
buttonRaw={<NewApiKeyButton />}
/>
)}
</div>
</>
<div>
{data?.length ? (
<>
<div className="border-subtle rounded-b-md border border-t-0">
{data.map((apiKey, index) => (
<ApiKeyListItem
key={apiKey.id}
apiKey={apiKey}
lastItem={data.length === index + 1}
onEditClick={() => {
setApiKeyToEdit(apiKey);
setApiKeyModal(true);
}}
/>
))}
</div>
</>
) : (
<EmptyScreen
Icon={LinkIcon}
headline={t("create_first_api_key")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
className="rounded-b-md rounded-t-none border-t-0"
buttonRaw={<NewApiKeyButton />}
/>
)}
</div>
</LicenseRequired>
<Dialog open={apiKeyModal} onOpenChange={setApiKeyModal}>
+250 -183
View File
@@ -3,8 +3,10 @@ import { Controller, useForm } from "react-hook-form";
import type { z } from "zod";
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { classNames } from "@calcom/lib";
import { APP_NAME } from "@calcom/lib/constants";
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
@@ -12,6 +14,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { userMetadata } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import {
Alert,
Button,
@@ -22,7 +25,7 @@ import {
SkeletonButton,
SkeletonContainer,
SkeletonText,
Switch,
SettingsToggle,
UpgradeTeamsBadge,
} from "@calcom/ui";
@@ -31,9 +34,9 @@ import PageWrapper from "@components/PageWrapper";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mb-8 mt-6 space-y-6">
<div className="flex items-center">
<Meta title={title} description={description} borderInShellHeader={false} />
<div className="border-subtle mt-6 space-y-6 rounded-t-xl border border-b-0 px-4 py-6 sm:px-6">
<div className="flex items-center justify-center">
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
@@ -44,49 +47,83 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
</div>
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
<div className="rounded-b-xl">
<SectionBottomActions align="end">
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</SectionBottomActions>
</div>
</SkeletonContainer>
);
};
const AppearanceView = () => {
const DEFAULT_LIGHT_BRAND_COLOR = "#292929";
const DEFAULT_DARK_BRAND_COLOR = "#fafafa";
const AppearanceView = ({
user,
hasPaidPlan,
}: {
user: RouterOutputs["viewer"]["me"];
hasPaidPlan: boolean;
}) => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const [darkModeError, setDarkModeError] = useState(false);
const [lightModeError, setLightModeError] = useState(false);
const [isCustomBrandColorChecked, setIsCustomBranColorChecked] = useState(
user?.brandColor !== DEFAULT_LIGHT_BRAND_COLOR || user?.darkBrandColor !== DEFAULT_DARK_BRAND_COLOR
);
const [hideBrandingValue, setHideBrandingValue] = useState(user?.hideBranding ?? false);
const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan();
const formMethods = useForm({
const userThemeFormMethods = useForm({
defaultValues: {
theme: user?.theme,
brandColor: user?.brandColor || "#292929",
darkBrandColor: user?.darkBrandColor || "#fafafa",
hideBranding: user?.hideBranding,
metadata: user?.metadata as z.infer<typeof userMetadata>,
theme: user.theme,
},
});
const selectedTheme = formMethods.watch("theme");
const {
formState: { isSubmitting: isUserThemeSubmitting, isDirty: isUserThemeDirty },
reset: resetUserThemeReset,
} = userThemeFormMethods;
const bookerLayoutFormMethods = useForm({
defaultValues: {
metadata: user.metadata as z.infer<typeof userMetadata>,
},
});
const {
formState: { isSubmitting: isBookerLayoutFormSubmitting, isDirty: isBookerLayoutFormDirty },
reset: resetBookerLayoutThemeReset,
} = bookerLayoutFormMethods;
const brandColorsFormMethods = useForm({
defaultValues: {
brandColor: user.brandColor || DEFAULT_LIGHT_BRAND_COLOR,
darkBrandColor: user.darkBrandColor || DEFAULT_DARK_BRAND_COLOR,
},
});
const {
formState: { isSubmitting: isBrandColorsFormSubmitting, isDirty: isBrandColorsFormDirty },
reset: resetBrandColorsThemeReset,
} = brandColorsFormMethods;
const selectedTheme = userThemeFormMethods.watch("theme");
const selectedThemeIsDark =
selectedTheme === "dark" ||
(selectedTheme === "" &&
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark"));
const {
formState: { isSubmitting, isDirty },
reset,
} = formMethods;
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (data) => {
await utils.viewer.me.invalidate();
showToast(t("settings_updated_successfully"), "success");
reset(data);
resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor });
resetBookerLayoutThemeReset({ metadata: data.metadata });
resetUserThemeReset({ theme: data.theme });
},
onError: (error) => {
if (error.message) {
@@ -97,136 +134,180 @@ const AppearanceView = () => {
},
});
if (isLoading || isTeamPlanStatusLoading)
return <SkeletonLoader title={t("appearance")} description={t("appearance_description")} />;
if (!user) return null;
const isDisabled = isSubmitting || !isDirty;
return (
<Form
form={formMethods}
handleSubmit={(values) => {
const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null);
if (layoutError) throw new Error(t(layoutError));
mutation.mutate({
...values,
// Radio values don't support null as values, therefore we convert an empty string
// back to null here.
theme: values.theme || null,
});
}}>
<Meta title={t("appearance")} description={t("appearance_description")} />
<div className="mb-6 flex items-center text-sm">
<div>
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
<div>
<p className="text-default font-semibold">{t("theme")}</p>
<p className="text-default text-base font-semibold">{t("theme")}</p>
<p className="text-default">{t("theme_applies_note")}</p>
</div>
</div>
<div className="flex flex-col justify-between sm:flex-row">
<ThemeLabel
variant="system"
value={null}
label={t("theme_system")}
defaultChecked={user.theme === null}
register={formMethods.register}
/>
<ThemeLabel
variant="light"
value="light"
label={t("light")}
defaultChecked={user.theme === "light"}
register={formMethods.register}
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("dark")}
defaultChecked={user.theme === "dark"}
register={formMethods.register}
/>
</div>
<hr className="border-subtle my-8 border [&:has(+hr)]:hidden" />
<BookerLayoutSelector
isDark={selectedThemeIsDark}
name="metadata.defaultBookerLayouts"
title={t("bookerlayout_user_settings_title")}
description={t("bookerlayout_user_settings_description")}
/>
<hr className="border-subtle my-8 border" />
<div className="mb-6 flex items-center text-sm">
<div>
<p className="text-default font-semibold">{t("custom_brand_colors")}</p>
<p className="text-default mt-0.5 leading-5">{t("customize_your_brand_colors")}</p>
<Form
form={userThemeFormMethods}
handleSubmit={(values) => {
mutation.mutate({
// Radio values don't support null as values, therefore we convert an empty string
// back to null here.
theme: values.theme || null,
});
}}>
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
<ThemeLabel
variant="system"
value={null}
label={t("theme_system")}
defaultChecked={user.theme === null}
register={userThemeFormMethods.register}
/>
<ThemeLabel
variant="light"
value="light"
label={t("light")}
defaultChecked={user.theme === "light"}
register={userThemeFormMethods.register}
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("dark")}
defaultChecked={user.theme === "dark"}
register={userThemeFormMethods.register}
/>
</div>
</div>
<SectionBottomActions className="mb-6" align="end">
<Button
disabled={isUserThemeSubmitting || !isUserThemeDirty}
type="submit"
data-testid="update-theme-btn"
color="primary">
{t("update")}
</Button>
</SectionBottomActions>
</Form>
<div className="block justify-between sm:flex">
<Controller
name="brandColor"
control={formMethods.control}
defaultValue={user.brandColor}
render={() => (
<div>
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
<ColorPicker
<Form
form={bookerLayoutFormMethods}
handleSubmit={(values) => {
const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null);
if (layoutError) {
showToast(t(layoutError), "error");
return;
} else {
mutation.mutate(values);
}
}}>
<BookerLayoutSelector
isDark={selectedThemeIsDark}
name="metadata.defaultBookerLayouts"
title={t("bookerlayout_user_settings_title")}
description={t("bookerlayout_user_settings_description")}
isDisabled={isBookerLayoutFormSubmitting || !isBookerLayoutFormDirty}
/>
</Form>
<Form
form={brandColorsFormMethods}
handleSubmit={(values) => {
mutation.mutate(values);
}}>
<div className="mt-6">
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("custom_brand_colors")}
description={t("customize_your_brand_colors")}
checked={isCustomBrandColorChecked}
onCheckedChange={(checked) => {
setIsCustomBranColorChecked(checked);
if (!checked) {
mutation.mutate({
brandColor: DEFAULT_LIGHT_BRAND_COLOR,
darkBrandColor: DEFAULT_DARK_BRAND_COLOR,
});
}
}}
childrenClassName="lg:ml-0"
switchContainerClassName={classNames(
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
isCustomBrandColorChecked && "rounded-b-none"
)}>
<div className="border-subtle flex flex-col gap-6 border-x p-6">
<Controller
name="brandColor"
control={brandColorsFormMethods.control}
defaultValue={user.brandColor}
resetDefaultValue="#292929"
onChange={(value) => {
if (!checkWCAGContrastColor("#ffffff", value)) {
setLightModeError(true);
} else {
setLightModeError(false);
}
formMethods.setValue("brandColor", value, { shouldDirty: true });
}}
render={() => (
<div>
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
<ColorPicker
defaultValue={user.brandColor}
resetDefaultValue="#292929"
onChange={(value) => {
try {
checkWCAGContrastColor("#ffffff", value);
setLightModeError(false);
brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true });
} catch (err) {
setLightModeError(false);
}
}}
/>
{lightModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div>
) : null}
</div>
)}
/>
</div>
)}
/>
<Controller
name="darkBrandColor"
control={formMethods.control}
defaultValue={user.darkBrandColor}
render={() => (
<div className="mt-6 sm:mt-0">
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
<ColorPicker
<Controller
name="darkBrandColor"
control={brandColorsFormMethods.control}
defaultValue={user.darkBrandColor}
resetDefaultValue="#fafafa"
onChange={(value) => {
if (!checkWCAGContrastColor("#101010", value)) {
setDarkModeError(true);
} else {
setDarkModeError(false);
}
formMethods.setValue("darkBrandColor", value, { shouldDirty: true });
}}
render={() => (
<div className="mt-6 sm:mt-0">
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
<ColorPicker
defaultValue={user.darkBrandColor}
resetDefaultValue="#fafafa"
onChange={(value) => {
try {
checkWCAGContrastColor("#101010", value);
setDarkModeError(false);
brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true });
} catch (err) {
setDarkModeError(true);
}
}}
/>
{darkModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div>
) : null}
</div>
)}
/>
</div>
)}
/>
</div>
{darkModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
<SectionBottomActions align="end">
<Button
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
color="primary"
type="submit">
{t("update")}
</Button>
</SectionBottomActions>
</SettingsToggle>
</div>
) : null}
{lightModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div>
) : null}
</Form>
{/* TODO future PR to preview brandColors */}
{/* <Button
color="secondary"
@@ -235,51 +316,37 @@ const AppearanceView = () => {
onClick={() => window.open(`${WEBAPP_URL}/${user.username}/${user.eventTypes[0].title}`, "_blank")}>
Preview
</Button> */}
<hr className="border-subtle my-8 border" />
<Controller
name="hideBranding"
control={formMethods.control}
defaultValue={user.hideBranding}
render={({ field: { value } }) => (
<>
<div className="flex w-full text-sm">
<div className="mr-1 flex-grow">
<div className="flex items-center">
<p className="text-default font-semibold ltr:mr-2 rtl:ml-2">
{t("disable_cal_branding", { appName: APP_NAME })}
</p>
<UpgradeTeamsBadge />
</div>
<p className="text-default mt-0.5">{t("removes_cal_branding", { appName: APP_NAME })}</p>
</div>
<div className="flex-none">
<Switch
id="hideBranding"
disabled={!hasPaidPlan}
onCheckedChange={(checked) =>
formMethods.setValue("hideBranding", checked, { shouldDirty: true })
}
checked={hasPaidPlan ? value : false}
/>
</div>
</div>
</>
)}
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("disable_cal_branding", { appName: APP_NAME })}
disabled={!hasPaidPlan || mutation?.isLoading}
description={t("removes_cal_branding", { appName: APP_NAME })}
checked={hasPaidPlan ? hideBrandingValue : false}
Badge={<UpgradeTeamsBadge />}
onCheckedChange={(checked) => {
setHideBrandingValue(checked);
mutation.mutate({ hideBranding: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
/>
<Button
disabled={isDisabled}
type="submit"
loading={mutation.isLoading}
color="primary"
className="mt-8"
data-testid="update-theme-btn">
{t("update")}
</Button>
</Form>
</div>
);
};
AppearanceView.getLayout = getLayout;
AppearanceView.PageWrapper = PageWrapper;
const AppearanceViewWrapper = () => {
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan();
export default AppearanceView;
const { t } = useLocale();
if (isLoading || isTeamPlanStatusLoading || !user)
return <SkeletonLoader title={t("appearance")} description={t("appearance_description")} />;
return <AppearanceView user={user} hasPaidPlan={hasPaidPlan} />;
};
AppearanceViewWrapper.getLayout = getLayout;
AppearanceViewWrapper.PageWrapper = PageWrapper;
export default AppearanceViewWrapper;
@@ -1,11 +1,12 @@
import { Trans } from "next-i18next";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Fragment } from "react";
import { Fragment, useState, useEffect } from "react";
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -34,13 +35,13 @@ import PageWrapper from "@components/PageWrapper";
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="mb-8 mt-6 space-y-6">
<div className="border-subtle mt-8 space-y-6 rounded-xl border px-4 py-6 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
@@ -65,6 +66,21 @@ const CalendarsView = () => {
const utils = trpc.useContext();
const query = trpc.viewer.connectedCalendars.useQuery();
const [selectedDestinationCalendarOption, setSelectedDestinationCalendar] = useState<{
integration: string;
externalId: string;
} | null>(null);
useEffect(() => {
if (query?.data?.destinationCalendar) {
setSelectedDestinationCalendar({
integration: query.data.destinationCalendar.integration,
externalId: query.data.destinationCalendar.externalId,
});
}
}, [query?.isLoading, query?.data?.destinationCalendar]);
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
async onSettled() {
await utils.viewer.connectedCalendars.invalidate();
@@ -79,43 +95,58 @@ const CalendarsView = () => {
return (
<>
<Meta title={t("calendars")} description={t("calendars_description")} CTA={<AddCalendarButton />} />
<Meta
title={t("calendars")}
description={t("calendars_description")}
CTA={<AddCalendarButton />}
borderInShellHeader={false}
/>
<QueryCell
query={query}
customLoader={<SkeletonLoader />}
success={({ data }) => {
const isDestinationUpdateBtnDisabled =
selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId;
return data.connectedCalendars.length ? (
<div>
<div className="bg-muted border-subtle mt-4 flex space-x-4 rounded-md p-2 sm:mx-0 sm:p-10 md:border md:p-6 xl:mt-0">
<div className=" bg-default border-subtle flex h-9 w-9 items-center justify-center rounded-md border-2 p-[6px]">
<Calendar className="text-default h-6 w-6" />
</div>
<div className="flex w-full flex-col space-y-3">
<div>
<h4 className=" text-emphasis pb-2 text-base font-semibold leading-5">
{t("add_to_calendar")}
</h4>
<p className=" text-default text-sm leading-5">
<Trans i18nKey="add_to_calendar_description">
Where to add events when you re booked. You can override this on a per-event basis in
advanced settings in the event type.
</Trans>
</p>
</div>
<DestinationCalendarSelector
hidePlaceholder
value={data.destinationCalendar?.externalId}
onChange={mutation.mutate}
isLoading={mutation.isLoading}
/>
</div>
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
{t("add_to_calendar")}
</h2>
<p className="text-default text-sm">{t("add_to_calendar_description")}</p>
</div>
<h4 className="text-emphasis mt-12 text-base font-semibold leading-5">
{t("check_for_conflicts")}
</h4>
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
<List className="flex flex-col gap-6" noBorderTreatment>
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
<DestinationCalendarSelector
hidePlaceholder
value={selectedDestinationCalendarOption?.externalId}
onChange={(option) => {
setSelectedDestinationCalendar(option);
}}
isLoading={mutation.isLoading}
/>
</div>
<SectionBottomActions align="end">
<Button
loading={mutation.isLoading}
disabled={isDestinationUpdateBtnDisabled}
color="primary"
onClick={() => {
if (selectedDestinationCalendarOption) mutation.mutate(selectedDestinationCalendarOption);
}}>
{t("update")}
</Button>
</SectionBottomActions>
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
<h4 className="text-emphasis text-base font-semibold leading-5">
{t("check_for_conflicts")}
</h4>
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
</div>
<List
className="border-subtle flex flex-col gap-6 rounded-b-xl border border-t-0 p-6"
noBorderTreatment>
{data.connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.error && item.error.message && (
@@ -207,6 +238,7 @@ const CalendarsView = () => {
description={t("no_calendar_installed_description")}
buttonText={t("add_a_calendar")}
buttonOnClick={() => router.push("/apps/categories/calendar")}
className="mt-6"
/>
);
}}
@@ -1,39 +1,22 @@
import { useCallback, useState } from "react";
import { useReducer } from "react";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
Dropdown,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
DropdownMenuTrigger,
List,
Meta,
showToast,
SkeletonContainer,
SkeletonText,
} from "@calcom/ui";
import { AlertCircle, MoreHorizontal, Trash, Video, Plus } from "@calcom/ui/components/icon";
import { Button, EmptyScreen, Meta, SkeletonContainer, SkeletonText } from "@calcom/ui";
import { Calendar, Plus } from "@calcom/ui/components/icon";
import { QueryCell } from "@lib/QueryCell";
import AppListCard from "@components/AppListCard";
import PageWrapper from "@components/PageWrapper";
import { AppList } from "@components/apps/AppList";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="divide-subtle mb-8 mt-6 space-y-6">
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
@@ -45,162 +28,93 @@ const AddConferencingButton = () => {
const { t } = useLocale();
return (
<>
<Button color="secondary" StartIcon={Plus} href="/apps/categories/video">
{t("add_conferencing_app")}
</Button>
</>
<Button color="secondary" StartIcon={Plus} href="/apps/categories/conferencing">
{t("add")}
</Button>
);
};
type ModalState = {
isOpen: boolean;
credentialId: null | number;
};
const ConferencingLayout = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: defaultConferencingApp, isLoading: defaultConferencingAppLoading } =
trpc.viewer.getUsersDefaultConferencingApp.useQuery();
const [modal, updateModal] = useReducer(
(data: ModalState, partialData: Partial<ModalState>) => ({ ...data, ...partialData }),
{
isOpen: false,
credentialId: null,
}
);
const { data: apps, isLoading } = trpc.viewer.integrations.useQuery({
const query = trpc.viewer.integrations.useQuery({
variant: "conferencing",
onlyInstalled: true,
});
const deleteAppMutation = trpc.viewer.deleteCredential.useMutation({
onSuccess: () => {
showToast("Integration deleted successfully", "success");
utils.viewer.integrations.invalidate({ variant: "conferencing", onlyInstalled: true });
setDeleteAppModal(false);
},
onError: () => {
showToast("Error deleting app", "error");
setDeleteAppModal(false);
},
});
const onSuccessCallback = useCallback(() => {
setBulkUpdateModal(true);
showToast("Default app updated successfully", "success");
}, []);
const handleModelClose = () => {
updateModal({ isOpen: false, credentialId: null });
};
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
onSuccess: async () => {
await utils.viewer.getUsersDefaultConferencingApp.invalidate();
onSuccessCallback();
},
onError: (error) => {
showToast(`Error: ${error.message}`, "error");
},
});
const [deleteAppModal, setDeleteAppModal] = useState(false);
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
undefined
);
const [deleteCredentialId, setDeleteCredentialId] = useState<number>(0);
if (isLoading || defaultConferencingAppLoading)
return <SkeletonLoader title={t("conferencing")} description={t("conferencing_description")} />;
const handleDisconnect = (credentialId: number) => {
updateModal({ isOpen: true, credentialId });
};
return (
<div className="bg-default w-full sm:mx-0 xl:mt-0">
<Meta
title={t("conferencing")}
description={t("conferencing_description")}
CTA={<AddConferencingButton />}
/>
<List>
{apps?.items &&
apps.items
.map((app) => ({ ...app, title: app.title || app.name }))
.map((app) => {
const appSlug = app?.slug;
const appIsDefault =
appSlug === defaultConferencingApp?.appSlug ||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug); // Default to cal video if the user doesnt have it set (we do this on new account creation but not old)
<>
<div className="bg-default w-full sm:mx-0 xl:mt-0">
<Meta
title={t("conferencing")}
description={t("conferencing_description")}
CTA={<AddConferencingButton />}
borderInShellHeader={true}
/>
<QueryCell
query={query}
customLoader={
<SkeletonLoader title={t("conferencing")} description={t("conferencing_description")} />
}
success={({ data }) => {
console.log(data);
if (!data.items.length) {
return (
<AppListCard
description={app.description}
title={app.title}
logo={app.logo}
key={app.title}
isDefault={appIsDefault} // @TODO: Handle when a user doesnt have this value set
actions={
<div>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="secondary"
StartIcon={Video}
onClick={() => {
const locationType = getEventLocationTypeFromApp(
app?.locationOption?.value ?? ""
);
if (locationType?.linkType === "static") {
setLocationType({ ...locationType, slug: appSlug });
} else {
updateDefaultAppMutation.mutate({
appSlug,
});
}
}}>
{t("set_as_default")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
disabled={app.isGlobal}
StartIcon={Trash}
onClick={() => {
setDeleteCredentialId(app.userCredentialIds[0]);
setDeleteAppModal(true);
}}>
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
<EmptyScreen
Icon={Calendar}
headline={t("no_category_apps", {
category: t("conferencing").toLowerCase(),
})}
description={t("no_category_apps_description_conferencing")}
buttonRaw={
<Button
color="secondary"
data-testid="connect-conferencing-apps"
href="/apps/categories/conferencing">
{t("connect_conference_apps")}
</Button>
}
/>
);
})}
</List>
<Dialog open={deleteAppModal} onOpenChange={setDeleteAppModal}>
<DialogContent
title={t("Remove app")}
description={t("are_you_sure_you_want_to_remove_this_app")}
type="confirmation"
Icon={AlertCircle}>
<DialogFooter>
<Button color="primary" onClick={() => deleteAppMutation.mutate({ id: deleteCredentialId })}>
{t("yes_remove_app")}
</Button>
<DialogClose />
</DialogFooter>
</DialogContent>
</Dialog>
{locationType && (
<AppSetDefaultLinkDialog
locationType={locationType}
setLocationType={setLocationType}
onSuccess={onSuccessCallback}
}
return (
<AppList
listClassName="rounded-xl rounded-t-none border-t-0"
handleDisconnect={handleDisconnect}
data={data}
variant="conferencing"
/>
);
}}
/>
)}
{bulkUpdateModal && (
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
)}
</div>
</div>
<DisconnectIntegrationModal
handleModelClose={handleModelClose}
isOpen={modal.isOpen}
credentialId={modal.credentialId}
/>
</>
);
};
+148 -148
View File
@@ -1,6 +1,8 @@
import { useSession } from "next-auth/react";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localeOptions } from "@calcom/lib/i18n";
@@ -13,12 +15,12 @@ import {
Label,
Meta,
Select,
SettingsToggle,
showToast,
SkeletonButton,
SkeletonContainer,
SkeletonText,
TimezoneSelect,
SettingsToggle,
} from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@@ -26,14 +28,14 @@ import PageWrapper from "@components/PageWrapper";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mb-8 mt-6 space-y-6">
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
@@ -59,6 +61,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
const utils = trpc.useContext();
const { t } = useLocale();
const { update } = useSession();
const [isUpdateBtnLoading, setIsUpdateBtnLoading] = useState<boolean>(false);
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
@@ -72,6 +75,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
},
onSettled: async () => {
await utils.viewer.me.invalidate();
setIsUpdateBtnLoading(false);
},
});
@@ -105,9 +109,6 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
value: user.weekStart,
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
},
allowDynamicBooking: user.allowDynamicBooking ?? true,
allowSEOIndexing: user.allowSEOIndexing ?? true,
receiveMonthlyDigestEmail: user.receiveMonthlyDigestEmail ?? true,
},
});
const {
@@ -117,151 +118,150 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
} = formMethods;
const isDisabled = isSubmitting || !isDirty;
const [isAllowDynamicBookingChecked, setIsAllowDynamicBookingChecked] = useState(
!!user.allowDynamicBooking
);
const [isAllowSEOIndexingChecked, setIsAllowSEOIndexingChecked] = useState(!!user.allowSEOIndexing);
const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState(
!!user.receiveMonthlyDigestEmail
);
return (
<Form
form={formMethods}
handleSubmit={(values) => {
mutation.mutate({
...values,
locale: values.locale.value,
timeFormat: values.timeFormat.value,
weekStart: values.weekStart.value,
});
}}>
<Meta title={t("general")} description={t("general_description")} />
<Controller
name="locale"
render={({ field: { value, onChange } }) => (
<>
<Label className="text-emphasis">
<>{t("language")}</>
</Label>
<Select<{ label: string; value: string }>
className="capitalize"
options={localeOptions}
value={value}
onChange={onChange}
/>
</>
)}
/>
<Controller
name="timeZone"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-8">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
}}
/>
</>
)}
/>
<Controller
name="timeFormat"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-8">
<>{t("time_format")}</>
</Label>
<Select
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="text-gray text-default mt-2 flex items-center text-sm">
{t("timeformat_profile_hint")}
</div>
<Controller
name="weekStart"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-8">
<>{t("start_of_week")}</>
</Label>
<Select
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="mt-8">
<Controller
name="allowDynamicBooking"
control={formMethods.control}
render={() => (
<SettingsToggle
title={t("dynamic_booking")}
description={t("allow_dynamic_booking")}
checked={formMethods.getValues("allowDynamicBooking")}
onCheckedChange={(checked) => {
formMethods.setValue("allowDynamicBooking", checked, { shouldDirty: true });
}}
/>
)}
/>
</div>
<div>
<Form
form={formMethods}
handleSubmit={(values) => {
setIsUpdateBtnLoading(true);
mutation.mutate({
...values,
locale: values.locale.value,
timeFormat: values.timeFormat.value,
weekStart: values.weekStart.value,
});
}}>
<Meta title={t("general")} description={t("general_description")} borderInShellHeader={true} />
<div className="border-subtle border-x border-y-0 px-4 py-8 sm:px-6">
<Controller
name="locale"
render={({ field: { value, onChange } }) => (
<>
<Label className="text-emphasis">
<>{t("language")}</>
</Label>
<Select<{ label: string; value: string }>
className="capitalize"
options={localeOptions}
value={value}
onChange={onChange}
/>
</>
)}
/>
<Controller
name="timeZone"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
}}
/>
</>
)}
/>
<Controller
name="timeFormat"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("time_format")}</>
</Label>
<Select
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="text-gray text-default mt-2 flex items-center text-sm">
{t("timeformat_profile_hint")}
</div>
<Controller
name="weekStart"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("start_of_week")}</>
</Label>
<Select
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
</div>
<div className="mt-8">
<Controller
name="allowSEOIndexing"
control={formMethods.control}
render={() => (
<SettingsToggle
title={t("seo_indexing")}
description={t("allow_seo_indexing")}
checked={formMethods.getValues("allowSEOIndexing")}
onCheckedChange={(checked) => {
formMethods.setValue("allowSEOIndexing", checked, { shouldDirty: true });
}}
/>
)}
/>
</div>
<SectionBottomActions align="end">
<Button loading={isUpdateBtnLoading} disabled={isDisabled} color="primary" type="submit">
<>{t("update")}</>
</Button>
</SectionBottomActions>
</Form>
<div className="mt-8">
<Controller
name="receiveMonthlyDigestEmail"
control={formMethods.control}
render={() => (
<SettingsToggle
title={t("monthly_digest_email")}
description={t("monthly_digest_email_for_teams")}
checked={formMethods.getValues("receiveMonthlyDigestEmail")}
onCheckedChange={(checked) => {
formMethods.setValue("receiveMonthlyDigestEmail", checked, { shouldDirty: true });
}}
/>
)}
/>
</div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("dynamic_booking")}
description={t("allow_dynamic_booking")}
disabled={mutation.isLoading}
checked={isAllowDynamicBookingChecked}
onCheckedChange={(checked) => {
setIsAllowDynamicBookingChecked(checked);
mutation.mutate({ allowDynamicBooking: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
/>
<Button
loading={mutation.isLoading}
disabled={isDisabled}
color="primary"
type="submit"
className="mt-8">
<>{t("update")}</>
</Button>
</Form>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("seo_indexing")}
description={t("allow_seo_indexing")}
disabled={mutation.isLoading}
checked={isAllowSEOIndexingChecked}
onCheckedChange={(checked) => {
setIsAllowSEOIndexingChecked(checked);
mutation.mutate({ allowSEOIndexing: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
/>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("monthly_digest_email")}
description={t("monthly_digest_email_for_teams")}
disabled={mutation.isLoading}
checked={isReceiveMonthlyDigestEmailChecked}
onCheckedChange={(checked) => {
setIsReceiveMonthlyDigestEmailChecked(checked);
mutation.mutate({ receiveMonthlyDigestEmail: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
/>
</div>
);
};
+114 -68
View File
@@ -7,8 +7,10 @@ import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import turndown from "@calcom/lib/turndownService";
@@ -47,8 +49,8 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mb-8 space-y-6">
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8">
<div className="flex items-center">
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
@@ -69,18 +71,30 @@ interface DeleteAccountValues {
type FormValues = {
username: string;
avatar: string;
avatar: string | null;
name: string;
email: string;
bio: string;
};
const checkIfItFallbackImage = (fetchedImgSrc: string) => {
return fetchedImgSrc.endsWith(AVATAR_FALLBACK);
};
const ProfileView = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const { update } = useSession();
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const [fetchedImgSrc, setFetchedImgSrc] = useState<string | undefined>();
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
onSuccess: (userData) => {
fetch(userData.avatar).then((res) => {
if (res.url) setFetchedImgSrc(res.url);
});
},
});
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
@@ -204,7 +218,7 @@ const ProfileView = () => {
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
};
if (isLoading || !user)
if (isLoading || !user || !fetchedImgSrc)
return (
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
);
@@ -219,11 +233,17 @@ const ProfileView = () => {
return (
<>
<Meta title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
<Meta
title={t("profile")}
description={t("profile_description", { appName: APP_NAME })}
borderInShellHeader={true}
/>
<ProfileForm
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
isLoading={updateProfileMutation.isLoading}
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
userAvatar={user.avatar}
userOrganization={user.organization}
onSubmit={(values) => {
if (values.email !== user.email && isCALIdentityProvider) {
@@ -238,7 +258,7 @@ const ProfileView = () => {
}
}}
extraField={
<div className="mt-8">
<div className="mt-6">
<UsernameAvailabilityField
onSuccessMutation={async () => {
showToast(t("settings_updated_successfully"), "success");
@@ -252,16 +272,19 @@ const ProfileView = () => {
}
/>
<hr className="border-subtle my-6" />
<Label>{t("danger_zone")}</Label>
<div className="border-subtle mt-6 rounded-xl rounded-b-none border border-b-0 p-6">
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
</div>
{/* Delete account Dialog */}
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<DialogTrigger asChild>
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
{t("delete_account")}
</Button>
</DialogTrigger>
<SectionBottomActions align="end">
<DialogTrigger asChild>
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
{t("delete_account")}
</Button>
</DialogTrigger>
</SectionBottomActions>
<DialogContent
title={t("delete_account_modal_title")}
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
@@ -269,9 +292,7 @@ const ProfileView = () => {
Icon={AlertTriangle}>
<>
<div className="mb-10">
<p className="text-default mb-4">
{t("delete_account_confirmation_message", { appName: APP_NAME })}
</p>
<p className="text-default mb-4">{t("delete_account_confirmation_message")}</p>
{isCALIdentityProvider && (
<PasswordField
data-testid="password"
@@ -366,12 +387,16 @@ const ProfileForm = ({
onSubmit,
extraField,
isLoading = false,
isFallbackImg,
userAvatar,
userOrganization,
}: {
defaultValues: FormValues;
onSubmit: (values: FormValues) => void;
extraField?: React.ReactNode;
isLoading: boolean;
isFallbackImg: boolean;
userAvatar: string;
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
}) => {
const { t } = useLocale();
@@ -379,7 +404,7 @@ const ProfileForm = ({
const profileFormSchema = z.object({
username: z.string(),
avatar: z.string(),
avatar: z.string().nullable(),
name: z
.string()
.trim()
@@ -404,56 +429,77 @@ const ProfileForm = ({
return (
<Form form={formMethods} handleSubmit={onSubmit}>
<div className="flex items-center">
<Controller
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => (
<>
<OrganizationAvatar
alt={formMethods.getValues("username")}
imageSrc={value}
size="lg"
organizationSlug={userOrganization.slug}
/>
<div className="ms-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("change_avatar")}
handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
/>
</div>
</>
)}
/>
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
<div className="flex items-center">
<Controller
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => {
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
return (
<>
<OrganizationAvatar
alt={formMethods.getValues("username")}
imageSrc={value}
size="lg"
organizationSlug={userOrganization.slug}
/>
<div className="ms-4">
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
<div className="flex gap-2">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("upload_avatar")}
handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
/>
{showRemoveAvatarButton && (
<Button
color="secondary"
onClick={() => {
formMethods.setValue("avatar", null, { shouldDirty: true });
}}>
{t("remove")}
</Button>
)}
</div>
</div>
</>
);
}}
/>
</div>
{extraField}
<div className="mt-6">
<TextField label={t("full_name")} {...formMethods.register("name")} />
</div>
<div className="mt-6">
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
</div>
<div className="mt-6">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(formMethods.getValues("bio") || "")}
setText={(value: string) => {
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
}}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
</div>
{extraField}
<div className="mt-8">
<TextField label={t("full_name")} {...formMethods.register("name")} />
</div>
<div className="mt-8">
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
</div>
<div className="mt-8">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(formMethods.getValues("bio") || "")}
setText={(value: string) => {
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
}}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
<Button loading={isLoading} disabled={isDisabled} color="primary" className="mt-8" type="submit">
{t("update")}
</Button>
<SectionBottomActions align="end">
<Button loading={isLoading} disabled={isDisabled} color="primary" type="submit">
{t("update")}
</Button>
</SectionBottomActions>
</Form>
);
};
@@ -1,23 +1,34 @@
import type { GetServerSidePropsContext } from "next";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, Form, Label, Meta, showToast, Skeleton, Switch } from "@calcom/ui";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Meta, showToast, SettingsToggle, SkeletonContainer, SkeletonText } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 border border-t-0 px-4 py-8 sm:px-6">
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
);
};
const ProfileImpersonationView = () => {
const ProfileImpersonationView = ({ user }: { user: RouterOutputs["viewer"]["me"] }) => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: user } = trpc.viewer.me.useQuery();
const [disableImpersonation, setDisableImpersonation] = useState<boolean | undefined>(
user?.disableImpersonation
);
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: () => {
showToast(t("profile_updated_successfully"), "success");
reset(getValues());
},
onSettled: () => {
utils.viewer.me.invalidate();
@@ -26,83 +37,54 @@ const ProfileImpersonationView = () => {
await utils.viewer.me.cancel();
const previousValue = utils.viewer.me.getData();
if (previousValue && disableImpersonation) {
utils.viewer.me.setData(undefined, { ...previousValue, disableImpersonation });
}
setDisableImpersonation(disableImpersonation);
return { previousValue };
},
onError: (error, variables, context) => {
if (context?.previousValue) {
utils.viewer.me.setData(undefined, context.previousValue);
setDisableImpersonation(context.previousValue?.disableImpersonation);
}
showToast(`${t("error")}, ${error.message}`, "error");
},
});
const formMethods = useForm<{ disableImpersonation: boolean }>({
defaultValues: {
disableImpersonation: user?.disableImpersonation,
},
});
const {
formState: { isSubmitting, isDirty },
setValue,
reset,
getValues,
watch,
} = formMethods;
const isDisabled = isSubmitting || !isDirty;
return (
<>
<Meta title={t("impersonation")} description={t("impersonation_description")} />
<Form
form={formMethods}
handleSubmit={({ disableImpersonation }) => {
mutation.mutate({ disableImpersonation });
}}>
<div className="flex space-x-3">
<Switch
onCheckedChange={(e) => {
setValue("disableImpersonation", !e, { shouldDirty: true });
}}
fitToHeight={true}
checked={!watch("disableImpersonation")}
/>
<div className="flex flex-col">
<Skeleton as={Label} className="text-emphasis text-sm font-semibold leading-none">
{t("user_impersonation_heading")}
</Skeleton>
<Skeleton as="p" className="text-default -mt-2 text-sm leading-normal">
{t("user_impersonation_description")}
</Skeleton>
</div>
</div>
<Button
color="primary"
loading={mutation.isLoading}
className="mt-8"
type="submit"
disabled={isDisabled}>
{t("update")}
</Button>
</Form>
<Meta
title={t("impersonation")}
description={t("impersonation_description")}
borderInShellHeader={true}
/>
<div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("user_impersonation_heading")}
description={t("user_impersonation_description")}
checked={!disableImpersonation}
onCheckedChange={(checked) => {
mutation.mutate({ disableImpersonation: !checked });
}}
disabled={mutation.isLoading}
switchContainerClassName="py-6 px-4 sm:px-6 border-subtle rounded-b-xl border border-t-0"
/>
</div>
</>
);
};
ProfileImpersonationView.getLayout = getLayout;
ProfileImpersonationView.PageWrapper = PageWrapper;
const ProfileImpersonationViewWrapper = () => {
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const { t } = useLocale();
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
await ssr.viewer.me.prefetch();
return {
props: {
trpcState: ssr.dehydrate(),
},
};
if (isLoading || !user)
return <SkeletonLoader title={t("impersonation")} description={t("impersonation_description")} />;
return <ProfileImpersonationView user={user} />;
};
export default ProfileImpersonationView;
ProfileImpersonationViewWrapper.getLayout = getLayout;
ProfileImpersonationViewWrapper.PageWrapper = PageWrapper;
export default ProfileImpersonationViewWrapper;
+174 -84
View File
@@ -1,13 +1,29 @@
import { signOut, useSession } from "next-auth/react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { identityProviderNameMap } from "@calcom/features/auth/lib/identityProviderNameMap";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { IdentityProvider } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, Form, Meta, PasswordField, Select, SettingsToggle, showToast } from "@calcom/ui";
import {
Alert,
Button,
Form,
Meta,
PasswordField,
Select,
SettingsToggle,
showToast,
SkeletonButton,
SkeletonContainer,
SkeletonText,
} from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@@ -18,34 +34,58 @@ type ChangePasswordSessionFormValues = {
apiError: string;
};
const PasswordView = () => {
interface PasswordViewProps {
user: RouterOutputs["viewer"]["me"];
}
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 border-x px-4 py-8 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
<div className="rounded-b-xl">
<SectionBottomActions align="end">
<SkeletonButton className="ml-auto h-8 w-20 rounded-md" />
</SectionBottomActions>
</div>
</SkeletonContainer>
);
};
const PasswordView = ({ user }: PasswordViewProps) => {
const { data } = useSession();
const { t } = useLocale();
const utils = trpc.useContext();
const { data: user } = trpc.viewer.me.useQuery();
const metadata = userMetadata.safeParse(user?.metadata);
const sessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
const metadata = userMetadataSchema.safeParse(user?.metadata);
const initialSessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
const [sessionTimeout, setSessionTimeout] = useState<number | undefined>(initialSessionTimeout);
const sessionMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: () => {
onSuccess: (data) => {
showToast(t("session_timeout_changed"), "success");
formMethods.reset(formMethods.getValues());
setSessionTimeout(data.metadata?.sessionTimeout);
},
onSettled: () => {
utils.viewer.me.invalidate();
},
onMutate: async () => {
await utils.viewer.me.cancel();
const previousValue = utils.viewer.me.getData();
const previousMetadata = userMetadata.parse(previousValue?.metadata);
const previousValue = await utils.viewer.me.getData();
const previousMetadata = userMetadataSchema.safeParse(previousValue?.metadata);
if (previousValue && sessionTimeout) {
if (previousValue && sessionTimeout && previousMetadata.success) {
utils.viewer.me.setData(undefined, {
...previousValue,
metadata: { ...previousMetadata, sessionTimeout: sessionTimeout },
metadata: { ...previousMetadata?.data, sessionTimeout: sessionTimeout },
});
return { previousValue };
}
return { previousValue };
},
onError: (error, _, context) => {
if (context?.previousValue) {
@@ -84,20 +124,30 @@ const PasswordView = () => {
defaultValues: {
oldPassword: "",
newPassword: "",
sessionTimeout,
},
});
const sessionTimeoutWatch = formMethods.watch("sessionTimeout");
const handleSubmit = (values: ChangePasswordSessionFormValues) => {
const { oldPassword, newPassword, sessionTimeout: newSessionTimeout } = values;
const { oldPassword, newPassword } = values;
if (!oldPassword.length) {
formMethods.setError(
"oldPassword",
{ type: "required", message: t("error_required_field") },
{ shouldFocus: true }
);
}
if (!newPassword.length) {
formMethods.setError(
"newPassword",
{ type: "required", message: t("error_required_field") },
{ shouldFocus: true }
);
}
if (oldPassword && newPassword) {
passwordMutation.mutate({ oldPassword, newPassword });
}
if (sessionTimeout !== newSessionTimeout) {
sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout: newSessionTimeout } });
}
};
const timeoutOptions = [5, 10, 15].map((mins) => ({
@@ -112,7 +162,7 @@ const PasswordView = () => {
return (
<>
<Meta title={t("password")} description={t("password_description")} />
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
{user && user.identityProvider !== IdentityProvider.CAL ? (
<div>
<div className="mt-6">
@@ -130,87 +180,127 @@ const PasswordView = () => {
</div>
) : (
<Form form={formMethods} handleSubmit={handleSubmit}>
{formMethods.formState.errors.apiError && (
<div className="pb-6">
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
</div>
)}
<div className="max-w-[38rem] sm:grid sm:grid-cols-2 sm:gap-x-4">
<div>
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
</div>
<div>
<PasswordField
{...formMethods.register("newPassword", {
minLength: {
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
value: passwordMinLength,
},
pattern: {
message: "Should contain a number, uppercase and lowercase letters",
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
},
})}
label={t("new_password")}
/>
<div className="border-x px-4 py-6 sm:px-6">
{formMethods.formState.errors.apiError && (
<div className="pb-6">
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
</div>
)}
<div className="w-full sm:grid sm:grid-cols-2 sm:gap-x-6">
<div>
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
</div>
<div>
<PasswordField
{...formMethods.register("newPassword", {
minLength: {
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
value: passwordMinLength,
},
pattern: {
message: "Should contain a number, uppercase and lowercase letters",
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
},
})}
label={t("new_password")}
/>
</div>
</div>
<p className="text-default mt-4 w-full text-sm">
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
</p>
</div>
<p className="text-default mt-4 max-w-[38rem] text-sm">
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
</p>
<div className="border-subtle mt-8 border-t py-8">
<SectionBottomActions align="end">
<Button
color="primary"
type="submit"
loading={passwordMutation.isLoading}
onClick={() => formMethods.clearErrors("apiError")}
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
{t("update")}
</Button>
</SectionBottomActions>
<div className="mt-6">
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("session_timeout")}
description={t("session_timeout_description")}
checked={sessionTimeoutWatch !== undefined}
checked={sessionTimeout !== undefined}
data-testid="session-check"
onCheckedChange={(e) => {
if (!e) {
formMethods.setValue("sessionTimeout", undefined, { shouldDirty: true });
setSessionTimeout(undefined);
if (metadata.success) {
sessionMutation.mutate({
metadata: { ...metadata.data, sessionTimeout: undefined },
});
}
} else {
formMethods.setValue("sessionTimeout", 10, { shouldDirty: true });
setSessionTimeout(10);
}
}}
/>
{sessionTimeoutWatch && (
<div className="mt-4 text-sm">
<div className="flex items-center">
<p className="text-default ltr:mr-2 rtl:ml-2">{t("session_timeout_after")}</p>
<Select
options={timeoutOptions}
defaultValue={
sessionTimeout
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
: timeoutOptions[1]
}
isSearchable={false}
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
onChange={(event) => {
formMethods.setValue("sessionTimeout", event?.value, { shouldDirty: true });
}}
/>
childrenClassName="lg:ml-0"
switchContainerClassName={classNames(
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
!!sessionTimeout && "rounded-b-none"
)}>
<>
<div className="border-subtle border-x p-6 pb-8">
<div className="flex flex-col">
<p className="text-default mb-2 font-medium">{t("session_timeout_after")}</p>
<Select
options={timeoutOptions}
defaultValue={
sessionTimeout
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
: timeoutOptions[1]
}
isSearchable={false}
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
onChange={(event) => {
setSessionTimeout(event?.value);
}}
/>
</div>
</div>
</div>
)}
<SectionBottomActions align="end">
<Button
color="primary"
loading={sessionMutation.isLoading}
onClick={() => {
sessionMutation.mutate({
metadata: { ...metadata, sessionTimeout },
});
formMethods.clearErrors("apiError");
}}
disabled={
initialSessionTimeout === sessionTimeout ||
passwordMutation.isLoading ||
sessionMutation.isLoading
}>
{t("update")}
</Button>
</SectionBottomActions>
</>
</SettingsToggle>
</div>
{/* TODO: Why is this Form not submitting? Hacky fix but works */}
<Button
color="primary"
className="mt-8"
type="submit"
loading={passwordMutation.isLoading || sessionMutation.isLoading}
onClick={() => formMethods.clearErrors("apiError")}
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
{t("update")}
</Button>
</Form>
)}
</>
);
};
PasswordView.getLayout = getLayout;
PasswordView.PageWrapper = PageWrapper;
const PasswordViewWrapper = () => {
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const { t } = useLocale();
if (isLoading || !user)
return <SkeletonLoader title={t("password")} description={t("password_description")} />;
export default PasswordView;
return <PasswordView user={user} />;
};
PasswordViewWrapper.getLayout = getLayout;
PasswordViewWrapper.PageWrapper = PageWrapper;
export default PasswordViewWrapper;
@@ -3,15 +3,24 @@ import { useState } from "react";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Badge, Meta, Switch, SkeletonButton, SkeletonContainer, SkeletonText, Alert } from "@calcom/ui";
import {
Badge,
Meta,
SkeletonButton,
SkeletonContainer,
SkeletonText,
Alert,
SettingsToggle,
} from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import DisableTwoFactorModal from "@components/settings/DisableTwoFactorModal";
import EnableTwoFactorModal from "@components/settings/EnableTwoFactorModal";
const SkeletonLoader = () => {
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="mb-8 mt-6 space-y-6">
<div className="flex items-center">
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
@@ -28,36 +37,34 @@ const TwoFactorAuthView = () => {
const { t } = useLocale();
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const [enableModalOpen, setEnableModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
const [enableModalOpen, setEnableModalOpen] = useState<boolean>(false);
const [disableModalOpen, setDisableModalOpen] = useState<boolean>(false);
if (isLoading) return <SkeletonLoader />;
if (isLoading)
return <SkeletonLoader title={t("2fa")} description={t("set_up_two_factor_authentication")} />;
const isCalProvider = user?.identityProvider === "CAL";
const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled;
return (
<>
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} />
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} borderInShellHeader={true} />
{canSetupTwoFactor && <Alert severity="neutral" message={t("2fa_disabled")} />}
<div className="mt-6 flex items-start space-x-4">
<Switch
data-testid="two-factor-switch"
disabled={canSetupTwoFactor}
checked={user?.twoFactorEnabled}
onCheckedChange={() =>
user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true)
}
/>
<div className="!mx-4">
<div className="flex">
<p className="text-default font-semibold">{t("two_factor_auth")}</p>
<Badge className="mx-2 text-xs" variant={user?.twoFactorEnabled ? "success" : "gray"}>
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
</Badge>
</div>
<p className="text-default text-sm">{t("add_an_extra_layer_of_security")}</p>
</div>
</div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
data-testid="two-factor-switch"
title={t("two_factor_auth")}
description={t("add_an_extra_layer_of_security")}
checked={user?.twoFactorEnabled ?? false}
onCheckedChange={() =>
user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true)
}
Badge={
<Badge className="mx-2 text-xs" variant={user?.twoFactorEnabled ? "success" : "gray"}>
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
</Badge>
}
switchContainerClassName="border-subtle rounded-b-xl border border-t-0 px-5 py-6 sm:px-6"
/>
<EnableTwoFactorModal
open={enableModalOpen}
+48 -34
View File
@@ -8,12 +8,13 @@ import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
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 { 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";
@@ -65,9 +66,9 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
// slug is a route parameter, we don't want to forward it to the next route
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = routerQuery;
const EventTypes = () => (
const EventTypes = ({ eventTypes }: { eventTypes: NonNullable<(typeof team)["eventTypes"]> }) => (
<ul className="border-subtle rounded-md border">
{team.eventTypes.map((type, index) => (
{eventTypes.map((type, index) => (
<li
key={index}
className={classNames(
@@ -125,7 +126,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<div className="flex items-center px-5 py-5">
<Avatar
size="md"
imageSrc={getPlaceholderAvatar(ch?.logo, ch?.name as string)}
imageSrc={`/team/${ch.slug}/avatar.png`}
alt="Team Logo"
className="inline-flex justify-center"
/>
@@ -134,7 +135,6 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<span className="text-subtle block text-xs">
{t("number_member", {
count: memberCount,
defaultValue: `${memberCount} member${memberCount > 1 ? "s" : ""}`,
})}
</span>
</div>
@@ -174,7 +174,10 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
description={teamName}
meeting={{
title: markdownStrippedBio,
profile: { name: `${team.name}`, image: getPlaceholderAvatar(team.logo, team.name) },
profile: {
name: `${team.name}`,
image: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`,
},
}}
/>
<main className="dark:bg-darkgray-50 bg-subtle mx-auto max-w-3xl rounded-md px-4 pb-12 pt-12">
@@ -182,7 +185,9 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<div className="relative">
<Avatar
alt={teamName}
imageSrc={getPlaceholderAvatar(team.parent ? team.parent.logo : team.logo, team.name)}
imageSrc={`${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${
team.slug
}/avatar.png`}
size="lg"
/>
</div>
@@ -203,17 +208,19 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<SubTeams />
) : (
<>
{(showMembers.isOn || !team.eventTypes.length) &&
{(showMembers.isOn || !team.eventTypes?.length) &&
(team.isPrivate ? (
<div className="w-full text-center">
<h2 className="text-emphasis font-semibold">{t("you_cannot_see_team_members")}</h2>
<h2 data-testid="you-cannot-see-team-members" className="text-emphasis font-semibold">
{t("you_cannot_see_team_members")}
</h2>
</div>
) : (
<Team team={team} />
<Team members={team.members} teamName={team.name} />
))}
{!showMembers.isOn && team.eventTypes.length > 0 && (
{!showMembers.isOn && team.eventTypes && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl ">
<EventTypes />
<EventTypes eventTypes={team.eventTypes} />
{/* Hide "Book a team member button when team is private or hideBookATeamMember is true" */}
{!team.hideBookATeamMember && !team.isPrivate && (
@@ -233,6 +240,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<Button
color="minimal"
EndIcon={ArrowRight}
data-testid="book-a-team-member-btn"
className="dark:hover:bg-darkgray-200"
href={{
pathname: `${isValidOrgDomain ? "" : "/team"}/${team.slug}`,
@@ -263,10 +271,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
context.params?.orgSlug
);
const flags = await getFeatureFlagMap(prisma);
const team = await getTeamWithMembers({ slug, orgSlug: currentOrgDomain });
const ssr = await ssrInit(context, {
noI18nPreload: isValidOrgDomain && !team?.parent,
const team = await getTeamWithMembers({
slug: slugify(slug ?? ""),
orgSlug: currentOrgDomain,
isTeamView: true,
isOrgView: isValidOrgDomain && context.resolvedUrl === "/",
});
const ssr = await ssrInit(context);
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
console.info("gSSP, team/[slug] - ", {
isValidOrgDomain,
@@ -308,28 +319,31 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
} as const;
}
team.eventTypes = team.eventTypes.map((type) => ({
...type,
users: type.users.map((user) => ({
...user,
avatar: "/" + user.username + "/avatar.png",
})),
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
}));
team.eventTypes =
team.eventTypes?.map((type) => ({
...type,
users: type.users.map((user) => ({
...user,
avatar: "/" + user.username + "/avatar.png",
})),
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
})) ?? null;
const safeBio = markdownToSafeHTML(team.bio) || "";
const members = team.members.map((member) => {
return {
name: member.name,
id: member.id,
bio: member.bio,
subteams: member.subteams,
username: member.username,
accepted: member.accepted,
safeBio: markdownToSafeHTML(member.bio || ""),
};
});
const members = !team.isPrivate
? team.members.map((member) => {
return {
name: member.name,
id: member.id,
bio: member.bio,
subteams: member.subteams,
username: member.username,
accepted: member.accepted,
safeBio: markdownToSafeHTML(member.bio || ""),
};
})
: [];
const markdownStrippedBio = stripMarkdown(team?.bio || "");
+17
View File
@@ -252,6 +252,23 @@ test.describe("prefill", () => {
});
});
test("Persist the field values when going back and coming back to the booking form", async ({
page,
users,
}) => {
await page.goto("/pro/30min");
await selectFirstAvailableTimeSlotNextMonth(page);
await page.fill('[name="name"]', "John Doe");
await page.fill('[name="email"]', "john@example.com");
await page.fill('[name="notes"]', "Test notes");
await page.click('[data-testid="back"]');
await selectFirstAvailableTimeSlotNextMonth(page);
await expect(page.locator('[name="name"]')).toHaveValue("John Doe");
await expect(page.locator('[name="email"]')).toHaveValue("john@example.com");
await expect(page.locator('[name="notes"]')).toHaveValue("Test notes");
});
test("logged out", async ({ page, users }) => {
await page.goto("/pro/30min");
+2 -3
View File
@@ -22,8 +22,7 @@ test.describe("Change username on settings", () => {
await users.deleteAll();
});
/** TODO: Find out why it's timing out */
test.fixme("User can change username", async ({ page, users, prisma }) => {
test("User can change username", async ({ page, users, prisma }) => {
const user = await users.create();
await user.apiLogin();
@@ -36,7 +35,7 @@ test.describe("Change username on settings", () => {
await page.click("[data-testid=update-username-btn]");
await Promise.all([
page.click("[data-testid=save-username]"),
page.waitForResponse("**/viewer.updateProfile*"),
page.getByTestId("toast-success").waitFor(),
]);
const newUpdatedUser = await prisma.user.findUniqueOrThrow({
+2
View File
@@ -556,6 +556,8 @@ export async function apiLogin(
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
await page.locator("div > .ml-auto").first().click();
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("select-option-usd").click();
await page.getByPlaceholder("Price").fill("100");
await page.getByTestId("update-eventtype").click();
}
+61 -2
View File
@@ -1,8 +1,8 @@
import { expect } from "@playwright/test";
import type Prisma from "@prisma/client";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
import type { Fixtures } from "./lib/fixtures";
import { todo, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
@@ -143,7 +143,66 @@ test.describe("Stripe integration", () => {
expect(await paidBadge.innerText()).toBe("Paid");
});
test("Paid and confirmed booking should be able to be rescheduled", async ({ page, users }) => {
await Promise.all([page.waitForURL("/booking/*"), page.click('[data-testid="reschedule-link"]')]);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.click('[data-testid="confirm-reschedule-button"]');
await expect(page.getByText("This meeting is scheduled")).toBeVisible();
});
todo("Payment should trigger a BOOKING_PAID webhook");
todo("Paid and confirmed booking should be able to be rescheduled");
});
test.describe("Change stripe presented currency", () => {
test("Should be able to change currency", async ({ page, users }) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
await user.apiLogin();
await user.getPaymentCredential();
// Edit currency inside event type page
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
// Enable Stripe
await page.locator("#event-type-form").getByRole("switch").click();
// Set price
await page.getByTestId("price-input-stripe").fill("200");
// Select currency in dropdown
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.locator("#react-select-2-input").fill("mexi");
await page.locator("#react-select-2-option-81").click();
await page.getByTestId("update-eventtype").click();
// Book event
await page.goto(`${user.username}/${eventType?.slug}`);
// Confirm MXN currency it's displayed use expect
await expect(await page.getByText("MX$200.00")).toBeVisible();
await selectFirstAvailableTimeSlotNextMonth(page);
// Confirm again in book form page
await expect(await page.getByText("MX$200.00")).toBeVisible();
// --- fill form
await page.fill('[name="name"]', "Stripe Stripeson");
await page.fill('[name="email"]', "stripe@example.com");
// Confirm booking
await page.click('[data-testid="confirm-book-button"]');
// wait for url to be payment
await page.waitForURL("/payment/*");
// Confirm again in book form page
await expect(await page.getByText("MX$200.00")).toBeVisible();
});
});
});
+203
View File
@@ -0,0 +1,203 @@
import { expect } from "@playwright/test";
import prisma from "@calcom/prisma";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Payment app", () => {
test("Should be able to edit alby price, currency", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "alby_payment",
userId: user.id,
key: {
account_id: "random",
account_email: "random@example.com",
webhook_endpoint_id: "ep_randomString",
webhook_endpoint_secret: "whsec_randomString",
account_lightning_address: "random@getalby.com",
},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByPlaceholder("Price").click();
await page.getByPlaceholder("Price").fill("200");
await page.getByText("SatoshissatsCurrencyBTCPayment optionCollect payment on booking").click();
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/${paymentEvent.slug}`);
// expect 200 sats to be displayed in page
expect(await page.locator("text=200 sats").first()).toBeTruthy();
await selectFirstAvailableTimeSlotNextMonth(page);
expect(await page.locator("text=200 sats").first()).toBeTruthy();
// go to /event-types and check if the price is 200 sats
await page.goto(`event-types/`);
expect(await page.locator("text=200 sats").first()).toBeTruthy();
});
test("Should be able to edit stripe price, currency", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "stripe_payment",
userId: user.id,
key: {
scope: "read_write",
livemode: false,
token_type: "bearer",
access_token: "sk_test_randomString",
refresh_token: "rt_randomString",
stripe_user_id: "acct_randomString",
default_currency: "usd",
stripe_publishable_key: "pk_test_randomString",
},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("select-option-usd").click();
await page.getByTestId("price-input-stripe").click();
await page.getByTestId("price-input-stripe").fill("350");
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/${paymentEvent.slug}`);
// expect 200 sats to be displayed in page
expect(await page.locator("text=350").first()).toBeTruthy();
await selectFirstAvailableTimeSlotNextMonth(page);
expect(await page.locator("text=350").first()).toBeTruthy();
// go to /event-types and check if the price is 200 sats
await page.goto(`event-types/`);
expect(await page.locator("text=350").first()).toBeTruthy();
});
test("Should be able to edit paypal price, currency", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "paypal_payment",
userId: user.id,
key: {
client_id: "randomString",
secret_key: "randomString",
webhook_id: "randomString",
},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByPlaceholder("Price").click();
await page.getByPlaceholder("Price").fill("150");
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.locator("#react-select-2-option-13").click();
await page.locator(".mb-1 > .bg-default > div > div:nth-child(2)").first().click();
await page.getByText("$MXNCurrencyMexican pesoPayment option").click();
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/${paymentEvent.slug}`);
// expect 150 to be displayed in page
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
await selectFirstAvailableTimeSlotNextMonth(page);
// expect 150 to be displayed in page
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
// go to /event-types and check if the price is 150
await page.goto(`event-types/`);
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
});
test("Should display App is not setup already for alby", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "alby_payment",
userId: user.id,
key: {},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
// expect text "This app has not been setup yet" to be displayed
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
await page.getByRole("button", { name: "Setup" }).click();
// Expect "Connect with Alby" to be displayed
expect(await page.locator("text=Connect with Alby").first()).toBeTruthy();
});
test("Should display App is not setup already for paypal", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "paypal_payment",
userId: user.id,
key: {},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
// expect text "This app has not been setup yet" to be displayed
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
await page.getByRole("button", { name: "Setup" }).click();
// Expect "Getting started with Paypal APP" to be displayed
expect(await page.locator("text=Getting started with Paypal APP").first()).toBeTruthy();
});
});
+32
View File
@@ -176,6 +176,38 @@ test.describe("Teams", () => {
await prisma.team.delete({ where: { id: team?.id } });
});
});
test("Can create a private team", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(ownerObj, {
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
});
await owner.apiLogin();
const { team } = await owner.getTeam();
// Mark team as private
await page.goto(`/settings/teams/${team.id}/members`);
await page.click("[data-testid=make-team-private-check]");
await expect(page.locator(`[data-testid=make-team-private-check][data-state="checked"]`)).toBeVisible();
// Go to Team's page
await page.goto(`/team/${team.slug}`);
await expect(page.locator('[data-testid="book-a-team-member-btn"]')).toBeHidden();
// Go to members page
await page.goto(`/team/${team.slug}?members=1`);
await expect(page.locator('[data-testid="you-cannot-see-team-members"]')).toBeVisible();
await expect(page.locator('[data-testid="team-members-container"]')).toBeHidden();
});
todo("Create a Round Robin with different leastRecentlyBooked hosts");
todo("Reschedule a Collective EventType booking");
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "نعم، قم بالحذف",
"delete_account": "حذف الحساب",
"confirm_delete_account": "نعم، احذف الحساب",
"delete_account_confirmation_message": "هل تريد بالتأكيد حذف حسابك على {{appName}}؟ أي شخص شاركت رابط حسابك معه لن يستطيع بعد الآن الحجز باستخدامه، وستفقد أي تفضيلات حفظتها.",
"delete_account_confirmation_message": "أي شخص شاركت رابط حسابك معه لن يستطيع بعد الآن الحجز باستخدامه، وستفقد أي تفضيلات حفظتها.",
"integrations": "التكاملات",
"apps": "التطبيقات",
"apps_description": "هنا يمكنك العثور على قائمة بتطبيقاتك",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "يتطلب ذلك تأكيد البريد الإلكتروني لمن يقوم بالحجز",
"description_requires_booker_email_verification": "لضمان تأكيد البريد الإلكتروني لمن يقوم بالحجز قبل جدولة الفعاليات",
"requires_confirmation_mandatory": "يمكن إرسال رسائل نصية فقط للحاضرين عندما يتطلب نوع الفعالية تأكيداً.",
"kyc_verification_information": "يجب عليك إثبات صحة {{teamOrAccount}} لديك قبل إرسال رسائل نصية للحضور بهدف ضمان الأمان. يرجى الاتصال بنا عبر البريد الإلكتروني <a>{{supportEmail}}</a> وتقديم المعلومات التالية:",
"kyc_verification_documents": "<ul><li> {{teamOrUser}}</li><li>للأعمال التجارية: إرفاق مستندات تأكيد الأعمال</li><li>للأفراد: إرفاق بطاقة تعريفية صادرة من جهة حكومية</li></ul>",
"verify_team_or_account": "تأكيد {{teamOrAccount}}",
"verify_account": "تأكيد الحساب",
"kyc_verification": "تأكيد KYC",
"organizations": "المؤسسات",
"org_admin_other_teams": "فرق أخرى",
"org_admin_other_teams_description": "يمكنك هنا رؤية الفرق ضمن مؤسستك والتي أنت لست جزءاً منها؛ ويمكنك إضافة نفسك إليها عند الحاجة.",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Ano, odstranit",
"delete_account": "Smazat účet",
"confirm_delete_account": "Ano, smazat účet",
"delete_account_confirmation_message": "Opravdu chcete odstranit svůj účet na {{appName}}? Všichni, se kterými jste sdíleli odkaz na účet, přijdou o možnost vytvářet rezervace a vy přijdete o uložené předvolby.",
"delete_account_confirmation_message": "Všichni, se kterými jste sdíleli odkaz na účet, přijdou o možnost vytvářet rezervace a vy přijdete o uložené předvolby.",
"integrations": "Integrace",
"apps": "Aplikace",
"apps_description": "Tady najdete seznam svých aplikací",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Povinné ověření e-mailu rezervujícího",
"description_requires_booker_email_verification": "Zajištění ověření e-mailu rezervujícího před plánováním událostí",
"requires_confirmation_mandatory": "SMS zprávy lze účastníkům zasílat pouze v případě, že typ události vyžaduje potvrzení.",
"kyc_verification_information": "V zájmu zajištění bezpečnosti je nutné před odesláním SMS zpráv účastníkům ověřit {{teamOrAccount}}. Kontaktujte nás na adrese <a>{{supportEmail}}</a> a uveďte následující informace:",
"kyc_verification_documents": "<ul><li>{{teamOrUser}}</li><li>Pro firmy: Přiložte svou dokumentaci k ověření podnikání</li><li>Pro jednotlivce: Přiložte dokumentaci k ověření podnikání</li><li>: Přiložte vládou vydaný průkaz totožnosti</li></ul>.",
"verify_team_or_account": "Ověřte {{teamOrAccount}}",
"verify_account": "Ověřit účet",
"kyc_verification": "Ověření typu KYC",
"organizations": "Organizace",
"org_admin_other_teams": "Ostatní týmy",
"org_admin_other_teams_description": "Zde můžete vidět týmy uvnitř vaší organizace, kterých nejste součástí, a případně se do nich můžete přidat.",
@@ -703,7 +703,7 @@
"slot_interval_default": "Brug begivenhedslængde (standard)",
"delete_account": "Slet konto",
"confirm_delete_account": "Ja, slet konto",
"delete_account_confirmation_message": "Er du sikker på, at du vil slette din {{appName}} konto? Alle, som du har delt dit konto-link med, vil ikke længere kunne booke ved hjælp af det, og eventuelle præferencer du har gemt, vil gå tabt.",
"delete_account_confirmation_message": "Alle, som du har delt dit konto-link med, vil ikke længere kunne booke ved hjælp af det, og eventuelle præferencer du har gemt, vil gå tabt.",
"integrations": "Integrationer",
"apps": "Apps",
"apps_description": "Her kan du finde en liste over dine apps",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Ja, löschen",
"delete_account": "Account löschen",
"confirm_delete_account": "Ja, Account löschen",
"delete_account_confirmation_message": "Sind Sie sicher, dass Sie Ihr {{appName}}-Konto löschen möchten? Sie können dann nicht mehr über Ihre geteilten Links gebucht werden und alle Einstellungen werden gelöscht.",
"delete_account_confirmation_message": "Sie können dann nicht mehr über Ihre geteilten Links gebucht werden und alle Einstellungen werden gelöscht.",
"integrations": "Integrationen",
"apps": "Apps",
"apps_description": "Hier finden Sie eine Liste Ihrer Apps",
@@ -2002,10 +2002,6 @@
"requires_booker_email_verification": "Erfordert E-Mail-Verifizierung des Buchenden",
"description_requires_booker_email_verification": "Um sicherzustellen, dass die E-Mail des Buchenden vor der Terminplanung verifiziert ist",
"requires_confirmation_mandatory": "Textnachrichten können nur an Teilnehmer gesendet werden, wenn der Ereignistyp bestätigt werden muss.",
"kyc_verification_information": "Aus Sicherheitsgründen müssen Sie Ihren {{teamOrAccount}} verifizieren, bevor Sie Textnachrichten an die Teilnehmer senden können. Bitte kontaktieren Sie uns unter <a>{{supportEmail}}</a> und geben Sie folgende Informationen an:",
"verify_team_or_account": "{{teamOrAccount}} verifizieren",
"verify_account": "Konto verifizieren",
"kyc_verification": "KYC-Verifizierung",
"organizations": "Organisationen",
"org_admin_other_teams": "Weitere Teams",
"org_admin_other_teams_description": "Hier können Sie Teams innerhalb Ihrer Organisation sehen, zu denen Sie nicht gehören. Sie können sich bei Bedarf selbst hinzufügen.",
+10 -8
View File
@@ -255,7 +255,7 @@
"yours": "Your account",
"available_apps": "Available Apps",
"available_apps_lower_case": "Available apps",
"available_apps_desc": "You have no apps installed. View popular apps below and explore more in our <1>App Store</1>",
"available_apps_desc": "View popular apps below and explore more in our <1>App Store</1>",
"fixed_host_helper": "Add anyone who needs to attend the event. <1>Learn more</1>",
"round_robin_helper":"People in the group take turns and only one person will show up for the event.",
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
@@ -288,6 +288,7 @@
"when": "When",
"where": "Where",
"add_to_calendar": "Add to calendar",
"add_to_calendar_description":"Select where to add events when you’re booked.",
"add_another_calendar": "Add another calendar",
"other": "Other",
"email_sign_in_subject": "Your sign-in link for {{appName}}",
@@ -599,6 +600,7 @@
"hide_book_a_team_member": "Hide Book a Team Member Button",
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
"danger_zone": "Danger zone",
"account_deletion_cannot_be_undone":"Careful. Account deletion cannot be undone.",
"back": "Back",
"cancel": "Cancel",
"cancel_all_remaining": "Cancel all remaining",
@@ -688,6 +690,7 @@
"people": "People",
"your_email": "Your Email",
"change_avatar": "Change Avatar",
"upload_avatar": "Upload Avatar",
"language": "Language",
"timezone": "Timezone",
"first_day_of_week": "First Day of Week",
@@ -826,7 +829,7 @@
"confirm_delete_event_type": "Yes, delete",
"delete_account": "Delete account",
"confirm_delete_account": "Yes, delete account",
"delete_account_confirmation_message": "Are you sure you want to delete your {{appName}} account? Anyone who you've shared your account link with will no longer be able to book using it and any preferences you have saved will be lost.",
"delete_account_confirmation_message": "Anyone who you've shared your account link with will no longer be able to book using it and any preferences you have saved will be lost.",
"integrations": "Integrations",
"apps": "Apps",
"apps_description": "Here you can find a list of your apps",
@@ -1276,6 +1279,7 @@
"personal_cal_url": "My personal {{appName}} URL",
"bio_hint": "A few sentences about yourself. this will appear on your personal url page.",
"user_has_no_bio": "This user has not added a bio yet.",
"bio":"Bio",
"delete_account_modal_title": "Delete Account",
"confirm_delete_account_modal": "Are you sure you want to delete your {{appName}} account?",
"delete_my_account": "Delete my account",
@@ -1292,7 +1296,7 @@
"customize_your_brand_colors": "Customize your own brand colour into your booking page.",
"pro": "Pro",
"removes_cal_branding": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}.'",
"profile_picture": "Profile picture",
"profile_picture": "Profile Picture",
"upload": "Upload",
"add_profile_photo": "Add profile photo",
"web3": "Web3",
@@ -1639,6 +1643,7 @@
"minimum_round_robin_hosts_count": "Number of hosts required to attend",
"hosts": "Hosts",
"upgrade_to_enable_feature": "You need to create a team to enable this feature. Click to create a team.",
"orgs_upgrade_to_enable_feature" : "You need to upgrade to our enterprise plan to enable this feature.",
"new_attendee": "New Attendee",
"awaiting_approval": "Awaiting Approval",
"requires_google_calendar": "This app requires a Google Calendar connection",
@@ -1878,6 +1883,7 @@
"edit_invite_link": "Edit link settings",
"invite_link_copied": "Invite link copied",
"invite_link_deleted": "Invite link deleted",
"api_key_deleted":"API Key deleted",
"invite_link_updated": "Invite link settings saved",
"link_expires_after": "Links set to expire after...",
"one_day": "1 day",
@@ -2003,11 +2009,6 @@
"requires_booker_email_verification": "Requires booker email verification",
"description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events",
"requires_confirmation_mandatory": "Text messages can only be sent to attendees when event type requires confirmation.",
"kyc_verification_information": "To ensure security, you have to verify your {{teamOrAccount}} before sending text messages to attendees. Please contact us at <a>{{supportEmail}}</a> and provide the following information:",
"kyc_verification_documents": "<ul><li>Your {{teamOrUser}}</li><li>For businesses: Attach your Business Verification Documentation</li><li>For individuals: Attach a government-issued ID</li></ul>",
"verify_team_or_account": "Verify {{teamOrAccount}}",
"verify_account": "Verify Account",
"kyc_verification": "KYC Verification",
"organizations": "Organizations",
"org_admin_other_teams": "Other teams",
"org_admin_other_teams_description": "Here you can see teams inside your organization that you are not part of. You can add yourself to them if needed.",
@@ -2062,5 +2063,6 @@
"edit_users_availability":"Edit user's availability: {{username}}",
"resend_invitation": "Resend invitation",
"invitation_resent": "The invitation was resent.",
"this_app_is_not_setup_already": "This app has not been setup yet",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Sí, eliminar",
"delete_account": "Eliminar cuenta",
"confirm_delete_account": "Sí, eliminar cuenta",
"delete_account_confirmation_message": "¿Estás seguro de que quieres eliminar tu cuenta de {{appName}}? Cualquier persona con la que hayas compartido el enlace de tu cuenta ya no podrá agendar con ella y cualquier preferencia que hayas guardado se perderá.",
"delete_account_confirmation_message": "Cualquier persona con la que hayas compartido el enlace de tu cuenta ya no podrá agendar con ella y cualquier preferencia que hayas guardado se perderá.",
"integrations": "Integraciones",
"apps": "Aplicaciones",
"apps_description": "Aquí puede encontrar una lista de sus aplicaciones",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Requiere verificación por correo electrónico de quien reserva",
"description_requires_booker_email_verification": "Para asegurar la verificación del correo electrónico de quien reserva antes de programar eventos",
"requires_confirmation_mandatory": "Los mensajes de texto solo se pueden enviar a los asistentes cuando el tipo de evento requiere confirmación.",
"kyc_verification_information": "Para garantizar la seguridad, debe verificar su {{teamOrAccount}} antes de enviar mensajes de texto a los asistentes. Comuníquese con nosotros en <a>{{supportEmail}}</a> y proporcione la siguiente información:",
"kyc_verification_documents": "<ul><li>Su {{teamOrUser}}</li><li>Para empresas: adjunte su documentación de verificación comercial</li><li>Para individuos: adjunte una identificación emitida por el gobierno</li></ul >",
"verify_team_or_account": "Verificar {{teamOrAccount}}",
"verify_account": "Verificar cuenta",
"kyc_verification": "Verificación de KYC",
"organizations": "Organizaciones",
"org_admin_other_teams": "Otros equipos",
"org_admin_other_teams_description": "Aquí puede ver los equipos dentro de su organización de los que no forma parte. Puede agregarse a ellos si es necesario.",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Oui, supprimer",
"delete_account": "Supprimer le compte",
"confirm_delete_account": "Oui, supprimer le compte",
"delete_account_confirmation_message": "Voulez-vous vraiment supprimer votre compte {{appName}} ? Toute personne avec qui vous avez partagé le lien de votre compte ne pourra plus réserver en utilisant ce lien et toutes les préférences que vous avez sauvegardées seront perdues.",
"delete_account_confirmation_message": "Toute personne avec qui vous avez partagé le lien de votre compte ne pourra plus réserver en utilisant ce lien et toutes les préférences que vous avez sauvegardées seront perdues.",
"integrations": "Intégrations",
"apps": "Applications",
"apps_description": "Vous trouverez ici une liste de vos applications.",
@@ -2003,11 +2003,6 @@
"requires_booker_email_verification": "Nécessite la vérification par e-mail du participant",
"description_requires_booker_email_verification": "Pour assurer la vérification par e-mail du participant avant la planification des événements.",
"requires_confirmation_mandatory": "Les messages ne peuvent être envoyés aux participants que lorsque le type d'événement nécessite une confirmation.",
"kyc_verification_information": "Pour garantir la sécurité, vous devez vérifier votre {{teamOrAccount}} avant d'envoyer des messages aux participants. Veuillez nous contacter à <a>{{supportEmail}}</a> et fournir les informations suivantes :",
"kyc_verification_documents": "<ul><li>Votre {{teamOrUser}}</li><li>Pour les entreprises : Joignez votre document de vérification d'entreprise</li><li>Pour les particuliers : Joignez une pièce d'identité</li></ul>",
"verify_team_or_account": "Vérifier {{teamOrAccount}}",
"verify_account": "Vérifier le compte",
"kyc_verification": "Vérification KYC",
"organizations": "Organisations",
"org_admin_other_teams": "Autres équipes",
"org_admin_other_teams_description": "Ici, vous pouvez voir les équipes au sein de votre organisation dont vous ne faites pas partie. Vous pouvez vous y ajouter si nécessaire.",
+111 -1
View File
@@ -17,9 +17,15 @@
"verify_email_banner_body": "יש לאמת את כתובת הדוא״ל כדי להבטיח שתוכל/י לקבל הודעות לכתובת הדוא״ל וללוח השנה בצורה הטובה ביותר",
"verify_email_email_header": "יש לאמת את כתובת הדוא״ל שלך",
"verify_email_email_button": "אימות כתובת דוא״ל",
"copy_somewhere_safe": "חשוב לשמור את מפתח ה-API הזה במקום בטוח. לא תהיה אפשרות לראות אותו שוב.",
"verify_email_email_body": "נא לאמת את כתובת הדוא״ל שלך על ידי לחיצה על הכפתור להלן.",
"verify_email_by_code_email_body": "נא לאמת את כתובת הדוא״ל באמצעות הקוד שמתחת.",
"verify_email_email_link_text": "הנה הקישור, למקרה שאינך אוהב/ת ללחוץ על כפתורים:",
"email_verification_code": "הזנת קוד האימות",
"email_verification_code_placeholder": "יש להזין את קוד האימות שנשלח אליך בדוא\"ל",
"incorrect_email_verification_code": "קוד האימות שגוי.",
"email_sent": "הודעת הדוא״ל נשלחה בהצלחה",
"email_not_sent": "אירעה שגיאה בעת שליחת הדוא\"ל",
"event_declined_subject": "נדחה: {{title}} ב- {{date}}",
"event_cancelled_subject": "בוטל: {{title}} ב-{{date}}",
"event_request_declined": "בקשת האירוע שלך נדחתה",
@@ -78,6 +84,7 @@
"event_awaiting_approval_recurring": "אירוע חוזר ממתין לאישורך",
"someone_requested_an_event": "מישהו ביקש לקבוע מועד לאירוע בלוח השנה שלך.",
"someone_requested_password_reset": "מישהו ביקש קישור כדי לשנות את הסיסמה שלך.",
"password_reset_email_sent": "אם כתובת הדוא\"ל הזו קיימת במערכת שלנו, אמור להישלח אליך דוא\"ל לאיפוס.",
"password_reset_instructions": "אם הבקשה לא היתה שלך, ניתן להתעלם מהודעת הדוא\"ל הזו בבטחה, והסיסמה שלך לא תשתנה.",
"event_awaiting_approval_subject": "ממתין לאישור: {{title}} ב- {{date}}",
"event_still_awaiting_approval": "אירוע עדיין ממתין לאישורך",
@@ -217,6 +224,10 @@
"already_have_an_account": "כבר יש לך חשבון?",
"create_account": "צור חשבון",
"confirm_password": "אישור סיסמה",
"confirm_auth_change": "זה ישנה את דרך ההתחברות שלך",
"confirm_auth_email_change": "שינוי כתובת הדוא\"ל ינתק את שיטת האימות הנוכחית שמשמשת אותך לכניסה אל Cal.com. נבקש ממך לאמת את כתובת הדוא\"ל החדשה שלך. מעכשיו, המערכת תנתק אותך מהחשבון ויהיה עליך להשתמש בכתובת הדוא\"ל החדשה שלך כדי להתחבר, במקום בשיטת האימות הנוכחית שלך לאחר שתגדיר/י סיסמה לפי ההוראות שיישלחו אליך בדוא\"ל.",
"reset_your_password": "הגדר/י את הסיסמה החדשה שלך לפי ההוראות שנשלחו אל כתובת הדוא\"ל שלך.",
"email_change": "התחבר/י שוב עם כתובת הדוא\"ל החדשה והסיסמה.",
"create_your_account": "צור את החשבון שלך",
"sign_up": "הרשמה",
"youve_been_logged_out": "יצאת מהמערכת",
@@ -243,6 +254,10 @@
"all": "כל האפליקציות",
"yours": "החשבון שלך",
"available_apps": "אפליקציות זמינות",
"available_apps_lower_case": "אפליקציות זמינות",
"available_apps_desc": "לא מותקנת אצלך אף אפליקציה. ראה/י אפליקציות פופולריות מתחת וסייר/י כדי לראות אפליקציות נוספות ב-<1>App Store</1> שלנו",
"fixed_host_helper": "הוסף/י את כל מי שצריך להשתתף באירוע. <1>מידע נוסף</1>",
"round_robin_helper": "האנשים בקבוצה משתתפים כל אחד בתורו ורק אדם אחד יגיע לאירוע.",
"check_email_reset_password": "בדוק/י בתיבת הדוא\"ל – שלחנו לך קישור לאיפוס הסיסמה.",
"finish": "סיום",
"organization_general_description": "ניהול הגדרות השפה ואזור הזמן של הצוות",
@@ -251,6 +266,7 @@
"nearly_there_instructions": "דבר אחרון: תיאור קצר אודותיך/ייך בתוספת תמונה עוזרים מאוד להשיג הזמנות ומאפשרים לאנשים לדעת עם מי הם עומדים להיפגש.",
"set_availability_instructions": "הגדר טווחי זמן שבהם אתה זמין באופן קבוע. ניתן יהיה ליצור טווחי זמן נוספים מאוחר יותר ולהקצות אותם ללוחות שנה אחרים.",
"set_availability": "ציין את הזמינות שלך",
"availability_settings": "הגדרות זמינוּת",
"continue_without_calendar": "להמשיך בלי לוח שנה",
"connect_your_calendar": "קשר את לוח השנה שלך",
"connect_your_video_app": "חיבור אפליקציות הווידאו שלך",
@@ -392,6 +408,8 @@
"allow_dynamic_booking_tooltip": "קישורי הזמנות קבוצתיות שניתן ליצור באופן דינמי על ידי הוספת שמות משתמשים מרובים עם '+', למשל: '{{appName}}/bailey+peer'",
"allow_dynamic_booking": "לאפשר למשתתפים לשריין איתך פגישה באמצעות הזמנות קבוצתיות דינמיות",
"dynamic_booking": "קישורים לקבוצות דינמיות",
"allow_seo_indexing": "מתן אפשרות למנועי חיפוש לגשת לתוכן הציבורי שלך",
"seo_indexing": "מתן אפשרות להוספה לאינדקסים של SEO",
"email": "דוא\"ל",
"email_placeholder": "iisraeli@example.com",
"full_name": "שם מלא",
@@ -406,6 +424,7 @@
"booking_requested": "התקבלה בקשת הזמנה",
"meeting_ended": "הפגישה הסתיימה",
"form_submitted": "הטופס נשלח",
"booking_paid": "בוצע תשלום עבור ההזמנה",
"event_triggers": "גורמים מפעילים של אירועים",
"subscriber_url": "כתובת URL של המנוי/ה",
"create_new_webhook": "יצירת Webhook חדש",
@@ -506,6 +525,7 @@
"your_name": "שמך",
"your_full_name": "שמך המלא",
"no_name": "ללא שם",
"enter_number_between_range": "נא להזין מספר בין 1 ל-{{maxOccurences}}",
"email_address": "כתובת דוא\"ל",
"enter_valid_email": "יש להזין כתובת דוא״ל תקינה",
"location": "מיקום",
@@ -543,6 +563,7 @@
"leave": "יציאה",
"profile": "פרופיל",
"my_team_url": "כתובת ה-URL של הצוות שלי",
"my_teams": "הצוותים שלי",
"team_name": "שם הצוות",
"your_team_name": "שם הצוות שלך",
"team_updated_successfully": "עדכון הצוות בוצע בהצלחה",
@@ -570,6 +591,8 @@
"invite_new_member": "להזמין חבר/ת צוות חדש/ה",
"invite_new_member_description": "הערה: פעולה זו <1>תחייב את המינוי שלך במקום נוסף ($15 לחודש)</1>.",
"invite_new_team_member": "להזמין מישהו לצוות שלך.",
"upload_csv_file": "העלאת קובץ ‎.csv",
"invite_via_email": "הזמנה בדוא\"ל",
"change_member_role": "לשנות את תפקיד החבר/ה בצוות",
"disable_cal_branding": "הסר מיתוג {{appName}}",
"disable_cal_branding_description": "הסתר את כל המיתוג של {{appName}} מהדפים הציבוריים שלך.",
@@ -803,7 +826,7 @@
"confirm_delete_event_type": "כן, למחוק",
"delete_account": "מחיקת חשבון",
"confirm_delete_account": "כן, למחוק את החשבון",
"delete_account_confirmation_message": "בטוח שברצונך למחוק את חשבון {{appName}} שלך? כל מי ששיתפת איתו את הקישור לחשבון לא יוכל יותר להזמין באמצעותו, וכל ההעדפות ששמרת יאבדו.",
"delete_account_confirmation_message": "כל מי ששיתפת איתו את הקישור לחשבון לא יוכל יותר להזמין באמצעותו, וכל ההעדפות ששמרת יאבדו.",
"integrations": "שילובים",
"apps": "אפליקציות",
"apps_description": "כאן ניתן למצוא רשימה של האפליקציות שלך",
@@ -842,6 +865,7 @@
"team_view_user_availability_disabled": "כדי להציג את זמינות הצוות, המשתמש חייב לאשר את ההזמנה",
"set_as_away": "הגדר/י את הסטטוס שלך כ'לא נמצא/ת'",
"set_as_free": "השבתת הסטטוס 'לא נמצא/ת'",
"toggle_away_error": "שגיאה בעת עדכון סטטוס 'לא נמצא/ת'",
"user_away": "משתמש זה מוגדר כעת כ'לא נמצא/ת'.",
"user_away_description": "האדם שניסית לשריין איתו חלון זמן הגדיר את עצמו כ'לא נמצא/ת', ולכן הוא אינו מקבל הזמנות חדשות.",
"meet_people_with_the_same_tokens": "לפגוש אנשים עם אותם טוקנים",
@@ -851,6 +875,7 @@
"account_managed_by_identity_provider_description": "כדי לשנות את כתובת הדוא\"ל ואת הסיסמה, לאפשר אימות דו-גורמי ועוד, יש לעבור אל הגדרות החשבון אצל {{provider}}.",
"signin_with_google": "כניסה עם Google",
"signin_with_saml": "כניסה עם SAML",
"signin_with_saml_oidc": "כניסה עם SAML/OIDC",
"you_will_need_to_generate": "תצטרך ליצור טוקן גישה מהכלי הישן שבו השתמשת לקביעת מועדים.",
"import": "ייבוא",
"import_from": "ייבוא מ",
@@ -950,6 +975,8 @@
"offer_seats_description": "הצעת מקומות להזמנות (אפשרות זו משביתה באופן אוטומטי אישורי הזמנות ואורחים).",
"seats_available_one": "מקום זמין",
"seats_available_other": "מקומות זמינים",
"seats_nearly_full": "כמעט כל המקומות תפוסים",
"seats_half_full": "המקומות מתמלאים במהירות",
"number_of_seats": "מספר המקומות לכל הזמנה",
"enter_number_of_seats": "יש להזין את מספר המקומות",
"you_can_manage_your_schedules": "ניתן לנהל את לוחות הזמנים בדף 'זמינות'.",
@@ -1030,12 +1057,15 @@
"how_you_want_add_cal_site": "איך ברצונך להוסיף את {{appName}} לאתר שלך?",
"choose_ways_put_cal_site": "בחר/י אחת מהדרכים הבאות כדי לשלב את {{appName}} באתר שלך.",
"setting_up_zapier": "המערכת מגדירה את השילוב שלך עם Zapier",
"setting_up_make": "המערכת מגדירה את השילוב שלך עם Make",
"generate_api_key": "יצירת מפתח ממשק תכנות יישומים (API)",
"generate_api_key_description": "צור מפתח ממשק מפתחים, API, לשימוש עם {{appName}} ב-",
"your_unique_api_key": "מפתח ה-API הייחודי שלך",
"copy_safe_api_key": "העתק/י את מפתח ה-API הזה ושמור/י אותו במקום בטוח. אם תאבד/י אותו, יהיה עליך ליצור מפתח חדש.",
"zapier_setup_instructions": "<0>התחבר/י לחשבון Zapier שלך וצור/י Zap חדש.</0><1>בחר/י את Cal.com כאפליקציית ה-Trigger. בנוסף, בחר/י אירוע Trigger.</1><2>בחר/י את החשבון שלך ולאחר מכן הזן/י את מפתח ה-API הייחודי שלך.</2><3>בדוק/י את ה-Trigger.</3><4>וזהו, הכל מוכן!</4>",
"make_setup_instructions": "<0>עבור/י אל <1><0>יצירת קישור Invite</0></1> והתקן/י את אפליקציית Cal.com.</0><1>התחבר/י לחשבון Make שלך וצור/י Scenario חדש.</1><2>בחר/י את Cal.com כאפליקציית ה-Trigger. בנוסף, בחר/י אירוע Trigger.</2><3>בחר/י את החשבון שלך ולאחר מכן הזן/י את מפתח ה-API הייחודי שלך.</3><4>בדוק/י את ה-Trigger.</4><5>וזהו, הכל מוכן!</5>",
"install_zapier_app": "תחילה עליך להוריד את אפליקציית Zapier מה-App Store ולהתקין אותה.",
"install_make_app": "תחילה עליך להוריד את אפליקציית Make מה-App Store ולהתקין אותה.",
"connect_apple_server": "חיבור לשרת Apple",
"calendar_url": "כתובת ה-URL של לוח השנה",
"apple_server_generate_password": "צור סיסמה ספציפית לאפליקציה שתשמש עבור {{appName}} ב-",
@@ -1085,6 +1115,7 @@
"email_attendee_action": "שליחת דוא\"ל למשתתפים",
"sms_attendee_action": "שליחת SMS למשתתף",
"sms_number_action": "שלח SMS למספר מסוים",
"send_reminder_sms": "שלח/י למשתתפים תזכורות לפגישות ב-SMS בקלות",
"whatsapp_number_action": "שליחת Whatsapp למספר מסוים",
"whatsapp_attendee_action": "שליחת Whatsapp למשתתף",
"workflows": "תהליכים",
@@ -1244,6 +1275,7 @@
"error_updating_settings": "שגיאה בעדכון ההגדרות",
"personal_cal_url": "כתובת ה-URL האישית שלי של {{appName}}",
"bio_hint": "מספר משפטים אודותיך. המידע הזה יופיע בדף ה-URL האישי שלך.",
"user_has_no_bio": "משתמש זה עדיין לא הוסיף ביוגרפיה.",
"delete_account_modal_title": "מחיקת החשבון",
"confirm_delete_account_modal": "בטוח שברצונך למחוק את חשבון {{appName}} שלך?",
"delete_my_account": "מחיקת החשבון שלי",
@@ -1426,6 +1458,7 @@
"add_limit": "הוספת הגבלה",
"team_name_required": "נדרש שם צוות",
"show_attendees": "שיתוף האורחים בפרטי המשתתפים",
"show_available_seats_count": "הצגת מספר המקומות הפנויים",
"how_booking_questions_as_variables": "איך משתמשים בשאלות הזמנה בתור משתנים?",
"format": "פורמט",
"uppercase_for_letters": "שימוש באותיות רישיות עבור כל האותיות",
@@ -1659,8 +1692,11 @@
"delete_sso_configuration_confirmation_description": "אתה בטוח שאתה רוצה למחוק את הגדרות {{connectionType}}? חברי הצוות שלך שמשתמשים ב- {{connectionType}} להזדהות לא יוכלו להשתמש בו להכנס ל- Cal.com.",
"organizer_timezone": "מארגן אזורי זמן",
"email_user_cta": "צפה בהזמנה",
"email_no_user_invite_heading_team": "הוזמנת להצטרף לצוות ב-{{appName}}",
"email_no_user_invite_heading_org": "הוזמנת להצטרף לארגון ב-{{appName}}",
"email_no_user_invite_subheading": "{{invitedBy}} הזמין אותך להצטרף לצוות שלו ב- {{appName}}. {{appName}} הינה מתזמן זימונים שמאפשר לך ולצוות שלך לזמן פגישות בלי כל הפינג פונג במיילים.",
"email_user_invite_subheading_team": "{{invitedBy}} הזמין/ה אותך להצטרף לצוות שלו/ה בשם '{{teamName}}' באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ולצוות שלך לתזמן פגישות בלי כל הפינג פונג במיילים.",
"email_user_invite_subheading_org": "{{invitedBy}} הזמין/ה אותך להצטרף לארגון שלו/ה בשם ״{{teamName}}״ באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ולארגון שלך לתזמן פגישות בלי הצורך לנהל התכתבויות ארוכות בדוא״ל.",
"email_no_user_invite_steps_intro": "נדריך אותך במספר קטן של צעדים ותוכל/י להתחיל ליהנות מקביעת מועדים עם ה-{{entity}} שלך במהירות ובלי בעיות.",
"email_no_user_step_one": "בחר שם משתמש",
"email_no_user_step_two": "קשר את לוח השנה שלך",
@@ -1831,6 +1867,8 @@
"insights_no_data_found_for_filter": "לא נמצאו נתונים עבור המסנן שנבחר או התאריכים שנבחרו.",
"acknowledge_booking_no_show_fee": "מובן לי שאם לא אשתתף באירוע הזה, דמי אי-הגעה בסך {{amount, currency}} ינוכו מהכרטיס שלי.",
"card_details": "פרטי כרטיס",
"something_went_wrong_on_our_end": "משהו השתבש בצד שלנו. פנה/י למחלקת התמיכה שלנו, ואנחנו נפתור זאת מיד עבורך.",
"please_provide_following_text_to_suppport": "כשפונים לתמיכה, יש לספק את הטקסט הבא כדי שנוכל לסייע לך בצורה יעילה יותר",
"seats_and_no_show_fee_error": "נכון לעכשיו, אי אפשר להפעיל מקומות ולחייב דמי אי-הגעה",
"complete_your_booking": "יש להשלים את ההזמנה",
"complete_your_booking_subject": "יש להשלים את ההזמנה: {{title}} ב-{{date}}",
@@ -1864,6 +1902,9 @@
"open_dialog_with_element_click": "לפתוח תיבת דו-שיח ב-Cal כשמישהו לוחץ על רכיב.",
"need_help_embedding": "זקוק/ה לעזרה? ניתן לעיין במדריכים שלנו בנושא הטמעת Cal ב-Wix, ב-Squarespace או ב-WordPress, לקרוא את דף השאלות הנפוצות או לסקור אפשרויות הטמעה מתקדמות.",
"book_my_cal": "הזמנה ב-Cal",
"first_name": "שם פרטי",
"last_name": "שם משפחה",
"first_last_name": "שם פרטי, שם משפחה",
"invite_as": "הזמנה בְתור",
"form_updated_successfully": "עדכון הטופס בוצע בהצלחה.",
"disable_attendees_confirmation_emails": "השבתת הודעות דוא\"ל לאישור שנשלחות כברירת מחדל עבור המשתתפים",
@@ -1877,6 +1918,7 @@
"first_event_type_webhook_description": "צור/צרי את ה-Webhook הראשון שלך עבור סוג האירוע הזה",
"install_app_on": "התקנת האפליקציה ב-",
"create_for": "צור/צרי עבור",
"currency": "מטבע",
"organization_banner_description": "צור/צרי סביבות שבהן הצוותים שלך יוכלו ליצור אפליקציות, תהליכי עבודה וסוגי אירועים משותפים, עם תכונות כמו סבב וקביעת מועדים שיתופית.",
"organization_banner_title": "ניהול ארגונים עם צוותים מרובים",
"set_up_your_organization": "הגדרת הארגון שלך",
@@ -1937,6 +1979,74 @@
"insights_team_filter": "צוות: {{teamName}}",
"insights_user_filter": "משתמש: {{userName}}",
"insights_subtitle": "הצגת insights לגבי ההזמנות באירועים שלך",
"location_options": "{{locationCount}} אפשרויות מיקום",
"custom_plan": "חבילה בהתאמה אישית",
"email_embed": "הטבעה בדוא\"ל",
"add_times_to_your_email": "בחר/י כמה מועדים פנויים והטבע/י אותם בדוא\"ל",
"select_time": "בחירת שעה",
"select_date": "בחירת תאריך",
"see_all_available_times": "לצפייה בכל המועדים הפנויים",
"org_team_names_example": "לדוגמה, מחלקת שיווק",
"org_team_names_example_1": "לדוגמה, מחלקת שיווק",
"org_team_names_example_2": "לדוגמה, מחלקת מכירות",
"org_team_names_example_3": "לדוגמה, מחלקת עיצוב",
"org_team_names_example_4": "לדוגמה, מחלקת הנדסה",
"org_team_names_example_5": "לדוגמה, מחלקת ניתוח נתונים",
"org_max_team_warnings": "תהיה אפשרות להוסיף עוד מחלקות או צוותים מאוחר יותר.",
"what_is_this_meeting_about": "במה עוסקת הפגישה הזו?",
"add_to_team": "הוספה לצוות",
"remove_users_from_org": "הסרת משתמשים מהארגון",
"remove_users_from_org_confirm": "בטוח שברצונך להסיר {{userCount}} משתמשים מהארגון הזה?",
"user_has_no_schedules": "משתמש זה עדיין לא הגדיר לוחות זמנים",
"user_isnt_in_any_teams": "משתמש זה לא שייך לאף צוות",
"requires_booker_email_verification": "מחייב אימות של כתובת הדוא\"ל של המזמין",
"description_requires_booker_email_verification": "כדי להבטיח אימות של כתובת הדוא\"ל של המזמין לפני תזמון אירועים",
"requires_confirmation_mandatory": "ניתן לשלוח הודעות טקסט למשתתפים רק כאשר סוג האירוע מחייב אישור.",
"organizations": "ארגונים",
"org_admin_other_teams": "צוותים אחרים",
"org_admin_other_teams_description": "כאן תוכל/י לראות צוותים בארגון שאינך שייך/ת אליהם. יש לך אפשרות להוסיף את עצמך, במקרה הצורך.",
"no_other_teams_found": "לא נמצא אף צוות אחר",
"no_other_teams_found_description": "אין צוותים אחרים בארגון הזה.",
"attendee_first_name_variable": "השם הפרטי של המשתתף",
"attendee_last_name_variable": "שם המשפחה של המשתתף",
"attendee_first_name_info": "השם הפרטי של האדם שביצע את ההזמנה",
"attendee_last_name_info": "שם המשפחה של האדם שביצע את ההזמנה",
"me": "אני",
"verify_team_tooltip": "אמת/י את הצוות שלך כדי לאפשר שליחת הודעות למשתתפים",
"member_removed": "החבר הוסר",
"my_availability": "הזמינות שלי",
"team_availability": "הזמינות של הצוות",
"backup_code": "קוד גיבוי",
"backup_codes": "קודי גיבוי",
"backup_code_instructions": "כל קוד גיבוי יכול לשמש פעם אחת בלבד להענקת גישה בלי היישום המאמת.",
"backup_codes_copied": "קודי הגיבוי הועתקו!",
"incorrect_backup_code": "קוד הגיבוי שגוי.",
"lost_access": "הגישה אבדה",
"missing_backup_codes": "לא נמצאו קודי גיבוי. צור/י אותם בהגדרות.",
"admin_org_notification_email_subject": "נוצר ארגון חדש: בהמתנה לפעולה",
"hi_admin": "שלום, מנהל/ת מערכת",
"admin_org_notification_email_title": "ארגון מחייב הגדרת DNS",
"admin_org_notification_email_body_part1": "נוצר ארגון עם רכיב ה-slug ‏\"{{orgSlug}}\".<br /><br />חשוב להקפיד להגדיר את רשם ה-DNS כך שיפנה את התת-דומיין המקביל לארגון החדש למיקום שבו האפליקציה הראשית פועלת. אחרת, הארגון לא יוכל לפעול.<br /><br />לפניך פירוט של האפשרויות הבסיסיות ממש להגדרת תת-דומיין כך שיפנה לאפליקציה שלו על מנת שדף הפרופיל של הארגון ייטען.<br /><br />אפשר לעשות את זה עם רשומת A:",
"admin_org_notification_email_body_part2": "או רשומת CNAME:",
"admin_org_notification_email_body_part3": "לאחר שתגדיר/י את התת-דומיין, יש לסמן שתצורת DNS הושלמה בהגדרות מנהלי המערכת של הארגון.",
"admin_org_notification_email_cta": "עבור/י אל הגדרות מנהלי המערכת של הארגון",
"org_has_been_processed": "עיבוד הארגון הושלם",
"org_error_processing": "היתה שגיאה בעיבוד של ארגון זה",
"orgs_page_description": "רשימה של כל הארגונים. קבלת ארגון תאפשר לכל המשתמשים מאותו דומיין דוא\"ל להירשם בלי להצטרך לבצע אימות של כתובת הדוא\"ל.",
"unverified": "לא אומת",
"dns_missing": "DNS חסר",
"mark_dns_configured": "סימון כי DNS הוגדר",
"value": "ערך",
"your_organization_updated_sucessfully": "עדכון הארגון שלך בוצע בהצלחה",
"team_no_event_types": "אין לצוות זה אף סוג של אירוע",
"seat_options_doesnt_multiple_durations": "האפשרויות של הושבה במקומות לא תומכות במשכי זמן שונים",
"include_calendar_event": "כלילת אירוע מלוח השנה",
"recently_added": "נוספו לאחרונה",
"no_members_found": "לא נמצא אף חבר",
"event_setup_length_error": "הגדרת אירוע: משך הזמן חייב להיות לפחות דקה אחת.",
"availability_schedules": "לוחות זמנים לזמינוּת",
"view_only_edit_availability_not_onboarded": "משתמש זה לא השלים תהליך הטמעה. לא תהיה לך אפשרות להגדיר את הזמינות שלו עד שהוא יעשה זאת.",
"view_only_edit_availability": "את/ה צופה בזמינות של משתמש זה. יש לך אפשרות לערוך רק את פרטי הזמינות שלך.",
"edit_users_availability": "עריכת הזמינות של משתמש: {{username}}",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -166,7 +166,7 @@
"starting": "Kezdés",
"length": "Hossz",
"delete_account": "Fiók törlése",
"delete_account_confirmation_message": "Biztosan törölnéd a {{appName}} fiókod? Senki akinek elküldted a fiókod linkjét nem fog tudni időpontot foglalni, az eddig elmentett beállításaid is el fognak veszni.",
"delete_account_confirmation_message": "Senki akinek elküldted a fiókod linkjét nem fog tudni időpontot foglalni, az eddig elmentett beállításaid is el fognak veszni.",
"apps": "Alkalmazások",
"app_store": "App Store",
"settings": "Beállítások",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Sì, elimina",
"delete_account": "Elimina account",
"confirm_delete_account": "Sì, elimina account",
"delete_account_confirmation_message": "Sei sicuro di voler eliminare il tuo account {{appName}}? Chiunque abbia il link del tuo account non potrà più utilizzarlo per prenotare e le preferenze che hai salvato andranno perse.",
"delete_account_confirmation_message": "Chiunque abbia il link del tuo account non potrà più utilizzarlo per prenotare e le preferenze che hai salvato andranno perse.",
"integrations": "Integrazioni",
"apps": "App",
"apps_description": "Qui puoi trovare l'elenco delle tue app",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Richiede la verifica dell'indirizzo e-mail della persona che prenota",
"description_requires_booker_email_verification": "Per garantire la verifica dell'indirizzo e-mail della persona che prenota prima di panificare eventi",
"requires_confirmation_mandatory": "I messaggi di testo possono solo essere inviati ai partecipanti quando il tipo di evento richiede una conferma.",
"kyc_verification_information": "Per garantire la sicurezza, è necessario verificare {{teamOrAccount}} prima di inviare messaggi di testo ai partecipanti. Contattaci all'indirizzo <a>{{supportEmail}}</a> fornendo le seguenti informazioni:",
"kyc_verification_documents": "<ul><li>Il tuo {{teamOrUser}}</li><li>Per le aziende: allegare la documentazione di verifica aziendale</li><li>Per gli utenti privati: allegare un documento di identità rilasciato dal governo</li></ul>",
"verify_team_or_account": "Verifica {{teamOrAccount}}",
"verify_account": "Verifica account",
"kyc_verification": "Verifica KYC",
"organizations": "Organizzazioni",
"org_admin_other_teams": "Atri team",
"org_admin_other_teams_description": "Qui puoi vedere i team di cui non fai parte all'interno della tua organizzazione. Puoi entrare a far parte di tali team se necessario.",
+8 -13
View File
@@ -84,7 +84,7 @@
"event_awaiting_approval_recurring": "繰り返しイベントはあなたの承認待ちです",
"someone_requested_an_event": "別のユーザーがあなたのカレンダーにイベントを予定するリクエストしています。",
"someone_requested_password_reset": "別のユーザーがあなたのパスワードを変更するためのリンクをリクエストしています。",
"password_reset_email_sent": "このメールがシステムに存在する場合は、リセットメールを受信するはずです。",
"password_reset_email_sent": "このメールがシステムに存在する場合は、リセットメールを受け取るはずです。",
"password_reset_instructions": "このリクエストをしていない場合は、このメールを無視しても大丈夫です。パスワードも変更されません。",
"event_awaiting_approval_subject": "承認を待っています: {{date}} の {{title}}",
"event_still_awaiting_approval": "イベントはまだあなたの承認待ちです",
@@ -255,7 +255,7 @@
"yours": "ご利用のアカウント",
"available_apps": "利用可能なアプリ",
"available_apps_lower_case": "利用できるアプリ",
"available_apps_desc": "インストールされているアプリがありません。以下で人気アプリを確認して、さらに <1>App Store</1> でご覧ください",
"available_apps_desc": "インストールされているアプリがありません。以下で人気アプリを確認してから <1>App Store</1> でさらにご覧ください",
"fixed_host_helper": "イベントに出席する必要がある人を追加します。<1>さらに詳しく</1>",
"round_robin_helper": "グループの人々は順番に交代し、イベントには1人だけが出席します。",
"check_email_reset_password": "メールをご確認ください。パスワードをリセットするためのリンクを送信しました。",
@@ -283,10 +283,10 @@
"connect": "接続",
"try_for_free": "無料で試す",
"create_booking_link_with_calcom": "{{appName}} で独自の予約リンクを作成する",
"who": "誰が",
"what": "何を",
"when": "いつ",
"where": "どこで",
"who": "ホスト",
"what": "イベント",
"when": "日時",
"where": "参加方法",
"add_to_calendar": "カレンダーに追加",
"add_another_calendar": "別のカレンダーを追加",
"other": "その他",
@@ -298,7 +298,7 @@
"needs_to_be_confirmed_or_rejected_recurring": "繰り返しミーティングは、まだ確認または拒否が必要です。",
"user_needs_to_confirm_or_reject_booking": "{{user}} はまだ予約を承認または拒否する必要があります",
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} は、まだ繰り返しミーティングの各予約を確認または拒否する必要があります。",
"meeting_is_scheduled": "このミーティングは予定されています",
"meeting_is_scheduled": "このミーティングを予定しました",
"meeting_is_scheduled_recurring": "繰り返しイベントがスケジュールされています",
"booking_submitted": "予約が送信されました",
"booking_submitted_recurring": "繰り返しミーティングが送信されました",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "はい、削除します",
"delete_account": "アカウントを削除する",
"confirm_delete_account": "はい。アカウントを削除します",
"delete_account_confirmation_message": "あなたの {{appName}} アカウントを本当に削除してもよろしいですか?あなたのアカウントリンクを共有しているユーザーは、そのアカウントを使用して予約を行うことができなくなり、保存していた設定も失われます。",
"delete_account_confirmation_message": "あなたのアカウントリンクを共有しているユーザーは、そのアカウントを使用して予約を行うことができなくなり、保存していた設定も失われます。",
"integrations": "連携",
"apps": "アプリ",
"apps_description": "ここにはアプリの一覧が表示されます",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "予約者のメール確認が必要です",
"description_requires_booker_email_verification": "イベントをスケジュール設定する前に、予約者のメールを確認しておくため",
"requires_confirmation_mandatory": "イベントタイプが確認を必要とする場合にのみ、出席者にテキストメッセージが送信されることがあります。",
"kyc_verification_information": "セキュリティを確保するため、出席者にテキストメッセージを送信する前に {{teamOrAccount}} を確認する必要があります。<a>{{supportEmail}}</a> まで連絡して、以下の情報をご提供ください:",
"kyc_verification_documents": "<ul><li>{{teamOrUser}}</li><li>企業の場合:企業確認書類を添付してください</li><li>個人の場合:政府発行の身分証明書を添付してください</li></ul>",
"verify_team_or_account": "{{teamOrAccount}}を確認",
"verify_account": "アカウントを確認",
"kyc_verification": "本人確認",
"organizations": "組織",
"org_admin_other_teams": "他のチーム",
"org_admin_other_teams_description": "こちらでは、組織内であなたが所属していないチームを確認できます。必要なら、これらのチームに自分自身を追加することができます。",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "네, 삭제합니다",
"delete_account": "계정 삭제",
"confirm_delete_account": "예, 계정을 삭제합니다",
"delete_account_confirmation_message": "{{appName}} 계정을 삭제하시겠습니까? 계정 링크를 공유한 사람은 더 이상 이 링크를 사용하여 예약할 수 없으며 저장한 모든 기본 설정이 손실됩니다.",
"delete_account_confirmation_message": "계정 링크를 공유한 사람은 더 이상 이 링크를 사용하여 예약할 수 없으며 저장한 모든 기본 설정이 손실됩니다.",
"integrations": "통합",
"apps": "앱",
"apps_description": "여기에서 앱 목록을 찾을 수 있습니다.",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "예약자 이메일 인증이 필요합니다",
"description_requires_booker_email_verification": "이벤트를 예약하기 전 예약자의 이메일 인증하기",
"requires_confirmation_mandatory": "문자 메시지는 이벤트 유형에 확인이 필요한 경우에만 참석자에게 보낼 수 있습니다.",
"kyc_verification_information": "보안이 되게 하려면 참석자에게 문자 메시지를 보내기 전에 {{teamOrAccount}} 인증은 필수입니다. <a>{{supportEmail}}</a>에 연락하여 다음 정보를 제공해 주시기 바랍니다.",
"kyc_verification_documents": "<ul><li>귀하의 {{teamOrUser}}</li><li>기업의 경우: 비즈니스 확인 서류 첨부</li><li>개인의 경우: 정부 발급 신분증 첨부</li></ul >",
"verify_team_or_account": "{{teamOrAccount}} 인증",
"verify_account": "계정 인증",
"kyc_verification": "KYC 인증",
"organizations": "조직",
"org_admin_other_teams": "다른 팀",
"org_admin_other_teams_description": "여기에서 귀하의 소속이 아닌 조직 내부의 팀을 볼 수 있습니다. 필요한 경우 여기에 자신을 추가할 수 있습니다.",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Ja, verwijderen",
"delete_account": "Account verwijderen",
"confirm_delete_account": "Ja, account verwijderen",
"delete_account_confirmation_message": "Weet u zeker dat u uw {{appName}}-account wilt verwijderen? Iedereen met wie u uw accountlink gedeeld heeft, kan er niet meer mee boeken en alle voorkeuren die u opgeslagen heeft gaan verloren.",
"delete_account_confirmation_message": "Iedereen met wie u uw accountlink gedeeld heeft, kan er niet meer mee boeken en alle voorkeuren die u opgeslagen heeft gaan verloren.",
"integrations": "Integraties",
"apps": "Apps",
"apps_description": "Hier vindt u een lijst van uw apps",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "E-mailverificatie door boeker vereist",
"description_requires_booker_email_verification": "Om te zorgen voor de e-mailverificatie door de boeker voordat gebeurtenissen worden gepland",
"requires_confirmation_mandatory": "Sms-berichten kunnen alleen naar deelnemers worden verzonden als het gebeurtenistype bevestiging vereist.",
"kyc_verification_information": "Om de veiligheid te garanderen, moet u uw {{teamOrAccount}} verifiëren voordat u sms-berichten naar deelnemers verstuurd. Neem contact met ons op via <a>{{supportEmail}}</a> en verstrek de volgende gegevens:",
"kyc_verification_documents": "<ul><li>Uw {{teamOrUser}}</li><li>Voor bedrijven: voeg uw bedrijfsverificatiedocumentatie bij</li><li>Voor particulieren: voeg een door de overheid uitgegeven identiteitsbewijs bij</li></ul>",
"verify_team_or_account": "{{teamOrAccount}} verifiëren",
"verify_account": "Account verifiëren",
"kyc_verification": "KYC-verificatie",
"organizations": "Organisaties",
"org_admin_other_teams": "Andere teams",
"org_admin_other_teams_description": "Hier kunt u teams binnen uw organisatie zien waar u geen deel van uitmaakt. U kunt uzelf aan hen toevoegen indien nodig.",
@@ -692,7 +692,7 @@
"slot_interval_default": "Bruk hendelseslengde (standard)",
"delete_account": "Slett konto",
"confirm_delete_account": "Ja, slett konto",
"delete_account_confirmation_message": "Er du sikker på at du vil slette {{appName}}-kontoen din? Alle du har delt konto-lenken med, vil ikke lenger kunne booke ved å bruke den, og eventuelle innstillinger du har lagret vil gå tapt.",
"delete_account_confirmation_message": "Alle du har delt konto-lenken med, vil ikke lenger kunne booke ved å bruke den, og eventuelle innstillinger du har lagret vil gå tapt.",
"integrations": "Integrasjoner",
"apps": "Apper",
"apps_description": "Her finner du en liste over appene dine",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Tak, usuń",
"delete_account": "Usuń konto",
"confirm_delete_account": "Tak, usuń konto",
"delete_account_confirmation_message": "Czy na pewno chcesz usunąć swój {{appName}}. umówić konto? Każdy, kto udostępnił link do Twojego konta, nie będzie już mógł zarezerwować za jego pomocą i wszystkie zapisane preferencje zostaną utracone.",
"delete_account_confirmation_message": "Każdy, kto udostępnił link do Twojego konta, nie będzie już mógł zarezerwować za jego pomocą i wszystkie zapisane preferencje zostaną utracone.",
"integrations": "Integracje",
"apps": "Aplikacje",
"apps_description": "Tutaj znajdziesz listę Twoich aplikacji",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Wymaga weryfikacji adresu e-mail rezerwującego.",
"description_requires_booker_email_verification": "W celu weryfikacji adresu e-mail rezerwującego przed wydarzeniami z harmonogramu.",
"requires_confirmation_mandatory": "Wiadomości tekstowe można wysyłać do uczestników tylko wtedy, gdy typ zdarzenia wymaga potwierdzenia.",
"kyc_verification_information": "Ze względów bezpieczeństwa musisz zweryfikować {{teamOrAccount}}, zanim wyślesz wiadomości tekstowe do uczestników. Napisz na adres <a>{{supportEmail}}</a> i podaj następujące informacje:",
"kyc_verification_documents": "<ul><li>Twój adres: {{teamOrUser}}</li><li>Firmy: dołącz dokumenty weryfikacyjne firmy</li><li>Osoby fizyczne: dołącz skan dokumentu tożsamości</li></ul>",
"verify_team_or_account": "Zweryfikuj {{teamOrAccount}}",
"verify_account": "Zweryfikuj konto",
"kyc_verification": "Weryfikacja KYC",
"organizations": "Organizacje",
"org_admin_other_teams": "Inne zespoły",
"org_admin_other_teams_description": "Tutaj możesz zobaczyć zespoły wewnątrz organizacji, do których nie należy. W razie potrzeby możesz do nich dołączyć.",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Sim, excluir",
"delete_account": "Apagar conta",
"confirm_delete_account": "Sim, apagar conta",
"delete_account_confirmation_message": "Você tem certeza de que deseja apagar sua conta {{appName}}? Qualquer pessoa que você compartilhou seu link de reservas não poderá mais utilizá-lo para agendar novos eventos e todas as suas preferências serão perdidas.",
"delete_account_confirmation_message": "Qualquer pessoa que você compartilhou seu link de reservas não poderá mais utilizá-lo para agendar novos eventos e todas as suas preferências serão perdidas.",
"integrations": "Integrações",
"apps": "Apps",
"apps_description": "Aqui você poderá encontrar uma lista de todos os seus aplicativos",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Requer verificação de e-mail do reservante",
"description_requires_booker_email_verification": "Para assegurar a verificação do e-mail do reservante antes de agendar eventos",
"requires_confirmation_mandatory": "Mensagens de texto só podem ser enviadas a participantes quando este tipo de evento exigir confirmação.",
"kyc_verification_information": "Para assegurar a segurança, é preciso verificar {{teamOrAccount}} antes de enviar mensagens de texto aos participantes. Fale conosco via <a>{{supportEmail}}</a> e envie as seguintes informações:",
"kyc_verification_documents": "<ul><li>{{teamOrUser}}</li><li>para empresas: anexe a documentação de verificação da sua empresa</li><li>Para pessoas físicas: anexe um documento de identidade emitido pelo governo</li></ul>",
"verify_team_or_account": "Verificar {{teamOrAccount}}",
"verify_account": "Verificar Conta",
"kyc_verification": "Verificação \"conheça seu cliente\"",
"organizations": "Organizações",
"org_admin_other_teams": "Outros times",
"org_admin_other_teams_description": "Aqui você pode conferir os times da sua organização dos quais você não faz parte. Se necessário, você pode se adicionar a eles.",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Sim, eliminar",
"delete_account": "Eliminar conta",
"confirm_delete_account": "Sim, eliminar conta",
"delete_account_confirmation_message": "De certeza que quer eliminar a sua conta {{appName}}? Qualquer pessoa com quem tenha partilhado esta ligação de equipa deixará de poder fazer reservas através da respetiva ligação, e quaisquer preferências que tenha guardado serão perdidas.",
"delete_account_confirmation_message": "Qualquer pessoa com quem tenha partilhado esta ligação de equipa deixará de poder fazer reservas através da respetiva ligação, e quaisquer preferências que tenha guardado serão perdidas.",
"integrations": "Integrações",
"apps": "Aplicações",
"apps_description": "Aqui pode encontrar uma lista das suas apps",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Requer a confirmação do e-mail do responsável pela reserva",
"description_requires_booker_email_verification": "Para garantir a confirmação do e-mail do responsável pela reserva antes de agendar eventos",
"requires_confirmation_mandatory": "As mensagens de texto só podem ser enviadas aos participantes quando o tipo de evento requerer confirmação.",
"kyc_verification_information": "Para garantir a segurança, tem de verificar {{teamOrAccount}} antes de enviar mensagens de texto aos participantes. Contacte-nos em <a>{{supportEmail}}</a> e forneça as seguintes informações:",
"kyc_verification_documents": "<ul><li>{{teamOrUser}}</li><li>Para empresas: anexe a sua documentação de verificação empresarial</li><li>Para indivíduos: anexe um documento de identificação emitido pelo governo</li></ul>",
"verify_team_or_account": "Verificar {{teamOrAccount}}",
"verify_account": "Verificar conta",
"kyc_verification": "Verificação KYC",
"organizations": "Organizações",
"org_admin_other_teams": "Outras equipas",
"org_admin_other_teams_description": "Aqui pode ver equipas dentro da sua organização e das quais não faz parte. Pode adicionar-se às mesmas, se necessário.",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Da, șterge",
"delete_account": "Ștergeți contul",
"confirm_delete_account": "Da, ștergeți contul",
"delete_account_confirmation_message": "Sigur doriți să vă ștergeți contul {{appName}}? Persoanele cărora le-ați transmis linkul contului dvs. nu vor mai putea rezerva folosindu-l și orice preferințe pe care le-ați salvat se vor pierde.",
"delete_account_confirmation_message": "Persoanele cărora le-ați transmis linkul contului dvs. nu vor mai putea rezerva folosindu-l și orice preferințe pe care le-ați salvat se vor pierde.",
"integrations": "Integrări",
"apps": "Aplicații",
"apps_description": "Aici poți găsi o listă a aplicațiilor tale",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Necesită verificarea e-mailului persoanei care efectuează rezervarea",
"description_requires_booker_email_verification": "Pentru a asigura verificarea de e-mailului persoanei care efectuează rezervarea înainte de programarea evenimentelor",
"requires_confirmation_mandatory": "Mesajele text pot fi trimise participanților doar atunci când tipul de eveniment necesită confirmare.",
"kyc_verification_information": "Din motive de securitate, trebuie să verificați {{teamOrAccount}} înainte de a trimite mesaje text participanților. Contactați-ne la <a>{{supportEmail}}</a> și furnizați următoarele informații:",
"kyc_verification_documents": "<ul><li>{{teamOrUser}} dvs.</li><li>Pentru companii: atașați documentele de verificare a companiei</li><li>Pentru persoane fizice: atașați un act de identitate emis de autorități</li></ul>",
"verify_team_or_account": "Verificați {{teamOrAccount}}",
"verify_account": "Verificați contul",
"kyc_verification": "Verificare KYC",
"organizations": "Organizații",
"org_admin_other_teams": "Alte echipe",
"org_admin_other_teams_description": "Aici puteți vedea echipe din cadrul organizației dvs. din care nu faceți parte. Vă puteți adăuga la acestea dacă este cazul.",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Да, удалить",
"delete_account": "Удалить аккаунт",
"confirm_delete_account": "Да, удалить аккаунт",
"delete_account_confirmation_message": "Вы уверены, что хотите удалить свой {{appName}}-аккаунт? Любой, кто имеет ссылку на вашу учетную запись, больше не сможет забронировать встречу, и сохраненные настройки будут потеряны.",
"delete_account_confirmation_message": "Любой, кто имеет ссылку на вашу учетную запись, больше не сможет забронировать встречу, и сохраненные настройки будут потеряны.",
"integrations": "Интеграции",
"apps": "Приложения",
"apps_description": "Здесь показывается список ваших приложений",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Требуется подтверждение электронной почты пользователя, оформляющего бронирование",
"description_requires_booker_email_verification": "Чтобы подтвердить электронную почту пользователя, оформляющего бронирование, до планирования событий",
"requires_confirmation_mandatory": "Участникам можно отправлять сообщения, только если тип события предполагает подтверждение.",
"kyc_verification_information": "Из соображений безопасности вы сможете отправлять текстовые сообщения участникам только после верификации {{teamOrAccount}}. Свяжитесь с нами по <a>{{supportEmail}}</a> и предоставьте следующую информацию:",
"kyc_verification_documents": "<ul><li>{{teamOrUser}}</li><li>Для юридических лиц: Предоставьте документы о верификации компании</li><li>Для физических лиц: Предоставьте официальное удостоверение личности</li></ul>",
"verify_team_or_account": "Подтвердить {{teamOrAccount}}",
"verify_account": "Подтвердить аккаунт",
"kyc_verification": "Верификация KYC",
"organizations": "Организации",
"org_admin_other_teams": "Другие команды",
"org_admin_other_teams_description": "Это перечень команд вашей организации, в которые вы не входите. При необходимости можно добавить себя в эти команды.",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Da, izbriši",
"delete_account": "Izbriši nalog",
"confirm_delete_account": "Da, izbriši nalog",
"delete_account_confirmation_message": "Da li ste sigurni da želite izbrisati svoj {{appName}} nalog? S kim god ste podelili link ovog naloga više neće moći da ga koristi za rezervacije i sva podešavanja koja imate neće biti sačuvana.",
"delete_account_confirmation_message": "S kim god ste podelili link ovog naloga više neće moći da ga koristi za rezervacije i sva podešavanja koja imate neće biti sačuvana.",
"integrations": "Integracije",
"apps": "Aplikacije",
"apps_description": "Ovde možete pronaći spisak svojih aplikacija",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Zahteva imejl verifikaciju rezervacije",
"description_requires_booker_email_verification": "Da biste obezbedili verifikaciju imejla osobe koja rezerviše pre zakazivanja događaja",
"requires_confirmation_mandatory": "Poruke mogu da se šalju samo učesnicima kada tip događaja zahteva potvrdu.",
"kyc_verification_information": "Da bi osigurali bezbednost, morate da verifikujete svoj {{teamOrAccount}} pre slanja poruka učesnicima. Obratite nam se putem <a>{{supportEmail}}</a> i dostavite sledeće podatke:",
"kyc_verification_documents": "<ul><li>Vaš {{teamOrUser}}</li><li> za firme: Priložite dokumentaciju za poslovnu verifikaciju</li><li>Za pojedince: Priložite zvaničnu ličnu ispravu</li></ul>",
"verify_team_or_account": "Verifikuj {{teamOrAccount}}",
"verify_account": "Verifikuj nalog",
"kyc_verification": "KYC verifikacija",
"organizations": "Organizacije",
"org_admin_other_teams": "Drugi timovi",
"org_admin_other_teams_description": "Ovde možete da vidite timove u vašoj organizaciji gde niste član. Možete da se dodate ako je potrebno.",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Ja, radera",
"delete_account": "Radera konto",
"confirm_delete_account": "Ja, radera konto",
"delete_account_confirmation_message": "Är du säker på att du vill radera ditt {{appName}} konto? Alla som du har delat din kontolänk med kommer inte längre att kunna boka med hjälp av den och eventuella inställningar du har sparat kommer att gå förlorade.",
"delete_account_confirmation_message": "Alla som du har delat din kontolänk med kommer inte längre att kunna boka med hjälp av den och eventuella inställningar du har sparat kommer att gå förlorade.",
"integrations": "Integrationer",
"apps": "Appar",
"apps_description": "Här hittar du en lista med dina appar",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Kräver e-postverifiering för bokare",
"description_requires_booker_email_verification": "För att säkerställa bokarens e-postverifiering före schemaläggning av evenemang",
"requires_confirmation_mandatory": "Textmeddelanden kan endast skickas till deltagare när evenemangstypen kräver bekräftelse.",
"kyc_verification_information": "För att garantera säkerheten måste du verifiera ditt {{teamOrAccount}} innan du skickar textmeddelanden till deltagare. Kontakta oss på <a>{{supportEmail}}</a> och ange följande information:",
"kyc_verification_documents": "<ul><li>Din {{teamOrUser}}</li><li>För företag: Bifoga din dokumentation för verifiering av företag</li><li>För privatpersoner: Bifoga ett statligt utfärdat ID-kort</li></ul>",
"verify_team_or_account": "Verifiera {{teamOrAccount}}",
"verify_account": "Verifiera konto",
"kyc_verification": "KYC-verifiering",
"organizations": "Organisationer",
"org_admin_other_teams": "Andra team",
"org_admin_other_teams_description": "Här kan du se team i din organisation som du inte är en del av. Du kan lägga till dem om det behövs.",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Evet, sil",
"delete_account": "Hesabı sil",
"confirm_delete_account": "Evet, hesabı sil",
"delete_account_confirmation_message": "{{appName}} hesabınızı silmek istediğinizden emin misiniz? Hesap bağlantısı paylaştığınız herkes artık bu bağlantıyı kullanarak rezervasyon yapamayacak ve kaydettiğiniz tüm tercihler kaybolacak.",
"delete_account_confirmation_message": "Hesap bağlantısı paylaştığınız herkes artık bu bağlantıyı kullanarak rezervasyon yapamayacak ve kaydettiğiniz tüm tercihler kaybolacak.",
"integrations": "Entegrasyonlar",
"apps": "Uygulamalar",
"apps_description": "Cal örneğiniz için uygulamayı etkinleştirin",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Rezervasyonu yapan kişinin e-posta doğrulaması gereklidir",
"description_requires_booker_email_verification": "Etkinlikleri planlamadan önce rezervasyon yapan kişinin e-posta doğrulaması yapmasını sağlayın",
"requires_confirmation_mandatory": "Metin mesajları yalnızca katılımcılardan etkinlik türü onayı alınması gerektiğinde gönderilebilir.",
"kyc_verification_information": "Güvenliği sağlamak için katılımcılara kısa mesaj göndermeden önce {{teamOrAccount}} hesabınızı doğrulamanız gereklidir. Lütfen <a>{{supportEmail}}</a> adresinden bizimle iletişime geçin ve aşağıdaki bilgileri sağlayın:",
"kyc_verification_documents": "<ul><li>{{teamOrUser}} kullanıcınız</li><li>Kuruluşlar için: Doğrulama Belgelerinizi ekleyin</li><li>Bireyler için: Devlet tarafından verilmiş bir kimlik belgesini ekleyin</li></ul>",
"verify_team_or_account": "{{teamOrAccount}} hesabını doğrulayın",
"verify_account": "Hesabı Doğrula",
"kyc_verification": "KYC Doğrulaması",
"organizations": "Gruplar",
"org_admin_other_teams": "Diğer ekipler",
"org_admin_other_teams_description": "Burada, kuruluşunuzda katılmadığınız ekipleri görebilirsiniz. Gerekirse onlara katılabilirsiniz.",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "Так, видалити",
"delete_account": "Видалити обліковий запис",
"confirm_delete_account": "Так, видалити обліковий запис",
"delete_account_confirmation_message": "Справді видалити обліковий запис {{appName}}? Усі, кому ви надавали посилання на свій обліковий запис, більше не зможуть бронювати ваш час за його допомогою. Усі збережені налаштування буде втрачено.",
"delete_account_confirmation_message": "Усі, кому ви надавали посилання на свій обліковий запис, більше не зможуть бронювати ваш час за його допомогою. Усі збережені налаштування буде втрачено.",
"integrations": "Інтеграції",
"apps": "Додатки",
"apps_description": "Тут наведено список ваших додатків",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Потрібно підтвердження ел. адреси особи, які здійснює бронювання",
"description_requires_booker_email_verification": "Щоб забезпечити підтвердження ел. адреси особи, яка здійснює бронювання, до планування заходів",
"requires_confirmation_mandatory": "Текстові повідомлення можна надсилати лише учасникам, коли тип події вимагає підтвердження.",
"kyc_verification_information": "З міркувань безпеки потрібно підтвердити {{teamOrAccount}}, перш ніж надсилати текстові повідомлення учасникам. Зверніться до нас за адресою <a>{{supportEmail}}</a> і надайте таку інформацію:",
"kyc_verification_documents": "<ul><li>{{teamOrUser}}</li><li>Для бізнесу: надайте документи для перевірки бізнесу</li><li>Для фізичних осіб: надайте посвідчення особи державного зразка</li></ul>",
"verify_team_or_account": "Підтвердити {{teamOrAccount}}",
"verify_account": "Підтвердити обліковий запис",
"kyc_verification": "Перевірка KYC",
"organizations": "Організації",
"org_admin_other_teams": "Інші команди",
"org_admin_other_teams_description": "Тут ви можете побачити команди у вашій організації, до яких ви не приєдналися. За потреби ви можете приєднатися до них.",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "Yêu cầu xác minh email của người đặt hẹn",
"description_requires_booker_email_verification": "Nhằm bảo đảm việc xác minh email của người đặt hẹn trước khi lên lịch sự kiện",
"requires_confirmation_mandatory": "Tin nhắn văn bản chỉ có thể được gửi đến người tham dự khi loại sự kiện cần thao tác xác nhận.",
"kyc_verification_information": "Nhằm bảo đảm về tính bảo mật, bạn phải xác minh {{teamOrAccount}} trước khi gửi tin nhắn văn bản đến người tham dự. Vui lòng liên lạc chúng tôi theo địa chỉ <a>{{supportEmail}}</a> và cung cấp thông tin sau:",
"kyc_verification_documents": "<ul><li>{{teamOrUser}} của bạn</li><li>Cho doanh nghiệp: Đính kèm giấy tờ xác minh doanh nghiệp</li><li>Cho cá nhân: Đính kèm thẻ căn cước do chính phủ cấp</li></ul>",
"verify_team_or_account": "Xác minh {{teamOrAccount}}",
"verify_account": "Xác minh tài khoản",
"kyc_verification": "Xác minh KYC",
"organizations": "Tổ chức",
"org_admin_other_teams": "Nhóm khác",
"org_admin_other_teams_description": "Tại đây bạn có thể thấy các nhóm trong tổ chức, những nhóm mà bạn không phải thành viên. Bạn có thể tự thêm mình vào những nhóm đó nếu cần.",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "是,删除",
"delete_account": "删除账户",
"confirm_delete_account": "是,删除账户",
"delete_account_confirmation_message": "您确定要删除您的 {{appName}} 账户? 任何曾通过您的账户链接与您预约过的人都将无法再使用它和您预约,您保存的任何偏好设置都将丢失。",
"delete_account_confirmation_message": "任何曾通过您的账户链接与您预约过的人都将无法再使用它和您预约,您保存的任何偏好设置都将丢失。",
"integrations": "集成",
"apps": "应用",
"apps_description": "在此处可以找到您应用的列表",
@@ -1276,6 +1276,7 @@
"personal_cal_url": "我的个人 {{appName}} URL",
"bio_hint": "请写一段简单的自我介绍,该介绍将会展示在您的个人 url 页面上。",
"user_has_no_bio": "此用户尚未添加个人简历。",
"bio": "个人简历",
"delete_account_modal_title": "删除账户",
"confirm_delete_account_modal": "您确定要删除您的 {{appName}} 账户吗?",
"delete_my_account": "删除我的账户",
@@ -2002,11 +2003,6 @@
"requires_booker_email_verification": "需要预约者电子邮件验证",
"description_requires_booker_email_verification": "确保在安排活动前验证预约者的电子邮件",
"requires_confirmation_mandatory": "仅当活动类型需要确认时,才能向参与者发送短信。",
"kyc_verification_information": "为了确保安全,您在向参与者发送短信之前必须验证您的 {{teamOrAccount}}。请通过 <a>{{supportEmail}}</a> 联系我们并提供以下信息:",
"kyc_verification_documents": "<ul><li>您的 {{teamOrUser}}</li><li>对于企业:附上您的企业验证文件</li><li>对于个人:附上政府颁发的 ID</li></ul>",
"verify_team_or_account": "验证 {{teamOrAccount}}",
"verify_account": "验证账户",
"kyc_verification": "KYC 验证",
"organizations": "组织",
"org_admin_other_teams": "其他团队",
"org_admin_other_teams_description": "在此处可以看到您的组织内您不属于的团队。如果需要,您可以将自己添加到这些团队中。",
@@ -826,7 +826,7 @@
"confirm_delete_event_type": "是,刪除",
"delete_account": "刪除帳號",
"confirm_delete_account": "是,刪除帳號",
"delete_account_confirmation_message": "確定要刪除 {{appName}} 的帳號?任何分享過帳號連結的人再也無法以此進行預約,而且任何儲存過的偏好設定都會消失。",
"delete_account_confirmation_message": "任何分享過帳號連結的人再也無法以此進行預約,而且任何儲存過的偏好設定都會消失。",
"integrations": "整合",
"apps": "應用程式",
"apps_description": "您可以在這裡找到應用程式清單",
@@ -2002,11 +2002,6 @@
"requires_booker_email_verification": "需要預約者電子郵件驗證",
"description_requires_booker_email_verification": "如要在預定活動之前先確保預約者的電子郵件驗證",
"requires_confirmation_mandatory": "簡訊只會在活動類型需要確認時傳送給與會者。",
"kyc_verification_information": "為確保安全,您必須先驗證您的 {{teamOrAccount}},再傳送簡訊給與會者。請透過 <a>{{supportEmail}}</a> 聯絡我們,並提供下列資訊:",
"kyc_verification_documents": "<ul><li>您的 {{teamOrUser}}</li><li>若是企業組織:請附上您的企業驗證文件</li><li>若是個人:請附上政府核發的身分證件</li></ul>",
"verify_team_or_account": "驗證 {{teamOrAccount}}",
"verify_account": "驗證帳號",
"kyc_verification": "KYC 驗證",
"organizations": "組織",
"org_admin_other_teams": "其他團隊",
"org_admin_other_teams_description": "您可查看貴組織內您未加入的團隊。您可視需要自行加入其中的團隊。",

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