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 | ||
|
|
3a3d8db489 | ||
|
|
de04b1a561 | ||
|
|
ca946a9822 | ||
|
|
a5c05bc550 | ||
|
|
fd02cc44fa | ||
|
|
5ee962948f | ||
|
|
c350f099f7 | ||
|
|
07860c238a | ||
|
|
2565d0915a | ||
|
|
3fdc41968f | ||
|
|
37f01db0da | ||
|
|
560ec244e8 | ||
|
|
21a0b67b5a | ||
|
|
824145b0e2 | ||
|
|
bc89fe00ea | ||
|
|
64850b54f0 | ||
|
|
405a4cc850 | ||
|
|
eccab37607 | ||
|
|
786c1c2ba3 | ||
|
|
50970dc249 | ||
|
|
45cded6b48 | ||
|
|
b139ec8994 | ||
|
|
d058d322bc | ||
|
|
72907e5e1b | ||
|
|
d9c3c184c4 | ||
|
|
b1fff71c91 | ||
|
|
56b1243e59 | ||
|
|
a3413216f6 | ||
|
|
fd507f1d12 | ||
|
|
c820a3937a | ||
|
|
5c24ed2ef7 | ||
|
|
8d9d7bbb25 | ||
|
|
837bf5d604 | ||
|
|
c9c634cceb | ||
|
|
8ba09ce04d | ||
|
|
65d4a4f40e | ||
|
|
581d8ff4ae | ||
|
|
c562f45904 | ||
|
|
b413f0a609 | ||
|
|
ffcf9b770d | ||
|
|
ab77531167 | ||
|
|
f59f4ca26c | ||
|
|
ca13eec4c9 | ||
|
|
bf1ff9bd71 | ||
|
|
caefa93e14 | ||
|
|
7154349e9e | ||
|
|
7beb9568ba | ||
|
|
2251d5a4be | ||
|
|
f1bdff9679 | ||
|
|
c433e0c74c | ||
|
|
fb940a1023 | ||
|
|
73366e4152 | ||
|
|
9d7da10744 | ||
|
|
c4f091debf |
@@ -125,4 +125,5 @@ SALESFORCE_CONSUMER_SECRET=""
|
||||
ZOHOCRM_CLIENT_ID=""
|
||||
ZOHOCRM_CLIENT_SECRET=""
|
||||
|
||||
|
||||
# *********************************************************************************************************
|
||||
|
||||
@@ -229,3 +229,31 @@ AUTH_BEARER_TOKEN_VERCEL=
|
||||
# Used for E2E tests on Apple Calendar
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL=""
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
|
||||
|
||||
# - APP CREDENTIAL SYNC ***********************************************************************************
|
||||
# Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations
|
||||
# Under settings/admin/apps ensure that all app secrets are set the same as the parent application
|
||||
# You can use: `openssl rand -base64 32` to generate one
|
||||
CALCOM_WEBHOOK_SECRET=""
|
||||
# This is the header name that will be used to verify the webhook secret. Should be in lowercase
|
||||
CALCOM_WEBHOOK_HEADER_NAME="calcom-webhook-secret"
|
||||
CALCOM_CREDENTIAL_SYNC_ENDPOINT=""
|
||||
# Key should match on Cal.com and your application
|
||||
# must be 32 bytes for AES256 encryption algorithm
|
||||
# You can use: `openssl rand -base64 24` to generate one
|
||||
CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY=""
|
||||
|
||||
# - OIDC E2E TEST *******************************************************************************************
|
||||
|
||||
# Ensure this ADMIN EMAIL is present in the SAML_ADMINS list
|
||||
E2E_TEST_SAML_ADMIN_EMAIL=
|
||||
E2E_TEST_SAML_ADMIN_PASSWORD=
|
||||
|
||||
E2E_TEST_OIDC_CLIENT_ID=
|
||||
E2E_TEST_OIDC_CLIENT_SECRET=
|
||||
E2E_TEST_OIDC_PROVIDER_DOMAIN=
|
||||
|
||||
E2E_TEST_OIDC_USER_EMAIL=
|
||||
E2E_TEST_OIDC_USER_PASSWORD=
|
||||
|
||||
# ***********************************************************************************************************
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Cron - monthlyDigestEmail
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs on the 28th, 29th, 30th and 31st of every month (see https://crontab.guru)
|
||||
- cron: "59 23 28-31 * *"
|
||||
jobs:
|
||||
cron-monthlyDigestEmail:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if today is the last day of the month
|
||||
id: check-last-day
|
||||
run: |
|
||||
LAST_DAY=$(date -d tomorrow +%d)
|
||||
if [ "$LAST_DAY" == "01" ]; then
|
||||
echo "::set-output name=is_last_day::true"
|
||||
else
|
||||
echo "::set-output name=is_last_day::false"
|
||||
fi
|
||||
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_API_KEY && steps.check-last-day.outputs.is_last_day == 'true' }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/monthlyDigestEmail \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
@@ -21,10 +21,18 @@ jobs:
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v1.13.0
|
||||
with:
|
||||
# upload sources
|
||||
upload_sources: true
|
||||
|
||||
# upload translations (& auto-approve 'em)
|
||||
upload_translations_args: '--auto-approve-imported'
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
push_translations: true
|
||||
|
||||
# download translations
|
||||
download_translations: true
|
||||
|
||||
# GH config
|
||||
commit_message: "New Crowdin translations by Github Action"
|
||||
localization_branch_name: main
|
||||
create_pull_request: false
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Auto Comment Merge Conflicts
|
||||
on: push
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-comment-merge-conflicts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: codytseng/auto-comment-merge-conflicts@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
comment-body: "Hey there, there is a merge conflict, can you take a look?"
|
||||
wait-ms: 3000
|
||||
max-retries: 5
|
||||
label-name: "🚨 merge conflict"
|
||||
ignore-authors: dependabot,otherAuthor
|
||||
@@ -44,4 +44,5 @@ jobs:
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Thank you for following the naming conventions! 🙏
|
||||
Thank you for following the naming conventions! 🙏 Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
|
||||
|
||||
|
||||
@@ -22,6 +22,6 @@ jobs:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pr-message: |-
|
||||
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
|
||||
Feel free to join the conversation at [discord](https://go.cal.com/discord)
|
||||
Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
|
||||
issue-message: |
|
||||
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
|
||||
|
||||
+3
-1
@@ -5,7 +5,9 @@ tasks:
|
||||
next_auth_secret=$(openssl rand -base64 32) &&
|
||||
calendso_encryption_key=$(openssl rand -base64 24) &&
|
||||
sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$next_auth_secret|" \
|
||||
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" .env
|
||||
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" \
|
||||
-e "s|http://localhost:3000|https://localhost:3000|" \
|
||||
-e "s|localhost:3000|3000-$GITPOD_WORKSPACE_ID.$GITPOD_WORKSPACE_CLUSTER_HOST|" .env
|
||||
command: yarn dx
|
||||
|
||||
ports:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
_
|
||||
@@ -135,3 +135,4 @@ If you get errors, be sure to fix them before committing.
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
|
||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
- Be sure to fill the PR Template accordingly.
|
||||
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
|
||||
@@ -504,6 +504,9 @@ For example, `Cal.com (support@cal.com)`.
|
||||
9. Click the "Save"/ "UPDATE" button at the bottom footer.
|
||||
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
|
||||
|
||||
### Obtaining Zoho Calendar Client ID and Secret
|
||||
|
||||
[Follow these steps](./packages/app-store/zohocalendar/)
|
||||
### Obtaining Zoho Bigin Client ID and Secret
|
||||
|
||||
[Follow these steps](./packages/app-store/zoho-bigin/)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
BACKEND_URL=http://localhost:3002/api
|
||||
# BACKEND_URL=https://api.cal.com/v1
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
# FRONTEND_URL=https://cal.com
|
||||
|
||||
APP_ID=cal-ai
|
||||
APP_URL=http://localhost:3000/apps/cal-ai
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Cal.com Email Assistant
|
||||
|
||||
Welcome to the first stage of Cal AI!
|
||||
Welcome to the first stage of Cal.ai!
|
||||
|
||||
This app lets you chat with your calendar via email:
|
||||
|
||||
@@ -27,7 +27,7 @@ Before running the app, please see [env.mjs](./src/env.mjs) for all required env
|
||||
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
|
||||
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
||||
- A default sender email (for example, `ai@cal.dev`)
|
||||
- The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||
- The Cal.ai's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||
|
||||
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/ai",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"author": "Cal.com Inc.",
|
||||
"dependencies": {
|
||||
|
||||
@@ -18,21 +18,21 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
const json = await request.json();
|
||||
|
||||
const { apiKey, userId, message, subject, user, replyTo } = json;
|
||||
const { apiKey, userId, message, subject, user, users, replyTo: agentEmail } = json;
|
||||
|
||||
if ((!message && !subject) || !user) {
|
||||
return new NextResponse("Missing fields", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId);
|
||||
const response = await agent(`${subject}\n\n${message}`, { ...user }, users, apiKey, userId, agentEmail);
|
||||
|
||||
// Send response to user
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
||||
to: user.email,
|
||||
from: replyTo,
|
||||
from: agentEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
|
||||
@@ -8,6 +8,7 @@ import prisma from "@calcom/prisma";
|
||||
import { env } from "../../../env.mjs";
|
||||
import { fetchAvailability } from "../../../tools/getAvailability";
|
||||
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
||||
import { extractUsers } from "../../../utils/extractUsers";
|
||||
import getHostFromHeaders from "../../../utils/host";
|
||||
import now from "../../../utils/now";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
@@ -45,6 +46,7 @@ export const POST = async (request: NextRequest) => {
|
||||
select: {
|
||||
email: true,
|
||||
id: true,
|
||||
username: true,
|
||||
timeZone: true,
|
||||
credentials: {
|
||||
select: {
|
||||
@@ -53,15 +55,15 @@ export const POST = async (request: NextRequest) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { email: envelope.from },
|
||||
where: { email: envelope.from, credentials: { some: { appId: env.APP_ID } } },
|
||||
});
|
||||
|
||||
// User is not a cal.com user or is using an unverified email.
|
||||
if (!signature || !user) {
|
||||
await sendEmail({
|
||||
html: `Thanks for your interest in Cal AI! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
||||
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: `Thanks for your interest in Cal AI! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
||||
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
@@ -76,9 +78,9 @@ export const POST = async (request: NextRequest) => {
|
||||
const url = env.APP_URL;
|
||||
|
||||
await sendEmail({
|
||||
html: `Thanks for using Cal AI! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`,
|
||||
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
@@ -89,7 +91,7 @@ export const POST = async (request: NextRequest) => {
|
||||
const { apiKey } = credential as { apiKey: string };
|
||||
|
||||
// Pre-fetch data relevant to most bookings.
|
||||
const [eventTypes, availability] = await Promise.all([
|
||||
const [eventTypes, availability, users] = await Promise.all([
|
||||
fetchEventTypes({
|
||||
apiKey,
|
||||
}),
|
||||
@@ -99,6 +101,7 @@ export const POST = async (request: NextRequest) => {
|
||||
dateFrom: now(user.timeZone),
|
||||
dateTo: now(user.timeZone),
|
||||
}),
|
||||
extractUsers(`${parsed.text} ${parsed.subject}`),
|
||||
]);
|
||||
|
||||
if ("error" in availability) {
|
||||
@@ -138,9 +141,11 @@ export const POST = async (request: NextRequest) => {
|
||||
user: {
|
||||
email: user.email,
|
||||
eventTypes,
|
||||
username: user.username,
|
||||
timeZone: user.timeZone,
|
||||
workingHours,
|
||||
},
|
||||
users,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -17,6 +17,7 @@ export const env = createEnv({
|
||||
*/
|
||||
runtimeEnv: {
|
||||
BACKEND_URL: process.env.BACKEND_URL,
|
||||
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||
APP_ID: process.env.APP_ID,
|
||||
APP_URL: process.env.APP_URL,
|
||||
PARSE_KEY: process.env.PARSE_KEY,
|
||||
@@ -32,6 +33,7 @@ export const env = createEnv({
|
||||
*/
|
||||
server: {
|
||||
BACKEND_URL: z.string().url(),
|
||||
FRONTEND_URL: z.string().url(),
|
||||
APP_ID: z.string().min(1),
|
||||
APP_URL: z.string().url(),
|
||||
PARSE_KEY: z.string().min(1),
|
||||
|
||||
+25
-14
@@ -1,6 +1,8 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { UserList } from "~/src/types/user";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
@@ -9,21 +11,23 @@ import { env } from "../env.mjs";
|
||||
const createBooking = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
users,
|
||||
eventTypeId,
|
||||
start,
|
||||
end,
|
||||
timeZone,
|
||||
language,
|
||||
responses,
|
||||
invite,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
users: UserList;
|
||||
eventTypeId: number;
|
||||
start: string;
|
||||
end: string;
|
||||
timeZone: string;
|
||||
language: string;
|
||||
responses: { name?: string; email?: string; location?: string };
|
||||
invite: number;
|
||||
title?: string;
|
||||
status?: string;
|
||||
}): Promise<string | Error | { error: string }> => {
|
||||
@@ -36,6 +40,18 @@ const createBooking = async ({
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||
|
||||
const user = users.find((u) => u.id === invite);
|
||||
|
||||
if (!user) {
|
||||
return { error: `User with id ${invite} not found to invite` };
|
||||
}
|
||||
|
||||
const responses = {
|
||||
id: invite,
|
||||
name: user.username,
|
||||
email: user.email,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({
|
||||
end,
|
||||
@@ -66,19 +82,19 @@ const createBooking = async ({
|
||||
return "Booking created";
|
||||
};
|
||||
|
||||
const createBookingTool = (apiKey: string, userId: number) => {
|
||||
const createBookingTool = (apiKey: string, userId: number, users: UserList) => {
|
||||
return new DynamicStructuredTool({
|
||||
description:
|
||||
"Tries to create a booking. If the user is unavailable, it will return availability that day, allowing you to avoid the getAvailability step in many cases.",
|
||||
func: async ({ eventTypeId, start, end, timeZone, language, responses, title, status }) => {
|
||||
description: "Creates a booking on the primary user's calendar.",
|
||||
func: async ({ eventTypeId, start, end, timeZone, language, invite, title, status }) => {
|
||||
return JSON.stringify(
|
||||
await createBooking({
|
||||
apiKey,
|
||||
userId,
|
||||
users,
|
||||
end,
|
||||
eventTypeId,
|
||||
language,
|
||||
responses,
|
||||
invite,
|
||||
start,
|
||||
status,
|
||||
timeZone,
|
||||
@@ -86,19 +102,14 @@ const createBookingTool = (apiKey: string, userId: number) => {
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "createBookingIfAvailable",
|
||||
name: "createBooking",
|
||||
schema: z.object({
|
||||
end: z
|
||||
.string()
|
||||
.describe("This should correspond to the event type's length, unless otherwise specified."),
|
||||
eventTypeId: z.number(),
|
||||
language: z.string(),
|
||||
responses: z
|
||||
.object({
|
||||
email: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.describe("External invited user. Not the user making the request."),
|
||||
invite: z.number().describe("External user id to invite."),
|
||||
start: z.string(),
|
||||
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
||||
timeZone: z.string(),
|
||||
@@ -12,13 +12,11 @@ export const fetchAvailability = async ({
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
eventTypeId?: number;
|
||||
}): Promise<Partial<Availability> | { error: string }> => {
|
||||
const params: { [k: string]: string } = {
|
||||
apiKey,
|
||||
@@ -27,8 +25,6 @@ export const fetchAvailability = async ({
|
||||
dateTo,
|
||||
};
|
||||
|
||||
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
|
||||
@@ -51,30 +47,29 @@ export const fetchAvailability = async ({
|
||||
};
|
||||
};
|
||||
|
||||
const getAvailabilityTool = (apiKey: string, userId: number) => {
|
||||
const getAvailabilityTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get availability within range.",
|
||||
func: async ({ dateFrom, dateTo, eventTypeId }) => {
|
||||
description: "Get availability of users within range.",
|
||||
func: async ({ userIds, dateFrom, dateTo }) => {
|
||||
return JSON.stringify(
|
||||
await fetchAvailability({
|
||||
apiKey,
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
})
|
||||
await Promise.all(
|
||||
userIds.map(
|
||||
async (userId) =>
|
||||
await fetchAvailability({
|
||||
userId: userId,
|
||||
apiKey,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
name: "getAvailability",
|
||||
schema: z.object({
|
||||
userIds: z.array(z.number()).describe("The users to fetch availability for."),
|
||||
dateFrom: z.string(),
|
||||
dateTo: z.string(),
|
||||
eventTypeId: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"The ID of the event type to filter availability for if you've called getEventTypes, otherwise do not include."
|
||||
),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ const fetchBookings = async ({
|
||||
|
||||
const getBookingsTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get bookings for a user between two dates.",
|
||||
description: "Get bookings for the primary user between two dates.",
|
||||
func: async ({ from, to }) => {
|
||||
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
|
||||
},
|
||||
|
||||
@@ -7,11 +7,15 @@ import type { EventType } from "../types/eventType";
|
||||
/**
|
||||
* Fetches event types by user ID.
|
||||
*/
|
||||
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
||||
const params = {
|
||||
export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; userId?: number }) => {
|
||||
const params: Record<string, string> = {
|
||||
apiKey,
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
params["userId"] = userId.toString();
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
|
||||
@@ -28,6 +32,7 @@ export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
||||
|
||||
return data.event_types.map((eventType: EventType) => ({
|
||||
id: eventType.id,
|
||||
slug: eventType.slug,
|
||||
length: eventType.length,
|
||||
title: eventType.title,
|
||||
}));
|
||||
@@ -35,16 +40,19 @@ export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
||||
|
||||
const getEventTypesTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
|
||||
func: async () => {
|
||||
description: "Get a user's event type IDs. Usually necessary to book a meeting.",
|
||||
func: async ({ userId }) => {
|
||||
return JSON.stringify(
|
||||
await fetchEventTypes({
|
||||
apiKey,
|
||||
userId,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "getEventTypes",
|
||||
schema: z.object({}),
|
||||
schema: z.object({
|
||||
userId: z.number().optional().describe("The user ID. Defaults to the primary user's ID."),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
timeZone: string;
|
||||
eventTypes: EventType[];
|
||||
workingHours: WorkingHours[];
|
||||
};
|
||||
|
||||
export type UserList = {
|
||||
id?: number;
|
||||
email?: string;
|
||||
username?: string;
|
||||
type: "fromUsername" | "fromEmail";
|
||||
}[];
|
||||
|
||||
+56
-23
@@ -2,30 +2,40 @@ import { initializeAgentExecutorWithOptions } from "langchain/agents";
|
||||
import { ChatOpenAI } from "langchain/chat_models/openai";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
|
||||
import createBookingIfAvailable from "../tools/createBooking";
|
||||
import deleteBooking from "../tools/deleteBooking";
|
||||
import getAvailability from "../tools/getAvailability";
|
||||
import getBookings from "../tools/getBookings";
|
||||
import sendBookingLink from "../tools/sendBookingLink";
|
||||
import updateBooking from "../tools/updateBooking";
|
||||
import type { EventType } from "../types/eventType";
|
||||
import type { User } from "../types/user";
|
||||
import type { User, UserList } from "../types/user";
|
||||
import type { WorkingHours } from "../types/workingHours";
|
||||
import now from "./now";
|
||||
|
||||
const gptModel = "gpt-4";
|
||||
|
||||
/**
|
||||
* Core of the Cal AI booking agent: a LangChain Agent Executor.
|
||||
* Core of the Cal.ai booking agent: a LangChain Agent Executor.
|
||||
* Uses a toolchain to book meetings, list available slots, etc.
|
||||
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
|
||||
*/
|
||||
const agent = async (input: string, user: User, apiKey: string, userId: number) => {
|
||||
const agent = async (
|
||||
input: string,
|
||||
user: User,
|
||||
users: UserList,
|
||||
apiKey: string,
|
||||
userId: number,
|
||||
agentEmail: string
|
||||
) => {
|
||||
const tools = [
|
||||
createBookingIfAvailable(apiKey, userId),
|
||||
getAvailability(apiKey, userId),
|
||||
// getEventTypes(apiKey),
|
||||
getAvailability(apiKey),
|
||||
getBookings(apiKey, userId),
|
||||
createBookingIfAvailable(apiKey, userId, users),
|
||||
updateBooking(apiKey, userId),
|
||||
deleteBooking(apiKey),
|
||||
sendBookingLink(apiKey, user, users, agentEmail),
|
||||
];
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
@@ -39,24 +49,47 @@ const agent = async (input: string, user: User, apiKey: string, userId: number)
|
||||
*/
|
||||
const executor = await initializeAgentExecutorWithOptions(tools, model, {
|
||||
agentArgs: {
|
||||
prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email.
|
||||
Make sure your final answers are definitive, complete and well formatted.
|
||||
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||
Tools will always handle times in UTC, but times sent to the user should be formatted per that user's timezone.
|
||||
prefix: `You are Cal.ai - a bleeding edge scheduling assistant that interfaces via email.
|
||||
Make sure your final answers are definitive, complete and well formatted.
|
||||
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||
Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
|
||||
|
||||
The current time in the user's timezone is: ${now(user.timeZone)}
|
||||
The user's time zone is: ${user.timeZone}
|
||||
The user's event types are: ${user.eventTypes
|
||||
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
|
||||
.join("\n")}
|
||||
The user's working hours are: ${user.workingHours
|
||||
.map(
|
||||
(w: WorkingHours) =>
|
||||
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
||||
w.startTime
|
||||
}, End Time (minutes in UTC): ${w.endTime}`
|
||||
)
|
||||
.join("\n")}
|
||||
The primary user's id is: ${userId}
|
||||
The primary user's username is: ${user.username}
|
||||
The current time in the primary user's timezone is: ${now(user.timeZone)}
|
||||
The primary user's time zone is: ${user.timeZone}
|
||||
The primary user's event types are: ${user.eventTypes
|
||||
.map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`)
|
||||
.join("\n")}
|
||||
The primary user's working hours are: ${user.workingHours
|
||||
.map(
|
||||
(w: WorkingHours) =>
|
||||
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
||||
w.startTime
|
||||
}, End Time (minutes in UTC): ${w.endTime};`
|
||||
)
|
||||
.join("\n")}
|
||||
${
|
||||
users.length
|
||||
? `The email references the following @usernames and emails: ${users
|
||||
.map(
|
||||
(u) =>
|
||||
(u.id ? `, id: ${u.id}` : "id: (non user)") +
|
||||
(u.username
|
||||
? u.type === "fromUsername"
|
||||
? `, username: @${u.username}`
|
||||
: ", username: REDACTED"
|
||||
: ", (no username)") +
|
||||
(u.email
|
||||
? u.type === "fromEmail"
|
||||
? `, email: ${u.email}`
|
||||
: ", email: REDACTED"
|
||||
: ", (no email)") +
|
||||
";"
|
||||
)
|
||||
.join("\n")}`
|
||||
: ""
|
||||
}
|
||||
`,
|
||||
},
|
||||
agentType: "openai-functions",
|
||||
|
||||
@@ -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 ({
|
||||
subject,
|
||||
to,
|
||||
cc,
|
||||
from,
|
||||
text,
|
||||
html,
|
||||
}: {
|
||||
subject: string;
|
||||
to: string;
|
||||
to: string | string[];
|
||||
cc?: string | string[];
|
||||
from: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
@@ -22,9 +24,10 @@ const send = async ({
|
||||
|
||||
const msg = {
|
||||
to,
|
||||
cc,
|
||||
from: {
|
||||
email: from,
|
||||
name: "Cal AI",
|
||||
name: "Cal.ai",
|
||||
},
|
||||
text,
|
||||
html,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { CONSOLE_URL } from "@calcom/lib/constants";
|
||||
import { customPrisma } from "@calcom/prisma";
|
||||
|
||||
const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL;
|
||||
|
||||
@@ -12,7 +12,7 @@ export const customPrismaClient: NextMiddleware = async (req, res, next) => {
|
||||
} = req;
|
||||
// If no custom api Id is provided, attach to request the regular cal.com prisma client.
|
||||
if (!key) {
|
||||
req.prisma = new PrismaClient();
|
||||
req.prisma = customPrisma();
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export const customPrismaClient: NextMiddleware = async (req, res, next) => {
|
||||
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
|
||||
return;
|
||||
}
|
||||
req.prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
|
||||
req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } });
|
||||
/* @note:
|
||||
In order to skip verifyApiKey for customPrisma requests,
|
||||
we pass isAdmin true, and userId 0, if we detect them later,
|
||||
|
||||
@@ -58,6 +58,7 @@ export const schemaBookingReadPublic = Booking.extend({
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
responses: z.record(z.any()),
|
||||
}).pick({
|
||||
id: true,
|
||||
userId: true,
|
||||
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { Session } from "next-auth";
|
||||
import type { NextApiRequest as BaseNextApiRequest } from "next/types";
|
||||
|
||||
import type { PrismaClient } from "@calcom/prisma/client";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
export type * from "next/types";
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /availability:
|
||||
* /teams/{teamId}/availability:
|
||||
* get:
|
||||
* summary: Find user or team availability
|
||||
* summary: Find team availability
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: apiKey
|
||||
@@ -21,25 +21,14 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
* type: string
|
||||
* example: "1234abcd5678efgh"
|
||||
* description: Your API key
|
||||
* - in: query
|
||||
* name: userId
|
||||
* schema:
|
||||
* type: integer
|
||||
* example: 101
|
||||
* description: ID of the user to fetch the availability for
|
||||
* - in: query
|
||||
* - in: path
|
||||
* name: teamId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* example: 123
|
||||
* description: ID of the team to fetch the availability for
|
||||
* - in: query
|
||||
* name: username
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "alice"
|
||||
* description: username of the user to fetch the availability for
|
||||
* - in: query
|
||||
* name: dateFrom
|
||||
* schema:
|
||||
* type: string
|
||||
@@ -59,7 +48,7 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
* type: integer
|
||||
* example: 123
|
||||
* description: Event Type ID of the event type to fetch the availability for
|
||||
* operationId: availability
|
||||
* operationId: team-availability
|
||||
* tags:
|
||||
* - availability
|
||||
* responses:
|
||||
@@ -96,7 +85,89 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
* 401:
|
||||
* description: Authorization information is missing or invalid.
|
||||
* 404:
|
||||
* description: User not found | Team not found | Team has no members
|
||||
* description: Team not found | Team has no members
|
||||
*
|
||||
* /availability:
|
||||
* get:
|
||||
* summary: Find user availability
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: apiKey
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "1234abcd5678efgh"
|
||||
* description: Your API key
|
||||
* - in: query
|
||||
* name: userId
|
||||
* schema:
|
||||
* type: integer
|
||||
* example: 101
|
||||
* description: ID of the user to fetch the availability for
|
||||
* - in: query
|
||||
* name: username
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "alice"
|
||||
* description: username of the user to fetch the availability for
|
||||
* - in: query
|
||||
* name: dateFrom
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* example: "2023-05-14 00:00:00"
|
||||
* description: Start Date of the availability query
|
||||
* - in: query
|
||||
* name: dateTo
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* example: "2023-05-20 00:00:00"
|
||||
* description: End Date of the availability query
|
||||
* - in: query
|
||||
* name: eventTypeId
|
||||
* schema:
|
||||
* type: integer
|
||||
* example: 123
|
||||
* description: Event Type ID of the event type to fetch the availability for
|
||||
* operationId: user-availability
|
||||
* tags:
|
||||
* - availability
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* example:
|
||||
* busy:
|
||||
* - start: "2023-05-14T10:00:00.000Z"
|
||||
* end: "2023-05-14T11:00:00.000Z"
|
||||
* title: "Team meeting between Alice and Bob"
|
||||
* - start: "2023-05-15T14:00:00.000Z"
|
||||
* end: "2023-05-15T15:00:00.000Z"
|
||||
* title: "Project review between Carol and Dave"
|
||||
* - start: "2023-05-16T09:00:00.000Z"
|
||||
* end: "2023-05-16T10:00:00.000Z"
|
||||
* - start: "2023-05-17T13:00:00.000Z"
|
||||
* end: "2023-05-17T14:00:00.000Z"
|
||||
* timeZone: "America/New_York"
|
||||
* workingHours:
|
||||
* - days: [1, 2, 3, 4, 5]
|
||||
* startTime: 540
|
||||
* endTime: 1020
|
||||
* userId: 101
|
||||
* dateOverrides:
|
||||
* - date: "2023-05-15"
|
||||
* startTime: 600
|
||||
* endTime: 960
|
||||
* userId: 101
|
||||
* currentSeats: 4
|
||||
* 401:
|
||||
* description: Authorization information is missing or invalid.
|
||||
* 404:
|
||||
* description: User not found
|
||||
*/
|
||||
interface MemberRoles {
|
||||
[userId: number | string]: MembershipRole;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import type { PrismaClient } from "@calcom/prisma/client";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
||||
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
||||
|
||||
@@ -79,8 +79,8 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
||||
<div className="flex items-center gap-x-3 px-5 py-4">
|
||||
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>
|
||||
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
|
||||
{logo ? (
|
||||
<img
|
||||
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
||||
|
||||
@@ -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 [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
||||
|
||||
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
||||
{ appType: type },
|
||||
{
|
||||
|
||||
@@ -76,6 +76,7 @@ export const InstallAppButtonChild = ({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
className="max-w-min"
|
||||
onInteractOutside={(event) => {
|
||||
if (mutation.isLoading) event.preventDefault();
|
||||
}}>
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
import { Copy, Edit } from "@calcom/ui/components/icon";
|
||||
import { Copy, Edit, Info } from "@calcom/ui/components/icon";
|
||||
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
@@ -124,79 +124,81 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
|
||||
const setEventName = (value: string) => formMethods.setValue("eventName", value);
|
||||
return (
|
||||
<div className="flex flex-col space-y-8">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/**
|
||||
* Only display calendar selector if user has connected calendars AND if it's not
|
||||
* a team event. Since we don't have logic to handle each attendee calendar (for now).
|
||||
* This will fallback to each user selected destination calendar.
|
||||
*/}
|
||||
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<Label>{t("add_to_calendar")}</Label>
|
||||
<Link
|
||||
href="/apps/categories/calendar"
|
||||
target="_blank"
|
||||
className="hover:text-emphasis text-default text-sm">
|
||||
{t("add_another_calendar")}
|
||||
</Link>
|
||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<Label className="font-medium">{t("add_to_calendar")}</Label>
|
||||
<Link
|
||||
href="/apps/categories/calendar"
|
||||
target="_blank"
|
||||
className="hover:text-emphasis text-default text-sm">
|
||||
{t("add_another_calendar")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="-mt-1 w-full">
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="destinationCalendar"
|
||||
defaultValue={eventType.destinationCalendar || undefined}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DestinationCalendarSelector
|
||||
destinationCalendar={eventType.destinationCalendar}
|
||||
value={value ? value.externalId : undefined}
|
||||
onChange={onChange}
|
||||
hidePlaceholder
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-subtle text-sm">{t("select_which_cal")}</p>
|
||||
</div>
|
||||
<div className="-mt-1 w-full">
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="destinationCalendar"
|
||||
defaultValue={eventType.destinationCalendar || undefined}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DestinationCalendarSelector
|
||||
destinationCalendar={eventType.destinationCalendar}
|
||||
value={value ? value.externalId : undefined}
|
||||
onChange={onChange}
|
||||
hidePlaceholder
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-default text-sm">{t("select_which_cal")}</p>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
{...shouldLockDisableProps("eventName")}
|
||||
placeholder={eventNamePlaceholder}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName")}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
aria-label="edit custom name"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
{...shouldLockDisableProps("eventName")}
|
||||
placeholder={eventNamePlaceholder}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName")}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
aria-label="edit custom name"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
|
||||
|
||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||
<FormBuilder
|
||||
title={t("booking_questions_title")}
|
||||
description={t("booking_questions_description")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
{...shouldLockDisableProps("bookingFields")}
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<hr className="border-subtle [&:has(+div:empty)]:hidden" />
|
||||
<div>
|
||||
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
|
||||
</div>
|
||||
<hr className="border-subtle" />
|
||||
<FormBuilder
|
||||
title={t("booking_questions_title")}
|
||||
description={t("booking_questions_description")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
{...shouldLockDisableProps("bookingFields")}
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<RequiresConfirmationController
|
||||
eventType={eventType}
|
||||
seatsEnabled={seatsEnabled}
|
||||
@@ -204,13 +206,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
requiresConfirmation={requiresConfirmation}
|
||||
onRequiresConfirmation={setRequiresConfirmation}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<Controller
|
||||
name="requiresBookerEmailVerification"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.requiresBookerEmailVerification}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||
title={t("requires_booker_email_verification")}
|
||||
{...shouldLockDisableProps("requiresBookerEmailVerification")}
|
||||
description={t("description_requires_booker_email_verification")}
|
||||
@@ -219,13 +223,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<Controller
|
||||
name="hideCalendarNotes"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.hideCalendarNotes}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||
title={t("disable_notes")}
|
||||
{...shouldLockDisableProps("hideCalendarNotes")}
|
||||
description={t("disable_notes_description")}
|
||||
@@ -234,13 +240,19 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<Controller
|
||||
name="successRedirectUrl"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||
redirectUrlVisible && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("redirect_success_booking")}
|
||||
{...successRedirectUrlLocked}
|
||||
description={t("redirect_url_description")}
|
||||
@@ -249,8 +261,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
setRedirectUrlVisible(e);
|
||||
onChange(e ? value : "");
|
||||
}}>
|
||||
{/* Textfield has some margin by default we remove that so we can keep consistent alignment */}
|
||||
<div className="lg:-mb-2 lg:-ml-2">
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label={t("redirect_success_booking")}
|
||||
@@ -274,10 +285,24 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||
hashedLinkVisible && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
data-testid="hashedLinkCheck"
|
||||
title={t("private_link")}
|
||||
Badge={
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://cal.com/docs/core-features/event-types/single-use-private-links">
|
||||
<Info className="mb-2 ml-1.5 h-4 w-4 cursor-pointer" />
|
||||
</a>
|
||||
}
|
||||
{...shouldLockDisableProps("hashedLinkCheck")}
|
||||
description={t("private_link_description", { appName: APP_NAME })}
|
||||
checked={hashedLinkVisible}
|
||||
@@ -285,8 +310,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
||||
setHashedLinkVisible(e);
|
||||
}}>
|
||||
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
|
||||
<div className="lg:-ml-2">
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
{!IS_VISUAL_REGRESSION_TESTING && (
|
||||
<TextField
|
||||
disabled
|
||||
@@ -321,7 +345,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
)}
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<Controller
|
||||
name="seatsPerTimeSlotEnabled"
|
||||
control={formMethods.control}
|
||||
@@ -329,6 +353,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||
value && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
data-testid="offer-seats-toggle"
|
||||
title={t("offer_seats")}
|
||||
{...seatsLocked}
|
||||
@@ -349,45 +379,49 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
}
|
||||
onChange(e);
|
||||
}}>
|
||||
<Controller
|
||||
name="seatsPerTimeSlot"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="lg:-ml-2">
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
labelSrOnly
|
||||
label={t("number_of_seats")}
|
||||
type="number"
|
||||
disabled={seatsLocked.disabled}
|
||||
defaultValue={value || 2}
|
||||
min={1}
|
||||
addOnSuffix={<>{t("seats")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CheckboxField
|
||||
description={t("show_attendees")}
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<Controller
|
||||
name="seatsPerTimeSlot"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="lg:-ml-2">
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
labelSrOnly
|
||||
label={t("number_of_seats")}
|
||||
type="number"
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAttendees}
|
||||
defaultValue={value || 2}
|
||||
min={1}
|
||||
addOnSuffix={<>{t("seats")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CheckboxField
|
||||
description={t("show_attendees")}
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAttendees}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CheckboxField
|
||||
description={t("show_available_seats_count")}
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) =>
|
||||
formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)
|
||||
}
|
||||
defaultChecked={!!eventType.seatsShowAvailabilityCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CheckboxField
|
||||
description={t("show_available_seats_count")}
|
||||
disabled={seatsLocked.disabled}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAvailabilityCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
{noShowFeeEnabled && <Alert severity="warning" title={t("seats_and_no_show_fee_error")} />}
|
||||
</>
|
||||
@@ -395,13 +429,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
/>
|
||||
{allowDisablingAttendeeConfirmationEmails(workflows) && (
|
||||
<>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.attendee"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||
title={t("disable_attendees_confirmation_emails")}
|
||||
description={t("disable_attendees_confirmation_emails_description")}
|
||||
checked={value || false}
|
||||
@@ -417,7 +452,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
)}
|
||||
{allowDisablingHostConfirmationEmails(workflows) && (
|
||||
<>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.host"
|
||||
control={formMethods.control}
|
||||
@@ -425,6 +459,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
||||
title={t("disable_host_confirmation_emails")}
|
||||
description={t("disable_host_confirmation_emails_description")}
|
||||
checked={value || false}
|
||||
|
||||
@@ -158,7 +158,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
</div>
|
||||
</div>
|
||||
{!shouldLockDisableProps("apps").disabled && (
|
||||
<div className="bg-muted rounded-md p-8">
|
||||
<div className="bg-muted mt-6 rounded-md p-8">
|
||||
{!isLoading && notInstalledApps?.length ? (
|
||||
<>
|
||||
<h2 className="text-emphasis mb-2 text-xl font-semibold leading-5 tracking-[0.01em]">
|
||||
@@ -166,7 +166,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
</h2>
|
||||
<p className="text-default mb-6 text-sm font-normal">
|
||||
<Trans i18nKey="available_apps_desc">
|
||||
You have no apps installed. View popular apps below and explore more in our
|
||||
View popular apps below and explore more in our
|
||||
<Link className="cursor-pointer underline" href="/apps">
|
||||
App Store
|
||||
</Link>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
|
||||
import { useState, memo } from "react";
|
||||
import { useState, memo, useEffect } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { OptionProps, SingleValueProps } from "react-select";
|
||||
import { components } from "react-select";
|
||||
@@ -98,42 +98,43 @@ const EventTypeScheduleDetails = memo(
|
||||
schedule?.schedule.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
||||
|
||||
return (
|
||||
<div className="border-default space-y-4 rounded border px-6 pb-4">
|
||||
<ol className="table border-collapse text-sm">
|
||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32 ",
|
||||
!isAvailable ? "text-subtle line-through" : "text-default"
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div key={i} className="text-default flex items-center leading-4">
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ms-4">-</span>
|
||||
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<hr className="border-subtle" />
|
||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
||||
<div>
|
||||
<div className="border-subtle space-y-4 border-x p-6">
|
||||
<ol className="table border-collapse text-sm">
|
||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32 ",
|
||||
!isAvailable ? "text-subtle line-through" : "text-default"
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div key={i} className="text-default flex items-center leading-4">
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ms-4">-</span>
|
||||
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
|
||||
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
|
||||
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
@@ -164,9 +165,8 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const { watch } = useFormContext<FormValues>();
|
||||
const { watch, setValue, getValues } = useFormContext<FormValues>();
|
||||
const watchSchedule = watch("schedule");
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [options, setOptions] = useState<AvailabilityOption[]>([]);
|
||||
|
||||
const { isLoading } = trpc.viewer.availability.list.useQuery(undefined, {
|
||||
@@ -214,7 +214,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
|
||||
setOptions(options);
|
||||
|
||||
const scheduleId = formMethods.getValues("schedule");
|
||||
const scheduleId = getValues("schedule");
|
||||
const value = options.find((option) =>
|
||||
scheduleId
|
||||
? option.value === scheduleId
|
||||
@@ -223,15 +223,20 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
|
||||
);
|
||||
|
||||
formMethods.setValue("availability", value);
|
||||
setValue("availability", value);
|
||||
},
|
||||
});
|
||||
|
||||
const availabilityValue = formMethods.watch("availability");
|
||||
const availabilityValue = watch("availability");
|
||||
|
||||
useEffect(() => {
|
||||
if (!availabilityValue?.value) return;
|
||||
setValue("schedule", availabilityValue.value);
|
||||
}, [availabilityValue, setValue]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div>
|
||||
<div className="border-subtle rounded-t-md border p-6">
|
||||
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
|
||||
{t("availability")}
|
||||
{shouldLockIndicator("availability")}
|
||||
@@ -248,7 +253,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
isSearchable={false}
|
||||
onChange={(selected) => {
|
||||
field.onChange(selected?.value || null);
|
||||
if (selected?.value) formMethods.setValue("availability", selected);
|
||||
if (selected?.value) setValue("availability", selected);
|
||||
}}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
value={availabilityValue}
|
||||
@@ -276,7 +281,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
|
||||
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
const { resetField, setValue } = useFormContext<FormValues>();
|
||||
const { setValue } = useFormContext<FormValues>();
|
||||
return (
|
||||
<Controller
|
||||
name="metadata.config.useHostSchedulesForTeamEvent"
|
||||
@@ -285,9 +290,7 @@ const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSe
|
||||
checked={!value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(!checked);
|
||||
if (checked) {
|
||||
resetField("schedule");
|
||||
} else {
|
||||
if (!checked) {
|
||||
setValue("schedule", null);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/interval
|
||||
import type { PeriodType } from "@calcom/prisma/enums";
|
||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||
import { Plus, Trash } from "@calcom/ui/components/icon";
|
||||
import { Plus, Trash2 } from "@calcom/ui/components/icon";
|
||||
|
||||
const MinimumBookingNoticeInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
@@ -83,14 +83,14 @@ const MinimumBookingNoticeInput = React.forwardRef<
|
||||
type="number"
|
||||
placeholder="0"
|
||||
min={0}
|
||||
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
|
||||
className="mb-0 h-9 rounded-[4px] ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
<input type="hidden" ref={ref} {...passThroughProps} />
|
||||
</div>
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={passThroughProps.disabled}
|
||||
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||
className="mb-0 ml-2 h-9 w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||
defaultValue={durationTypeOptions.find(
|
||||
(option) => option.value === minimumBookingNoticeDisplayValues.type
|
||||
)}
|
||||
@@ -170,8 +170,8 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + offsetStartValue * 60 * 1000);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4 lg:space-y-8">
|
||||
<div>
|
||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="beforeBufferTime">
|
||||
@@ -295,159 +295,195 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="bookingLimits"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<SettingsToggle
|
||||
title={t("limit_booking_frequency")}
|
||||
{...bookingLimitsLocked}
|
||||
description={t("limit_booking_frequency_description")}
|
||||
checked={Object.keys(value ?? {}).length > 0}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("bookingLimits", {
|
||||
PER_DAY: 1,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("bookingLimits", {});
|
||||
}
|
||||
}}>
|
||||
<IntervalLimitsManager
|
||||
disabled={bookingLimitsLocked.disabled}
|
||||
propertyName="bookingLimits"
|
||||
defaultLimit={1}
|
||||
step={1}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
render={({ field: { value } }) => {
|
||||
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||
return (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("limit_booking_frequency")}
|
||||
{...bookingLimitsLocked}
|
||||
description={t("limit_booking_frequency_description")}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("bookingLimits", {
|
||||
PER_DAY: 1,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("bookingLimits", {});
|
||||
}
|
||||
}}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0">
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<IntervalLimitsManager
|
||||
disabled={bookingLimitsLocked.disabled}
|
||||
propertyName="bookingLimits"
|
||||
defaultLimit={1}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="durationLimits"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<SettingsToggle
|
||||
title={t("limit_total_booking_duration")}
|
||||
description={t("limit_total_booking_duration_description")}
|
||||
{...durationLimitsLocked}
|
||||
checked={Object.keys(value ?? {}).length > 0}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("durationLimits", {
|
||||
PER_DAY: 60,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("durationLimits", {});
|
||||
}
|
||||
}}>
|
||||
<IntervalLimitsManager
|
||||
propertyName="durationLimits"
|
||||
defaultLimit={60}
|
||||
disabled={durationLimitsLocked.disabled}
|
||||
step={15}
|
||||
textFieldSuffix={t("minutes")}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
render={({ field: { value } }) => {
|
||||
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||
return (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_total_booking_duration")}
|
||||
description={t("limit_total_booking_duration_description")}
|
||||
{...durationLimitsLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
formMethods.setValue("durationLimits", {
|
||||
PER_DAY: 60,
|
||||
});
|
||||
} else {
|
||||
formMethods.setValue("durationLimits", {});
|
||||
}
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<IntervalLimitsManager
|
||||
propertyName="durationLimits"
|
||||
defaultLimit={60}
|
||||
disabled={durationLimitsLocked.disabled}
|
||||
step={15}
|
||||
textFieldSuffix={t("minutes")}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="periodType"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<SettingsToggle
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
{...periodTypeLocked}
|
||||
checked={value && value !== "UNLIMITED"}
|
||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={watchPeriodType}
|
||||
value={watchPeriodType}
|
||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||
{PERIOD_TYPES.filter((opt) =>
|
||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
||||
).map((period) => {
|
||||
if (period.type === "UNLIMITED") return null;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"text-default mb-2 flex flex-wrap items-center text-sm",
|
||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||
)}
|
||||
key={period.type}>
|
||||
{!periodTypeLocked.disabled && (
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
)}
|
||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||
{period.type === "ROLLING" && (
|
||||
<div className="flex items-center">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
type="number"
|
||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
placeholder="30"
|
||||
disabled={periodTypeLocked.disabled}
|
||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||
defaultValue={eventType.periodDays || 30}
|
||||
/>
|
||||
<Select
|
||||
options={optionsPeriod}
|
||||
isSearchable={false}
|
||||
isDisabled={periodTypeLocked.disabled}
|
||||
onChange={(opt) => {
|
||||
formMethods.setValue(
|
||||
"periodCountCalendarDays",
|
||||
opt?.value.toString() as "0" | "1"
|
||||
);
|
||||
}}
|
||||
defaultValue={
|
||||
optionsPeriod.find(
|
||||
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
|
||||
) ?? optionsPeriod[0]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{period.type === "RANGE" && (
|
||||
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
|
||||
<Controller
|
||||
name="periodDates"
|
||||
control={formMethods.control}
|
||||
defaultValue={periodDates}
|
||||
render={() => (
|
||||
<DateRangePicker
|
||||
startDate={formMethods.getValues("periodDates").startDate}
|
||||
endDate={formMethods.getValues("periodDates").endDate}
|
||||
render={({ field: { value } }) => {
|
||||
const isChecked = value && value !== "UNLIMITED";
|
||||
|
||||
return (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
{...periodTypeLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<RadioGroup.Root
|
||||
defaultValue={watchPeriodType}
|
||||
value={watchPeriodType}
|
||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||
{PERIOD_TYPES.filter((opt) =>
|
||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
||||
).map((period) => {
|
||||
if (period.type === "UNLIMITED") return null;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"text-default mb-2 flex flex-wrap items-center text-sm",
|
||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||
)}
|
||||
key={period.type}>
|
||||
{!periodTypeLocked.disabled && (
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
)}
|
||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||
{period.type === "ROLLING" && (
|
||||
<div className="flex items-center">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
type="number"
|
||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
placeholder="30"
|
||||
disabled={periodTypeLocked.disabled}
|
||||
onDatesChange={({ startDate, endDate }) => {
|
||||
formMethods.setValue("periodDates", {
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
}}
|
||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||
defaultValue={eventType.periodDays || 30}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Select
|
||||
options={optionsPeriod}
|
||||
isSearchable={false}
|
||||
isDisabled={periodTypeLocked.disabled}
|
||||
onChange={(opt) => {
|
||||
formMethods.setValue(
|
||||
"periodCountCalendarDays",
|
||||
opt?.value.toString() as "0" | "1"
|
||||
);
|
||||
}}
|
||||
defaultValue={
|
||||
optionsPeriod.find(
|
||||
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
|
||||
) ?? optionsPeriod[0]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{period.type === "RANGE" && (
|
||||
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
|
||||
<Controller
|
||||
name="periodDates"
|
||||
control={formMethods.control}
|
||||
defaultValue={periodDates}
|
||||
render={() => (
|
||||
<DateRangePicker
|
||||
startDate={formMethods.getValues("periodDates").startDate}
|
||||
endDate={formMethods.getValues("periodDates").endDate}
|
||||
disabled={periodTypeLocked.disabled}
|
||||
onDatesChange={({ startDate, endDate }) => {
|
||||
formMethods.setValue("periodDates", {
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{period.suffix ? <span className="me-2 ms-2"> {period.suffix}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
{period.suffix ? <span className="me-2 ms-2"> {period.suffix}</span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup.Root>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
||||
offsetToggle && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("offset_toggle")}
|
||||
description={t("offset_toggle_description")}
|
||||
{...offsetStartLockedProps}
|
||||
@@ -458,18 +494,20 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
formMethods.setValue("offsetStart", 0);
|
||||
}
|
||||
}}>
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...offsetStartLockedProps}
|
||||
label={t("offset_start")}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
hint={t("offset_start_description", {
|
||||
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
})}
|
||||
/>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...offsetStartLockedProps}
|
||||
label={t("offset_start")}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
hint={t("offset_start_description", {
|
||||
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
);
|
||||
@@ -509,19 +547,19 @@ const IntervalLimitItem = ({
|
||||
onIntervalSelect,
|
||||
}: IntervalLimitItemProps) => {
|
||||
return (
|
||||
<div className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
|
||||
<div className="mb-4 flex max-h-9 items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
|
||||
className="mb-0 !h-auto"
|
||||
className="mb-0"
|
||||
placeholder={`${value}`}
|
||||
disabled={disabled}
|
||||
min={step}
|
||||
step={step}
|
||||
defaultValue={value}
|
||||
addOnSuffix={textFieldSuffix}
|
||||
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value))}
|
||||
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value || "0", 10))}
|
||||
/>
|
||||
<Select
|
||||
options={selectOptions}
|
||||
@@ -529,9 +567,16 @@ const IntervalLimitItem = ({
|
||||
isDisabled={disabled}
|
||||
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
|
||||
onChange={onIntervalSelect}
|
||||
className="w-36"
|
||||
/>
|
||||
{hasDeleteButton && !disabled && (
|
||||
<Button variant="icon" StartIcon={Trash} color="destructive" onClick={() => onDelete(limitKey)} />
|
||||
<Button
|
||||
variant="icon"
|
||||
StartIcon={Trash2}
|
||||
color="destructive"
|
||||
className="border-none"
|
||||
onClick={() => onDelete(limitKey)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -387,178 +387,185 @@ export const EventSetupTab = (
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-8">
|
||||
<TextField
|
||||
required
|
||||
label={t("title")}
|
||||
{...shouldLockDisableProps("title")}
|
||||
defaultValue={eventType.title}
|
||||
{...formMethods.register("title")}
|
||||
/>
|
||||
<div>
|
||||
<Label>
|
||||
{t("description")}
|
||||
{shouldLockIndicator("description")}
|
||||
</Label>
|
||||
<DescriptionEditor
|
||||
description={eventType?.description}
|
||||
editable={!descriptionLockedProps.disabled}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
required
|
||||
label={t("URL")}
|
||||
{...shouldLockDisableProps("slug")}
|
||||
defaultValue={eventType.slug}
|
||||
addOnLeading={
|
||||
<>
|
||||
{urlPrefix}/
|
||||
{!isManagedEventType
|
||||
? team
|
||||
? (orgBranding ? "" : "team/") + team.slug
|
||||
: eventType.users[0].username
|
||||
: t("username_placeholder")}
|
||||
/
|
||||
</>
|
||||
}
|
||||
{...formMethods.register("slug", {
|
||||
setValueAs: (v) => slugify(v),
|
||||
})}
|
||||
/>
|
||||
{multipleDuration ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("available_durations")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
isMulti
|
||||
defaultValue={selectedMultipleDuration}
|
||||
name="metadata.multipleDuration"
|
||||
isSearchable={false}
|
||||
className="h-auto !min-h-[36px] text-sm"
|
||||
options={multipleDurationOptions}
|
||||
value={selectedMultipleDuration}
|
||||
onChange={(options) => {
|
||||
let newOptions = [...options];
|
||||
newOptions = newOptions.sort((a, b) => {
|
||||
return a?.value - b?.value;
|
||||
});
|
||||
const values = newOptions.map((opt) => opt.value);
|
||||
setMultipleDuration(values);
|
||||
setSelectedMultipleDuration(newOptions);
|
||||
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
|
||||
if (newOptions.length > 0) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
formMethods.setValue("length", newOptions[0].value);
|
||||
} else {
|
||||
setDefaultDuration(null);
|
||||
}
|
||||
}
|
||||
if (newOptions.length === 1 && defaultDuration === null) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
formMethods.setValue("length", newOptions[0].value);
|
||||
}
|
||||
formMethods.setValue("metadata.multipleDuration", values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("default_duration")}
|
||||
{shouldLockIndicator("length")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
value={defaultDuration}
|
||||
isSearchable={false}
|
||||
name="length"
|
||||
className="text-sm"
|
||||
isDisabled={lengthLockedProps.disabled}
|
||||
noOptionsMessage={() => t("default_duration_no_options")}
|
||||
options={selectedMultipleDuration}
|
||||
onChange={(option) => {
|
||||
setDefaultDuration(
|
||||
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
|
||||
);
|
||||
if (option) formMethods.setValue("length", option.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...lengthLockedProps}
|
||||
label={t("duration")}
|
||||
defaultValue={eventType.length ?? 15}
|
||||
{...formMethods.register("length")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
min={1}
|
||||
label={t("title")}
|
||||
{...shouldLockDisableProps("title")}
|
||||
defaultValue={eventType.title}
|
||||
{...formMethods.register("title")}
|
||||
/>
|
||||
)}
|
||||
{!lengthLockedProps.disabled && (
|
||||
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
||||
<SettingsToggle
|
||||
title={t("allow_booker_to_select_duration")}
|
||||
checked={multipleDuration !== undefined}
|
||||
disabled={seatsEnabled}
|
||||
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
|
||||
onCheckedChange={() => {
|
||||
if (multipleDuration !== undefined) {
|
||||
setMultipleDuration(undefined);
|
||||
formMethods.setValue("metadata.multipleDuration", undefined);
|
||||
formMethods.setValue("length", eventType.length);
|
||||
} else {
|
||||
setMultipleDuration([]);
|
||||
formMethods.setValue("metadata.multipleDuration", []);
|
||||
formMethods.setValue("length", 0);
|
||||
}
|
||||
}}
|
||||
<div>
|
||||
<Label>
|
||||
{t("description")}
|
||||
{shouldLockIndicator("description")}
|
||||
</Label>
|
||||
<DescriptionEditor
|
||||
description={eventType?.description}
|
||||
editable={!descriptionLockedProps.disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("location")}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => <Locations />}
|
||||
<TextField
|
||||
required
|
||||
label={t("URL")}
|
||||
{...shouldLockDisableProps("slug")}
|
||||
defaultValue={eventType.slug}
|
||||
addOnLeading={
|
||||
<>
|
||||
{urlPrefix}/
|
||||
{!isManagedEventType
|
||||
? team
|
||||
? (orgBranding ? "" : "team/") + team.slug
|
||||
: eventType.users[0].username
|
||||
: t("username_placeholder")}
|
||||
/
|
||||
</>
|
||||
}
|
||||
{...formMethods.register("slug", {
|
||||
setValueAs: (v) => slugify(v),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle rounded-md border p-6">
|
||||
{multipleDuration ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("available_durations")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
isMulti
|
||||
defaultValue={selectedMultipleDuration}
|
||||
name="metadata.multipleDuration"
|
||||
isSearchable={false}
|
||||
className="h-auto !min-h-[36px] text-sm"
|
||||
options={multipleDurationOptions}
|
||||
value={selectedMultipleDuration}
|
||||
onChange={(options) => {
|
||||
let newOptions = [...options];
|
||||
newOptions = newOptions.sort((a, b) => {
|
||||
return a?.value - b?.value;
|
||||
});
|
||||
const values = newOptions.map((opt) => opt.value);
|
||||
setMultipleDuration(values);
|
||||
setSelectedMultipleDuration(newOptions);
|
||||
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
|
||||
if (newOptions.length > 0) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
formMethods.setValue("length", newOptions[0].value);
|
||||
} else {
|
||||
setDefaultDuration(null);
|
||||
}
|
||||
}
|
||||
if (newOptions.length === 1 && defaultDuration === null) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
formMethods.setValue("length", newOptions[0].value);
|
||||
}
|
||||
formMethods.setValue("metadata.multipleDuration", values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("default_duration")}
|
||||
{shouldLockIndicator("length")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
value={defaultDuration}
|
||||
isSearchable={false}
|
||||
name="length"
|
||||
className="text-sm"
|
||||
isDisabled={lengthLockedProps.disabled}
|
||||
noOptionsMessage={() => t("default_duration_no_options")}
|
||||
options={selectedMultipleDuration}
|
||||
onChange={(option) => {
|
||||
setDefaultDuration(
|
||||
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
|
||||
);
|
||||
if (option) formMethods.setValue("length", option.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...lengthLockedProps}
|
||||
label={t("duration")}
|
||||
defaultValue={eventType.length ?? 15}
|
||||
{...formMethods.register("length")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
min={1}
|
||||
/>
|
||||
)}
|
||||
{!lengthLockedProps.disabled && (
|
||||
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
||||
<SettingsToggle
|
||||
title={t("allow_booker_to_select_duration")}
|
||||
checked={multipleDuration !== undefined}
|
||||
disabled={seatsEnabled}
|
||||
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
|
||||
onCheckedChange={() => {
|
||||
if (multipleDuration !== undefined) {
|
||||
setMultipleDuration(undefined);
|
||||
formMethods.setValue("metadata.multipleDuration", undefined);
|
||||
formMethods.setValue("length", eventType.length);
|
||||
} else {
|
||||
setMultipleDuration([]);
|
||||
formMethods.setValue("metadata.multipleDuration", []);
|
||||
formMethods.setValue("length", 0);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||
<EditLocationDialog
|
||||
isOpenDialog={showLocationModal}
|
||||
setShowLocationModal={setShowLocationModal}
|
||||
saveLocation={saveLocation}
|
||||
defaultValues={formMethods.getValues("locations")}
|
||||
selection={
|
||||
selectedLocation
|
||||
? selectedLocation.address
|
||||
? {
|
||||
value: selectedLocation.value,
|
||||
label: t(selectedLocation.label),
|
||||
icon: selectedLocation.icon,
|
||||
address: selectedLocation.address,
|
||||
}
|
||||
: {
|
||||
value: selectedLocation.value,
|
||||
label: t(selectedLocation.label),
|
||||
icon: selectedLocation.icon,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
setSelectedLocation={setSelectedLocation}
|
||||
setEditingLocationType={setEditingLocationType}
|
||||
teamId={eventType.team?.id}
|
||||
/>
|
||||
<div className="border-subtle rounded-md border p-6">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("location")}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => <Locations />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||
<EditLocationDialog
|
||||
isOpenDialog={showLocationModal}
|
||||
setShowLocationModal={setShowLocationModal}
|
||||
saveLocation={saveLocation}
|
||||
defaultValues={formMethods.getValues("locations")}
|
||||
selection={
|
||||
selectedLocation
|
||||
? selectedLocation.address
|
||||
? {
|
||||
value: selectedLocation.value,
|
||||
label: t(selectedLocation.label),
|
||||
icon: selectedLocation.icon,
|
||||
address: selectedLocation.address,
|
||||
}
|
||||
: {
|
||||
value: selectedLocation.value,
|
||||
label: t(selectedLocation.label),
|
||||
icon: selectedLocation.icon,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
setSelectedLocation={setSelectedLocation}
|
||||
setEditingLocationType={setEditingLocationType}
|
||||
teamId={eventType.team?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Webhook } from "@prisma/client";
|
||||
import { Webhook as TbWebhook } from "lucide-react";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -8,6 +10,7 @@ import { WebhookForm } from "@calcom/features/webhooks/components";
|
||||
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
||||
@@ -115,23 +118,40 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
|
||||
)}
|
||||
{webhooks.length ? (
|
||||
<>
|
||||
<div className="mb-2 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
return (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
lastItem={webhooks.length === index + 1}
|
||||
canEditWebhook={!webhookLockedStatus.disabled}
|
||||
onEditWebhook={() => {
|
||||
setEditModalOpen(true);
|
||||
setWebhookToEdit(webhook);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="border-subtle mb-2 rounded-md border p-8">
|
||||
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
|
||||
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||
{t("add_webhook_description", { appName: APP_NAME })}
|
||||
</p>
|
||||
|
||||
<div className="border-subtle mt-8 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
return (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
lastItem={webhooks.length === index + 1}
|
||||
canEditWebhook={!webhookLockedStatus.disabled}
|
||||
onEditWebhook={() => {
|
||||
setEditModalOpen(true);
|
||||
setWebhookToEdit(webhook);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-default mt-8 text-sm font-normal">
|
||||
<Trans i18nKey="edit_or_manage_webhooks">
|
||||
If you wish to edit or manage your web hooks, please head over to
|
||||
<Link
|
||||
className="cursor-pointer font-semibold underline"
|
||||
href="/settings/developer/webhooks">
|
||||
webhooks settings
|
||||
</Link>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<NewWebhookButton />
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
@@ -47,6 +48,12 @@ export default function RecurringEventController({
|
||||
) : (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||
recurringEventState !== null && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("recurring_event")}
|
||||
{...recurringLocked}
|
||||
description={t("recurring_event_description")}
|
||||
@@ -66,68 +73,70 @@ export default function RecurringEventController({
|
||||
setRecurringEventState(newVal);
|
||||
}
|
||||
}}>
|
||||
{recurringEventState && (
|
||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="mb-0"
|
||||
defaultValue={recurringEventState.interval}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
interval: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={recurringEventFreqOptions}
|
||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||
isSearchable={false}
|
||||
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
|
||||
isDisabled={recurringLocked.disabled}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
{recurringEventState && (
|
||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="mb-0"
|
||||
defaultValue={recurringEventState.interval}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
interval: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={recurringEventFreqOptions}
|
||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||
isSearchable={false}
|
||||
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
|
||||
isDisabled={recurringLocked.disabled}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
defaultValue={recurringEventState.count}
|
||||
className="mb-0"
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
count: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
|
||||
{t("events", {
|
||||
count: recurringEventState.count,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
||||
<TextField
|
||||
disabled={recurringLocked.disabled}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
defaultValue={recurringEventState.count}
|
||||
className="mb-0"
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
count: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
|
||||
{t("events", {
|
||||
count: recurringEventState.count,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -67,6 +67,12 @@ export default function RequiresConfirmationController({
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
||||
requiresConfirmation && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("requires_confirmation")}
|
||||
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
|
||||
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
|
||||
@@ -77,107 +83,111 @@ export default function RequiresConfirmationController({
|
||||
formMethods.setValue("requiresConfirmation", val);
|
||||
onRequiresConfirmation(val);
|
||||
}}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={
|
||||
requiresConfirmation
|
||||
? requiresConfirmationSetup === undefined
|
||||
? "always"
|
||||
: "notice"
|
||||
: undefined
|
||||
}
|
||||
onValueChange={(val) => {
|
||||
if (val === "always") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||
setRequiresConfirmationSetup(undefined);
|
||||
} else if (val === "notice") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold",
|
||||
requiresConfirmationSetup || defaultRequiresConfirmationSetup
|
||||
);
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<RadioGroup.Root
|
||||
defaultValue={
|
||||
requiresConfirmation
|
||||
? requiresConfirmationSetup === undefined
|
||||
? "always"
|
||||
: "notice"
|
||||
: undefined
|
||||
}
|
||||
}}>
|
||||
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
||||
{(requiresConfirmationSetup === undefined || !requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
label={t("always_requires_confirmation")}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
id="always"
|
||||
value="always"
|
||||
/>
|
||||
)}
|
||||
{(requiresConfirmationSetup !== undefined || !requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
className="items-center"
|
||||
label={
|
||||
<>
|
||||
<Trans
|
||||
i18nKey="when_booked_with_less_than_notice"
|
||||
defaults="When booked with less than <time></time> notice"
|
||||
components={{
|
||||
time: (
|
||||
<div className="mx-2 inline-flex">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
onChange={(evt) => {
|
||||
const val = Number(evt.target?.value);
|
||||
setRequiresConfirmationSetup({
|
||||
unit:
|
||||
requiresConfirmationSetup?.unit ??
|
||||
defaultRequiresConfirmationSetup.unit,
|
||||
time: val,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.time",
|
||||
val
|
||||
);
|
||||
}}
|
||||
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
|
||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||
/>
|
||||
<label
|
||||
className={classNames(
|
||||
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
||||
)}>
|
||||
<Select
|
||||
inputId="notice"
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
isDisabled={requiresConfirmationLockedProps.disabled}
|
||||
className="ml-2"
|
||||
onChange={(opt) => {
|
||||
onValueChange={(val) => {
|
||||
if (val === "always") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||
setRequiresConfirmationSetup(undefined);
|
||||
} else if (val === "notice") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold",
|
||||
requiresConfirmationSetup || defaultRequiresConfirmationSetup
|
||||
);
|
||||
}
|
||||
}}>
|
||||
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
||||
{(requiresConfirmationSetup === undefined ||
|
||||
!requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
label={t("always_requires_confirmation")}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
id="always"
|
||||
value="always"
|
||||
/>
|
||||
)}
|
||||
{(requiresConfirmationSetup !== undefined ||
|
||||
!requiresConfirmationLockedProps.disabled) && (
|
||||
<RadioField
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
className="items-center"
|
||||
label={
|
||||
<>
|
||||
<Trans
|
||||
i18nKey="when_booked_with_less_than_notice"
|
||||
defaults="When booked with less than <time></time> notice"
|
||||
components={{
|
||||
time: (
|
||||
<div className="mx-2 inline-flex">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={requiresConfirmationLockedProps.disabled}
|
||||
onChange={(evt) => {
|
||||
const val = Number(evt.target?.value);
|
||||
setRequiresConfirmationSetup({
|
||||
time:
|
||||
requiresConfirmationSetup?.time ??
|
||||
defaultRequiresConfirmationSetup.time,
|
||||
unit: opt?.value as UnitTypeLongPlural,
|
||||
unit:
|
||||
requiresConfirmationSetup?.unit ??
|
||||
defaultRequiresConfirmationSetup.unit,
|
||||
time: val,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.unit",
|
||||
opt?.value as UnitTypeLongPlural
|
||||
"metadata.requiresConfirmationThreshold.time",
|
||||
val
|
||||
);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield]"
|
||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
id="notice"
|
||||
value="notice"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
<label
|
||||
className={classNames(
|
||||
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
||||
)}>
|
||||
<Select
|
||||
inputId="notice"
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
isDisabled={requiresConfirmationLockedProps.disabled}
|
||||
innerClassNames={{ control: "rounded-l-none bg-subtle" }}
|
||||
onChange={(opt) => {
|
||||
setRequiresConfirmationSetup({
|
||||
time:
|
||||
requiresConfirmationSetup?.time ??
|
||||
defaultRequiresConfirmationSetup.time,
|
||||
unit: opt?.value as UnitTypeLongPlural,
|
||||
});
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold.unit",
|
||||
opt?.value as UnitTypeLongPlural
|
||||
);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
id="notice"
|
||||
value="notice"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
@@ -22,7 +23,7 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
const { nextStep } = props;
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const { t } = useLocale();
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
|
||||
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
|
||||
const telemetry = useTelemetry();
|
||||
const userSettingsSchema = z.object({
|
||||
name: z
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Avatar } from "@calcom/ui";
|
||||
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username"> & { safeBio: string | null };
|
||||
type TeamTypeWithSafeHtml = Omit<TeamType, "members" | "inviteToken"> & { members: MemberType[] };
|
||||
|
||||
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
||||
const routerQuery = useRouterQuery();
|
||||
@@ -50,7 +49,9 @@ const Members = ({ members, teamName }: { members: MemberType[]; teamName: strin
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
|
||||
<section
|
||||
data-testid="team-members-container"
|
||||
className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
|
||||
{members.map((member) => {
|
||||
return member.username !== null && <Member key={member.id} member={member} teamName={teamName} />;
|
||||
})}
|
||||
@@ -58,10 +59,10 @@ const Members = ({ members, teamName }: { members: MemberType[]; teamName: strin
|
||||
);
|
||||
};
|
||||
|
||||
const Team = ({ team }: { team: TeamTypeWithSafeHtml }) => {
|
||||
const Team = ({ members, teamName }: { members: MemberType[]; teamName: string | null }) => {
|
||||
return (
|
||||
<div>
|
||||
<Members members={team.members} teamName={team.name} />
|
||||
<Members members={members} teamName={teamName} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,8 @@ const initClock = () => {
|
||||
// This only sets browser locale if there's no preference on localStorage.
|
||||
if (getIs24hClockFromLocalStorage() === null) set24hClock(isBrowserLocale24h());
|
||||
timeOptions.is24hClock = !!getIs24hClockFromLocalStorage();
|
||||
timeOptions.inviteeTimeZone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
|
||||
timeOptions.inviteeTimeZone =
|
||||
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London";
|
||||
};
|
||||
|
||||
const is24h = (is24hClock?: boolean) => {
|
||||
|
||||
@@ -226,6 +226,10 @@ const nextConfig = {
|
||||
},
|
||||
async rewrites() {
|
||||
const beforeFiles = [
|
||||
{
|
||||
source: "/login",
|
||||
destination: "/auth/login",
|
||||
},
|
||||
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
|
||||
...(isOrganizationsEnabled
|
||||
? [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -250,11 +250,8 @@ export default function Custom404() {
|
||||
) : IS_CALCOM ? (
|
||||
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
||||
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
|
||||
<strong className="text-blue-500">
|
||||
{new URL(WEBSITE_URL).hostname}
|
||||
{username}
|
||||
</strong>{" "}
|
||||
{t("is_still_available")} <span className="text-blue-500">{t("register_now")}</span>.
|
||||
<strong className="text-blue-500">{username}</strong> {t("is_still_available")}{" "}
|
||||
<span className="text-blue-500">{t("register_now")}</span>.
|
||||
</a>
|
||||
) : (
|
||||
<span className="mt-2 inline-block text-lg">
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
getMultipleDurationValue,
|
||||
} from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -152,7 +151,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
organization: userOrgQuery(context.req.headers.host ?? "", context.params?.orgSlug),
|
||||
},
|
||||
select: {
|
||||
away: true,
|
||||
|
||||
@@ -29,7 +29,11 @@ class MyDocument extends Document<Props> {
|
||||
const asPath = ctx.asPath || "";
|
||||
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
|
||||
const parsedUrl = new URL(asPath, "https://dummyurl");
|
||||
const isEmbed = parsedUrl.pathname.endsWith("/embed") || parsedUrl.searchParams.get("embedType") !== null;
|
||||
const isEmbedSnippetGeneratorPath = parsedUrl.pathname.startsWith("/event-types");
|
||||
// FIXME: Revisit this logic to remove embedType query param check completely. Ideally, /embed should always be there at the end of the URL. Test properly and then remove it.
|
||||
const isEmbed =
|
||||
(parsedUrl.pathname.endsWith("/embed") || parsedUrl.searchParams.get("embedType") !== null) &&
|
||||
!isEmbedSnippetGeneratorPath;
|
||||
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return { isEmbed, embedColorScheme, nonce, ...initialProps };
|
||||
|
||||
@@ -10,6 +10,7 @@ import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -89,6 +90,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// Identify the org id in an org context signup, either the invited team is an org
|
||||
// or has a parentId, otherwise parentId will be null, making orgId null
|
||||
const orgId = teamMetadata?.isOrganization ? team.id : team.parentId;
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: userEmail },
|
||||
update: {
|
||||
@@ -96,49 +101,52 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
password: hashedPassword,
|
||||
emailVerified: new Date(Date.now()),
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
organizationId: orgId,
|
||||
},
|
||||
create: {
|
||||
username,
|
||||
email: userEmail,
|
||||
password: hashedPassword,
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
organizationId: orgId,
|
||||
},
|
||||
});
|
||||
|
||||
if (teamMetadata?.isOrganization) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
organizationId: team.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const membership = await prisma.membership.update({
|
||||
const membership = await prisma.membership.upsert({
|
||||
where: {
|
||||
userId_teamId: { userId: user.id, teamId: team.id },
|
||||
},
|
||||
data: {
|
||||
update: {
|
||||
accepted: true,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
accepted: true,
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
});
|
||||
closeComUpsertTeamUser(team, user, membership.role);
|
||||
|
||||
// Accept any child team invites for orgs.
|
||||
// Accept any child team invites for orgs and create a membership for the org itself
|
||||
if (team.parentId) {
|
||||
// Join ORG
|
||||
await prisma.user.update({
|
||||
// Create (when invite link is used) or Update (when regular email invitation is used) membership for the organization itself
|
||||
await prisma.membership.upsert({
|
||||
where: {
|
||||
id: user.id,
|
||||
userId_teamId: { userId: user.id, teamId: team.parentId },
|
||||
},
|
||||
data: {
|
||||
organizationId: 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({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendMonthlyDigestEmails } from "@calcom/emails/email-manager";
|
||||
import { EventsInsights } from "@calcom/features/insights/server/events";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const querySchema = z.object({
|
||||
page: z.coerce.number().min(0).optional().default(0),
|
||||
});
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const apiKey = req.headers.authorization || req.query.apiKey;
|
||||
|
||||
if (process.env.CRON_API_KEY !== apiKey) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.status(405).json({ message: "Invalid method" });
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time
|
||||
let { page: pageNumber } = querySchema.parse(req.query);
|
||||
|
||||
const firstDateOfMonth = new Date();
|
||||
firstDateOfMonth.setDate(1);
|
||||
|
||||
while (true) {
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
slug: {
|
||||
not: null,
|
||||
},
|
||||
createdAt: {
|
||||
// created before or on the first day of this month
|
||||
lte: firstDateOfMonth,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
members: true,
|
||||
name: true,
|
||||
},
|
||||
skip: pageNumber * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
if (teams.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const team of teams) {
|
||||
const EventData: {
|
||||
Created: number;
|
||||
Completed: number;
|
||||
Rescheduled: number;
|
||||
Cancelled: number;
|
||||
mostBookedEvents: {
|
||||
eventTypeId?: number | null;
|
||||
eventTypeName?: string | null;
|
||||
count?: number | null;
|
||||
}[];
|
||||
membersWithMostBookings: {
|
||||
userId: number | null;
|
||||
user: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
username: string | null;
|
||||
};
|
||||
count: number;
|
||||
}[];
|
||||
admin: { email: string; name: string };
|
||||
team: {
|
||||
name: string;
|
||||
id: number;
|
||||
};
|
||||
} = {
|
||||
Created: 0,
|
||||
Completed: 0,
|
||||
Rescheduled: 0,
|
||||
Cancelled: 0,
|
||||
mostBookedEvents: [],
|
||||
membersWithMostBookings: [],
|
||||
admin: { email: "", name: "" },
|
||||
team: { name: team.name, id: team.id },
|
||||
};
|
||||
|
||||
const userIdsFromTeams = team.members.map((u) => u.userId);
|
||||
|
||||
// Booking Events
|
||||
const whereConditional: Prisma.BookingTimeStatusWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeams,
|
||||
},
|
||||
teamId: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const promisesResult = await Promise.all([
|
||||
EventsInsights.getCreatedEventsInTimeRange(
|
||||
{
|
||||
start: dayjs(firstDateOfMonth),
|
||||
end: dayjs(new Date()),
|
||||
},
|
||||
whereConditional
|
||||
),
|
||||
EventsInsights.getCompletedEventsInTimeRange(
|
||||
{
|
||||
start: dayjs(firstDateOfMonth),
|
||||
end: dayjs(new Date()),
|
||||
},
|
||||
whereConditional
|
||||
),
|
||||
EventsInsights.getRescheduledEventsInTimeRange(
|
||||
{
|
||||
start: dayjs(firstDateOfMonth),
|
||||
end: dayjs(new Date()),
|
||||
},
|
||||
whereConditional
|
||||
),
|
||||
EventsInsights.getCancelledEventsInTimeRange(
|
||||
{
|
||||
start: dayjs(firstDateOfMonth),
|
||||
end: dayjs(new Date()),
|
||||
},
|
||||
whereConditional
|
||||
),
|
||||
]);
|
||||
|
||||
EventData["Created"] = promisesResult[0];
|
||||
EventData["Completed"] = promisesResult[1];
|
||||
EventData["Rescheduled"] = promisesResult[2];
|
||||
EventData["Cancelled"] = promisesResult[3];
|
||||
|
||||
// Most Booked Event Type
|
||||
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
|
||||
createdAt: {
|
||||
gte: dayjs(firstDateOfMonth).startOf("day").toDate(),
|
||||
lte: dayjs(new Date()).endOf("day").toDate(),
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeams,
|
||||
},
|
||||
teamId: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const bookingsFromSelected = await prisma.bookingTimeStatus.groupBy({
|
||||
by: ["eventTypeId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: "desc",
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const eventTypeIds = bookingsFromSelected
|
||||
.filter((booking) => typeof booking.eventTypeId === "number")
|
||||
.map((booking) => booking.eventTypeId);
|
||||
|
||||
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {
|
||||
id: {
|
||||
in: eventTypeIds as number[],
|
||||
},
|
||||
};
|
||||
|
||||
const eventTypesFrom = await prisma.eventType.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamId: true,
|
||||
userId: true,
|
||||
slug: true,
|
||||
users: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: eventTypeWhereConditional,
|
||||
});
|
||||
|
||||
const eventTypeHashMap: Map<
|
||||
number,
|
||||
Prisma.EventTypeGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
title: true;
|
||||
teamId: true;
|
||||
userId: true;
|
||||
slug: true;
|
||||
users: {
|
||||
select: {
|
||||
username: true;
|
||||
};
|
||||
};
|
||||
team: {
|
||||
select: {
|
||||
slug: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>
|
||||
> = new Map();
|
||||
eventTypesFrom.forEach((eventType) => {
|
||||
eventTypeHashMap.set(eventType.id, eventType);
|
||||
});
|
||||
|
||||
EventData["mostBookedEvents"] = bookingsFromSelected.map((booking) => {
|
||||
const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0);
|
||||
if (!eventTypeSelected) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let eventSlug = "";
|
||||
if (eventTypeSelected.userId) {
|
||||
eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`;
|
||||
}
|
||||
if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) {
|
||||
eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`;
|
||||
}
|
||||
return {
|
||||
eventTypeId: booking.eventTypeId,
|
||||
eventTypeName: eventSlug,
|
||||
count: booking._count.id,
|
||||
};
|
||||
});
|
||||
|
||||
// Most booked members
|
||||
const bookingsFromTeam = await prisma.bookingTimeStatus.groupBy({
|
||||
by: ["userId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: "desc",
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const userIds = bookingsFromTeam
|
||||
.filter((booking) => typeof booking.userId === "number")
|
||||
.map((booking) => booking.userId);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
EventData["membersWithMostBookings"] = [];
|
||||
} else {
|
||||
const teamUsers = await prisma.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: userIds as number[],
|
||||
},
|
||||
},
|
||||
select: { id: true, name: true, email: true, avatar: true, username: true },
|
||||
});
|
||||
|
||||
const userHashMap = new Map();
|
||||
teamUsers.forEach((user) => {
|
||||
userHashMap.set(user.id, user);
|
||||
});
|
||||
|
||||
EventData["membersWithMostBookings"] = bookingsFromTeam.map((booking) => {
|
||||
return {
|
||||
userId: booking.userId,
|
||||
user: userHashMap.get(booking.userId),
|
||||
count: booking._count.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Send mail to all Owners and Admins
|
||||
const mailReceivers = team?.members?.filter(
|
||||
(member) => member.role === "OWNER" || member.role === "ADMIN"
|
||||
);
|
||||
|
||||
const mailsToSend = mailReceivers.map(async (receiver) => {
|
||||
const owner = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: receiver?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (owner) {
|
||||
const t = await getTranslation(owner?.locale ?? "en", "common");
|
||||
|
||||
// Only send email if user has allowed to receive monthly digest emails
|
||||
if (owner.receiveMonthlyDigestEmail) {
|
||||
await sendMonthlyDigestEmails({
|
||||
...EventData,
|
||||
admin: { email: owner?.email ?? "", name: owner?.name ?? "" },
|
||||
language: t,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(mailsToSend);
|
||||
|
||||
await delay(100); // Adjust the delay as needed to avoid rate limiting
|
||||
}
|
||||
|
||||
pageNumber++;
|
||||
}
|
||||
res.json({ ok: true });
|
||||
}
|
||||
@@ -13,14 +13,50 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
|
||||
res.write(
|
||||
renderEmail("VerifyAccountEmail", {
|
||||
renderEmail("MonthlyDigestEmail", {
|
||||
language: t,
|
||||
user: {
|
||||
name: "Pro Example",
|
||||
email: "pro@example.com",
|
||||
},
|
||||
verificationEmailLink:
|
||||
"http://localhost:3000/api/auth/verify-email?token=b91af0eee5a9a24a8d83a3d3d6a58c1606496e94ced589441649273c66100f5b",
|
||||
Created: 12,
|
||||
Completed: 13,
|
||||
Rescheduled: 14,
|
||||
Cancelled: 16,
|
||||
mostBookedEvents: [
|
||||
{
|
||||
eventTypeId: 3,
|
||||
eventTypeName: "Test1",
|
||||
count: 3,
|
||||
},
|
||||
{
|
||||
eventTypeId: 4,
|
||||
eventTypeName: "Test2",
|
||||
count: 5,
|
||||
},
|
||||
],
|
||||
membersWithMostBookings: [
|
||||
{
|
||||
userId: 4,
|
||||
user: {
|
||||
id: 4,
|
||||
name: "User1 name",
|
||||
email: "email.com",
|
||||
avatar: "none",
|
||||
username: "User1",
|
||||
},
|
||||
count: 4,
|
||||
},
|
||||
{
|
||||
userId: 6,
|
||||
user: {
|
||||
id: 6,
|
||||
name: "User2 name",
|
||||
email: "email2.com",
|
||||
avatar: "none",
|
||||
username: "User2",
|
||||
},
|
||||
count: 8,
|
||||
},
|
||||
],
|
||||
admin: { email: "admin.com", name: "admin" },
|
||||
team: { name: "Team1", id: 4 },
|
||||
})
|
||||
);
|
||||
res.end();
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const appCredentialWebhookRequestBodySchema = z.object({
|
||||
// UserId of the cal.com user
|
||||
userId: z.number().int(),
|
||||
appSlug: z.string(),
|
||||
// Keys should be AES256 encrypted with the CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY
|
||||
keys: z.string(),
|
||||
});
|
||||
/** */
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!APP_CREDENTIAL_SHARING_ENABLED) {
|
||||
return res.status(403).json({ message: "Credential sharing is not enabled" });
|
||||
}
|
||||
|
||||
if (
|
||||
req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !==
|
||||
process.env.CALCOM_WEBHOOK_SECRET
|
||||
) {
|
||||
return res.status(403).json({ message: "Invalid webhook secret" });
|
||||
}
|
||||
|
||||
const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
const app = await prisma.app.findUnique({
|
||||
where: { slug: reqBody.appSlug },
|
||||
select: { dirName: true },
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
return res.status(404).json({ message: "App not found" });
|
||||
}
|
||||
|
||||
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
|
||||
|
||||
if (!appMetadata) {
|
||||
return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" });
|
||||
}
|
||||
|
||||
const keys = JSON.parse(
|
||||
symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
|
||||
);
|
||||
|
||||
// INFO: Can't use prisma upsert as we don't know the id of the credential
|
||||
const appCredential = await prisma.credential.findFirst({
|
||||
where: {
|
||||
userId: reqBody.userId,
|
||||
appId: appMetadata.slug,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (appCredential) {
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: appCredential.id,
|
||||
},
|
||||
data: {
|
||||
key: keys,
|
||||
},
|
||||
});
|
||||
return res.status(200).json({ message: `Credentials updated for userId: ${reqBody.userId}` });
|
||||
} else {
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
key: keys,
|
||||
userId: reqBody.userId,
|
||||
appId: appMetadata.slug,
|
||||
type: appMetadata.type,
|
||||
},
|
||||
});
|
||||
return res.status(200).json({ message: `Credentials created for 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 { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
|
||||
import { getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
|
||||
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const slug = searchParams?.get("slug") as string;
|
||||
@@ -36,11 +36,4 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
|
||||
|
||||
SetupInformation.PageWrapper = PageWrapper;
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
return {
|
||||
paths: [],
|
||||
fallback: "blocking",
|
||||
};
|
||||
};
|
||||
|
||||
export { getStaticProps };
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,35 +1,13 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useReducer, useState } from "react";
|
||||
import z from "zod";
|
||||
import { useReducer } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
||||
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import type { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProps";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
EmptyScreen,
|
||||
List,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
ShellSubHeading,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
Dropdown,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
import { Button, EmptyScreen, AppSkeletonLoader as SkeletonLoader, ShellSubHeading } from "@calcom/ui";
|
||||
import type { LucideIcon } from "@calcom/ui/components/icon";
|
||||
import {
|
||||
BarChart,
|
||||
@@ -38,231 +16,24 @@ import {
|
||||
CreditCard,
|
||||
Grid,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Share2,
|
||||
Trash,
|
||||
Video,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import AppListCard from "@components/AppListCard";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { AppList } from "@components/apps/AppList";
|
||||
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
||||
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
|
||||
|
||||
function ConnectOrDisconnectIntegrationMenuItem(props: {
|
||||
credentialId: number;
|
||||
type: App["type"];
|
||||
isGlobal?: boolean;
|
||||
installed?: boolean;
|
||||
invalidCredentialIds?: number[];
|
||||
teamId?: number;
|
||||
handleDisconnect: (credentialId: number, teamId?: number) => void;
|
||||
}) {
|
||||
const { type, credentialId, isGlobal, installed, handleDisconnect, teamId } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const handleOpenChange = () => {
|
||||
utils.viewer.integrations.invalidate();
|
||||
};
|
||||
|
||||
if (credentialId || type === "stripe_payment" || isGlobal) {
|
||||
return (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => handleDisconnect(credentialId, teamId)}
|
||||
disabled={isGlobal}
|
||||
StartIcon={Trash}>
|
||||
{t("remove_app")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (!installed) {
|
||||
return (
|
||||
<div className="flex items-center truncate">
|
||||
<Alert severity="warning" title={t("not_installed")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
render={(buttonProps) => (
|
||||
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
|
||||
{t("install")}
|
||||
</Button>
|
||||
)}
|
||||
onChanged={handleOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntegrationsContainerProps {
|
||||
variant?: AppCategories;
|
||||
exclude?: AppCategories[];
|
||||
handleDisconnect: (credentialId: number) => void;
|
||||
}
|
||||
|
||||
interface IntegrationsListProps {
|
||||
variant?: IntegrationsContainerProps["variant"];
|
||||
data: RouterOutputs["viewer"]["integrations"];
|
||||
handleDisconnect: (credentialId: number) => void;
|
||||
}
|
||||
|
||||
const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListProps) => {
|
||||
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
|
||||
const utils = trpc.useContext();
|
||||
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
|
||||
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const onSuccessCallback = useCallback(() => {
|
||||
setBulkUpdateModal(true);
|
||||
showToast("Default app updated successfully", "success");
|
||||
}, []);
|
||||
|
||||
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast("Default app updated successfully", "success");
|
||||
utils.viewer.getUsersDefaultConferencingApp.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(`Error: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const ChildAppCard = ({
|
||||
item,
|
||||
}: {
|
||||
item: RouterOutputs["viewer"]["integrations"]["items"][number] & {
|
||||
credentialOwner?: CredentialOwner;
|
||||
};
|
||||
}) => {
|
||||
const appSlug = item?.slug;
|
||||
const appIsDefault =
|
||||
appSlug === defaultConferencingApp?.appSlug ||
|
||||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
|
||||
return (
|
||||
<AppListCard
|
||||
key={item.name}
|
||||
description={item.description}
|
||||
title={item.name}
|
||||
logo={item.logo}
|
||||
isDefault={appIsDefault}
|
||||
shouldHighlight
|
||||
slug={item.slug}
|
||||
invalidCredential={item?.invalidCredentialIds ? item.invalidCredentialIds.length > 0 : false}
|
||||
credentialOwner={item?.credentialOwner}
|
||||
actions={
|
||||
!item.credentialOwner?.readOnly ? (
|
||||
<div className="flex justify-end">
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon={Video}
|
||||
onClick={() => {
|
||||
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
|
||||
if (locationType?.linkType === "static") {
|
||||
setLocationType({ ...locationType, slug: appSlug });
|
||||
} else {
|
||||
updateDefaultAppMutation.mutate({
|
||||
appSlug,
|
||||
});
|
||||
setBulkUpdateModal(true);
|
||||
}
|
||||
}}>
|
||||
{t("set_as_default")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<ConnectOrDisconnectIntegrationMenuItem
|
||||
credentialId={item.credentialOwner?.credentialId || item.userCredentialIds[0]}
|
||||
type={item.type}
|
||||
isGlobal={item.isGlobal}
|
||||
installed
|
||||
invalidCredentialIds={item.invalidCredentialIds}
|
||||
handleDisconnect={handleDisconnect}
|
||||
teamId={item.credentialOwner ? item.credentialOwner?.teamId : undefined}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
) : null
|
||||
}>
|
||||
<AppSettings slug={item.slug} />
|
||||
</AppListCard>
|
||||
);
|
||||
};
|
||||
|
||||
const appsWithTeamCredentials = data.items.filter((app) => app.teams.length);
|
||||
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
|
||||
const appCards = [];
|
||||
|
||||
if (app.userCredentialIds.length) {
|
||||
appCards.push(<ChildAppCard item={app} />);
|
||||
}
|
||||
for (const team of app.teams) {
|
||||
if (team) {
|
||||
appCards.push(
|
||||
<ChildAppCard
|
||||
item={{
|
||||
...app,
|
||||
credentialOwner: {
|
||||
name: team.name,
|
||||
avatar: team.logo,
|
||||
teamId: team.teamId,
|
||||
credentialId: team.credentialId,
|
||||
readOnly: !team.isAdmin,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return appCards;
|
||||
});
|
||||
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
|
||||
{data.items
|
||||
.filter((item) => item.invalidCredentialIds)
|
||||
.map((item) => {
|
||||
if (!item.teams.length) return <ChildAppCard item={item} />;
|
||||
})}
|
||||
</List>
|
||||
{locationType && (
|
||||
<AppSetDefaultLinkDialog
|
||||
locationType={locationType}
|
||||
setLocationType={() => setLocationType(undefined)}
|
||||
onSuccess={onSuccessCallback}
|
||||
/>
|
||||
)}
|
||||
|
||||
{bulkUpdateModal && (
|
||||
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const IntegrationsContainer = ({
|
||||
variant,
|
||||
exclude,
|
||||
@@ -329,7 +100,8 @@ const IntegrationsContainer = ({
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<IntegrationsList handleDisconnect={handleDisconnect} data={data} variant={variant} />
|
||||
|
||||
<AppList handleDisconnect={handleDisconnect} data={data} variant={variant} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -60,10 +60,16 @@ export default function Verify() {
|
||||
const routerQuery = useRouterQuery();
|
||||
const { t, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
|
||||
const [secondsLeft, setSecondsLeft] = useState(30);
|
||||
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery({
|
||||
stripeCustomerId,
|
||||
checkoutSessionId: sessionId,
|
||||
});
|
||||
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery(
|
||||
{
|
||||
stripeCustomerId,
|
||||
checkoutSessionId: sessionId,
|
||||
},
|
||||
{
|
||||
enabled: !!stripeCustomerId || !!sessionId,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username });
|
||||
// @note: check for t=timestamp and apply disabled state and secondsLeft accordingly
|
||||
// to avoid refresh to skip waiting 30 seconds to re-send email
|
||||
@@ -123,12 +129,12 @@ export default function Verify() {
|
||||
{hasPaymentFailed ? (
|
||||
<AlertTriangle className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
||||
) : sessionId ? (
|
||||
<Check className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
||||
<Check className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight dark:text-white" />
|
||||
) : (
|
||||
<MailOpen className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-cal my-6 text-3xl font-normal">
|
||||
<h3 className="font-cal text-inverted my-6 text-3xl font-normal dark:text-white">
|
||||
{hasPaymentFailed
|
||||
? "Your payment failed"
|
||||
: sessionId
|
||||
@@ -138,7 +144,7 @@ export default function Verify() {
|
||||
{hasPaymentFailed && (
|
||||
<p className="my-6">Your account has been created, but your premium has not been reserved.</p>
|
||||
)}
|
||||
<p>
|
||||
<p className="text-inverted dark:text-white">
|
||||
We have sent an email to <b>{customer?.email} </b>with a link to activate your account.{" "}
|
||||
{hasPaymentFailed &&
|
||||
"Once you activate your account you will be able to try purchase your premium username again or select a different one."}
|
||||
@@ -158,7 +164,7 @@ export default function Verify() {
|
||||
e.preventDefault();
|
||||
setSecondsLeft(30);
|
||||
// Update query params with t:timestamp, shallow: true doesn't re-render the page
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
const _searchParams = new URLSearchParams(searchParams.toString());
|
||||
_searchParams.set("t", `${Date.now()}`);
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
return await sendVerificationLogin(customer.email, customer.username);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
|
||||
import { SMS_REMINDER_NUMBER_FIELD, SystemField } from "@calcom/features/bookings/lib/SystemField";
|
||||
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
@@ -93,7 +94,7 @@ const querySchema = z.object({
|
||||
});
|
||||
|
||||
export default function Success(props: SuccessProps) {
|
||||
const { t, i18n } = useLocale();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const routerQuery = useRouterQuery();
|
||||
const pathname = usePathname();
|
||||
@@ -132,7 +133,9 @@ export default function Success(props: SuccessProps) {
|
||||
|
||||
const isGmail = !!attendees.find((attendee) => attendee.email.includes("gmail.com"));
|
||||
|
||||
const [is24h, setIs24h] = useState(isBrowserLocale24h());
|
||||
const [is24h, setIs24h] = useState(
|
||||
props?.userTimeFormat ? props.userTimeFormat === 24 : isBrowserLocale24h()
|
||||
);
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [date, setDate] = useState(dayjs.utc(props.bookingInfo.startTime));
|
||||
@@ -214,8 +217,10 @@ export default function Success(props: SuccessProps) {
|
||||
confirmed: !needsConfirmation,
|
||||
// TODO: Add payment details
|
||||
});
|
||||
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
|
||||
setIs24h(!!getIs24hClockFromLocalStorage());
|
||||
setDate(
|
||||
date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London")
|
||||
);
|
||||
setIs24h(props?.userTimeFormat ? props.userTimeFormat === 24 : !!getIs24hClockFromLocalStorage());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventType, needsConfirmation]);
|
||||
|
||||
@@ -353,10 +358,10 @@ export default function Success(props: SuccessProps) {
|
||||
<img src={giphyImage} alt="Gif from Giphy" />
|
||||
)}
|
||||
{!giphyImage && !needsConfirmation && !isCancelled && (
|
||||
<Check className="h-5 w-5 text-green-600" />
|
||||
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
)}
|
||||
{needsConfirmation && !isCancelled && <Calendar className="text-emphasis h-5 w-5" />}
|
||||
{isCancelled && <X className="h-5 w-5 text-red-600" />}
|
||||
{isCancelled && <X className="h-5 w-5 text-red-600 dark:text-red-200" />}
|
||||
</div>
|
||||
<div className="mb-8 mt-6 text-center last:mb-0">
|
||||
<h3
|
||||
@@ -486,10 +491,7 @@ export default function Success(props: SuccessProps) {
|
||||
: t("payment")}
|
||||
</div>
|
||||
<div className="col-span-2 mb-2 mt-3">
|
||||
{new Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: props.paymentStatus.currency,
|
||||
}).format(props.paymentStatus.amount / 100.0)}
|
||||
<Price currency={props.paymentStatus.currency} price={props.paymentStatus.amount} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1006,10 +1008,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const session = await getServerSession(context);
|
||||
let tz: string | null = null;
|
||||
let userTimeFormat: number | null = null;
|
||||
|
||||
if (session) {
|
||||
const user = await ssr.viewer.me.fetch();
|
||||
tz = user.timeZone;
|
||||
userTimeFormat = user.timeFormat;
|
||||
}
|
||||
|
||||
const parsedQuery = querySchema.safeParse(context.query);
|
||||
@@ -1150,6 +1154,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
bookingInfo,
|
||||
paymentStatus: payment,
|
||||
...(tz && { tz }),
|
||||
userTimeFormat,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -449,7 +449,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
availability={availability}
|
||||
isUpdateMutationLoading={updateMutation.isLoading}
|
||||
formMethods={formMethods}
|
||||
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||
// disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||
disableBorder={true}
|
||||
currentUserMembership={currentUserMembership}
|
||||
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
|
||||
<Form
|
||||
|
||||
@@ -127,7 +127,7 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
||||
readOnly={events[0].metadata.readOnly}
|
||||
/>
|
||||
) : (
|
||||
<CreateFirstEventTypeView />
|
||||
<CreateFirstEventTypeView slug={eventTypeGroups[0].profile.slug ?? ""} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -363,7 +363,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
}, []);
|
||||
|
||||
if (!types.length) {
|
||||
return group.teamId ? <EmptyEventTypeList group={group} /> : <CreateFirstEventTypeView />;
|
||||
return group.teamId ? (
|
||||
<EmptyEventTypeList group={group} />
|
||||
) : (
|
||||
<CreateFirstEventTypeView slug={group.profile.slug ?? ""} />
|
||||
);
|
||||
}
|
||||
|
||||
const firstItem = types[0];
|
||||
@@ -764,7 +768,7 @@ const EventTypeListHeading = ({
|
||||
);
|
||||
};
|
||||
|
||||
const CreateFirstEventTypeView = () => {
|
||||
const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
@@ -774,7 +778,7 @@ const CreateFirstEventTypeView = () => {
|
||||
description={t("new_event_type_description")}
|
||||
className="mb-16"
|
||||
buttonRaw={
|
||||
<Button href="?dialog=new" variant="button">
|
||||
<Button href={`?dialog=new&eventPage=${slug}`} variant="button">
|
||||
{t("create")}
|
||||
</Button>
|
||||
}
|
||||
@@ -915,7 +919,7 @@ const Main = ({
|
||||
) : group.teamId ? (
|
||||
<EmptyEventTypeList group={group} />
|
||||
) : (
|
||||
<CreateFirstEventTypeView />
|
||||
<CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
@@ -931,7 +935,7 @@ const Main = ({
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
|
||||
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />}
|
||||
<EventTypeEmbedDialog />
|
||||
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
|
||||
</>
|
||||
|
||||
@@ -20,19 +20,6 @@ import { RefreshCcw, UserPlus, Users } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
const Heading = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="min-w-52 hidden md:block">
|
||||
<h3 className="font-cal max-w-28 sm:max-w-72 md:max-w-80 text-emphasis truncate text-xl font-semibold tracking-wide xl:max-w-full">
|
||||
{t("insights")}
|
||||
</h3>
|
||||
<p className="text-default mt-2 hidden text-sm md:block">{t("insights_subtitle")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function InsightsPage() {
|
||||
const { t } = useLocale();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
@@ -79,10 +66,6 @@ export default function InsightsPage() {
|
||||
<></>
|
||||
) : (
|
||||
<FiltersProvider>
|
||||
<div className="ml-auto mt-0">
|
||||
<Heading />
|
||||
</div>
|
||||
|
||||
<Filters />
|
||||
|
||||
<div className="mb-4 space-y-6">
|
||||
|
||||
@@ -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)}>
|
||||
<div>
|
||||
<h2 className="font-medium">{title}</h2>
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">{children}</div>
|
||||
</section>
|
||||
<hr className="border-subtle" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -45,14 +44,16 @@ const BillingView = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("billing")} description={t("manage_billing_description")} />
|
||||
<div className="space-y-6 text-sm sm:space-y-8">
|
||||
<Meta title={t("billing")} description={t("manage_billing_description")} borderInShellHeader={true} />
|
||||
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-8 text-sm sm:space-y-8">
|
||||
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
|
||||
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
|
||||
{t("billing_portal")}
|
||||
</Button>
|
||||
</CtaRow>
|
||||
|
||||
<hr className="border-subtle" />
|
||||
|
||||
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
|
||||
<Button color="secondary" onClick={onContactSupportClick}>
|
||||
{t("contact_support")}
|
||||
|
||||
@@ -14,12 +14,25 @@ import {
|
||||
DialogContent,
|
||||
EmptyScreen,
|
||||
Meta,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
import { Link as LinkIcon, Plus } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const ApiKeysView = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -39,49 +52,57 @@ const ApiKeysView = () => {
|
||||
setApiKeyToEdit(undefined);
|
||||
setApiKeyModal(true);
|
||||
}}>
|
||||
{t("new_api_key")}
|
||||
{t("add")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<SkeletonLoader
|
||||
title={t("api_keys")}
|
||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title={t("api_keys")}
|
||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||
CTA={<NewApiKeyButton />}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
|
||||
<LicenseRequired>
|
||||
<>
|
||||
{isLoading && <SkeletonLoader />}
|
||||
<div>
|
||||
{isLoading ? null : data?.length ? (
|
||||
<>
|
||||
<div className="border-subtle mb-8 mt-6 rounded-md border">
|
||||
{data.map((apiKey, index) => (
|
||||
<ApiKeyListItem
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
lastItem={data.length === index + 1}
|
||||
onEditClick={() => {
|
||||
setApiKeyToEdit(apiKey);
|
||||
setApiKeyModal(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<NewApiKeyButton />
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon={LinkIcon}
|
||||
headline={t("create_first_api_key")}
|
||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||
buttonRaw={<NewApiKeyButton />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
{data?.length ? (
|
||||
<>
|
||||
<div className="border-subtle rounded-b-md border border-t-0">
|
||||
{data.map((apiKey, index) => (
|
||||
<ApiKeyListItem
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
lastItem={data.length === index + 1}
|
||||
onEditClick={() => {
|
||||
setApiKeyToEdit(apiKey);
|
||||
setApiKeyModal(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon={LinkIcon}
|
||||
headline={t("create_first_api_key")}
|
||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||
className="rounded-b-md rounded-t-none border-t-0"
|
||||
buttonRaw={<NewApiKeyButton />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LicenseRequired>
|
||||
|
||||
<Dialog open={apiKeyModal} onOpenChange={setApiKeyModal}>
|
||||
|
||||
@@ -3,8 +3,10 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
||||
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
@@ -12,6 +14,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||
import type { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -22,7 +25,7 @@ import {
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
Switch,
|
||||
SettingsToggle,
|
||||
UpgradeTeamsBadge,
|
||||
} from "@calcom/ui";
|
||||
|
||||
@@ -31,9 +34,9 @@ import PageWrapper from "@components/PageWrapper";
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} />
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<div className="flex items-center">
|
||||
<Meta title={title} description={description} borderInShellHeader={false} />
|
||||
<div className="border-subtle mt-6 space-y-6 rounded-t-xl border border-b-0 px-4 py-6 sm:px-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
@@ -44,49 +47,83 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
||||
</div>
|
||||
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
</div>
|
||||
<div className="rounded-b-xl">
|
||||
<SectionBottomActions align="end">
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
</SectionBottomActions>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const AppearanceView = () => {
|
||||
const DEFAULT_LIGHT_BRAND_COLOR = "#292929";
|
||||
const DEFAULT_DARK_BRAND_COLOR = "#fafafa";
|
||||
|
||||
const AppearanceView = ({
|
||||
user,
|
||||
hasPaidPlan,
|
||||
}: {
|
||||
user: RouterOutputs["viewer"]["me"];
|
||||
hasPaidPlan: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const [darkModeError, setDarkModeError] = useState(false);
|
||||
const [lightModeError, setLightModeError] = useState(false);
|
||||
const [isCustomBrandColorChecked, setIsCustomBranColorChecked] = useState(
|
||||
user?.brandColor !== DEFAULT_LIGHT_BRAND_COLOR || user?.darkBrandColor !== DEFAULT_DARK_BRAND_COLOR
|
||||
);
|
||||
const [hideBrandingValue, setHideBrandingValue] = useState(user?.hideBranding ?? false);
|
||||
|
||||
const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan();
|
||||
|
||||
const formMethods = useForm({
|
||||
const userThemeFormMethods = useForm({
|
||||
defaultValues: {
|
||||
theme: user?.theme,
|
||||
brandColor: user?.brandColor || "#292929",
|
||||
darkBrandColor: user?.darkBrandColor || "#fafafa",
|
||||
hideBranding: user?.hideBranding,
|
||||
metadata: user?.metadata as z.infer<typeof userMetadata>,
|
||||
theme: user.theme,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedTheme = formMethods.watch("theme");
|
||||
const {
|
||||
formState: { isSubmitting: isUserThemeSubmitting, isDirty: isUserThemeDirty },
|
||||
reset: resetUserThemeReset,
|
||||
} = userThemeFormMethods;
|
||||
|
||||
const bookerLayoutFormMethods = useForm({
|
||||
defaultValues: {
|
||||
metadata: user.metadata as z.infer<typeof userMetadata>,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
formState: { isSubmitting: isBookerLayoutFormSubmitting, isDirty: isBookerLayoutFormDirty },
|
||||
reset: resetBookerLayoutThemeReset,
|
||||
} = bookerLayoutFormMethods;
|
||||
|
||||
const brandColorsFormMethods = useForm({
|
||||
defaultValues: {
|
||||
brandColor: user.brandColor || DEFAULT_LIGHT_BRAND_COLOR,
|
||||
darkBrandColor: user.darkBrandColor || DEFAULT_DARK_BRAND_COLOR,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
formState: { isSubmitting: isBrandColorsFormSubmitting, isDirty: isBrandColorsFormDirty },
|
||||
reset: resetBrandColorsThemeReset,
|
||||
} = brandColorsFormMethods;
|
||||
|
||||
const selectedTheme = userThemeFormMethods.watch("theme");
|
||||
const selectedThemeIsDark =
|
||||
selectedTheme === "dark" ||
|
||||
(selectedTheme === "" &&
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark"));
|
||||
|
||||
const {
|
||||
formState: { isSubmitting, isDirty },
|
||||
reset,
|
||||
} = formMethods;
|
||||
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
await utils.viewer.me.invalidate();
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
reset(data);
|
||||
resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor });
|
||||
resetBookerLayoutThemeReset({ metadata: data.metadata });
|
||||
resetUserThemeReset({ theme: data.theme });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message) {
|
||||
@@ -97,136 +134,180 @@ const AppearanceView = () => {
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || isTeamPlanStatusLoading)
|
||||
return <SkeletonLoader title={t("appearance")} description={t("appearance_description")} />;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const isDisabled = isSubmitting || !isDirty;
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={formMethods}
|
||||
handleSubmit={(values) => {
|
||||
const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null);
|
||||
if (layoutError) throw new Error(t(layoutError));
|
||||
|
||||
mutation.mutate({
|
||||
...values,
|
||||
// Radio values don't support null as values, therefore we convert an empty string
|
||||
// back to null here.
|
||||
theme: values.theme || null,
|
||||
});
|
||||
}}>
|
||||
<Meta title={t("appearance")} description={t("appearance_description")} />
|
||||
<div className="mb-6 flex items-center text-sm">
|
||||
<div>
|
||||
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
|
||||
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
|
||||
<div>
|
||||
<p className="text-default font-semibold">{t("theme")}</p>
|
||||
<p className="text-default text-base font-semibold">{t("theme")}</p>
|
||||
<p className="text-default">{t("theme_applies_note")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<ThemeLabel
|
||||
variant="system"
|
||||
value={null}
|
||||
label={t("theme_system")}
|
||||
defaultChecked={user.theme === null}
|
||||
register={formMethods.register}
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="light"
|
||||
value="light"
|
||||
label={t("light")}
|
||||
defaultChecked={user.theme === "light"}
|
||||
register={formMethods.register}
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="dark"
|
||||
value="dark"
|
||||
label={t("dark")}
|
||||
defaultChecked={user.theme === "dark"}
|
||||
register={formMethods.register}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-subtle my-8 border [&:has(+hr)]:hidden" />
|
||||
<BookerLayoutSelector
|
||||
isDark={selectedThemeIsDark}
|
||||
name="metadata.defaultBookerLayouts"
|
||||
title={t("bookerlayout_user_settings_title")}
|
||||
description={t("bookerlayout_user_settings_description")}
|
||||
/>
|
||||
|
||||
<hr className="border-subtle my-8 border" />
|
||||
<div className="mb-6 flex items-center text-sm">
|
||||
<div>
|
||||
<p className="text-default font-semibold">{t("custom_brand_colors")}</p>
|
||||
<p className="text-default mt-0.5 leading-5">{t("customize_your_brand_colors")}</p>
|
||||
<Form
|
||||
form={userThemeFormMethods}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
// Radio values don't support null as values, therefore we convert an empty string
|
||||
// back to null here.
|
||||
theme: values.theme || null,
|
||||
});
|
||||
}}>
|
||||
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
|
||||
<ThemeLabel
|
||||
variant="system"
|
||||
value={null}
|
||||
label={t("theme_system")}
|
||||
defaultChecked={user.theme === null}
|
||||
register={userThemeFormMethods.register}
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="light"
|
||||
value="light"
|
||||
label={t("light")}
|
||||
defaultChecked={user.theme === "light"}
|
||||
register={userThemeFormMethods.register}
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="dark"
|
||||
value="dark"
|
||||
label={t("dark")}
|
||||
defaultChecked={user.theme === "dark"}
|
||||
register={userThemeFormMethods.register}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SectionBottomActions className="mb-6" align="end">
|
||||
<Button
|
||||
disabled={isUserThemeSubmitting || !isUserThemeDirty}
|
||||
type="submit"
|
||||
data-testid="update-theme-btn"
|
||||
color="primary">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</Form>
|
||||
|
||||
<div className="block justify-between sm:flex">
|
||||
<Controller
|
||||
name="brandColor"
|
||||
control={formMethods.control}
|
||||
defaultValue={user.brandColor}
|
||||
render={() => (
|
||||
<div>
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
||||
<ColorPicker
|
||||
<Form
|
||||
form={bookerLayoutFormMethods}
|
||||
handleSubmit={(values) => {
|
||||
const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null);
|
||||
if (layoutError) {
|
||||
showToast(t(layoutError), "error");
|
||||
return;
|
||||
} else {
|
||||
mutation.mutate(values);
|
||||
}
|
||||
}}>
|
||||
<BookerLayoutSelector
|
||||
isDark={selectedThemeIsDark}
|
||||
name="metadata.defaultBookerLayouts"
|
||||
title={t("bookerlayout_user_settings_title")}
|
||||
description={t("bookerlayout_user_settings_description")}
|
||||
isDisabled={isBookerLayoutFormSubmitting || !isBookerLayoutFormDirty}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
<Form
|
||||
form={brandColorsFormMethods}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate(values);
|
||||
}}>
|
||||
<div className="mt-6">
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("custom_brand_colors")}
|
||||
description={t("customize_your_brand_colors")}
|
||||
checked={isCustomBrandColorChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsCustomBranColorChecked(checked);
|
||||
if (!checked) {
|
||||
mutation.mutate({
|
||||
brandColor: DEFAULT_LIGHT_BRAND_COLOR,
|
||||
darkBrandColor: DEFAULT_DARK_BRAND_COLOR,
|
||||
});
|
||||
}
|
||||
}}
|
||||
childrenClassName="lg:ml-0"
|
||||
switchContainerClassName={classNames(
|
||||
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
|
||||
isCustomBrandColorChecked && "rounded-b-none"
|
||||
)}>
|
||||
<div className="border-subtle flex flex-col gap-6 border-x p-6">
|
||||
<Controller
|
||||
name="brandColor"
|
||||
control={brandColorsFormMethods.control}
|
||||
defaultValue={user.brandColor}
|
||||
resetDefaultValue="#292929"
|
||||
onChange={(value) => {
|
||||
if (!checkWCAGContrastColor("#ffffff", value)) {
|
||||
setLightModeError(true);
|
||||
} else {
|
||||
setLightModeError(false);
|
||||
}
|
||||
formMethods.setValue("brandColor", value, { shouldDirty: true });
|
||||
}}
|
||||
render={() => (
|
||||
<div>
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={user.brandColor}
|
||||
resetDefaultValue="#292929"
|
||||
onChange={(value) => {
|
||||
try {
|
||||
checkWCAGContrastColor("#ffffff", value);
|
||||
setLightModeError(false);
|
||||
brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true });
|
||||
} catch (err) {
|
||||
setLightModeError(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{lightModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="darkBrandColor"
|
||||
control={formMethods.control}
|
||||
defaultValue={user.darkBrandColor}
|
||||
render={() => (
|
||||
<div className="mt-6 sm:mt-0">
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
||||
<ColorPicker
|
||||
|
||||
<Controller
|
||||
name="darkBrandColor"
|
||||
control={brandColorsFormMethods.control}
|
||||
defaultValue={user.darkBrandColor}
|
||||
resetDefaultValue="#fafafa"
|
||||
onChange={(value) => {
|
||||
if (!checkWCAGContrastColor("#101010", value)) {
|
||||
setDarkModeError(true);
|
||||
} else {
|
||||
setDarkModeError(false);
|
||||
}
|
||||
formMethods.setValue("darkBrandColor", value, { shouldDirty: true });
|
||||
}}
|
||||
render={() => (
|
||||
<div className="mt-6 sm:mt-0">
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={user.darkBrandColor}
|
||||
resetDefaultValue="#fafafa"
|
||||
onChange={(value) => {
|
||||
try {
|
||||
checkWCAGContrastColor("#101010", value);
|
||||
setDarkModeError(false);
|
||||
brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true });
|
||||
} catch (err) {
|
||||
setDarkModeError(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{darkModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{darkModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
|
||||
color="primary"
|
||||
type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
) : null}
|
||||
{lightModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Form>
|
||||
|
||||
{/* TODO future PR to preview brandColors */}
|
||||
{/* <Button
|
||||
color="secondary"
|
||||
@@ -235,51 +316,37 @@ const AppearanceView = () => {
|
||||
onClick={() => window.open(`${WEBAPP_URL}/${user.username}/${user.eventTypes[0].title}`, "_blank")}>
|
||||
Preview
|
||||
</Button> */}
|
||||
<hr className="border-subtle my-8 border" />
|
||||
<Controller
|
||||
name="hideBranding"
|
||||
control={formMethods.control}
|
||||
defaultValue={user.hideBranding}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<div className="flex w-full text-sm">
|
||||
<div className="mr-1 flex-grow">
|
||||
<div className="flex items-center">
|
||||
<p className="text-default font-semibold ltr:mr-2 rtl:ml-2">
|
||||
{t("disable_cal_branding", { appName: APP_NAME })}
|
||||
</p>
|
||||
<UpgradeTeamsBadge />
|
||||
</div>
|
||||
<p className="text-default mt-0.5">{t("removes_cal_branding", { appName: APP_NAME })}</p>
|
||||
</div>
|
||||
<div className="flex-none">
|
||||
<Switch
|
||||
id="hideBranding"
|
||||
disabled={!hasPaidPlan}
|
||||
onCheckedChange={(checked) =>
|
||||
formMethods.setValue("hideBranding", checked, { shouldDirty: true })
|
||||
}
|
||||
checked={hasPaidPlan ? value : false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("disable_cal_branding", { appName: APP_NAME })}
|
||||
disabled={!hasPaidPlan || mutation?.isLoading}
|
||||
description={t("removes_cal_branding", { appName: APP_NAME })}
|
||||
checked={hasPaidPlan ? hideBrandingValue : false}
|
||||
Badge={<UpgradeTeamsBadge />}
|
||||
onCheckedChange={(checked) => {
|
||||
setHideBrandingValue(checked);
|
||||
mutation.mutate({ hideBranding: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
/>
|
||||
<Button
|
||||
disabled={isDisabled}
|
||||
type="submit"
|
||||
loading={mutation.isLoading}
|
||||
color="primary"
|
||||
className="mt-8"
|
||||
data-testid="update-theme-btn">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AppearanceView.getLayout = getLayout;
|
||||
AppearanceView.PageWrapper = PageWrapper;
|
||||
const AppearanceViewWrapper = () => {
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan();
|
||||
|
||||
export default AppearanceView;
|
||||
const { t } = useLocale();
|
||||
|
||||
if (isLoading || isTeamPlanStatusLoading || !user)
|
||||
return <SkeletonLoader title={t("appearance")} description={t("appearance_description")} />;
|
||||
|
||||
return <AppearanceView user={user} hasPaidPlan={hasPaidPlan} />;
|
||||
};
|
||||
|
||||
AppearanceViewWrapper.getLayout = getLayout;
|
||||
AppearanceViewWrapper.PageWrapper = PageWrapper;
|
||||
|
||||
export default AppearanceViewWrapper;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useState, useEffect } from "react";
|
||||
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -34,13 +35,13 @@ import PageWrapper from "@components/PageWrapper";
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<div className="border-subtle mt-8 space-y-6 rounded-xl border px-4 py-6 sm:px-6">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
@@ -65,6 +66,21 @@ const CalendarsView = () => {
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||
|
||||
const [selectedDestinationCalendarOption, setSelectedDestinationCalendar] = useState<{
|
||||
integration: string;
|
||||
externalId: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (query?.data?.destinationCalendar) {
|
||||
setSelectedDestinationCalendar({
|
||||
integration: query.data.destinationCalendar.integration,
|
||||
externalId: query.data.destinationCalendar.externalId,
|
||||
});
|
||||
}
|
||||
}, [query?.isLoading, query?.data?.destinationCalendar]);
|
||||
|
||||
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
|
||||
async onSettled() {
|
||||
await utils.viewer.connectedCalendars.invalidate();
|
||||
@@ -79,43 +95,58 @@ const CalendarsView = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("calendars")} description={t("calendars_description")} CTA={<AddCalendarButton />} />
|
||||
<Meta
|
||||
title={t("calendars")}
|
||||
description={t("calendars_description")}
|
||||
CTA={<AddCalendarButton />}
|
||||
borderInShellHeader={false}
|
||||
/>
|
||||
<QueryCell
|
||||
query={query}
|
||||
customLoader={<SkeletonLoader />}
|
||||
success={({ data }) => {
|
||||
const isDestinationUpdateBtnDisabled =
|
||||
selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId;
|
||||
return data.connectedCalendars.length ? (
|
||||
<div>
|
||||
<div className="bg-muted border-subtle mt-4 flex space-x-4 rounded-md p-2 sm:mx-0 sm:p-10 md:border md:p-6 xl:mt-0">
|
||||
<div className=" bg-default border-subtle flex h-9 w-9 items-center justify-center rounded-md border-2 p-[6px]">
|
||||
<Calendar className="text-default h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col space-y-3">
|
||||
<div>
|
||||
<h4 className=" text-emphasis pb-2 text-base font-semibold leading-5">
|
||||
{t("add_to_calendar")}
|
||||
</h4>
|
||||
<p className=" text-default text-sm leading-5">
|
||||
<Trans i18nKey="add_to_calendar_description">
|
||||
Where to add events when you re booked. You can override this on a per-event basis in
|
||||
advanced settings in the event type.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<DestinationCalendarSelector
|
||||
hidePlaceholder
|
||||
value={data.destinationCalendar?.externalId}
|
||||
onChange={mutation.mutate}
|
||||
isLoading={mutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
|
||||
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
|
||||
{t("add_to_calendar")}
|
||||
</h2>
|
||||
<p className="text-default text-sm">{t("add_to_calendar_description")}</p>
|
||||
</div>
|
||||
<h4 className="text-emphasis mt-12 text-base font-semibold leading-5">
|
||||
{t("check_for_conflicts")}
|
||||
</h4>
|
||||
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
|
||||
<List className="flex flex-col gap-6" noBorderTreatment>
|
||||
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
|
||||
<DestinationCalendarSelector
|
||||
hidePlaceholder
|
||||
value={selectedDestinationCalendarOption?.externalId}
|
||||
onChange={(option) => {
|
||||
setSelectedDestinationCalendar(option);
|
||||
}}
|
||||
isLoading={mutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
loading={mutation.isLoading}
|
||||
disabled={isDestinationUpdateBtnDisabled}
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
if (selectedDestinationCalendarOption) mutation.mutate(selectedDestinationCalendarOption);
|
||||
}}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
|
||||
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
|
||||
<h4 className="text-emphasis text-base font-semibold leading-5">
|
||||
{t("check_for_conflicts")}
|
||||
</h4>
|
||||
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
|
||||
</div>
|
||||
|
||||
<List
|
||||
className="border-subtle flex flex-col gap-6 rounded-b-xl border border-t-0 p-6"
|
||||
noBorderTreatment>
|
||||
{data.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.error && item.error.message && (
|
||||
@@ -207,6 +238,7 @@ const CalendarsView = () => {
|
||||
description={t("no_calendar_installed_description")}
|
||||
buttonText={t("add_a_calendar")}
|
||||
buttonOnClick={() => router.push("/apps/categories/calendar")}
|
||||
className="mt-6"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -1,39 +1,22 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useReducer } from "react";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
|
||||
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
||||
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
|
||||
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
DropdownMenuTrigger,
|
||||
List,
|
||||
Meta,
|
||||
showToast,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
import { AlertCircle, MoreHorizontal, Trash, Video, Plus } from "@calcom/ui/components/icon";
|
||||
import { Button, EmptyScreen, Meta, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
import { Calendar, Plus } from "@calcom/ui/components/icon";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import AppListCard from "@components/AppListCard";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { AppList } from "@components/apps/AppList";
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} />
|
||||
<div className="divide-subtle mb-8 mt-6 space-y-6">
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
@@ -45,162 +28,93 @@ const AddConferencingButton = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button color="secondary" StartIcon={Plus} href="/apps/categories/video">
|
||||
{t("add_conferencing_app")}
|
||||
</Button>
|
||||
</>
|
||||
<Button color="secondary" StartIcon={Plus} href="/apps/categories/conferencing">
|
||||
{t("add")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
type ModalState = {
|
||||
isOpen: boolean;
|
||||
credentialId: null | number;
|
||||
};
|
||||
|
||||
const ConferencingLayout = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const { data: defaultConferencingApp, isLoading: defaultConferencingAppLoading } =
|
||||
trpc.viewer.getUsersDefaultConferencingApp.useQuery();
|
||||
const [modal, updateModal] = useReducer(
|
||||
(data: ModalState, partialData: Partial<ModalState>) => ({ ...data, ...partialData }),
|
||||
{
|
||||
isOpen: false,
|
||||
credentialId: null,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: apps, isLoading } = trpc.viewer.integrations.useQuery({
|
||||
const query = trpc.viewer.integrations.useQuery({
|
||||
variant: "conferencing",
|
||||
onlyInstalled: true,
|
||||
});
|
||||
const deleteAppMutation = trpc.viewer.deleteCredential.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast("Integration deleted successfully", "success");
|
||||
utils.viewer.integrations.invalidate({ variant: "conferencing", onlyInstalled: true });
|
||||
setDeleteAppModal(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast("Error deleting app", "error");
|
||||
setDeleteAppModal(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSuccessCallback = useCallback(() => {
|
||||
setBulkUpdateModal(true);
|
||||
showToast("Default app updated successfully", "success");
|
||||
}, []);
|
||||
const handleModelClose = () => {
|
||||
updateModal({ isOpen: false, credentialId: null });
|
||||
};
|
||||
|
||||
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.getUsersDefaultConferencingApp.invalidate();
|
||||
onSuccessCallback();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(`Error: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const [deleteAppModal, setDeleteAppModal] = useState(false);
|
||||
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
|
||||
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [deleteCredentialId, setDeleteCredentialId] = useState<number>(0);
|
||||
|
||||
if (isLoading || defaultConferencingAppLoading)
|
||||
return <SkeletonLoader title={t("conferencing")} description={t("conferencing_description")} />;
|
||||
const handleDisconnect = (credentialId: number) => {
|
||||
updateModal({ isOpen: true, credentialId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default w-full sm:mx-0 xl:mt-0">
|
||||
<Meta
|
||||
title={t("conferencing")}
|
||||
description={t("conferencing_description")}
|
||||
CTA={<AddConferencingButton />}
|
||||
/>
|
||||
<List>
|
||||
{apps?.items &&
|
||||
apps.items
|
||||
.map((app) => ({ ...app, title: app.title || app.name }))
|
||||
.map((app) => {
|
||||
const appSlug = app?.slug;
|
||||
const appIsDefault =
|
||||
appSlug === defaultConferencingApp?.appSlug ||
|
||||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug); // Default to cal video if the user doesnt have it set (we do this on new account creation but not old)
|
||||
<>
|
||||
<div className="bg-default w-full sm:mx-0 xl:mt-0">
|
||||
<Meta
|
||||
title={t("conferencing")}
|
||||
description={t("conferencing_description")}
|
||||
CTA={<AddConferencingButton />}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<QueryCell
|
||||
query={query}
|
||||
customLoader={
|
||||
<SkeletonLoader title={t("conferencing")} description={t("conferencing_description")} />
|
||||
}
|
||||
success={({ data }) => {
|
||||
console.log(data);
|
||||
if (!data.items.length) {
|
||||
return (
|
||||
<AppListCard
|
||||
description={app.description}
|
||||
title={app.title}
|
||||
logo={app.logo}
|
||||
key={app.title}
|
||||
isDefault={appIsDefault} // @TODO: Handle when a user doesnt have this value set
|
||||
actions={
|
||||
<div>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!appIsDefault && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon={Video}
|
||||
onClick={() => {
|
||||
const locationType = getEventLocationTypeFromApp(
|
||||
app?.locationOption?.value ?? ""
|
||||
);
|
||||
if (locationType?.linkType === "static") {
|
||||
setLocationType({ ...locationType, slug: appSlug });
|
||||
} else {
|
||||
updateDefaultAppMutation.mutate({
|
||||
appSlug,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
{t("set_as_default")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
disabled={app.isGlobal}
|
||||
StartIcon={Trash}
|
||||
onClick={() => {
|
||||
setDeleteCredentialId(app.userCredentialIds[0]);
|
||||
setDeleteAppModal(true);
|
||||
}}>
|
||||
{t("remove_app")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<EmptyScreen
|
||||
Icon={Calendar}
|
||||
headline={t("no_category_apps", {
|
||||
category: t("conferencing").toLowerCase(),
|
||||
})}
|
||||
description={t("no_category_apps_description_conferencing")}
|
||||
buttonRaw={
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="connect-conferencing-apps"
|
||||
href="/apps/categories/conferencing">
|
||||
{t("connect_conference_apps")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
|
||||
<Dialog open={deleteAppModal} onOpenChange={setDeleteAppModal}>
|
||||
<DialogContent
|
||||
title={t("Remove app")}
|
||||
description={t("are_you_sure_you_want_to_remove_this_app")}
|
||||
type="confirmation"
|
||||
Icon={AlertCircle}>
|
||||
<DialogFooter>
|
||||
<Button color="primary" onClick={() => deleteAppMutation.mutate({ id: deleteCredentialId })}>
|
||||
{t("yes_remove_app")}
|
||||
</Button>
|
||||
<DialogClose />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{locationType && (
|
||||
<AppSetDefaultLinkDialog
|
||||
locationType={locationType}
|
||||
setLocationType={setLocationType}
|
||||
onSuccess={onSuccessCallback}
|
||||
}
|
||||
return (
|
||||
<AppList
|
||||
listClassName="rounded-xl rounded-t-none border-t-0"
|
||||
handleDisconnect={handleDisconnect}
|
||||
data={data}
|
||||
variant="conferencing"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{bulkUpdateModal && (
|
||||
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DisconnectIntegrationModal
|
||||
handleModelClose={handleModelClose}
|
||||
isOpen={modal.isOpen}
|
||||
credentialId={modal.credentialId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { localeOptions } from "@calcom/lib/i18n";
|
||||
@@ -13,12 +15,12 @@ import {
|
||||
Label,
|
||||
Meta,
|
||||
Select,
|
||||
SettingsToggle,
|
||||
showToast,
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
TimezoneSelect,
|
||||
SettingsToggle,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
@@ -26,14 +28,14 @@ import PageWrapper from "@components/PageWrapper";
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} />
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8 sm:px-6">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
@@ -59,6 +61,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
const { update } = useSession();
|
||||
const [isUpdateBtnLoading, setIsUpdateBtnLoading] = useState<boolean>(false);
|
||||
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
@@ -72,6 +75,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.viewer.me.invalidate();
|
||||
setIsUpdateBtnLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,8 +109,6 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
value: user.weekStart,
|
||||
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
||||
},
|
||||
allowDynamicBooking: user.allowDynamicBooking ?? true,
|
||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
||||
},
|
||||
});
|
||||
const {
|
||||
@@ -116,134 +118,150 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
} = formMethods;
|
||||
const isDisabled = isSubmitting || !isDirty;
|
||||
|
||||
const [isAllowDynamicBookingChecked, setIsAllowDynamicBookingChecked] = useState(
|
||||
!!user.allowDynamicBooking
|
||||
);
|
||||
const [isAllowSEOIndexingChecked, setIsAllowSEOIndexingChecked] = useState(!!user.allowSEOIndexing);
|
||||
const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState(
|
||||
!!user.receiveMonthlyDigestEmail
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={formMethods}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
...values,
|
||||
locale: values.locale.value,
|
||||
timeFormat: values.timeFormat.value,
|
||||
weekStart: values.weekStart.value,
|
||||
});
|
||||
}}>
|
||||
<Meta title={t("general")} description={t("general_description")} />
|
||||
<Controller
|
||||
name="locale"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis">
|
||||
<>{t("language")}</>
|
||||
</Label>
|
||||
<Select<{ label: string; value: string }>
|
||||
className="capitalize"
|
||||
options={localeOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="timeZone"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis mt-8">
|
||||
<>{t("timezone")}</>
|
||||
</Label>
|
||||
<TimezoneSelect
|
||||
id="timezone"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="timeFormat"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis mt-8">
|
||||
<>{t("time_format")}</>
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
options={timeFormatOptions}
|
||||
onChange={(event) => {
|
||||
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div className="text-gray text-default mt-2 flex items-center text-sm">
|
||||
{t("timeformat_profile_hint")}
|
||||
</div>
|
||||
<Controller
|
||||
name="weekStart"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis mt-8">
|
||||
<>{t("start_of_week")}</>
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
options={weekStartOptions}
|
||||
onChange={(event) => {
|
||||
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Controller
|
||||
name="allowDynamicBooking"
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
title={t("dynamic_booking")}
|
||||
description={t("allow_dynamic_booking")}
|
||||
checked={formMethods.getValues("allowDynamicBooking")}
|
||||
onCheckedChange={(checked) => {
|
||||
formMethods.setValue("allowDynamicBooking", checked, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Form
|
||||
form={formMethods}
|
||||
handleSubmit={(values) => {
|
||||
setIsUpdateBtnLoading(true);
|
||||
mutation.mutate({
|
||||
...values,
|
||||
locale: values.locale.value,
|
||||
timeFormat: values.timeFormat.value,
|
||||
weekStart: values.weekStart.value,
|
||||
});
|
||||
}}>
|
||||
<Meta title={t("general")} description={t("general_description")} borderInShellHeader={true} />
|
||||
<div className="border-subtle border-x border-y-0 px-4 py-8 sm:px-6">
|
||||
<Controller
|
||||
name="locale"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis">
|
||||
<>{t("language")}</>
|
||||
</Label>
|
||||
<Select<{ label: string; value: string }>
|
||||
className="capitalize"
|
||||
options={localeOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="timeZone"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis mt-6">
|
||||
<>{t("timezone")}</>
|
||||
</Label>
|
||||
<TimezoneSelect
|
||||
id="timezone"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="timeFormat"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis mt-6">
|
||||
<>{t("time_format")}</>
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
options={timeFormatOptions}
|
||||
onChange={(event) => {
|
||||
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div className="text-gray text-default mt-2 flex items-center text-sm">
|
||||
{t("timeformat_profile_hint")}
|
||||
</div>
|
||||
<Controller
|
||||
name="weekStart"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-emphasis mt-6">
|
||||
<>{t("start_of_week")}</>
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
options={weekStartOptions}
|
||||
onChange={(event) => {
|
||||
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Controller
|
||||
name="allowSEOIndexing"
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
title={t("seo_indexing")}
|
||||
description={t("allow_seo_indexing")}
|
||||
checked={formMethods.getValues("allowSEOIndexing")}
|
||||
onCheckedChange={(checked) => {
|
||||
formMethods.setValue("allowSEOIndexing", checked, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button loading={isUpdateBtnLoading} disabled={isDisabled} color="primary" type="submit">
|
||||
<>{t("update")}</>
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
loading={mutation.isLoading}
|
||||
disabled={isDisabled}
|
||||
color="primary"
|
||||
type="submit"
|
||||
className="mt-8">
|
||||
<>{t("update")}</>
|
||||
</Button>
|
||||
</Form>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("dynamic_booking")}
|
||||
description={t("allow_dynamic_booking")}
|
||||
disabled={mutation.isLoading}
|
||||
checked={isAllowDynamicBookingChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsAllowDynamicBookingChecked(checked);
|
||||
mutation.mutate({ allowDynamicBooking: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("seo_indexing")}
|
||||
description={t("allow_seo_indexing")}
|
||||
disabled={mutation.isLoading}
|
||||
checked={isAllowSEOIndexingChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsAllowSEOIndexingChecked(checked);
|
||||
mutation.mutate({ allowSEOIndexing: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("monthly_digest_email")}
|
||||
description={t("monthly_digest_email_for_teams")}
|
||||
disabled={mutation.isLoading}
|
||||
checked={isReceiveMonthlyDigestEmailChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsReceiveMonthlyDigestEmailChecked(checked);
|
||||
mutation.mutate({ receiveMonthlyDigestEmail: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ import { z } from "zod";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
@@ -47,8 +49,8 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} />
|
||||
<div className="mb-8 space-y-6">
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8">
|
||||
<div className="flex items-center">
|
||||
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
|
||||
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
|
||||
@@ -69,18 +71,30 @@ interface DeleteAccountValues {
|
||||
|
||||
type FormValues = {
|
||||
username: string;
|
||||
avatar: string;
|
||||
avatar: string | null;
|
||||
name: string;
|
||||
email: string;
|
||||
bio: string;
|
||||
};
|
||||
|
||||
const checkIfItFallbackImage = (fetchedImgSrc: string) => {
|
||||
return fetchedImgSrc.endsWith(AVATAR_FALLBACK);
|
||||
};
|
||||
|
||||
const ProfileView = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { update } = useSession();
|
||||
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const [fetchedImgSrc, setFetchedImgSrc] = useState<string | undefined>();
|
||||
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
|
||||
onSuccess: (userData) => {
|
||||
fetch(userData.avatar).then((res) => {
|
||||
if (res.url) setFetchedImgSrc(res.url);
|
||||
});
|
||||
},
|
||||
});
|
||||
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
await update(res);
|
||||
@@ -204,7 +218,7 @@ const ProfileView = () => {
|
||||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||
};
|
||||
|
||||
if (isLoading || !user)
|
||||
if (isLoading || !user || !fetchedImgSrc)
|
||||
return (
|
||||
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
|
||||
);
|
||||
@@ -219,11 +233,17 @@ const ProfileView = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
|
||||
<Meta
|
||||
title={t("profile")}
|
||||
description={t("profile_description", { appName: APP_NAME })}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<ProfileForm
|
||||
key={JSON.stringify(defaultValues)}
|
||||
defaultValues={defaultValues}
|
||||
isLoading={updateProfileMutation.isLoading}
|
||||
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
|
||||
userAvatar={user.avatar}
|
||||
userOrganization={user.organization}
|
||||
onSubmit={(values) => {
|
||||
if (values.email !== user.email && isCALIdentityProvider) {
|
||||
@@ -238,7 +258,7 @@ const ProfileView = () => {
|
||||
}
|
||||
}}
|
||||
extraField={
|
||||
<div className="mt-8">
|
||||
<div className="mt-6">
|
||||
<UsernameAvailabilityField
|
||||
onSuccessMutation={async () => {
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
@@ -252,16 +272,19 @@ const ProfileView = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
<hr className="border-subtle my-6" />
|
||||
|
||||
<Label>{t("danger_zone")}</Label>
|
||||
<div className="border-subtle mt-6 rounded-xl rounded-b-none border border-b-0 p-6">
|
||||
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
|
||||
</div>
|
||||
{/* Delete account Dialog */}
|
||||
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
|
||||
{t("delete_account")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<SectionBottomActions align="end">
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
|
||||
{t("delete_account")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</SectionBottomActions>
|
||||
<DialogContent
|
||||
title={t("delete_account_modal_title")}
|
||||
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
|
||||
@@ -269,9 +292,7 @@ const ProfileView = () => {
|
||||
Icon={AlertTriangle}>
|
||||
<>
|
||||
<div className="mb-10">
|
||||
<p className="text-default mb-4">
|
||||
{t("delete_account_confirmation_message", { appName: APP_NAME })}
|
||||
</p>
|
||||
<p className="text-default mb-4">{t("delete_account_confirmation_message")}</p>
|
||||
{isCALIdentityProvider && (
|
||||
<PasswordField
|
||||
data-testid="password"
|
||||
@@ -366,12 +387,16 @@ const ProfileForm = ({
|
||||
onSubmit,
|
||||
extraField,
|
||||
isLoading = false,
|
||||
isFallbackImg,
|
||||
userAvatar,
|
||||
userOrganization,
|
||||
}: {
|
||||
defaultValues: FormValues;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
extraField?: React.ReactNode;
|
||||
isLoading: boolean;
|
||||
isFallbackImg: boolean;
|
||||
userAvatar: string;
|
||||
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
@@ -379,7 +404,7 @@ const ProfileForm = ({
|
||||
|
||||
const profileFormSchema = z.object({
|
||||
username: z.string(),
|
||||
avatar: z.string(),
|
||||
avatar: z.string().nullable(),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -404,56 +429,77 @@ const ProfileForm = ({
|
||||
|
||||
return (
|
||||
<Form form={formMethods} handleSubmit={onSubmit}>
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<OrganizationAvatar
|
||||
alt={formMethods.getValues("username")}
|
||||
imageSrc={value}
|
||||
size="lg"
|
||||
organizationSlug={userOrganization.slug}
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("change_avatar")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
|
||||
}}
|
||||
imageSrc={value || undefined}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => {
|
||||
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
|
||||
return (
|
||||
<>
|
||||
<OrganizationAvatar
|
||||
alt={formMethods.getValues("username")}
|
||||
imageSrc={value}
|
||||
size="lg"
|
||||
organizationSlug={userOrganization.slug}
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
|
||||
<div className="flex gap-2">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("upload_avatar")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
|
||||
}}
|
||||
imageSrc={value || undefined}
|
||||
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
|
||||
/>
|
||||
|
||||
{showRemoveAvatarButton && (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
formMethods.setValue("avatar", null, { shouldDirty: true });
|
||||
}}>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{extraField}
|
||||
<div className="mt-6">
|
||||
<TextField label={t("full_name")} {...formMethods.register("name")} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Label>{t("about")}</Label>
|
||||
<Editor
|
||||
getText={() => md.render(formMethods.getValues("bio") || "")}
|
||||
setText={(value: string) => {
|
||||
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
|
||||
}}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
disableLists
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{extraField}
|
||||
<div className="mt-8">
|
||||
<TextField label={t("full_name")} {...formMethods.register("name")} />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Label>{t("about")}</Label>
|
||||
<Editor
|
||||
getText={() => md.render(formMethods.getValues("bio") || "")}
|
||||
setText={(value: string) => {
|
||||
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
|
||||
}}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
disableLists
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
<Button loading={isLoading} disabled={isDisabled} color="primary" className="mt-8" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
<SectionBottomActions align="end">
|
||||
<Button loading={isLoading} disabled={isDisabled} color="primary" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Form, Label, Meta, showToast, Skeleton, Switch } from "@calcom/ui";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Meta, showToast, SettingsToggle, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="border-subtle space-y-6 border border-t-0 px-4 py-8 sm:px-6">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileImpersonationView = () => {
|
||||
const ProfileImpersonationView = ({ user }: { user: RouterOutputs["viewer"]["me"] }) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const [disableImpersonation, setDisableImpersonation] = useState<boolean | undefined>(
|
||||
user?.disableImpersonation
|
||||
);
|
||||
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("profile_updated_successfully"), "success");
|
||||
reset(getValues());
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.viewer.me.invalidate();
|
||||
@@ -26,83 +37,54 @@ const ProfileImpersonationView = () => {
|
||||
await utils.viewer.me.cancel();
|
||||
const previousValue = utils.viewer.me.getData();
|
||||
|
||||
if (previousValue && disableImpersonation) {
|
||||
utils.viewer.me.setData(undefined, { ...previousValue, disableImpersonation });
|
||||
}
|
||||
setDisableImpersonation(disableImpersonation);
|
||||
|
||||
return { previousValue };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (context?.previousValue) {
|
||||
utils.viewer.me.setData(undefined, context.previousValue);
|
||||
setDisableImpersonation(context.previousValue?.disableImpersonation);
|
||||
}
|
||||
showToast(`${t("error")}, ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const formMethods = useForm<{ disableImpersonation: boolean }>({
|
||||
defaultValues: {
|
||||
disableImpersonation: user?.disableImpersonation,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
formState: { isSubmitting, isDirty },
|
||||
setValue,
|
||||
reset,
|
||||
getValues,
|
||||
watch,
|
||||
} = formMethods;
|
||||
|
||||
const isDisabled = isSubmitting || !isDirty;
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("impersonation")} description={t("impersonation_description")} />
|
||||
<Form
|
||||
form={formMethods}
|
||||
handleSubmit={({ disableImpersonation }) => {
|
||||
mutation.mutate({ disableImpersonation });
|
||||
}}>
|
||||
<div className="flex space-x-3">
|
||||
<Switch
|
||||
onCheckedChange={(e) => {
|
||||
setValue("disableImpersonation", !e, { shouldDirty: true });
|
||||
}}
|
||||
fitToHeight={true}
|
||||
checked={!watch("disableImpersonation")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Skeleton as={Label} className="text-emphasis text-sm font-semibold leading-none">
|
||||
{t("user_impersonation_heading")}
|
||||
</Skeleton>
|
||||
<Skeleton as="p" className="text-default -mt-2 text-sm leading-normal">
|
||||
{t("user_impersonation_description")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
loading={mutation.isLoading}
|
||||
className="mt-8"
|
||||
type="submit"
|
||||
disabled={isDisabled}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</Form>
|
||||
<Meta
|
||||
title={t("impersonation")}
|
||||
description={t("impersonation_description")}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<div>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("user_impersonation_heading")}
|
||||
description={t("user_impersonation_description")}
|
||||
checked={!disableImpersonation}
|
||||
onCheckedChange={(checked) => {
|
||||
mutation.mutate({ disableImpersonation: !checked });
|
||||
}}
|
||||
disabled={mutation.isLoading}
|
||||
switchContainerClassName="py-6 px-4 sm:px-6 border-subtle rounded-b-xl border border-t-0"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileImpersonationView.getLayout = getLayout;
|
||||
ProfileImpersonationView.PageWrapper = PageWrapper;
|
||||
const ProfileImpersonationViewWrapper = () => {
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
await ssr.viewer.me.prefetch();
|
||||
return {
|
||||
props: {
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
if (isLoading || !user)
|
||||
return <SkeletonLoader title={t("impersonation")} description={t("impersonation_description")} />;
|
||||
|
||||
return <ProfileImpersonationView user={user} />;
|
||||
};
|
||||
|
||||
export default ProfileImpersonationView;
|
||||
ProfileImpersonationViewWrapper.getLayout = getLayout;
|
||||
ProfileImpersonationViewWrapper.PageWrapper = PageWrapper;
|
||||
|
||||
export default ProfileImpersonationViewWrapper;
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { identityProviderNameMap } from "@calcom/features/auth/lib/identityProviderNameMap";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, Form, Meta, PasswordField, Select, SettingsToggle, showToast } from "@calcom/ui";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
Meta,
|
||||
PasswordField,
|
||||
Select,
|
||||
SettingsToggle,
|
||||
showToast,
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
@@ -18,34 +34,58 @@ type ChangePasswordSessionFormValues = {
|
||||
apiError: string;
|
||||
};
|
||||
|
||||
const PasswordView = () => {
|
||||
interface PasswordViewProps {
|
||||
user: RouterOutputs["viewer"]["me"];
|
||||
}
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="border-subtle space-y-6 border-x px-4 py-8 sm:px-6">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
<div className="rounded-b-xl">
|
||||
<SectionBottomActions align="end">
|
||||
<SkeletonButton className="ml-auto h-8 w-20 rounded-md" />
|
||||
</SectionBottomActions>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const PasswordView = ({ user }: PasswordViewProps) => {
|
||||
const { data } = useSession();
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const metadata = userMetadata.safeParse(user?.metadata);
|
||||
const sessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
|
||||
const metadata = userMetadataSchema.safeParse(user?.metadata);
|
||||
const initialSessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
|
||||
|
||||
const [sessionTimeout, setSessionTimeout] = useState<number | undefined>(initialSessionTimeout);
|
||||
|
||||
const sessionMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
showToast(t("session_timeout_changed"), "success");
|
||||
formMethods.reset(formMethods.getValues());
|
||||
setSessionTimeout(data.metadata?.sessionTimeout);
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.viewer.me.invalidate();
|
||||
},
|
||||
onMutate: async () => {
|
||||
await utils.viewer.me.cancel();
|
||||
const previousValue = utils.viewer.me.getData();
|
||||
const previousMetadata = userMetadata.parse(previousValue?.metadata);
|
||||
const previousValue = await utils.viewer.me.getData();
|
||||
const previousMetadata = userMetadataSchema.safeParse(previousValue?.metadata);
|
||||
|
||||
if (previousValue && sessionTimeout) {
|
||||
if (previousValue && sessionTimeout && previousMetadata.success) {
|
||||
utils.viewer.me.setData(undefined, {
|
||||
...previousValue,
|
||||
metadata: { ...previousMetadata, sessionTimeout: sessionTimeout },
|
||||
metadata: { ...previousMetadata?.data, sessionTimeout: sessionTimeout },
|
||||
});
|
||||
return { previousValue };
|
||||
}
|
||||
return { previousValue };
|
||||
},
|
||||
onError: (error, _, context) => {
|
||||
if (context?.previousValue) {
|
||||
@@ -84,20 +124,30 @@ const PasswordView = () => {
|
||||
defaultValues: {
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
sessionTimeout,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionTimeoutWatch = formMethods.watch("sessionTimeout");
|
||||
|
||||
const handleSubmit = (values: ChangePasswordSessionFormValues) => {
|
||||
const { oldPassword, newPassword, sessionTimeout: newSessionTimeout } = values;
|
||||
const { oldPassword, newPassword } = values;
|
||||
|
||||
if (!oldPassword.length) {
|
||||
formMethods.setError(
|
||||
"oldPassword",
|
||||
{ type: "required", message: t("error_required_field") },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
}
|
||||
if (!newPassword.length) {
|
||||
formMethods.setError(
|
||||
"newPassword",
|
||||
{ type: "required", message: t("error_required_field") },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
}
|
||||
|
||||
if (oldPassword && newPassword) {
|
||||
passwordMutation.mutate({ oldPassword, newPassword });
|
||||
}
|
||||
if (sessionTimeout !== newSessionTimeout) {
|
||||
sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout: newSessionTimeout } });
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutOptions = [5, 10, 15].map((mins) => ({
|
||||
@@ -112,7 +162,7 @@ const PasswordView = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("password")} description={t("password_description")} />
|
||||
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
|
||||
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
||||
<div>
|
||||
<div className="mt-6">
|
||||
@@ -130,87 +180,127 @@ const PasswordView = () => {
|
||||
</div>
|
||||
) : (
|
||||
<Form form={formMethods} handleSubmit={handleSubmit}>
|
||||
{formMethods.formState.errors.apiError && (
|
||||
<div className="pb-6">
|
||||
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-[38rem] sm:grid sm:grid-cols-2 sm:gap-x-4">
|
||||
<div>
|
||||
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
|
||||
</div>
|
||||
<div>
|
||||
<PasswordField
|
||||
{...formMethods.register("newPassword", {
|
||||
minLength: {
|
||||
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
|
||||
value: passwordMinLength,
|
||||
},
|
||||
pattern: {
|
||||
message: "Should contain a number, uppercase and lowercase letters",
|
||||
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
||||
},
|
||||
})}
|
||||
label={t("new_password")}
|
||||
/>
|
||||
<div className="border-x px-4 py-6 sm:px-6">
|
||||
{formMethods.formState.errors.apiError && (
|
||||
<div className="pb-6">
|
||||
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full sm:grid sm:grid-cols-2 sm:gap-x-6">
|
||||
<div>
|
||||
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
|
||||
</div>
|
||||
<div>
|
||||
<PasswordField
|
||||
{...formMethods.register("newPassword", {
|
||||
minLength: {
|
||||
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
|
||||
value: passwordMinLength,
|
||||
},
|
||||
pattern: {
|
||||
message: "Should contain a number, uppercase and lowercase letters",
|
||||
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
||||
},
|
||||
})}
|
||||
label={t("new_password")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-default mt-4 w-full text-sm">
|
||||
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-default mt-4 max-w-[38rem] text-sm">
|
||||
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
|
||||
</p>
|
||||
<div className="border-subtle mt-8 border-t py-8">
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
color="primary"
|
||||
type="submit"
|
||||
loading={passwordMutation.isLoading}
|
||||
onClick={() => formMethods.clearErrors("apiError")}
|
||||
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
<div className="mt-6">
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("session_timeout")}
|
||||
description={t("session_timeout_description")}
|
||||
checked={sessionTimeoutWatch !== undefined}
|
||||
checked={sessionTimeout !== undefined}
|
||||
data-testid="session-check"
|
||||
onCheckedChange={(e) => {
|
||||
if (!e) {
|
||||
formMethods.setValue("sessionTimeout", undefined, { shouldDirty: true });
|
||||
setSessionTimeout(undefined);
|
||||
|
||||
if (metadata.success) {
|
||||
sessionMutation.mutate({
|
||||
metadata: { ...metadata.data, sessionTimeout: undefined },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
formMethods.setValue("sessionTimeout", 10, { shouldDirty: true });
|
||||
setSessionTimeout(10);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{sessionTimeoutWatch && (
|
||||
<div className="mt-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="text-default ltr:mr-2 rtl:ml-2">{t("session_timeout_after")}</p>
|
||||
<Select
|
||||
options={timeoutOptions}
|
||||
defaultValue={
|
||||
sessionTimeout
|
||||
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
|
||||
: timeoutOptions[1]
|
||||
}
|
||||
isSearchable={false}
|
||||
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
|
||||
onChange={(event) => {
|
||||
formMethods.setValue("sessionTimeout", event?.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
childrenClassName="lg:ml-0"
|
||||
switchContainerClassName={classNames(
|
||||
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
|
||||
!!sessionTimeout && "rounded-b-none"
|
||||
)}>
|
||||
<>
|
||||
<div className="border-subtle border-x p-6 pb-8">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-default mb-2 font-medium">{t("session_timeout_after")}</p>
|
||||
<Select
|
||||
options={timeoutOptions}
|
||||
defaultValue={
|
||||
sessionTimeout
|
||||
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
|
||||
: timeoutOptions[1]
|
||||
}
|
||||
isSearchable={false}
|
||||
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
|
||||
onChange={(event) => {
|
||||
setSessionTimeout(event?.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
color="primary"
|
||||
loading={sessionMutation.isLoading}
|
||||
onClick={() => {
|
||||
sessionMutation.mutate({
|
||||
metadata: { ...metadata, sessionTimeout },
|
||||
});
|
||||
formMethods.clearErrors("apiError");
|
||||
}}
|
||||
disabled={
|
||||
initialSessionTimeout === sessionTimeout ||
|
||||
passwordMutation.isLoading ||
|
||||
sessionMutation.isLoading
|
||||
}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
{/* TODO: Why is this Form not submitting? Hacky fix but works */}
|
||||
<Button
|
||||
color="primary"
|
||||
className="mt-8"
|
||||
type="submit"
|
||||
loading={passwordMutation.isLoading || sessionMutation.isLoading}
|
||||
onClick={() => formMethods.clearErrors("apiError")}
|
||||
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
PasswordView.getLayout = getLayout;
|
||||
PasswordView.PageWrapper = PageWrapper;
|
||||
const PasswordViewWrapper = () => {
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const { t } = useLocale();
|
||||
if (isLoading || !user)
|
||||
return <SkeletonLoader title={t("password")} description={t("password_description")} />;
|
||||
|
||||
export default PasswordView;
|
||||
return <PasswordView user={user} />;
|
||||
};
|
||||
|
||||
PasswordViewWrapper.getLayout = getLayout;
|
||||
PasswordViewWrapper.PageWrapper = PageWrapper;
|
||||
|
||||
export default PasswordViewWrapper;
|
||||
|
||||
@@ -3,15 +3,24 @@ import { useState } from "react";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Badge, Meta, Switch, SkeletonButton, SkeletonContainer, SkeletonText, Alert } from "@calcom/ui";
|
||||
import {
|
||||
Badge,
|
||||
Meta,
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
Alert,
|
||||
SettingsToggle,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import DisableTwoFactorModal from "@components/settings/DisableTwoFactorModal";
|
||||
import EnableTwoFactorModal from "@components/settings/EnableTwoFactorModal";
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<div className="flex items-center">
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
@@ -28,36 +37,34 @@ const TwoFactorAuthView = () => {
|
||||
const { t } = useLocale();
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
|
||||
const [enableModalOpen, setEnableModalOpen] = useState(false);
|
||||
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
||||
const [enableModalOpen, setEnableModalOpen] = useState<boolean>(false);
|
||||
const [disableModalOpen, setDisableModalOpen] = useState<boolean>(false);
|
||||
|
||||
if (isLoading) return <SkeletonLoader />;
|
||||
if (isLoading)
|
||||
return <SkeletonLoader title={t("2fa")} description={t("set_up_two_factor_authentication")} />;
|
||||
|
||||
const isCalProvider = user?.identityProvider === "CAL";
|
||||
const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled;
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} />
|
||||
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} borderInShellHeader={true} />
|
||||
{canSetupTwoFactor && <Alert severity="neutral" message={t("2fa_disabled")} />}
|
||||
<div className="mt-6 flex items-start space-x-4">
|
||||
<Switch
|
||||
data-testid="two-factor-switch"
|
||||
disabled={canSetupTwoFactor}
|
||||
checked={user?.twoFactorEnabled}
|
||||
onCheckedChange={() =>
|
||||
user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true)
|
||||
}
|
||||
/>
|
||||
<div className="!mx-4">
|
||||
<div className="flex">
|
||||
<p className="text-default font-semibold">{t("two_factor_auth")}</p>
|
||||
<Badge className="mx-2 text-xs" variant={user?.twoFactorEnabled ? "success" : "gray"}>
|
||||
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-default text-sm">{t("add_an_extra_layer_of_security")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
data-testid="two-factor-switch"
|
||||
title={t("two_factor_auth")}
|
||||
description={t("add_an_extra_layer_of_security")}
|
||||
checked={user?.twoFactorEnabled ?? false}
|
||||
onCheckedChange={() =>
|
||||
user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true)
|
||||
}
|
||||
Badge={
|
||||
<Badge className="mx-2 text-xs" variant={user?.twoFactorEnabled ? "success" : "gray"}>
|
||||
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
}
|
||||
switchContainerClassName="border-subtle rounded-b-xl border border-t-0 px-5 py-6 sm:px-6"
|
||||
/>
|
||||
|
||||
<EnableTwoFactorModal
|
||||
open={enableModalOpen}
|
||||
|
||||
+67
-21
@@ -34,7 +34,25 @@ type FormValues = z.infer<typeof signupSchema>;
|
||||
|
||||
type SignupProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Signup({ prepopulateFormValues, token, orgSlug }: SignupProps) {
|
||||
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
|
||||
|
||||
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
|
||||
const [emailUser, emailDomain] = email.split("@");
|
||||
const username =
|
||||
emailDomain === autoAcceptEmailDomain
|
||||
? slugify(emailUser)
|
||||
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
|
||||
|
||||
return username;
|
||||
};
|
||||
|
||||
function addOrUpdateQueryParam(url: string, key: string, value: string) {
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
const param = `${key}=${encodeURIComponent(value)}`;
|
||||
return `${url}${separator}${param}`;
|
||||
}
|
||||
|
||||
export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoAcceptEmail }: SignupProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const telemetry = useTelemetry();
|
||||
const { t, i18n } = useLocale();
|
||||
@@ -56,6 +74,8 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
}
|
||||
};
|
||||
|
||||
const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username;
|
||||
|
||||
const signUp: SubmitHandler<FormValues> = async (data) => {
|
||||
await fetch("/api/auth/signup", {
|
||||
body: JSON.stringify({
|
||||
@@ -72,13 +92,17 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
.then(async () => {
|
||||
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
|
||||
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
|
||||
const callBackUrl = `${
|
||||
searchParams?.get("callbackUrl")
|
||||
? isOrgInviteByLink
|
||||
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
|
||||
: addOrUpdateQueryParam(`${WEBAPP_URL}/${searchParams.get("callbackUrl")}`, "from", "signup")
|
||||
: `${WEBAPP_URL}/${verifyOrGettingStarted}?from=signup`
|
||||
}`;
|
||||
|
||||
await signIn<"credentials">("credentials", {
|
||||
...data,
|
||||
callbackUrl: `${
|
||||
searchParams?.get("callbackUrl")
|
||||
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
|
||||
: `${WEBAPP_URL}/${verifyOrGettingStarted}`
|
||||
}?from=signup`,
|
||||
callbackUrl: callBackUrl,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -118,21 +142,31 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
if (methods.formState?.errors?.apiError) {
|
||||
methods.clearErrors("apiError");
|
||||
}
|
||||
|
||||
if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) {
|
||||
methods.setValue(
|
||||
"username",
|
||||
getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail)
|
||||
);
|
||||
}
|
||||
methods.handleSubmit(signUp)(event);
|
||||
}}
|
||||
className="bg-default space-y-6">
|
||||
{errors.apiError && <Alert severity="error" message={errors.apiError?.message} />}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<TextField
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
|
||||
}
|
||||
{...register("username")}
|
||||
disabled={!!orgSlug}
|
||||
required
|
||||
/>
|
||||
{!isOrgInviteByLink && (
|
||||
<TextField
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
|
||||
}
|
||||
{...register("username")}
|
||||
disabled={!!orgSlug}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<EmailField
|
||||
{...register("email")}
|
||||
disabled={prepopulateFormValues?.email}
|
||||
@@ -213,6 +247,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
parent: {
|
||||
select: {
|
||||
slug: true,
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
slug: true,
|
||||
@@ -267,7 +302,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const isOrganization = tokenTeam.metadata?.isOrganization || tokenTeam?.parentId !== null;
|
||||
// If we are dealing with an org, the slug may come from the team itself or its parent
|
||||
const orgSlug = isOrganization
|
||||
? tokenTeam.slug || tokenTeam.metadata?.requestedSlug || tokenTeam.parent?.slug
|
||||
? tokenTeam.metadata?.requestedSlug || tokenTeam.parent?.slug || tokenTeam.slug
|
||||
: null;
|
||||
|
||||
// Org context shouldn't check if a username is premium
|
||||
@@ -278,15 +313,26 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
username = available ? username : suggestion || username;
|
||||
}
|
||||
|
||||
const isValidEmail = checkValidEmail(verificationToken.identifier);
|
||||
const isOrgInviteByLink = isOrganization && !isValidEmail;
|
||||
const parentMetaDataForSubteam = tokenTeam?.parent?.metadata
|
||||
? teamMetadataSchema.parse(tokenTeam.parent.metadata)
|
||||
: null;
|
||||
|
||||
return {
|
||||
props: {
|
||||
...props,
|
||||
token,
|
||||
prepopulateFormValues: {
|
||||
email: verificationToken.identifier,
|
||||
username: slugify(username),
|
||||
},
|
||||
prepopulateFormValues: !isOrgInviteByLink
|
||||
? {
|
||||
email: verificationToken.identifier,
|
||||
username: slugify(username),
|
||||
}
|
||||
: null,
|
||||
orgSlug,
|
||||
orgAutoAcceptEmail: isOrgInviteByLink
|
||||
? tokenTeam?.metadata?.orgAutoAcceptEmail ?? parentMetaDataForSubteam?.orgAutoAcceptEmail ?? null
|
||||
: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,12 +8,13 @@ import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -65,9 +66,9 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
// slug is a route parameter, we don't want to forward it to the next route
|
||||
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = routerQuery;
|
||||
|
||||
const EventTypes = () => (
|
||||
const EventTypes = ({ eventTypes }: { eventTypes: NonNullable<(typeof team)["eventTypes"]> }) => (
|
||||
<ul className="border-subtle rounded-md border">
|
||||
{team.eventTypes.map((type, index) => (
|
||||
{eventTypes.map((type, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={classNames(
|
||||
@@ -115,40 +116,45 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
const SubTeams = () =>
|
||||
team.children.length ? (
|
||||
<ul className="divide-subtle border-subtle bg-default !static w-full divide-y rounded-md border">
|
||||
{team.children.map((ch, i) => (
|
||||
<li key={i} className="hover:bg-muted w-full">
|
||||
<Link href={`/${ch.slug}`} className="flex items-center justify-between">
|
||||
<div className="flex items-center px-5 py-5">
|
||||
<Avatar
|
||||
size="md"
|
||||
imageSrc={getPlaceholderAvatar(ch?.logo, ch?.name as string)}
|
||||
alt="Team Logo"
|
||||
className="inline-flex justify-center"
|
||||
/>
|
||||
<div className="ms-3 inline-block truncate">
|
||||
<span className="text-default text-sm font-bold">{ch.name}</span>
|
||||
<span className="text-subtle block text-xs">
|
||||
{t("number_member", {
|
||||
count: ch.members.filter((mem) => mem.user.username !== null).length,
|
||||
})}
|
||||
</span>
|
||||
{team.children.map((ch, i) => {
|
||||
const memberCount = team.members.filter(
|
||||
(mem) => mem.subteams?.includes(ch.slug) && mem.accepted
|
||||
).length;
|
||||
return (
|
||||
<li key={i} className="hover:bg-muted w-full">
|
||||
<Link href={`/${ch.slug}`} className="flex items-center justify-between">
|
||||
<div className="flex items-center px-5 py-5">
|
||||
<Avatar
|
||||
size="md"
|
||||
imageSrc={`/team/${ch.slug}/avatar.png`}
|
||||
alt="Team Logo"
|
||||
className="inline-flex justify-center"
|
||||
/>
|
||||
<div className="ms-3 inline-block truncate">
|
||||
<span className="text-default text-sm font-bold">{ch.name}</span>
|
||||
<span className="text-subtle block text-xs">
|
||||
{t("number_member", {
|
||||
count: memberCount,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AvatarGroup
|
||||
className="mr-6"
|
||||
size="sm"
|
||||
truncateAfter={4}
|
||||
items={ch.members
|
||||
.filter((mem) => mem.user.username !== null)
|
||||
.map(({ user: member }) => ({
|
||||
alt: member.name || "",
|
||||
image: `/${member.username}/avatar.png`,
|
||||
title: member.name || "",
|
||||
}))}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<AvatarGroup
|
||||
className="mr-6"
|
||||
size="sm"
|
||||
truncateAfter={4}
|
||||
items={team.members
|
||||
.filter((mem) => mem.subteams?.includes(ch.slug) && mem.accepted)
|
||||
.map((member) => ({
|
||||
alt: member.name || "",
|
||||
image: `/${member.username}/avatar.png`,
|
||||
title: member.name || "",
|
||||
}))}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
@@ -168,7 +174,10 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
description={teamName}
|
||||
meeting={{
|
||||
title: markdownStrippedBio,
|
||||
profile: { name: `${team.name}`, image: getPlaceholderAvatar(team.logo, team.name) },
|
||||
profile: {
|
||||
name: `${team.name}`,
|
||||
image: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<main className="dark:bg-darkgray-50 bg-subtle mx-auto max-w-3xl rounded-md px-4 pb-12 pt-12">
|
||||
@@ -176,7 +185,9 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
alt={teamName}
|
||||
imageSrc={getPlaceholderAvatar(team.parent ? team.parent.logo : team.logo, team.name)}
|
||||
imageSrc={`${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${
|
||||
team.slug
|
||||
}/avatar.png`}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -197,17 +208,19 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
<SubTeams />
|
||||
) : (
|
||||
<>
|
||||
{(showMembers.isOn || !team.eventTypes.length) &&
|
||||
{(showMembers.isOn || !team.eventTypes?.length) &&
|
||||
(team.isPrivate ? (
|
||||
<div className="w-full text-center">
|
||||
<h2 className="text-emphasis font-semibold">{t("you_cannot_see_team_members")}</h2>
|
||||
<h2 data-testid="you-cannot-see-team-members" className="text-emphasis font-semibold">
|
||||
{t("you_cannot_see_team_members")}
|
||||
</h2>
|
||||
</div>
|
||||
) : (
|
||||
<Team team={team} />
|
||||
<Team members={team.members} teamName={team.name} />
|
||||
))}
|
||||
{!showMembers.isOn && team.eventTypes.length > 0 && (
|
||||
{!showMembers.isOn && team.eventTypes && team.eventTypes.length > 0 && (
|
||||
<div className="mx-auto max-w-3xl ">
|
||||
<EventTypes />
|
||||
<EventTypes eventTypes={team.eventTypes} />
|
||||
|
||||
{/* Hide "Book a team member button when team is private or hideBookATeamMember is true" */}
|
||||
{!team.hideBookATeamMember && !team.isPrivate && (
|
||||
@@ -227,6 +240,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
<Button
|
||||
color="minimal"
|
||||
EndIcon={ArrowRight}
|
||||
data-testid="book-a-team-member-btn"
|
||||
className="dark:hover:bg-darkgray-200"
|
||||
href={{
|
||||
pathname: `${isValidOrgDomain ? "" : "/team"}/${team.slug}`,
|
||||
@@ -251,20 +265,25 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
const team = await getTeamWithMembers({ slug, orgSlug: currentOrgDomain });
|
||||
const team = await getTeamWithMembers({
|
||||
slug: slugify(slug ?? ""),
|
||||
orgSlug: currentOrgDomain,
|
||||
isTeamView: true,
|
||||
isOrgView: isValidOrgDomain && context.resolvedUrl === "/",
|
||||
});
|
||||
const ssr = await ssrInit(context);
|
||||
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
|
||||
console.warn("gSSP, team/[slug] - ", {
|
||||
console.info("gSSP, team/[slug] - ", {
|
||||
isValidOrgDomain,
|
||||
currentOrgDomain,
|
||||
ALLOWED_HOSTNAMES: process.env.ALLOWED_HOSTNAMES,
|
||||
flags: JSON.stringify,
|
||||
flags: JSON.stringify(flags),
|
||||
});
|
||||
// Taking care of sub-teams and orgs
|
||||
if (
|
||||
@@ -300,26 +319,31 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
} as const;
|
||||
}
|
||||
|
||||
team.eventTypes = team.eventTypes.map((type) => ({
|
||||
...type,
|
||||
users: type.users.map((user) => ({
|
||||
...user,
|
||||
avatar: "/" + user.username + "/avatar.png",
|
||||
})),
|
||||
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
|
||||
}));
|
||||
team.eventTypes =
|
||||
team.eventTypes?.map((type) => ({
|
||||
...type,
|
||||
users: type.users.map((user) => ({
|
||||
...user,
|
||||
avatar: "/" + user.username + "/avatar.png",
|
||||
})),
|
||||
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
|
||||
})) ?? null;
|
||||
|
||||
const safeBio = markdownToSafeHTML(team.bio) || "";
|
||||
|
||||
const members = team.members.map((member) => {
|
||||
return {
|
||||
name: member.name,
|
||||
id: member.id,
|
||||
bio: member.bio,
|
||||
username: member.username,
|
||||
safeBio: markdownToSafeHTML(member.bio || ""),
|
||||
};
|
||||
});
|
||||
const members = !team.isPrivate
|
||||
? team.members.map((member) => {
|
||||
return {
|
||||
name: member.name,
|
||||
id: member.id,
|
||||
bio: member.bio,
|
||||
subteams: member.subteams,
|
||||
username: member.username,
|
||||
accepted: member.accepted,
|
||||
safeBio: markdownToSafeHTML(member.bio || ""),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const markdownStrippedBio = stripMarkdown(team?.bio || "");
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
@@ -41,6 +42,20 @@ function Teams() {
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
await ssr.viewer.me.prefetch();
|
||||
const session = await getServerSession({ req: context.req, res: context.res });
|
||||
const token = Array.isArray(context.query?.token) ? context.query.token[0] : context.query?.token;
|
||||
|
||||
const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}` : null;
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login",
|
||||
permanent: false,
|
||||
},
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
|
||||
return { props: { trpcState: ssr.dehydrate() } };
|
||||
};
|
||||
|
||||
@@ -252,6 +252,23 @@ test.describe("prefill", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Persist the field values when going back and coming back to the booking form", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
await page.goto("/pro/30min");
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await page.fill('[name="name"]', "John Doe");
|
||||
await page.fill('[name="email"]', "john@example.com");
|
||||
await page.fill('[name="notes"]', "Test notes");
|
||||
await page.click('[data-testid="back"]');
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await expect(page.locator('[name="name"]')).toHaveValue("John Doe");
|
||||
await expect(page.locator('[name="email"]')).toHaveValue("john@example.com");
|
||||
await expect(page.locator('[name="notes"]')).toHaveValue("Test notes");
|
||||
});
|
||||
|
||||
test("logged out", async ({ page, users }) => {
|
||||
await page.goto("/pro/30min");
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ test.describe("Change username on settings", () => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
/** TODO: Find out why it's timing out */
|
||||
test.fixme("User can change username", async ({ page, users, prisma }) => {
|
||||
test("User can change username", async ({ page, users, prisma }) => {
|
||||
const user = await users.create();
|
||||
|
||||
await user.apiLogin();
|
||||
@@ -36,7 +35,7 @@ test.describe("Change username on settings", () => {
|
||||
await page.click("[data-testid=update-username-btn]");
|
||||
await Promise.all([
|
||||
page.click("[data-testid=save-username]"),
|
||||
page.waitForResponse("**/viewer.updateProfile*"),
|
||||
page.getByTestId("toast-success").waitFor(),
|
||||
]);
|
||||
|
||||
const newUpdatedUser = await prisma.user.findUniqueOrThrow({
|
||||
|
||||
@@ -438,7 +438,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
|
||||
_bookings?: PrismaType.BookingCreateInput[];
|
||||
};
|
||||
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name";
|
||||
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name" | "email";
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
||||
timeZone?: TimeZoneEnum;
|
||||
eventTypes?: SupportedTestEventTypes[];
|
||||
@@ -457,7 +457,7 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
|
||||
return {
|
||||
username: uname,
|
||||
name: opts?.name,
|
||||
email: `${uname}@example.com`,
|
||||
email: opts?.email ?? `${uname}@example.com`,
|
||||
password: hashPassword(uname),
|
||||
emailVerified: new Date(),
|
||||
completedOnboarding: opts?.completedOnboarding ?? true,
|
||||
@@ -556,6 +556,8 @@ export async function apiLogin(
|
||||
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
|
||||
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
|
||||
await page.locator("div > .ml-auto").first().click();
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.getByTestId("select-option-usd").click();
|
||||
await page.getByPlaceholder("Price").fill("100");
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type Prisma from "@prisma/client";
|
||||
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { test } from "./lib/fixtures";
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { todo, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
@@ -143,7 +143,66 @@ test.describe("Stripe integration", () => {
|
||||
expect(await paidBadge.innerText()).toBe("Paid");
|
||||
});
|
||||
|
||||
test("Paid and confirmed booking should be able to be rescheduled", async ({ page, users }) => {
|
||||
await Promise.all([page.waitForURL("/booking/*"), page.click('[data-testid="reschedule-link"]')]);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.click('[data-testid="confirm-reschedule-button"]');
|
||||
|
||||
await expect(page.getByText("This meeting is scheduled")).toBeVisible();
|
||||
});
|
||||
|
||||
todo("Payment should trigger a BOOKING_PAID webhook");
|
||||
todo("Paid and confirmed booking should be able to be rescheduled");
|
||||
});
|
||||
|
||||
test.describe("Change stripe presented currency", () => {
|
||||
test("Should be able to change currency", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType;
|
||||
await user.apiLogin();
|
||||
|
||||
await user.getPaymentCredential();
|
||||
|
||||
// Edit currency inside event type page
|
||||
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
|
||||
|
||||
// Enable Stripe
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
// Set price
|
||||
await page.getByTestId("price-input-stripe").fill("200");
|
||||
|
||||
// Select currency in dropdown
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.locator("#react-select-2-input").fill("mexi");
|
||||
await page.locator("#react-select-2-option-81").click();
|
||||
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
|
||||
// Book event
|
||||
await page.goto(`${user.username}/${eventType?.slug}`);
|
||||
|
||||
// Confirm MXN currency it's displayed use expect
|
||||
await expect(await page.getByText("MX$200.00")).toBeVisible();
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// Confirm again in book form page
|
||||
await expect(await page.getByText("MX$200.00")).toBeVisible();
|
||||
|
||||
// --- fill form
|
||||
await page.fill('[name="name"]', "Stripe Stripeson");
|
||||
await page.fill('[name="email"]', "stripe@example.com");
|
||||
|
||||
// Confirm booking
|
||||
await page.click('[data-testid="confirm-book-button"]');
|
||||
|
||||
// wait for url to be payment
|
||||
await page.waitForURL("/payment/*");
|
||||
|
||||
// Confirm again in book form page
|
||||
await expect(await page.getByText("MX$200.00")).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +153,25 @@ test.describe("Manage Booking Questions", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Verify that we can prefill name field with firstName,lastName query params", async () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append("firstName", "John");
|
||||
searchParams.append("lastName", "Doe");
|
||||
await doOnFreshPreviewWithSearchParams(searchParams, page, context, async (page) => {
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await expectSystemFieldsToBeThereOnBookingPage({
|
||||
page,
|
||||
isFirstAndLastNameVariant: true,
|
||||
values: {
|
||||
name: {
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
const SAML_DATABASE_URL = process.env.SAML_DATABASE_URL!;
|
||||
const SAML_ADMINS = process.env.SAML_ADMINS!;
|
||||
const SAML_ADMIN_EMAIL = process.env.E2E_TEST_SAML_ADMIN_EMAIL!;
|
||||
const SAML_ADMIN_PASSWORD = process.env.E2E_TEST_SAML_ADMIN_PASSWORD!;
|
||||
const OIDC_CLIENT_ID = process.env.E2E_TEST_OIDC_CLIENT_ID!;
|
||||
const OIDC_CLIENT_SECRET = process.env.E2E_TEST_OIDC_CLIENT_SECRET!;
|
||||
const OIDC_PROVIDER_DOMAIN = process.env.E2E_TEST_OIDC_PROVIDER_DOMAIN!;
|
||||
const OIDC_USER_EMAIL = process.env.E2E_TEST_OIDC_USER_EMAIL!;
|
||||
const OIDC_USER_PASSWORD = process.env.E2E_TEST_OIDC_USER_PASSWORD!;
|
||||
|
||||
const SHOULD_SKIP_TESTS =
|
||||
!SAML_DATABASE_URL ||
|
||||
!SAML_ADMINS ||
|
||||
!SAML_ADMIN_EMAIL ||
|
||||
!SAML_ADMIN_PASSWORD ||
|
||||
!OIDC_CLIENT_ID ||
|
||||
!OIDC_CLIENT_SECRET ||
|
||||
!OIDC_PROVIDER_DOMAIN ||
|
||||
!OIDC_USER_EMAIL ||
|
||||
!OIDC_USER_PASSWORD;
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
// TODO: Cleanup the OIDC connection after the tests with fixtures
|
||||
test.describe("OIDC", () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(SHOULD_SKIP_TESTS, "Skipping due to missing the testing variables");
|
||||
test("Setup with SAML admin and login", async ({ page, users }) => {
|
||||
// Add the admin user provided in the environment variables to the db
|
||||
const samlAdminUser = await users.create({ email: SAML_ADMIN_EMAIL, password: SAML_ADMIN_PASSWORD });
|
||||
await samlAdminUser.apiLogin();
|
||||
await test.step("Connect with OIDC Provider", async () => {
|
||||
await page.goto("/settings/security/sso");
|
||||
await page.click('[data-testid="sso-oidc-configure"]');
|
||||
await page.fill('[data-testid="sso-oidc-client-id"]', OIDC_CLIENT_ID);
|
||||
await page.fill('[data-testid="sso-oidc-client-secret"]', OIDC_CLIENT_SECRET);
|
||||
await page.fill(
|
||||
'[data-testid="sso-oidc-well-known-url"]',
|
||||
`https://${OIDC_PROVIDER_DOMAIN}/.well-known/openid-configuration`
|
||||
);
|
||||
await page.click('[data-testid="sso-oidc-save"]');
|
||||
await page.waitForSelector('[data-testid="toast-success"]');
|
||||
});
|
||||
// Logout the SAML Admin
|
||||
await samlAdminUser.logout();
|
||||
await test.step("Login using the OIDC provider", async () => {
|
||||
// Login a user using the OIDC provider.
|
||||
// The credentials are handled by the provider, so we don't need to create a user in the db.
|
||||
await page.goto("/auth/login");
|
||||
await page.click('[data-testid="saml"]');
|
||||
// Redirected outide of the app, the user would be redirected to the OIDC provider.
|
||||
await page.waitForURL(/https:\/\/[^/]+\/oauth2\/v1\/authorize\?.*/);
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(OIDC_USER_EMAIL);
|
||||
await page.getByRole("textbox", { name: "Password" }).fill(OIDC_USER_PASSWORD);
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
// The user is redirected back to the app.
|
||||
await page.waitForURL("getting-started", { waitUntil: "load" });
|
||||
});
|
||||
// Logout the user.
|
||||
await page.goto("/auth/logout");
|
||||
await test.step("Disconnect OIDC Provider", async () => {
|
||||
samlAdminUser.apiLogin();
|
||||
await page.goto("/settings/security/sso", { waitUntil: "load" });
|
||||
await page.getByTestId("delete-oidc-sso-connection").click();
|
||||
await page.getByRole("button", { name: "Yes, delete OIDC configuration" }).click();
|
||||
await page.waitForSelector('[data-testid="toast-success"]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
test("Can create a private team", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(ownerObj, {
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
|
||||
await owner.apiLogin();
|
||||
const { team } = await owner.getTeam();
|
||||
|
||||
// Mark team as private
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
await page.click("[data-testid=make-team-private-check]");
|
||||
await expect(page.locator(`[data-testid=make-team-private-check][data-state="checked"]`)).toBeVisible();
|
||||
|
||||
// Go to Team's page
|
||||
await page.goto(`/team/${team.slug}`);
|
||||
await expect(page.locator('[data-testid="book-a-team-member-btn"]')).toBeHidden();
|
||||
|
||||
// Go to members page
|
||||
await page.goto(`/team/${team.slug}?members=1`);
|
||||
await expect(page.locator('[data-testid="you-cannot-see-team-members"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="team-members-container"]')).toBeHidden();
|
||||
});
|
||||
|
||||
todo("Create a Round Robin with different leastRecentlyBooked hosts");
|
||||
todo("Reschedule a Collective EventType booking");
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe.configure({ mode: "parallel" });
|
||||
const title = (name: string) => `${name} is unpublished`;
|
||||
const description = (entity: string) =>
|
||||
`This ${entity} link is currently not available. Please contact the ${entity} owner or ask them to publish it.`;
|
||||
const avatar = (slug: string) => `/team/${slug}/avatar.png`;
|
||||
const avatar = (slug: string, entity = "team") => `/${entity}/${slug}/avatar.png`;
|
||||
|
||||
test.afterAll(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
@@ -52,7 +52,7 @@ test.describe("Unpublished", () => {
|
||||
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
|
||||
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
|
||||
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
|
||||
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug));
|
||||
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
|
||||
});
|
||||
|
||||
test("Organization sub-team", async ({ users, page }) => {
|
||||
@@ -70,7 +70,7 @@ test.describe("Unpublished", () => {
|
||||
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
|
||||
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
|
||||
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
|
||||
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug));
|
||||
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
|
||||
});
|
||||
|
||||
test("Organization sub-team event-type", async ({ users, page }) => {
|
||||
@@ -90,7 +90,7 @@ test.describe("Unpublished", () => {
|
||||
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
|
||||
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
|
||||
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
|
||||
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug));
|
||||
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
|
||||
});
|
||||
|
||||
test("Organization user", async ({ users, page }) => {
|
||||
@@ -102,7 +102,7 @@ test.describe("Unpublished", () => {
|
||||
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
|
||||
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
|
||||
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
|
||||
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug));
|
||||
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
|
||||
});
|
||||
|
||||
test("Organization user event-type", async ({ users, page }) => {
|
||||
@@ -115,6 +115,6 @@ test.describe("Unpublished", () => {
|
||||
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
|
||||
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
|
||||
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
|
||||
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug));
|
||||
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
"verify_email_banner_body": "قم بتأكيد عنوان بريدك الإلكتروني لضمان أفضل تسليم للبريد الإلكتروني والتقويم",
|
||||
"verify_email_email_header": "تأكيد عنوان بريدك الإلكتروني",
|
||||
"verify_email_email_button": "تأكيد البريد الإلكتروني",
|
||||
"copy_somewhere_safe": "احتفظ بمفتاح API هذا في مكانٍ آمن، لأنك لن تتمكن من الاطلاع عليه هنا ثانيةً.",
|
||||
"verify_email_email_body": "الرجاء تأكيد عنوان بريدك الإلكتروني بالنقر فوق الزر أدناه.",
|
||||
"verify_email_by_code_email_body": "الرجاء تأكيد عنوان بريدك الإلكتروني عبر استخدام الرمز أدناه.",
|
||||
"verify_email_email_link_text": "إليك الرابط في حال لم تكن ترغب بالنقر فوق أي أزرار:",
|
||||
"email_verification_code": "الرجاء إدخال رمز التأكيد",
|
||||
"email_verification_code_placeholder": "الرجاء إدخال رمز التأكيد الذي أُرسل إلى بريدك الإلكتروني",
|
||||
"incorrect_email_verification_code": "رمز التأكيد غير صحيح.",
|
||||
"email_sent": "تم إرسال الرسالة الإلكترونية بنجاح",
|
||||
"email_not_sent": "حدث خطأ أثناء إرسال الرسالة الإلكترونية",
|
||||
"event_declined_subject": "تم الرفض: {{title}} في {{date}}",
|
||||
"event_cancelled_subject": "تم الإلغاء: {{title}} في {{date}}",
|
||||
"event_request_declined": "تم رفض طلب الحدث الخاص بك",
|
||||
@@ -78,6 +84,7 @@
|
||||
"event_awaiting_approval_recurring": "الحدث المتكرر في انتظار موافقتك",
|
||||
"someone_requested_an_event": "طلب شخص ما جدولة حدث في تقويمك.",
|
||||
"someone_requested_password_reset": "طلب شخص ما رابطا لتغيير كلمة المرور الخاصة بك.",
|
||||
"password_reset_email_sent": "يجب أن تتلقى رسالة إلكترونية حول إعادة التعيين على هذا البريد الإلكتروني إذا كان موجوداً في نظامنا.",
|
||||
"password_reset_instructions": "إذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني بأمان ولن يتم تغيير كلمة المرور الخاصة بك.",
|
||||
"event_awaiting_approval_subject": "في انتظار الموافقة: {{title}} في {{date}}",
|
||||
"event_still_awaiting_approval": "الحدث لا يزال في انتظار موافقتك",
|
||||
@@ -217,6 +224,10 @@
|
||||
"already_have_an_account": "هل لديك حساب بالفعل؟",
|
||||
"create_account": "إنشاء حساب",
|
||||
"confirm_password": "تأكيد كلمة المرور",
|
||||
"confirm_auth_change": "سيتسبب هذا في تغيير طريقة تسجيلك للدخول",
|
||||
"confirm_auth_email_change": "سيتسبب تغيير عنوان بريدك الإلكتروني في إنهاء طريقة المصادقة الحالية لتسجيل الدخول إلى Cal.com. وسنطلب منك تأكيد عنوان بريدك الإلكتروني الجديد، ثم سيتم تسجيل الخروج وسيتوجب عليك استخدام عنوان البريد الإلكتروني الجديد لتسجيل الدخول بدلاً من طريقة المصادقة الحالية بعد إعادة ضبط كلمة المرور لديك باتباع التعليمات التي ستُرسل إلى بريدك الإلكتروني.",
|
||||
"reset_your_password": "عيّن كلمة المرور الجديدة متبعاً الإرشادات المرسلة إلى عنوان بريدك الإلكتروني.",
|
||||
"email_change": "سجّل الدخول ثانيةً باستخدام عنوان بريدك الإلكتروني الجديد وكلمة مرور.",
|
||||
"create_your_account": "إنشاء حسابك",
|
||||
"sign_up": "تسجيل الاشتراك",
|
||||
"youve_been_logged_out": "لقد قمت بتسجيل الخروج",
|
||||
@@ -243,6 +254,10 @@
|
||||
"all": "الكل",
|
||||
"yours": "حسابك",
|
||||
"available_apps": "التطبيقات المتوفرة",
|
||||
"available_apps_lower_case": "التطبيقات المتوفرة",
|
||||
"available_apps_desc": "ليس لديك تطبيقات مثبّتة. تصفّح التطبيقات ذات الشعبية أدناه واستكشف المزيد في <1>متجر التطبيقات</1>",
|
||||
"fixed_host_helper": "أضف أي شخص بحاجة إلى ارتياد الفعالية. <1>اعرف المزيد</1>",
|
||||
"round_robin_helper": "سيتناوب الأشخاص ضمن المجموعة على الحضور بحيث سيحضر شخص واحد الفعالية في كل مرة.",
|
||||
"check_email_reset_password": "تحقق من البريد الإلكتروني. لقد أرسلنا رابطًا لإعادة تعيين كلمة المرور.",
|
||||
"finish": "إنهاء",
|
||||
"organization_general_description": "إدارة الإعدادات للغة فريقك ومنطقته الزمنية",
|
||||
@@ -251,6 +266,7 @@
|
||||
"nearly_there_instructions": "وأخيرًا، يساعدك الوصف الموجز عنك والصورة في الحصول على الحجوزات والسماح للأشخاص بمعرفة الشخص الذين يحجزون معه.",
|
||||
"set_availability_instructions": "حدد الفترات الزمنية التي تكون متاحًا فيها بشكل متكرر. يمكنك لاحقًا تحديد المزيد منها وربطها مع تقاويم مختلفة.",
|
||||
"set_availability": "تحديد الوقت الذي تكون فيه متاحًا",
|
||||
"availability_settings": "إعدادات التوافرية",
|
||||
"continue_without_calendar": "المتابعة من دون تقويم",
|
||||
"connect_your_calendar": "ربط التقويم لديك",
|
||||
"connect_your_video_app": "اربط تطبيقات الفيديو لديك",
|
||||
@@ -392,6 +408,8 @@
|
||||
"allow_dynamic_booking_tooltip": "روابط الحجز الجماعية التي يمكن إنشاؤها ديناميكيًا عن طريق إضافة أسماء مستخدمين متعددة باستخدام \"+\". مثال: \"{{appName}}/bailey+peer\"",
|
||||
"allow_dynamic_booking": "السماح للحضور بالحجز لك عن طريق حجز المجموعات الديناميكي",
|
||||
"dynamic_booking": "روابط المجموعة الديناميكية",
|
||||
"allow_seo_indexing": "السماح لمحركات البحث بالوصول إلى المحتوى العام لديك",
|
||||
"seo_indexing": "السماح بالفهرسة وفق SEO",
|
||||
"email": "البريد الإلكتروني",
|
||||
"email_placeholder": "name@example.com",
|
||||
"full_name": "الاسم بالكامل",
|
||||
@@ -406,6 +424,7 @@
|
||||
"booking_requested": "تم طلب الحجز",
|
||||
"meeting_ended": "انتهى الاجتماع",
|
||||
"form_submitted": "تم إرسال النموذج",
|
||||
"booking_paid": "تم الدفع لقاء الحجز",
|
||||
"event_triggers": "مشغلات الحدث",
|
||||
"subscriber_url": "رابط المشترك",
|
||||
"create_new_webhook": "إنشاء ويب هوك جديد",
|
||||
@@ -477,6 +496,7 @@
|
||||
"max_limit_allowed_hint": "يجب أن يكون عدد الأحرف {{limit}} أو أقل",
|
||||
"invalid_password_hint": "يجب أن تتألف كلمة المرور على الأقل من {{passwordLength}} أحرف، ورقم واحد على الأقل، ومزيج من الحروف الكبيرة والصغيرة",
|
||||
"incorrect_password": "كلمة المرور غير صحيحة.",
|
||||
"incorrect_email_password": "اسم المستخدم أو كلمة المرور غير صحيحين.",
|
||||
"use_setting": "استخدام الإعداد",
|
||||
"am_pm": "صباحًا/مساءً",
|
||||
"time_options": "خيارات الوقت",
|
||||
@@ -505,6 +525,7 @@
|
||||
"your_name": "اسمك",
|
||||
"your_full_name": "اسمك الكامل",
|
||||
"no_name": "لا يوجد اسم",
|
||||
"enter_number_between_range": "الرجاء إدخال رقم بين 1 و {{maxOccurences}}",
|
||||
"email_address": "عنوان البريد الإلكتروني",
|
||||
"enter_valid_email": "يرجى إدخال بريد إلكتروني صالح",
|
||||
"location": "الموقع",
|
||||
@@ -542,6 +563,7 @@
|
||||
"leave": "خروج",
|
||||
"profile": "الملف الشخصي",
|
||||
"my_team_url": "عنوان URL الخاص بفريقي",
|
||||
"my_teams": "فِرَقي",
|
||||
"team_name": "اسم الفريق",
|
||||
"your_team_name": "اسم فريقك",
|
||||
"team_updated_successfully": "تم تحديث الفريق بنجاح",
|
||||
@@ -569,6 +591,8 @@
|
||||
"invite_new_member": "دعوة عضو جديد في الفريق",
|
||||
"invite_new_member_description": "ملاحظة: سيكلّف هذا <1>مقعداً إضافياً (15 دولاراً شهرياً)</1> زيادةً على اشتراكك.",
|
||||
"invite_new_team_member": "دعوة شخص ما إلى فريقك.",
|
||||
"upload_csv_file": "تحميل ملف بصيغة .csv",
|
||||
"invite_via_email": "الدعوة عبر البريد الإلكتروني",
|
||||
"change_member_role": "تغيير دور العضو في الفريق",
|
||||
"disable_cal_branding": "تعطيل علامات {{appName}} التجارية",
|
||||
"disable_cal_branding_description": "إخفاء كل علامات {{appName}} التجارية من الصفحات العامة لديك.",
|
||||
@@ -802,7 +826,7 @@
|
||||
"confirm_delete_event_type": "نعم، قم بالحذف",
|
||||
"delete_account": "حذف الحساب",
|
||||
"confirm_delete_account": "نعم، احذف الحساب",
|
||||
"delete_account_confirmation_message": "هل تريد بالتأكيد حذف حسابك على {{appName}}؟ أي شخص شاركت رابط حسابك معه لن يستطيع بعد الآن الحجز باستخدامه، وستفقد أي تفضيلات حفظتها.",
|
||||
"delete_account_confirmation_message": "أي شخص شاركت رابط حسابك معه لن يستطيع بعد الآن الحجز باستخدامه، وستفقد أي تفضيلات حفظتها.",
|
||||
"integrations": "التكاملات",
|
||||
"apps": "التطبيقات",
|
||||
"apps_description": "هنا يمكنك العثور على قائمة بتطبيقاتك",
|
||||
@@ -841,6 +865,7 @@
|
||||
"team_view_user_availability_disabled": "يحتاج المستخدم إلى قبول الدعوة لعرض الأوقات المتاحة",
|
||||
"set_as_away": "تغيير حالتك إلى \"ليس موجودًا\"",
|
||||
"set_as_free": "تعطيل حالة \"ليس موجودًا\"",
|
||||
"toggle_away_error": "حديث خطأ في تحديث الحالة إلى \"بعيد\"",
|
||||
"user_away": "هذا المستخدم ليس موجودًا حاليًا.",
|
||||
"user_away_description": "حالة الشخص الذي تحاول حجز خدماته \"ليس موجودًا\"، ولذا فهو لا يقبل عمليات حجز جديدة.",
|
||||
"meet_people_with_the_same_tokens": "الاجتماع مع أشخاص بنفس ال tokens",
|
||||
@@ -850,6 +875,7 @@
|
||||
"account_managed_by_identity_provider_description": "لتغيير بريدك الإلكتروني وكلمة المرور وتمكين المصادقة من عاملين والمزيد، يُرجى زيارة إعدادات حساب {{provider}} لديك.",
|
||||
"signin_with_google": "تسجيل الدخول عبر Google",
|
||||
"signin_with_saml": "تسجيل الدخول عبر SAML",
|
||||
"signin_with_saml_oidc": "تسجيل الدخول عبر SAML/OIDC",
|
||||
"you_will_need_to_generate": "ستحتاج إلى إنشاء access token من أداة الجدولة القديمة لديك.",
|
||||
"import": "استيراد",
|
||||
"import_from": "الاستيراد من",
|
||||
@@ -949,6 +975,8 @@
|
||||
"offer_seats_description": "عرض مقاعد للحجز. هذا يعطل تلقائياً حجز الضيوف وحجز الاشتراك.",
|
||||
"seats_available_one": "المقاعد المتاحة",
|
||||
"seats_available_other": "المقاعد المتاحة",
|
||||
"seats_nearly_full": "المقاعد ممتلئة تقريباً",
|
||||
"seats_half_full": "المقاعد تُشغل بسرعة",
|
||||
"number_of_seats": "عدد المقاعد لكل حجز",
|
||||
"enter_number_of_seats": "أدخل عدد المقاعد",
|
||||
"you_can_manage_your_schedules": "يمكنك إدارة الجداول الخاصة بك في صفحة الأوقات المتاحة.",
|
||||
@@ -1029,12 +1057,15 @@
|
||||
"how_you_want_add_cal_site": "كيف تريد إضافة {{appName}} إلى موقعك الإلكتروني؟",
|
||||
"choose_ways_put_cal_site": "اختر واحدة من الطرق التالية لوضع {{appName}} على موقعك الإلكتروني.",
|
||||
"setting_up_zapier": "إعداد دمج Zapier الخاص بك",
|
||||
"setting_up_make": "إعداد دمج Make لديك",
|
||||
"generate_api_key": "إنشاء مفتاح API",
|
||||
"generate_api_key_description": "إنشاء مفتاح API لاستخدامه مع {{appName}} على",
|
||||
"your_unique_api_key": "مفتاح API الفريد الخاص بك",
|
||||
"copy_safe_api_key": "انسخ مفتاح API هذا واحفظه في مكان آمن. إذا فقدت هذا المفتاح، يتوجب عليك إنشاء مفتاح جديد.",
|
||||
"zapier_setup_instructions": "<0>سجل الدخول إلى حساب Zapier الخاص بك وأنشئ Zap جديد.</0><1>حدد Cal.com كتطبيق Trigger لديك. اختر أيضًا حدث Trigger.</1><2>اختر حسابك ثم أدخل مفتاح API الفريد الخاص بك.</2><3>اختبر Trigger الخاص بك.</3><4>أنت جاهز!</4>",
|
||||
"make_setup_instructions": "<0>انتقل إلى <1><0>إنشاء رابط دعوة</0></1> وقم بتثبيت تطبيق Cal.com </0><1> ثم سجّل الدخول إلى حسابك وقم بإنشاء \"سيناريو جديد\".</1><2>اختر Cal.com على أنه أحد تطبيقات \"التشغيل\" لديك. اختر أيضا حدث تشغيل. </2><3>اختر حسابك ثم ادخل مفتاح API الفريد الخاص بك.</3><4>اختبر المشغل لديك.</4><5>لقد أتممت الأمر!</5>",
|
||||
"install_zapier_app": "يرجى تثبيت تطبيق Zapier من App Store أولًا.",
|
||||
"install_make_app": "يرجى تثبيت تطبيق Make من متجر التطبيقات أولًا.",
|
||||
"connect_apple_server": "الاتصال بخادم Apple",
|
||||
"calendar_url": "رابط التقويم",
|
||||
"apple_server_generate_password": "أنشئ كلمة مرور خاصة بالتطبيق لاستخدامها مع {{appName}} على",
|
||||
@@ -1084,6 +1115,9 @@
|
||||
"email_attendee_action": "إرسال رسالة إلكترونية إلى الحضور",
|
||||
"sms_attendee_action": "إرسال رسالة نصية قصيرة إلى أحد الحضور",
|
||||
"sms_number_action": "إرسال رسالة نصية قصيرة إلى رقم محدد",
|
||||
"send_reminder_sms": "إرسال تذكيرات بالاجتماعات إلى الحاضرين بيُسر عبر الرسائل النصية القصيرة",
|
||||
"whatsapp_number_action": "إرسال رسالة WhatsApp إلى رقم محدد",
|
||||
"whatsapp_attendee_action": "إرسال رسالة عبر WhatsApp إلى أحد الحاضرين",
|
||||
"workflows": "سير العمل",
|
||||
"new_workflow_btn": "سير عمل جديد",
|
||||
"add_new_workflow": "إضافة سير عمل جديد",
|
||||
@@ -1147,6 +1181,8 @@
|
||||
"choose_template": "اختيار قالب",
|
||||
"custom": "مخصص",
|
||||
"reminder": "تذكير",
|
||||
"rescheduled": "تمت إعادة الجدولة",
|
||||
"completed": "تمّ",
|
||||
"reminder_email": "تذكر {{eventType}} مع {{name}} في {{date}}",
|
||||
"not_triggering_existing_bookings": "لن يتم تشغيل الحجوزات الموجودة مسبقاً حيث سيتم طلب رقم الهاتف عند حجز الحدث.",
|
||||
"minute_one": "{{count}} دقيقة",
|
||||
@@ -1239,6 +1275,7 @@
|
||||
"error_updating_settings": "خطأ في تحديث الإعدادات",
|
||||
"personal_cal_url": "عنوان {{appName}} URL الخاص بي",
|
||||
"bio_hint": "اكتب بضع جمل عن نفسك، والتي ستظهر على صفحة عنوان Url الشخصية لديك.",
|
||||
"user_has_no_bio": "لم يُضف هذا المستخدم لمحة عنه بعد.",
|
||||
"delete_account_modal_title": "حذف الحساب",
|
||||
"confirm_delete_account_modal": "هل أنت متأكد من أنك تريد حذف حساب {{appName}} الخاص بك؟",
|
||||
"delete_my_account": "حذف حسابي",
|
||||
@@ -1421,6 +1458,7 @@
|
||||
"add_limit": "إضافة حد",
|
||||
"team_name_required": "اسم الفريق مطلوب",
|
||||
"show_attendees": "مشاركة معلومات الحضور مع الضيوف",
|
||||
"show_available_seats_count": "إظهار عدد المقاعد المتاحة",
|
||||
"how_booking_questions_as_variables": "كيف يمكن استخدام أسئلة الحجز كمتغيرات؟",
|
||||
"format": "التنسيق",
|
||||
"uppercase_for_letters": "استخدم الحروف الكبيرة لجميع الأحرف",
|
||||
@@ -1489,7 +1527,9 @@
|
||||
"must_enter_team_name": "يجب إدخال اسم فريق",
|
||||
"team_url_required": "يجب إدخال عنوان URL للفريق",
|
||||
"url_taken": "عنوان URL هذا مأخوذ بالفعل",
|
||||
"problem_registering_domain": "حدثت مشكلة في تسجيل المجال الفرعي، يُرجى المحاولة لاحقاً أو الاتصال بالمشرف",
|
||||
"team_publish": "نشر فريق",
|
||||
"number_text_notifications": "رقم الهاتف (إشعارات الرسائل النصية)",
|
||||
"attendee_email_variable": "اسم الحاضر",
|
||||
"attendee_email_info": "البريد الإلكتروني للشخص الحجز",
|
||||
"kbar_search_placeholder": "اكتب أمرًا أو بحثًا...",
|
||||
@@ -1652,8 +1692,11 @@
|
||||
"delete_sso_configuration_confirmation_description": "هل تريد بالتأكيد حذف تكوين {{connectionType}}؟ لن يتمكن أعضاء فريقك الذين يستخدمون معلومات تسجيل الدخول إلى {{connectionType}} من الوصول إلى Cal.com بعد الآن.",
|
||||
"organizer_timezone": "منظم المناطق الزمنية",
|
||||
"email_user_cta": "عرض الدعوة",
|
||||
"email_no_user_invite_heading_team": "تمت دعوتك للانضمام إلى فريق {{appName}}",
|
||||
"email_no_user_invite_heading_org": "تمت دعوتك للانضمام إلى منظمة {{appName}}",
|
||||
"email_no_user_invite_subheading": "دعاك {{invitedBy}} للانضمام إلى فريقه على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت وفريقك من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.",
|
||||
"email_user_invite_subheading_team": "دعاك {{invitedBy}} للانضمام إلى فريقه {{teamName}} على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت وفريقك من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.",
|
||||
"email_user_invite_subheading_org": "لقد دعاك {{invitedBy}} للانضمام إلى فريق {{teamName}} التابع لمنظمتهم على {{appName}}. إن {{appName}} هو برنامج جدولة للفعاليات يمنحك القدرة أنت ومؤسستك على جدولة الاجتماعات من دون الحاجة إلى التراسل الكثيف عبر البريد الإلكتروني.",
|
||||
"email_no_user_invite_steps_intro": "سنرشدك خلال بضع خطوات قصيرة وستستمتع بجدول زمني خالٍ من التوتر مع {{entity}} في لمح البصر.",
|
||||
"email_no_user_step_one": "اختر اسم المستخدم الخاص بك",
|
||||
"email_no_user_step_two": "ربط حساب التقويم الخاص بك",
|
||||
@@ -1824,6 +1867,8 @@
|
||||
"insights_no_data_found_for_filter": "لم يتم العثور على بيانات لعامل التصفية المحدد أو التواريخ المحددة.",
|
||||
"acknowledge_booking_no_show_fee": "أقر بأنه إذا لم أحضر هذا الحدث فسيتم سحب رسوم عدم حضور من بطاقتي تبلغ {{amount, currency}}.",
|
||||
"card_details": "تفاصيل البطاقة",
|
||||
"something_went_wrong_on_our_end": "حدث خطأ ما من طرفنا. اتصل بفريق الدعم لدينا، وسنُصلحه لك فوراً.",
|
||||
"please_provide_following_text_to_suppport": "يرجى تقديم النص التالي عند الاتصال بالدعم لمساعدتك بشكل أفضل",
|
||||
"seats_and_no_show_fee_error": "لا يمكن حاليًا تمكين المقاعد وفرض رسوم عدم الحضور",
|
||||
"complete_your_booking": "أكمل الحجز",
|
||||
"complete_your_booking_subject": "أكمل الحجز: {{title}} في {{date}}",
|
||||
@@ -1857,6 +1902,9 @@
|
||||
"open_dialog_with_element_click": "فتح مربع حوار Cal عندما ينقر شخص فوق عنصر ما.",
|
||||
"need_help_embedding": "هل أنت بحاجة إلى مساعدة؟ راجع أدلتنا لتضمين Cal على Wix، أو Squarespace، أو WordPress، راجع أسئلتنا الشائعة، أو استكشف خيارات التضمين المتقدمة.",
|
||||
"book_my_cal": "تثبيت الحجز في Cal",
|
||||
"first_name": "الاسم الأول",
|
||||
"last_name": "اسم العائلة",
|
||||
"first_last_name": "الاسم الأول، اسم العائلة",
|
||||
"invite_as": "دعوة باسم",
|
||||
"form_updated_successfully": "تم تحديث النموذج بنجاح.",
|
||||
"disable_attendees_confirmation_emails": "تعطيل رسائل التأكيد الافتراضية للحضور",
|
||||
@@ -1870,6 +1918,7 @@
|
||||
"first_event_type_webhook_description": "قم بإنشاء أول شبكة ويب هوك لهذا النوع من الأحداث",
|
||||
"install_app_on": "تثبيت التطبيق على",
|
||||
"create_for": "إنشاء من أجل",
|
||||
"currency": "العملة",
|
||||
"organization_banner_description": "إنشاء بيئات حيث يمكن لفرقك إنشاء تطبيقات مشتركة ومهام سير العمل وأنواع الأحداث باستخدام الجدولة الدوارة والجماعية.",
|
||||
"organization_banner_title": "إدارة المنظمات ذات الفرق المتعددة",
|
||||
"set_up_your_organization": "إعداد منظمتك",
|
||||
@@ -1930,5 +1979,74 @@
|
||||
"insights_team_filter": "الفريق: {{teamName}}",
|
||||
"insights_user_filter": "المستخدم: {{userName}}",
|
||||
"insights_subtitle": "عرض Insights الحجز عبر أحداثك",
|
||||
"location_options": "خيارات الموقع {{locationCount}}",
|
||||
"custom_plan": "الخطة المخصصة",
|
||||
"email_embed": "البريد الإلكتروني المضمن",
|
||||
"add_times_to_your_email": "حدّد بعض الفترات المتاحة وأدرجها في رسالتك الإلكترونية",
|
||||
"select_time": "اختيار الوقت",
|
||||
"select_date": "اختيار التاريخ",
|
||||
"see_all_available_times": "رؤية كل الأوقات المتاحة",
|
||||
"org_team_names_example": "مثال، فريق التسويق",
|
||||
"org_team_names_example_1": "مثال، فريق التسويق",
|
||||
"org_team_names_example_2": "مثال، فريق المبيعات",
|
||||
"org_team_names_example_3": "مثال، فريق التصميم",
|
||||
"org_team_names_example_4": "مثال، الفريق الهندسي",
|
||||
"org_team_names_example_5": "مثال، فريق تحليل البيانات",
|
||||
"org_max_team_warnings": "ستكون قادراً على إضافة فرق إضافية في وقت لاحق.",
|
||||
"what_is_this_meeting_about": "عمَّ تدور هذه الجلسة؟",
|
||||
"add_to_team": "إضافة إلى الفريق",
|
||||
"remove_users_from_org": "إزالة مستخدمين من مؤسسة",
|
||||
"remove_users_from_org_confirm": "هل أنت متأكد من أنك تريد إزالة هذا العدد من المستخدمين {{userCount}} من هذه المؤسسة؟",
|
||||
"user_has_no_schedules": "لم يقم هذا المستخدم بإعداد بأي جدولة حتى الآن",
|
||||
"user_isnt_in_any_teams": "هذا المستخدم ليس في أي فريق",
|
||||
"requires_booker_email_verification": "يتطلب ذلك تأكيد البريد الإلكتروني لمن يقوم بالحجز",
|
||||
"description_requires_booker_email_verification": "لضمان تأكيد البريد الإلكتروني لمن يقوم بالحجز قبل جدولة الفعاليات",
|
||||
"requires_confirmation_mandatory": "يمكن إرسال رسائل نصية فقط للحاضرين عندما يتطلب نوع الفعالية تأكيداً.",
|
||||
"organizations": "المؤسسات",
|
||||
"org_admin_other_teams": "فرق أخرى",
|
||||
"org_admin_other_teams_description": "يمكنك هنا رؤية الفرق ضمن مؤسستك والتي أنت لست جزءاً منها؛ ويمكنك إضافة نفسك إليها عند الحاجة.",
|
||||
"no_other_teams_found": "لم يتم العثور على فرق أخرى",
|
||||
"no_other_teams_found_description": "لا توجد فرق أخرى في هذه المؤسسة.",
|
||||
"attendee_first_name_variable": "الاسم الأول للحاضر",
|
||||
"attendee_last_name_variable": "اسم العائلة للحاضر",
|
||||
"attendee_first_name_info": "الاسم الأول للشخص صاحب الحجز",
|
||||
"attendee_last_name_info": "اسم العائلة للشخص صاحب الحجز",
|
||||
"me": "أنا",
|
||||
"verify_team_tooltip": "تأكيد فريقك لتمكين إرسال الرسائل إلى الحاضرين",
|
||||
"member_removed": "تمت إزالة العضو",
|
||||
"my_availability": "أوقاتي المتاحة",
|
||||
"team_availability": "أوقات الفريق المتاحة",
|
||||
"backup_code": "الرمز الاحتياطي",
|
||||
"backup_codes": "الرموز الاحتياطية",
|
||||
"backup_code_instructions": "يمكن استخدام كل رمز احتياطي لمرة واحدة فقط لمنح حق الوصول بدون مصادقة منك.",
|
||||
"backup_codes_copied": "تم نسخ الرموز الاحتياطية!",
|
||||
"incorrect_backup_code": "الرمز الاحتياطي غير صحيح.",
|
||||
"lost_access": "فُقد الوصول",
|
||||
"missing_backup_codes": "لم يُعثر على رموز احتياطية. الرجاء إنشاؤها في الإعدادات لديك.",
|
||||
"admin_org_notification_email_subject": "تم إنشاء منظمة جديدة: في انتظار العمل",
|
||||
"hi_admin": "مرحباً حضرة المشرف",
|
||||
"admin_org_notification_email_title": "تحتاج منظمة إلى إعداد DNS",
|
||||
"admin_org_notification_email_body_part1": "تم إنشاء منظمة بعيار \"{{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": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
"verify_email_banner_body": "Ověřte svoji e-mailovou adresu, abyste zaručili co nejlepší doručitelnost e-mailů a kalendáře",
|
||||
"verify_email_email_header": "Ověřte svoji e-mailovou adresu",
|
||||
"verify_email_email_button": "Ověřit e-mail",
|
||||
"copy_somewhere_safe": "Uložte si tento klíč API na bezpečné místo. Možnost dalšího zobrazení mít nebudete.",
|
||||
"verify_email_email_body": "Ověřte svoji e-mailovou adresu kliknutím na tlačítko níže.",
|
||||
"verify_email_by_code_email_body": "Ověřte prosím svoji e-mailovou adresu pomocí níže uvedeného kódu.",
|
||||
"verify_email_email_link_text": "Zde je odkaz pro případ, že nemáte rádi klikání na tlačítka:",
|
||||
"email_verification_code": "Zadejte ověřovací kód",
|
||||
"email_verification_code_placeholder": "Zadejte ověřovací kód odeslaný na váš e-mail",
|
||||
"incorrect_email_verification_code": "Ověřovací kód je nesprávný.",
|
||||
"email_sent": "E-mail byl odeslán",
|
||||
"email_not_sent": "Při odesílání e-mailu došlo k chybě",
|
||||
"event_declined_subject": "Odmítnuto: {{title}} v {{date}}",
|
||||
"event_cancelled_subject": "Zrušeno: {{title}} dne {{date}}",
|
||||
"event_request_declined": "Žádost o událost byla zamítnuta",
|
||||
@@ -78,6 +84,7 @@
|
||||
"event_awaiting_approval_recurring": "Opakovaná událost čeká na schválení",
|
||||
"someone_requested_an_event": "Někdo požádal o naplánování události ve vašem kalendáři.",
|
||||
"someone_requested_password_reset": "Někdo požádal o odkaz pro změnu vašeho hesla.",
|
||||
"password_reset_email_sent": "Pokud tento e-mail v našem systému existuje, měli byste obdržet obnovovací e-mail.",
|
||||
"password_reset_instructions": "Pokud jste o obnovení hesla nepožádali, můžete tento e-mail ignorovat. Vaše heslo se nezmění.",
|
||||
"event_awaiting_approval_subject": "Čeká se na schválení: {{title}} dne {{date}}",
|
||||
"event_still_awaiting_approval": "Událost stále čeká na schválení",
|
||||
@@ -217,6 +224,10 @@
|
||||
"already_have_an_account": "Už máte účet?",
|
||||
"create_account": "Vytvořit účet",
|
||||
"confirm_password": "Potvrdit heslo",
|
||||
"confirm_auth_change": "Tím se změní způsob přihlašování",
|
||||
"confirm_auth_email_change": "Změnou e-mailové adresy dojde v souvislosti s přihlášením do Cal.com k odpojení vašeho současného způsobu ověřování. Budeme po vás chtít ověření nové e-mailové adresy. Následně budete odhlášeni a po nastavení hesla podle pokynů, které vám přijdou do pošty, se k přihlašování bude namísto stávajícího způsobu ověřování používat nová e-mailová adresa.",
|
||||
"reset_your_password": "Nastavte si nové heslo podle pokynů odeslaných na vaši e-mailovou adresu.",
|
||||
"email_change": "Přihlaste se znovu pomocí nové e-mailové adresy a hesla.",
|
||||
"create_your_account": "Vytvořit účet",
|
||||
"sign_up": "Přihlásit se",
|
||||
"youve_been_logged_out": "Byli jste odhlášeni",
|
||||
@@ -243,6 +254,10 @@
|
||||
"all": "Všechny aplikace",
|
||||
"yours": "Váš účet",
|
||||
"available_apps": "Dostupné aplikace",
|
||||
"available_apps_lower_case": "Dostupné aplikace",
|
||||
"available_apps_desc": "Nemáte nainstalované žádné aplikace. Níže si můžete prohlédnout oblíbené aplikace a další objevit v našem <1>obchodu s aplikacemi</1>",
|
||||
"fixed_host_helper": "Přidejte každého, kdo se má zúčastnit události. <1>Další informace</1>",
|
||||
"round_robin_helper": "Lidé ve skupině se střídají a na událost se dostaví pouze jedna osoba.",
|
||||
"check_email_reset_password": "Podívejte se do e-mailu. Poslali jsme vám odkaz pro obnovení hesla.",
|
||||
"finish": "Dokončit",
|
||||
"organization_general_description": "Správa nastavení jazyka a časového pásma vašeho týmu",
|
||||
@@ -251,6 +266,7 @@
|
||||
"nearly_there_instructions": "Poslední věc... pár slov o vás a fotografie vám pomůžou získat rezervace a lidi aspoň budou vědět, koho si rezervují.",
|
||||
"set_availability_instructions": "Určete si časové intervaly, kdy jste pravidelně dostupní. Později můžete vytvořit další a přiřadit je různým kalendářům.",
|
||||
"set_availability": "Nastavte, jak jste dostupní",
|
||||
"availability_settings": "Nastavení dostupnosti",
|
||||
"continue_without_calendar": "Pokračovat bez kalendáře",
|
||||
"connect_your_calendar": "Připojit svůj kalendář",
|
||||
"connect_your_video_app": "Propojte své video aplikace",
|
||||
@@ -392,6 +408,8 @@
|
||||
"allow_dynamic_booking_tooltip": "Odkazy na skupinové rezervace, které lze vytvořit dynamicky přidáním více uživatelských jmen s „+“. příklad: „{{appName}}/bailey+peer“",
|
||||
"allow_dynamic_booking": "Povolit účastníkům rezervovat vás prostřednictvím dynamických skupinových rezervací",
|
||||
"dynamic_booking": "Dynamické odkazy na skupiny",
|
||||
"allow_seo_indexing": "Povolte vyhledávačům přístup k vašemu veřejnému obsahu",
|
||||
"seo_indexing": "Zapnout indexování SEO",
|
||||
"email": "E-mailová adresa",
|
||||
"email_placeholder": "vas@email.cz",
|
||||
"full_name": "Jméno a příjmení",
|
||||
@@ -406,6 +424,7 @@
|
||||
"booking_requested": "Váš požadavek na rezervaci byl úspěšně odeslán",
|
||||
"meeting_ended": "Schůzka skončila",
|
||||
"form_submitted": "Formulář byl odeslán",
|
||||
"booking_paid": "Rezervace uhrazena",
|
||||
"event_triggers": "Eventy na základě akce",
|
||||
"subscriber_url": "URL pro odběry",
|
||||
"create_new_webhook": "Vytvořit nový webhook",
|
||||
@@ -506,6 +525,7 @@
|
||||
"your_name": "Vaše jméno",
|
||||
"your_full_name": "Vaše celé jméno",
|
||||
"no_name": "Žádné jméno",
|
||||
"enter_number_between_range": "Zadejte prosím číslo v rozpětí 1 a {{maxOccurences}}",
|
||||
"email_address": "E-mailová adresa",
|
||||
"enter_valid_email": "Zadejte platný e-mail",
|
||||
"location": "Místo",
|
||||
@@ -543,6 +563,7 @@
|
||||
"leave": "Opustit",
|
||||
"profile": "Profil",
|
||||
"my_team_url": "URL adresa mého týmu",
|
||||
"my_teams": "Moje týmy",
|
||||
"team_name": "Název týmu",
|
||||
"your_team_name": "Název vašeho týmu",
|
||||
"team_updated_successfully": "Tým byl úspěšně aktualizován",
|
||||
@@ -570,6 +591,8 @@
|
||||
"invite_new_member": "Pozvat nového člena",
|
||||
"invite_new_member_description": "Poznámka: Ve vašem předplatném to bude <1>stát místo navíc (15 USD/měs.)</1> .",
|
||||
"invite_new_team_member": "Pozvat někoho do týmu.",
|
||||
"upload_csv_file": "Nahrajte soubor .csv",
|
||||
"invite_via_email": "Pozvat e-mailem",
|
||||
"change_member_role": "Změnit roli člena týmu",
|
||||
"disable_cal_branding": "Vypnout branding {{appName}}",
|
||||
"disable_cal_branding_description": "Skrýt branding {{appName}} ze všech veřejných stránek.",
|
||||
@@ -803,7 +826,7 @@
|
||||
"confirm_delete_event_type": "Ano, odstranit",
|
||||
"delete_account": "Smazat účet",
|
||||
"confirm_delete_account": "Ano, smazat účet",
|
||||
"delete_account_confirmation_message": "Opravdu chcete odstranit svůj účet na {{appName}}? Všichni, se kterými jste sdíleli odkaz na účet, přijdou o možnost vytvářet rezervace a vy přijdete o uložené předvolby.",
|
||||
"delete_account_confirmation_message": "Všichni, se kterými jste sdíleli odkaz na účet, přijdou o možnost vytvářet rezervace a vy přijdete o uložené předvolby.",
|
||||
"integrations": "Integrace",
|
||||
"apps": "Aplikace",
|
||||
"apps_description": "Tady najdete seznam svých aplikací",
|
||||
@@ -842,6 +865,7 @@
|
||||
"team_view_user_availability_disabled": "Uživatel musí přijmout pozvánku, aby mohl zobrazit dostupnost",
|
||||
"set_as_away": "Nastavit status \"pryč\"",
|
||||
"set_as_free": "Vypnout status \"pryč\"",
|
||||
"toggle_away_error": "Chyba při aktualizaci statusu „pryč“",
|
||||
"user_away": "Tento uživatel je právě \"pryč\".",
|
||||
"user_away_description": "Osoba, kterou se snažíte rezervovat, má nastavený status \"pryč\" a nepřijímá tak nové rezervace.",
|
||||
"meet_people_with_the_same_tokens": "Seznamte se s lidmi se stejnými tokeny",
|
||||
@@ -851,6 +875,7 @@
|
||||
"account_managed_by_identity_provider_description": "Pro změnu e-mailu, hesla, dvoufázového ověřování apod. prosím přejděte do nastavení účtu {{provider}}.",
|
||||
"signin_with_google": "Přihlásit se pomocí Googlu",
|
||||
"signin_with_saml": "Přihlásit se pomocí SAML",
|
||||
"signin_with_saml_oidc": "Přihlášení pomocí SAML/OIDC",
|
||||
"you_will_need_to_generate": "Budete muset vygenerovat přístupový token ve vašem dřívějším plánovacím nástroji.",
|
||||
"import": "Importovat",
|
||||
"import_from": "Importovat z",
|
||||
@@ -950,6 +975,8 @@
|
||||
"offer_seats_description": "Nabídnout místa k rezervaci. Tím se vypnou rezervace hostů a volitelné rezervace.",
|
||||
"seats_available_one": "Dostupné místo",
|
||||
"seats_available_other": "Dostupná místa",
|
||||
"seats_nearly_full": "Místa jsou téměř obsazena",
|
||||
"seats_half_full": "Místa se rychle zaplňují",
|
||||
"number_of_seats": "Počet míst na rezervaci",
|
||||
"enter_number_of_seats": "Zadejte počet míst",
|
||||
"you_can_manage_your_schedules": "Své plány můžete spravovat na stránce Dostupnost.",
|
||||
@@ -1030,12 +1057,15 @@
|
||||
"how_you_want_add_cal_site": "Jak chcete přidat {{appName}} na vaše stránky?",
|
||||
"choose_ways_put_cal_site": "Vyberte si některý z následujících způsobů, jak umístit {{appName}} na své stránky.",
|
||||
"setting_up_zapier": "Nastavení integrace Zapier",
|
||||
"setting_up_make": "Nastavení integrace s Make",
|
||||
"generate_api_key": "Vygenerovat klíč API",
|
||||
"generate_api_key_description": "Vygenerujte klíč API pro použití s {{appName}} na adrese",
|
||||
"your_unique_api_key": "Váš unikátní klíč API",
|
||||
"copy_safe_api_key": "Zkopírujte tento klíč API a uschovejte si ho na bezpečné místo. Pokud tento klíč ztratíte, musíte vygenerovat nový.",
|
||||
"zapier_setup_instructions": "<0>Přihlaste se ke svému účtu Zapier a vytvořte nový Zap.</0><1>Vyberte Cal.com jako svoji aplikaci Trigger. Také vyberte událost Trigger.</1><2>Vyberte svůj účet a pak zadejte svůj unikátní klíč API.</2><3>Otestujte svůj Trigger.</3><4>Všechno máte nastaveno!</4>",
|
||||
"make_setup_instructions": "<0>Přejděte na <1><0>odkaz s pozvánkou pro Make</0></1> a nainstalujte aplikaci Cal.com.</0><1>Přihlaste se ke svému účtu Make a vytvořte nový scénář.</1><2>Jako spouštěcí aplikaci vyberte Cal.com. Dále vyberte událost spouštěče.</2><3>Vyberte svůj účet a následně zadejte svůj unikátní klíč API.</3><4>Otestujte svůj spouštěč.</4><5>Všechno je připraveno!</5>",
|
||||
"install_zapier_app": "Nejdřív si z App Store nainstalujte aplikaci Zapier.",
|
||||
"install_make_app": "Nejprve si nainstalujte aplikaci Make z obchodu s aplikacemi.",
|
||||
"connect_apple_server": "Připojit se k serveru Apple",
|
||||
"calendar_url": "URL kalendáře",
|
||||
"apple_server_generate_password": "Vygenerujte si specifické heslo pro aplikaci {{appName}} na",
|
||||
@@ -1085,6 +1115,7 @@
|
||||
"email_attendee_action": "odeslat e-mail účastníkům",
|
||||
"sms_attendee_action": "Odeslat SMS účastníkovi",
|
||||
"sms_number_action": "odeslání SMS na konkrétní číslo",
|
||||
"send_reminder_sms": "Snadné zasílání připomenutí schůzek účastníkům přes SMS",
|
||||
"whatsapp_number_action": "odeslat Whatsapp na konkrétní číslo",
|
||||
"whatsapp_attendee_action": "odeslat Whatsapp účastníkovi",
|
||||
"workflows": "Pracovní postupy",
|
||||
@@ -1244,6 +1275,7 @@
|
||||
"error_updating_settings": "Chyba při aktualizaci nastavení",
|
||||
"personal_cal_url": "Moje osobní adresa URL {{appName}}",
|
||||
"bio_hint": "Několik vět o vás. Obsah se zobrazí se na vaší osobní stránce URL.",
|
||||
"user_has_no_bio": "Tento uživatel zatím nepřidal životopis.",
|
||||
"delete_account_modal_title": "Odstranit účet",
|
||||
"confirm_delete_account_modal": "Opravdu chcete odstranit svůj účet {{appName}}?",
|
||||
"delete_my_account": "Odstranit můj účet",
|
||||
@@ -1426,6 +1458,7 @@
|
||||
"add_limit": "Přidat limit",
|
||||
"team_name_required": "Je vyžadován název týmu",
|
||||
"show_attendees": "Sdílet informace o účastnících",
|
||||
"show_available_seats_count": "Zobrazení počtu volných míst",
|
||||
"how_booking_questions_as_variables": "Jak používat rezervační otázky jako proměnné?",
|
||||
"format": "Formát",
|
||||
"uppercase_for_letters": "Použít velká písmena pro všechna písmena",
|
||||
@@ -1659,8 +1692,11 @@
|
||||
"delete_sso_configuration_confirmation_description": "Opravdu chcete odstranit konfiguraci {{connectionType}}? Členové vašeho týmu, kteří používají přihlášení {{connectionType}}, ztratí přístup k webu Cal.com.",
|
||||
"organizer_timezone": "Časové pásmo organizátora",
|
||||
"email_user_cta": "Zobrazit pozvánku",
|
||||
"email_no_user_invite_heading_team": "Byli jste pozváni do týmu {{appName}}",
|
||||
"email_no_user_invite_heading_org": "Byli jste pozváni do organizace {{appName}}",
|
||||
"email_no_user_invite_subheading": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k jeho týmu v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu týmu umožňuje plánovat schůzky bez e-mailového ping-pongu.",
|
||||
"email_user_invite_subheading_team": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k jeho týmu „{{teamName}}“ v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu týmu umožňuje plánovat schůzky bez e-mailového pingpongu.",
|
||||
"email_user_invite_subheading_org": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k týmu {{teamName}} jeho organizace v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vaší organizaci umožňuje plánovat schůzky bez e-mailového pingpongu.",
|
||||
"email_no_user_invite_steps_intro": "Provedeme vás několika krátkými kroky a za malou chvíli si budete se svým subjektem {{entity}} užívat plánování bez stresu.",
|
||||
"email_no_user_step_one": "Vyberte si uživatelské jméno",
|
||||
"email_no_user_step_two": "Připojte svůj účet kalendáře",
|
||||
@@ -1831,6 +1867,8 @@
|
||||
"insights_no_data_found_for_filter": "Pro vybraný filtr nebo vybraná data nebyla nalezena žádná data.",
|
||||
"acknowledge_booking_no_show_fee": "Beru na vědomí, že pokud se této události nezúčastním, bude z mé karty stržen poplatek za nedostavení se ve výši {{amount, currency}}.",
|
||||
"card_details": "Údaje o kartě",
|
||||
"something_went_wrong_on_our_end": "Na naší straně se něco pokazilo. Spojte se s naší podporou a hned to napravíme.",
|
||||
"please_provide_following_text_to_suppport": "Při kontaktování podpory uveďte v zájmu lepší efektivity následující",
|
||||
"seats_and_no_show_fee_error": "V současné době nelze povolit místa a strhnout poplatek za nedostavení se",
|
||||
"complete_your_booking": "Dokončete svou rezervaci",
|
||||
"complete_your_booking_subject": "Dokončete svou rezervaci: {{title}} dne {{date}}",
|
||||
@@ -1864,6 +1902,9 @@
|
||||
"open_dialog_with_element_click": "Otevře dialogové okno Cal, pokud někdo klikne na prvek.",
|
||||
"need_help_embedding": "Potřebujete pomoc? Prohlédněte si naše průvodce pro vložení Cal do Wixu, Squarespace nebo WordPressu, podívejte se na naše nejčastější dotazy nebo prozkoumejte pokročilé možnosti vložení.",
|
||||
"book_my_cal": "Zarezervovat na Cal",
|
||||
"first_name": "Jméno",
|
||||
"last_name": "Příjmení",
|
||||
"first_last_name": "Jméno, příjmení",
|
||||
"invite_as": "Pozvat jako",
|
||||
"form_updated_successfully": "Formulář byl aktualizován.",
|
||||
"disable_attendees_confirmation_emails": "Vypnutí výchozích potvrzovacích e-mailů pro účastníky",
|
||||
@@ -1877,6 +1918,7 @@
|
||||
"first_event_type_webhook_description": "Vytvořte svůj první webhook pro tento typ události",
|
||||
"install_app_on": "Nainstalovat aplikaci v",
|
||||
"create_for": "Vytvořit pro",
|
||||
"currency": "Měna",
|
||||
"organization_banner_description": "Vytvořte prostředí, ve kterém budou moct vaše týmy vytvářet sdílené aplikace, pracovní postupy a typy událostí s kolektivním plánováním nebo plánováním typu round-robin.",
|
||||
"organization_banner_title": "Správa organizace s více týmy",
|
||||
"set_up_your_organization": "Nastavení organizace",
|
||||
@@ -1937,6 +1979,74 @@
|
||||
"insights_team_filter": "Tým: {{teamName}}",
|
||||
"insights_user_filter": "Uživatel: {{userName}}",
|
||||
"insights_subtitle": "Zobrazte si Insight rezervací napříč vašimi událostmi",
|
||||
"location_options": "Možná místa konání: {{locationCount}}",
|
||||
"custom_plan": "Vlastní plán",
|
||||
"email_embed": "Vložení do e-mailu",
|
||||
"add_times_to_your_email": "Vyberte několik dostupných časů a vložte je do e-mailu",
|
||||
"select_time": "Vyberte čas",
|
||||
"select_date": "Vyberte datum",
|
||||
"see_all_available_times": "Zobrazit všechny dostupné časy",
|
||||
"org_team_names_example": "Např. marketingový tým",
|
||||
"org_team_names_example_1": "Např. marketingový tým",
|
||||
"org_team_names_example_2": "Např. obchodní tým",
|
||||
"org_team_names_example_3": "Např. designérský tým",
|
||||
"org_team_names_example_4": "Např. inženýrský tým",
|
||||
"org_team_names_example_5": "Např. tým datové analytiky",
|
||||
"org_max_team_warnings": "Další týmy budete moct přidat později.",
|
||||
"what_is_this_meeting_about": "Čeho se tato schůzka týká?",
|
||||
"add_to_team": "Přidat do týmu",
|
||||
"remove_users_from_org": "Odstranění uživatelů z organizace",
|
||||
"remove_users_from_org_confirm": "Opravdu chcete odebrat uživatele ({{userCount}}) z této organizace?",
|
||||
"user_has_no_schedules": "Tento uživatel zatím nenastavil žádné plány",
|
||||
"user_isnt_in_any_teams": "Tento uživatel není v žádném týmu",
|
||||
"requires_booker_email_verification": "Povinné ověření e-mailu rezervujícího",
|
||||
"description_requires_booker_email_verification": "Zajištění ověření e-mailu rezervujícího před plánováním událostí",
|
||||
"requires_confirmation_mandatory": "SMS zprávy lze účastníkům zasílat pouze v případě, že typ události vyžaduje potvrzení.",
|
||||
"organizations": "Organizace",
|
||||
"org_admin_other_teams": "Ostatní týmy",
|
||||
"org_admin_other_teams_description": "Zde můžete vidět týmy uvnitř vaší organizace, kterých nejste součástí, a případně se do nich můžete přidat.",
|
||||
"no_other_teams_found": "Nenalezeny žádné další týmy",
|
||||
"no_other_teams_found_description": "V této organizaci nejsou žádné další týmy.",
|
||||
"attendee_first_name_variable": "Jméno účastníka",
|
||||
"attendee_last_name_variable": "Příjmení účastníka",
|
||||
"attendee_first_name_info": "Jméno rezervující osoby",
|
||||
"attendee_last_name_info": "Příjmení rezervující osoby",
|
||||
"me": "Já",
|
||||
"verify_team_tooltip": "Proveďte ověření svého týmu a zapněte odesílání zpráv účastníkům",
|
||||
"member_removed": "Člen odstraněn",
|
||||
"my_availability": "Moje dostupnost",
|
||||
"team_availability": "Dostupnost týmu",
|
||||
"backup_code": "Záložní kód",
|
||||
"backup_codes": "Záložní kódy",
|
||||
"backup_code_instructions": "Každý záložní kód lze použít právě jednou k udělení přístupu bez vašeho způsobu ověřování.",
|
||||
"backup_codes_copied": "Záložní kódy zkopírovány!",
|
||||
"incorrect_backup_code": "Záložní kód není správný.",
|
||||
"lost_access": "Ztracený přístup",
|
||||
"missing_backup_codes": "Nenalezeny žádné záložní kódy. Vygenerujte je v nastavení.",
|
||||
"admin_org_notification_email_subject": "Vytvořena nová organizace: čekající akce",
|
||||
"hi_admin": "Dobrý den, administrátore",
|
||||
"admin_org_notification_email_title": "Organizace vyžaduje nastavení DNS",
|
||||
"admin_org_notification_email_body_part1": "Byla vytvořena organizace se slugem „{{orgSlug}}“.<br /><br />Zkontrolujte prosím, zda máte nakonfigurovaný registr DNS tak, aby subdoména odpovídající nové organizaci směřovala na místo, kde je spuštěna hlavní aplikace. Jinak organizace nebude fungovat.<br /><br />Tady jsou jen zcela základní možnosti, jak nakonfigurovat subdoménu tak, aby směřovala na příslušnou aplikaci a načítala profilovou stránku organizace.<br /><br />Můžete to udělat buď pomocí záznamu typu A:",
|
||||
"admin_org_notification_email_body_part2": "nebo záznamu typu CNAME:",
|
||||
"admin_org_notification_email_body_part3": "Až subdoménu nakonfigurujete, označte konfiguraci DNS jako provedenou v nastavení správce organizací.",
|
||||
"admin_org_notification_email_cta": "Přejděte do nastavení správce organizací",
|
||||
"org_has_been_processed": "Organizace byla zpracována",
|
||||
"org_error_processing": "Při zpracování této organizace došlo k chybě",
|
||||
"orgs_page_description": "Seznam všech organizací. Přijetí organizace umožní všem uživatelům s touto e-mailovou doménou registraci BEZ ověření e-mailu.",
|
||||
"unverified": "Neověřeno",
|
||||
"dns_missing": "Chybí DNS",
|
||||
"mark_dns_configured": "Označit jako s nakonfigurovaným DNS",
|
||||
"value": "Hodnota",
|
||||
"your_organization_updated_sucessfully": "Vaše organizace byla aktualizována",
|
||||
"team_no_event_types": "Tento tým nemá žádné typy událostí",
|
||||
"seat_options_doesnt_multiple_durations": "Volba místa nepodporuje více dob trvání",
|
||||
"include_calendar_event": "Zahrnout událost kalendáře",
|
||||
"recently_added": "Nedávno přidáno",
|
||||
"no_members_found": "Nenalezeni žádní členové",
|
||||
"event_setup_length_error": "Nastavení události: Doba trvání musí být alespoň 1 minuta.",
|
||||
"availability_schedules": "Plány dostupnosti",
|
||||
"view_only_edit_availability_not_onboarded": "Tento uživatel ještě nedokončil onboarding. Dokud nedokončí onboarding, dostupnost nebude možné nastavit.",
|
||||
"view_only_edit_availability": "Právě máte zobrazenou dostupnost tohoto uživatele. Upravovat lze pouze vlastní dostupnost.",
|
||||
"edit_users_availability": "Upravte dostupnost uživatele: {{username}}",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -703,7 +703,7 @@
|
||||
"slot_interval_default": "Brug begivenhedslængde (standard)",
|
||||
"delete_account": "Slet konto",
|
||||
"confirm_delete_account": "Ja, slet konto",
|
||||
"delete_account_confirmation_message": "Er du sikker på, at du vil slette din {{appName}} konto? Alle, som du har delt dit konto-link med, vil ikke længere kunne booke ved hjælp af det, og eventuelle præferencer du har gemt, vil gå tabt.",
|
||||
"delete_account_confirmation_message": "Alle, som du har delt dit konto-link med, vil ikke længere kunne booke ved hjælp af det, og eventuelle præferencer du har gemt, vil gå tabt.",
|
||||
"integrations": "Integrationer",
|
||||
"apps": "Apps",
|
||||
"apps_description": "Her kan du finde en liste over dine apps",
|
||||
|
||||
@@ -257,6 +257,7 @@
|
||||
"available_apps_lower_case": "Verfügbare Apps",
|
||||
"available_apps_desc": "Sie haben keine Apps installiert. Sehen Sie sich beliebte Apps unten an und entdecken Sie noch mehr in unserem <1>App Store</1>",
|
||||
"fixed_host_helper": "Füge jeden hinzu, der an der Veranstaltung teilnehmen muss. <1>Mehr erfahren</1>",
|
||||
"round_robin_helper": "Die Leute in der Gruppe wechseln sich ab und nur eine Person wird für die Veranstaltung erscheinen.",
|
||||
"check_email_reset_password": "Überprüfen Sie Ihre E-Mail. Wir haben Ihnen einen Link zum Zurücksetzen Ihres Passworts gesendet.",
|
||||
"finish": "Fertig",
|
||||
"organization_general_description": "Einstellungen für die Sprache und Zeitzone Ihres Teams verwalten",
|
||||
@@ -265,6 +266,7 @@
|
||||
"nearly_there_instructions": "Zu guter Letzt: Ein Foto und eine Kurzbeschreibung zu Ihnen helfen, öfter gebucht zu werden, da Kunden dann genauer wissen, bei wem sie einen Termin buchen.",
|
||||
"set_availability_instructions": "Definieren Sie Zeiträume, wenn Sie wiederkehrend verfügbar sind. Sie können später weitere Zeiträume erstellen und verschiedenen Kalendern zuordnen.",
|
||||
"set_availability": "Verfügbarkeit festlegen",
|
||||
"availability_settings": "Verfügbarkeitseinstellungen",
|
||||
"continue_without_calendar": "Ohne Kalender fortfahren",
|
||||
"connect_your_calendar": "Kalender verbinden",
|
||||
"connect_your_video_app": "Verbinden Sie Ihre Video-Apps",
|
||||
@@ -289,9 +291,9 @@
|
||||
"add_another_calendar": "Einen weiteren Kalender hinzufügen",
|
||||
"other": "Sonstige",
|
||||
"email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}",
|
||||
"emailed_you_and_attendees": "Wir haben Ihnen und den anderen Teilnehmern eine Einladung zum Kalender mit allen Details zugeschickt.",
|
||||
"emailed_you_and_attendees_recurring": "Wir haben Ihnen und den anderen Teilnemern eine Kalendereinladung für den ersten Termin dieser wiederkehrenden Termins gesendet.",
|
||||
"emailed_you_and_any_other_attendees": "Wir haben Ihnen und allen anderen Teilnehmern diese Informationen per E-Mail zugeschickt.",
|
||||
"emailed_you_and_attendees": "Wir haben eine E-Mail mit einer Kalendereinladung mit den Details an alle gesendet.",
|
||||
"emailed_you_and_attendees_recurring": "Wir haben eine E-Mail mit einer Kalendereinladung mit den Details für das erste dieser wiederkehrenden Ereignisse an alle gesendet.",
|
||||
"emailed_you_and_any_other_attendees": "Wir haben eine E-Mail mit diesen Informationen an alle gesendet.",
|
||||
"needs_to_be_confirmed_or_rejected": "Ihr Termin muss noch bestätigt oder abgelehnt werden.",
|
||||
"needs_to_be_confirmed_or_rejected_recurring": "Ihr wiederkehrender Termin muss noch bestätigt oder abgelehnt werden.",
|
||||
"user_needs_to_confirm_or_reject_booking": "{{user}} muss den Termin noch bestätigen oder ablehnen.",
|
||||
@@ -589,6 +591,8 @@
|
||||
"invite_new_member": "Ein neues Mitglied einladen",
|
||||
"invite_new_member_description": "Hinweis: Dies wird <1>einen zusätzlichen Platz(15 $/m)</1> in Ihrem Abonnement kosten.",
|
||||
"invite_new_team_member": "Laden Sie jemanden in Ihr Team ein.",
|
||||
"upload_csv_file": "Eine .csv-Datei hochladen",
|
||||
"invite_via_email": "Per E-Mail einladen",
|
||||
"change_member_role": "Rolle des Teammitglieds ändern",
|
||||
"disable_cal_branding": "{{appName}}-Branding deaktivieren",
|
||||
"disable_cal_branding_description": "Blendet alle {{appName}}-Werbung auf Ihren öffentlichen Seiten aus.",
|
||||
@@ -822,7 +826,7 @@
|
||||
"confirm_delete_event_type": "Ja, löschen",
|
||||
"delete_account": "Account löschen",
|
||||
"confirm_delete_account": "Ja, Account löschen",
|
||||
"delete_account_confirmation_message": "Sind Sie sicher, dass Sie Ihr {{appName}}-Konto löschen möchten? Sie können dann nicht mehr über Ihre geteilten Links gebucht werden und alle Einstellungen werden gelöscht.",
|
||||
"delete_account_confirmation_message": "Sie können dann nicht mehr über Ihre geteilten Links gebucht werden und alle Einstellungen werden gelöscht.",
|
||||
"integrations": "Integrationen",
|
||||
"apps": "Apps",
|
||||
"apps_description": "Hier finden Sie eine Liste Ihrer Apps",
|
||||
@@ -871,6 +875,7 @@
|
||||
"account_managed_by_identity_provider_description": "Um Ihre E-Mail-Adresse, Ihr Passwort, die Zwei-Faktor-Authentifizierung und sonstiges zu ändern, besuchen Sie bitte Ihre {{provider}} -Kontoeinstellungen.",
|
||||
"signin_with_google": "Mit Google anmelden",
|
||||
"signin_with_saml": "Anmelden mit SAML",
|
||||
"signin_with_saml_oidc": "Anmelden mit SAML/OIDC",
|
||||
"you_will_need_to_generate": "Sie müssen ein Zugriffstoken in ihrem alten Kalender-Tool erzeugen.",
|
||||
"import": "Importieren",
|
||||
"import_from": "Importieren von",
|
||||
@@ -970,6 +975,8 @@
|
||||
"offer_seats_description": "Bieten Sie Plätze für Buchungen an (dies deaktiviert Gäste & Opt-in-Buchungen)",
|
||||
"seats_available_one": "Verfügbarer Platz",
|
||||
"seats_available_other": "Verfügbare Plätze",
|
||||
"seats_nearly_full": "Plätze fast voll",
|
||||
"seats_half_full": "Plätze werden schnell belegt",
|
||||
"number_of_seats": "Anzahl der Plätze pro Termin",
|
||||
"enter_number_of_seats": "Anzahl der Plätze eingeben",
|
||||
"you_can_manage_your_schedules": "Sie können Ihren Verfügbarkeitsplan bei Ihren Verfügbarkeiten ändern.",
|
||||
@@ -1050,12 +1057,15 @@
|
||||
"how_you_want_add_cal_site": "Wie möchten Sie {{appName}} zu Ihrer Website hinzufügen?",
|
||||
"choose_ways_put_cal_site": "Wählen Sie eine der folgenden Möglichkeiten, um {{appName}} in Ihre Website zu integrieren.",
|
||||
"setting_up_zapier": "Einrichtung Ihrer Zapier-Integration",
|
||||
"setting_up_make": "Einrichtung Ihrer Make-Integration",
|
||||
"generate_api_key": "Api Key generieren",
|
||||
"generate_api_key_description": "Generieren Sie einen API-Schlüssel für {{appName}} unter",
|
||||
"your_unique_api_key": "Ihr eindeutiger API-Key",
|
||||
"copy_safe_api_key": "Kopieren Sie diesen API-Schlüssel und speichern Sie ihn an einem sicheren Ort. Wenn Sie diesen Schlüssel verlieren, müssen Sie einen neuen generieren.",
|
||||
"zapier_setup_instructions": "<0>Loggen Sie sich in Ihr Zapier-Konto ein und erstelle einen neuen Zap.</0><1>Wählen Sie Cal.com als ihre Trigger-App aus. Wählen Sie auch ein Auslöserereignis.</0><1>Wählen Sie ihr Konto aus und geben dann ihren eindeutigen API-Key ein.</2><3>Testen Sie den Trigger.</3><4>Alles fertig!</4>",
|
||||
"make_setup_instructions": "<0>Gehen Sie zu: <1><0>Make-Einladungs-Link</0></1>und installieren Sie die Cal.com-App.</0><1> Loggen Sie sich in Ihr Make-Konto ein und erstellen Sie ein neues Szenario.</1><2>Wählen Sie Cal.com als ihre Auslöser-App aus. Wählen Sie auch ein Auslöserereignis aus.</2><3>Wählen Sie ihr Konto aus und geben Sie dann Ihren eindeutigen API-Key ein.</3><4>Probierne Sie den Auslöser aus.</4><5>Und fertig!</5>",
|
||||
"install_zapier_app": "Bitte installieren Sie zuerst die Zapier-App im App Store.",
|
||||
"install_make_app": "Bitte installieren Sie zuerst die Make-App im App Store.",
|
||||
"connect_apple_server": "Mit Apple-Server verbinden",
|
||||
"calendar_url": "Kalender-URL",
|
||||
"apple_server_generate_password": "Generieren Sie ein App-spezifisches Passwort für {{appName}} unter",
|
||||
@@ -1093,7 +1103,7 @@
|
||||
"reschedule_optional": "Grund für die Verschiebung (optional)",
|
||||
"reschedule_placeholder": "Lassen Sie andere wissen, warum Sie den Termin verschieben müssen",
|
||||
"event_cancelled": "Dieser Termin ist abgesagt",
|
||||
"emailed_information_about_cancelled_event": "Wir haben Ihnen und den anderen Teilnehmern eine E-Mail gesendet, damit alle Bescheid wissen.",
|
||||
"emailed_information_about_cancelled_event": "Wir haben eine E-Mail an alle gesendet, um sie darüber zu informieren.",
|
||||
"this_input_will_shown_booking_this_event": "Diese Eingabe wird bei der Buchung dieses Termins angezeigt",
|
||||
"meeting_url_in_confirmation_email": "Termin-URL ist in der Bestätigungsmail",
|
||||
"url_start_with_https": "URL muss mit http:// oder https:// beginnen",
|
||||
@@ -1105,6 +1115,7 @@
|
||||
"email_attendee_action": "E-Mail an Teilnehmende senden",
|
||||
"sms_attendee_action": "SMS an Teilnehmende senden",
|
||||
"sms_number_action": "SMS an eine bestimmte Nummer senden",
|
||||
"send_reminder_sms": "Senden Sie Meeting-Erinnerungen einfach per SMS an Ihre Teilnehmer",
|
||||
"whatsapp_number_action": "WhatsApp-Nachricht an eine bestimmte Nummer senden",
|
||||
"whatsapp_attendee_action": "WhatsApp-Nachricht an Teilnehmer senden",
|
||||
"workflows": "Workflows",
|
||||
@@ -1264,6 +1275,7 @@
|
||||
"error_updating_settings": "Fehler beim Aktualisieren der Einstellungen",
|
||||
"personal_cal_url": "Meine persönliche {{appName}}-URL",
|
||||
"bio_hint": "Schreiben Sie eine kurze Beschreibung, welche auf Ihrer persönlichen Profil-Seite erscheinen wird.",
|
||||
"user_has_no_bio": "Dieser Benutzer hat noch keine Bio hinzugefügt.",
|
||||
"delete_account_modal_title": "Account löschen",
|
||||
"confirm_delete_account_modal": "Sind Sie sicher, dass Sie Ihr {{appName}}-Konto löschen möchten?",
|
||||
"delete_my_account": "Meinen Account löschen",
|
||||
@@ -1446,6 +1458,7 @@
|
||||
"add_limit": "Begrenzung hinzufügen",
|
||||
"team_name_required": "Teamname erforderlich",
|
||||
"show_attendees": "Teilnehmerinformationen zwischen Gästen teilen",
|
||||
"show_available_seats_count": "Anzahl der verfügbaren Plätze anzeigen",
|
||||
"how_booking_questions_as_variables": "Wie verwendet man Buchungsfragen als Variablen?",
|
||||
"format": "Format",
|
||||
"uppercase_for_letters": "Großbuchstaben für alle Buchstaben verwenden",
|
||||
@@ -1679,8 +1692,11 @@
|
||||
"delete_sso_configuration_confirmation_description": "Sind Sie sicher, dass Sie die {{connectionType}}-Konfiguration löschen möchten? Ihre Teammitglieder, die sich über {{connectionType}} anmelden, werden keinen Zugriff mehr auf Cal.com haben.",
|
||||
"organizer_timezone": "Zeitzone des Veranstalters",
|
||||
"email_user_cta": "Einladung anzeigen",
|
||||
"email_no_user_invite_heading_team": "Sie wurden eingeladen, einem {{appName}}-Team beizutreten",
|
||||
"email_no_user_invite_heading_org": "Sie wurden eingeladen, einer {{appName}}-Organisation beizutreten",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} hat Sie eingeladen, dem Team in {{appName}} beizutreten. {{appName}} ist der Event-Planer, der es Ihnen und Ihrem Team ermöglicht, Meetings ohne ständiges hin und her zu planen.",
|
||||
"email_user_invite_subheading_team": "{{invitedBy}} hat Sie eingeladen, dem Team „{{teamName}}“ beizutreten. {{appName}} ist der Event-Planer, der es Ihnen und Ihrem Team ermöglicht, Meetings ohne Hin und Her zu planen.",
|
||||
"email_user_invite_subheading_org": "{{invitedBy}} hat Sie eingeladen, ihrer Organisation „{{teamName}}“ in {{appName}} beizutreten. {{appName}} ist der Terminplaner, der es Ihnen und Ihrer Organisation ermöglicht, Meetings ohne ständiges hin und her zu planen.",
|
||||
"email_no_user_invite_steps_intro": "Wir begleiten Sie durch ein paar Schritte, danach können Sie in kürzester Zeit stressfreie Planung mit Ihrer {{entity}} genießen.",
|
||||
"email_no_user_step_one": "Wählen Sie Ihren Benutzernamen",
|
||||
"email_no_user_step_two": "Verbinden Sie Ihr Kalenderkonto",
|
||||
@@ -1851,6 +1867,8 @@
|
||||
"insights_no_data_found_for_filter": "Keine Daten für den ausgewählten Filter oder das ausgewählte Datum gefunden.",
|
||||
"acknowledge_booking_no_show_fee": "Ich bestätige, dass meiner Karte eine Gebühr von {{amount, currency}} berechnet wird, sofern ich nicht an diesem Ereignis teilnehme.",
|
||||
"card_details": "Karteninformationen",
|
||||
"something_went_wrong_on_our_end": "Etwas ist auf unserer Seite schief gelaufen. Kontaktieren Sie unser Support-Team, wir werden es sofort für Sie reparieren.",
|
||||
"please_provide_following_text_to_suppport": "Bitte geben Sie den folgenden Text an, wenn Sie den Support kontaktieren, damit wir Ihnen besser helfen können",
|
||||
"seats_and_no_show_fee_error": "Aktuell können keine Plätze aktiviert oder eine Gebühr für das Nichterscheinen berechnet werden",
|
||||
"complete_your_booking": "Schließen Sie Ihre Buchung ab",
|
||||
"complete_your_booking_subject": "Schließen Sie Ihre Buchung ab: {{title}} am {{date}}",
|
||||
@@ -1961,6 +1979,7 @@
|
||||
"insights_team_filter": "Team: {{teamName}}",
|
||||
"insights_user_filter": "Benutzer: {{userName}}",
|
||||
"insights_subtitle": "Sehen Sie sich Buchungs-Insights zu Ihren Termine an",
|
||||
"location_options": "{{locationCount}} Veranstaltungsort-Optionen",
|
||||
"custom_plan": "Maßgeschneiderter Tarif",
|
||||
"email_embed": "E-Mail Einbettung",
|
||||
"add_times_to_your_email": "Zeiten zur E-Mail hinzufügen",
|
||||
@@ -1976,14 +1995,58 @@
|
||||
"org_max_team_warnings": "Weitere Teams können Sie zu einem späteren Zeitpunkt hinzufügen.",
|
||||
"what_is_this_meeting_about": "Worum geht es in diesem Termin?",
|
||||
"add_to_team": "Zum Team hinzufügen",
|
||||
"remove_users_from_org": "Benutzer aus der Organisation entfernen",
|
||||
"remove_users_from_org_confirm": "Sind Sie sicher, dass Sie {{userCount}} Benutzer aus dieser Organisation entfernen möchten?",
|
||||
"user_has_no_schedules": "Dieser Benutzer hat noch keine Verfügbarkeitspläne eingerichtet",
|
||||
"user_isnt_in_any_teams": "Dieser Benutzer ist in keinem Team",
|
||||
"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:",
|
||||
"requires_booker_email_verification": "Erfordert E-Mail-Verifizierung des Buchenden",
|
||||
"description_requires_booker_email_verification": "Um sicherzustellen, dass die E-Mail des Buchenden vor der Terminplanung verifiziert ist",
|
||||
"requires_confirmation_mandatory": "Textnachrichten können nur an Teilnehmer gesendet werden, wenn der Ereignistyp bestätigt werden muss.",
|
||||
"organizations": "Organisationen",
|
||||
"org_admin_other_teams": "Weitere Teams",
|
||||
"org_admin_other_teams_description": "Hier können Sie Teams innerhalb Ihrer Organisation sehen, zu denen Sie nicht gehören. Sie können sich bei Bedarf selbst hinzufügen.",
|
||||
"no_other_teams_found": "Keine weiteren Teams gefunden",
|
||||
"no_other_teams_found_description": "Es gibt keine weiteren Teams in dieser Organisation.",
|
||||
"attendee_first_name_variable": "Vorname des Teilnehmers",
|
||||
"attendee_last_name_variable": "Nachname des Teilnehmers",
|
||||
"attendee_first_name_info": "Vorname der buchenden Person",
|
||||
"attendee_last_name_info": "Nachname der buchenden Person",
|
||||
"me": "Ich",
|
||||
"verify_team_tooltip": "Verifizieren Sie Ihr Team, um das Senden von Nachrichten an Teilnehmer zu aktivieren",
|
||||
"member_removed": "Mitglied entfernt",
|
||||
"my_availability": "Meine Verfügbarkeit",
|
||||
"team_availability": "Teamverfügbarkeit",
|
||||
"backup_code": "Backup-Code",
|
||||
"backup_codes": "Backup-Codes",
|
||||
"backup_code_instructions": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugriff ohne Ihren Authentifikator zu gewähren.",
|
||||
"backup_codes_copied": "Backup-Codes kopiert!",
|
||||
"incorrect_backup_code": "Backup-Code ist falsch.",
|
||||
"lost_access": "Zugriff verloren",
|
||||
"missing_backup_codes": "Keine Backup-Codes gefunden. Bitte generieren Sie diese in Ihren Einstellungen.",
|
||||
"admin_org_notification_email_subject": "Neue Organisation erstellt: Ausstehende Aktion",
|
||||
"hi_admin": "Hallo, Administrator",
|
||||
"admin_org_notification_email_title": "Eine Organisation benötigt DNS-Konfiguration",
|
||||
"admin_org_notification_email_body_part1": "Eine Organisation mit slug \"{{orgSlug}}\" wurde erstellt.<br /><br />Bitte konfigurieren Sie Ihre DNS-Registry so, dass die Subdomain der neuen Organisation auf die Hauptanwendung verweist. Andernfalls wird die Organisation nicht funktionieren.<br /><br />Hier sind nur die grundlegenden Optionen, um eine Subdomain so zu konfigurieren, dass sie auf ihre App verweist, so dass sie die Organisationsprofilseite lädt.<br /><br />SIe können es entweder mit dem A Record machen:",
|
||||
"admin_org_notification_email_body_part2": "Oder der CNAME-Eintrag:",
|
||||
"admin_org_notification_email_body_part3": "Sobald Sie die Subdomain konfiguriert haben, markieren Sie bitte die DNS-Konfiguration wie in den Organisationen Admin-Einstellungen.",
|
||||
"admin_org_notification_email_cta": "Zu den Organisationen Admin-Einstellungen wechseln",
|
||||
"org_has_been_processed": "Org wurde verarbeitet",
|
||||
"org_error_processing": "Beim Verarbeiten dieser Organisation ist ein Fehler aufgetreten",
|
||||
"orgs_page_description": "Eine Liste aller Organisationen. Wenn Sie eine Organisation akzeptieren, können sich alle Benutzer mit dieser E-Mail-Domain OHNE E-Mail-Überprüfung registrieren.",
|
||||
"unverified": "Nicht verifiziert",
|
||||
"dns_missing": "DNS fehlt",
|
||||
"mark_dns_configured": "Als DNS konfiguriert kennzeichnen",
|
||||
"value": "Wert",
|
||||
"your_organization_updated_sucessfully": "Ihre Organisation wurde erfolgreich aktualisiert",
|
||||
"team_no_event_types": "Dieses Team hat keine Ereignistypen",
|
||||
"seat_options_doesnt_multiple_durations": "Platzoption unterstützt mehrere Dauern nicht",
|
||||
"include_calendar_event": "Kalenderereignis hinzufügen",
|
||||
"recently_added": "Kürzlich hinzugefügt",
|
||||
"no_members_found": "Keine Mitglieder gefunden",
|
||||
"event_setup_length_error": "Ereignis-Einrichtung: Die Dauer muss mindestens 1 Minute betragen.",
|
||||
"availability_schedules": "Verfügbarkeitspläne",
|
||||
"view_only_edit_availability_not_onboarded": "Dieser Benutzer hat das Onboarding noch nicht abgeschlossen. Sie können seine Verfügbarkeit erst festlegen, wenn er das Onboarding abgeschlossen hat.",
|
||||
"view_only_edit_availability": "Sie sehen die Verfügbarkeit dieses Benutzers. Sie können nur Ihre eigene Verfügbarkeit bearbeiten.",
|
||||
"edit_users_availability": "Benutzerverfügbarkeit bearbeiten: {{username}}",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
"yours": "Your account",
|
||||
"available_apps": "Available Apps",
|
||||
"available_apps_lower_case": "Available apps",
|
||||
"available_apps_desc": "You have no apps installed. View popular apps below and explore more in our <1>App Store</1>",
|
||||
"available_apps_desc": "View popular apps below and explore more in our <1>App Store</1>",
|
||||
"fixed_host_helper": "Add anyone who needs to attend the event. <1>Learn more</1>",
|
||||
"round_robin_helper":"People in the group take turns and only one person will show up for the event.",
|
||||
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
||||
@@ -288,6 +288,7 @@
|
||||
"when": "When",
|
||||
"where": "Where",
|
||||
"add_to_calendar": "Add to calendar",
|
||||
"add_to_calendar_description":"Select where to add events when you’re booked.",
|
||||
"add_another_calendar": "Add another calendar",
|
||||
"other": "Other",
|
||||
"email_sign_in_subject": "Your sign-in link for {{appName}}",
|
||||
@@ -599,6 +600,7 @@
|
||||
"hide_book_a_team_member": "Hide Book a Team Member Button",
|
||||
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
|
||||
"danger_zone": "Danger zone",
|
||||
"account_deletion_cannot_be_undone":"Careful. Account deletion cannot be undone.",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"cancel_all_remaining": "Cancel all remaining",
|
||||
@@ -688,6 +690,7 @@
|
||||
"people": "People",
|
||||
"your_email": "Your Email",
|
||||
"change_avatar": "Change Avatar",
|
||||
"upload_avatar": "Upload Avatar",
|
||||
"language": "Language",
|
||||
"timezone": "Timezone",
|
||||
"first_day_of_week": "First Day of Week",
|
||||
@@ -826,7 +829,7 @@
|
||||
"confirm_delete_event_type": "Yes, delete",
|
||||
"delete_account": "Delete account",
|
||||
"confirm_delete_account": "Yes, delete account",
|
||||
"delete_account_confirmation_message": "Are you sure you want to delete your {{appName}} account? Anyone who you've shared your account link with will no longer be able to book using it and any preferences you have saved will be lost.",
|
||||
"delete_account_confirmation_message": "Anyone who you've shared your account link with will no longer be able to book using it and any preferences you have saved will be lost.",
|
||||
"integrations": "Integrations",
|
||||
"apps": "Apps",
|
||||
"apps_description": "Here you can find a list of your apps",
|
||||
@@ -1276,6 +1279,7 @@
|
||||
"personal_cal_url": "My personal {{appName}} URL",
|
||||
"bio_hint": "A few sentences about yourself. this will appear on your personal url page.",
|
||||
"user_has_no_bio": "This user has not added a bio yet.",
|
||||
"bio":"Bio",
|
||||
"delete_account_modal_title": "Delete Account",
|
||||
"confirm_delete_account_modal": "Are you sure you want to delete your {{appName}} account?",
|
||||
"delete_my_account": "Delete my account",
|
||||
@@ -1292,7 +1296,7 @@
|
||||
"customize_your_brand_colors": "Customize your own brand colour into your booking page.",
|
||||
"pro": "Pro",
|
||||
"removes_cal_branding": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}.'",
|
||||
"profile_picture": "Profile picture",
|
||||
"profile_picture": "Profile Picture",
|
||||
"upload": "Upload",
|
||||
"add_profile_photo": "Add profile photo",
|
||||
"web3": "Web3",
|
||||
@@ -1639,6 +1643,7 @@
|
||||
"minimum_round_robin_hosts_count": "Number of hosts required to attend",
|
||||
"hosts": "Hosts",
|
||||
"upgrade_to_enable_feature": "You need to create a team to enable this feature. Click to create a team.",
|
||||
"orgs_upgrade_to_enable_feature" : "You need to upgrade to our enterprise plan to enable this feature.",
|
||||
"new_attendee": "New Attendee",
|
||||
"awaiting_approval": "Awaiting Approval",
|
||||
"requires_google_calendar": "This app requires a Google Calendar connection",
|
||||
@@ -1743,7 +1748,8 @@
|
||||
"show_on_booking_page": "Show on booking page",
|
||||
"get_started_zapier_templates": "Get started with Zapier templates",
|
||||
"team_is_unpublished": "{{team}} is unpublished",
|
||||
"team_is_unpublished_description": "This {{entity}} link is currently not available. Please contact the {{entity}} owner or ask them to publish it.",
|
||||
"org_is_unpublished_description": "This organization link is currently not available. Please contact the organization owner or ask them to publish it.",
|
||||
"team_is_unpublished_description": "This team link is currently not available. Please contact the team owner or ask them to publish it.",
|
||||
"team_member": "Team member",
|
||||
"a_routing_form": "A Routing Form",
|
||||
"form_description_placeholder": "Form Description",
|
||||
@@ -1877,6 +1883,7 @@
|
||||
"edit_invite_link": "Edit link settings",
|
||||
"invite_link_copied": "Invite link copied",
|
||||
"invite_link_deleted": "Invite link deleted",
|
||||
"api_key_deleted":"API Key deleted",
|
||||
"invite_link_updated": "Invite link settings saved",
|
||||
"link_expires_after": "Links set to expire after...",
|
||||
"one_day": "1 day",
|
||||
@@ -2002,11 +2009,6 @@
|
||||
"requires_booker_email_verification": "Requires booker email verification",
|
||||
"description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events",
|
||||
"requires_confirmation_mandatory": "Text messages can only be sent to attendees when event type requires confirmation.",
|
||||
"kyc_verification_information": "To ensure security, you have to verify your {{teamOrAccount}} before sending text messages to attendees. Please contact us at <a>{{supportEmail}}</a> and provide the following information:",
|
||||
"kyc_verification_documents": "<ul><li>Your {{teamOrUser}}</li><li>For businesses: Attach your Business Verification Documentation</li><li>For individuals: Attach a government-issued ID</li></ul>",
|
||||
"verify_team_or_account": "Verify {{teamOrAccount}}",
|
||||
"verify_account": "Verify Account",
|
||||
"kyc_verification": "KYC Verification",
|
||||
"organizations": "Organizations",
|
||||
"org_admin_other_teams": "Other teams",
|
||||
"org_admin_other_teams_description": "Here you can see teams inside your organization that you are not part of. You can add yourself to them if needed.",
|
||||
@@ -2016,7 +2018,13 @@
|
||||
"attendee_last_name_variable": "Attendee last name",
|
||||
"attendee_first_name_info": "The person booking's first name",
|
||||
"attendee_last_name_info": "The person booking's last name",
|
||||
"your_monthly_digest": "Your Monthly Digest",
|
||||
"member_name": "Member Name",
|
||||
"most_popular_events": "Most Popular Events",
|
||||
"summary_of_events_for_your_team_for_the_last_30_days": "Here's your summary of popular events for your team {{teamName}} for the last 30 days",
|
||||
"me": "Me",
|
||||
"monthly_digest_email":"Monthly Digest Email",
|
||||
"monthly_digest_email_for_teams": "Monthly digest email for teams",
|
||||
"verify_team_tooltip": "Verify your team to enable sending messages to attendees",
|
||||
"member_removed": "Member removed",
|
||||
"my_availability": "My Availability",
|
||||
@@ -2053,5 +2061,8 @@
|
||||
"view_only_edit_availability_not_onboarded":"This user has not completed onboarding. You will not be able to set their availability until they have completed onboarding.",
|
||||
"view_only_edit_availability":"You are viewing this user's availability. You can only edit your own availability.",
|
||||
"edit_users_availability":"Edit user's availability: {{username}}",
|
||||
"resend_invitation": "Resend invitation",
|
||||
"invitation_resent": "The invitation was resent.",
|
||||
"this_app_is_not_setup_already": "This app has not been setup yet",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
"verify_email_banner_body": "Verifique su dirección de correo electrónico para garantizar la mejor entrega de correo electrónico y calendario",
|
||||
"verify_email_email_header": "Verifique su dirección de correo electrónico",
|
||||
"verify_email_email_button": "Verificar correo electrónico",
|
||||
"copy_somewhere_safe": "Guarde esta clave API en algún lugar seguro. No podrá volver a verla.",
|
||||
"verify_email_email_body": "Verifique su dirección de correo electrónico al hacer clic en el botón a continuación.",
|
||||
"verify_email_by_code_email_body": "Verifique su dirección de correo electrónico al hacer clic en el botón a continuación.",
|
||||
"verify_email_email_link_text": "Este es el enlace en caso de que no prefiera hacer clic en los botones:",
|
||||
"email_verification_code": "Ingrese el código de verificación",
|
||||
"email_verification_code_placeholder": "Ingrese el código de verificación enviado a su correo",
|
||||
"incorrect_email_verification_code": "El código de verificación es incorrecto.",
|
||||
"email_sent": "Correo electrónico enviado correctamente",
|
||||
"email_not_sent": "Se produjo un error al enviar el correo electrónico",
|
||||
"event_declined_subject": "Rechazado: {{title}} en {{date}}",
|
||||
"event_cancelled_subject": "Cancelado: {{title}} el {{date}}",
|
||||
"event_request_declined": "Su solicitud de reunión ha sido rechazada",
|
||||
@@ -78,6 +84,7 @@
|
||||
"event_awaiting_approval_recurring": "Un nuevo evento está esperando su aprobación",
|
||||
"someone_requested_an_event": "Alguien ha solicitado programar un evento en su calendario.",
|
||||
"someone_requested_password_reset": "Alguien ha solicitado un enlace para cambiar su contraseña.",
|
||||
"password_reset_email_sent": "Si este correo electrónico existe en nuestro sistema, debería recibir un correo electrónico de restablecimiento.",
|
||||
"password_reset_instructions": "Si usted no solicitó esto, puede ignorar este correo electrónico de forma segura y su contraseña no se cambiará.",
|
||||
"event_awaiting_approval_subject": "Esperando aprobación: {{title}} en {{date}}",
|
||||
"event_still_awaiting_approval": "Un evento aún está esperando su aprobación",
|
||||
@@ -217,6 +224,10 @@
|
||||
"already_have_an_account": "¿Ya tienes una cuenta?",
|
||||
"create_account": "Crear Cuenta",
|
||||
"confirm_password": "Confirmar Contraseña",
|
||||
"confirm_auth_change": "Esto cambiará la forma de iniciar sesión",
|
||||
"confirm_auth_email_change": "Cambiar la dirección de correo electrónico desconectará su método de autenticación actual para iniciar sesión en Cal.com. Le pediremos que verifique su nueva dirección de correo electrónico. En el futuro, se cerrará su sesión y utilizará su nueva dirección de correo electrónico para iniciar sesión en lugar de su método de autenticación actual después de configurar su contraseña siguiendo las instrucciones que se enviarán a su correo.",
|
||||
"reset_your_password": "Establezca su nueva contraseña con las instrucciones enviadas a su dirección de correo electrónico.",
|
||||
"email_change": "Vuelva a iniciar sesión con su nueva dirección de correo electrónico y contraseña.",
|
||||
"create_your_account": "Crea tu Cuenta",
|
||||
"sign_up": "Regístrate",
|
||||
"youve_been_logged_out": "Has cerrado la sesión",
|
||||
@@ -243,6 +254,10 @@
|
||||
"all": "Todas las aplicaciones",
|
||||
"yours": "Su cuenta",
|
||||
"available_apps": "Aplicaciones disponibles",
|
||||
"available_apps_lower_case": "Aplicaciones disponibles",
|
||||
"available_apps_desc": "No tiene aplicaciones instaladas. Vea las aplicaciones populares a continuación y explore más en nuestra <1>App Store</1>",
|
||||
"fixed_host_helper": "Agregue a cualquier persona que necesite asistir al evento. <1>Más información</1>",
|
||||
"round_robin_helper": "Las personas del grupo se turnan y solo una persona se presentará al evento.",
|
||||
"check_email_reset_password": "Revisa tu email. Te hemos enviado un enlace para restablecer tu contraseña.",
|
||||
"finish": "Terminar",
|
||||
"organization_general_description": "Administre los ajustes para el idioma y la zona horaria de su equipo",
|
||||
@@ -251,6 +266,7 @@
|
||||
"nearly_there_instructions": "Lo último, una breve descripción sobre usted y una foto lo ayudan a obtener reservas y a que las personas sepan con quién están reservando.",
|
||||
"set_availability_instructions": "Defina los intervalos de tiempo cuando esté disponible de forma recurrente. Puede crear más tarde y asignarlos a diferentes calendarios.",
|
||||
"set_availability": "Establecer Disponibilidad",
|
||||
"availability_settings": "Configuración de disponibilidad",
|
||||
"continue_without_calendar": "Continuar sin calendario",
|
||||
"connect_your_calendar": "Conecta tu calendario",
|
||||
"connect_your_video_app": "Conecte sus aplicaciones favoritas",
|
||||
@@ -392,6 +408,8 @@
|
||||
"allow_dynamic_booking_tooltip": "Los enlaces de reservas grupales se pueden crear dinámicamente añadiendo varios nombres de usuario con un '+'. Ejemplo: '{{appName}}/bailey+peer'",
|
||||
"allow_dynamic_booking": "Permitir que los asistentes lo reserven a través de reservas dinámicas de grupo",
|
||||
"dynamic_booking": "Enlaces dinámicos de grupo",
|
||||
"allow_seo_indexing": "Permita que los motores de búsqueda accedan a su contenido público",
|
||||
"seo_indexing": "Permitir indexación SEO",
|
||||
"email": "Correo electrónico",
|
||||
"email_placeholder": "nombre@ejemplo.com",
|
||||
"full_name": "Nombre Completo",
|
||||
@@ -406,6 +424,7 @@
|
||||
"booking_requested": "Reserva solicitada",
|
||||
"meeting_ended": "Reunión finalizada",
|
||||
"form_submitted": "Formulario enviado",
|
||||
"booking_paid": "Reserva pagada",
|
||||
"event_triggers": "Activadores de Eventos",
|
||||
"subscriber_url": "URL del suscriptor",
|
||||
"create_new_webhook": "Crear un nuevo Webhook",
|
||||
@@ -506,6 +525,7 @@
|
||||
"your_name": "Tu Nombre",
|
||||
"your_full_name": "Su nombre completo",
|
||||
"no_name": "Sin nombre",
|
||||
"enter_number_between_range": "Introduzca un número entre 1 y {{maxOccurences}}",
|
||||
"email_address": "Email",
|
||||
"enter_valid_email": "Ingresa un correo electrónico válido",
|
||||
"location": "Ubicación",
|
||||
@@ -543,6 +563,7 @@
|
||||
"leave": "Abandonar",
|
||||
"profile": "Perfil",
|
||||
"my_team_url": "URL de Mi Equipo",
|
||||
"my_teams": "Mis equipos",
|
||||
"team_name": "Nombre del equipo",
|
||||
"your_team_name": "Nombre de tu Equipo",
|
||||
"team_updated_successfully": "Equipo Actualizado Correctamente",
|
||||
@@ -570,6 +591,8 @@
|
||||
"invite_new_member": "Invitar a un nuevo miembro",
|
||||
"invite_new_member_description": "Nota: Esto costará <1>una plaza adicional ($15/m)</1> en su suscripción.",
|
||||
"invite_new_team_member": "Invita a alguien a tu equipo.",
|
||||
"upload_csv_file": "Cargar un archivo .csv",
|
||||
"invite_via_email": "Invitar por correo electrónico",
|
||||
"change_member_role": "Cambiar rol del miembro del equipo",
|
||||
"disable_cal_branding": "Desactivar la marca de {{appName}}",
|
||||
"disable_cal_branding_description": "Ocultar todas las marcas de {{appName}} de sus páginas públicas.",
|
||||
@@ -803,7 +826,7 @@
|
||||
"confirm_delete_event_type": "Sí, eliminar",
|
||||
"delete_account": "Eliminar cuenta",
|
||||
"confirm_delete_account": "Sí, eliminar cuenta",
|
||||
"delete_account_confirmation_message": "¿Estás seguro de que quieres eliminar tu cuenta de {{appName}}? Cualquier persona con la que hayas compartido el enlace de tu cuenta ya no podrá agendar con ella y cualquier preferencia que hayas guardado se perderá.",
|
||||
"delete_account_confirmation_message": "Cualquier persona con la que hayas compartido el enlace de tu cuenta ya no podrá agendar con ella y cualquier preferencia que hayas guardado se perderá.",
|
||||
"integrations": "Integraciones",
|
||||
"apps": "Aplicaciones",
|
||||
"apps_description": "Aquí puede encontrar una lista de sus aplicaciones",
|
||||
@@ -842,6 +865,7 @@
|
||||
"team_view_user_availability_disabled": "El usuario necesita aceptar la invitación para ver su disponibilidad",
|
||||
"set_as_away": "Activar estado Ausente",
|
||||
"set_as_free": "Desactivar estado Ausente",
|
||||
"toggle_away_error": "Error al actualizar el estado ausente",
|
||||
"user_away": "Este usuario se encuentra Ausente.",
|
||||
"user_away_description": "La persona a la que estás intentando reservar se ha marcado a sí misma como Ausente, por lo tanto, no acepta nuevas reservas por ahora.",
|
||||
"meet_people_with_the_same_tokens": "Conoce personas con los mismos tokens",
|
||||
@@ -851,6 +875,7 @@
|
||||
"account_managed_by_identity_provider_description": "Para cambiar tu correo electrónico, contraseña, habilitar la autenticación de doble factor y más, por favor visita la configuración de tu cuenta de {{provider}}.",
|
||||
"signin_with_google": "Iniciar sesión con Google",
|
||||
"signin_with_saml": "Iniciar sesión con SAML",
|
||||
"signin_with_saml_oidc": "Inicie sesión con SAML/OIDC",
|
||||
"you_will_need_to_generate": "Necesitarás generar un token de acceso desde tu antigua herramienta de programación.",
|
||||
"import": "Importar",
|
||||
"import_from": "Importar desde",
|
||||
@@ -950,6 +975,8 @@
|
||||
"offer_seats_description": "Ofrecer plazas para las reservas (desactiva las reservas de invitados y las optativas)",
|
||||
"seats_available_one": "Plazas disponibles",
|
||||
"seats_available_other": "Plazas disponibles",
|
||||
"seats_nearly_full": "Cupos casi llenos",
|
||||
"seats_half_full": "Los cupos se están copando rápido",
|
||||
"number_of_seats": "Número de plazas por reserva",
|
||||
"enter_number_of_seats": "Introduzca el número de plazas",
|
||||
"you_can_manage_your_schedules": "Puede administrar sus horarios en la página Disponibilidad.",
|
||||
@@ -1030,12 +1057,15 @@
|
||||
"how_you_want_add_cal_site": "¿Cómo quiere añadir {{appName}} a su sitio?",
|
||||
"choose_ways_put_cal_site": "Elija una de las siguientes maneras de poner {{appName}} en su sitio.",
|
||||
"setting_up_zapier": "Configurando su integración con Zapier",
|
||||
"setting_up_make": "Configurando su integración Make",
|
||||
"generate_api_key": "Generar clave API",
|
||||
"generate_api_key_description": "Generar una clave API para usar con {{appName}} en",
|
||||
"your_unique_api_key": "Su clave API única",
|
||||
"copy_safe_api_key": "Copie esta clave API y guárdela en algún lugar seguro. Si pierde esta clave tendrá que generar una nueva.",
|
||||
"zapier_setup_instructions": "<0>Inicie sesión en su cuenta de Zapier y cree un nuevo Zap.</0><1>Seleccione Cal.com cómo su aplicación Trigger. También elija un evento Trigger.</1><2>Seleccione su cuenta e ingrese su Clave API única.</2><3>Pruebe su Trigger.</3><4>¡Listo!</4>",
|
||||
"make_setup_instructions": "<0>Vaya al <1><0>Enlace de invitación de Make</0></1> e instale la aplicación Cal.com.</0><1>Inicie sesión en su cuenta de Make y cree un nuevo escenario.</1><2>Seleccione Cal.com como su aplicación de activación. Elija también un evento de activación.</2><3>Elija su cuenta y luego ingrese su clave API única.</3><4>Pruebe su activador.</4><5>¡Ya está!</5 >",
|
||||
"install_zapier_app": "Instale primero la aplicación Zapier desde la App Store.",
|
||||
"install_make_app": "Instale primero la aplicación Make desde la App Store.",
|
||||
"connect_apple_server": "Conectar con Apple Server",
|
||||
"calendar_url": "URL del calendario",
|
||||
"apple_server_generate_password": "Genere una contraseña específica de la aplicación para utilizarla con {{appName}} en",
|
||||
@@ -1085,6 +1115,7 @@
|
||||
"email_attendee_action": "enviar correo electrónico a los asistentes",
|
||||
"sms_attendee_action": "Enviar un SMS al asistente",
|
||||
"sms_number_action": "enviar SMS a un número específico",
|
||||
"send_reminder_sms": "Envíe fácilmente recordatorios de reuniones por SMS a sus asistentes",
|
||||
"whatsapp_number_action": "enviar un mensaje de Whatsapp a un número específico",
|
||||
"whatsapp_attendee_action": "enviar un mensae de Whatsapp al asistente",
|
||||
"workflows": "Flujos de trabajo",
|
||||
@@ -1244,6 +1275,7 @@
|
||||
"error_updating_settings": "Error al actualizar la configuración",
|
||||
"personal_cal_url": "Mi URL personal de {{appName}}",
|
||||
"bio_hint": "Algunas frases sobre usted, esta información aparecerá en la página de su URL personal.",
|
||||
"user_has_no_bio": "Este usuario no ha añadido una biografía todavía.",
|
||||
"delete_account_modal_title": "Eliminar cuenta",
|
||||
"confirm_delete_account_modal": "¿Está seguro que desea eliminar su cuenta de {{appName}}?",
|
||||
"delete_my_account": "Eliminar mi cuenta",
|
||||
@@ -1426,6 +1458,7 @@
|
||||
"add_limit": "Agregue un límite",
|
||||
"team_name_required": "Nombre de equipo requerido",
|
||||
"show_attendees": "Comparta información de los asistentes entre los invitados",
|
||||
"show_available_seats_count": "Mostrar el número de cupos disponibles",
|
||||
"how_booking_questions_as_variables": "¿Cómo utilizar las preguntas de reserva como variables?",
|
||||
"format": "Formato",
|
||||
"uppercase_for_letters": "Use mayúsculas para todas las letras",
|
||||
@@ -1659,8 +1692,11 @@
|
||||
"delete_sso_configuration_confirmation_description": "¿Está seguro de que desea eliminar la configuración de {{connectionType}}? Los miembros de su equipo que utilicen el inicio de sesión {{connectionType}} ya no podrán acceder a Cal.com.",
|
||||
"organizer_timezone": "Zona horaria del organizador",
|
||||
"email_user_cta": "Ver la invitación",
|
||||
"email_no_user_invite_heading_team": "Lo han invitado a unirse a un equipo en {{appName}}",
|
||||
"email_no_user_invite_heading_org": "Lo han invitado a unirse a una organización en {{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} lo invitó a unirse a su equipo en {{appName}}. {{appName}} es el programador de eventos que les permite a usted y a su equipo programar reuniones sin necesidad de enviar correos electrónicos.",
|
||||
"email_user_invite_subheading_team": "{{invitedBy}} lo ha invitado a unirse a su equipo \"{{teamName}}\" en {{appName}}. {{appName}} es el planificador de eventos que le permite a usted y a su equipo programar reuniones sin correos electrónicos de ida y vuelta.",
|
||||
"email_user_invite_subheading_org": "{{invitedBy}} lo ha invitado a unirse a su organización \"{{teamName}}\" en {{appName}}. {{appName}} es el programador de eventos que le permite a usted y a su organización programar reuniones sin el tenis del correo electrónico.",
|
||||
"email_no_user_invite_steps_intro": "Lo guiaremos a través de unos pocos pasos y disfrutará de una programación sin estrés con su {{entity}} en muy poco tiempo.",
|
||||
"email_no_user_step_one": "Elija su nombre de usuario",
|
||||
"email_no_user_step_two": "Conecte su cuenta de calendario",
|
||||
@@ -1831,6 +1867,8 @@
|
||||
"insights_no_data_found_for_filter": "No se encontraron datos para el filtro seleccionado o las fechas seleccionadas.",
|
||||
"acknowledge_booking_no_show_fee": "Reconozco que si no asisto a este evento, se aplicará a mi tarjeta una tarifa de {{amount, currency}} por no presentarme.",
|
||||
"card_details": "Detalles de la tarjeta",
|
||||
"something_went_wrong_on_our_end": "Algo salió mal por nuestra parte. Póngase en contacto con nuestro equipo de soporte y lo solucionaremos de inmediato.",
|
||||
"please_provide_following_text_to_suppport": "Proporcione el siguiente texto cuando se comunique con el soporte para ayudarlo mejor",
|
||||
"seats_and_no_show_fee_error": "Actualmente, no se pueden habilitar cupos ni cobrar una tarifa por no presentarse",
|
||||
"complete_your_booking": "Complete su reserva",
|
||||
"complete_your_booking_subject": "Complete su reserva: {{title}} el {{date}}",
|
||||
@@ -1864,6 +1902,9 @@
|
||||
"open_dialog_with_element_click": "Abre su cuadro de diálogo Cal cuando alguien hace clic en un elemento.",
|
||||
"need_help_embedding": "¿Necesita ayuda? Consulte nuestras guías para incrustar Cal en Wix, Squarespace o WordPress, consulte nuestras preguntas comunes o explore las opciones avanzadas de incrustación.",
|
||||
"book_my_cal": "Reservar mi Cal",
|
||||
"first_name": "Nombre",
|
||||
"last_name": "Nombre",
|
||||
"first_last_name": "Nombre, Apellido",
|
||||
"invite_as": "Invitar como",
|
||||
"form_updated_successfully": "Formulario actualizado con éxito.",
|
||||
"disable_attendees_confirmation_emails": "Desactivar los correos electrónicos de confirmación predeterminados para los asistentes",
|
||||
@@ -1877,6 +1918,7 @@
|
||||
"first_event_type_webhook_description": "Cree su primer webhook para este tipo de evento",
|
||||
"install_app_on": "Instalar aplicación en",
|
||||
"create_for": "Crear para",
|
||||
"currency": "Moneda",
|
||||
"organization_banner_description": "Cree entornos en los que sus equipos puedan crear aplicaciones, flujos de trabajo y tipos de eventos compartidos con programación por turnos y colectiva.",
|
||||
"organization_banner_title": "Gestione organizaciones con varios equipos",
|
||||
"set_up_your_organization": "Configure su organización",
|
||||
@@ -1937,6 +1979,74 @@
|
||||
"insights_team_filter": "Equipo: {{teamName}}",
|
||||
"insights_user_filter": "Usuario: {{userName}}",
|
||||
"insights_subtitle": "Mire información de insights en todos sus eventos",
|
||||
"location_options": "{{locationCount}} opciones de ubicación",
|
||||
"custom_plan": "Plan personalizado",
|
||||
"email_embed": "Incrustado en el correo electrónico",
|
||||
"add_times_to_your_email": "Seleccione algunos horarios disponibles e incrústelos en su correo electrónico",
|
||||
"select_time": "Seleccione la hora",
|
||||
"select_date": "Seleccione la fecha",
|
||||
"see_all_available_times": "Ver todas las horas disponibles",
|
||||
"org_team_names_example": "ej. Equipo de marketing",
|
||||
"org_team_names_example_1": "ej. Equipo de marketing",
|
||||
"org_team_names_example_2": "ej. Equipo de ventas",
|
||||
"org_team_names_example_3": "ej. Equipo de diseño",
|
||||
"org_team_names_example_4": "ej. Equipo de ingeniería",
|
||||
"org_team_names_example_5": "ej. Equipo de análisis de datos",
|
||||
"org_max_team_warnings": "Más adelante podrá añadir más equipos.",
|
||||
"what_is_this_meeting_about": "¿De qué trata esta reunión?",
|
||||
"add_to_team": "Agregar al equipo",
|
||||
"remove_users_from_org": "Eliminar usuarios de la organización",
|
||||
"remove_users_from_org_confirm": "¿Está seguro de que desea eliminar {{userCount}} usuarios de esta organización?",
|
||||
"user_has_no_schedules": "Este usuario no ha configurado ningún horario todavía",
|
||||
"user_isnt_in_any_teams": "Este usuario no está en ningún equipo",
|
||||
"requires_booker_email_verification": "Requiere verificación por correo electrónico de quien reserva",
|
||||
"description_requires_booker_email_verification": "Para asegurar la verificación del correo electrónico de quien reserva antes de programar eventos",
|
||||
"requires_confirmation_mandatory": "Los mensajes de texto solo se pueden enviar a los asistentes cuando el tipo de evento requiere confirmación.",
|
||||
"organizations": "Organizaciones",
|
||||
"org_admin_other_teams": "Otros equipos",
|
||||
"org_admin_other_teams_description": "Aquí puede ver los equipos dentro de su organización de los que no forma parte. Puede agregarse a ellos si es necesario.",
|
||||
"no_other_teams_found": "No se encontraron otros equipos",
|
||||
"no_other_teams_found_description": "No hay otros equipos en esta organización.",
|
||||
"attendee_first_name_variable": "Nombre del asistente",
|
||||
"attendee_last_name_variable": "Apellido del asistente",
|
||||
"attendee_first_name_info": "Nombre de la persona que reserva",
|
||||
"attendee_last_name_info": "Apellido de la persona que reserva",
|
||||
"me": "Yo",
|
||||
"verify_team_tooltip": "Verifique su equipo para activar el envío de mensajes a los asistentes",
|
||||
"member_removed": "Miembro eliminado",
|
||||
"my_availability": "Mi disponibilidad",
|
||||
"team_availability": "Disponibilidad del equipo",
|
||||
"backup_code": "Código de respaldo",
|
||||
"backup_codes": "Códigos de respaldo",
|
||||
"backup_code_instructions": "Cada código de respaldo se puede utilizar exactamente una vez para conceder acceso sin su autenticador.",
|
||||
"backup_codes_copied": "¡Códigos de respaldo copiados!",
|
||||
"incorrect_backup_code": "El código de respaldo no es correcto.",
|
||||
"lost_access": "Acceso perdido",
|
||||
"missing_backup_codes": "No se encontraron códigos de respaldo. Genere los códigos en su configuración.",
|
||||
"admin_org_notification_email_subject": "Nueva organización creada: acción pendiente",
|
||||
"hi_admin": "Hola Administrador",
|
||||
"admin_org_notification_email_title": "Una organización requiere la configuración DNS",
|
||||
"admin_org_notification_email_body_part1": "Se creó una organización con slug \"{{orgSlug}}\".<br /><br />Asegúrese de configurar su registro DNS para que apunte el subdominio correspondiente a la nueva organización hacia donde se ejecuta la aplicación principal. De lo contrario, la organización no funcionará.<br /><br />Estas son solo las opciones muy básicas para configurar un subdominio para que apunte a su aplicación para que cargue la página de perfil de la organización.<br /><br />Puede hacerlo ya sea con el Registro A:",
|
||||
"admin_org_notification_email_body_part2": "O el registro CNAME:",
|
||||
"admin_org_notification_email_body_part3": "Una vez que configure el subdominio, marque la configuración DNS como se hace en la Configuración de administrador.",
|
||||
"admin_org_notification_email_cta": "Ir a la Configuración del administrador de organizaciones",
|
||||
"org_has_been_processed": "Se procesó la organización",
|
||||
"org_error_processing": "Ocurrió un error al procesar esta organización",
|
||||
"orgs_page_description": "Una lista de todas las organizaciones. Aceptar una organización permitirá que todos los usuarios con ese dominio de correo electrónico se registren SIN verificación de correo electrónico.",
|
||||
"unverified": "Sin verificar",
|
||||
"dns_missing": "Falta el DNS",
|
||||
"mark_dns_configured": "Marcar como DNS configurado",
|
||||
"value": "Valor",
|
||||
"your_organization_updated_sucessfully": "Su organization se actualizó con éxito",
|
||||
"team_no_event_types": "Este equipo no tiene tipos de eventos",
|
||||
"seat_options_doesnt_multiple_durations": "La opción Cupo no soporta múltiples duraciones",
|
||||
"include_calendar_event": "Incluir evento del calendario",
|
||||
"recently_added": "Añadido recientemente",
|
||||
"no_members_found": "No se encontraron miembros",
|
||||
"event_setup_length_error": "Configuración del evento: la duración debe ser de al menos 1 minuto.",
|
||||
"availability_schedules": "Horarios de disponibilidad",
|
||||
"view_only_edit_availability_not_onboarded": "Este usuario no ha completado la incorporación. No podrá establecer su disponibilidad hasta que haya completado la incorporación.",
|
||||
"view_only_edit_availability": "Está viendo la disponibilidad de este usuario. Sólo puede editar su propia disponibilidad.",
|
||||
"edit_users_availability": "Editar disponibilidad del usuario: {{username}}",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agregue sus nuevas cadenas arriba ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -826,7 +826,7 @@
|
||||
"confirm_delete_event_type": "Oui, supprimer",
|
||||
"delete_account": "Supprimer le compte",
|
||||
"confirm_delete_account": "Oui, supprimer le compte",
|
||||
"delete_account_confirmation_message": "Voulez-vous vraiment supprimer votre compte {{appName}} ? Toute personne avec qui vous avez partagé le lien de votre compte ne pourra plus réserver en utilisant ce lien et toutes les préférences que vous avez sauvegardées seront perdues.",
|
||||
"delete_account_confirmation_message": "Toute personne avec qui vous avez partagé le lien de votre compte ne pourra plus réserver en utilisant ce lien et toutes les préférences que vous avez sauvegardées seront perdues.",
|
||||
"integrations": "Intégrations",
|
||||
"apps": "Applications",
|
||||
"apps_description": "Vous trouverez ici une liste de vos applications.",
|
||||
@@ -1743,7 +1743,8 @@
|
||||
"show_on_booking_page": "Afficher sur la page de réservation",
|
||||
"get_started_zapier_templates": "Démarrer avec les modèles Zapier",
|
||||
"team_is_unpublished": "{{team}} n'est pas publiée",
|
||||
"team_is_unpublished_description": "Ce lien d'{{entity}} n'est actuellement pas disponible. Veuillez contacter le propriétaire de l'{{entity}} ou lui demander de le publier.",
|
||||
"org_is_unpublished_description": "Ce lien d'organisation n'est actuellement pas disponible. Veuillez contacter le propriétaire de l'organisation ou lui demander de le publier.",
|
||||
"team_is_unpublished_description": "Ce lien d'équipe n'est actuellement pas disponible. Veuillez contacter le propriétaire de l'équipe ou lui demander de le publier.",
|
||||
"team_member": "Membre d'équipe",
|
||||
"a_routing_form": "Un formulaire de routage",
|
||||
"form_description_placeholder": "Description du formulaire",
|
||||
@@ -2002,11 +2003,6 @@
|
||||
"requires_booker_email_verification": "Nécessite la vérification par e-mail du participant",
|
||||
"description_requires_booker_email_verification": "Pour assurer la vérification par e-mail du participant avant la planification des événements.",
|
||||
"requires_confirmation_mandatory": "Les messages ne peuvent être envoyés aux participants que lorsque le type d'événement nécessite une confirmation.",
|
||||
"kyc_verification_information": "Pour garantir la sécurité, vous devez vérifier votre {{teamOrAccount}} avant d'envoyer des messages aux participants. Veuillez nous contacter à <a>{{supportEmail}}</a> et fournir les informations suivantes :",
|
||||
"kyc_verification_documents": "<ul><li>Votre {{teamOrUser}}</li><li>Pour les entreprises : Joignez votre document de vérification d'entreprise</li><li>Pour les particuliers : Joignez une pièce d'identité</li></ul>",
|
||||
"verify_team_or_account": "Vérifier {{teamOrAccount}}",
|
||||
"verify_account": "Vérifier le compte",
|
||||
"kyc_verification": "Vérification KYC",
|
||||
"organizations": "Organisations",
|
||||
"org_admin_other_teams": "Autres équipes",
|
||||
"org_admin_other_teams_description": "Ici, vous pouvez voir les équipes au sein de votre organisation dont vous ne faites pas partie. Vous pouvez vous y ajouter si nécessaire.",
|
||||
@@ -2016,7 +2012,13 @@
|
||||
"attendee_last_name_variable": "Nom du participant",
|
||||
"attendee_first_name_info": "Le prénom du participant",
|
||||
"attendee_last_name_info": "Le nom de famille du participant",
|
||||
"your_monthly_digest": "Votre résumé mensuel",
|
||||
"member_name": "Nom du membre",
|
||||
"most_popular_events": "Événements les plus populaires",
|
||||
"summary_of_events_for_your_team_for_the_last_30_days": "Voici votre résumé des événements populaires pour votre équipe {{teamName}} au cours des 30 derniers jours",
|
||||
"me": "Moi",
|
||||
"monthly_digest_email": "E-mail de résumé mensuel",
|
||||
"monthly_digest_email_for_teams": "E-mail de résumé mensuel pour les équipes",
|
||||
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
|
||||
"member_removed": "Membre supprimé",
|
||||
"my_availability": "Mes disponibilités",
|
||||
@@ -2046,7 +2048,7 @@
|
||||
"team_no_event_types": "Cette équipe n'a aucun type d'événement",
|
||||
"seat_options_doesnt_multiple_durations": "L'option par place ne prend pas en charge les durées multiples",
|
||||
"include_calendar_event": "Inclure l'événement du calendrier",
|
||||
"recently_added": "Ajouté récemment",
|
||||
"recently_added": "Ajoutées récemment",
|
||||
"no_members_found": "Aucun membre trouvé",
|
||||
"event_setup_length_error": "Configuration de l'événement : la durée doit être d'au moins 1 minute.",
|
||||
"availability_schedules": "Horaires de disponibilité",
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
"verify_email_banner_body": "יש לאמת את כתובת הדוא״ל כדי להבטיח שתוכל/י לקבל הודעות לכתובת הדוא״ל וללוח השנה בצורה הטובה ביותר",
|
||||
"verify_email_email_header": "יש לאמת את כתובת הדוא״ל שלך",
|
||||
"verify_email_email_button": "אימות כתובת דוא״ל",
|
||||
"copy_somewhere_safe": "חשוב לשמור את מפתח ה-API הזה במקום בטוח. לא תהיה אפשרות לראות אותו שוב.",
|
||||
"verify_email_email_body": "נא לאמת את כתובת הדוא״ל שלך על ידי לחיצה על הכפתור להלן.",
|
||||
"verify_email_by_code_email_body": "נא לאמת את כתובת הדוא״ל באמצעות הקוד שמתחת.",
|
||||
"verify_email_email_link_text": "הנה הקישור, למקרה שאינך אוהב/ת ללחוץ על כפתורים:",
|
||||
"email_verification_code": "הזנת קוד האימות",
|
||||
"email_verification_code_placeholder": "יש להזין את קוד האימות שנשלח אליך בדוא\"ל",
|
||||
"incorrect_email_verification_code": "קוד האימות שגוי.",
|
||||
"email_sent": "הודעת הדוא״ל נשלחה בהצלחה",
|
||||
"email_not_sent": "אירעה שגיאה בעת שליחת הדוא\"ל",
|
||||
"event_declined_subject": "נדחה: {{title}} ב- {{date}}",
|
||||
"event_cancelled_subject": "בוטל: {{title}} ב-{{date}}",
|
||||
"event_request_declined": "בקשת האירוע שלך נדחתה",
|
||||
@@ -78,6 +84,7 @@
|
||||
"event_awaiting_approval_recurring": "אירוע חוזר ממתין לאישורך",
|
||||
"someone_requested_an_event": "מישהו ביקש לקבוע מועד לאירוע בלוח השנה שלך.",
|
||||
"someone_requested_password_reset": "מישהו ביקש קישור כדי לשנות את הסיסמה שלך.",
|
||||
"password_reset_email_sent": "אם כתובת הדוא\"ל הזו קיימת במערכת שלנו, אמור להישלח אליך דוא\"ל לאיפוס.",
|
||||
"password_reset_instructions": "אם הבקשה לא היתה שלך, ניתן להתעלם מהודעת הדוא\"ל הזו בבטחה, והסיסמה שלך לא תשתנה.",
|
||||
"event_awaiting_approval_subject": "ממתין לאישור: {{title}} ב- {{date}}",
|
||||
"event_still_awaiting_approval": "אירוע עדיין ממתין לאישורך",
|
||||
@@ -217,6 +224,10 @@
|
||||
"already_have_an_account": "כבר יש לך חשבון?",
|
||||
"create_account": "צור חשבון",
|
||||
"confirm_password": "אישור סיסמה",
|
||||
"confirm_auth_change": "זה ישנה את דרך ההתחברות שלך",
|
||||
"confirm_auth_email_change": "שינוי כתובת הדוא\"ל ינתק את שיטת האימות הנוכחית שמשמשת אותך לכניסה אל Cal.com. נבקש ממך לאמת את כתובת הדוא\"ל החדשה שלך. מעכשיו, המערכת תנתק אותך מהחשבון ויהיה עליך להשתמש בכתובת הדוא\"ל החדשה שלך כדי להתחבר, במקום בשיטת האימות הנוכחית שלך לאחר שתגדיר/י סיסמה לפי ההוראות שיישלחו אליך בדוא\"ל.",
|
||||
"reset_your_password": "הגדר/י את הסיסמה החדשה שלך לפי ההוראות שנשלחו אל כתובת הדוא\"ל שלך.",
|
||||
"email_change": "התחבר/י שוב עם כתובת הדוא\"ל החדשה והסיסמה.",
|
||||
"create_your_account": "צור את החשבון שלך",
|
||||
"sign_up": "הרשמה",
|
||||
"youve_been_logged_out": "יצאת מהמערכת",
|
||||
@@ -243,6 +254,10 @@
|
||||
"all": "כל האפליקציות",
|
||||
"yours": "החשבון שלך",
|
||||
"available_apps": "אפליקציות זמינות",
|
||||
"available_apps_lower_case": "אפליקציות זמינות",
|
||||
"available_apps_desc": "לא מותקנת אצלך אף אפליקציה. ראה/י אפליקציות פופולריות מתחת וסייר/י כדי לראות אפליקציות נוספות ב-<1>App Store</1> שלנו",
|
||||
"fixed_host_helper": "הוסף/י את כל מי שצריך להשתתף באירוע. <1>מידע נוסף</1>",
|
||||
"round_robin_helper": "האנשים בקבוצה משתתפים כל אחד בתורו ורק אדם אחד יגיע לאירוע.",
|
||||
"check_email_reset_password": "בדוק/י בתיבת הדוא\"ל – שלחנו לך קישור לאיפוס הסיסמה.",
|
||||
"finish": "סיום",
|
||||
"organization_general_description": "ניהול הגדרות השפה ואזור הזמן של הצוות",
|
||||
@@ -251,6 +266,7 @@
|
||||
"nearly_there_instructions": "דבר אחרון: תיאור קצר אודותיך/ייך בתוספת תמונה עוזרים מאוד להשיג הזמנות ומאפשרים לאנשים לדעת עם מי הם עומדים להיפגש.",
|
||||
"set_availability_instructions": "הגדר טווחי זמן שבהם אתה זמין באופן קבוע. ניתן יהיה ליצור טווחי זמן נוספים מאוחר יותר ולהקצות אותם ללוחות שנה אחרים.",
|
||||
"set_availability": "ציין את הזמינות שלך",
|
||||
"availability_settings": "הגדרות זמינוּת",
|
||||
"continue_without_calendar": "להמשיך בלי לוח שנה",
|
||||
"connect_your_calendar": "קשר את לוח השנה שלך",
|
||||
"connect_your_video_app": "חיבור אפליקציות הווידאו שלך",
|
||||
@@ -392,6 +408,8 @@
|
||||
"allow_dynamic_booking_tooltip": "קישורי הזמנות קבוצתיות שניתן ליצור באופן דינמי על ידי הוספת שמות משתמשים מרובים עם '+', למשל: '{{appName}}/bailey+peer'",
|
||||
"allow_dynamic_booking": "לאפשר למשתתפים לשריין איתך פגישה באמצעות הזמנות קבוצתיות דינמיות",
|
||||
"dynamic_booking": "קישורים לקבוצות דינמיות",
|
||||
"allow_seo_indexing": "מתן אפשרות למנועי חיפוש לגשת לתוכן הציבורי שלך",
|
||||
"seo_indexing": "מתן אפשרות להוספה לאינדקסים של SEO",
|
||||
"email": "דוא\"ל",
|
||||
"email_placeholder": "iisraeli@example.com",
|
||||
"full_name": "שם מלא",
|
||||
@@ -406,6 +424,7 @@
|
||||
"booking_requested": "התקבלה בקשת הזמנה",
|
||||
"meeting_ended": "הפגישה הסתיימה",
|
||||
"form_submitted": "הטופס נשלח",
|
||||
"booking_paid": "בוצע תשלום עבור ההזמנה",
|
||||
"event_triggers": "גורמים מפעילים של אירועים",
|
||||
"subscriber_url": "כתובת URL של המנוי/ה",
|
||||
"create_new_webhook": "יצירת Webhook חדש",
|
||||
@@ -506,6 +525,7 @@
|
||||
"your_name": "שמך",
|
||||
"your_full_name": "שמך המלא",
|
||||
"no_name": "ללא שם",
|
||||
"enter_number_between_range": "נא להזין מספר בין 1 ל-{{maxOccurences}}",
|
||||
"email_address": "כתובת דוא\"ל",
|
||||
"enter_valid_email": "יש להזין כתובת דוא״ל תקינה",
|
||||
"location": "מיקום",
|
||||
@@ -543,6 +563,7 @@
|
||||
"leave": "יציאה",
|
||||
"profile": "פרופיל",
|
||||
"my_team_url": "כתובת ה-URL של הצוות שלי",
|
||||
"my_teams": "הצוותים שלי",
|
||||
"team_name": "שם הצוות",
|
||||
"your_team_name": "שם הצוות שלך",
|
||||
"team_updated_successfully": "עדכון הצוות בוצע בהצלחה",
|
||||
@@ -570,6 +591,8 @@
|
||||
"invite_new_member": "להזמין חבר/ת צוות חדש/ה",
|
||||
"invite_new_member_description": "הערה: פעולה זו <1>תחייב את המינוי שלך במקום נוסף ($15 לחודש)</1>.",
|
||||
"invite_new_team_member": "להזמין מישהו לצוות שלך.",
|
||||
"upload_csv_file": "העלאת קובץ .csv",
|
||||
"invite_via_email": "הזמנה בדוא\"ל",
|
||||
"change_member_role": "לשנות את תפקיד החבר/ה בצוות",
|
||||
"disable_cal_branding": "הסר מיתוג {{appName}}",
|
||||
"disable_cal_branding_description": "הסתר את כל המיתוג של {{appName}} מהדפים הציבוריים שלך.",
|
||||
@@ -803,7 +826,7 @@
|
||||
"confirm_delete_event_type": "כן, למחוק",
|
||||
"delete_account": "מחיקת חשבון",
|
||||
"confirm_delete_account": "כן, למחוק את החשבון",
|
||||
"delete_account_confirmation_message": "בטוח שברצונך למחוק את חשבון {{appName}} שלך? כל מי ששיתפת איתו את הקישור לחשבון לא יוכל יותר להזמין באמצעותו, וכל ההעדפות ששמרת יאבדו.",
|
||||
"delete_account_confirmation_message": "כל מי ששיתפת איתו את הקישור לחשבון לא יוכל יותר להזמין באמצעותו, וכל ההעדפות ששמרת יאבדו.",
|
||||
"integrations": "שילובים",
|
||||
"apps": "אפליקציות",
|
||||
"apps_description": "כאן ניתן למצוא רשימה של האפליקציות שלך",
|
||||
@@ -842,6 +865,7 @@
|
||||
"team_view_user_availability_disabled": "כדי להציג את זמינות הצוות, המשתמש חייב לאשר את ההזמנה",
|
||||
"set_as_away": "הגדר/י את הסטטוס שלך כ'לא נמצא/ת'",
|
||||
"set_as_free": "השבתת הסטטוס 'לא נמצא/ת'",
|
||||
"toggle_away_error": "שגיאה בעת עדכון סטטוס 'לא נמצא/ת'",
|
||||
"user_away": "משתמש זה מוגדר כעת כ'לא נמצא/ת'.",
|
||||
"user_away_description": "האדם שניסית לשריין איתו חלון זמן הגדיר את עצמו כ'לא נמצא/ת', ולכן הוא אינו מקבל הזמנות חדשות.",
|
||||
"meet_people_with_the_same_tokens": "לפגוש אנשים עם אותם טוקנים",
|
||||
@@ -851,6 +875,7 @@
|
||||
"account_managed_by_identity_provider_description": "כדי לשנות את כתובת הדוא\"ל ואת הסיסמה, לאפשר אימות דו-גורמי ועוד, יש לעבור אל הגדרות החשבון אצל {{provider}}.",
|
||||
"signin_with_google": "כניסה עם Google",
|
||||
"signin_with_saml": "כניסה עם SAML",
|
||||
"signin_with_saml_oidc": "כניסה עם SAML/OIDC",
|
||||
"you_will_need_to_generate": "תצטרך ליצור טוקן גישה מהכלי הישן שבו השתמשת לקביעת מועדים.",
|
||||
"import": "ייבוא",
|
||||
"import_from": "ייבוא מ",
|
||||
@@ -950,6 +975,8 @@
|
||||
"offer_seats_description": "הצעת מקומות להזמנות (אפשרות זו משביתה באופן אוטומטי אישורי הזמנות ואורחים).",
|
||||
"seats_available_one": "מקום זמין",
|
||||
"seats_available_other": "מקומות זמינים",
|
||||
"seats_nearly_full": "כמעט כל המקומות תפוסים",
|
||||
"seats_half_full": "המקומות מתמלאים במהירות",
|
||||
"number_of_seats": "מספר המקומות לכל הזמנה",
|
||||
"enter_number_of_seats": "יש להזין את מספר המקומות",
|
||||
"you_can_manage_your_schedules": "ניתן לנהל את לוחות הזמנים בדף 'זמינות'.",
|
||||
@@ -1030,12 +1057,15 @@
|
||||
"how_you_want_add_cal_site": "איך ברצונך להוסיף את {{appName}} לאתר שלך?",
|
||||
"choose_ways_put_cal_site": "בחר/י אחת מהדרכים הבאות כדי לשלב את {{appName}} באתר שלך.",
|
||||
"setting_up_zapier": "המערכת מגדירה את השילוב שלך עם Zapier",
|
||||
"setting_up_make": "המערכת מגדירה את השילוב שלך עם Make",
|
||||
"generate_api_key": "יצירת מפתח ממשק תכנות יישומים (API)",
|
||||
"generate_api_key_description": "צור מפתח ממשק מפתחים, API, לשימוש עם {{appName}} ב-",
|
||||
"your_unique_api_key": "מפתח ה-API הייחודי שלך",
|
||||
"copy_safe_api_key": "העתק/י את מפתח ה-API הזה ושמור/י אותו במקום בטוח. אם תאבד/י אותו, יהיה עליך ליצור מפתח חדש.",
|
||||
"zapier_setup_instructions": "<0>התחבר/י לחשבון Zapier שלך וצור/י Zap חדש.</0><1>בחר/י את Cal.com כאפליקציית ה-Trigger. בנוסף, בחר/י אירוע Trigger.</1><2>בחר/י את החשבון שלך ולאחר מכן הזן/י את מפתח ה-API הייחודי שלך.</2><3>בדוק/י את ה-Trigger.</3><4>וזהו, הכל מוכן!</4>",
|
||||
"make_setup_instructions": "<0>עבור/י אל <1><0>יצירת קישור Invite</0></1> והתקן/י את אפליקציית Cal.com.</0><1>התחבר/י לחשבון Make שלך וצור/י Scenario חדש.</1><2>בחר/י את Cal.com כאפליקציית ה-Trigger. בנוסף, בחר/י אירוע Trigger.</2><3>בחר/י את החשבון שלך ולאחר מכן הזן/י את מפתח ה-API הייחודי שלך.</3><4>בדוק/י את ה-Trigger.</4><5>וזהו, הכל מוכן!</5>",
|
||||
"install_zapier_app": "תחילה עליך להוריד את אפליקציית Zapier מה-App Store ולהתקין אותה.",
|
||||
"install_make_app": "תחילה עליך להוריד את אפליקציית Make מה-App Store ולהתקין אותה.",
|
||||
"connect_apple_server": "חיבור לשרת Apple",
|
||||
"calendar_url": "כתובת ה-URL של לוח השנה",
|
||||
"apple_server_generate_password": "צור סיסמה ספציפית לאפליקציה שתשמש עבור {{appName}} ב-",
|
||||
@@ -1085,6 +1115,7 @@
|
||||
"email_attendee_action": "שליחת דוא\"ל למשתתפים",
|
||||
"sms_attendee_action": "שליחת SMS למשתתף",
|
||||
"sms_number_action": "שלח SMS למספר מסוים",
|
||||
"send_reminder_sms": "שלח/י למשתתפים תזכורות לפגישות ב-SMS בקלות",
|
||||
"whatsapp_number_action": "שליחת Whatsapp למספר מסוים",
|
||||
"whatsapp_attendee_action": "שליחת Whatsapp למשתתף",
|
||||
"workflows": "תהליכים",
|
||||
@@ -1244,6 +1275,7 @@
|
||||
"error_updating_settings": "שגיאה בעדכון ההגדרות",
|
||||
"personal_cal_url": "כתובת ה-URL האישית שלי של {{appName}}",
|
||||
"bio_hint": "מספר משפטים אודותיך. המידע הזה יופיע בדף ה-URL האישי שלך.",
|
||||
"user_has_no_bio": "משתמש זה עדיין לא הוסיף ביוגרפיה.",
|
||||
"delete_account_modal_title": "מחיקת החשבון",
|
||||
"confirm_delete_account_modal": "בטוח שברצונך למחוק את חשבון {{appName}} שלך?",
|
||||
"delete_my_account": "מחיקת החשבון שלי",
|
||||
@@ -1426,6 +1458,7 @@
|
||||
"add_limit": "הוספת הגבלה",
|
||||
"team_name_required": "נדרש שם צוות",
|
||||
"show_attendees": "שיתוף האורחים בפרטי המשתתפים",
|
||||
"show_available_seats_count": "הצגת מספר המקומות הפנויים",
|
||||
"how_booking_questions_as_variables": "איך משתמשים בשאלות הזמנה בתור משתנים?",
|
||||
"format": "פורמט",
|
||||
"uppercase_for_letters": "שימוש באותיות רישיות עבור כל האותיות",
|
||||
@@ -1659,8 +1692,11 @@
|
||||
"delete_sso_configuration_confirmation_description": "אתה בטוח שאתה רוצה למחוק את הגדרות {{connectionType}}? חברי הצוות שלך שמשתמשים ב- {{connectionType}} להזדהות לא יוכלו להשתמש בו להכנס ל- Cal.com.",
|
||||
"organizer_timezone": "מארגן אזורי זמן",
|
||||
"email_user_cta": "צפה בהזמנה",
|
||||
"email_no_user_invite_heading_team": "הוזמנת להצטרף לצוות ב-{{appName}}",
|
||||
"email_no_user_invite_heading_org": "הוזמנת להצטרף לארגון ב-{{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} הזמין אותך להצטרף לצוות שלו ב- {{appName}}. {{appName}} הינה מתזמן זימונים שמאפשר לך ולצוות שלך לזמן פגישות בלי כל הפינג פונג במיילים.",
|
||||
"email_user_invite_subheading_team": "{{invitedBy}} הזמין/ה אותך להצטרף לצוות שלו/ה בשם '{{teamName}}' באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ולצוות שלך לתזמן פגישות בלי כל הפינג פונג במיילים.",
|
||||
"email_user_invite_subheading_org": "{{invitedBy}} הזמין/ה אותך להצטרף לארגון שלו/ה בשם ״{{teamName}}״ באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ולארגון שלך לתזמן פגישות בלי הצורך לנהל התכתבויות ארוכות בדוא״ל.",
|
||||
"email_no_user_invite_steps_intro": "נדריך אותך במספר קטן של צעדים ותוכל/י להתחיל ליהנות מקביעת מועדים עם ה-{{entity}} שלך במהירות ובלי בעיות.",
|
||||
"email_no_user_step_one": "בחר שם משתמש",
|
||||
"email_no_user_step_two": "קשר את לוח השנה שלך",
|
||||
@@ -1831,6 +1867,8 @@
|
||||
"insights_no_data_found_for_filter": "לא נמצאו נתונים עבור המסנן שנבחר או התאריכים שנבחרו.",
|
||||
"acknowledge_booking_no_show_fee": "מובן לי שאם לא אשתתף באירוע הזה, דמי אי-הגעה בסך {{amount, currency}} ינוכו מהכרטיס שלי.",
|
||||
"card_details": "פרטי כרטיס",
|
||||
"something_went_wrong_on_our_end": "משהו השתבש בצד שלנו. פנה/י למחלקת התמיכה שלנו, ואנחנו נפתור זאת מיד עבורך.",
|
||||
"please_provide_following_text_to_suppport": "כשפונים לתמיכה, יש לספק את הטקסט הבא כדי שנוכל לסייע לך בצורה יעילה יותר",
|
||||
"seats_and_no_show_fee_error": "נכון לעכשיו, אי אפשר להפעיל מקומות ולחייב דמי אי-הגעה",
|
||||
"complete_your_booking": "יש להשלים את ההזמנה",
|
||||
"complete_your_booking_subject": "יש להשלים את ההזמנה: {{title}} ב-{{date}}",
|
||||
@@ -1864,6 +1902,9 @@
|
||||
"open_dialog_with_element_click": "לפתוח תיבת דו-שיח ב-Cal כשמישהו לוחץ על רכיב.",
|
||||
"need_help_embedding": "זקוק/ה לעזרה? ניתן לעיין במדריכים שלנו בנושא הטמעת Cal ב-Wix, ב-Squarespace או ב-WordPress, לקרוא את דף השאלות הנפוצות או לסקור אפשרויות הטמעה מתקדמות.",
|
||||
"book_my_cal": "הזמנה ב-Cal",
|
||||
"first_name": "שם פרטי",
|
||||
"last_name": "שם משפחה",
|
||||
"first_last_name": "שם פרטי, שם משפחה",
|
||||
"invite_as": "הזמנה בְתור",
|
||||
"form_updated_successfully": "עדכון הטופס בוצע בהצלחה.",
|
||||
"disable_attendees_confirmation_emails": "השבתת הודעות דוא\"ל לאישור שנשלחות כברירת מחדל עבור המשתתפים",
|
||||
@@ -1877,6 +1918,7 @@
|
||||
"first_event_type_webhook_description": "צור/צרי את ה-Webhook הראשון שלך עבור סוג האירוע הזה",
|
||||
"install_app_on": "התקנת האפליקציה ב-",
|
||||
"create_for": "צור/צרי עבור",
|
||||
"currency": "מטבע",
|
||||
"organization_banner_description": "צור/צרי סביבות שבהן הצוותים שלך יוכלו ליצור אפליקציות, תהליכי עבודה וסוגי אירועים משותפים, עם תכונות כמו סבב וקביעת מועדים שיתופית.",
|
||||
"organization_banner_title": "ניהול ארגונים עם צוותים מרובים",
|
||||
"set_up_your_organization": "הגדרת הארגון שלך",
|
||||
@@ -1937,6 +1979,74 @@
|
||||
"insights_team_filter": "צוות: {{teamName}}",
|
||||
"insights_user_filter": "משתמש: {{userName}}",
|
||||
"insights_subtitle": "הצגת insights לגבי ההזמנות באירועים שלך",
|
||||
"location_options": "{{locationCount}} אפשרויות מיקום",
|
||||
"custom_plan": "חבילה בהתאמה אישית",
|
||||
"email_embed": "הטבעה בדוא\"ל",
|
||||
"add_times_to_your_email": "בחר/י כמה מועדים פנויים והטבע/י אותם בדוא\"ל",
|
||||
"select_time": "בחירת שעה",
|
||||
"select_date": "בחירת תאריך",
|
||||
"see_all_available_times": "לצפייה בכל המועדים הפנויים",
|
||||
"org_team_names_example": "לדוגמה, מחלקת שיווק",
|
||||
"org_team_names_example_1": "לדוגמה, מחלקת שיווק",
|
||||
"org_team_names_example_2": "לדוגמה, מחלקת מכירות",
|
||||
"org_team_names_example_3": "לדוגמה, מחלקת עיצוב",
|
||||
"org_team_names_example_4": "לדוגמה, מחלקת הנדסה",
|
||||
"org_team_names_example_5": "לדוגמה, מחלקת ניתוח נתונים",
|
||||
"org_max_team_warnings": "תהיה אפשרות להוסיף עוד מחלקות או צוותים מאוחר יותר.",
|
||||
"what_is_this_meeting_about": "במה עוסקת הפגישה הזו?",
|
||||
"add_to_team": "הוספה לצוות",
|
||||
"remove_users_from_org": "הסרת משתמשים מהארגון",
|
||||
"remove_users_from_org_confirm": "בטוח שברצונך להסיר {{userCount}} משתמשים מהארגון הזה?",
|
||||
"user_has_no_schedules": "משתמש זה עדיין לא הגדיר לוחות זמנים",
|
||||
"user_isnt_in_any_teams": "משתמש זה לא שייך לאף צוות",
|
||||
"requires_booker_email_verification": "מחייב אימות של כתובת הדוא\"ל של המזמין",
|
||||
"description_requires_booker_email_verification": "כדי להבטיח אימות של כתובת הדוא\"ל של המזמין לפני תזמון אירועים",
|
||||
"requires_confirmation_mandatory": "ניתן לשלוח הודעות טקסט למשתתפים רק כאשר סוג האירוע מחייב אישור.",
|
||||
"organizations": "ארגונים",
|
||||
"org_admin_other_teams": "צוותים אחרים",
|
||||
"org_admin_other_teams_description": "כאן תוכל/י לראות צוותים בארגון שאינך שייך/ת אליהם. יש לך אפשרות להוסיף את עצמך, במקרה הצורך.",
|
||||
"no_other_teams_found": "לא נמצא אף צוות אחר",
|
||||
"no_other_teams_found_description": "אין צוותים אחרים בארגון הזה.",
|
||||
"attendee_first_name_variable": "השם הפרטי של המשתתף",
|
||||
"attendee_last_name_variable": "שם המשפחה של המשתתף",
|
||||
"attendee_first_name_info": "השם הפרטי של האדם שביצע את ההזמנה",
|
||||
"attendee_last_name_info": "שם המשפחה של האדם שביצע את ההזמנה",
|
||||
"me": "אני",
|
||||
"verify_team_tooltip": "אמת/י את הצוות שלך כדי לאפשר שליחת הודעות למשתתפים",
|
||||
"member_removed": "החבר הוסר",
|
||||
"my_availability": "הזמינות שלי",
|
||||
"team_availability": "הזמינות של הצוות",
|
||||
"backup_code": "קוד גיבוי",
|
||||
"backup_codes": "קודי גיבוי",
|
||||
"backup_code_instructions": "כל קוד גיבוי יכול לשמש פעם אחת בלבד להענקת גישה בלי היישום המאמת.",
|
||||
"backup_codes_copied": "קודי הגיבוי הועתקו!",
|
||||
"incorrect_backup_code": "קוד הגיבוי שגוי.",
|
||||
"lost_access": "הגישה אבדה",
|
||||
"missing_backup_codes": "לא נמצאו קודי גיבוי. צור/י אותם בהגדרות.",
|
||||
"admin_org_notification_email_subject": "נוצר ארגון חדש: בהמתנה לפעולה",
|
||||
"hi_admin": "שלום, מנהל/ת מערכת",
|
||||
"admin_org_notification_email_title": "ארגון מחייב הגדרת DNS",
|
||||
"admin_org_notification_email_body_part1": "נוצר ארגון עם רכיב ה-slug \"{{orgSlug}}\".<br /><br />חשוב להקפיד להגדיר את רשם ה-DNS כך שיפנה את התת-דומיין המקביל לארגון החדש למיקום שבו האפליקציה הראשית פועלת. אחרת, הארגון לא יוכל לפעול.<br /><br />לפניך פירוט של האפשרויות הבסיסיות ממש להגדרת תת-דומיין כך שיפנה לאפליקציה שלו על מנת שדף הפרופיל של הארגון ייטען.<br /><br />אפשר לעשות את זה עם רשומת A:",
|
||||
"admin_org_notification_email_body_part2": "או רשומת CNAME:",
|
||||
"admin_org_notification_email_body_part3": "לאחר שתגדיר/י את התת-דומיין, יש לסמן שתצורת DNS הושלמה בהגדרות מנהלי המערכת של הארגון.",
|
||||
"admin_org_notification_email_cta": "עבור/י אל הגדרות מנהלי המערכת של הארגון",
|
||||
"org_has_been_processed": "עיבוד הארגון הושלם",
|
||||
"org_error_processing": "היתה שגיאה בעיבוד של ארגון זה",
|
||||
"orgs_page_description": "רשימה של כל הארגונים. קבלת ארגון תאפשר לכל המשתמשים מאותו דומיין דוא\"ל להירשם בלי להצטרך לבצע אימות של כתובת הדוא\"ל.",
|
||||
"unverified": "לא אומת",
|
||||
"dns_missing": "DNS חסר",
|
||||
"mark_dns_configured": "סימון כי DNS הוגדר",
|
||||
"value": "ערך",
|
||||
"your_organization_updated_sucessfully": "עדכון הארגון שלך בוצע בהצלחה",
|
||||
"team_no_event_types": "אין לצוות זה אף סוג של אירוע",
|
||||
"seat_options_doesnt_multiple_durations": "האפשרויות של הושבה במקומות לא תומכות במשכי זמן שונים",
|
||||
"include_calendar_event": "כלילת אירוע מלוח השנה",
|
||||
"recently_added": "נוספו לאחרונה",
|
||||
"no_members_found": "לא נמצא אף חבר",
|
||||
"event_setup_length_error": "הגדרת אירוע: משך הזמן חייב להיות לפחות דקה אחת.",
|
||||
"availability_schedules": "לוחות זמנים לזמינוּת",
|
||||
"view_only_edit_availability_not_onboarded": "משתמש זה לא השלים תהליך הטמעה. לא תהיה לך אפשרות להגדיר את הזמינות שלו עד שהוא יעשה זאת.",
|
||||
"view_only_edit_availability": "את/ה צופה בזמינות של משתמש זה. יש לך אפשרות לערוך רק את פרטי הזמינות שלך.",
|
||||
"edit_users_availability": "עריכת הזמינות של משתמש: {{username}}",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
"starting": "Kezdés",
|
||||
"length": "Hossz",
|
||||
"delete_account": "Fiók törlése",
|
||||
"delete_account_confirmation_message": "Biztosan törölnéd a {{appName}} fiókod? Senki akinek elküldted a fiókod linkjét nem fog tudni időpontot foglalni, az eddig elmentett beállításaid is el fognak veszni.",
|
||||
"delete_account_confirmation_message": "Senki akinek elküldted a fiókod linkjét nem fog tudni időpontot foglalni, az eddig elmentett beállításaid is el fognak veszni.",
|
||||
"apps": "Alkalmazások",
|
||||
"app_store": "App Store",
|
||||
"settings": "Beállítások",
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
"verify_email_banner_body": "Verifica il tuo indirizzo e-mail per garantire la consegna delle e-mail e delle notifiche del calendario",
|
||||
"verify_email_email_header": "Verifica il tuo indirizzo e-mail",
|
||||
"verify_email_email_button": "Verifica l'e-mail",
|
||||
"copy_somewhere_safe": "Salva questa chiave API in un posto sicuro. Non potrai visualizzarla in seguito.",
|
||||
"verify_email_email_body": "Verifica il tuo indirizzo e-mail facendo clic sul pulsante qui sotto.",
|
||||
"verify_email_by_code_email_body": "Verifica il tuo indirizzo e-mail utilizzando il codice sottostante.",
|
||||
"verify_email_email_link_text": "Se non ti piacciono i pulsanti, puoi usare questo link:",
|
||||
"email_verification_code": "Inserisci il codice di verifica",
|
||||
"email_verification_code_placeholder": "Inserisci il codice di verifica che hai ricevuto via e-mail",
|
||||
"incorrect_email_verification_code": "Il codice di verifica è errato.",
|
||||
"email_sent": "Email inviata",
|
||||
"email_not_sent": "Si è verificato un errore durante l'invito dell'e-mail",
|
||||
"event_declined_subject": "Rifiutato: {{title}} il {{date}}",
|
||||
"event_cancelled_subject": "Cancellato: {{title}} il {{date}}",
|
||||
"event_request_declined": "La tua richiesta per l'evento è stata rifiutata",
|
||||
@@ -78,6 +84,7 @@
|
||||
"event_awaiting_approval_recurring": "Un evento ricorrente è in attesa della tua approvazione",
|
||||
"someone_requested_an_event": "Qualcuno ha richiesto di programmare un evento sul tuo calendario.",
|
||||
"someone_requested_password_reset": "Qualcuno ha richiesto un link per cambiare la tua password.",
|
||||
"password_reset_email_sent": "Se questo indirizzo e-mail esiste nel nostro sistema, riceverai un'e-mail per la reimpostazione.",
|
||||
"password_reset_instructions": "Se non l'hai richiesto, puoi tranquillamente ignorare questa email e la tua password non verrà modificata.",
|
||||
"event_awaiting_approval_subject": "In attesa di approvazione: {{title}} il {{date}}",
|
||||
"event_still_awaiting_approval": "Un evento è ancora in attesa della tua approvazione",
|
||||
@@ -217,6 +224,10 @@
|
||||
"already_have_an_account": "Hai già un account?",
|
||||
"create_account": "Crea Account",
|
||||
"confirm_password": "Conferma password",
|
||||
"confirm_auth_change": "Questo cambierà il modo il cui effettui l'accesso",
|
||||
"confirm_auth_email_change": "Cambiando l'indirizzo e-mail disconnetterai il tuo metodo di autenticazione attuale che utilizzi per accedere a Cal.com. Ti verrà chiesto di verificare il tuo nuovo indirizzo e-mail. Continuando, verrai disconnesso e dovrai usare il nuovo indirizzo e-mail per accedere, al posto del tuo metodo di autenticazione attuale, dopo aver impostato la password seguendo le istruzioni che ti invieremo al tuo indirizzo e-mail.",
|
||||
"reset_your_password": "Imposta la nuova password seguendo le istruzioni che ti sono state inviate via e-mail.",
|
||||
"email_change": "Accedi di nuovo con il nuovo indirizzo e-mail e la nuova password.",
|
||||
"create_your_account": "Crea il tuo account",
|
||||
"sign_up": "Registrati",
|
||||
"youve_been_logged_out": "Hai fatto il log out",
|
||||
@@ -243,6 +254,10 @@
|
||||
"all": "Tutte le app",
|
||||
"yours": "Il tuo account",
|
||||
"available_apps": "Applicazioni disponibili",
|
||||
"available_apps_lower_case": "Applicazioni disponibili",
|
||||
"available_apps_desc": "Non hai nessuna applicazione installata. Visualizza le applicazioni popolari qui sotto ed esplorane altre nel nostro <1>App Store</1>",
|
||||
"fixed_host_helper": "Aggiungi chiunque abbia bisogno di partecipare all'evento. <1>Per saperne di più</1>",
|
||||
"round_robin_helper": "Le persone nel gruppo fanno a turno e una sola persona si presenterà all'evento.",
|
||||
"check_email_reset_password": "Controlla la tua email. Ti abbiamo inviato un link per reimpostare la password.",
|
||||
"finish": "Fine",
|
||||
"organization_general_description": "Gestisci le impostazioni della lingua e del fuso orario per il tuo team",
|
||||
@@ -251,6 +266,7 @@
|
||||
"nearly_there_instructions": "Infine: qualche informazione su di te e una foto sono utili per ottenere prenotazioni e per far capire alle persone con chi stanno prenotando.",
|
||||
"set_availability_instructions": "Definisci intervalli di tempo di quando sei disponibile su una base ricorrente. Puoi crearne di più in seguito e assegnarli a calendari diversi.",
|
||||
"set_availability": "Imposta la tua disponibilità",
|
||||
"availability_settings": "Impostazioni di disponibilità",
|
||||
"continue_without_calendar": "Continua senza calendario",
|
||||
"connect_your_calendar": "Collega il tuo calendario",
|
||||
"connect_your_video_app": "Collega le tue applicazioni video",
|
||||
@@ -392,6 +408,8 @@
|
||||
"allow_dynamic_booking_tooltip": "È possibile creare dinamicamente collegamenti a prenotazioni di gruppo inserendo più nomi utente separati da '+'. Esempio: '{{appName}}/bailey+peer'",
|
||||
"allow_dynamic_booking": "Consenti ai partecipanti di prenotare per te utilizzando le prenotazioni di gruppo dinamiche",
|
||||
"dynamic_booking": "Collegamenti dinamici di gruppo",
|
||||
"allow_seo_indexing": "Consenti ai motori di ricerca di accedere ai tuoi contenuti pubblici",
|
||||
"seo_indexing": "Consenti l'indicizzazione SEO",
|
||||
"email": "Email",
|
||||
"email_placeholder": "jdoe@example.com",
|
||||
"full_name": "Nome completo",
|
||||
@@ -406,6 +424,7 @@
|
||||
"booking_requested": "Richiesta di prenotazione inviata",
|
||||
"meeting_ended": "Riunione terminata",
|
||||
"form_submitted": "Modulo inviato",
|
||||
"booking_paid": "Prenotazione pagata",
|
||||
"event_triggers": "Attivatori Evento",
|
||||
"subscriber_url": "URL abbonato",
|
||||
"create_new_webhook": "Crea un nuovo webhook",
|
||||
@@ -506,6 +525,7 @@
|
||||
"your_name": "Il tuo nome",
|
||||
"your_full_name": "Nome completo",
|
||||
"no_name": "Nessun nome",
|
||||
"enter_number_between_range": "Insrisci un numero compreso tra 1 e {{maxOccurences}}",
|
||||
"email_address": "Indirizzo email",
|
||||
"enter_valid_email": "Immettere un indirizzo e-mail valido",
|
||||
"location": "Luogo",
|
||||
@@ -543,6 +563,7 @@
|
||||
"leave": "Abbandona",
|
||||
"profile": "Profilo",
|
||||
"my_team_url": "Url del mio team",
|
||||
"my_teams": "I miei team",
|
||||
"team_name": "Nome del team",
|
||||
"your_team_name": "Nome del tuo team",
|
||||
"team_updated_successfully": "Team aggiornato con successo",
|
||||
@@ -570,6 +591,8 @@
|
||||
"invite_new_member": "Invita un nuovo membro",
|
||||
"invite_new_member_description": "Nota: Sul tuo abbonamento verrà addebitato <1>un posto aggiuntivo (15$/m)</1>.",
|
||||
"invite_new_team_member": "Invita qualcuno nel tuo team.",
|
||||
"upload_csv_file": "Carica un file .csv",
|
||||
"invite_via_email": "Invita via e-mail",
|
||||
"change_member_role": "Cambia il ruolo del membro del team",
|
||||
"disable_cal_branding": "Disabilita il branding {{appName}}",
|
||||
"disable_cal_branding_description": "Nascondi tutti i brand {{appName}} dalle tue pagine pubbliche.",
|
||||
@@ -803,7 +826,7 @@
|
||||
"confirm_delete_event_type": "Sì, elimina",
|
||||
"delete_account": "Elimina account",
|
||||
"confirm_delete_account": "Sì, elimina account",
|
||||
"delete_account_confirmation_message": "Sei sicuro di voler eliminare il tuo account {{appName}}? Chiunque abbia il link del tuo account non potrà più utilizzarlo per prenotare e le preferenze che hai salvato andranno perse.",
|
||||
"delete_account_confirmation_message": "Chiunque abbia il link del tuo account non potrà più utilizzarlo per prenotare e le preferenze che hai salvato andranno perse.",
|
||||
"integrations": "Integrazioni",
|
||||
"apps": "App",
|
||||
"apps_description": "Qui puoi trovare l'elenco delle tue app",
|
||||
@@ -842,6 +865,7 @@
|
||||
"team_view_user_availability_disabled": "L'utente deve accettare l'invito per visualizzare la disponibilità",
|
||||
"set_as_away": "Segnalati assente",
|
||||
"set_as_free": "Disabilita lo stato \"assente\"",
|
||||
"toggle_away_error": "Errore durante l'aggiornamento dello stato \"assente\"",
|
||||
"user_away": "Questo utente è attualmente assente.",
|
||||
"user_away_description": "La persona che stai cercando di prenotare si è segnalata assente, quindi non accetta nuove prenotazioni.",
|
||||
"meet_people_with_the_same_tokens": "Incontra persone con gli stessi token",
|
||||
@@ -851,6 +875,7 @@
|
||||
"account_managed_by_identity_provider_description": "Per modificare la tua email o la tua password, per abilitare l'autenticazione a due fattori e altro ancora, consulta le impostazioni del tuo account {{provider}}.",
|
||||
"signin_with_google": "Accedi con Google",
|
||||
"signin_with_saml": "Accedi con SAML",
|
||||
"signin_with_saml_oidc": "Accedi con SAML/OIDC",
|
||||
"you_will_need_to_generate": "Dovrai generare un token di accesso con il tuo vecchio strumento di pianificazione.",
|
||||
"import": "Importa",
|
||||
"import_from": "Importa da",
|
||||
@@ -950,6 +975,8 @@
|
||||
"offer_seats_description": "Indica i posti disponibili (ciò disabiliterà le prenotazioni ospite e le prenotazioni opt-in).",
|
||||
"seats_available_one": "Posto disponibile",
|
||||
"seats_available_other": "Posti disponibili",
|
||||
"seats_nearly_full": "Quasi tutti i posti sono al completo",
|
||||
"seats_half_full": "I posti si esauriscono in fretta",
|
||||
"number_of_seats": "Numero di posti per prenotazione",
|
||||
"enter_number_of_seats": "Immettere il numero di posti",
|
||||
"you_can_manage_your_schedules": "Puoi gestire i tuoi programmi dalla pagina Disponibilità.",
|
||||
@@ -1030,12 +1057,15 @@
|
||||
"how_you_want_add_cal_site": "Come desideri aggiungere {{appName}} al tuo sito?",
|
||||
"choose_ways_put_cal_site": "Scegli uno dei seguenti modi per inserire {{appName}} nel tuo sito.",
|
||||
"setting_up_zapier": "Configurare l'integrazione con Zapier",
|
||||
"setting_up_make": "Configurare l'integrazione con Make",
|
||||
"generate_api_key": "Genera una chiave API",
|
||||
"generate_api_key_description": "Genera una chiave API da usare con {{appName}} su",
|
||||
"your_unique_api_key": "La tua chiave API univoca",
|
||||
"copy_safe_api_key": "Copia questa chiave API e conservala in un posto sicuro. Se perdi la chiave, dovrai generarne una nuova.",
|
||||
"zapier_setup_instructions": "<0>Accedi al tuo account Zapier e crea un nuovo Zap.</0><1>Seleziona Cal.com come app trigger. Scegli l'evento trigger.</1><2>Seleziona il tuo account e immetti la tua chiave API univoca.</2><3>Fai una prova del tuo trigger.</3><4>Fatto!</4>",
|
||||
"make_setup_instructions": "<0>Segui il <1><0>link di invito Make</0></1> e installa l'applicazione Cal.com.</0><1>Accedi al tuo account Make e crea un nuovo Scenario.</1><2>Seleziona Cal.com come app trigger. Scegli l'evento trigger.</2><3>Seleziona il tuo account e immetti la tua chiave API unica.</3><4>Fai una prova del tuo trigger.</4><5>Fatto!</5>",
|
||||
"install_zapier_app": "Prima è necessario installare l'app Zapier, disponibile nell'App Store.",
|
||||
"install_make_app": "Prima è necessario installare l'applicazione Make, disponibile nell'App Store.",
|
||||
"connect_apple_server": "Connetti al server Apple",
|
||||
"calendar_url": "URL del calendario",
|
||||
"apple_server_generate_password": "Genera una password specifica dell'app da usare con {{appName}} presso",
|
||||
@@ -1085,6 +1115,7 @@
|
||||
"email_attendee_action": "invia e-mail ai partecipanti",
|
||||
"sms_attendee_action": "Invia SMS al partecipante",
|
||||
"sms_number_action": "invia SMS a un numero specifico",
|
||||
"send_reminder_sms": "Invia facilmente dei promemoria di riunioni via SMS ai partecipanti",
|
||||
"whatsapp_number_action": "invia un messaggio Whatsapp a un numero specifico",
|
||||
"whatsapp_attendee_action": "invia un messaggio Whatsapp al partecipante",
|
||||
"workflows": "Flussi di lavoro",
|
||||
@@ -1244,6 +1275,7 @@
|
||||
"error_updating_settings": "Errore durante l'aggiornamento delle impostazioni",
|
||||
"personal_cal_url": "Il mio URL personale di {{appName}}",
|
||||
"bio_hint": "Scrivi qualcosa di te. Queste informazioni appariranno nella tua pagina personale.",
|
||||
"user_has_no_bio": "Questo utente non ha ancora aggiunto una biografia.",
|
||||
"delete_account_modal_title": "Elimina account",
|
||||
"confirm_delete_account_modal": "Eliminare il tuo account {{appName}}?",
|
||||
"delete_my_account": "Elimina il mio account",
|
||||
@@ -1426,6 +1458,7 @@
|
||||
"add_limit": "Aggiungi limite",
|
||||
"team_name_required": "Nome del team richiesto",
|
||||
"show_attendees": "Condividi le informazioni dei partecipanti tra gli ospiti",
|
||||
"show_available_seats_count": "Mostra il numero di posti disponibili",
|
||||
"how_booking_questions_as_variables": "Come utilizzare le domande poste nella pagina di prenotazione come variabili?",
|
||||
"format": "Formato",
|
||||
"uppercase_for_letters": "Usa tutte lettere maiuscole",
|
||||
@@ -1659,8 +1692,11 @@
|
||||
"delete_sso_configuration_confirmation_description": "Eliminare la configurazione {{connectionType}}? I membri del tuo team che utilizzano l'accesso {{connectionType}} non saranno più in grado di accedere a Cal.com.",
|
||||
"organizer_timezone": "Fuso orario organizzatore",
|
||||
"email_user_cta": "Visualizza invito",
|
||||
"email_no_user_invite_heading_team": "Hai ricevuto un invito a entrare a far parte del team di {{appName}}",
|
||||
"email_no_user_invite_heading_org": "Hai ricevuto un invito a entrare a far parte dell'organizzazione {{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} ti ha invitato a unirti al suo team su {{appName}}. {{appName}} è uno strumento di pianificazione di eventi che permette al tuo team di pianificare le riunioni senza scambiare decine di e-mail.",
|
||||
"email_user_invite_subheading_team": "{{invitedBy}} ti ha invitato a unirti al suo team `{{teamName}}` su {{appName}}. {{appName}} è uno strumento di pianificazione di eventi che permette a te e al tuo team di pianificare le riunioni senza scambiare decine di e-mail.",
|
||||
"email_user_invite_subheading_org": "{{invitedBy}} ti ha invitato a unirti alla sua organizzazione `{{teamName}}` su {{appName}}. {{appName}} è uno strumento di pianificazione di eventi che permette a te e alla tua organizzazione di pianificare le riunioni senza scambiare decine di e-mail.",
|
||||
"email_no_user_invite_steps_intro": "Ti assisteremo nei passaggi iniziali e potrai rapidamente pianificare eventi per il/la {{entity}} senza fatica.",
|
||||
"email_no_user_step_one": "Scegli il tuo nome utente",
|
||||
"email_no_user_step_two": "Collega il tuo account di calendario",
|
||||
@@ -1831,6 +1867,8 @@
|
||||
"insights_no_data_found_for_filter": "Nessun dato trovato per il filtro selezionato o le date selezionate.",
|
||||
"acknowledge_booking_no_show_fee": "Confermo che in caso di mia mancata partecipazione a questo evento, una penale per mancata presentazione di {{amount, currency}} verrà addebitata sulla mia carta.",
|
||||
"card_details": "Dati della carta",
|
||||
"something_went_wrong_on_our_end": "Si è verificato un problema dal lato nostro. Contatta il nostro team di supporto e provvederemo subito a risolverlo.",
|
||||
"please_provide_following_text_to_suppport": "Fornisci il seguente testo quando contatterai il team di supporto per permeterci di aiutarti meglio",
|
||||
"seats_and_no_show_fee_error": "Attualmente non è possibile abilitare i posti e addebitare la penale per mancata presentazione",
|
||||
"complete_your_booking": "Completa la prenotazione",
|
||||
"complete_your_booking_subject": "Completa la prenotazione: {{title}} del {{date}}",
|
||||
@@ -1864,6 +1902,9 @@
|
||||
"open_dialog_with_element_click": "Apre la finestra di dialogo di Cal quando un utente fa clic su un elemento.",
|
||||
"need_help_embedding": "Hai bisogno di aiuto? Consulta le nostre guide per integrare Cal su Wix, Squarespace o WordPress, leggi le risposte alle domande più frequenti o esplora le opzioni di incorporamento avanzate.",
|
||||
"book_my_cal": "Prenota su Cal",
|
||||
"first_name": "Nome",
|
||||
"last_name": "Cognome",
|
||||
"first_last_name": "Nome, cognome",
|
||||
"invite_as": "Invita come",
|
||||
"form_updated_successfully": "Modulo aggiornato correttamente.",
|
||||
"disable_attendees_confirmation_emails": "Disabilita le e-mail di conferma predefinite per i partecipanti",
|
||||
@@ -1877,6 +1918,7 @@
|
||||
"first_event_type_webhook_description": "Crea il primo webhook per questo tipo di evento",
|
||||
"install_app_on": "Installa l'app su",
|
||||
"create_for": "Crea per",
|
||||
"currency": "Valuta",
|
||||
"organization_banner_description": "Crea ambienti dove i tuoi team potranno creare e condividere applicazioni, flussi di lavoro e tipi di eventi con pianificazioni di gruppo e round robin.",
|
||||
"organization_banner_title": "Gestisci organizzazioni con più team",
|
||||
"set_up_your_organization": "Imposta la tua organizzazione",
|
||||
@@ -1937,6 +1979,74 @@
|
||||
"insights_team_filter": "Team: {{teamName}}",
|
||||
"insights_user_filter": "Utente: {{userName}}",
|
||||
"insights_subtitle": "Visualizza insight sulle prenotazioni per tutti i tuoi eventi",
|
||||
"location_options": "{{locationCount}} opzioni di luogo",
|
||||
"custom_plan": "Piano personalizzato",
|
||||
"email_embed": "Incorporamento nell'e-mail",
|
||||
"add_times_to_your_email": "Seleziona alcuni orari disponibili e incorporali nella tua e-mail",
|
||||
"select_time": "Seleziona l'ora",
|
||||
"select_date": "Seleziona la data",
|
||||
"see_all_available_times": "Vedi tutti gli orari disponibili",
|
||||
"org_team_names_example": "ad es., team di marketing",
|
||||
"org_team_names_example_1": "ad es., team di marketing",
|
||||
"org_team_names_example_2": "ad es., team vendite",
|
||||
"org_team_names_example_3": "ad es., team di progettazione",
|
||||
"org_team_names_example_4": "ad es., team di ingegneria",
|
||||
"org_team_names_example_5": "ad es., team di analisi dei dati",
|
||||
"org_max_team_warnings": "Sarà possibile aggiungere altri team in un secondo momento.",
|
||||
"what_is_this_meeting_about": "Qual è l'oggetto della riunione?",
|
||||
"add_to_team": "Aggiungi al team",
|
||||
"remove_users_from_org": "Rimuovi utenti dall'organizzazione",
|
||||
"remove_users_from_org_confirm": "Rimuovere {{userCount}} utenti da questa organizzazione?",
|
||||
"user_has_no_schedules": "Questo utente non ha ancora impostato alcuna pianificazione",
|
||||
"user_isnt_in_any_teams": "Questo utente non fa parte di nessun team",
|
||||
"requires_booker_email_verification": "Richiede la verifica dell'indirizzo e-mail della persona che prenota",
|
||||
"description_requires_booker_email_verification": "Per garantire la verifica dell'indirizzo e-mail della persona che prenota prima di panificare eventi",
|
||||
"requires_confirmation_mandatory": "I messaggi di testo possono solo essere inviati ai partecipanti quando il tipo di evento richiede una conferma.",
|
||||
"organizations": "Organizzazioni",
|
||||
"org_admin_other_teams": "Atri team",
|
||||
"org_admin_other_teams_description": "Qui puoi vedere i team di cui non fai parte all'interno della tua organizzazione. Puoi entrare a far parte di tali team se necessario.",
|
||||
"no_other_teams_found": "Nessun altro team trovato",
|
||||
"no_other_teams_found_description": "Non ci sono altri team in questa organizzazione.",
|
||||
"attendee_first_name_variable": "Nome del partecipante",
|
||||
"attendee_last_name_variable": "Cognome del partecipante",
|
||||
"attendee_first_name_info": "Nome della persona che prenota",
|
||||
"attendee_last_name_info": "Cognome della persona che prenota",
|
||||
"me": "Io",
|
||||
"verify_team_tooltip": "Effettua la verifica del tuo team per abilitare l'invio di messaggi ai partecipanti",
|
||||
"member_removed": "Membro rimosso",
|
||||
"my_availability": "La mia disponibilità",
|
||||
"team_availability": "La disponibilità del team",
|
||||
"backup_code": "Codice di backup",
|
||||
"backup_codes": "Codici di backup",
|
||||
"backup_code_instructions": "Ogni codice di backup può essere utilizzato soltanto una volta per concedere l'accesso senza l'autenticatore.",
|
||||
"backup_codes_copied": "Codici di backup copiati!",
|
||||
"incorrect_backup_code": "Il codice di backup è errato.",
|
||||
"lost_access": "Accesso perso",
|
||||
"missing_backup_codes": "Nessun codice di backup trovato. Puoi generarli nelle impostazioni.",
|
||||
"admin_org_notification_email_subject": "Nuova organizzazione creata: azione in sospeso",
|
||||
"hi_admin": "Ciao amministratore",
|
||||
"admin_org_notification_email_title": "Un'organizzazione richiede la configurazione DNS",
|
||||
"admin_org_notification_email_body_part1": "È stata creata un'organizzazione con lo slug \"{{orgSlug}}\".<br /><br />Assicurati di configurare il registro DNS in modo che il sottodominio corrispondente alla nuova organizzazione punti verso dov'è eseguita l'applicazione principale, altrimenti l'organizzazione non funzionerà.<br /><br />Ecco solo alcune opzioni molto basilari per configurare un sottodominio in modo che punti alla propria app e carichi la pagina del profilo dell'organizzazione.<br /><br />È possibile farlo utilizzando il record A:",
|
||||
"admin_org_notification_email_body_part2": "O il record CNAME:",
|
||||
"admin_org_notification_email_body_part3": "Dopo aver configurato il sottodominio, contrassegnare la configurazione del DNS come completata nelle impostazioni di amministrazione dell'organizzazione.",
|
||||
"admin_org_notification_email_cta": "Vai alle impostazioni di amministrazione dell'organizzazione",
|
||||
"org_has_been_processed": "Organizzazione elaborata",
|
||||
"org_error_processing": "Si è verificato un errore durante l'elaborazione di questa organizzazione",
|
||||
"orgs_page_description": "Un elenco di tutte le organizzazioni. Accettando un'organizzazione, tutti gli utenti con il rispettivo dominio e-mail potranno registrarsi SENZA verificare l'indirizzo e-mail.",
|
||||
"unverified": "Non verificato",
|
||||
"dns_missing": "DNS mancante",
|
||||
"mark_dns_configured": "Contrassegna DNS come configurato",
|
||||
"value": "Valore",
|
||||
"your_organization_updated_sucessfully": "L'organizzazione è stata aggiornata correttamente",
|
||||
"team_no_event_types": "Questo team non ha nessun tipo di evento",
|
||||
"seat_options_doesnt_multiple_durations": "L'opzione di prenotazione dei posti non supporta durate multiple",
|
||||
"include_calendar_event": "Includi evento del calendario",
|
||||
"recently_added": "Aggiunti di recente",
|
||||
"no_members_found": "Nessun membro trovato",
|
||||
"event_setup_length_error": "Impostazione evento: la durata deve essere di almeno 1 minuto.",
|
||||
"availability_schedules": "Calendario disponibilità",
|
||||
"view_only_edit_availability_not_onboarded": "Questo utente non ha completato l'onboarding. Non sarai in grado di impostare la sua disponibilità fino a quando non avrà completato l'onboarding.",
|
||||
"view_only_edit_availability": "Stai visualizzando la disponibilità di questo utente. Puoi solo modificare la tua disponibilità.",
|
||||
"edit_users_availability": "Modifica la disponibilità dell'utente: {{username}}",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Aggiungi le tue nuove stringhe qui sopra ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user