Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4f44e9a60 | ||
|
|
9c448e3b87 | ||
|
|
5deda33712 | ||
|
|
2335948099 | ||
|
|
8bbb38d2be | ||
|
|
a4035aef98 | ||
|
|
c57de8f43f | ||
|
|
2b1918f40a | ||
|
|
fb2c966e6c | ||
|
|
4f3cf4f948 | ||
|
|
4613ac34c9 | ||
|
|
d69ba825fe | ||
|
|
92a221197a | ||
|
|
b54f716fd7 | ||
|
|
57e712dd9e | ||
|
|
2021b641ce | ||
|
|
ef45cbfb3f | ||
|
|
ab17cb216f | ||
|
|
b04c870a3d | ||
|
|
09abb90c8b | ||
|
|
b8e3b549f8 | ||
|
|
403d08de41 | ||
|
|
d7c3132fa5 | ||
|
|
a2df323cf8 | ||
|
|
fe82d88adf | ||
|
|
01e2ba3fd9 | ||
|
|
c9b87bbf85 | ||
|
|
19e6dbb781 | ||
|
|
a3a49f9069 | ||
|
|
468da1f35a | ||
|
|
ec4e2f5996 | ||
|
|
8a413a97b4 | ||
|
|
c275981be6 | ||
|
|
ce13350d5d | ||
|
|
919066222b | ||
|
|
79b60adf9b | ||
|
|
1d882e6b3b | ||
|
|
7f7841fabc | ||
|
|
260064c836 | ||
|
|
8c8be1efa3 | ||
|
|
9084cc6677 | ||
|
|
fda3192d53 | ||
|
|
4acfb4b28c | ||
|
|
a0642598ab | ||
|
|
20282a16f7 | ||
|
|
69120095e4 | ||
|
|
bfc137c349 | ||
|
|
13caf6f2cd | ||
|
|
1d4eeab33e | ||
|
|
3bee2d705f | ||
|
|
9c621bf726 | ||
|
|
70461b2718 | ||
|
|
efb04d04ab | ||
|
|
538e3bf07c | ||
|
|
798707a553 | ||
|
|
5940f9bccd | ||
|
|
5e449670ea | ||
|
|
82d7a3db92 | ||
|
|
0b7e3510a5 | ||
|
|
e0c87cf664 | ||
|
|
0d653afd0c | ||
|
|
f414428dfe | ||
|
|
2a171e9284 | ||
|
|
faf974ca4c | ||
|
|
a78a7a1fa0 | ||
|
|
5943763726 | ||
|
|
2648e2770f | ||
|
|
4e7ab391ff | ||
|
|
578495de87 | ||
|
|
86b3a33ff6 | ||
|
|
94476565ad | ||
|
|
3b50fe075d | ||
|
|
96263b0cf7 |
No files matched your search
@@ -125,4 +125,5 @@ SALESFORCE_CONSUMER_SECRET=""
|
|||||||
ZOHOCRM_CLIENT_ID=""
|
ZOHOCRM_CLIENT_ID=""
|
||||||
ZOHOCRM_CLIENT_SECRET=""
|
ZOHOCRM_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
|
||||||
# *********************************************************************************************************
|
# *********************************************************************************************************
|
||||||
@@ -21,10 +21,18 @@ jobs:
|
|||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@v1.13.0
|
uses: crowdin/github-action@v1.13.0
|
||||||
with:
|
with:
|
||||||
|
# upload sources
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
|
|
||||||
|
# upload translations (& auto-approve 'em)
|
||||||
|
upload_translations_args: '--auto-approve-imported'
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
download_translations: true
|
|
||||||
push_translations: true
|
push_translations: true
|
||||||
|
|
||||||
|
# download translations
|
||||||
|
download_translations: true
|
||||||
|
|
||||||
|
# GH config
|
||||||
commit_message: "New Crowdin translations by Github Action"
|
commit_message: "New Crowdin translations by Github Action"
|
||||||
localization_branch_name: main
|
localization_branch_name: main
|
||||||
create_pull_request: false
|
create_pull_request: false
|
||||||
|
|||||||
File renamed without changes.
@@ -44,4 +44,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
message: |
|
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 }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
pr-message: |-
|
pr-message: |-
|
||||||
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
|
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: |
|
issue-message: |
|
||||||
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
|
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
|
||||||
+3
-1
@@ -5,7 +5,9 @@ tasks:
|
|||||||
next_auth_secret=$(openssl rand -base64 32) &&
|
next_auth_secret=$(openssl rand -base64 32) &&
|
||||||
calendso_encryption_key=$(openssl rand -base64 24) &&
|
calendso_encryption_key=$(openssl rand -base64 24) &&
|
||||||
sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$next_auth_secret|" \
|
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
|
command: yarn dx
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
_
|
|
||||||
@@ -504,6 +504,9 @@ For example, `Cal.com (support@cal.com)`.
|
|||||||
9. Click the "Save"/ "UPDATE" button at the bottom footer.
|
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.
|
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
|
### Obtaining Zoho Bigin Client ID and Secret
|
||||||
|
|
||||||
[Follow these steps](./packages/app-store/zoho-bigin/)
|
[Follow these steps](./packages/app-store/zoho-bigin/)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
BACKEND_URL=http://localhost:3002/api
|
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_ID=cal-ai
|
||||||
APP_URL=http://localhost:3000/apps/cal-ai
|
APP_URL=http://localhost:3000/apps/cal-ai
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Cal.com Email Assistant
|
# 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:
|
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
|
- 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 [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
||||||
- A default sender email (for example, `ai@cal.dev`)
|
- 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`.
|
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/ai",
|
"name": "@calcom/ai",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "Cal.com Inc.",
|
"author": "Cal.com Inc.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -18,21 +18,21 @@ export const POST = async (request: NextRequest) => {
|
|||||||
|
|
||||||
const json = await request.json();
|
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) {
|
if ((!message && !subject) || !user) {
|
||||||
return new NextResponse("Missing fields", { status: 400 });
|
return new NextResponse("Missing fields", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// Send response to user
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
subject: `Re: ${subject}`,
|
subject: `Re: ${subject}`,
|
||||||
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
||||||
to: user.email,
|
to: user.email,
|
||||||
from: replyTo,
|
from: agentEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new NextResponse("ok");
|
return new NextResponse("ok");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import prisma from "@calcom/prisma";
|
|||||||
import { env } from "../../../env.mjs";
|
import { env } from "../../../env.mjs";
|
||||||
import { fetchAvailability } from "../../../tools/getAvailability";
|
import { fetchAvailability } from "../../../tools/getAvailability";
|
||||||
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
||||||
|
import { extractUsers } from "../../../utils/extractUsers";
|
||||||
import getHostFromHeaders from "../../../utils/host";
|
import getHostFromHeaders from "../../../utils/host";
|
||||||
import now from "../../../utils/now";
|
import now from "../../../utils/now";
|
||||||
import sendEmail from "../../../utils/sendEmail";
|
import sendEmail from "../../../utils/sendEmail";
|
||||||
@@ -45,6 +46,7 @@ export const POST = async (request: NextRequest) => {
|
|||||||
select: {
|
select: {
|
||||||
email: true,
|
email: true,
|
||||||
id: true,
|
id: true,
|
||||||
|
username: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
credentials: {
|
credentials: {
|
||||||
select: {
|
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.
|
// User is not a cal.com user or is using an unverified email.
|
||||||
if (!signature || !user) {
|
if (!signature || !user) {
|
||||||
await sendEmail({
|
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}`,
|
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,
|
to: envelope.from,
|
||||||
from: aiEmail,
|
from: aiEmail,
|
||||||
});
|
});
|
||||||
@@ -76,9 +78,9 @@ export const POST = async (request: NextRequest) => {
|
|||||||
const url = env.APP_URL;
|
const url = env.APP_URL;
|
||||||
|
|
||||||
await sendEmail({
|
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}`,
|
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,
|
to: envelope.from,
|
||||||
from: aiEmail,
|
from: aiEmail,
|
||||||
});
|
});
|
||||||
@@ -89,7 +91,7 @@ export const POST = async (request: NextRequest) => {
|
|||||||
const { apiKey } = credential as { apiKey: string };
|
const { apiKey } = credential as { apiKey: string };
|
||||||
|
|
||||||
// Pre-fetch data relevant to most bookings.
|
// Pre-fetch data relevant to most bookings.
|
||||||
const [eventTypes, availability] = await Promise.all([
|
const [eventTypes, availability, users] = await Promise.all([
|
||||||
fetchEventTypes({
|
fetchEventTypes({
|
||||||
apiKey,
|
apiKey,
|
||||||
}),
|
}),
|
||||||
@@ -99,6 +101,7 @@ export const POST = async (request: NextRequest) => {
|
|||||||
dateFrom: now(user.timeZone),
|
dateFrom: now(user.timeZone),
|
||||||
dateTo: now(user.timeZone),
|
dateTo: now(user.timeZone),
|
||||||
}),
|
}),
|
||||||
|
extractUsers(`${parsed.text} ${parsed.subject}`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ("error" in availability) {
|
if ("error" in availability) {
|
||||||
@@ -138,9 +141,11 @@ export const POST = async (request: NextRequest) => {
|
|||||||
user: {
|
user: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
eventTypes,
|
eventTypes,
|
||||||
|
username: user.username,
|
||||||
timeZone: user.timeZone,
|
timeZone: user.timeZone,
|
||||||
workingHours,
|
workingHours,
|
||||||
},
|
},
|
||||||
|
users,
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const env = createEnv({
|
|||||||
*/
|
*/
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
BACKEND_URL: process.env.BACKEND_URL,
|
BACKEND_URL: process.env.BACKEND_URL,
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
APP_ID: process.env.APP_ID,
|
APP_ID: process.env.APP_ID,
|
||||||
APP_URL: process.env.APP_URL,
|
APP_URL: process.env.APP_URL,
|
||||||
PARSE_KEY: process.env.PARSE_KEY,
|
PARSE_KEY: process.env.PARSE_KEY,
|
||||||
@@ -32,6 +33,7 @@ export const env = createEnv({
|
|||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
BACKEND_URL: z.string().url(),
|
BACKEND_URL: z.string().url(),
|
||||||
|
FRONTEND_URL: z.string().url(),
|
||||||
APP_ID: z.string().min(1),
|
APP_ID: z.string().min(1),
|
||||||
APP_URL: z.string().url(),
|
APP_URL: z.string().url(),
|
||||||
PARSE_KEY: z.string().min(1),
|
PARSE_KEY: z.string().min(1),
|
||||||
|
|||||||
+25
-14
@@ -1,6 +1,8 @@
|
|||||||
import { DynamicStructuredTool } from "langchain/tools";
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { UserList } from "~/src/types/user";
|
||||||
|
|
||||||
import { env } from "../env.mjs";
|
import { env } from "../env.mjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,21 +11,23 @@ import { env } from "../env.mjs";
|
|||||||
const createBooking = async ({
|
const createBooking = async ({
|
||||||
apiKey,
|
apiKey,
|
||||||
userId,
|
userId,
|
||||||
|
users,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
timeZone,
|
timeZone,
|
||||||
language,
|
language,
|
||||||
responses,
|
invite,
|
||||||
}: {
|
}: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
users: UserList;
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
language: string;
|
language: string;
|
||||||
responses: { name?: string; email?: string; location?: string };
|
invite: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}): Promise<string | Error | { error: string }> => {
|
}): Promise<string | Error | { error: string }> => {
|
||||||
@@ -36,6 +40,18 @@ const createBooking = async ({
|
|||||||
|
|
||||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
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, {
|
const response = await fetch(url, {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
end,
|
end,
|
||||||
@@ -66,19 +82,19 @@ const createBooking = async ({
|
|||||||
return "Booking created";
|
return "Booking created";
|
||||||
};
|
};
|
||||||
|
|
||||||
const createBookingTool = (apiKey: string, userId: number) => {
|
const createBookingTool = (apiKey: string, userId: number, users: UserList) => {
|
||||||
return new DynamicStructuredTool({
|
return new DynamicStructuredTool({
|
||||||
description:
|
description: "Creates a booking on the primary user's calendar.",
|
||||||
"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, invite, title, status }) => {
|
||||||
func: async ({ eventTypeId, start, end, timeZone, language, responses, title, status }) => {
|
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
await createBooking({
|
await createBooking({
|
||||||
apiKey,
|
apiKey,
|
||||||
userId,
|
userId,
|
||||||
|
users,
|
||||||
end,
|
end,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
language,
|
language,
|
||||||
responses,
|
invite,
|
||||||
start,
|
start,
|
||||||
status,
|
status,
|
||||||
timeZone,
|
timeZone,
|
||||||
@@ -86,19 +102,14 @@ const createBookingTool = (apiKey: string, userId: number) => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
name: "createBookingIfAvailable",
|
name: "createBooking",
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
end: z
|
end: z
|
||||||
.string()
|
.string()
|
||||||
.describe("This should correspond to the event type's length, unless otherwise specified."),
|
.describe("This should correspond to the event type's length, unless otherwise specified."),
|
||||||
eventTypeId: z.number(),
|
eventTypeId: z.number(),
|
||||||
language: z.string(),
|
language: z.string(),
|
||||||
responses: z
|
invite: z.number().describe("External user id to invite."),
|
||||||
.object({
|
|
||||||
email: z.string().optional(),
|
|
||||||
name: z.string().optional(),
|
|
||||||
})
|
|
||||||
.describe("External invited user. Not the user making the request."),
|
|
||||||
start: z.string(),
|
start: z.string(),
|
||||||
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
||||||
timeZone: z.string(),
|
timeZone: z.string(),
|
||||||
@@ -12,13 +12,11 @@ export const fetchAvailability = async ({
|
|||||||
userId,
|
userId,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
eventTypeId,
|
|
||||||
}: {
|
}: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
dateFrom: string;
|
dateFrom: string;
|
||||||
dateTo: string;
|
dateTo: string;
|
||||||
eventTypeId?: number;
|
|
||||||
}): Promise<Partial<Availability> | { error: string }> => {
|
}): Promise<Partial<Availability> | { error: string }> => {
|
||||||
const params: { [k: string]: string } = {
|
const params: { [k: string]: string } = {
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -27,8 +25,6 @@ export const fetchAvailability = async ({
|
|||||||
dateTo,
|
dateTo,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
|
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(params);
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
|
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({
|
return new DynamicStructuredTool({
|
||||||
description: "Get availability within range.",
|
description: "Get availability of users within range.",
|
||||||
func: async ({ dateFrom, dateTo, eventTypeId }) => {
|
func: async ({ userIds, dateFrom, dateTo }) => {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
await fetchAvailability({
|
await Promise.all(
|
||||||
apiKey,
|
userIds.map(
|
||||||
userId,
|
async (userId) =>
|
||||||
dateFrom,
|
await fetchAvailability({
|
||||||
dateTo,
|
userId: userId,
|
||||||
eventTypeId,
|
apiKey,
|
||||||
})
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
name: "getAvailability",
|
name: "getAvailability",
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
|
userIds: z.array(z.number()).describe("The users to fetch availability for."),
|
||||||
dateFrom: z.string(),
|
dateFrom: z.string(),
|
||||||
dateTo: 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."
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const fetchBookings = async ({
|
|||||||
|
|
||||||
const getBookingsTool = (apiKey: string, userId: number) => {
|
const getBookingsTool = (apiKey: string, userId: number) => {
|
||||||
return new DynamicStructuredTool({
|
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 }) => {
|
func: async ({ from, to }) => {
|
||||||
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
|
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import type { EventType } from "../types/eventType";
|
|||||||
/**
|
/**
|
||||||
* Fetches event types by user ID.
|
* Fetches event types by user ID.
|
||||||
*/
|
*/
|
||||||
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; userId?: number }) => {
|
||||||
const params = {
|
const params: Record<string, string> = {
|
||||||
apiKey,
|
apiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
params["userId"] = userId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(params);
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
|
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) => ({
|
return data.event_types.map((eventType: EventType) => ({
|
||||||
id: eventType.id,
|
id: eventType.id,
|
||||||
|
slug: eventType.slug,
|
||||||
length: eventType.length,
|
length: eventType.length,
|
||||||
title: eventType.title,
|
title: eventType.title,
|
||||||
}));
|
}));
|
||||||
@@ -35,16 +40,19 @@ export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
|||||||
|
|
||||||
const getEventTypesTool = (apiKey: string) => {
|
const getEventTypesTool = (apiKey: string) => {
|
||||||
return new DynamicStructuredTool({
|
return new DynamicStructuredTool({
|
||||||
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
|
description: "Get a user's event type IDs. Usually necessary to book a meeting.",
|
||||||
func: async () => {
|
func: async ({ userId }) => {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
await fetchEventTypes({
|
await fetchEventTypes({
|
||||||
apiKey,
|
apiKey,
|
||||||
|
userId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
name: "getEventTypes",
|
name: "getEventTypes",
|
||||||
schema: z.object({}),
|
schema: z.object({
|
||||||
|
userId: z.number().optional().describe("The user ID. Defaults to the primary user's ID."),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -2,8 +2,17 @@ import type { EventType } from "./eventType";
|
|||||||
import type { WorkingHours } from "./workingHours";
|
import type { WorkingHours } from "./workingHours";
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
|
username: string;
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
eventTypes: EventType[];
|
eventTypes: EventType[];
|
||||||
workingHours: WorkingHours[];
|
workingHours: WorkingHours[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserList = {
|
||||||
|
id?: number;
|
||||||
|
email?: string;
|
||||||
|
username?: string;
|
||||||
|
type: "fromUsername" | "fromEmail";
|
||||||
|
}[];
|
||||||
+56
-23
@@ -2,30 +2,40 @@ import { initializeAgentExecutorWithOptions } from "langchain/agents";
|
|||||||
import { ChatOpenAI } from "langchain/chat_models/openai";
|
import { ChatOpenAI } from "langchain/chat_models/openai";
|
||||||
|
|
||||||
import { env } from "../env.mjs";
|
import { env } from "../env.mjs";
|
||||||
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
|
import createBookingIfAvailable from "../tools/createBooking";
|
||||||
import deleteBooking from "../tools/deleteBooking";
|
import deleteBooking from "../tools/deleteBooking";
|
||||||
import getAvailability from "../tools/getAvailability";
|
import getAvailability from "../tools/getAvailability";
|
||||||
import getBookings from "../tools/getBookings";
|
import getBookings from "../tools/getBookings";
|
||||||
|
import sendBookingLink from "../tools/sendBookingLink";
|
||||||
import updateBooking from "../tools/updateBooking";
|
import updateBooking from "../tools/updateBooking";
|
||||||
import type { EventType } from "../types/eventType";
|
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 type { WorkingHours } from "../types/workingHours";
|
||||||
import now from "./now";
|
import now from "./now";
|
||||||
|
|
||||||
const gptModel = "gpt-4";
|
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 a toolchain to book meetings, list available slots, etc.
|
||||||
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
|
* 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 = [
|
const tools = [
|
||||||
createBookingIfAvailable(apiKey, userId),
|
// getEventTypes(apiKey),
|
||||||
getAvailability(apiKey, userId),
|
getAvailability(apiKey),
|
||||||
getBookings(apiKey, userId),
|
getBookings(apiKey, userId),
|
||||||
|
createBookingIfAvailable(apiKey, userId, users),
|
||||||
updateBooking(apiKey, userId),
|
updateBooking(apiKey, userId),
|
||||||
deleteBooking(apiKey),
|
deleteBooking(apiKey),
|
||||||
|
sendBookingLink(apiKey, user, users, agentEmail),
|
||||||
];
|
];
|
||||||
|
|
||||||
const model = new ChatOpenAI({
|
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, {
|
const executor = await initializeAgentExecutorWithOptions(tools, model, {
|
||||||
agentArgs: {
|
agentArgs: {
|
||||||
prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email.
|
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.
|
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.
|
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.
|
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 primary user's id is: ${userId}
|
||||||
The user's time zone is: ${user.timeZone}
|
The primary user's username is: ${user.username}
|
||||||
The user's event types are: ${user.eventTypes
|
The current time in the primary user's timezone is: ${now(user.timeZone)}
|
||||||
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
|
The primary user's time zone is: ${user.timeZone}
|
||||||
.join("\n")}
|
The primary user's event types are: ${user.eventTypes
|
||||||
The user's working hours are: ${user.workingHours
|
.map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`)
|
||||||
.map(
|
.join("\n")}
|
||||||
(w: WorkingHours) =>
|
The primary user's working hours are: ${user.workingHours
|
||||||
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
.map(
|
||||||
w.startTime
|
(w: WorkingHours) =>
|
||||||
}, End Time (minutes in UTC): ${w.endTime}`
|
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
||||||
)
|
w.startTime
|
||||||
.join("\n")}
|
}, 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",
|
agentType: "openai-functions",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const context = { apiKey: "", userId: "" };
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -8,12 +8,14 @@ const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
|
|||||||
const send = async ({
|
const send = async ({
|
||||||
subject,
|
subject,
|
||||||
to,
|
to,
|
||||||
|
cc,
|
||||||
from,
|
from,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
}: {
|
}: {
|
||||||
subject: string;
|
subject: string;
|
||||||
to: string;
|
to: string | string[];
|
||||||
|
cc?: string | string[];
|
||||||
from: string;
|
from: string;
|
||||||
text: string;
|
text: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
@@ -22,9 +24,10 @@ const send = async ({
|
|||||||
|
|
||||||
const msg = {
|
const msg = {
|
||||||
to,
|
to,
|
||||||
|
cc,
|
||||||
from: {
|
from: {
|
||||||
email: from,
|
email: from,
|
||||||
name: "Cal AI",
|
name: "Cal.ai",
|
||||||
},
|
},
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /availability:
|
* /teams/{teamId}/availability:
|
||||||
* get:
|
* get:
|
||||||
* summary: Find user or team availability
|
* summary: Find team availability
|
||||||
* parameters:
|
* parameters:
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: apiKey
|
* name: apiKey
|
||||||
@@ -21,25 +21,14 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
|||||||
* type: string
|
* type: string
|
||||||
* example: "1234abcd5678efgh"
|
* example: "1234abcd5678efgh"
|
||||||
* description: Your API key
|
* description: Your API key
|
||||||
* - in: query
|
* - in: path
|
||||||
* name: userId
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* example: 101
|
|
||||||
* description: ID of the user to fetch the availability for
|
|
||||||
* - in: query
|
|
||||||
* name: teamId
|
* name: teamId
|
||||||
|
* required: true
|
||||||
* schema:
|
* schema:
|
||||||
* type: integer
|
* type: integer
|
||||||
* example: 123
|
* example: 123
|
||||||
* description: ID of the team to fetch the availability for
|
* description: ID of the team to fetch the availability for
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: username
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* example: "alice"
|
|
||||||
* description: username of the user to fetch the availability for
|
|
||||||
* - in: query
|
|
||||||
* name: dateFrom
|
* name: dateFrom
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
@@ -59,7 +48,7 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
|||||||
* type: integer
|
* type: integer
|
||||||
* example: 123
|
* example: 123
|
||||||
* description: Event Type ID of the event type to fetch the availability for
|
* description: Event Type ID of the event type to fetch the availability for
|
||||||
* operationId: availability
|
* operationId: team-availability
|
||||||
* tags:
|
* tags:
|
||||||
* - availability
|
* - availability
|
||||||
* responses:
|
* responses:
|
||||||
@@ -96,7 +85,89 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
|||||||
* 401:
|
* 401:
|
||||||
* description: Authorization information is missing or invalid.
|
* description: Authorization information is missing or invalid.
|
||||||
* 404:
|
* 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 {
|
interface MemberRoles {
|
||||||
[userId: number | string]: MembershipRole;
|
[userId: number | string]: MembershipRole;
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export default function AppListCard(props: AppListCardProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>
|
||||||
<div className="flex items-center gap-x-3 px-5 py-4">
|
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
|
||||||
{logo ? (
|
{logo ? (
|
||||||
<img
|
<img
|
||||||
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ export const AppPage = ({
|
|||||||
|
|
||||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||||
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
||||||
|
|
||||||
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
||||||
{ appType: type },
|
{ appType: type },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const InstallAppButtonChild = ({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
|
className="max-w-min"
|
||||||
onInteractOutside={(event) => {
|
onInteractOutside={(event) => {
|
||||||
if (mutation.isLoading) event.preventDefault();
|
if (mutation.isLoading) event.preventDefault();
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@calcom/ui";
|
} 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 { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
|
||||||
|
|
||||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||||
@@ -124,79 +124,81 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
|
|
||||||
const setEventName = (value: string) => formMethods.setValue("eventName", value);
|
const setEventName = (value: string) => formMethods.setValue("eventName", value);
|
||||||
return (
|
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
|
* 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).
|
* 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.
|
* This will fallback to each user selected destination calendar.
|
||||||
*/}
|
*/}
|
||||||
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||||
<div className="flex flex-col">
|
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
||||||
<div className="flex justify-between">
|
<div className="flex flex-col">
|
||||||
<Label>{t("add_to_calendar")}</Label>
|
<div className="flex justify-between">
|
||||||
<Link
|
<Label className="font-medium">{t("add_to_calendar")}</Label>
|
||||||
href="/apps/categories/calendar"
|
<Link
|
||||||
target="_blank"
|
href="/apps/categories/calendar"
|
||||||
className="hover:text-emphasis text-default text-sm">
|
target="_blank"
|
||||||
{t("add_another_calendar")}
|
className="hover:text-emphasis text-default text-sm">
|
||||||
</Link>
|
{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>
|
||||||
<div className="-mt-1 w-full">
|
)}
|
||||||
<Controller
|
<div className="w-full">
|
||||||
control={formMethods.control}
|
<TextField
|
||||||
name="destinationCalendar"
|
label={t("event_name_in_calendar")}
|
||||||
defaultValue={eventType.destinationCalendar || undefined}
|
type="text"
|
||||||
render={({ field: { onChange, value } }) => (
|
{...shouldLockDisableProps("eventName")}
|
||||||
<DestinationCalendarSelector
|
placeholder={eventNamePlaceholder}
|
||||||
destinationCalendar={eventType.destinationCalendar}
|
defaultValue={eventType.eventName || ""}
|
||||||
value={value ? value.externalId : undefined}
|
{...formMethods.register("eventName")}
|
||||||
onChange={onChange}
|
addOnSuffix={
|
||||||
hidePlaceholder
|
<Button
|
||||||
/>
|
color="minimal"
|
||||||
)}
|
size="sm"
|
||||||
/>
|
aria-label="edit custom name"
|
||||||
</div>
|
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||||
<p className="text-default text-sm">{t("select_which_cal")}</p>
|
onClick={() => setShowEventNameTip((old) => !old)}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<div className="w-full">
|
|
||||||
<TextField
|
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
|
||||||
label={t("event_name_in_calendar")}
|
|
||||||
type="text"
|
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||||
{...shouldLockDisableProps("eventName")}
|
<FormBuilder
|
||||||
placeholder={eventNamePlaceholder}
|
title={t("booking_questions_title")}
|
||||||
defaultValue={eventType.eventName || ""}
|
description={t("booking_questions_description")}
|
||||||
{...formMethods.register("eventName")}
|
addFieldLabel={t("add_a_booking_question")}
|
||||||
addOnSuffix={
|
formProp="bookingFields"
|
||||||
<Button
|
{...shouldLockDisableProps("bookingFields")}
|
||||||
color="minimal"
|
dataStore={{
|
||||||
size="sm"
|
options: {
|
||||||
aria-label="edit custom name"
|
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
|
||||||
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>
|
||||||
<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
|
<RequiresConfirmationController
|
||||||
eventType={eventType}
|
eventType={eventType}
|
||||||
seatsEnabled={seatsEnabled}
|
seatsEnabled={seatsEnabled}
|
||||||
@@ -204,13 +206,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
requiresConfirmation={requiresConfirmation}
|
requiresConfirmation={requiresConfirmation}
|
||||||
onRequiresConfirmation={setRequiresConfirmation}
|
onRequiresConfirmation={setRequiresConfirmation}
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="requiresBookerEmailVerification"
|
name="requiresBookerEmailVerification"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
defaultValue={eventType.requiresBookerEmailVerification}
|
defaultValue={eventType.requiresBookerEmailVerification}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||||
title={t("requires_booker_email_verification")}
|
title={t("requires_booker_email_verification")}
|
||||||
{...shouldLockDisableProps("requiresBookerEmailVerification")}
|
{...shouldLockDisableProps("requiresBookerEmailVerification")}
|
||||||
description={t("description_requires_booker_email_verification")}
|
description={t("description_requires_booker_email_verification")}
|
||||||
@@ -219,13 +223,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="hideCalendarNotes"
|
name="hideCalendarNotes"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
defaultValue={eventType.hideCalendarNotes}
|
defaultValue={eventType.hideCalendarNotes}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||||
title={t("disable_notes")}
|
title={t("disable_notes")}
|
||||||
{...shouldLockDisableProps("hideCalendarNotes")}
|
{...shouldLockDisableProps("hideCalendarNotes")}
|
||||||
description={t("disable_notes_description")}
|
description={t("disable_notes_description")}
|
||||||
@@ -234,13 +240,19 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="successRedirectUrl"
|
name="successRedirectUrl"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<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")}
|
title={t("redirect_success_booking")}
|
||||||
{...successRedirectUrlLocked}
|
{...successRedirectUrlLocked}
|
||||||
description={t("redirect_url_description")}
|
description={t("redirect_url_description")}
|
||||||
@@ -249,8 +261,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
setRedirectUrlVisible(e);
|
setRedirectUrlVisible(e);
|
||||||
onChange(e ? value : "");
|
onChange(e ? value : "");
|
||||||
}}>
|
}}>
|
||||||
{/* Textfield has some margin by default we remove that so we can keep consistent alignment */}
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
<div className="lg:-mb-2 lg:-ml-2">
|
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label={t("redirect_success_booking")}
|
label={t("redirect_success_booking")}
|
||||||
@@ -274,10 +285,24 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<SettingsToggle
|
<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"
|
data-testid="hashedLinkCheck"
|
||||||
title={t("private_link")}
|
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")}
|
{...shouldLockDisableProps("hashedLinkCheck")}
|
||||||
description={t("private_link_description", { appName: APP_NAME })}
|
description={t("private_link_description", { appName: APP_NAME })}
|
||||||
checked={hashedLinkVisible}
|
checked={hashedLinkVisible}
|
||||||
@@ -285,8 +310,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
||||||
setHashedLinkVisible(e);
|
setHashedLinkVisible(e);
|
||||||
}}>
|
}}>
|
||||||
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
<div className="lg:-ml-2">
|
|
||||||
{!IS_VISUAL_REGRESSION_TESTING && (
|
{!IS_VISUAL_REGRESSION_TESTING && (
|
||||||
<TextField
|
<TextField
|
||||||
disabled
|
disabled
|
||||||
@@ -321,7 +345,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="seatsPerTimeSlotEnabled"
|
name="seatsPerTimeSlotEnabled"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -329,6 +353,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<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"
|
data-testid="offer-seats-toggle"
|
||||||
title={t("offer_seats")}
|
title={t("offer_seats")}
|
||||||
{...seatsLocked}
|
{...seatsLocked}
|
||||||
@@ -349,45 +379,49 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
}
|
}
|
||||||
onChange(e);
|
onChange(e);
|
||||||
}}>
|
}}>
|
||||||
<Controller
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
name="seatsPerTimeSlot"
|
<Controller
|
||||||
control={formMethods.control}
|
name="seatsPerTimeSlot"
|
||||||
defaultValue={eventType.seatsPerTimeSlot}
|
control={formMethods.control}
|
||||||
render={({ field: { value, onChange } }) => (
|
defaultValue={eventType.seatsPerTimeSlot}
|
||||||
<div className="lg:-ml-2">
|
render={({ field: { value, onChange } }) => (
|
||||||
<TextField
|
<div className="lg:-ml-2">
|
||||||
required
|
<TextField
|
||||||
name="seatsPerTimeSlot"
|
required
|
||||||
labelSrOnly
|
name="seatsPerTimeSlot"
|
||||||
label={t("number_of_seats")}
|
labelSrOnly
|
||||||
type="number"
|
label={t("number_of_seats")}
|
||||||
disabled={seatsLocked.disabled}
|
type="number"
|
||||||
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}
|
disabled={seatsLocked.disabled}
|
||||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
defaultValue={value || 2}
|
||||||
defaultChecked={!!eventType.seatsShowAttendees}
|
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>
|
||||||
<div className="mt-2">
|
)}
|
||||||
<CheckboxField
|
/>
|
||||||
description={t("show_available_seats_count")}
|
</div>
|
||||||
disabled={seatsLocked.disabled}
|
|
||||||
onChange={(e) => formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)}
|
|
||||||
defaultChecked={!!eventType.seatsShowAvailabilityCount}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
{noShowFeeEnabled && <Alert severity="warning" title={t("seats_and_no_show_fee_error")} />}
|
{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) && (
|
{allowDisablingAttendeeConfirmationEmails(workflows) && (
|
||||||
<>
|
<>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="metadata.disableStandardEmails.confirmation.attendee"
|
name="metadata.disableStandardEmails.confirmation.attendee"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||||
title={t("disable_attendees_confirmation_emails")}
|
title={t("disable_attendees_confirmation_emails")}
|
||||||
description={t("disable_attendees_confirmation_emails_description")}
|
description={t("disable_attendees_confirmation_emails_description")}
|
||||||
checked={value || false}
|
checked={value || false}
|
||||||
@@ -417,7 +452,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
)}
|
)}
|
||||||
{allowDisablingHostConfirmationEmails(workflows) && (
|
{allowDisablingHostConfirmationEmails(workflows) && (
|
||||||
<>
|
<>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="metadata.disableStandardEmails.confirmation.host"
|
name="metadata.disableStandardEmails.confirmation.host"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -425,6 +459,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
|
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||||
title={t("disable_host_confirmation_emails")}
|
title={t("disable_host_confirmation_emails")}
|
||||||
description={t("disable_host_confirmation_emails_description")}
|
description={t("disable_host_confirmation_emails_description")}
|
||||||
checked={value || false}
|
checked={value || false}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!shouldLockDisableProps("apps").disabled && (
|
{!shouldLockDisableProps("apps").disabled && (
|
||||||
<div className="bg-muted rounded-md p-8">
|
<div className="bg-muted mt-6 rounded-md p-8">
|
||||||
{!isLoading && notInstalledApps?.length ? (
|
{!isLoading && notInstalledApps?.length ? (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-emphasis mb-2 text-xl font-semibold leading-5 tracking-[0.01em]">
|
<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>
|
</h2>
|
||||||
<p className="text-default mb-6 text-sm font-normal">
|
<p className="text-default mb-6 text-sm font-normal">
|
||||||
<Trans i18nKey="available_apps_desc">
|
<Trans i18nKey="available_apps_desc">
|
||||||
You have no apps installed. View popular apps below and explore more in our
|
View popular apps below and explore more in our
|
||||||
<Link className="cursor-pointer underline" href="/apps">
|
<Link className="cursor-pointer underline" href="/apps">
|
||||||
App Store
|
App Store
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
|
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 { Controller, useFormContext } from "react-hook-form";
|
||||||
import type { OptionProps, SingleValueProps } from "react-select";
|
import type { OptionProps, SingleValueProps } from "react-select";
|
||||||
import { components } 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)) || [];
|
schedule?.schedule.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-default space-y-4 rounded border px-6 pb-4">
|
<div>
|
||||||
<ol className="table border-collapse text-sm">
|
<div className="border-subtle space-y-4 border-x p-6">
|
||||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
<ol className="table border-collapse text-sm">
|
||||||
const isAvailable = !!filterDays(index).length;
|
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||||
return (
|
const isAvailable = !!filterDays(index).length;
|
||||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
return (
|
||||||
<span
|
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||||
className={classNames(
|
<span
|
||||||
"w-20 font-medium sm:w-32 ",
|
className={classNames(
|
||||||
!isAvailable ? "text-subtle line-through" : "text-default"
|
"w-20 font-medium sm:w-32 ",
|
||||||
)}>
|
!isAvailable ? "text-subtle line-through" : "text-default"
|
||||||
{day}
|
)}>
|
||||||
</span>
|
{day}
|
||||||
{isLoading ? (
|
</span>
|
||||||
<SkeletonText className="block h-5 w-60" />
|
{isLoading ? (
|
||||||
) : isAvailable ? (
|
<SkeletonText className="block h-5 w-60" />
|
||||||
<div className="space-y-3 text-right">
|
) : isAvailable ? (
|
||||||
{filterDays(index).map((dayRange, i) => (
|
<div className="space-y-3 text-right">
|
||||||
<div key={i} className="text-default flex items-center leading-4">
|
{filterDays(index).map((dayRange, i) => (
|
||||||
<span className="w-16 sm:w-28 sm:text-left">
|
<div key={i} className="text-default flex items-center leading-4">
|
||||||
{format(dayRange.startTime, timeFormat === 12)}
|
<span className="w-16 sm:w-28 sm:text-left">
|
||||||
</span>
|
{format(dayRange.startTime, timeFormat === 12)}
|
||||||
<span className="ms-4">-</span>
|
</span>
|
||||||
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
|
<span className="ms-4">-</span>
|
||||||
</div>
|
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
|
) : (
|
||||||
)}
|
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
|
||||||
</li>
|
)}
|
||||||
);
|
</li>
|
||||||
})}
|
);
|
||||||
</ol>
|
})}
|
||||||
<hr className="border-subtle" />
|
</ol>
|
||||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
</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">
|
<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" />
|
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
{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_admin_description"),
|
||||||
t("locked_fields_member_description")
|
t("locked_fields_member_description")
|
||||||
);
|
);
|
||||||
const { watch } = useFormContext<FormValues>();
|
const { watch, setValue, getValues } = useFormContext<FormValues>();
|
||||||
const watchSchedule = watch("schedule");
|
const watchSchedule = watch("schedule");
|
||||||
const formMethods = useFormContext<FormValues>();
|
|
||||||
const [options, setOptions] = useState<AvailabilityOption[]>([]);
|
const [options, setOptions] = useState<AvailabilityOption[]>([]);
|
||||||
|
|
||||||
const { isLoading } = trpc.viewer.availability.list.useQuery(undefined, {
|
const { isLoading } = trpc.viewer.availability.list.useQuery(undefined, {
|
||||||
@@ -214,7 +214,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
|
|
||||||
setOptions(options);
|
setOptions(options);
|
||||||
|
|
||||||
const scheduleId = formMethods.getValues("schedule");
|
const scheduleId = getValues("schedule");
|
||||||
const value = options.find((option) =>
|
const value = options.find((option) =>
|
||||||
scheduleId
|
scheduleId
|
||||||
? option.value === scheduleId
|
? option.value === scheduleId
|
||||||
@@ -223,15 +223,20 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
|
: 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 (
|
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">
|
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
|
||||||
{t("availability")}
|
{t("availability")}
|
||||||
{shouldLockIndicator("availability")}
|
{shouldLockIndicator("availability")}
|
||||||
@@ -248,7 +253,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
onChange={(selected) => {
|
onChange={(selected) => {
|
||||||
field.onChange(selected?.value || null);
|
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"
|
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||||
value={availabilityValue}
|
value={availabilityValue}
|
||||||
@@ -276,7 +281,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
|
|
||||||
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
|
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { resetField, setValue } = useFormContext<FormValues>();
|
const { setValue } = useFormContext<FormValues>();
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
name="metadata.config.useHostSchedulesForTeamEvent"
|
name="metadata.config.useHostSchedulesForTeamEvent"
|
||||||
@@ -285,9 +290,7 @@ const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSe
|
|||||||
checked={!value}
|
checked={!value}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
onChange(!checked);
|
onChange(!checked);
|
||||||
if (checked) {
|
if (!checked) {
|
||||||
resetField("schedule");
|
|
||||||
} else {
|
|
||||||
setValue("schedule", null);
|
setValue("schedule", null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/interval
|
|||||||
import type { PeriodType } from "@calcom/prisma/enums";
|
import type { PeriodType } from "@calcom/prisma/enums";
|
||||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||||
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
|
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<
|
const MinimumBookingNoticeInput = React.forwardRef<
|
||||||
HTMLInputElement,
|
HTMLInputElement,
|
||||||
@@ -83,14 +83,14 @@ const MinimumBookingNoticeInput = React.forwardRef<
|
|||||||
type="number"
|
type="number"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
min={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} />
|
<input type="hidden" ref={ref} {...passThroughProps} />
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
isDisabled={passThroughProps.disabled}
|
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(
|
defaultValue={durationTypeOptions.find(
|
||||||
(option) => option.value === minimumBookingNoticeDisplayValues.type
|
(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);
|
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + offsetStartValue * 60 * 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div>
|
||||||
<div className="space-y-4 lg:space-y-8">
|
<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="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Label htmlFor="beforeBufferTime">
|
<Label htmlFor="beforeBufferTime">
|
||||||
@@ -295,159 +295,195 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<Controller
|
<Controller
|
||||||
name="bookingLimits"
|
name="bookingLimits"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={({ field: { value } }) => (
|
render={({ field: { value } }) => {
|
||||||
<SettingsToggle
|
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||||
title={t("limit_booking_frequency")}
|
return (
|
||||||
{...bookingLimitsLocked}
|
<SettingsToggle
|
||||||
description={t("limit_booking_frequency_description")}
|
toggleSwitchAtTheEnd={true}
|
||||||
checked={Object.keys(value ?? {}).length > 0}
|
title={t("limit_booking_frequency")}
|
||||||
onCheckedChange={(active) => {
|
{...bookingLimitsLocked}
|
||||||
if (active) {
|
description={t("limit_booking_frequency_description")}
|
||||||
formMethods.setValue("bookingLimits", {
|
checked={isChecked}
|
||||||
PER_DAY: 1,
|
onCheckedChange={(active) => {
|
||||||
});
|
if (active) {
|
||||||
} else {
|
formMethods.setValue("bookingLimits", {
|
||||||
formMethods.setValue("bookingLimits", {});
|
PER_DAY: 1,
|
||||||
}
|
});
|
||||||
}}>
|
} else {
|
||||||
<IntervalLimitsManager
|
formMethods.setValue("bookingLimits", {});
|
||||||
disabled={bookingLimitsLocked.disabled}
|
}
|
||||||
propertyName="bookingLimits"
|
}}
|
||||||
defaultLimit={1}
|
switchContainerClassName={classNames(
|
||||||
step={1}
|
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||||
/>
|
isChecked && "rounded-b-none"
|
||||||
</SettingsToggle>
|
)}
|
||||||
)}
|
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
|
<Controller
|
||||||
name="durationLimits"
|
name="durationLimits"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={({ field: { value } }) => (
|
render={({ field: { value } }) => {
|
||||||
<SettingsToggle
|
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||||
title={t("limit_total_booking_duration")}
|
return (
|
||||||
description={t("limit_total_booking_duration_description")}
|
<SettingsToggle
|
||||||
{...durationLimitsLocked}
|
toggleSwitchAtTheEnd={true}
|
||||||
checked={Object.keys(value ?? {}).length > 0}
|
switchContainerClassName={classNames(
|
||||||
onCheckedChange={(active) => {
|
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||||
if (active) {
|
isChecked && "rounded-b-none"
|
||||||
formMethods.setValue("durationLimits", {
|
)}
|
||||||
PER_DAY: 60,
|
childrenClassName="lg:ml-0"
|
||||||
});
|
title={t("limit_total_booking_duration")}
|
||||||
} else {
|
description={t("limit_total_booking_duration_description")}
|
||||||
formMethods.setValue("durationLimits", {});
|
{...durationLimitsLocked}
|
||||||
}
|
checked={isChecked}
|
||||||
}}>
|
onCheckedChange={(active) => {
|
||||||
<IntervalLimitsManager
|
if (active) {
|
||||||
propertyName="durationLimits"
|
formMethods.setValue("durationLimits", {
|
||||||
defaultLimit={60}
|
PER_DAY: 60,
|
||||||
disabled={durationLimitsLocked.disabled}
|
});
|
||||||
step={15}
|
} else {
|
||||||
textFieldSuffix={t("minutes")}
|
formMethods.setValue("durationLimits", {});
|
||||||
/>
|
}
|
||||||
</SettingsToggle>
|
}}>
|
||||||
)}
|
<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
|
<Controller
|
||||||
name="periodType"
|
name="periodType"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={({ field: { value } }) => (
|
render={({ field: { value } }) => {
|
||||||
<SettingsToggle
|
const isChecked = value && value !== "UNLIMITED";
|
||||||
title={t("limit_future_bookings")}
|
|
||||||
description={t("limit_future_bookings_description")}
|
return (
|
||||||
{...periodTypeLocked}
|
<SettingsToggle
|
||||||
checked={value && value !== "UNLIMITED"}
|
toggleSwitchAtTheEnd={true}
|
||||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
switchContainerClassName={classNames(
|
||||||
<RadioGroup.Root
|
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||||
defaultValue={watchPeriodType}
|
isChecked && "rounded-b-none"
|
||||||
value={watchPeriodType}
|
)}
|
||||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
childrenClassName="lg:ml-0"
|
||||||
{PERIOD_TYPES.filter((opt) =>
|
title={t("limit_future_bookings")}
|
||||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
description={t("limit_future_bookings_description")}
|
||||||
).map((period) => {
|
{...periodTypeLocked}
|
||||||
if (period.type === "UNLIMITED") return null;
|
checked={isChecked}
|
||||||
return (
|
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||||
<div
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
className={classNames(
|
<RadioGroup.Root
|
||||||
"text-default mb-2 flex flex-wrap items-center text-sm",
|
defaultValue={watchPeriodType}
|
||||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
value={watchPeriodType}
|
||||||
)}
|
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||||
key={period.type}>
|
{PERIOD_TYPES.filter((opt) =>
|
||||||
{!periodTypeLocked.disabled && (
|
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
||||||
<RadioGroup.Item
|
).map((period) => {
|
||||||
id={period.type}
|
if (period.type === "UNLIMITED") return null;
|
||||||
value={period.type}
|
return (
|
||||||
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">
|
<div
|
||||||
<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" />
|
className={classNames(
|
||||||
</RadioGroup.Item>
|
"text-default mb-2 flex flex-wrap items-center text-sm",
|
||||||
)}
|
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
)}
|
||||||
{period.type === "ROLLING" && (
|
key={period.type}>
|
||||||
<div className="flex items-center">
|
{!periodTypeLocked.disabled && (
|
||||||
<TextField
|
<RadioGroup.Item
|
||||||
labelSrOnly
|
id={period.type}
|
||||||
type="number"
|
value={period.type}
|
||||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
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">
|
||||||
placeholder="30"
|
<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" />
|
||||||
disabled={periodTypeLocked.disabled}
|
</RadioGroup.Item>
|
||||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
)}
|
||||||
defaultValue={eventType.periodDays || 30}
|
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||||
/>
|
{period.type === "ROLLING" && (
|
||||||
<Select
|
<div className="flex items-center">
|
||||||
options={optionsPeriod}
|
<TextField
|
||||||
isSearchable={false}
|
labelSrOnly
|
||||||
isDisabled={periodTypeLocked.disabled}
|
type="number"
|
||||||
onChange={(opt) => {
|
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||||
formMethods.setValue(
|
placeholder="30"
|
||||||
"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}
|
disabled={periodTypeLocked.disabled}
|
||||||
onDatesChange={({ startDate, endDate }) => {
|
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||||
formMethods.setValue("periodDates", {
|
defaultValue={eventType.periodDays || 30}
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<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"> {period.suffix}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
{period.suffix ? <span className="me-2 ms-2"> {period.suffix}</span> : null}
|
})}
|
||||||
</div>
|
</RadioGroup.Root>
|
||||||
);
|
</div>
|
||||||
})}
|
</SettingsToggle>
|
||||||
</RadioGroup.Root>
|
);
|
||||||
</SettingsToggle>
|
}}
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
|
||||||
<SettingsToggle
|
<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")}
|
title={t("offset_toggle")}
|
||||||
description={t("offset_toggle_description")}
|
description={t("offset_toggle_description")}
|
||||||
{...offsetStartLockedProps}
|
{...offsetStartLockedProps}
|
||||||
@@ -458,18 +494,20 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
formMethods.setValue("offsetStart", 0);
|
formMethods.setValue("offsetStart", 0);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<TextField
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
required
|
<TextField
|
||||||
type="number"
|
required
|
||||||
{...offsetStartLockedProps}
|
type="number"
|
||||||
label={t("offset_start")}
|
{...offsetStartLockedProps}
|
||||||
{...formMethods.register("offsetStart")}
|
label={t("offset_start")}
|
||||||
addOnSuffix={<>{t("minutes")}</>}
|
{...formMethods.register("offsetStart")}
|
||||||
hint={t("offset_start_description", {
|
addOnSuffix={<>{t("minutes")}</>}
|
||||||
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
hint={t("offset_start_description", {
|
||||||
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||||
})}
|
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||||
/>
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -509,19 +547,19 @@ const IntervalLimitItem = ({
|
|||||||
onIntervalSelect,
|
onIntervalSelect,
|
||||||
}: IntervalLimitItemProps) => {
|
}: IntervalLimitItemProps) => {
|
||||||
return (
|
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
|
<TextField
|
||||||
required
|
required
|
||||||
type="number"
|
type="number"
|
||||||
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
|
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
|
||||||
className="mb-0 !h-auto"
|
className="mb-0"
|
||||||
placeholder={`${value}`}
|
placeholder={`${value}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={step}
|
min={step}
|
||||||
step={step}
|
step={step}
|
||||||
defaultValue={value}
|
defaultValue={value}
|
||||||
addOnSuffix={textFieldSuffix}
|
addOnSuffix={textFieldSuffix}
|
||||||
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value))}
|
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value || "0", 10))}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
options={selectOptions}
|
options={selectOptions}
|
||||||
@@ -529,9 +567,16 @@ const IntervalLimitItem = ({
|
|||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
|
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
|
||||||
onChange={onIntervalSelect}
|
onChange={onIntervalSelect}
|
||||||
|
className="w-36"
|
||||||
/>
|
/>
|
||||||
{hasDeleteButton && !disabled && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -387,178 +387,185 @@ export const EventSetupTab = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="space-y-8">
|
<div className="space-y-4">
|
||||||
<TextField
|
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||||
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>
|
|
||||||
) : (
|
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
type="number"
|
label={t("title")}
|
||||||
{...lengthLockedProps}
|
{...shouldLockDisableProps("title")}
|
||||||
label={t("duration")}
|
defaultValue={eventType.title}
|
||||||
defaultValue={eventType.length ?? 15}
|
{...formMethods.register("title")}
|
||||||
{...formMethods.register("length")}
|
|
||||||
addOnSuffix={<>{t("minutes")}</>}
|
|
||||||
min={1}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<div>
|
||||||
{!lengthLockedProps.disabled && (
|
<Label>
|
||||||
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
{t("description")}
|
||||||
<SettingsToggle
|
{shouldLockIndicator("description")}
|
||||||
title={t("allow_booker_to_select_duration")}
|
</Label>
|
||||||
checked={multipleDuration !== undefined}
|
<DescriptionEditor
|
||||||
disabled={seatsEnabled}
|
description={eventType?.description}
|
||||||
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
|
editable={!descriptionLockedProps.disabled}
|
||||||
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>
|
||||||
)}
|
<TextField
|
||||||
<div>
|
required
|
||||||
<Skeleton as={Label} loadingClassName="w-16">
|
label={t("URL")}
|
||||||
{t("location")}
|
{...shouldLockDisableProps("slug")}
|
||||||
{shouldLockIndicator("locations")}
|
defaultValue={eventType.slug}
|
||||||
</Skeleton>
|
addOnLeading={
|
||||||
|
<>
|
||||||
<Controller
|
{urlPrefix}/
|
||||||
name="locations"
|
{!isManagedEventType
|
||||||
control={formMethods.control}
|
? team
|
||||||
defaultValue={eventType.locations || []}
|
? (orgBranding ? "" : "team/") + team.slug
|
||||||
render={() => <Locations />}
|
: eventType.users[0].username
|
||||||
|
: t("username_placeholder")}
|
||||||
|
/
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{...formMethods.register("slug", {
|
||||||
|
setValueAs: (v) => slugify(v),
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
<div className="border-subtle rounded-md border p-6">
|
||||||
<EditLocationDialog
|
<div>
|
||||||
isOpenDialog={showLocationModal}
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
setShowLocationModal={setShowLocationModal}
|
{t("location")}
|
||||||
saveLocation={saveLocation}
|
{shouldLockIndicator("locations")}
|
||||||
defaultValues={formMethods.getValues("locations")}
|
</Skeleton>
|
||||||
selection={
|
|
||||||
selectedLocation
|
<Controller
|
||||||
? selectedLocation.address
|
name="locations"
|
||||||
? {
|
control={formMethods.control}
|
||||||
value: selectedLocation.value,
|
defaultValue={eventType.locations || []}
|
||||||
label: t(selectedLocation.label),
|
render={() => <Locations />}
|
||||||
icon: selectedLocation.icon,
|
/>
|
||||||
address: selectedLocation.address,
|
</div>
|
||||||
}
|
</div>
|
||||||
: {
|
|
||||||
value: selectedLocation.value,
|
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||||
label: t(selectedLocation.label),
|
<EditLocationDialog
|
||||||
icon: selectedLocation.icon,
|
isOpenDialog={showLocationModal}
|
||||||
}
|
setShowLocationModal={setShowLocationModal}
|
||||||
: undefined
|
saveLocation={saveLocation}
|
||||||
}
|
defaultValues={formMethods.getValues("locations")}
|
||||||
setSelectedLocation={setSelectedLocation}
|
selection={
|
||||||
setEditingLocationType={setEditingLocationType}
|
selectedLocation
|
||||||
teamId={eventType.team?.id}
|
? 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Webhook } from "@prisma/client";
|
import type { Webhook } from "@prisma/client";
|
||||||
import { Webhook as TbWebhook } from "lucide-react";
|
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 type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||||
import { useState } from "react";
|
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 type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||||
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
|
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
|
||||||
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
||||||
@@ -115,23 +118,40 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
|
|||||||
)}
|
)}
|
||||||
{webhooks.length ? (
|
{webhooks.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2 rounded-md border">
|
<div className="border-subtle mb-2 rounded-md border p-8">
|
||||||
{webhooks.map((webhook, index) => {
|
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
|
||||||
return (
|
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||||
<WebhookListItem
|
{t("add_webhook_description", { appName: APP_NAME })}
|
||||||
key={webhook.id}
|
</p>
|
||||||
webhook={webhook}
|
|
||||||
lastItem={webhooks.length === index + 1}
|
<div className="border-subtle mt-8 rounded-md border">
|
||||||
canEditWebhook={!webhookLockedStatus.disabled}
|
{webhooks.map((webhook, index) => {
|
||||||
onEditWebhook={() => {
|
return (
|
||||||
setEditModalOpen(true);
|
<WebhookListItem
|
||||||
setWebhookToEdit(webhook);
|
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
|
||||||
|
<Link
|
||||||
|
className="cursor-pointer font-semibold underline"
|
||||||
|
href="/settings/developer/webhooks">
|
||||||
|
webhooks settings
|
||||||
|
</Link>
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NewWebhookButton />
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyScreen
|
<EmptyScreen
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
|||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
@@ -47,6 +48,12 @@ export default function RecurringEventController({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<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")}
|
title={t("recurring_event")}
|
||||||
{...recurringLocked}
|
{...recurringLocked}
|
||||||
description={t("recurring_event_description")}
|
description={t("recurring_event_description")}
|
||||||
@@ -66,68 +73,70 @@ export default function RecurringEventController({
|
|||||||
setRecurringEventState(newVal);
|
setRecurringEventState(newVal);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{recurringEventState && (
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
{recurringEventState && (
|
||||||
<div className="flex items-center">
|
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
<div className="flex items-center">
|
||||||
<TextField
|
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||||
disabled={recurringLocked.disabled}
|
<TextField
|
||||||
type="number"
|
disabled={recurringLocked.disabled}
|
||||||
min="1"
|
type="number"
|
||||||
max="20"
|
min="1"
|
||||||
className="mb-0"
|
max="20"
|
||||||
defaultValue={recurringEventState.interval}
|
className="mb-0"
|
||||||
onChange={(event) => {
|
defaultValue={recurringEventState.interval}
|
||||||
const newVal = {
|
onChange={(event) => {
|
||||||
...recurringEventState,
|
const newVal = {
|
||||||
interval: parseInt(event?.target.value),
|
...recurringEventState,
|
||||||
};
|
interval: parseInt(event?.target.value),
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
};
|
||||||
setRecurringEventState(newVal);
|
formMethods.setValue("recurringEvent", newVal);
|
||||||
}}
|
setRecurringEventState(newVal);
|
||||||
/>
|
}}
|
||||||
<Select
|
/>
|
||||||
options={recurringEventFreqOptions}
|
<Select
|
||||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
options={recurringEventFreqOptions}
|
||||||
isSearchable={false}
|
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||||
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
|
isSearchable={false}
|
||||||
isDisabled={recurringLocked.disabled}
|
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
|
||||||
onChange={(event) => {
|
isDisabled={recurringLocked.disabled}
|
||||||
const newVal = {
|
onChange={(event) => {
|
||||||
...recurringEventState,
|
const newVal = {
|
||||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
...recurringEventState,
|
||||||
};
|
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
};
|
||||||
setRecurringEventState(newVal);
|
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>
|
||||||
<div className="mt-4 flex items-center">
|
)}
|
||||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export default function RequiresConfirmationController({
|
|||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={() => (
|
render={() => (
|
||||||
<SettingsToggle
|
<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")}
|
title={t("requires_confirmation")}
|
||||||
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
|
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
|
||||||
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
|
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
|
||||||
@@ -77,107 +83,111 @@ export default function RequiresConfirmationController({
|
|||||||
formMethods.setValue("requiresConfirmation", val);
|
formMethods.setValue("requiresConfirmation", val);
|
||||||
onRequiresConfirmation(val);
|
onRequiresConfirmation(val);
|
||||||
}}>
|
}}>
|
||||||
<RadioGroup.Root
|
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||||
defaultValue={
|
<RadioGroup.Root
|
||||||
requiresConfirmation
|
defaultValue={
|
||||||
? requiresConfirmationSetup === undefined
|
requiresConfirmation
|
||||||
? "always"
|
? requiresConfirmationSetup === undefined
|
||||||
: "notice"
|
? "always"
|
||||||
: undefined
|
: "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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}>
|
onValueChange={(val) => {
|
||||||
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
if (val === "always") {
|
||||||
{(requiresConfirmationSetup === undefined || !requiresConfirmationLockedProps.disabled) && (
|
formMethods.setValue("requiresConfirmation", true);
|
||||||
<RadioField
|
onRequiresConfirmation(true);
|
||||||
label={t("always_requires_confirmation")}
|
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||||
disabled={requiresConfirmationLockedProps.disabled}
|
setRequiresConfirmationSetup(undefined);
|
||||||
id="always"
|
} else if (val === "notice") {
|
||||||
value="always"
|
formMethods.setValue("requiresConfirmation", true);
|
||||||
/>
|
onRequiresConfirmation(true);
|
||||||
)}
|
formMethods.setValue(
|
||||||
{(requiresConfirmationSetup !== undefined || !requiresConfirmationLockedProps.disabled) && (
|
"metadata.requiresConfirmationThreshold",
|
||||||
<RadioField
|
requiresConfirmationSetup || defaultRequiresConfirmationSetup
|
||||||
disabled={requiresConfirmationLockedProps.disabled}
|
);
|
||||||
className="items-center"
|
}
|
||||||
label={
|
}}>
|
||||||
<>
|
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
||||||
<Trans
|
{(requiresConfirmationSetup === undefined ||
|
||||||
i18nKey="when_booked_with_less_than_notice"
|
!requiresConfirmationLockedProps.disabled) && (
|
||||||
defaults="When booked with less than <time></time> notice"
|
<RadioField
|
||||||
components={{
|
label={t("always_requires_confirmation")}
|
||||||
time: (
|
disabled={requiresConfirmationLockedProps.disabled}
|
||||||
<div className="mx-2 inline-flex">
|
id="always"
|
||||||
<Input
|
value="always"
|
||||||
type="number"
|
/>
|
||||||
min={1}
|
)}
|
||||||
disabled={requiresConfirmationLockedProps.disabled}
|
{(requiresConfirmationSetup !== undefined ||
|
||||||
onChange={(evt) => {
|
!requiresConfirmationLockedProps.disabled) && (
|
||||||
const val = Number(evt.target?.value);
|
<RadioField
|
||||||
setRequiresConfirmationSetup({
|
disabled={requiresConfirmationLockedProps.disabled}
|
||||||
unit:
|
className="items-center"
|
||||||
requiresConfirmationSetup?.unit ??
|
label={
|
||||||
defaultRequiresConfirmationSetup.unit,
|
<>
|
||||||
time: val,
|
<Trans
|
||||||
});
|
i18nKey="when_booked_with_less_than_notice"
|
||||||
formMethods.setValue(
|
defaults="When booked with less than <time></time> notice"
|
||||||
"metadata.requiresConfirmationThreshold.time",
|
components={{
|
||||||
val
|
time: (
|
||||||
);
|
<div className="mx-2 inline-flex">
|
||||||
}}
|
<Input
|
||||||
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
|
type="number"
|
||||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
min={1}
|
||||||
/>
|
disabled={requiresConfirmationLockedProps.disabled}
|
||||||
<label
|
onChange={(evt) => {
|
||||||
className={classNames(
|
const val = Number(evt.target?.value);
|
||||||
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
|
||||||
)}>
|
|
||||||
<Select
|
|
||||||
inputId="notice"
|
|
||||||
options={options}
|
|
||||||
isSearchable={false}
|
|
||||||
isDisabled={requiresConfirmationLockedProps.disabled}
|
|
||||||
className="ml-2"
|
|
||||||
onChange={(opt) => {
|
|
||||||
setRequiresConfirmationSetup({
|
setRequiresConfirmationSetup({
|
||||||
time:
|
unit:
|
||||||
requiresConfirmationSetup?.time ??
|
requiresConfirmationSetup?.unit ??
|
||||||
defaultRequiresConfirmationSetup.time,
|
defaultRequiresConfirmationSetup.unit,
|
||||||
unit: opt?.value as UnitTypeLongPlural,
|
time: val,
|
||||||
});
|
});
|
||||||
formMethods.setValue(
|
formMethods.setValue(
|
||||||
"metadata.requiresConfirmationThreshold.unit",
|
"metadata.requiresConfirmationThreshold.time",
|
||||||
opt?.value as UnitTypeLongPlural
|
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>
|
<label
|
||||||
</div>
|
className={classNames(
|
||||||
),
|
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
||||||
}}
|
)}>
|
||||||
/>
|
<Select
|
||||||
</>
|
inputId="notice"
|
||||||
}
|
options={options}
|
||||||
id="notice"
|
isSearchable={false}
|
||||||
value="notice"
|
isDisabled={requiresConfirmationLockedProps.disabled}
|
||||||
/>
|
innerClassNames={{ control: "rounded-l-none bg-subtle" }}
|
||||||
)}
|
onChange={(opt) => {
|
||||||
</div>
|
setRequiresConfirmationSetup({
|
||||||
</RadioGroup.Root>
|
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>
|
</SettingsToggle>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||||
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
@@ -22,7 +23,7 @@ const UserSettings = (props: IUserSettingsProps) => {
|
|||||||
const { nextStep } = props;
|
const { nextStep } = props;
|
||||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
|
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const userSettingsSchema = z.object({
|
const userSettingsSchema = z.object({
|
||||||
name: z
|
name: z
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { Avatar } from "@calcom/ui";
|
|||||||
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
|
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
|
||||||
type MembersType = TeamType["members"];
|
type MembersType = TeamType["members"];
|
||||||
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username"> & { safeBio: string | null };
|
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username"> & { safeBio: string | null };
|
||||||
type TeamTypeWithSafeHtml = Omit<TeamType, "members" | "inviteToken"> & { members: MemberType[] };
|
|
||||||
|
|
||||||
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
||||||
const routerQuery = useRouterQuery();
|
const routerQuery = useRouterQuery();
|
||||||
@@ -50,7 +49,9 @@ const Members = ({ members, teamName }: { members: MemberType[]; teamName: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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) => {
|
{members.map((member) => {
|
||||||
return member.username !== null && <Member key={member.id} member={member} teamName={teamName} />;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Members members={team.members} teamName={team.name} />
|
<Members members={members} teamName={teamName} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const initClock = () => {
|
|||||||
// This only sets browser locale if there's no preference on localStorage.
|
// This only sets browser locale if there's no preference on localStorage.
|
||||||
if (getIs24hClockFromLocalStorage() === null) set24hClock(isBrowserLocale24h());
|
if (getIs24hClockFromLocalStorage() === null) set24hClock(isBrowserLocale24h());
|
||||||
timeOptions.is24hClock = !!getIs24hClockFromLocalStorage();
|
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) => {
|
const is24h = (is24hClock?: boolean) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "3.3.3",
|
"version": "3.3.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
|
|||||||
@@ -250,11 +250,8 @@ export default function Custom404() {
|
|||||||
) : IS_CALCOM ? (
|
) : IS_CALCOM ? (
|
||||||
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
||||||
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
|
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
|
||||||
<strong className="text-blue-500">
|
<strong className="text-blue-500">{username}</strong> {t("is_still_available")}{" "}
|
||||||
{new URL(WEBSITE_URL).hostname}
|
<span className="text-blue-500">{t("register_now")}</span>.
|
||||||
{username}
|
|
||||||
</strong>{" "}
|
|
||||||
{t("is_still_available")} <span className="text-blue-500">{t("register_now")}</span>.
|
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="mt-2 inline-block text-lg">
|
<span className="mt-2 inline-block text-lg">
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
getMultipleDurationValue,
|
getMultipleDurationValue,
|
||||||
} from "@calcom/features/bookings/lib/get-booking";
|
} from "@calcom/features/bookings/lib/get-booking";
|
||||||
import type { GetBookingType } 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, userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
|
||||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
@@ -152,7 +151,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
username,
|
username,
|
||||||
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
organization: userOrgQuery(context.req.headers.host ?? "", context.params?.orgSlug),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
away: true,
|
away: true,
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ class MyDocument extends Document<Props> {
|
|||||||
const asPath = ctx.asPath || "";
|
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
|
// 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 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 embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
|
||||||
const initialProps = await Document.getInitialProps(ctx);
|
const initialProps = await Document.getInitialProps(ctx);
|
||||||
return { isEmbed, embedColorScheme, nonce, ...initialProps };
|
return { isEmbed, embedColorScheme, nonce, ...initialProps };
|
||||||
|
|||||||
@@ -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
|
// Accept any child team invites for orgs and create a membership for the org itself
|
||||||
if (team.parentId) {
|
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
|
// 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({
|
await prisma.membership.updateMany({
|
||||||
where: {
|
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);
|
|
||||||
@@ -15,12 +15,10 @@ const appCredentialWebhookRequestBodySchema = z.object({
|
|||||||
});
|
});
|
||||||
/** */
|
/** */
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
// Check that credential sharing is enabled
|
|
||||||
if (!APP_CREDENTIAL_SHARING_ENABLED) {
|
if (!APP_CREDENTIAL_SHARING_ENABLED) {
|
||||||
return res.status(403).json({ message: "Credential sharing is not enabled" });
|
return res.status(403).json({ message: "Credential sharing is not enabled" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that the webhook secret matches
|
|
||||||
if (
|
if (
|
||||||
req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
|
req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
|
||||||
process.env.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);
|
const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);
|
||||||
|
|
||||||
// Check that the user exists
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
|
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -39,26 +36,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
const app = await prisma.app.findUnique({
|
const app = await prisma.app.findUnique({
|
||||||
where: { slug: reqBody.appSlug },
|
where: { slug: reqBody.appSlug },
|
||||||
select: { slug: true },
|
select: { dirName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!app) {
|
if (!app) {
|
||||||
return res.status(404).json({ message: "App not found" });
|
return res.status(404).json({ message: "App not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for the app's slug and type
|
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
|
||||||
const appMetadata = appStoreMetadata[app.slug as keyof typeof appStoreMetadata];
|
|
||||||
|
|
||||||
if (!appMetadata) {
|
if (!appMetadata) {
|
||||||
return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" });
|
return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the keys
|
|
||||||
const keys = JSON.parse(
|
const keys = JSON.parse(
|
||||||
symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
|
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({
|
const appCredential = await prisma.credential.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: reqBody.userId,
|
userId: reqBody.userId,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { GetStaticPaths, InferGetStaticPropsType } from "next";
|
import type { InferGetServerSidePropsType } from "next";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
|
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 { HeadSeo } from "@calcom/ui";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
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 searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const slug = searchParams?.get("slug") as string;
|
const slug = searchParams?.get("slug") as string;
|
||||||
@@ -36,11 +36,4 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
|
|||||||
|
|
||||||
SetupInformation.PageWrapper = PageWrapper;
|
SetupInformation.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
export const getStaticPaths: GetStaticPaths = async () => {
|
export { getServerSideProps };
|
||||||
return {
|
|
||||||
paths: [],
|
|
||||||
fallback: "blocking",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getStaticProps };
|
|
||||||
@@ -1,35 +1,13 @@
|
|||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useReducer, useState } from "react";
|
import { useReducer } from "react";
|
||||||
import z from "zod";
|
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 DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||||
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { AppCategories } from "@calcom/prisma/enums";
|
import { AppCategories } from "@calcom/prisma/enums";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
|
||||||
import { trpc } 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 type { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProps";
|
||||||
import {
|
import { Button, EmptyScreen, AppSkeletonLoader as SkeletonLoader, ShellSubHeading } from "@calcom/ui";
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
EmptyScreen,
|
|
||||||
List,
|
|
||||||
AppSkeletonLoader as SkeletonLoader,
|
|
||||||
ShellSubHeading,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
Dropdown,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownItem,
|
|
||||||
showToast,
|
|
||||||
} from "@calcom/ui";
|
|
||||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -38,231 +16,24 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
Grid,
|
Grid,
|
||||||
Mail,
|
Mail,
|
||||||
MoreHorizontal,
|
|
||||||
Plus,
|
Plus,
|
||||||
Share2,
|
Share2,
|
||||||
Trash,
|
|
||||||
Video,
|
Video,
|
||||||
} from "@calcom/ui/components/icon";
|
} from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
import AppListCard from "@components/AppListCard";
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
import { AppList } from "@components/apps/AppList";
|
||||||
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
||||||
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
|
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 {
|
interface IntegrationsContainerProps {
|
||||||
variant?: AppCategories;
|
variant?: AppCategories;
|
||||||
exclude?: AppCategories[];
|
exclude?: AppCategories[];
|
||||||
handleDisconnect: (credentialId: number) => void;
|
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 = ({
|
const IntegrationsContainer = ({
|
||||||
variant,
|
variant,
|
||||||
exclude,
|
exclude,
|
||||||
@@ -329,7 +100,8 @@ const IntegrationsContainer = ({
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<IntegrationsList handleDisconnect={handleDisconnect} data={data} variant={variant} />
|
|
||||||
|
<AppList handleDisconnect={handleDisconnect} data={data} variant={variant} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -60,10 +60,16 @@ export default function Verify() {
|
|||||||
const routerQuery = useRouterQuery();
|
const routerQuery = useRouterQuery();
|
||||||
const { t, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
|
const { t, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
|
||||||
const [secondsLeft, setSecondsLeft] = useState(30);
|
const [secondsLeft, setSecondsLeft] = useState(30);
|
||||||
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery({
|
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery(
|
||||||
stripeCustomerId,
|
{
|
||||||
checkoutSessionId: sessionId,
|
stripeCustomerId,
|
||||||
});
|
checkoutSessionId: sessionId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!stripeCustomerId || !!sessionId,
|
||||||
|
staleTime: Infinity,
|
||||||
|
}
|
||||||
|
);
|
||||||
useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username });
|
useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username });
|
||||||
// @note: check for t=timestamp and apply disabled state and secondsLeft accordingly
|
// @note: check for t=timestamp and apply disabled state and secondsLeft accordingly
|
||||||
// to avoid refresh to skip waiting 30 seconds to re-send email
|
// to avoid refresh to skip waiting 30 seconds to re-send email
|
||||||
@@ -123,12 +129,12 @@ export default function Verify() {
|
|||||||
{hasPaymentFailed ? (
|
{hasPaymentFailed ? (
|
||||||
<AlertTriangle className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
<AlertTriangle className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
||||||
) : sessionId ? (
|
) : 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" />
|
<MailOpen className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
{hasPaymentFailed
|
||||||
? "Your payment failed"
|
? "Your payment failed"
|
||||||
: sessionId
|
: sessionId
|
||||||
@@ -138,7 +144,7 @@ export default function Verify() {
|
|||||||
{hasPaymentFailed && (
|
{hasPaymentFailed && (
|
||||||
<p className="my-6">Your account has been created, but your premium has not been reserved.</p>
|
<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.{" "}
|
We have sent an email to <b>{customer?.email} </b>with a link to activate your account.{" "}
|
||||||
{hasPaymentFailed &&
|
{hasPaymentFailed &&
|
||||||
"Once you activate your account you will be able to try purchase your premium username again or select a different one."}
|
"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();
|
e.preventDefault();
|
||||||
setSecondsLeft(30);
|
setSecondsLeft(30);
|
||||||
// Update query params with t:timestamp, shallow: true doesn't re-render the page
|
// 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()}`);
|
_searchParams.set("t", `${Date.now()}`);
|
||||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||||
return await sendVerificationLogin(customer.email, customer.username);
|
return await sendVerificationLogin(customer.email, customer.username);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
useIsEmbed,
|
useIsEmbed,
|
||||||
} from "@calcom/embed-core/embed-iframe";
|
} from "@calcom/embed-core/embed-iframe";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
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 { SMS_REMINDER_NUMBER_FIELD, SystemField } from "@calcom/features/bookings/lib/SystemField";
|
||||||
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
|
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
|
||||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||||
@@ -93,7 +94,7 @@ const querySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function Success(props: SuccessProps) {
|
export default function Success(props: SuccessProps) {
|
||||||
const { t, i18n } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const routerQuery = useRouterQuery();
|
const routerQuery = useRouterQuery();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -132,7 +133,9 @@ export default function Success(props: SuccessProps) {
|
|||||||
|
|
||||||
const isGmail = !!attendees.find((attendee) => attendee.email.includes("gmail.com"));
|
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 { data: session } = useSession();
|
||||||
|
|
||||||
const [date, setDate] = useState(dayjs.utc(props.bookingInfo.startTime));
|
const [date, setDate] = useState(dayjs.utc(props.bookingInfo.startTime));
|
||||||
@@ -214,8 +217,10 @@ export default function Success(props: SuccessProps) {
|
|||||||
confirmed: !needsConfirmation,
|
confirmed: !needsConfirmation,
|
||||||
// TODO: Add payment details
|
// TODO: Add payment details
|
||||||
});
|
});
|
||||||
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
|
setDate(
|
||||||
setIs24h(!!getIs24hClockFromLocalStorage());
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [eventType, needsConfirmation]);
|
}, [eventType, needsConfirmation]);
|
||||||
|
|
||||||
@@ -353,10 +358,10 @@ export default function Success(props: SuccessProps) {
|
|||||||
<img src={giphyImage} alt="Gif from Giphy" />
|
<img src={giphyImage} alt="Gif from Giphy" />
|
||||||
)}
|
)}
|
||||||
{!giphyImage && !needsConfirmation && !isCancelled && (
|
{!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" />}
|
{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>
|
||||||
<div className="mb-8 mt-6 text-center last:mb-0">
|
<div className="mb-8 mt-6 text-center last:mb-0">
|
||||||
<h3
|
<h3
|
||||||
@@ -486,10 +491,7 @@ export default function Success(props: SuccessProps) {
|
|||||||
: t("payment")}
|
: t("payment")}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 mb-2 mt-3">
|
<div className="col-span-2 mb-2 mt-3">
|
||||||
{new Intl.NumberFormat(i18n.language, {
|
<Price currency={props.paymentStatus.currency} price={props.paymentStatus.amount} />
|
||||||
style: "currency",
|
|
||||||
currency: props.paymentStatus.currency,
|
|
||||||
}).format(props.paymentStatus.amount / 100.0)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1006,10 +1008,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
const ssr = await ssrInit(context);
|
const ssr = await ssrInit(context);
|
||||||
const session = await getServerSession(context);
|
const session = await getServerSession(context);
|
||||||
let tz: string | null = null;
|
let tz: string | null = null;
|
||||||
|
let userTimeFormat: number | null = null;
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
const user = await ssr.viewer.me.fetch();
|
const user = await ssr.viewer.me.fetch();
|
||||||
tz = user.timeZone;
|
tz = user.timeZone;
|
||||||
|
userTimeFormat = user.timeFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedQuery = querySchema.safeParse(context.query);
|
const parsedQuery = querySchema.safeParse(context.query);
|
||||||
@@ -1150,6 +1154,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
bookingInfo,
|
bookingInfo,
|
||||||
paymentStatus: payment,
|
paymentStatus: payment,
|
||||||
...(tz && { tz }),
|
...(tz && { tz }),
|
||||||
|
userTimeFormat,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -449,7 +449,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||||||
availability={availability}
|
availability={availability}
|
||||||
isUpdateMutationLoading={updateMutation.isLoading}
|
isUpdateMutationLoading={updateMutation.isLoading}
|
||||||
formMethods={formMethods}
|
formMethods={formMethods}
|
||||||
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
// disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||||
|
disableBorder={true}
|
||||||
currentUserMembership={currentUserMembership}
|
currentUserMembership={currentUserMembership}
|
||||||
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
|
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
|
||||||
<Form
|
<Form
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
|||||||
readOnly={events[0].metadata.readOnly}
|
readOnly={events[0].metadata.readOnly}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CreateFirstEventTypeView />
|
<CreateFirstEventTypeView slug={eventTypeGroups[0].profile.slug ?? ""} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -363,7 +363,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!types.length) {
|
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];
|
const firstItem = types[0];
|
||||||
@@ -764,7 +768,7 @@ const EventTypeListHeading = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CreateFirstEventTypeView = () => {
|
const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -774,7 +778,7 @@ const CreateFirstEventTypeView = () => {
|
|||||||
description={t("new_event_type_description")}
|
description={t("new_event_type_description")}
|
||||||
className="mb-16"
|
className="mb-16"
|
||||||
buttonRaw={
|
buttonRaw={
|
||||||
<Button href="?dialog=new" variant="button">
|
<Button href={`?dialog=new&eventPage=${slug}`} variant="button">
|
||||||
{t("create")}
|
{t("create")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -915,7 +919,7 @@ const Main = ({
|
|||||||
) : group.teamId ? (
|
) : group.teamId ? (
|
||||||
<EmptyEventTypeList group={group} />
|
<EmptyEventTypeList group={group} />
|
||||||
) : (
|
) : (
|
||||||
<CreateFirstEventTypeView />
|
<CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -931,7 +935,7 @@ const Main = ({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
|
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />}
|
||||||
<EventTypeEmbedDialog />
|
<EventTypeEmbedDialog />
|
||||||
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
|
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -22,12 +22,11 @@ const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
|
|||||||
<>
|
<>
|
||||||
<section className={classNames("text-default flex flex-col sm:flex-row", className)}>
|
<section className={classNames("text-default flex flex-col sm:flex-row", className)}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-medium">{title}</h2>
|
<h2 className="text-base font-semibold">{title}</h2>
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">{children}</div>
|
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
<hr className="border-subtle" />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -45,14 +44,16 @@ const BillingView = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title={t("billing")} description={t("manage_billing_description")} />
|
<Meta title={t("billing")} description={t("manage_billing_description")} borderInShellHeader={true} />
|
||||||
<div className="space-y-6 text-sm sm:space-y-8">
|
<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")}>
|
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
|
||||||
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
|
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
|
||||||
{t("billing_portal")}
|
{t("billing_portal")}
|
||||||
</Button>
|
</Button>
|
||||||
</CtaRow>
|
</CtaRow>
|
||||||
|
|
||||||
|
<hr className="border-subtle" />
|
||||||
|
|
||||||
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
|
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
|
||||||
<Button color="secondary" onClick={onContactSupportClick}>
|
<Button color="secondary" onClick={onContactSupportClick}>
|
||||||
{t("contact_support")}
|
{t("contact_support")}
|
||||||
|
|||||||
@@ -14,12 +14,25 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
EmptyScreen,
|
EmptyScreen,
|
||||||
Meta,
|
Meta,
|
||||||
AppSkeletonLoader as SkeletonLoader,
|
SkeletonContainer,
|
||||||
|
SkeletonText,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import { Link as LinkIcon, Plus } from "@calcom/ui/components/icon";
|
import { Link as LinkIcon, Plus } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
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 ApiKeysView = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
@@ -39,49 +52,57 @@ const ApiKeysView = () => {
|
|||||||
setApiKeyToEdit(undefined);
|
setApiKeyToEdit(undefined);
|
||||||
setApiKeyModal(true);
|
setApiKeyModal(true);
|
||||||
}}>
|
}}>
|
||||||
{t("new_api_key")}
|
{t("add")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<SkeletonLoader
|
||||||
|
title={t("api_keys")}
|
||||||
|
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta
|
<Meta
|
||||||
title={t("api_keys")}
|
title={t("api_keys")}
|
||||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||||
|
CTA={<NewApiKeyButton />}
|
||||||
|
borderInShellHeader={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
<>
|
<div>
|
||||||
{isLoading && <SkeletonLoader />}
|
{data?.length ? (
|
||||||
<div>
|
<>
|
||||||
{isLoading ? null : data?.length ? (
|
<div className="border-subtle rounded-b-md border border-t-0">
|
||||||
<>
|
{data.map((apiKey, index) => (
|
||||||
<div className="border-subtle mb-8 mt-6 rounded-md border">
|
<ApiKeyListItem
|
||||||
{data.map((apiKey, index) => (
|
key={apiKey.id}
|
||||||
<ApiKeyListItem
|
apiKey={apiKey}
|
||||||
key={apiKey.id}
|
lastItem={data.length === index + 1}
|
||||||
apiKey={apiKey}
|
onEditClick={() => {
|
||||||
lastItem={data.length === index + 1}
|
setApiKeyToEdit(apiKey);
|
||||||
onEditClick={() => {
|
setApiKeyModal(true);
|
||||||
setApiKeyToEdit(apiKey);
|
}}
|
||||||
setApiKeyModal(true);
|
/>
|
||||||
}}
|
))}
|
||||||
/>
|
</div>
|
||||||
))}
|
</>
|
||||||
</div>
|
) : (
|
||||||
<NewApiKeyButton />
|
<EmptyScreen
|
||||||
</>
|
Icon={LinkIcon}
|
||||||
) : (
|
headline={t("create_first_api_key")}
|
||||||
<EmptyScreen
|
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||||
Icon={LinkIcon}
|
className="rounded-b-md rounded-t-none border-t-0"
|
||||||
headline={t("create_first_api_key")}
|
buttonRaw={<NewApiKeyButton />}
|
||||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
/>
|
||||||
buttonRaw={<NewApiKeyButton />}
|
)}
|
||||||
/>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
</LicenseRequired>
|
</LicenseRequired>
|
||||||
|
|
||||||
<Dialog open={apiKeyModal} onOpenChange={setApiKeyModal}>
|
<Dialog open={apiKeyModal} onOpenChange={setApiKeyModal}>
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { Controller, useForm } from "react-hook-form";
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
||||||
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
|
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
||||||
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
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 { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||||
import type { userMetadata } from "@calcom/prisma/zod-utils";
|
import type { userMetadata } from "@calcom/prisma/zod-utils";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -22,7 +25,7 @@ import {
|
|||||||
SkeletonButton,
|
SkeletonButton,
|
||||||
SkeletonContainer,
|
SkeletonContainer,
|
||||||
SkeletonText,
|
SkeletonText,
|
||||||
Switch,
|
SettingsToggle,
|
||||||
UpgradeTeamsBadge,
|
UpgradeTeamsBadge,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
|
||||||
@@ -31,9 +34,9 @@ import PageWrapper from "@components/PageWrapper";
|
|||||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<Meta title={title} description={description} />
|
<Meta title={title} description={description} borderInShellHeader={false} />
|
||||||
<div className="mb-8 mt-6 space-y-6">
|
<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">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
<div className="rounded-b-xl">
|
||||||
|
<SectionBottomActions align="end">
|
||||||
|
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||||
|
</SectionBottomActions>
|
||||||
</div>
|
</div>
|
||||||
</SkeletonContainer>
|
</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 { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
|
||||||
const [darkModeError, setDarkModeError] = useState(false);
|
const [darkModeError, setDarkModeError] = useState(false);
|
||||||
const [lightModeError, setLightModeError] = 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 userThemeFormMethods = useForm({
|
||||||
|
|
||||||
const formMethods = useForm({
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
theme: user?.theme,
|
theme: user.theme,
|
||||||
brandColor: user?.brandColor || "#292929",
|
|
||||||
darkBrandColor: user?.darkBrandColor || "#fafafa",
|
|
||||||
hideBranding: user?.hideBranding,
|
|
||||||
metadata: user?.metadata as z.infer<typeof userMetadata>,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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 =
|
const selectedThemeIsDark =
|
||||||
selectedTheme === "dark" ||
|
selectedTheme === "dark" ||
|
||||||
(selectedTheme === "" &&
|
(selectedTheme === "" &&
|
||||||
typeof document !== "undefined" &&
|
typeof document !== "undefined" &&
|
||||||
document.documentElement.classList.contains("dark"));
|
document.documentElement.classList.contains("dark"));
|
||||||
|
|
||||||
const {
|
|
||||||
formState: { isSubmitting, isDirty },
|
|
||||||
reset,
|
|
||||||
} = formMethods;
|
|
||||||
|
|
||||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
await utils.viewer.me.invalidate();
|
await utils.viewer.me.invalidate();
|
||||||
showToast(t("settings_updated_successfully"), "success");
|
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) => {
|
onError: (error) => {
|
||||||
if (error.message) {
|
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 (
|
return (
|
||||||
<Form
|
<div>
|
||||||
form={formMethods}
|
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
|
||||||
handleSubmit={(values) => {
|
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
|
||||||
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>
|
<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>
|
<p className="text-default">{t("theme_applies_note")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-between sm:flex-row">
|
<Form
|
||||||
<ThemeLabel
|
form={userThemeFormMethods}
|
||||||
variant="system"
|
handleSubmit={(values) => {
|
||||||
value={null}
|
mutation.mutate({
|
||||||
label={t("theme_system")}
|
// Radio values don't support null as values, therefore we convert an empty string
|
||||||
defaultChecked={user.theme === null}
|
// back to null here.
|
||||||
register={formMethods.register}
|
theme: values.theme || null,
|
||||||
/>
|
});
|
||||||
<ThemeLabel
|
}}>
|
||||||
variant="light"
|
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
|
||||||
value="light"
|
<ThemeLabel
|
||||||
label={t("light")}
|
variant="system"
|
||||||
defaultChecked={user.theme === "light"}
|
value={null}
|
||||||
register={formMethods.register}
|
label={t("theme_system")}
|
||||||
/>
|
defaultChecked={user.theme === null}
|
||||||
<ThemeLabel
|
register={userThemeFormMethods.register}
|
||||||
variant="dark"
|
/>
|
||||||
value="dark"
|
<ThemeLabel
|
||||||
label={t("dark")}
|
variant="light"
|
||||||
defaultChecked={user.theme === "dark"}
|
value="light"
|
||||||
register={formMethods.register}
|
label={t("light")}
|
||||||
/>
|
defaultChecked={user.theme === "light"}
|
||||||
</div>
|
register={userThemeFormMethods.register}
|
||||||
|
/>
|
||||||
<hr className="border-subtle my-8 border [&:has(+hr)]:hidden" />
|
<ThemeLabel
|
||||||
<BookerLayoutSelector
|
variant="dark"
|
||||||
isDark={selectedThemeIsDark}
|
value="dark"
|
||||||
name="metadata.defaultBookerLayouts"
|
label={t("dark")}
|
||||||
title={t("bookerlayout_user_settings_title")}
|
defaultChecked={user.theme === "dark"}
|
||||||
description={t("bookerlayout_user_settings_description")}
|
register={userThemeFormMethods.register}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</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">
|
<Form
|
||||||
<Controller
|
form={bookerLayoutFormMethods}
|
||||||
name="brandColor"
|
handleSubmit={(values) => {
|
||||||
control={formMethods.control}
|
const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null);
|
||||||
defaultValue={user.brandColor}
|
if (layoutError) {
|
||||||
render={() => (
|
showToast(t(layoutError), "error");
|
||||||
<div>
|
return;
|
||||||
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
} else {
|
||||||
<ColorPicker
|
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}
|
defaultValue={user.brandColor}
|
||||||
resetDefaultValue="#292929"
|
render={() => (
|
||||||
onChange={(value) => {
|
<div>
|
||||||
if (!checkWCAGContrastColor("#ffffff", value)) {
|
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
||||||
setLightModeError(true);
|
<ColorPicker
|
||||||
} else {
|
defaultValue={user.brandColor}
|
||||||
setLightModeError(false);
|
resetDefaultValue="#292929"
|
||||||
}
|
onChange={(value) => {
|
||||||
formMethods.setValue("brandColor", value, { shouldDirty: true });
|
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"
|
||||||
<Controller
|
control={brandColorsFormMethods.control}
|
||||||
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
|
|
||||||
defaultValue={user.darkBrandColor}
|
defaultValue={user.darkBrandColor}
|
||||||
resetDefaultValue="#fafafa"
|
render={() => (
|
||||||
onChange={(value) => {
|
<div className="mt-6 sm:mt-0">
|
||||||
if (!checkWCAGContrastColor("#101010", value)) {
|
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
||||||
setDarkModeError(true);
|
<ColorPicker
|
||||||
} else {
|
defaultValue={user.darkBrandColor}
|
||||||
setDarkModeError(false);
|
resetDefaultValue="#fafafa"
|
||||||
}
|
onChange={(value) => {
|
||||||
formMethods.setValue("darkBrandColor", value, { shouldDirty: true });
|
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>
|
||||||
)}
|
<SectionBottomActions align="end">
|
||||||
/>
|
<Button
|
||||||
</div>
|
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
|
||||||
{darkModeError ? (
|
color="primary"
|
||||||
<div className="mt-4">
|
type="submit">
|
||||||
<Alert
|
{t("update")}
|
||||||
severity="warning"
|
</Button>
|
||||||
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
</SectionBottomActions>
|
||||||
/>
|
</SettingsToggle>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</Form>
|
||||||
{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}
|
|
||||||
{/* TODO future PR to preview brandColors */}
|
{/* TODO future PR to preview brandColors */}
|
||||||
{/* <Button
|
{/* <Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
@@ -235,51 +316,37 @@ const AppearanceView = () => {
|
|||||||
onClick={() => window.open(`${WEBAPP_URL}/${user.username}/${user.eventTypes[0].title}`, "_blank")}>
|
onClick={() => window.open(`${WEBAPP_URL}/${user.username}/${user.eventTypes[0].title}`, "_blank")}>
|
||||||
Preview
|
Preview
|
||||||
</Button> */}
|
</Button> */}
|
||||||
<hr className="border-subtle my-8 border" />
|
|
||||||
<Controller
|
<SettingsToggle
|
||||||
name="hideBranding"
|
toggleSwitchAtTheEnd={true}
|
||||||
control={formMethods.control}
|
title={t("disable_cal_branding", { appName: APP_NAME })}
|
||||||
defaultValue={user.hideBranding}
|
disabled={!hasPaidPlan || mutation?.isLoading}
|
||||||
render={({ field: { value } }) => (
|
description={t("removes_cal_branding", { appName: APP_NAME })}
|
||||||
<>
|
checked={hasPaidPlan ? hideBrandingValue : false}
|
||||||
<div className="flex w-full text-sm">
|
Badge={<UpgradeTeamsBadge />}
|
||||||
<div className="mr-1 flex-grow">
|
onCheckedChange={(checked) => {
|
||||||
<div className="flex items-center">
|
setHideBrandingValue(checked);
|
||||||
<p className="text-default font-semibold ltr:mr-2 rtl:ml-2">
|
mutation.mutate({ hideBranding: checked });
|
||||||
{t("disable_cal_branding", { appName: APP_NAME })}
|
}}
|
||||||
</p>
|
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
</div>
|
||||||
disabled={isDisabled}
|
|
||||||
type="submit"
|
|
||||||
loading={mutation.isLoading}
|
|
||||||
color="primary"
|
|
||||||
className="mt-8"
|
|
||||||
data-testid="update-theme-btn">
|
|
||||||
{t("update")}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
AppearanceView.getLayout = getLayout;
|
const AppearanceViewWrapper = () => {
|
||||||
AppearanceView.PageWrapper = PageWrapper;
|
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 { Trans } from "next-i18next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Fragment } from "react";
|
import { Fragment, useState, useEffect } from "react";
|
||||||
|
|
||||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||||
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
||||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||||
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -34,13 +35,13 @@ import PageWrapper from "@components/PageWrapper";
|
|||||||
const SkeletonLoader = () => {
|
const SkeletonLoader = () => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<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" />
|
||||||
<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>
|
</div>
|
||||||
</SkeletonContainer>
|
</SkeletonContainer>
|
||||||
);
|
);
|
||||||
@@ -65,6 +66,21 @@ const CalendarsView = () => {
|
|||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
|
||||||
const query = trpc.viewer.connectedCalendars.useQuery();
|
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({
|
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
await utils.viewer.connectedCalendars.invalidate();
|
await utils.viewer.connectedCalendars.invalidate();
|
||||||
@@ -79,43 +95,58 @@ const CalendarsView = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title={t("calendars")} description={t("calendars_description")} CTA={<AddCalendarButton />} />
|
<Meta
|
||||||
|
title={t("calendars")}
|
||||||
|
description={t("calendars_description")}
|
||||||
|
CTA={<AddCalendarButton />}
|
||||||
|
borderInShellHeader={false}
|
||||||
|
/>
|
||||||
<QueryCell
|
<QueryCell
|
||||||
query={query}
|
query={query}
|
||||||
customLoader={<SkeletonLoader />}
|
customLoader={<SkeletonLoader />}
|
||||||
success={({ data }) => {
|
success={({ data }) => {
|
||||||
|
const isDestinationUpdateBtnDisabled =
|
||||||
|
selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId;
|
||||||
return data.connectedCalendars.length ? (
|
return data.connectedCalendars.length ? (
|
||||||
<div>
|
<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="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
|
||||||
<div className=" bg-default border-subtle flex h-9 w-9 items-center justify-center rounded-md border-2 p-[6px]">
|
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
|
||||||
<Calendar className="text-default h-6 w-6" />
|
{t("add_to_calendar")}
|
||||||
</div>
|
</h2>
|
||||||
|
<p className="text-default text-sm">{t("add_to_calendar_description")}</p>
|
||||||
<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>
|
</div>
|
||||||
<h4 className="text-emphasis mt-12 text-base font-semibold leading-5">
|
<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">
|
||||||
{t("check_for_conflicts")}
|
<DestinationCalendarSelector
|
||||||
</h4>
|
hidePlaceholder
|
||||||
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
|
value={selectedDestinationCalendarOption?.externalId}
|
||||||
<List className="flex flex-col gap-6" noBorderTreatment>
|
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) => (
|
{data.connectedCalendars.map((item) => (
|
||||||
<Fragment key={item.credentialId}>
|
<Fragment key={item.credentialId}>
|
||||||
{item.error && item.error.message && (
|
{item.error && item.error.message && (
|
||||||
@@ -207,6 +238,7 @@ const CalendarsView = () => {
|
|||||||
description={t("no_calendar_installed_description")}
|
description={t("no_calendar_installed_description")}
|
||||||
buttonText={t("add_a_calendar")}
|
buttonText={t("add_a_calendar")}
|
||||||
buttonOnClick={() => router.push("/apps/categories/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 DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||||
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
|
|
||||||
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
|
||||||
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
|
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import {
|
import { Button, EmptyScreen, Meta, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||||
Button,
|
import { Calendar, Plus } from "@calcom/ui/components/icon";
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
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 AppListCard from "@components/AppListCard";
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
import { AppList } from "@components/apps/AppList";
|
||||||
|
|
||||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<Meta title={title} description={description} />
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
<div className="divide-subtle mb-8 mt-6 space-y-6">
|
<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" />
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
</div>
|
</div>
|
||||||
@@ -45,162 +28,93 @@ const AddConferencingButton = () => {
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Button color="secondary" StartIcon={Plus} href="/apps/categories/conferencing">
|
||||||
<Button color="secondary" StartIcon={Plus} href="/apps/categories/video">
|
{t("add")}
|
||||||
{t("add_conferencing_app")}
|
</Button>
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ModalState = {
|
||||||
|
isOpen: boolean;
|
||||||
|
credentialId: null | number;
|
||||||
|
};
|
||||||
|
|
||||||
const ConferencingLayout = () => {
|
const ConferencingLayout = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
|
||||||
|
|
||||||
const { data: defaultConferencingApp, isLoading: defaultConferencingAppLoading } =
|
const [modal, updateModal] = useReducer(
|
||||||
trpc.viewer.getUsersDefaultConferencingApp.useQuery();
|
(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",
|
variant: "conferencing",
|
||||||
onlyInstalled: true,
|
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(() => {
|
const handleModelClose = () => {
|
||||||
setBulkUpdateModal(true);
|
updateModal({ isOpen: false, credentialId: null });
|
||||||
showToast("Default app updated successfully", "success");
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
const handleDisconnect = (credentialId: number) => {
|
||||||
onSuccess: async () => {
|
updateModal({ isOpen: true, credentialId });
|
||||||
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")} />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-default w-full sm:mx-0 xl:mt-0">
|
<>
|
||||||
<Meta
|
<div className="bg-default w-full sm:mx-0 xl:mt-0">
|
||||||
title={t("conferencing")}
|
<Meta
|
||||||
description={t("conferencing_description")}
|
title={t("conferencing")}
|
||||||
CTA={<AddConferencingButton />}
|
description={t("conferencing_description")}
|
||||||
/>
|
CTA={<AddConferencingButton />}
|
||||||
<List>
|
borderInShellHeader={true}
|
||||||
{apps?.items &&
|
/>
|
||||||
apps.items
|
<QueryCell
|
||||||
.map((app) => ({ ...app, title: app.title || app.name }))
|
query={query}
|
||||||
.map((app) => {
|
customLoader={
|
||||||
const appSlug = app?.slug;
|
<SkeletonLoader title={t("conferencing")} description={t("conferencing_description")} />
|
||||||
const appIsDefault =
|
}
|
||||||
appSlug === defaultConferencingApp?.appSlug ||
|
success={({ data }) => {
|
||||||
(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)
|
console.log(data);
|
||||||
|
if (!data.items.length) {
|
||||||
return (
|
return (
|
||||||
<AppListCard
|
<EmptyScreen
|
||||||
description={app.description}
|
Icon={Calendar}
|
||||||
title={app.title}
|
headline={t("no_category_apps", {
|
||||||
logo={app.logo}
|
category: t("conferencing").toLowerCase(),
|
||||||
key={app.title}
|
})}
|
||||||
isDefault={appIsDefault} // @TODO: Handle when a user doesnt have this value set
|
description={t("no_category_apps_description_conferencing")}
|
||||||
actions={
|
buttonRaw={
|
||||||
<div>
|
<Button
|
||||||
<Dropdown>
|
color="secondary"
|
||||||
<DropdownMenuTrigger asChild>
|
data-testid="connect-conferencing-apps"
|
||||||
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
href="/apps/categories/conferencing">
|
||||||
</DropdownMenuTrigger>
|
{t("connect_conference_apps")}
|
||||||
<DropdownMenuContent>
|
</Button>
|
||||||
{!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>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
</List>
|
return (
|
||||||
|
<AppList
|
||||||
<Dialog open={deleteAppModal} onOpenChange={setDeleteAppModal}>
|
listClassName="rounded-xl rounded-t-none border-t-0"
|
||||||
<DialogContent
|
handleDisconnect={handleDisconnect}
|
||||||
title={t("Remove app")}
|
data={data}
|
||||||
description={t("are_you_sure_you_want_to_remove_this_app")}
|
variant="conferencing"
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
{bulkUpdateModal && (
|
<DisconnectIntegrationModal
|
||||||
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
|
handleModelClose={handleModelClose}
|
||||||
)}
|
isOpen={modal.isOpen}
|
||||||
</div>
|
credentialId={modal.credentialId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { localeOptions } from "@calcom/lib/i18n";
|
import { localeOptions } from "@calcom/lib/i18n";
|
||||||
@@ -13,12 +15,12 @@ import {
|
|||||||
Label,
|
Label,
|
||||||
Meta,
|
Meta,
|
||||||
Select,
|
Select,
|
||||||
SettingsToggle,
|
|
||||||
showToast,
|
showToast,
|
||||||
SkeletonButton,
|
SkeletonButton,
|
||||||
SkeletonContainer,
|
SkeletonContainer,
|
||||||
SkeletonText,
|
SkeletonText,
|
||||||
TimezoneSelect,
|
TimezoneSelect,
|
||||||
|
SettingsToggle,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
@@ -26,14 +28,14 @@ import PageWrapper from "@components/PageWrapper";
|
|||||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<Meta title={title} description={description} />
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
<div className="mb-8 mt-6 space-y-6">
|
<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" />
|
||||||
<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>
|
</div>
|
||||||
</SkeletonContainer>
|
</SkeletonContainer>
|
||||||
);
|
);
|
||||||
@@ -59,6 +61,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { update } = useSession();
|
const { update } = useSession();
|
||||||
|
const [isUpdateBtnLoading, setIsUpdateBtnLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
@@ -72,6 +75,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await utils.viewer.me.invalidate();
|
await utils.viewer.me.invalidate();
|
||||||
|
setIsUpdateBtnLoading(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,9 +109,6 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||||||
value: user.weekStart,
|
value: user.weekStart,
|
||||||
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
||||||
},
|
},
|
||||||
allowDynamicBooking: user.allowDynamicBooking ?? true,
|
|
||||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
|
||||||
receiveMonthlyDigestEmail: user.receiveMonthlyDigestEmail ?? true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
@@ -117,151 +118,150 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||||||
} = formMethods;
|
} = formMethods;
|
||||||
const isDisabled = isSubmitting || !isDirty;
|
const isDisabled = isSubmitting || !isDirty;
|
||||||
|
|
||||||
|
const [isAllowDynamicBookingChecked, setIsAllowDynamicBookingChecked] = useState(
|
||||||
|
!!user.allowDynamicBooking
|
||||||
|
);
|
||||||
|
const [isAllowSEOIndexingChecked, setIsAllowSEOIndexingChecked] = useState(!!user.allowSEOIndexing);
|
||||||
|
const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState(
|
||||||
|
!!user.receiveMonthlyDigestEmail
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<div>
|
||||||
form={formMethods}
|
<Form
|
||||||
handleSubmit={(values) => {
|
form={formMethods}
|
||||||
mutation.mutate({
|
handleSubmit={(values) => {
|
||||||
...values,
|
setIsUpdateBtnLoading(true);
|
||||||
locale: values.locale.value,
|
mutation.mutate({
|
||||||
timeFormat: values.timeFormat.value,
|
...values,
|
||||||
weekStart: values.weekStart.value,
|
locale: values.locale.value,
|
||||||
});
|
timeFormat: values.timeFormat.value,
|
||||||
}}>
|
weekStart: values.weekStart.value,
|
||||||
<Meta title={t("general")} description={t("general_description")} />
|
});
|
||||||
<Controller
|
}}>
|
||||||
name="locale"
|
<Meta title={t("general")} description={t("general_description")} borderInShellHeader={true} />
|
||||||
render={({ field: { value, onChange } }) => (
|
<div className="border-subtle border-x border-y-0 px-4 py-8 sm:px-6">
|
||||||
<>
|
<Controller
|
||||||
<Label className="text-emphasis">
|
name="locale"
|
||||||
<>{t("language")}</>
|
render={({ field: { value, onChange } }) => (
|
||||||
</Label>
|
<>
|
||||||
<Select<{ label: string; value: string }>
|
<Label className="text-emphasis">
|
||||||
className="capitalize"
|
<>{t("language")}</>
|
||||||
options={localeOptions}
|
</Label>
|
||||||
value={value}
|
<Select<{ label: string; value: string }>
|
||||||
onChange={onChange}
|
className="capitalize"
|
||||||
/>
|
options={localeOptions}
|
||||||
</>
|
value={value}
|
||||||
)}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
<Controller
|
</>
|
||||||
name="timeZone"
|
)}
|
||||||
control={formMethods.control}
|
/>
|
||||||
render={({ field: { value } }) => (
|
<Controller
|
||||||
<>
|
name="timeZone"
|
||||||
<Label className="text-emphasis mt-8">
|
control={formMethods.control}
|
||||||
<>{t("timezone")}</>
|
render={({ field: { value } }) => (
|
||||||
</Label>
|
<>
|
||||||
<TimezoneSelect
|
<Label className="text-emphasis mt-6">
|
||||||
id="timezone"
|
<>{t("timezone")}</>
|
||||||
value={value}
|
</Label>
|
||||||
onChange={(event) => {
|
<TimezoneSelect
|
||||||
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
|
id="timezone"
|
||||||
}}
|
value={value}
|
||||||
/>
|
onChange={(event) => {
|
||||||
</>
|
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
|
||||||
)}
|
}}
|
||||||
/>
|
/>
|
||||||
<Controller
|
</>
|
||||||
name="timeFormat"
|
)}
|
||||||
control={formMethods.control}
|
/>
|
||||||
render={({ field: { value } }) => (
|
<Controller
|
||||||
<>
|
name="timeFormat"
|
||||||
<Label className="text-emphasis mt-8">
|
control={formMethods.control}
|
||||||
<>{t("time_format")}</>
|
render={({ field: { value } }) => (
|
||||||
</Label>
|
<>
|
||||||
<Select
|
<Label className="text-emphasis mt-6">
|
||||||
value={value}
|
<>{t("time_format")}</>
|
||||||
options={timeFormatOptions}
|
</Label>
|
||||||
onChange={(event) => {
|
<Select
|
||||||
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
|
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
|
<div className="text-gray text-default mt-2 flex items-center text-sm">
|
||||||
name="weekStart"
|
{t("timeformat_profile_hint")}
|
||||||
control={formMethods.control}
|
</div>
|
||||||
render={({ field: { value } }) => (
|
<Controller
|
||||||
<>
|
name="weekStart"
|
||||||
<Label className="text-emphasis mt-8">
|
control={formMethods.control}
|
||||||
<>{t("start_of_week")}</>
|
render={({ field: { value } }) => (
|
||||||
</Label>
|
<>
|
||||||
<Select
|
<Label className="text-emphasis mt-6">
|
||||||
value={value}
|
<>{t("start_of_week")}</>
|
||||||
options={weekStartOptions}
|
</Label>
|
||||||
onChange={(event) => {
|
<Select
|
||||||
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
|
value={value}
|
||||||
}}
|
options={weekStartOptions}
|
||||||
/>
|
onChange={(event) => {
|
||||||
</>
|
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
|
||||||
)}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="mt-8">
|
</>
|
||||||
<Controller
|
)}
|
||||||
name="allowDynamicBooking"
|
/>
|
||||||
control={formMethods.control}
|
</div>
|
||||||
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 className="mt-8">
|
<SectionBottomActions align="end">
|
||||||
<Controller
|
<Button loading={isUpdateBtnLoading} disabled={isDisabled} color="primary" type="submit">
|
||||||
name="allowSEOIndexing"
|
<>{t("update")}</>
|
||||||
control={formMethods.control}
|
</Button>
|
||||||
render={() => (
|
</SectionBottomActions>
|
||||||
<SettingsToggle
|
</Form>
|
||||||
title={t("seo_indexing")}
|
|
||||||
description={t("allow_seo_indexing")}
|
|
||||||
checked={formMethods.getValues("allowSEOIndexing")}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
formMethods.setValue("allowSEOIndexing", checked, { shouldDirty: true });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
<SettingsToggle
|
||||||
<Controller
|
toggleSwitchAtTheEnd={true}
|
||||||
name="receiveMonthlyDigestEmail"
|
title={t("dynamic_booking")}
|
||||||
control={formMethods.control}
|
description={t("allow_dynamic_booking")}
|
||||||
render={() => (
|
disabled={mutation.isLoading}
|
||||||
<SettingsToggle
|
checked={isAllowDynamicBookingChecked}
|
||||||
title={t("monthly_digest_email")}
|
onCheckedChange={(checked) => {
|
||||||
description={t("monthly_digest_email_for_teams")}
|
setIsAllowDynamicBookingChecked(checked);
|
||||||
checked={formMethods.getValues("receiveMonthlyDigestEmail")}
|
mutation.mutate({ allowDynamicBooking: checked });
|
||||||
onCheckedChange={(checked) => {
|
}}
|
||||||
formMethods.setValue("receiveMonthlyDigestEmail", checked, { shouldDirty: true });
|
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<SettingsToggle
|
||||||
loading={mutation.isLoading}
|
toggleSwitchAtTheEnd={true}
|
||||||
disabled={isDisabled}
|
title={t("seo_indexing")}
|
||||||
color="primary"
|
description={t("allow_seo_indexing")}
|
||||||
type="submit"
|
disabled={mutation.isLoading}
|
||||||
className="mt-8">
|
checked={isAllowSEOIndexingChecked}
|
||||||
<>{t("update")}</>
|
onCheckedChange={(checked) => {
|
||||||
</Button>
|
setIsAllowSEOIndexingChecked(checked);
|
||||||
</Form>
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
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 { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { md } from "@calcom/lib/markdownIt";
|
import { md } from "@calcom/lib/markdownIt";
|
||||||
import turndown from "@calcom/lib/turndownService";
|
import turndown from "@calcom/lib/turndownService";
|
||||||
@@ -47,8 +49,8 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
|||||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<Meta title={title} description={description} />
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
<div className="mb-8 space-y-6">
|
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
|
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
|
||||||
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
|
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
|
||||||
@@ -69,18 +71,30 @@ interface DeleteAccountValues {
|
|||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
username: string;
|
username: string;
|
||||||
avatar: string;
|
avatar: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkIfItFallbackImage = (fetchedImgSrc: string) => {
|
||||||
|
return fetchedImgSrc.endsWith(AVATAR_FALLBACK);
|
||||||
|
};
|
||||||
|
|
||||||
const ProfileView = () => {
|
const ProfileView = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { update } = useSession();
|
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({
|
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
await update(res);
|
await update(res);
|
||||||
@@ -204,7 +218,7 @@ const ProfileView = () => {
|
|||||||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading || !user)
|
if (isLoading || !user || !fetchedImgSrc)
|
||||||
return (
|
return (
|
||||||
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
|
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
|
||||||
);
|
);
|
||||||
@@ -219,11 +233,17 @@ const ProfileView = () => {
|
|||||||
|
|
||||||
return (
|
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
|
<ProfileForm
|
||||||
key={JSON.stringify(defaultValues)}
|
key={JSON.stringify(defaultValues)}
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
isLoading={updateProfileMutation.isLoading}
|
isLoading={updateProfileMutation.isLoading}
|
||||||
|
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
|
||||||
|
userAvatar={user.avatar}
|
||||||
userOrganization={user.organization}
|
userOrganization={user.organization}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
if (values.email !== user.email && isCALIdentityProvider) {
|
if (values.email !== user.email && isCALIdentityProvider) {
|
||||||
@@ -238,7 +258,7 @@ const ProfileView = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
extraField={
|
extraField={
|
||||||
<div className="mt-8">
|
<div className="mt-6">
|
||||||
<UsernameAvailabilityField
|
<UsernameAvailabilityField
|
||||||
onSuccessMutation={async () => {
|
onSuccessMutation={async () => {
|
||||||
showToast(t("settings_updated_successfully"), "success");
|
showToast(t("settings_updated_successfully"), "success");
|
||||||
@@ -252,16 +272,19 @@ const ProfileView = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<hr className="border-subtle my-6" />
|
<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>
|
||||||
<Label>{t("danger_zone")}</Label>
|
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
|
||||||
|
</div>
|
||||||
{/* Delete account Dialog */}
|
{/* Delete account Dialog */}
|
||||||
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
||||||
<DialogTrigger asChild>
|
<SectionBottomActions align="end">
|
||||||
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
|
<DialogTrigger asChild>
|
||||||
{t("delete_account")}
|
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
|
||||||
</Button>
|
{t("delete_account")}
|
||||||
</DialogTrigger>
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</SectionBottomActions>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
title={t("delete_account_modal_title")}
|
title={t("delete_account_modal_title")}
|
||||||
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
|
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
|
||||||
@@ -269,9 +292,7 @@ const ProfileView = () => {
|
|||||||
Icon={AlertTriangle}>
|
Icon={AlertTriangle}>
|
||||||
<>
|
<>
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
<p className="text-default mb-4">
|
<p className="text-default mb-4">{t("delete_account_confirmation_message")}</p>
|
||||||
{t("delete_account_confirmation_message", { appName: APP_NAME })}
|
|
||||||
</p>
|
|
||||||
{isCALIdentityProvider && (
|
{isCALIdentityProvider && (
|
||||||
<PasswordField
|
<PasswordField
|
||||||
data-testid="password"
|
data-testid="password"
|
||||||
@@ -366,12 +387,16 @@ const ProfileForm = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
extraField,
|
extraField,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
isFallbackImg,
|
||||||
|
userAvatar,
|
||||||
userOrganization,
|
userOrganization,
|
||||||
}: {
|
}: {
|
||||||
defaultValues: FormValues;
|
defaultValues: FormValues;
|
||||||
onSubmit: (values: FormValues) => void;
|
onSubmit: (values: FormValues) => void;
|
||||||
extraField?: React.ReactNode;
|
extraField?: React.ReactNode;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isFallbackImg: boolean;
|
||||||
|
userAvatar: string;
|
||||||
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -379,7 +404,7 @@ const ProfileForm = ({
|
|||||||
|
|
||||||
const profileFormSchema = z.object({
|
const profileFormSchema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
avatar: z.string(),
|
avatar: z.string().nullable(),
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -404,56 +429,77 @@ const ProfileForm = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form form={formMethods} handleSubmit={onSubmit}>
|
<Form form={formMethods} handleSubmit={onSubmit}>
|
||||||
<div className="flex items-center">
|
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
|
||||||
<Controller
|
<div className="flex items-center">
|
||||||
control={formMethods.control}
|
<Controller
|
||||||
name="avatar"
|
control={formMethods.control}
|
||||||
render={({ field: { value } }) => (
|
name="avatar"
|
||||||
<>
|
render={({ field: { value } }) => {
|
||||||
<OrganizationAvatar
|
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
|
||||||
alt={formMethods.getValues("username")}
|
return (
|
||||||
imageSrc={value}
|
<>
|
||||||
size="lg"
|
<OrganizationAvatar
|
||||||
organizationSlug={userOrganization.slug}
|
alt={formMethods.getValues("username")}
|
||||||
/>
|
imageSrc={value}
|
||||||
<div className="ms-4">
|
size="lg"
|
||||||
<ImageUploader
|
organizationSlug={userOrganization.slug}
|
||||||
target="avatar"
|
/>
|
||||||
id="avatar-upload"
|
<div className="ms-4">
|
||||||
buttonMsg={t("change_avatar")}
|
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
|
||||||
handleAvatarChange={(newAvatar) => {
|
<div className="flex gap-2">
|
||||||
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
|
<ImageUploader
|
||||||
}}
|
target="avatar"
|
||||||
imageSrc={value || undefined}
|
id="avatar-upload"
|
||||||
/>
|
buttonMsg={t("upload_avatar")}
|
||||||
</div>
|
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>
|
</div>
|
||||||
{extraField}
|
<SectionBottomActions align="end">
|
||||||
<div className="mt-8">
|
<Button loading={isLoading} disabled={isDisabled} color="primary" type="submit">
|
||||||
<TextField label={t("full_name")} {...formMethods.register("name")} />
|
{t("update")}
|
||||||
</div>
|
</Button>
|
||||||
<div className="mt-8">
|
</SectionBottomActions>
|
||||||
<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>
|
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
import type { GetServerSidePropsContext } from "next";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
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 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 { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
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({
|
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showToast(t("profile_updated_successfully"), "success");
|
showToast(t("profile_updated_successfully"), "success");
|
||||||
reset(getValues());
|
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
utils.viewer.me.invalidate();
|
utils.viewer.me.invalidate();
|
||||||
@@ -26,83 +37,54 @@ const ProfileImpersonationView = () => {
|
|||||||
await utils.viewer.me.cancel();
|
await utils.viewer.me.cancel();
|
||||||
const previousValue = utils.viewer.me.getData();
|
const previousValue = utils.viewer.me.getData();
|
||||||
|
|
||||||
if (previousValue && disableImpersonation) {
|
setDisableImpersonation(disableImpersonation);
|
||||||
utils.viewer.me.setData(undefined, { ...previousValue, disableImpersonation });
|
|
||||||
}
|
|
||||||
return { previousValue };
|
return { previousValue };
|
||||||
},
|
},
|
||||||
onError: (error, variables, context) => {
|
onError: (error, variables, context) => {
|
||||||
if (context?.previousValue) {
|
if (context?.previousValue) {
|
||||||
utils.viewer.me.setData(undefined, context.previousValue);
|
utils.viewer.me.setData(undefined, context.previousValue);
|
||||||
|
setDisableImpersonation(context.previousValue?.disableImpersonation);
|
||||||
}
|
}
|
||||||
showToast(`${t("error")}, ${error.message}`, "error");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title={t("impersonation")} description={t("impersonation_description")} />
|
<Meta
|
||||||
<Form
|
title={t("impersonation")}
|
||||||
form={formMethods}
|
description={t("impersonation_description")}
|
||||||
handleSubmit={({ disableImpersonation }) => {
|
borderInShellHeader={true}
|
||||||
mutation.mutate({ disableImpersonation });
|
/>
|
||||||
}}>
|
<div>
|
||||||
<div className="flex space-x-3">
|
<SettingsToggle
|
||||||
<Switch
|
toggleSwitchAtTheEnd={true}
|
||||||
onCheckedChange={(e) => {
|
title={t("user_impersonation_heading")}
|
||||||
setValue("disableImpersonation", !e, { shouldDirty: true });
|
description={t("user_impersonation_description")}
|
||||||
}}
|
checked={!disableImpersonation}
|
||||||
fitToHeight={true}
|
onCheckedChange={(checked) => {
|
||||||
checked={!watch("disableImpersonation")}
|
mutation.mutate({ disableImpersonation: !checked });
|
||||||
/>
|
}}
|
||||||
<div className="flex flex-col">
|
disabled={mutation.isLoading}
|
||||||
<Skeleton as={Label} className="text-emphasis text-sm font-semibold leading-none">
|
switchContainerClassName="py-6 px-4 sm:px-6 border-subtle rounded-b-xl border border-t-0"
|
||||||
{t("user_impersonation_heading")}
|
/>
|
||||||
</Skeleton>
|
</div>
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ProfileImpersonationView.getLayout = getLayout;
|
const ProfileImpersonationViewWrapper = () => {
|
||||||
ProfileImpersonationView.PageWrapper = PageWrapper;
|
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
if (isLoading || !user)
|
||||||
const ssr = await ssrInit(context);
|
return <SkeletonLoader title={t("impersonation")} description={t("impersonation_description")} />;
|
||||||
await ssr.viewer.me.prefetch();
|
|
||||||
return {
|
return <ProfileImpersonationView user={user} />;
|
||||||
props: {
|
|
||||||
trpcState: ssr.dehydrate(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProfileImpersonationView;
|
ProfileImpersonationViewWrapper.getLayout = getLayout;
|
||||||
|
ProfileImpersonationViewWrapper.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
|
export default ProfileImpersonationViewWrapper;
|
||||||
@@ -1,13 +1,29 @@
|
|||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { identityProviderNameMap } from "@calcom/features/auth/lib/identityProviderNameMap";
|
import { identityProviderNameMap } from "@calcom/features/auth/lib/identityProviderNameMap";
|
||||||
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
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 { 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";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
@@ -18,34 +34,58 @@ type ChangePasswordSessionFormValues = {
|
|||||||
apiError: string;
|
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 { data } = useSession();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { data: user } = trpc.viewer.me.useQuery();
|
const metadata = userMetadataSchema.safeParse(user?.metadata);
|
||||||
const metadata = userMetadata.safeParse(user?.metadata);
|
const initialSessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
|
||||||
const sessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
|
|
||||||
|
const [sessionTimeout, setSessionTimeout] = useState<number | undefined>(initialSessionTimeout);
|
||||||
|
|
||||||
const sessionMutation = trpc.viewer.updateProfile.useMutation({
|
const sessionMutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
showToast(t("session_timeout_changed"), "success");
|
showToast(t("session_timeout_changed"), "success");
|
||||||
formMethods.reset(formMethods.getValues());
|
formMethods.reset(formMethods.getValues());
|
||||||
|
setSessionTimeout(data.metadata?.sessionTimeout);
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
utils.viewer.me.invalidate();
|
utils.viewer.me.invalidate();
|
||||||
},
|
},
|
||||||
onMutate: async () => {
|
onMutate: async () => {
|
||||||
await utils.viewer.me.cancel();
|
await utils.viewer.me.cancel();
|
||||||
const previousValue = utils.viewer.me.getData();
|
const previousValue = await utils.viewer.me.getData();
|
||||||
const previousMetadata = userMetadata.parse(previousValue?.metadata);
|
const previousMetadata = userMetadataSchema.safeParse(previousValue?.metadata);
|
||||||
|
|
||||||
if (previousValue && sessionTimeout) {
|
if (previousValue && sessionTimeout && previousMetadata.success) {
|
||||||
utils.viewer.me.setData(undefined, {
|
utils.viewer.me.setData(undefined, {
|
||||||
...previousValue,
|
...previousValue,
|
||||||
metadata: { ...previousMetadata, sessionTimeout: sessionTimeout },
|
metadata: { ...previousMetadata?.data, sessionTimeout: sessionTimeout },
|
||||||
});
|
});
|
||||||
|
return { previousValue };
|
||||||
}
|
}
|
||||||
return { previousValue };
|
|
||||||
},
|
},
|
||||||
onError: (error, _, context) => {
|
onError: (error, _, context) => {
|
||||||
if (context?.previousValue) {
|
if (context?.previousValue) {
|
||||||
@@ -84,20 +124,30 @@ const PasswordView = () => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
oldPassword: "",
|
oldPassword: "",
|
||||||
newPassword: "",
|
newPassword: "",
|
||||||
sessionTimeout,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionTimeoutWatch = formMethods.watch("sessionTimeout");
|
|
||||||
|
|
||||||
const handleSubmit = (values: ChangePasswordSessionFormValues) => {
|
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) {
|
if (oldPassword && newPassword) {
|
||||||
passwordMutation.mutate({ oldPassword, newPassword });
|
passwordMutation.mutate({ oldPassword, newPassword });
|
||||||
}
|
}
|
||||||
if (sessionTimeout !== newSessionTimeout) {
|
|
||||||
sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout: newSessionTimeout } });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeoutOptions = [5, 10, 15].map((mins) => ({
|
const timeoutOptions = [5, 10, 15].map((mins) => ({
|
||||||
@@ -112,7 +162,7 @@ const PasswordView = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title={t("password")} description={t("password_description")} />
|
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
|
||||||
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
@@ -130,87 +180,127 @@ const PasswordView = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Form form={formMethods} handleSubmit={handleSubmit}>
|
<Form form={formMethods} handleSubmit={handleSubmit}>
|
||||||
{formMethods.formState.errors.apiError && (
|
<div className="border-x px-4 py-6 sm:px-6">
|
||||||
<div className="pb-6">
|
{formMethods.formState.errors.apiError && (
|
||||||
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
|
<div className="pb-6">
|
||||||
</div>
|
<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 className="w-full sm:grid sm:grid-cols-2 sm:gap-x-6">
|
||||||
<div>
|
<div>
|
||||||
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
|
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<PasswordField
|
<PasswordField
|
||||||
{...formMethods.register("newPassword", {
|
{...formMethods.register("newPassword", {
|
||||||
minLength: {
|
minLength: {
|
||||||
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
|
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
|
||||||
value: passwordMinLength,
|
value: passwordMinLength,
|
||||||
},
|
},
|
||||||
pattern: {
|
pattern: {
|
||||||
message: "Should contain a number, uppercase and lowercase letters",
|
message: "Should contain a number, uppercase and lowercase letters",
|
||||||
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
label={t("new_password")}
|
label={t("new_password")}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-default mt-4 w-full text-sm">
|
||||||
|
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-default mt-4 max-w-[38rem] text-sm">
|
<SectionBottomActions align="end">
|
||||||
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
|
<Button
|
||||||
</p>
|
color="primary"
|
||||||
<div className="border-subtle mt-8 border-t py-8">
|
type="submit"
|
||||||
|
loading={passwordMutation.isLoading}
|
||||||
|
onClick={() => formMethods.clearErrors("apiError")}
|
||||||
|
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
|
||||||
|
{t("update")}
|
||||||
|
</Button>
|
||||||
|
</SectionBottomActions>
|
||||||
|
<div className="mt-6">
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
toggleSwitchAtTheEnd={true}
|
||||||
title={t("session_timeout")}
|
title={t("session_timeout")}
|
||||||
description={t("session_timeout_description")}
|
description={t("session_timeout_description")}
|
||||||
checked={sessionTimeoutWatch !== undefined}
|
checked={sessionTimeout !== undefined}
|
||||||
data-testid="session-check"
|
data-testid="session-check"
|
||||||
onCheckedChange={(e) => {
|
onCheckedChange={(e) => {
|
||||||
if (!e) {
|
if (!e) {
|
||||||
formMethods.setValue("sessionTimeout", undefined, { shouldDirty: true });
|
setSessionTimeout(undefined);
|
||||||
|
|
||||||
|
if (metadata.success) {
|
||||||
|
sessionMutation.mutate({
|
||||||
|
metadata: { ...metadata.data, sessionTimeout: undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
formMethods.setValue("sessionTimeout", 10, { shouldDirty: true });
|
setSessionTimeout(10);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
childrenClassName="lg:ml-0"
|
||||||
{sessionTimeoutWatch && (
|
switchContainerClassName={classNames(
|
||||||
<div className="mt-4 text-sm">
|
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
|
||||||
<div className="flex items-center">
|
!!sessionTimeout && "rounded-b-none"
|
||||||
<p className="text-default ltr:mr-2 rtl:ml-2">{t("session_timeout_after")}</p>
|
)}>
|
||||||
<Select
|
<>
|
||||||
options={timeoutOptions}
|
<div className="border-subtle border-x p-6 pb-8">
|
||||||
defaultValue={
|
<div className="flex flex-col">
|
||||||
sessionTimeout
|
<p className="text-default mb-2 font-medium">{t("session_timeout_after")}</p>
|
||||||
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
|
<Select
|
||||||
: timeoutOptions[1]
|
options={timeoutOptions}
|
||||||
}
|
defaultValue={
|
||||||
isSearchable={false}
|
sessionTimeout
|
||||||
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
|
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
|
||||||
onChange={(event) => {
|
: timeoutOptions[1]
|
||||||
formMethods.setValue("sessionTimeout", event?.value, { shouldDirty: true });
|
}
|
||||||
}}
|
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>
|
||||||
</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>
|
</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>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PasswordView.getLayout = getLayout;
|
const PasswordViewWrapper = () => {
|
||||||
PasswordView.PageWrapper = PageWrapper;
|
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 { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
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 PageWrapper from "@components/PageWrapper";
|
||||||
import DisableTwoFactorModal from "@components/settings/DisableTwoFactorModal";
|
import DisableTwoFactorModal from "@components/settings/DisableTwoFactorModal";
|
||||||
import EnableTwoFactorModal from "@components/settings/EnableTwoFactorModal";
|
import EnableTwoFactorModal from "@components/settings/EnableTwoFactorModal";
|
||||||
|
|
||||||
const SkeletonLoader = () => {
|
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
<div className="mb-8 mt-6 space-y-6">
|
<div className="mb-8 mt-6 space-y-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||||
@@ -28,36 +37,34 @@ const TwoFactorAuthView = () => {
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||||
|
|
||||||
const [enableModalOpen, setEnableModalOpen] = useState(false);
|
const [enableModalOpen, setEnableModalOpen] = useState<boolean>(false);
|
||||||
const [disableModalOpen, setDisableModalOpen] = useState(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 isCalProvider = user?.identityProvider === "CAL";
|
||||||
const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled;
|
const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled;
|
||||||
return (
|
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")} />}
|
{canSetupTwoFactor && <Alert severity="neutral" message={t("2fa_disabled")} />}
|
||||||
<div className="mt-6 flex items-start space-x-4">
|
<SettingsToggle
|
||||||
<Switch
|
toggleSwitchAtTheEnd={true}
|
||||||
data-testid="two-factor-switch"
|
data-testid="two-factor-switch"
|
||||||
disabled={canSetupTwoFactor}
|
title={t("two_factor_auth")}
|
||||||
checked={user?.twoFactorEnabled}
|
description={t("add_an_extra_layer_of_security")}
|
||||||
onCheckedChange={() =>
|
checked={user?.twoFactorEnabled ?? false}
|
||||||
user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true)
|
onCheckedChange={() =>
|
||||||
}
|
user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true)
|
||||||
/>
|
}
|
||||||
<div className="!mx-4">
|
Badge={
|
||||||
<div className="flex">
|
<Badge className="mx-2 text-xs" variant={user?.twoFactorEnabled ? "success" : "gray"}>
|
||||||
<p className="text-default font-semibold">{t("two_factor_auth")}</p>
|
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
|
||||||
<Badge className="mx-2 text-xs" variant={user?.twoFactorEnabled ? "success" : "gray"}>
|
</Badge>
|
||||||
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
|
}
|
||||||
</Badge>
|
switchContainerClassName="border-subtle rounded-b-xl border border-t-0 px-5 py-6 sm:px-6"
|
||||||
</div>
|
/>
|
||||||
<p className="text-default text-sm">{t("add_an_extra_layer_of_security")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EnableTwoFactorModal
|
<EnableTwoFactorModal
|
||||||
open={enableModalOpen}
|
open={enableModalOpen}
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
|||||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
|
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
|
||||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||||
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import prisma from "@calcom/prisma";
|
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
|
// 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 { 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">
|
<ul className="border-subtle rounded-md border">
|
||||||
{team.eventTypes.map((type, index) => (
|
{eventTypes.map((type, index) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
key={index}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -125,7 +126,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
|||||||
<div className="flex items-center px-5 py-5">
|
<div className="flex items-center px-5 py-5">
|
||||||
<Avatar
|
<Avatar
|
||||||
size="md"
|
size="md"
|
||||||
imageSrc={getPlaceholderAvatar(ch?.logo, ch?.name as string)}
|
imageSrc={`/team/${ch.slug}/avatar.png`}
|
||||||
alt="Team Logo"
|
alt="Team Logo"
|
||||||
className="inline-flex justify-center"
|
className="inline-flex justify-center"
|
||||||
/>
|
/>
|
||||||
@@ -134,7 +135,6 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
|||||||
<span className="text-subtle block text-xs">
|
<span className="text-subtle block text-xs">
|
||||||
{t("number_member", {
|
{t("number_member", {
|
||||||
count: memberCount,
|
count: memberCount,
|
||||||
defaultValue: `${memberCount} member${memberCount > 1 ? "s" : ""}`,
|
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +174,10 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
|||||||
description={teamName}
|
description={teamName}
|
||||||
meeting={{
|
meeting={{
|
||||||
title: markdownStrippedBio,
|
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">
|
<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">
|
<div className="relative">
|
||||||
<Avatar
|
<Avatar
|
||||||
alt={teamName}
|
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"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,17 +208,19 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
|||||||
<SubTeams />
|
<SubTeams />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{(showMembers.isOn || !team.eventTypes.length) &&
|
{(showMembers.isOn || !team.eventTypes?.length) &&
|
||||||
(team.isPrivate ? (
|
(team.isPrivate ? (
|
||||||
<div className="w-full text-center">
|
<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>
|
</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 ">
|
<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" */}
|
{/* Hide "Book a team member button when team is private or hideBookATeamMember is true" */}
|
||||||
{!team.hideBookATeamMember && !team.isPrivate && (
|
{!team.hideBookATeamMember && !team.isPrivate && (
|
||||||
@@ -233,6 +240,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
|||||||
<Button
|
<Button
|
||||||
color="minimal"
|
color="minimal"
|
||||||
EndIcon={ArrowRight}
|
EndIcon={ArrowRight}
|
||||||
|
data-testid="book-a-team-member-btn"
|
||||||
className="dark:hover:bg-darkgray-200"
|
className="dark:hover:bg-darkgray-200"
|
||||||
href={{
|
href={{
|
||||||
pathname: `${isValidOrgDomain ? "" : "/team"}/${team.slug}`,
|
pathname: `${isValidOrgDomain ? "" : "/team"}/${team.slug}`,
|
||||||
@@ -263,10 +271,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
context.params?.orgSlug
|
context.params?.orgSlug
|
||||||
);
|
);
|
||||||
const flags = await getFeatureFlagMap(prisma);
|
const flags = await getFeatureFlagMap(prisma);
|
||||||
const team = await getTeamWithMembers({ slug, orgSlug: currentOrgDomain });
|
const team = await getTeamWithMembers({
|
||||||
const ssr = await ssrInit(context, {
|
slug: slugify(slug ?? ""),
|
||||||
noI18nPreload: isValidOrgDomain && !team?.parent,
|
orgSlug: currentOrgDomain,
|
||||||
|
isTeamView: true,
|
||||||
|
isOrgView: isValidOrgDomain && context.resolvedUrl === "/",
|
||||||
});
|
});
|
||||||
|
const ssr = await ssrInit(context);
|
||||||
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
|
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
|
||||||
console.info("gSSP, team/[slug] - ", {
|
console.info("gSSP, team/[slug] - ", {
|
||||||
isValidOrgDomain,
|
isValidOrgDomain,
|
||||||
@@ -308,28 +319,31 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
team.eventTypes = team.eventTypes.map((type) => ({
|
team.eventTypes =
|
||||||
...type,
|
team.eventTypes?.map((type) => ({
|
||||||
users: type.users.map((user) => ({
|
...type,
|
||||||
...user,
|
users: type.users.map((user) => ({
|
||||||
avatar: "/" + user.username + "/avatar.png",
|
...user,
|
||||||
})),
|
avatar: "/" + user.username + "/avatar.png",
|
||||||
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
|
})),
|
||||||
}));
|
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
|
||||||
|
})) ?? null;
|
||||||
|
|
||||||
const safeBio = markdownToSafeHTML(team.bio) || "";
|
const safeBio = markdownToSafeHTML(team.bio) || "";
|
||||||
|
|
||||||
const members = team.members.map((member) => {
|
const members = !team.isPrivate
|
||||||
return {
|
? team.members.map((member) => {
|
||||||
name: member.name,
|
return {
|
||||||
id: member.id,
|
name: member.name,
|
||||||
bio: member.bio,
|
id: member.id,
|
||||||
subteams: member.subteams,
|
bio: member.bio,
|
||||||
username: member.username,
|
subteams: member.subteams,
|
||||||
accepted: member.accepted,
|
username: member.username,
|
||||||
safeBio: markdownToSafeHTML(member.bio || ""),
|
accepted: member.accepted,
|
||||||
};
|
safeBio: markdownToSafeHTML(member.bio || ""),
|
||||||
});
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
const markdownStrippedBio = stripMarkdown(team?.bio || "");
|
const markdownStrippedBio = stripMarkdown(team?.bio || "");
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
test("logged out", async ({ page, users }) => {
|
||||||
await page.goto("/pro/30min");
|
await page.goto("/pro/30min");
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ test.describe("Change username on settings", () => {
|
|||||||
await users.deleteAll();
|
await users.deleteAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** TODO: Find out why it's timing out */
|
test("User can change username", async ({ page, users, prisma }) => {
|
||||||
test.fixme("User can change username", async ({ page, users, prisma }) => {
|
|
||||||
const user = await users.create();
|
const user = await users.create();
|
||||||
|
|
||||||
await user.apiLogin();
|
await user.apiLogin();
|
||||||
@@ -36,7 +35,7 @@ test.describe("Change username on settings", () => {
|
|||||||
await page.click("[data-testid=update-username-btn]");
|
await page.click("[data-testid=update-username-btn]");
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.click("[data-testid=save-username]"),
|
page.click("[data-testid=save-username]"),
|
||||||
page.waitForResponse("**/viewer.updateProfile*"),
|
page.getByTestId("toast-success").waitFor(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const newUpdatedUser = await prisma.user.findUniqueOrThrow({
|
const newUpdatedUser = await prisma.user.findUniqueOrThrow({
|
||||||
|
|||||||
@@ -556,6 +556,8 @@ export async function apiLogin(
|
|||||||
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
|
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
|
||||||
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
|
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
|
||||||
await page.locator("div > .ml-auto").first().click();
|
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.getByPlaceholder("Price").fill("100");
|
||||||
await page.getByTestId("update-eventtype").click();
|
await page.getByTestId("update-eventtype").click();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import type Prisma from "@prisma/client";
|
import type Prisma from "@prisma/client";
|
||||||
|
|
||||||
import type { Fixtures } from "./lib/fixtures";
|
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
|
import type { Fixtures } from "./lib/fixtures";
|
||||||
import { todo, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
import { todo, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||||
|
|
||||||
test.describe.configure({ mode: "parallel" });
|
test.describe.configure({ mode: "parallel" });
|
||||||
@@ -143,7 +143,66 @@ test.describe("Stripe integration", () => {
|
|||||||
expect(await paidBadge.innerText()).toBe("Paid");
|
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("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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -176,6 +176,38 @@ test.describe("Teams", () => {
|
|||||||
await prisma.team.delete({ where: { id: team?.id } });
|
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("Create a Round Robin with different leastRecentlyBooked hosts");
|
||||||
todo("Reschedule a Collective EventType booking");
|
todo("Reschedule a Collective EventType booking");
|
||||||
|
|||||||
@@ -826,7 +826,7 @@
|
|||||||
"confirm_delete_event_type": "نعم، قم بالحذف",
|
"confirm_delete_event_type": "نعم، قم بالحذف",
|
||||||
"delete_account": "حذف الحساب",
|
"delete_account": "حذف الحساب",
|
||||||
"confirm_delete_account": "نعم، احذف الحساب",
|
"confirm_delete_account": "نعم، احذف الحساب",
|
||||||
"delete_account_confirmation_message": "هل تريد بالتأكيد حذف حسابك على {{appName}}؟ أي شخص شاركت رابط حسابك معه لن يستطيع بعد الآن الحجز باستخدامه، وستفقد أي تفضيلات حفظتها.",
|
"delete_account_confirmation_message": "أي شخص شاركت رابط حسابك معه لن يستطيع بعد الآن الحجز باستخدامه، وستفقد أي تفضيلات حفظتها.",
|
||||||
"integrations": "التكاملات",
|
"integrations": "التكاملات",
|
||||||
"apps": "التطبيقات",
|
"apps": "التطبيقات",
|
||||||
"apps_description": "هنا يمكنك العثور على قائمة بتطبيقاتك",
|
"apps_description": "هنا يمكنك العثور على قائمة بتطبيقاتك",
|
||||||
@@ -2002,11 +2002,6 @@
|
|||||||
"requires_booker_email_verification": "يتطلب ذلك تأكيد البريد الإلكتروني لمن يقوم بالحجز",
|
"requires_booker_email_verification": "يتطلب ذلك تأكيد البريد الإلكتروني لمن يقوم بالحجز",
|
||||||
"description_requires_booker_email_verification": "لضمان تأكيد البريد الإلكتروني لمن يقوم بالحجز قبل جدولة الفعاليات",
|
"description_requires_booker_email_verification": "لضمان تأكيد البريد الإلكتروني لمن يقوم بالحجز قبل جدولة الفعاليات",
|
||||||
"requires_confirmation_mandatory": "يمكن إرسال رسائل نصية فقط للحاضرين عندما يتطلب نوع الفعالية تأكيداً.",
|
"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": "المؤسسات",
|
"organizations": "المؤسسات",
|
||||||
"org_admin_other_teams": "فرق أخرى",
|
"org_admin_other_teams": "فرق أخرى",
|
||||||
"org_admin_other_teams_description": "يمكنك هنا رؤية الفرق ضمن مؤسستك والتي أنت لست جزءاً منها؛ ويمكنك إضافة نفسك إليها عند الحاجة.",
|
"org_admin_other_teams_description": "يمكنك هنا رؤية الفرق ضمن مؤسستك والتي أنت لست جزءاً منها؛ ويمكنك إضافة نفسك إليها عند الحاجة.",
|
||||||
|
|||||||
@@ -826,7 +826,7 @@
|
|||||||
"confirm_delete_event_type": "Ano, odstranit",
|
"confirm_delete_event_type": "Ano, odstranit",
|
||||||
"delete_account": "Smazat účet",
|
"delete_account": "Smazat účet",
|
||||||
"confirm_delete_account": "Ano, 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",
|
"integrations": "Integrace",
|
||||||
"apps": "Aplikace",
|
"apps": "Aplikace",
|
||||||
"apps_description": "Tady najdete seznam svých aplikací",
|
"apps_description": "Tady najdete seznam svých aplikací",
|
||||||
@@ -2002,11 +2002,6 @@
|
|||||||
"requires_booker_email_verification": "Povinné ověření e-mailu rezervujícího",
|
"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í",
|
"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í.",
|
"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",
|
"organizations": "Organizace",
|
||||||
"org_admin_other_teams": "Ostatní týmy",
|
"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.",
|
"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)",
|
"slot_interval_default": "Brug begivenhedslængde (standard)",
|
||||||
"delete_account": "Slet konto",
|
"delete_account": "Slet konto",
|
||||||
"confirm_delete_account": "Ja, 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",
|
"integrations": "Integrationer",
|
||||||
"apps": "Apps",
|
"apps": "Apps",
|
||||||
"apps_description": "Her kan du finde en liste over dine apps",
|
"apps_description": "Her kan du finde en liste over dine apps",
|
||||||
|
|||||||
@@ -826,7 +826,7 @@
|
|||||||
"confirm_delete_event_type": "Ja, löschen",
|
"confirm_delete_event_type": "Ja, löschen",
|
||||||
"delete_account": "Account löschen",
|
"delete_account": "Account löschen",
|
||||||
"confirm_delete_account": "Ja, 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",
|
"integrations": "Integrationen",
|
||||||
"apps": "Apps",
|
"apps": "Apps",
|
||||||
"apps_description": "Hier finden Sie eine Liste Ihrer Apps",
|
"apps_description": "Hier finden Sie eine Liste Ihrer Apps",
|
||||||
@@ -2002,10 +2002,6 @@
|
|||||||
"requires_booker_email_verification": "Erfordert E-Mail-Verifizierung des Buchenden",
|
"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",
|
"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.",
|
"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",
|
"organizations": "Organisationen",
|
||||||
"org_admin_other_teams": "Weitere Teams",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -255,7 +255,7 @@
|
|||||||
"yours": "Your account",
|
"yours": "Your account",
|
||||||
"available_apps": "Available Apps",
|
"available_apps": "Available Apps",
|
||||||
"available_apps_lower_case": "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>",
|
"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.",
|
"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.",
|
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
||||||
@@ -288,6 +288,7 @@
|
|||||||
"when": "When",
|
"when": "When",
|
||||||
"where": "Where",
|
"where": "Where",
|
||||||
"add_to_calendar": "Add to calendar",
|
"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",
|
"add_another_calendar": "Add another calendar",
|
||||||
"other": "Other",
|
"other": "Other",
|
||||||
"email_sign_in_subject": "Your sign-in link for {{appName}}",
|
"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": "Hide Book a Team Member Button",
|
||||||
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
|
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
|
||||||
"danger_zone": "Danger zone",
|
"danger_zone": "Danger zone",
|
||||||
|
"account_deletion_cannot_be_undone":"Careful. Account deletion cannot be undone.",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"cancel_all_remaining": "Cancel all remaining",
|
"cancel_all_remaining": "Cancel all remaining",
|
||||||
@@ -688,6 +690,7 @@
|
|||||||
"people": "People",
|
"people": "People",
|
||||||
"your_email": "Your Email",
|
"your_email": "Your Email",
|
||||||
"change_avatar": "Change Avatar",
|
"change_avatar": "Change Avatar",
|
||||||
|
"upload_avatar": "Upload Avatar",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
"first_day_of_week": "First Day of Week",
|
"first_day_of_week": "First Day of Week",
|
||||||
@@ -826,7 +829,7 @@
|
|||||||
"confirm_delete_event_type": "Yes, delete",
|
"confirm_delete_event_type": "Yes, delete",
|
||||||
"delete_account": "Delete account",
|
"delete_account": "Delete account",
|
||||||
"confirm_delete_account": "Yes, 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",
|
"integrations": "Integrations",
|
||||||
"apps": "Apps",
|
"apps": "Apps",
|
||||||
"apps_description": "Here you can find a list of your apps",
|
"apps_description": "Here you can find a list of your apps",
|
||||||
@@ -1276,6 +1279,7 @@
|
|||||||
"personal_cal_url": "My personal {{appName}} URL",
|
"personal_cal_url": "My personal {{appName}} URL",
|
||||||
"bio_hint": "A few sentences about yourself. this will appear on your personal url page.",
|
"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.",
|
"user_has_no_bio": "This user has not added a bio yet.",
|
||||||
|
"bio":"Bio",
|
||||||
"delete_account_modal_title": "Delete Account",
|
"delete_account_modal_title": "Delete Account",
|
||||||
"confirm_delete_account_modal": "Are you sure you want to delete your {{appName}} account?",
|
"confirm_delete_account_modal": "Are you sure you want to delete your {{appName}} account?",
|
||||||
"delete_my_account": "Delete my account",
|
"delete_my_account": "Delete my account",
|
||||||
@@ -1292,7 +1296,7 @@
|
|||||||
"customize_your_brand_colors": "Customize your own brand colour into your booking page.",
|
"customize_your_brand_colors": "Customize your own brand colour into your booking page.",
|
||||||
"pro": "Pro",
|
"pro": "Pro",
|
||||||
"removes_cal_branding": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}.'",
|
"removes_cal_branding": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}.'",
|
||||||
"profile_picture": "Profile picture",
|
"profile_picture": "Profile Picture",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"add_profile_photo": "Add profile photo",
|
"add_profile_photo": "Add profile photo",
|
||||||
"web3": "Web3",
|
"web3": "Web3",
|
||||||
@@ -1639,6 +1643,7 @@
|
|||||||
"minimum_round_robin_hosts_count": "Number of hosts required to attend",
|
"minimum_round_robin_hosts_count": "Number of hosts required to attend",
|
||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
"upgrade_to_enable_feature": "You need to create a team to enable this feature. Click to create a team.",
|
"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",
|
"new_attendee": "New Attendee",
|
||||||
"awaiting_approval": "Awaiting Approval",
|
"awaiting_approval": "Awaiting Approval",
|
||||||
"requires_google_calendar": "This app requires a Google Calendar connection",
|
"requires_google_calendar": "This app requires a Google Calendar connection",
|
||||||
@@ -1878,6 +1883,7 @@
|
|||||||
"edit_invite_link": "Edit link settings",
|
"edit_invite_link": "Edit link settings",
|
||||||
"invite_link_copied": "Invite link copied",
|
"invite_link_copied": "Invite link copied",
|
||||||
"invite_link_deleted": "Invite link deleted",
|
"invite_link_deleted": "Invite link deleted",
|
||||||
|
"api_key_deleted":"API Key deleted",
|
||||||
"invite_link_updated": "Invite link settings saved",
|
"invite_link_updated": "Invite link settings saved",
|
||||||
"link_expires_after": "Links set to expire after...",
|
"link_expires_after": "Links set to expire after...",
|
||||||
"one_day": "1 day",
|
"one_day": "1 day",
|
||||||
@@ -2003,11 +2009,6 @@
|
|||||||
"requires_booker_email_verification": "Requires booker email verification",
|
"requires_booker_email_verification": "Requires booker email verification",
|
||||||
"description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events",
|
"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.",
|
"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",
|
"organizations": "Organizations",
|
||||||
"org_admin_other_teams": "Other teams",
|
"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.",
|
"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}}",
|
"edit_users_availability":"Edit user's availability: {{username}}",
|
||||||
"resend_invitation": "Resend invitation",
|
"resend_invitation": "Resend invitation",
|
||||||
"invitation_resent": "The invitation was resent.",
|
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"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",
|
"confirm_delete_event_type": "Sí, eliminar",
|
||||||
"delete_account": "Eliminar cuenta",
|
"delete_account": "Eliminar cuenta",
|
||||||
"confirm_delete_account": "Sí, 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",
|
"integrations": "Integraciones",
|
||||||
"apps": "Aplicaciones",
|
"apps": "Aplicaciones",
|
||||||
"apps_description": "Aquí puede encontrar una lista de sus 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",
|
"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",
|
"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.",
|
"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",
|
"organizations": "Organizaciones",
|
||||||
"org_admin_other_teams": "Otros equipos",
|
"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.",
|
"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",
|
"confirm_delete_event_type": "Oui, supprimer",
|
||||||
"delete_account": "Supprimer le compte",
|
"delete_account": "Supprimer le compte",
|
||||||
"confirm_delete_account": "Oui, 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",
|
"integrations": "Intégrations",
|
||||||
"apps": "Applications",
|
"apps": "Applications",
|
||||||
"apps_description": "Vous trouverez ici une liste de vos 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",
|
"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.",
|
"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.",
|
"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",
|
"organizations": "Organisations",
|
||||||
"org_admin_other_teams": "Autres équipes",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -17,9 +17,15 @@
|
|||||||
"verify_email_banner_body": "יש לאמת את כתובת הדוא״ל כדי להבטיח שתוכל/י לקבל הודעות לכתובת הדוא״ל וללוח השנה בצורה הטובה ביותר",
|
"verify_email_banner_body": "יש לאמת את כתובת הדוא״ל כדי להבטיח שתוכל/י לקבל הודעות לכתובת הדוא״ל וללוח השנה בצורה הטובה ביותר",
|
||||||
"verify_email_email_header": "יש לאמת את כתובת הדוא״ל שלך",
|
"verify_email_email_header": "יש לאמת את כתובת הדוא״ל שלך",
|
||||||
"verify_email_email_button": "אימות כתובת דוא״ל",
|
"verify_email_email_button": "אימות כתובת דוא״ל",
|
||||||
|
"copy_somewhere_safe": "חשוב לשמור את מפתח ה-API הזה במקום בטוח. לא תהיה אפשרות לראות אותו שוב.",
|
||||||
"verify_email_email_body": "נא לאמת את כתובת הדוא״ל שלך על ידי לחיצה על הכפתור להלן.",
|
"verify_email_email_body": "נא לאמת את כתובת הדוא״ל שלך על ידי לחיצה על הכפתור להלן.",
|
||||||
|
"verify_email_by_code_email_body": "נא לאמת את כתובת הדוא״ל באמצעות הקוד שמתחת.",
|
||||||
"verify_email_email_link_text": "הנה הקישור, למקרה שאינך אוהב/ת ללחוץ על כפתורים:",
|
"verify_email_email_link_text": "הנה הקישור, למקרה שאינך אוהב/ת ללחוץ על כפתורים:",
|
||||||
|
"email_verification_code": "הזנת קוד האימות",
|
||||||
|
"email_verification_code_placeholder": "יש להזין את קוד האימות שנשלח אליך בדוא\"ל",
|
||||||
|
"incorrect_email_verification_code": "קוד האימות שגוי.",
|
||||||
"email_sent": "הודעת הדוא״ל נשלחה בהצלחה",
|
"email_sent": "הודעת הדוא״ל נשלחה בהצלחה",
|
||||||
|
"email_not_sent": "אירעה שגיאה בעת שליחת הדוא\"ל",
|
||||||
"event_declined_subject": "נדחה: {{title}} ב- {{date}}",
|
"event_declined_subject": "נדחה: {{title}} ב- {{date}}",
|
||||||
"event_cancelled_subject": "בוטל: {{title}} ב-{{date}}",
|
"event_cancelled_subject": "בוטל: {{title}} ב-{{date}}",
|
||||||
"event_request_declined": "בקשת האירוע שלך נדחתה",
|
"event_request_declined": "בקשת האירוע שלך נדחתה",
|
||||||
@@ -78,6 +84,7 @@
|
|||||||
"event_awaiting_approval_recurring": "אירוע חוזר ממתין לאישורך",
|
"event_awaiting_approval_recurring": "אירוע חוזר ממתין לאישורך",
|
||||||
"someone_requested_an_event": "מישהו ביקש לקבוע מועד לאירוע בלוח השנה שלך.",
|
"someone_requested_an_event": "מישהו ביקש לקבוע מועד לאירוע בלוח השנה שלך.",
|
||||||
"someone_requested_password_reset": "מישהו ביקש קישור כדי לשנות את הסיסמה שלך.",
|
"someone_requested_password_reset": "מישהו ביקש קישור כדי לשנות את הסיסמה שלך.",
|
||||||
|
"password_reset_email_sent": "אם כתובת הדוא\"ל הזו קיימת במערכת שלנו, אמור להישלח אליך דוא\"ל לאיפוס.",
|
||||||
"password_reset_instructions": "אם הבקשה לא היתה שלך, ניתן להתעלם מהודעת הדוא\"ל הזו בבטחה, והסיסמה שלך לא תשתנה.",
|
"password_reset_instructions": "אם הבקשה לא היתה שלך, ניתן להתעלם מהודעת הדוא\"ל הזו בבטחה, והסיסמה שלך לא תשתנה.",
|
||||||
"event_awaiting_approval_subject": "ממתין לאישור: {{title}} ב- {{date}}",
|
"event_awaiting_approval_subject": "ממתין לאישור: {{title}} ב- {{date}}",
|
||||||
"event_still_awaiting_approval": "אירוע עדיין ממתין לאישורך",
|
"event_still_awaiting_approval": "אירוע עדיין ממתין לאישורך",
|
||||||
@@ -217,6 +224,10 @@
|
|||||||
"already_have_an_account": "כבר יש לך חשבון?",
|
"already_have_an_account": "כבר יש לך חשבון?",
|
||||||
"create_account": "צור חשבון",
|
"create_account": "צור חשבון",
|
||||||
"confirm_password": "אישור סיסמה",
|
"confirm_password": "אישור סיסמה",
|
||||||
|
"confirm_auth_change": "זה ישנה את דרך ההתחברות שלך",
|
||||||
|
"confirm_auth_email_change": "שינוי כתובת הדוא\"ל ינתק את שיטת האימות הנוכחית שמשמשת אותך לכניסה אל Cal.com. נבקש ממך לאמת את כתובת הדוא\"ל החדשה שלך. מעכשיו, המערכת תנתק אותך מהחשבון ויהיה עליך להשתמש בכתובת הדוא\"ל החדשה שלך כדי להתחבר, במקום בשיטת האימות הנוכחית שלך לאחר שתגדיר/י סיסמה לפי ההוראות שיישלחו אליך בדוא\"ל.",
|
||||||
|
"reset_your_password": "הגדר/י את הסיסמה החדשה שלך לפי ההוראות שנשלחו אל כתובת הדוא\"ל שלך.",
|
||||||
|
"email_change": "התחבר/י שוב עם כתובת הדוא\"ל החדשה והסיסמה.",
|
||||||
"create_your_account": "צור את החשבון שלך",
|
"create_your_account": "צור את החשבון שלך",
|
||||||
"sign_up": "הרשמה",
|
"sign_up": "הרשמה",
|
||||||
"youve_been_logged_out": "יצאת מהמערכת",
|
"youve_been_logged_out": "יצאת מהמערכת",
|
||||||
@@ -243,6 +254,10 @@
|
|||||||
"all": "כל האפליקציות",
|
"all": "כל האפליקציות",
|
||||||
"yours": "החשבון שלך",
|
"yours": "החשבון שלך",
|
||||||
"available_apps": "אפליקציות זמינות",
|
"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": "בדוק/י בתיבת הדוא\"ל – שלחנו לך קישור לאיפוס הסיסמה.",
|
"check_email_reset_password": "בדוק/י בתיבת הדוא\"ל – שלחנו לך קישור לאיפוס הסיסמה.",
|
||||||
"finish": "סיום",
|
"finish": "סיום",
|
||||||
"organization_general_description": "ניהול הגדרות השפה ואזור הזמן של הצוות",
|
"organization_general_description": "ניהול הגדרות השפה ואזור הזמן של הצוות",
|
||||||
@@ -251,6 +266,7 @@
|
|||||||
"nearly_there_instructions": "דבר אחרון: תיאור קצר אודותיך/ייך בתוספת תמונה עוזרים מאוד להשיג הזמנות ומאפשרים לאנשים לדעת עם מי הם עומדים להיפגש.",
|
"nearly_there_instructions": "דבר אחרון: תיאור קצר אודותיך/ייך בתוספת תמונה עוזרים מאוד להשיג הזמנות ומאפשרים לאנשים לדעת עם מי הם עומדים להיפגש.",
|
||||||
"set_availability_instructions": "הגדר טווחי זמן שבהם אתה זמין באופן קבוע. ניתן יהיה ליצור טווחי זמן נוספים מאוחר יותר ולהקצות אותם ללוחות שנה אחרים.",
|
"set_availability_instructions": "הגדר טווחי זמן שבהם אתה זמין באופן קבוע. ניתן יהיה ליצור טווחי זמן נוספים מאוחר יותר ולהקצות אותם ללוחות שנה אחרים.",
|
||||||
"set_availability": "ציין את הזמינות שלך",
|
"set_availability": "ציין את הזמינות שלך",
|
||||||
|
"availability_settings": "הגדרות זמינוּת",
|
||||||
"continue_without_calendar": "להמשיך בלי לוח שנה",
|
"continue_without_calendar": "להמשיך בלי לוח שנה",
|
||||||
"connect_your_calendar": "קשר את לוח השנה שלך",
|
"connect_your_calendar": "קשר את לוח השנה שלך",
|
||||||
"connect_your_video_app": "חיבור אפליקציות הווידאו שלך",
|
"connect_your_video_app": "חיבור אפליקציות הווידאו שלך",
|
||||||
@@ -392,6 +408,8 @@
|
|||||||
"allow_dynamic_booking_tooltip": "קישורי הזמנות קבוצתיות שניתן ליצור באופן דינמי על ידי הוספת שמות משתמשים מרובים עם '+', למשל: '{{appName}}/bailey+peer'",
|
"allow_dynamic_booking_tooltip": "קישורי הזמנות קבוצתיות שניתן ליצור באופן דינמי על ידי הוספת שמות משתמשים מרובים עם '+', למשל: '{{appName}}/bailey+peer'",
|
||||||
"allow_dynamic_booking": "לאפשר למשתתפים לשריין איתך פגישה באמצעות הזמנות קבוצתיות דינמיות",
|
"allow_dynamic_booking": "לאפשר למשתתפים לשריין איתך פגישה באמצעות הזמנות קבוצתיות דינמיות",
|
||||||
"dynamic_booking": "קישורים לקבוצות דינמיות",
|
"dynamic_booking": "קישורים לקבוצות דינמיות",
|
||||||
|
"allow_seo_indexing": "מתן אפשרות למנועי חיפוש לגשת לתוכן הציבורי שלך",
|
||||||
|
"seo_indexing": "מתן אפשרות להוספה לאינדקסים של SEO",
|
||||||
"email": "דוא\"ל",
|
"email": "דוא\"ל",
|
||||||
"email_placeholder": "iisraeli@example.com",
|
"email_placeholder": "iisraeli@example.com",
|
||||||
"full_name": "שם מלא",
|
"full_name": "שם מלא",
|
||||||
@@ -406,6 +424,7 @@
|
|||||||
"booking_requested": "התקבלה בקשת הזמנה",
|
"booking_requested": "התקבלה בקשת הזמנה",
|
||||||
"meeting_ended": "הפגישה הסתיימה",
|
"meeting_ended": "הפגישה הסתיימה",
|
||||||
"form_submitted": "הטופס נשלח",
|
"form_submitted": "הטופס נשלח",
|
||||||
|
"booking_paid": "בוצע תשלום עבור ההזמנה",
|
||||||
"event_triggers": "גורמים מפעילים של אירועים",
|
"event_triggers": "גורמים מפעילים של אירועים",
|
||||||
"subscriber_url": "כתובת URL של המנוי/ה",
|
"subscriber_url": "כתובת URL של המנוי/ה",
|
||||||
"create_new_webhook": "יצירת Webhook חדש",
|
"create_new_webhook": "יצירת Webhook חדש",
|
||||||
@@ -506,6 +525,7 @@
|
|||||||
"your_name": "שמך",
|
"your_name": "שמך",
|
||||||
"your_full_name": "שמך המלא",
|
"your_full_name": "שמך המלא",
|
||||||
"no_name": "ללא שם",
|
"no_name": "ללא שם",
|
||||||
|
"enter_number_between_range": "נא להזין מספר בין 1 ל-{{maxOccurences}}",
|
||||||
"email_address": "כתובת דוא\"ל",
|
"email_address": "כתובת דוא\"ל",
|
||||||
"enter_valid_email": "יש להזין כתובת דוא״ל תקינה",
|
"enter_valid_email": "יש להזין כתובת דוא״ל תקינה",
|
||||||
"location": "מיקום",
|
"location": "מיקום",
|
||||||
@@ -543,6 +563,7 @@
|
|||||||
"leave": "יציאה",
|
"leave": "יציאה",
|
||||||
"profile": "פרופיל",
|
"profile": "פרופיל",
|
||||||
"my_team_url": "כתובת ה-URL של הצוות שלי",
|
"my_team_url": "כתובת ה-URL של הצוות שלי",
|
||||||
|
"my_teams": "הצוותים שלי",
|
||||||
"team_name": "שם הצוות",
|
"team_name": "שם הצוות",
|
||||||
"your_team_name": "שם הצוות שלך",
|
"your_team_name": "שם הצוות שלך",
|
||||||
"team_updated_successfully": "עדכון הצוות בוצע בהצלחה",
|
"team_updated_successfully": "עדכון הצוות בוצע בהצלחה",
|
||||||
@@ -570,6 +591,8 @@
|
|||||||
"invite_new_member": "להזמין חבר/ת צוות חדש/ה",
|
"invite_new_member": "להזמין חבר/ת צוות חדש/ה",
|
||||||
"invite_new_member_description": "הערה: פעולה זו <1>תחייב את המינוי שלך במקום נוסף ($15 לחודש)</1>.",
|
"invite_new_member_description": "הערה: פעולה זו <1>תחייב את המינוי שלך במקום נוסף ($15 לחודש)</1>.",
|
||||||
"invite_new_team_member": "להזמין מישהו לצוות שלך.",
|
"invite_new_team_member": "להזמין מישהו לצוות שלך.",
|
||||||
|
"upload_csv_file": "העלאת קובץ .csv",
|
||||||
|
"invite_via_email": "הזמנה בדוא\"ל",
|
||||||
"change_member_role": "לשנות את תפקיד החבר/ה בצוות",
|
"change_member_role": "לשנות את תפקיד החבר/ה בצוות",
|
||||||
"disable_cal_branding": "הסר מיתוג {{appName}}",
|
"disable_cal_branding": "הסר מיתוג {{appName}}",
|
||||||
"disable_cal_branding_description": "הסתר את כל המיתוג של {{appName}} מהדפים הציבוריים שלך.",
|
"disable_cal_branding_description": "הסתר את כל המיתוג של {{appName}} מהדפים הציבוריים שלך.",
|
||||||
@@ -803,7 +826,7 @@
|
|||||||
"confirm_delete_event_type": "כן, למחוק",
|
"confirm_delete_event_type": "כן, למחוק",
|
||||||
"delete_account": "מחיקת חשבון",
|
"delete_account": "מחיקת חשבון",
|
||||||
"confirm_delete_account": "כן, למחוק את החשבון",
|
"confirm_delete_account": "כן, למחוק את החשבון",
|
||||||
"delete_account_confirmation_message": "בטוח שברצונך למחוק את חשבון {{appName}} שלך? כל מי ששיתפת איתו את הקישור לחשבון לא יוכל יותר להזמין באמצעותו, וכל ההעדפות ששמרת יאבדו.",
|
"delete_account_confirmation_message": "כל מי ששיתפת איתו את הקישור לחשבון לא יוכל יותר להזמין באמצעותו, וכל ההעדפות ששמרת יאבדו.",
|
||||||
"integrations": "שילובים",
|
"integrations": "שילובים",
|
||||||
"apps": "אפליקציות",
|
"apps": "אפליקציות",
|
||||||
"apps_description": "כאן ניתן למצוא רשימה של האפליקציות שלך",
|
"apps_description": "כאן ניתן למצוא רשימה של האפליקציות שלך",
|
||||||
@@ -842,6 +865,7 @@
|
|||||||
"team_view_user_availability_disabled": "כדי להציג את זמינות הצוות, המשתמש חייב לאשר את ההזמנה",
|
"team_view_user_availability_disabled": "כדי להציג את זמינות הצוות, המשתמש חייב לאשר את ההזמנה",
|
||||||
"set_as_away": "הגדר/י את הסטטוס שלך כ'לא נמצא/ת'",
|
"set_as_away": "הגדר/י את הסטטוס שלך כ'לא נמצא/ת'",
|
||||||
"set_as_free": "השבתת הסטטוס 'לא נמצא/ת'",
|
"set_as_free": "השבתת הסטטוס 'לא נמצא/ת'",
|
||||||
|
"toggle_away_error": "שגיאה בעת עדכון סטטוס 'לא נמצא/ת'",
|
||||||
"user_away": "משתמש זה מוגדר כעת כ'לא נמצא/ת'.",
|
"user_away": "משתמש זה מוגדר כעת כ'לא נמצא/ת'.",
|
||||||
"user_away_description": "האדם שניסית לשריין איתו חלון זמן הגדיר את עצמו כ'לא נמצא/ת', ולכן הוא אינו מקבל הזמנות חדשות.",
|
"user_away_description": "האדם שניסית לשריין איתו חלון זמן הגדיר את עצמו כ'לא נמצא/ת', ולכן הוא אינו מקבל הזמנות חדשות.",
|
||||||
"meet_people_with_the_same_tokens": "לפגוש אנשים עם אותם טוקנים",
|
"meet_people_with_the_same_tokens": "לפגוש אנשים עם אותם טוקנים",
|
||||||
@@ -851,6 +875,7 @@
|
|||||||
"account_managed_by_identity_provider_description": "כדי לשנות את כתובת הדוא\"ל ואת הסיסמה, לאפשר אימות דו-גורמי ועוד, יש לעבור אל הגדרות החשבון אצל {{provider}}.",
|
"account_managed_by_identity_provider_description": "כדי לשנות את כתובת הדוא\"ל ואת הסיסמה, לאפשר אימות דו-גורמי ועוד, יש לעבור אל הגדרות החשבון אצל {{provider}}.",
|
||||||
"signin_with_google": "כניסה עם Google",
|
"signin_with_google": "כניסה עם Google",
|
||||||
"signin_with_saml": "כניסה עם SAML",
|
"signin_with_saml": "כניסה עם SAML",
|
||||||
|
"signin_with_saml_oidc": "כניסה עם SAML/OIDC",
|
||||||
"you_will_need_to_generate": "תצטרך ליצור טוקן גישה מהכלי הישן שבו השתמשת לקביעת מועדים.",
|
"you_will_need_to_generate": "תצטרך ליצור טוקן גישה מהכלי הישן שבו השתמשת לקביעת מועדים.",
|
||||||
"import": "ייבוא",
|
"import": "ייבוא",
|
||||||
"import_from": "ייבוא מ",
|
"import_from": "ייבוא מ",
|
||||||
@@ -950,6 +975,8 @@
|
|||||||
"offer_seats_description": "הצעת מקומות להזמנות (אפשרות זו משביתה באופן אוטומטי אישורי הזמנות ואורחים).",
|
"offer_seats_description": "הצעת מקומות להזמנות (אפשרות זו משביתה באופן אוטומטי אישורי הזמנות ואורחים).",
|
||||||
"seats_available_one": "מקום זמין",
|
"seats_available_one": "מקום זמין",
|
||||||
"seats_available_other": "מקומות זמינים",
|
"seats_available_other": "מקומות זמינים",
|
||||||
|
"seats_nearly_full": "כמעט כל המקומות תפוסים",
|
||||||
|
"seats_half_full": "המקומות מתמלאים במהירות",
|
||||||
"number_of_seats": "מספר המקומות לכל הזמנה",
|
"number_of_seats": "מספר המקומות לכל הזמנה",
|
||||||
"enter_number_of_seats": "יש להזין את מספר המקומות",
|
"enter_number_of_seats": "יש להזין את מספר המקומות",
|
||||||
"you_can_manage_your_schedules": "ניתן לנהל את לוחות הזמנים בדף 'זמינות'.",
|
"you_can_manage_your_schedules": "ניתן לנהל את לוחות הזמנים בדף 'זמינות'.",
|
||||||
@@ -1030,12 +1057,15 @@
|
|||||||
"how_you_want_add_cal_site": "איך ברצונך להוסיף את {{appName}} לאתר שלך?",
|
"how_you_want_add_cal_site": "איך ברצונך להוסיף את {{appName}} לאתר שלך?",
|
||||||
"choose_ways_put_cal_site": "בחר/י אחת מהדרכים הבאות כדי לשלב את {{appName}} באתר שלך.",
|
"choose_ways_put_cal_site": "בחר/י אחת מהדרכים הבאות כדי לשלב את {{appName}} באתר שלך.",
|
||||||
"setting_up_zapier": "המערכת מגדירה את השילוב שלך עם Zapier",
|
"setting_up_zapier": "המערכת מגדירה את השילוב שלך עם Zapier",
|
||||||
|
"setting_up_make": "המערכת מגדירה את השילוב שלך עם Make",
|
||||||
"generate_api_key": "יצירת מפתח ממשק תכנות יישומים (API)",
|
"generate_api_key": "יצירת מפתח ממשק תכנות יישומים (API)",
|
||||||
"generate_api_key_description": "צור מפתח ממשק מפתחים, API, לשימוש עם {{appName}} ב-",
|
"generate_api_key_description": "צור מפתח ממשק מפתחים, API, לשימוש עם {{appName}} ב-",
|
||||||
"your_unique_api_key": "מפתח ה-API הייחודי שלך",
|
"your_unique_api_key": "מפתח ה-API הייחודי שלך",
|
||||||
"copy_safe_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>",
|
"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_zapier_app": "תחילה עליך להוריד את אפליקציית Zapier מה-App Store ולהתקין אותה.",
|
||||||
|
"install_make_app": "תחילה עליך להוריד את אפליקציית Make מה-App Store ולהתקין אותה.",
|
||||||
"connect_apple_server": "חיבור לשרת Apple",
|
"connect_apple_server": "חיבור לשרת Apple",
|
||||||
"calendar_url": "כתובת ה-URL של לוח השנה",
|
"calendar_url": "כתובת ה-URL של לוח השנה",
|
||||||
"apple_server_generate_password": "צור סיסמה ספציפית לאפליקציה שתשמש עבור {{appName}} ב-",
|
"apple_server_generate_password": "צור סיסמה ספציפית לאפליקציה שתשמש עבור {{appName}} ב-",
|
||||||
@@ -1085,6 +1115,7 @@
|
|||||||
"email_attendee_action": "שליחת דוא\"ל למשתתפים",
|
"email_attendee_action": "שליחת דוא\"ל למשתתפים",
|
||||||
"sms_attendee_action": "שליחת SMS למשתתף",
|
"sms_attendee_action": "שליחת SMS למשתתף",
|
||||||
"sms_number_action": "שלח SMS למספר מסוים",
|
"sms_number_action": "שלח SMS למספר מסוים",
|
||||||
|
"send_reminder_sms": "שלח/י למשתתפים תזכורות לפגישות ב-SMS בקלות",
|
||||||
"whatsapp_number_action": "שליחת Whatsapp למספר מסוים",
|
"whatsapp_number_action": "שליחת Whatsapp למספר מסוים",
|
||||||
"whatsapp_attendee_action": "שליחת Whatsapp למשתתף",
|
"whatsapp_attendee_action": "שליחת Whatsapp למשתתף",
|
||||||
"workflows": "תהליכים",
|
"workflows": "תהליכים",
|
||||||
@@ -1244,6 +1275,7 @@
|
|||||||
"error_updating_settings": "שגיאה בעדכון ההגדרות",
|
"error_updating_settings": "שגיאה בעדכון ההגדרות",
|
||||||
"personal_cal_url": "כתובת ה-URL האישית שלי של {{appName}}",
|
"personal_cal_url": "כתובת ה-URL האישית שלי של {{appName}}",
|
||||||
"bio_hint": "מספר משפטים אודותיך. המידע הזה יופיע בדף ה-URL האישי שלך.",
|
"bio_hint": "מספר משפטים אודותיך. המידע הזה יופיע בדף ה-URL האישי שלך.",
|
||||||
|
"user_has_no_bio": "משתמש זה עדיין לא הוסיף ביוגרפיה.",
|
||||||
"delete_account_modal_title": "מחיקת החשבון",
|
"delete_account_modal_title": "מחיקת החשבון",
|
||||||
"confirm_delete_account_modal": "בטוח שברצונך למחוק את חשבון {{appName}} שלך?",
|
"confirm_delete_account_modal": "בטוח שברצונך למחוק את חשבון {{appName}} שלך?",
|
||||||
"delete_my_account": "מחיקת החשבון שלי",
|
"delete_my_account": "מחיקת החשבון שלי",
|
||||||
@@ -1426,6 +1458,7 @@
|
|||||||
"add_limit": "הוספת הגבלה",
|
"add_limit": "הוספת הגבלה",
|
||||||
"team_name_required": "נדרש שם צוות",
|
"team_name_required": "נדרש שם צוות",
|
||||||
"show_attendees": "שיתוף האורחים בפרטי המשתתפים",
|
"show_attendees": "שיתוף האורחים בפרטי המשתתפים",
|
||||||
|
"show_available_seats_count": "הצגת מספר המקומות הפנויים",
|
||||||
"how_booking_questions_as_variables": "איך משתמשים בשאלות הזמנה בתור משתנים?",
|
"how_booking_questions_as_variables": "איך משתמשים בשאלות הזמנה בתור משתנים?",
|
||||||
"format": "פורמט",
|
"format": "פורמט",
|
||||||
"uppercase_for_letters": "שימוש באותיות רישיות עבור כל האותיות",
|
"uppercase_for_letters": "שימוש באותיות רישיות עבור כל האותיות",
|
||||||
@@ -1659,8 +1692,11 @@
|
|||||||
"delete_sso_configuration_confirmation_description": "אתה בטוח שאתה רוצה למחוק את הגדרות {{connectionType}}? חברי הצוות שלך שמשתמשים ב- {{connectionType}} להזדהות לא יוכלו להשתמש בו להכנס ל- Cal.com.",
|
"delete_sso_configuration_confirmation_description": "אתה בטוח שאתה רוצה למחוק את הגדרות {{connectionType}}? חברי הצוות שלך שמשתמשים ב- {{connectionType}} להזדהות לא יוכלו להשתמש בו להכנס ל- Cal.com.",
|
||||||
"organizer_timezone": "מארגן אזורי זמן",
|
"organizer_timezone": "מארגן אזורי זמן",
|
||||||
"email_user_cta": "צפה בהזמנה",
|
"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_no_user_invite_subheading": "{{invitedBy}} הזמין אותך להצטרף לצוות שלו ב- {{appName}}. {{appName}} הינה מתזמן זימונים שמאפשר לך ולצוות שלך לזמן פגישות בלי כל הפינג פונג במיילים.",
|
||||||
"email_user_invite_subheading_team": "{{invitedBy}} הזמין/ה אותך להצטרף לצוות שלו/ה בשם '{{teamName}}' באפליקציה {{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_invite_steps_intro": "נדריך אותך במספר קטן של צעדים ותוכל/י להתחיל ליהנות מקביעת מועדים עם ה-{{entity}} שלך במהירות ובלי בעיות.",
|
||||||
"email_no_user_step_one": "בחר שם משתמש",
|
"email_no_user_step_one": "בחר שם משתמש",
|
||||||
"email_no_user_step_two": "קשר את לוח השנה שלך",
|
"email_no_user_step_two": "קשר את לוח השנה שלך",
|
||||||
@@ -1831,6 +1867,8 @@
|
|||||||
"insights_no_data_found_for_filter": "לא נמצאו נתונים עבור המסנן שנבחר או התאריכים שנבחרו.",
|
"insights_no_data_found_for_filter": "לא נמצאו נתונים עבור המסנן שנבחר או התאריכים שנבחרו.",
|
||||||
"acknowledge_booking_no_show_fee": "מובן לי שאם לא אשתתף באירוע הזה, דמי אי-הגעה בסך {{amount, currency}} ינוכו מהכרטיס שלי.",
|
"acknowledge_booking_no_show_fee": "מובן לי שאם לא אשתתף באירוע הזה, דמי אי-הגעה בסך {{amount, currency}} ינוכו מהכרטיס שלי.",
|
||||||
"card_details": "פרטי כרטיס",
|
"card_details": "פרטי כרטיס",
|
||||||
|
"something_went_wrong_on_our_end": "משהו השתבש בצד שלנו. פנה/י למחלקת התמיכה שלנו, ואנחנו נפתור זאת מיד עבורך.",
|
||||||
|
"please_provide_following_text_to_suppport": "כשפונים לתמיכה, יש לספק את הטקסט הבא כדי שנוכל לסייע לך בצורה יעילה יותר",
|
||||||
"seats_and_no_show_fee_error": "נכון לעכשיו, אי אפשר להפעיל מקומות ולחייב דמי אי-הגעה",
|
"seats_and_no_show_fee_error": "נכון לעכשיו, אי אפשר להפעיל מקומות ולחייב דמי אי-הגעה",
|
||||||
"complete_your_booking": "יש להשלים את ההזמנה",
|
"complete_your_booking": "יש להשלים את ההזמנה",
|
||||||
"complete_your_booking_subject": "יש להשלים את ההזמנה: {{title}} ב-{{date}}",
|
"complete_your_booking_subject": "יש להשלים את ההזמנה: {{title}} ב-{{date}}",
|
||||||
@@ -1864,6 +1902,9 @@
|
|||||||
"open_dialog_with_element_click": "לפתוח תיבת דו-שיח ב-Cal כשמישהו לוחץ על רכיב.",
|
"open_dialog_with_element_click": "לפתוח תיבת דו-שיח ב-Cal כשמישהו לוחץ על רכיב.",
|
||||||
"need_help_embedding": "זקוק/ה לעזרה? ניתן לעיין במדריכים שלנו בנושא הטמעת Cal ב-Wix, ב-Squarespace או ב-WordPress, לקרוא את דף השאלות הנפוצות או לסקור אפשרויות הטמעה מתקדמות.",
|
"need_help_embedding": "זקוק/ה לעזרה? ניתן לעיין במדריכים שלנו בנושא הטמעת Cal ב-Wix, ב-Squarespace או ב-WordPress, לקרוא את דף השאלות הנפוצות או לסקור אפשרויות הטמעה מתקדמות.",
|
||||||
"book_my_cal": "הזמנה ב-Cal",
|
"book_my_cal": "הזמנה ב-Cal",
|
||||||
|
"first_name": "שם פרטי",
|
||||||
|
"last_name": "שם משפחה",
|
||||||
|
"first_last_name": "שם פרטי, שם משפחה",
|
||||||
"invite_as": "הזמנה בְתור",
|
"invite_as": "הזמנה בְתור",
|
||||||
"form_updated_successfully": "עדכון הטופס בוצע בהצלחה.",
|
"form_updated_successfully": "עדכון הטופס בוצע בהצלחה.",
|
||||||
"disable_attendees_confirmation_emails": "השבתת הודעות דוא\"ל לאישור שנשלחות כברירת מחדל עבור המשתתפים",
|
"disable_attendees_confirmation_emails": "השבתת הודעות דוא\"ל לאישור שנשלחות כברירת מחדל עבור המשתתפים",
|
||||||
@@ -1877,6 +1918,7 @@
|
|||||||
"first_event_type_webhook_description": "צור/צרי את ה-Webhook הראשון שלך עבור סוג האירוע הזה",
|
"first_event_type_webhook_description": "צור/צרי את ה-Webhook הראשון שלך עבור סוג האירוע הזה",
|
||||||
"install_app_on": "התקנת האפליקציה ב-",
|
"install_app_on": "התקנת האפליקציה ב-",
|
||||||
"create_for": "צור/צרי עבור",
|
"create_for": "צור/צרי עבור",
|
||||||
|
"currency": "מטבע",
|
||||||
"organization_banner_description": "צור/צרי סביבות שבהן הצוותים שלך יוכלו ליצור אפליקציות, תהליכי עבודה וסוגי אירועים משותפים, עם תכונות כמו סבב וקביעת מועדים שיתופית.",
|
"organization_banner_description": "צור/צרי סביבות שבהן הצוותים שלך יוכלו ליצור אפליקציות, תהליכי עבודה וסוגי אירועים משותפים, עם תכונות כמו סבב וקביעת מועדים שיתופית.",
|
||||||
"organization_banner_title": "ניהול ארגונים עם צוותים מרובים",
|
"organization_banner_title": "ניהול ארגונים עם צוותים מרובים",
|
||||||
"set_up_your_organization": "הגדרת הארגון שלך",
|
"set_up_your_organization": "הגדרת הארגון שלך",
|
||||||
@@ -1937,6 +1979,74 @@
|
|||||||
"insights_team_filter": "צוות: {{teamName}}",
|
"insights_team_filter": "צוות: {{teamName}}",
|
||||||
"insights_user_filter": "משתמש: {{userName}}",
|
"insights_user_filter": "משתמש: {{userName}}",
|
||||||
"insights_subtitle": "הצגת insights לגבי ההזמנות באירועים שלך",
|
"insights_subtitle": "הצגת insights לגבי ההזמנות באירועים שלך",
|
||||||
|
"location_options": "{{locationCount}} אפשרויות מיקום",
|
||||||
"custom_plan": "חבילה בהתאמה אישית",
|
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
"starting": "Kezdés",
|
"starting": "Kezdés",
|
||||||
"length": "Hossz",
|
"length": "Hossz",
|
||||||
"delete_account": "Fiók törlése",
|
"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",
|
"apps": "Alkalmazások",
|
||||||
"app_store": "App Store",
|
"app_store": "App Store",
|
||||||
"settings": "Beállítások",
|
"settings": "Beállítások",
|
||||||
|
|||||||
@@ -826,7 +826,7 @@
|
|||||||
"confirm_delete_event_type": "Sì, elimina",
|
"confirm_delete_event_type": "Sì, elimina",
|
||||||
"delete_account": "Elimina account",
|
"delete_account": "Elimina account",
|
||||||
"confirm_delete_account": "Sì, 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",
|
"integrations": "Integrazioni",
|
||||||
"apps": "App",
|
"apps": "App",
|
||||||
"apps_description": "Qui puoi trovare l'elenco delle tue 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",
|
"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",
|
"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.",
|
"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",
|
"organizations": "Organizzazioni",
|
||||||
"org_admin_other_teams": "Atri team",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
"event_awaiting_approval_recurring": "繰り返しイベントはあなたの承認待ちです",
|
"event_awaiting_approval_recurring": "繰り返しイベントはあなたの承認待ちです",
|
||||||
"someone_requested_an_event": "別のユーザーがあなたのカレンダーにイベントを予定するリクエストしています。",
|
"someone_requested_an_event": "別のユーザーがあなたのカレンダーにイベントを予定するリクエストしています。",
|
||||||
"someone_requested_password_reset": "別のユーザーがあなたのパスワードを変更するためのリンクをリクエストしています。",
|
"someone_requested_password_reset": "別のユーザーがあなたのパスワードを変更するためのリンクをリクエストしています。",
|
||||||
"password_reset_email_sent": "このメールがシステムに存在する場合は、リセットメールを受信するはずです。",
|
"password_reset_email_sent": "このメールがシステムに存在する場合は、リセットメールを受け取るはずです。",
|
||||||
"password_reset_instructions": "このリクエストをしていない場合は、このメールを無視しても大丈夫です。パスワードも変更されません。",
|
"password_reset_instructions": "このリクエストをしていない場合は、このメールを無視しても大丈夫です。パスワードも変更されません。",
|
||||||
"event_awaiting_approval_subject": "承認を待っています: {{date}} の {{title}}",
|
"event_awaiting_approval_subject": "承認を待っています: {{date}} の {{title}}",
|
||||||
"event_still_awaiting_approval": "イベントはまだあなたの承認待ちです",
|
"event_still_awaiting_approval": "イベントはまだあなたの承認待ちです",
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
"yours": "ご利用のアカウント",
|
"yours": "ご利用のアカウント",
|
||||||
"available_apps": "利用可能なアプリ",
|
"available_apps": "利用可能なアプリ",
|
||||||
"available_apps_lower_case": "利用できるアプリ",
|
"available_apps_lower_case": "利用できるアプリ",
|
||||||
"available_apps_desc": "インストールされているアプリがありません。以下で人気アプリを確認して、さらに <1>App Store</1> でご覧ください",
|
"available_apps_desc": "インストールされているアプリがありません。以下で人気アプリを確認してから <1>App Store</1> でさらにご覧ください",
|
||||||
"fixed_host_helper": "イベントに出席する必要がある人を追加します。<1>さらに詳しく</1>",
|
"fixed_host_helper": "イベントに出席する必要がある人を追加します。<1>さらに詳しく</1>",
|
||||||
"round_robin_helper": "グループの人々は順番に交代し、イベントには1人だけが出席します。",
|
"round_robin_helper": "グループの人々は順番に交代し、イベントには1人だけが出席します。",
|
||||||
"check_email_reset_password": "メールをご確認ください。パスワードをリセットするためのリンクを送信しました。",
|
"check_email_reset_password": "メールをご確認ください。パスワードをリセットするためのリンクを送信しました。",
|
||||||
@@ -283,10 +283,10 @@
|
|||||||
"connect": "接続",
|
"connect": "接続",
|
||||||
"try_for_free": "無料で試す",
|
"try_for_free": "無料で試す",
|
||||||
"create_booking_link_with_calcom": "{{appName}} で独自の予約リンクを作成する",
|
"create_booking_link_with_calcom": "{{appName}} で独自の予約リンクを作成する",
|
||||||
"who": "誰が",
|
"who": "ホスト",
|
||||||
"what": "何を",
|
"what": "イベント",
|
||||||
"when": "いつ",
|
"when": "日時",
|
||||||
"where": "どこで",
|
"where": "参加方法",
|
||||||
"add_to_calendar": "カレンダーに追加",
|
"add_to_calendar": "カレンダーに追加",
|
||||||
"add_another_calendar": "別のカレンダーを追加",
|
"add_another_calendar": "別のカレンダーを追加",
|
||||||
"other": "その他",
|
"other": "その他",
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"needs_to_be_confirmed_or_rejected_recurring": "繰り返しミーティングは、まだ確認または拒否が必要です。",
|
"needs_to_be_confirmed_or_rejected_recurring": "繰り返しミーティングは、まだ確認または拒否が必要です。",
|
||||||
"user_needs_to_confirm_or_reject_booking": "{{user}} はまだ予約を承認または拒否する必要があります",
|
"user_needs_to_confirm_or_reject_booking": "{{user}} はまだ予約を承認または拒否する必要があります",
|
||||||
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} は、まだ繰り返しミーティングの各予約を確認または拒否する必要があります。",
|
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} は、まだ繰り返しミーティングの各予約を確認または拒否する必要があります。",
|
||||||
"meeting_is_scheduled": "このミーティングは予定されています",
|
"meeting_is_scheduled": "このミーティングを予定しました",
|
||||||
"meeting_is_scheduled_recurring": "繰り返しイベントがスケジュールされています",
|
"meeting_is_scheduled_recurring": "繰り返しイベントがスケジュールされています",
|
||||||
"booking_submitted": "予約が送信されました",
|
"booking_submitted": "予約が送信されました",
|
||||||
"booking_submitted_recurring": "繰り返しミーティングが送信されました",
|
"booking_submitted_recurring": "繰り返しミーティングが送信されました",
|
||||||
@@ -826,7 +826,7 @@
|
|||||||
"confirm_delete_event_type": "はい、削除します",
|
"confirm_delete_event_type": "はい、削除します",
|
||||||
"delete_account": "アカウントを削除する",
|
"delete_account": "アカウントを削除する",
|
||||||
"confirm_delete_account": "はい。アカウントを削除します",
|
"confirm_delete_account": "はい。アカウントを削除します",
|
||||||
"delete_account_confirmation_message": "あなたの {{appName}} アカウントを本当に削除してもよろしいですか?あなたのアカウントリンクを共有しているユーザーは、そのアカウントを使用して予約を行うことができなくなり、保存していた設定も失われます。",
|
"delete_account_confirmation_message": "あなたのアカウントリンクを共有しているユーザーは、そのアカウントを使用して予約を行うことができなくなり、保存していた設定も失われます。",
|
||||||
"integrations": "連携",
|
"integrations": "連携",
|
||||||
"apps": "アプリ",
|
"apps": "アプリ",
|
||||||
"apps_description": "ここにはアプリの一覧が表示されます",
|
"apps_description": "ここにはアプリの一覧が表示されます",
|
||||||
@@ -2002,11 +2002,6 @@
|
|||||||
"requires_booker_email_verification": "予約者のメール確認が必要です",
|
"requires_booker_email_verification": "予約者のメール確認が必要です",
|
||||||
"description_requires_booker_email_verification": "イベントをスケジュール設定する前に、予約者のメールを確認しておくため",
|
"description_requires_booker_email_verification": "イベントをスケジュール設定する前に、予約者のメールを確認しておくため",
|
||||||
"requires_confirmation_mandatory": "イベントタイプが確認を必要とする場合にのみ、出席者にテキストメッセージが送信されることがあります。",
|
"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": "組織",
|
"organizations": "組織",
|
||||||
"org_admin_other_teams": "他のチーム",
|
"org_admin_other_teams": "他のチーム",
|
||||||
"org_admin_other_teams_description": "こちらでは、組織内であなたが所属していないチームを確認できます。必要なら、これらのチームに自分自身を追加することができます。",
|
"org_admin_other_teams_description": "こちらでは、組織内であなたが所属していないチームを確認できます。必要なら、これらのチームに自分自身を追加することができます。",
|
||||||
|
|||||||
@@ -826,7 +826,7 @@
|
|||||||
"confirm_delete_event_type": "네, 삭제합니다",
|
"confirm_delete_event_type": "네, 삭제합니다",
|
||||||
"delete_account": "계정 삭제",
|
"delete_account": "계정 삭제",
|
||||||
"confirm_delete_account": "예, 계정을 삭제합니다",
|
"confirm_delete_account": "예, 계정을 삭제합니다",
|
||||||
"delete_account_confirmation_message": "{{appName}} 계정을 삭제하시겠습니까? 계정 링크를 공유한 사람은 더 이상 이 링크를 사용하여 예약할 수 없으며 저장한 모든 기본 설정이 손실됩니다.",
|
"delete_account_confirmation_message": "계정 링크를 공유한 사람은 더 이상 이 링크를 사용하여 예약할 수 없으며 저장한 모든 기본 설정이 손실됩니다.",
|
||||||
"integrations": "통합",
|
"integrations": "통합",
|
||||||
"apps": "앱",
|
"apps": "앱",
|
||||||
"apps_description": "여기에서 앱 목록을 찾을 수 있습니다.",
|
"apps_description": "여기에서 앱 목록을 찾을 수 있습니다.",
|
||||||
@@ -2002,11 +2002,6 @@
|
|||||||
"requires_booker_email_verification": "예약자 이메일 인증이 필요합니다",
|
"requires_booker_email_verification": "예약자 이메일 인증이 필요합니다",
|
||||||
"description_requires_booker_email_verification": "이벤트를 예약하기 전 예약자의 이메일 인증하기",
|
"description_requires_booker_email_verification": "이벤트를 예약하기 전 예약자의 이메일 인증하기",
|
||||||
"requires_confirmation_mandatory": "문자 메시지는 이벤트 유형에 확인이 필요한 경우에만 참석자에게 보낼 수 있습니다.",
|
"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": "조직",
|
"organizations": "조직",
|
||||||
"org_admin_other_teams": "다른 팀",
|
"org_admin_other_teams": "다른 팀",
|
||||||
"org_admin_other_teams_description": "여기에서 귀하의 소속이 아닌 조직 내부의 팀을 볼 수 있습니다. 필요한 경우 여기에 자신을 추가할 수 있습니다.",
|
"org_admin_other_teams_description": "여기에서 귀하의 소속이 아닌 조직 내부의 팀을 볼 수 있습니다. 필요한 경우 여기에 자신을 추가할 수 있습니다.",
|
||||||
|
|||||||
@@ -826,7 +826,7 @@
|
|||||||
"confirm_delete_event_type": "Ja, verwijderen",
|
"confirm_delete_event_type": "Ja, verwijderen",
|
||||||
"delete_account": "Account verwijderen",
|
"delete_account": "Account verwijderen",
|
||||||
"confirm_delete_account": "Ja, 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",
|
"integrations": "Integraties",
|
||||||
"apps": "Apps",
|
"apps": "Apps",
|
||||||
"apps_description": "Hier vindt u een lijst van uw apps",
|
"apps_description": "Hier vindt u een lijst van uw apps",
|
||||||
@@ -2002,11 +2002,6 @@
|
|||||||
"requires_booker_email_verification": "E-mailverificatie door boeker vereist",
|
"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",
|
"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.",
|
"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",
|
"organizations": "Organisaties",
|
||||||
"org_admin_other_teams": "Andere teams",
|
"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.",
|
"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)",
|
"slot_interval_default": "Bruk hendelseslengde (standard)",
|
||||||
"delete_account": "Slett konto",
|
"delete_account": "Slett konto",
|
||||||
"confirm_delete_account": "Ja, 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",
|
"integrations": "Integrasjoner",
|
||||||
"apps": "Apper",
|
"apps": "Apper",
|
||||||
"apps_description": "Her finner du en liste over appene dine",
|
"apps_description": "Her finner du en liste over appene dine",
|
||||||
|
|||||||
@@ -826,7 +826,7 @@
|
|||||||
"confirm_delete_event_type": "Tak, usuń",
|
"confirm_delete_event_type": "Tak, usuń",
|
||||||
"delete_account": "Usuń konto",
|
"delete_account": "Usuń konto",
|
||||||
"confirm_delete_account": "Tak, 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",
|
"integrations": "Integracje",
|
||||||
"apps": "Aplikacje",
|
"apps": "Aplikacje",
|
||||||
"apps_description": "Tutaj znajdziesz listę Twoich aplikacji",
|
"apps_description": "Tutaj znajdziesz listę Twoich aplikacji",
|
||||||
@@ -2002,11 +2002,6 @@
|
|||||||
"requires_booker_email_verification": "Wymaga weryfikacji adresu e-mail rezerwującego.",
|
"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.",
|
"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.",
|
"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",
|
"organizations": "Organizacje",
|
||||||
"org_admin_other_teams": "Inne zespoły",
|
"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ć.",
|
"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",
|
"confirm_delete_event_type": "Sim, excluir",
|
||||||
"delete_account": "Apagar conta",
|
"delete_account": "Apagar conta",
|
||||||
"confirm_delete_account": "Sim, 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",
|
"integrations": "Integrações",
|
||||||
"apps": "Apps",
|
"apps": "Apps",
|
||||||
"apps_description": "Aqui você poderá encontrar uma lista de todos os seus aplicativos",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"organizations": "Organizações",
|
||||||
"org_admin_other_teams": "Outros times",
|
"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.",
|
"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",
|
"confirm_delete_event_type": "Sim, eliminar",
|
||||||
"delete_account": "Eliminar conta",
|
"delete_account": "Eliminar conta",
|
||||||
"confirm_delete_account": "Sim, 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",
|
"integrations": "Integrações",
|
||||||
"apps": "Aplicações",
|
"apps": "Aplicações",
|
||||||
"apps_description": "Aqui pode encontrar uma lista das suas apps",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"organizations": "Organizações",
|
||||||
"org_admin_other_teams": "Outras equipas",
|
"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.",
|
"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",
|
"confirm_delete_event_type": "Da, șterge",
|
||||||
"delete_account": "Ștergeți contul",
|
"delete_account": "Ștergeți contul",
|
||||||
"confirm_delete_account": "Da, ș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",
|
"integrations": "Integrări",
|
||||||
"apps": "Aplicații",
|
"apps": "Aplicații",
|
||||||
"apps_description": "Aici poți găsi o listă a aplicațiilor tale",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"organizations": "Organizații",
|
||||||
"org_admin_other_teams": "Alte echipe",
|
"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.",
|
"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": "Да, удалить",
|
"confirm_delete_event_type": "Да, удалить",
|
||||||
"delete_account": "Удалить аккаунт",
|
"delete_account": "Удалить аккаунт",
|
||||||
"confirm_delete_account": "Да, удалить аккаунт",
|
"confirm_delete_account": "Да, удалить аккаунт",
|
||||||
"delete_account_confirmation_message": "Вы уверены, что хотите удалить свой {{appName}}-аккаунт? Любой, кто имеет ссылку на вашу учетную запись, больше не сможет забронировать встречу, и сохраненные настройки будут потеряны.",
|
"delete_account_confirmation_message": "Любой, кто имеет ссылку на вашу учетную запись, больше не сможет забронировать встречу, и сохраненные настройки будут потеряны.",
|
||||||
"integrations": "Интеграции",
|
"integrations": "Интеграции",
|
||||||
"apps": "Приложения",
|
"apps": "Приложения",
|
||||||
"apps_description": "Здесь показывается список ваших приложений",
|
"apps_description": "Здесь показывается список ваших приложений",
|
||||||
@@ -2002,11 +2002,6 @@
|
|||||||
"requires_booker_email_verification": "Требуется подтверждение электронной почты пользователя, оформляющего бронирование",
|
"requires_booker_email_verification": "Требуется подтверждение электронной почты пользователя, оформляющего бронирование",
|
||||||
"description_requires_booker_email_verification": "Чтобы подтвердить электронную почту пользователя, оформляющего бронирование, до планирования событий",
|
"description_requires_booker_email_verification": "Чтобы подтвердить электронную почту пользователя, оформляющего бронирование, до планирования событий",
|
||||||
"requires_confirmation_mandatory": "Участникам можно отправлять сообщения, только если тип события предполагает подтверждение.",
|
"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": "Организации",
|
"organizations": "Организации",
|
||||||
"org_admin_other_teams": "Другие команды",
|
"org_admin_other_teams": "Другие команды",
|
||||||
"org_admin_other_teams_description": "Это перечень команд вашей организации, в которые вы не входите. При необходимости можно добавить себя в эти команды.",
|
"org_admin_other_teams_description": "Это перечень команд вашей организации, в которые вы не входите. При необходимости можно добавить себя в эти команды.",
|
||||||
|
|||||||
@@ -826,7 +826,7 @@
|
|||||||
"confirm_delete_event_type": "Da, izbriši",
|
"confirm_delete_event_type": "Da, izbriši",
|
||||||
"delete_account": "Izbriši nalog",
|
"delete_account": "Izbriši nalog",
|
||||||
"confirm_delete_account": "Da, 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",
|
"integrations": "Integracije",
|
||||||
"apps": "Aplikacije",
|
"apps": "Aplikacije",
|
||||||
"apps_description": "Ovde možete pronaći spisak svojih aplikacija",
|
"apps_description": "Ovde možete pronaći spisak svojih aplikacija",
|
||||||
@@ -2002,11 +2002,6 @@
|
|||||||
"requires_booker_email_verification": "Zahteva imejl verifikaciju rezervacije",
|
"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",
|
"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.",
|
"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",
|
"organizations": "Organizacije",
|
||||||
"org_admin_other_teams": "Drugi timovi",
|
"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.",
|
"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",
|
"confirm_delete_event_type": "Ja, radera",
|
||||||
"delete_account": "Radera konto",
|
"delete_account": "Radera konto",
|
||||||
"confirm_delete_account": "Ja, 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",
|
"integrations": "Integrationer",
|
||||||
"apps": "Appar",
|
"apps": "Appar",
|
||||||
"apps_description": "Här hittar du en lista med dina 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",
|
"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",
|
"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.",
|
"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",
|
"organizations": "Organisationer",
|
||||||
"org_admin_other_teams": "Andra team",
|
"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.",
|
"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",
|
"confirm_delete_event_type": "Evet, sil",
|
||||||
"delete_account": "Hesabı sil",
|
"delete_account": "Hesabı sil",
|
||||||
"confirm_delete_account": "Evet, 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",
|
"integrations": "Entegrasyonlar",
|
||||||
"apps": "Uygulamalar",
|
"apps": "Uygulamalar",
|
||||||
"apps_description": "Cal örneğiniz için uygulamayı etkinleştirin",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"organizations": "Gruplar",
|
||||||
"org_admin_other_teams": "Diğer ekipler",
|
"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.",
|
"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": "Так, видалити",
|
"confirm_delete_event_type": "Так, видалити",
|
||||||
"delete_account": "Видалити обліковий запис",
|
"delete_account": "Видалити обліковий запис",
|
||||||
"confirm_delete_account": "Так, видалити обліковий запис",
|
"confirm_delete_account": "Так, видалити обліковий запис",
|
||||||
"delete_account_confirmation_message": "Справді видалити обліковий запис {{appName}}? Усі, кому ви надавали посилання на свій обліковий запис, більше не зможуть бронювати ваш час за його допомогою. Усі збережені налаштування буде втрачено.",
|
"delete_account_confirmation_message": "Усі, кому ви надавали посилання на свій обліковий запис, більше не зможуть бронювати ваш час за його допомогою. Усі збережені налаштування буде втрачено.",
|
||||||
"integrations": "Інтеграції",
|
"integrations": "Інтеграції",
|
||||||
"apps": "Додатки",
|
"apps": "Додатки",
|
||||||
"apps_description": "Тут наведено список ваших додатків",
|
"apps_description": "Тут наведено список ваших додатків",
|
||||||
@@ -2002,11 +2002,6 @@
|
|||||||
"requires_booker_email_verification": "Потрібно підтвердження ел. адреси особи, які здійснює бронювання",
|
"requires_booker_email_verification": "Потрібно підтвердження ел. адреси особи, які здійснює бронювання",
|
||||||
"description_requires_booker_email_verification": "Щоб забезпечити підтвердження ел. адреси особи, яка здійснює бронювання, до планування заходів",
|
"description_requires_booker_email_verification": "Щоб забезпечити підтвердження ел. адреси особи, яка здійснює бронювання, до планування заходів",
|
||||||
"requires_confirmation_mandatory": "Текстові повідомлення можна надсилати лише учасникам, коли тип події вимагає підтвердження.",
|
"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": "Організації",
|
"organizations": "Організації",
|
||||||
"org_admin_other_teams": "Інші команди",
|
"org_admin_other_teams": "Інші команди",
|
||||||
"org_admin_other_teams_description": "Тут ви можете побачити команди у вашій організації, до яких ви не приєдналися. За потреби ви можете приєднатися до них.",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"organizations": "Tổ chức",
|
||||||
"org_admin_other_teams": "Nhóm khá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.",
|
"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": "是,删除",
|
"confirm_delete_event_type": "是,删除",
|
||||||
"delete_account": "删除账户",
|
"delete_account": "删除账户",
|
||||||
"confirm_delete_account": "是,删除账户",
|
"confirm_delete_account": "是,删除账户",
|
||||||
"delete_account_confirmation_message": "您确定要删除您的 {{appName}} 账户? 任何曾通过您的账户链接与您预约过的人都将无法再使用它和您预约,您保存的任何偏好设置都将丢失。",
|
"delete_account_confirmation_message": "任何曾通过您的账户链接与您预约过的人都将无法再使用它和您预约,您保存的任何偏好设置都将丢失。",
|
||||||
"integrations": "集成",
|
"integrations": "集成",
|
||||||
"apps": "应用",
|
"apps": "应用",
|
||||||
"apps_description": "在此处可以找到您应用的列表",
|
"apps_description": "在此处可以找到您应用的列表",
|
||||||
@@ -1276,6 +1276,7 @@
|
|||||||
"personal_cal_url": "我的个人 {{appName}} URL",
|
"personal_cal_url": "我的个人 {{appName}} URL",
|
||||||
"bio_hint": "请写一段简单的自我介绍,该介绍将会展示在您的个人 url 页面上。",
|
"bio_hint": "请写一段简单的自我介绍,该介绍将会展示在您的个人 url 页面上。",
|
||||||
"user_has_no_bio": "此用户尚未添加个人简历。",
|
"user_has_no_bio": "此用户尚未添加个人简历。",
|
||||||
|
"bio": "个人简历",
|
||||||
"delete_account_modal_title": "删除账户",
|
"delete_account_modal_title": "删除账户",
|
||||||
"confirm_delete_account_modal": "您确定要删除您的 {{appName}} 账户吗?",
|
"confirm_delete_account_modal": "您确定要删除您的 {{appName}} 账户吗?",
|
||||||
"delete_my_account": "删除我的账户",
|
"delete_my_account": "删除我的账户",
|
||||||
@@ -2002,11 +2003,6 @@
|
|||||||
"requires_booker_email_verification": "需要预约者电子邮件验证",
|
"requires_booker_email_verification": "需要预约者电子邮件验证",
|
||||||
"description_requires_booker_email_verification": "确保在安排活动前验证预约者的电子邮件",
|
"description_requires_booker_email_verification": "确保在安排活动前验证预约者的电子邮件",
|
||||||
"requires_confirmation_mandatory": "仅当活动类型需要确认时,才能向参与者发送短信。",
|
"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": "组织",
|
"organizations": "组织",
|
||||||
"org_admin_other_teams": "其他团队",
|
"org_admin_other_teams": "其他团队",
|
||||||
"org_admin_other_teams_description": "在此处可以看到您的组织内您不属于的团队。如果需要,您可以将自己添加到这些团队中。",
|
"org_admin_other_teams_description": "在此处可以看到您的组织内您不属于的团队。如果需要,您可以将自己添加到这些团队中。",
|
||||||
|
|||||||
@@ -826,7 +826,7 @@
|
|||||||
"confirm_delete_event_type": "是,刪除",
|
"confirm_delete_event_type": "是,刪除",
|
||||||
"delete_account": "刪除帳號",
|
"delete_account": "刪除帳號",
|
||||||
"confirm_delete_account": "是,刪除帳號",
|
"confirm_delete_account": "是,刪除帳號",
|
||||||
"delete_account_confirmation_message": "確定要刪除 {{appName}} 的帳號?任何分享過帳號連結的人再也無法以此進行預約,而且任何儲存過的偏好設定都會消失。",
|
"delete_account_confirmation_message": "任何分享過帳號連結的人再也無法以此進行預約,而且任何儲存過的偏好設定都會消失。",
|
||||||
"integrations": "整合",
|
"integrations": "整合",
|
||||||
"apps": "應用程式",
|
"apps": "應用程式",
|
||||||
"apps_description": "您可以在這裡找到應用程式清單",
|
"apps_description": "您可以在這裡找到應用程式清單",
|
||||||
@@ -2002,11 +2002,6 @@
|
|||||||
"requires_booker_email_verification": "需要預約者電子郵件驗證",
|
"requires_booker_email_verification": "需要預約者電子郵件驗證",
|
||||||
"description_requires_booker_email_verification": "如要在預定活動之前先確保預約者的電子郵件驗證",
|
"description_requires_booker_email_verification": "如要在預定活動之前先確保預約者的電子郵件驗證",
|
||||||
"requires_confirmation_mandatory": "簡訊只會在活動類型需要確認時傳送給與會者。",
|
"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": "組織",
|
"organizations": "組織",
|
||||||
"org_admin_other_teams": "其他團隊",
|
"org_admin_other_teams": "其他團隊",
|
||||||
"org_admin_other_teams_description": "您可查看貴組織內您未加入的團隊。您可視需要自行加入其中的團隊。",
|
"org_admin_other_teams_description": "您可查看貴組織內您未加入的團隊。您可視需要自行加入其中的團隊。",
|
||||||
|
|||||||
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user