Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d4a3c7a93 | ||
|
|
1c1625d3fc | ||
|
|
75f9f045b8 | ||
|
|
86af383f6c | ||
|
|
84408025ed | ||
|
|
9fba8c548b | ||
|
|
56f3647ec2 | ||
|
|
a5cb3ce573 | ||
|
|
7581f4d641 | ||
|
|
a1d67b256e | ||
|
|
75f9dad16e | ||
|
|
0a0d212373 | ||
|
|
b8794ba807 | ||
|
|
9b8e4ed385 | ||
|
|
997b5d5290 | ||
|
|
d7080b41cb | ||
|
|
32754bd120 | ||
|
|
13fd02d174 | ||
|
|
d6cbac54e0 | ||
|
|
489ffdf49a | ||
|
|
37b22977ee | ||
|
|
848a040162 | ||
|
|
350d84a047 | ||
|
|
9c3cbee363 | ||
|
|
7e2ad3cea9 | ||
|
|
4ef52aa2c6 | ||
|
|
3132c64205 | ||
|
|
f25f8e0909 | ||
|
|
d8961f8eb9 | ||
|
|
9ff7cf650b | ||
|
|
ba4e717b59 | ||
|
|
f326a9a620 | ||
|
|
95be16c358 | ||
|
|
5621507f7e | ||
|
|
bc6d58fd47 | ||
|
|
db29cc1251 | ||
|
|
69888c57b0 | ||
|
|
5c0da23b97 | ||
|
|
5324ec8051 | ||
|
|
b946f89ffb | ||
|
|
3e3581fabc | ||
|
|
05ac339233 | ||
|
|
ef4675ff44 | ||
|
|
47dc85ee1b | ||
|
|
50342e33ed | ||
|
|
eebcb636f1 | ||
|
|
ed2ced899a | ||
|
|
b7ae8c9594 | ||
|
|
fce98ebb13 | ||
|
|
b81b221bf5 | ||
|
|
f9eb335d0b | ||
|
|
d7d7bcd651 | ||
|
|
4c861b9e67 | ||
|
|
2fa704c39b | ||
|
|
9ccdf21d75 | ||
|
|
9d6148da42 | ||
|
|
bf437b7d6b | ||
|
|
3701fcd68b | ||
|
|
2f90dc7cb6 | ||
|
|
829f90b82f | ||
|
|
3539693729 | ||
|
|
868ba4396d | ||
|
|
d06e36a5cd | ||
|
|
f61c9d9bbf | ||
|
|
a2d521e001 | ||
|
|
bf9c881d16 | ||
|
|
6dd0f9ce29 | ||
|
|
d34db7aef6 | ||
|
|
b4b90f2d27 | ||
|
|
3f0380617a | ||
|
|
7143923ea4 | ||
|
|
dfccf4604f | ||
|
|
c12abd75a8 | ||
|
|
2e12f09f05 | ||
|
|
4258da1df5 | ||
|
|
41ef354c7b | ||
|
|
8891641953 | ||
|
|
0150041496 | ||
|
|
13f9e97015 | ||
|
|
3c5901edb8 | ||
|
|
d26bab5880 | ||
|
|
1c451a8d51 | ||
|
|
576261bf88 | ||
|
|
7d944af7f6 | ||
|
|
dfe7056c93 | ||
|
|
b27f01449a | ||
|
|
6dd77d0494 | ||
|
|
f73d38b632 | ||
|
|
a7d8f0c84e | ||
|
|
b0164990c0 | ||
|
|
bad72ba7bf | ||
|
|
1b317a98a4 | ||
|
|
6a22d75caa | ||
|
|
a030861423 | ||
|
|
bd9cd00c04 | ||
|
|
6fd34af6f6 | ||
|
|
1a85f336c5 | ||
|
|
cc5096a54a | ||
|
|
eea7059226 | ||
|
|
63f3247b1b | ||
|
|
7a769c38fb | ||
|
|
34c2565c70 | ||
|
|
a130804f18 | ||
|
|
446e851065 | ||
|
|
f06b4bfb9c | ||
|
|
1896efa3a0 | ||
|
|
62c8603863 | ||
|
|
7d8559b0b0 | ||
|
|
7a48bbe553 | ||
|
|
817e95c246 | ||
|
|
4ac6225a35 | ||
|
|
38d9b93dee | ||
|
|
376b38d4ed | ||
|
|
0e181f6d9f | ||
|
|
4dbb692156 | ||
|
|
0068718d24 | ||
|
|
ac817b3542 | ||
|
|
97f2cffd1f | ||
|
|
b6dea9c161 | ||
|
|
57eb4746ad | ||
|
|
eff9c87964 | ||
|
|
e06c07bfec | ||
|
|
332c00dac2 | ||
|
|
e3523db6bb | ||
|
|
9c0f046b2a | ||
|
|
6e1a717ecf | ||
|
|
b2e098c781 | ||
|
|
3010900358 | ||
|
|
9b043f44a0 | ||
|
|
814674fe40 | ||
|
|
acaa79b0b8 | ||
|
|
781a0f4a7c | ||
|
|
b2028870d0 | ||
|
|
8f70be8861 | ||
|
|
53c8f8c04a | ||
|
|
c4e5d168a5 | ||
|
|
500bfb4d82 | ||
|
|
024b92b9ce | ||
|
|
f23b63cc98 | ||
|
|
edc90e3f27 | ||
|
|
05631d093f | ||
|
|
6000ff4b48 | ||
|
|
cd7de98e3f | ||
|
|
3f51858d27 | ||
|
|
2c498ace5c | ||
|
|
595d781d51 | ||
|
|
3c508568da | ||
|
|
3204d0af34 | ||
|
|
25684f9040 | ||
|
|
bc9aeef710 | ||
|
|
393411a47a | ||
|
|
356117feaf | ||
|
|
1bf68d50e0 | ||
|
|
4a35e85756 | ||
|
|
a4f56bf680 | ||
|
|
a308075bc3 | ||
|
|
efa6d464a3 | ||
|
|
811262d7ee | ||
|
|
99a5dcf96a | ||
|
|
79f092673a | ||
|
|
b285f27d00 | ||
|
|
1fa87ae179 | ||
|
|
f6f4b67bf9 | ||
|
|
8c8550e6f8 | ||
|
|
c6246c9408 | ||
|
|
ccc814eb54 | ||
|
|
2ee0ec11d6 | ||
|
|
3223beb9ac | ||
|
|
c1bcfcfa3d | ||
|
|
83aea7d28c |
@@ -213,6 +213,10 @@ NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
||||
# use organizations
|
||||
ORGANIZATIONS_ENABLED=
|
||||
|
||||
# This variable should only be set to 1 or true if you want to autolink external provider sing-ups with
|
||||
# existing organizations based on email domain address
|
||||
ORGANIZATIONS_AUTOLINK=
|
||||
|
||||
# Vercel Config to create subdomains for organizations
|
||||
# Get it from https://vercel.com/<TEAM_OR_USER_NAME>/<PROJECT_SLUG>/settings
|
||||
PROJECT_ID_VERCEL=
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Add PRs to project Reviewing PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
add-PR-to-project:
|
||||
name: Add PRs to project Reviewing PRs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.1.0
|
||||
with:
|
||||
project-url: https://github.com/orgs/calcom/projects/11
|
||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Cron - webhookTriggers
|
||||
|
||||
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 “every 5 minutes” (see https://crontab.guru)
|
||||
- cron: "*/5 * * * *"
|
||||
jobs:
|
||||
cron-webhookTriggers:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/webhookTriggers \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
# Contributing to Cal.com
|
||||
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
Contributions are what makes the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||
|
||||
@@ -37,7 +37,7 @@ Contributions are what make the open source community such an amazing place to l
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Core Features (Booking page, availabilty, timezone calculation)
|
||||
Core Features (Booking page, availability, timezone calculation)
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22">
|
||||
@@ -132,7 +132,6 @@ If you get errors, be sure to fix them before committing.
|
||||
|
||||
## Making a Pull Request
|
||||
|
||||
- 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 you 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 [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.
|
||||
|
||||
@@ -423,7 +423,7 @@ yarn seed-app-store
|
||||
```
|
||||
|
||||
You will need to complete a few more steps to activate Google Calendar App.
|
||||
Make sure to complete section "Obtaining the Google API Credentials". After the do the
|
||||
Make sure to complete section "Obtaining the Google API Credentials". After that do the
|
||||
following
|
||||
|
||||
1. Add extra redirect URL `<Cal.com URL>/api/auth/callback/google`
|
||||
@@ -449,8 +449,8 @@ following
|
||||
7. Click "Create".
|
||||
8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
10. Also add the redirect URL given above as a allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
10. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
12. Click "Done".
|
||||
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
||||
|
||||
@@ -526,7 +526,7 @@ For example, `Cal.com (support@cal.com)`.
|
||||
3. Copy Account SID to your `.env` file into the `TWILIO_SID` field
|
||||
4. Copy Auth Token to your `.env` file into the `TWILIO_TOKEN` field
|
||||
5. Copy your Twilio phone number to your `.env` file into the `TWILIO_PHONE_NUMBER` field
|
||||
6. Add your own sender id to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com)
|
||||
6. Add your own sender ID to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com)
|
||||
7. Create a messaging service (Develop -> Messaging -> Services)
|
||||
8. Choose any name for the messaging service
|
||||
9. Click 'Add Senders'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
BACKEND_URL=http://localhost:3002/api
|
||||
|
||||
APP_ID=cal-ai
|
||||
APP_URL=http://localhost:3000/apps/cal-ai
|
||||
|
||||
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
|
||||
PARSE_KEY=
|
||||
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optionally trace completions at https://smith.langchain.com
|
||||
# LANGCHAIN_TRACING_V2=true
|
||||
# LANGCHAIN_ENDPOINT=
|
||||
# LANGCHAIN_API_KEY=
|
||||
# LANGCHAIN_PROJECT=
|
||||
@@ -0,0 +1,48 @@
|
||||
# Cal.com Email Assistant
|
||||
|
||||
Welcome to the first stage of Cal AI!
|
||||
|
||||
This app lets you chat with your calendar via email:
|
||||
|
||||
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
||||
- List and rearrange your bookings eg. "Cancel my next meeting"
|
||||
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
|
||||
|
||||
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
|
||||
|
||||
_The AI agent can only choose from a set of tools, without ever seeing your API key._
|
||||
|
||||
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
|
||||
|
||||
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making it hard to spoof them.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Development
|
||||
|
||||
If you haven't yet, please run the [root setup](/README.md) steps.
|
||||
|
||||
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. You'll need:
|
||||
|
||||
- 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))
|
||||
|
||||
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||
|
||||
### Email Router
|
||||
|
||||
To expose the AI app, run `ngrok http 3000` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
|
||||
|
||||
To forward incoming emails to the Node.js server, one option is to use [SendGrid's Inbound Parse Webhook](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
|
||||
|
||||
1. [Sign up for an account](https://signup.sendgrid.com/)
|
||||
2. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL.
|
||||
3. For subdomain, use `<sub>.<domain>.com` for now, where `sub` can be any subdomain but `domain.com` will need to be verified via MX records in your environment variables, eg. on [Vercel](https://vercel.com/guides/how-to-add-vercel-environment-variables).
|
||||
4. Use the nGrok URL from above as the **Destination URL**.
|
||||
5. Activate "POST the raw, full MIME message".
|
||||
6. Send an email to `<anyone>@ai.example.com`. You should see a ping on the nGrok listener and Node.js server.
|
||||
7. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
|
||||
|
||||
Please feel free to improve any part of this architecture.
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,24 @@
|
||||
const withBundleAnalyzer = require("@next/bundle-analyzer");
|
||||
|
||||
const plugins = [];
|
||||
plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/",
|
||||
destination: "https://cal.com/ai",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@calcom/ai",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"author": "Cal.com Inc.",
|
||||
"dependencies": {
|
||||
"@calcom/prisma": "*",
|
||||
"@t3-oss/env-nextjs": "^0.6.1",
|
||||
"langchain": "^0.0.131",
|
||||
"mailparser": "^3.6.5",
|
||||
"next": "^13.4.6",
|
||||
"supports-color": "8.1.1",
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mailparser": "^3.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev -p 3005",
|
||||
"format": "npx prettier . --write",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"start": "next start"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import agent from "../../../utils/agent";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||
|
||||
/**
|
||||
* Launches a LangChain agent to process an incoming email,
|
||||
* then sends the response to the user.
|
||||
*/
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const verified = verifyParseKey(request.url);
|
||||
|
||||
if (!verified) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const json = await request.json();
|
||||
|
||||
const { apiKey, userId, message, subject, user, replyTo } = json;
|
||||
|
||||
if ((!message && !subject) || !user) {
|
||||
return new NextResponse("Missing fields", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId);
|
||||
|
||||
// Send response to user
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
||||
to: user.email,
|
||||
from: replyTo,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
} catch (error) {
|
||||
return new NextResponse(
|
||||
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { ParsedMail, Source } from "mailparser";
|
||||
import { simpleParser } from "mailparser";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { env } from "../../../env.mjs";
|
||||
import { fetchAvailability } from "../../../tools/getAvailability";
|
||||
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
||||
import getHostFromHeaders from "../../../utils/host";
|
||||
import now from "../../../utils/now";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||
|
||||
/**
|
||||
* Verifies email signature and app authorization,
|
||||
* then hands off to booking agent.
|
||||
*/
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const verified = verifyParseKey(request.url);
|
||||
|
||||
if (!verified) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const body = Object.fromEntries(formData);
|
||||
|
||||
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
|
||||
const signature = (body.dkim as string).includes(" : pass");
|
||||
|
||||
const envelope = JSON.parse(body.envelope as string);
|
||||
|
||||
const aiEmail = envelope.to[0];
|
||||
|
||||
// Parse email from mixed MIME type
|
||||
const parsed: ParsedMail = await simpleParser(body.email as Source);
|
||||
|
||||
if (!parsed.text && !parsed.subject) {
|
||||
return new NextResponse("Email missing text and subject", { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
select: {
|
||||
email: true,
|
||||
id: true,
|
||||
timeZone: true,
|
||||
credentials: {
|
||||
select: {
|
||||
appId: true,
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { email: envelope.from },
|
||||
});
|
||||
|
||||
// User is not a cal.com user or is using an unverified email.
|
||||
if (!signature || !user) {
|
||||
await sendEmail({
|
||||
html: `Thanks for your interest in Cal AI! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
||||
subject: `Re: ${body.subject}`,
|
||||
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,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
}
|
||||
|
||||
const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key;
|
||||
|
||||
// User has not installed the app from the app store. Direct them to install it.
|
||||
if (!(credential as { apiKey: string })?.apiKey) {
|
||||
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.`,
|
||||
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}`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
}
|
||||
|
||||
const { apiKey } = credential as { apiKey: string };
|
||||
|
||||
// Pre-fetch data relevant to most bookings.
|
||||
const [eventTypes, availability] = await Promise.all([
|
||||
fetchEventTypes({
|
||||
apiKey,
|
||||
}),
|
||||
fetchAvailability({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
dateFrom: now(user.timeZone),
|
||||
dateTo: now(user.timeZone),
|
||||
}),
|
||||
]);
|
||||
|
||||
if ("error" in availability) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: "Sorry, there was an error fetching your availability. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
});
|
||||
console.error(availability.error);
|
||||
return new NextResponse("Error fetching availability. Please try again.", { status: 400 });
|
||||
}
|
||||
|
||||
if ("error" in eventTypes) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: "Sorry, there was an error fetching your event types. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
});
|
||||
console.error(eventTypes.error);
|
||||
return new NextResponse("Error fetching event types. Please try again.", { status: 400 });
|
||||
}
|
||||
|
||||
const { workingHours } = availability;
|
||||
|
||||
const appHost = getHostFromHeaders(request.headers);
|
||||
|
||||
// Hand off to long-running agent endpoint to handle the email. (don't await)
|
||||
fetch(`${appHost}/api/agent?parseKey=${env.PARSE_KEY}`, {
|
||||
body: JSON.stringify({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
message: parsed.text,
|
||||
subject: parsed.subject,
|
||||
replyTo: aiEmail,
|
||||
user: {
|
||||
email: user.email,
|
||||
eventTypes,
|
||||
timeZone: user.timeZone,
|
||||
workingHours,
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
return new NextResponse("ok");
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||
* `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
|
||||
},
|
||||
|
||||
/**
|
||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
BACKEND_URL: process.env.BACKEND_URL,
|
||||
APP_ID: process.env.APP_ID,
|
||||
APP_URL: process.env.APP_URL,
|
||||
PARSE_KEY: process.env.PARSE_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
BACKEND_URL: z.string().url(),
|
||||
APP_ID: z.string().min(1),
|
||||
APP_URL: z.string().url(),
|
||||
PARSE_KEY: z.string().min(1),
|
||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||
OPENAI_API_KEY: z.string().min(1),
|
||||
SENDGRID_API_KEY: z.string().min(1),
|
||||
DATABASE_URL: z.string().url(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Creates a booking for a user by event type, times, and timezone.
|
||||
*/
|
||||
const createBooking = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
eventTypeId,
|
||||
start,
|
||||
end,
|
||||
timeZone,
|
||||
language,
|
||||
responses,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
eventTypeId: number;
|
||||
start: string;
|
||||
end: string;
|
||||
timeZone: string;
|
||||
language: string;
|
||||
responses: { name?: string; email?: string; location?: string };
|
||||
title?: string;
|
||||
status?: string;
|
||||
}): Promise<string | Error | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({
|
||||
end,
|
||||
eventTypeId,
|
||||
language,
|
||||
metadata: {},
|
||||
responses,
|
||||
start,
|
||||
timeZone,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
// Let GPT handle this. This will happen when wrong event type id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
error: data.message,
|
||||
};
|
||||
}
|
||||
|
||||
return "Booking created";
|
||||
};
|
||||
|
||||
const createBookingTool = (apiKey: string, userId: number) => {
|
||||
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 }) => {
|
||||
return JSON.stringify(
|
||||
await createBooking({
|
||||
apiKey,
|
||||
userId,
|
||||
end,
|
||||
eventTypeId,
|
||||
language,
|
||||
responses,
|
||||
start,
|
||||
status,
|
||||
timeZone,
|
||||
title,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "createBookingIfAvailable",
|
||||
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."),
|
||||
start: z.string(),
|
||||
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
||||
timeZone: z.string(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default createBookingTool;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Cancels a booking for a user by ID with reason.
|
||||
*/
|
||||
const cancelBooking = async ({
|
||||
apiKey,
|
||||
id,
|
||||
reason,
|
||||
}: {
|
||||
apiKey: string;
|
||||
id: string;
|
||||
reason: string;
|
||||
}): Promise<string | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings/${id}/cancel?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({ reason }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return "Booking cancelled";
|
||||
};
|
||||
|
||||
const cancelBookingTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Cancel a booking",
|
||||
func: async ({ id, reason }) => {
|
||||
return JSON.stringify(
|
||||
await cancelBooking({
|
||||
apiKey,
|
||||
id,
|
||||
reason,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "cancelBooking",
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default cancelBookingTool;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { Availability } from "../types/availability";
|
||||
|
||||
/**
|
||||
* Fetches availability for a user by date range and event type.
|
||||
*/
|
||||
export const fetchAvailability = async ({
|
||||
apiKey,
|
||||
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,
|
||||
userId: userId.toString(),
|
||||
dateFrom,
|
||||
dateTo,
|
||||
};
|
||||
|
||||
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return {
|
||||
busy: data.busy,
|
||||
dateRanges: data.dateRanges,
|
||||
timeZone: data.timeZone,
|
||||
workingHours: data.workingHours,
|
||||
};
|
||||
};
|
||||
|
||||
const getAvailabilityTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get availability within range.",
|
||||
func: async ({ dateFrom, dateTo, eventTypeId }) => {
|
||||
return JSON.stringify(
|
||||
await fetchAvailability({
|
||||
apiKey,
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "getAvailability",
|
||||
schema: z.object({
|
||||
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."
|
||||
),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getAvailabilityTool;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { Booking } from "../types/booking";
|
||||
import { BOOKING_STATUS } from "../types/booking";
|
||||
|
||||
/**
|
||||
* Fetches bookings for a user by date range.
|
||||
*/
|
||||
const fetchBookings = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
from: string;
|
||||
to: string;
|
||||
}): Promise<Booking[] | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
const bookings: Booking[] = data.bookings
|
||||
.filter((booking: Booking) => {
|
||||
const afterFrom = new Date(booking.startTime).getTime() > new Date(from).getTime();
|
||||
const beforeTo = new Date(booking.endTime).getTime() < new Date(to).getTime();
|
||||
const notCancelled = booking.status !== BOOKING_STATUS.CANCELLED;
|
||||
|
||||
return afterFrom && beforeTo && notCancelled;
|
||||
})
|
||||
.map(({ endTime, eventTypeId, id, startTime, status, title }: Booking) => ({
|
||||
endTime,
|
||||
eventTypeId,
|
||||
id,
|
||||
startTime,
|
||||
status,
|
||||
title,
|
||||
}));
|
||||
|
||||
return bookings;
|
||||
};
|
||||
|
||||
const getBookingsTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get bookings for a user between two dates.",
|
||||
func: async ({ from, to }) => {
|
||||
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
|
||||
},
|
||||
name: "getBookings",
|
||||
schema: z.object({
|
||||
from: z.string().describe("ISO 8601 datetime string"),
|
||||
to: z.string().describe("ISO 8601 datetime string"),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getBookingsTool;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { EventType } from "../types/eventType";
|
||||
|
||||
/**
|
||||
* Fetches event types by user ID.
|
||||
*/
|
||||
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
||||
const params = {
|
||||
apiKey,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return data.event_types.map((eventType: EventType) => ({
|
||||
id: eventType.id,
|
||||
length: eventType.length,
|
||||
title: eventType.title,
|
||||
}));
|
||||
};
|
||||
|
||||
const getEventTypesTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
|
||||
func: async () => {
|
||||
return JSON.stringify(
|
||||
await fetchEventTypes({
|
||||
apiKey,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "getEventTypes",
|
||||
schema: z.object({}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getEventTypesTool;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Edits a booking for a user by booking ID with new times, title, description, or status.
|
||||
*/
|
||||
const editBooking = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
id,
|
||||
startTime, // In the docs it says start, but it's startTime: https://cal.com/docs/enterprise-features/api/api-reference/bookings#edit-an-existing-booking.
|
||||
endTime, // Same here: it says end but it's endTime.
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
id: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
}): Promise<string | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings/${id}?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({ description, endTime, startTime, status, title }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PATCH",
|
||||
});
|
||||
|
||||
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return "Booking edited";
|
||||
};
|
||||
|
||||
const editBookingTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Edit a booking",
|
||||
func: async ({ description, endTime, id, startTime, status, title }) => {
|
||||
return JSON.stringify(
|
||||
await editBooking({
|
||||
apiKey,
|
||||
userId,
|
||||
description,
|
||||
endTime,
|
||||
id,
|
||||
startTime,
|
||||
status,
|
||||
title,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "editBooking",
|
||||
schema: z.object({
|
||||
description: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
id: z.string(),
|
||||
startTime: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default editBookingTool;
|
||||
@@ -0,0 +1,25 @@
|
||||
export type Availability = {
|
||||
busy: {
|
||||
start: string;
|
||||
end: string;
|
||||
title?: string;
|
||||
}[];
|
||||
timeZone: string;
|
||||
dateRanges: {
|
||||
start: string;
|
||||
end: string;
|
||||
}[];
|
||||
workingHours: {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
userId: number;
|
||||
}[];
|
||||
dateOverrides: {
|
||||
date: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
userId: number;
|
||||
};
|
||||
currentSeats: number;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export enum BOOKING_STATUS {
|
||||
ACCEPTED = "ACCEPTED",
|
||||
PENDING = "PENDING",
|
||||
CANCELLED = "CANCELLED",
|
||||
REJECTED = "REJECTED",
|
||||
}
|
||||
|
||||
export type Booking = {
|
||||
id: number;
|
||||
userId: number;
|
||||
description: string | null;
|
||||
eventTypeId: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
attendees: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||
user: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||
payment: { id: number; success: boolean; paymentOption: string }[];
|
||||
metadata: object | null;
|
||||
status: BOOKING_STATUS;
|
||||
responses: { email: string; name: string; location: string } | null;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export type EventType = {
|
||||
id: number;
|
||||
title: string;
|
||||
length: number;
|
||||
metadata: object;
|
||||
slug: string;
|
||||
hosts: {
|
||||
userId: number;
|
||||
isFixed: boolean;
|
||||
}[];
|
||||
hidden: boolean;
|
||||
// ...
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { EventType } from "./eventType";
|
||||
import type { WorkingHours } from "./workingHours";
|
||||
|
||||
export type User = {
|
||||
email: string;
|
||||
timeZone: string;
|
||||
eventTypes: EventType[];
|
||||
workingHours: WorkingHours[];
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export type WorkingHours = {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { initializeAgentExecutorWithOptions } from "langchain/agents";
|
||||
import { ChatOpenAI } from "langchain/chat_models/openai";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
|
||||
import deleteBooking from "../tools/deleteBooking";
|
||||
import getAvailability from "../tools/getAvailability";
|
||||
import getBookings from "../tools/getBookings";
|
||||
import updateBooking from "../tools/updateBooking";
|
||||
import type { EventType } from "../types/eventType";
|
||||
import type { User } 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.
|
||||
* 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 tools = [
|
||||
createBookingIfAvailable(apiKey, userId),
|
||||
getAvailability(apiKey, userId),
|
||||
getBookings(apiKey, userId),
|
||||
updateBooking(apiKey, userId),
|
||||
deleteBooking(apiKey),
|
||||
];
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
modelName: gptModel,
|
||||
openAIApiKey: env.OPENAI_API_KEY,
|
||||
temperature: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize the agent executor with arguments.
|
||||
*/
|
||||
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.
|
||||
|
||||
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")}
|
||||
`,
|
||||
},
|
||||
agentType: "openai-functions",
|
||||
returnIntermediateSteps: env.NODE_ENV === "development",
|
||||
verbose: env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
const result = await executor.call({ input });
|
||||
const { output } = result;
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
export default agent;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const getHostFromHeaders = (headers: NextRequest["headers"]): string => {
|
||||
return `https://${headers.get("host")}`;
|
||||
};
|
||||
|
||||
export default getHostFromHeaders;
|
||||
@@ -0,0 +1,5 @@
|
||||
export default function now(timeZone: string) {
|
||||
return new Date().toLocaleString("en-US", {
|
||||
timeZone,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import mail from "@sendgrid/mail";
|
||||
|
||||
const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
|
||||
|
||||
/**
|
||||
* Simply send an email by address, subject, and body.
|
||||
*/
|
||||
const send = async ({
|
||||
subject,
|
||||
to,
|
||||
from,
|
||||
text,
|
||||
html,
|
||||
}: {
|
||||
subject: string;
|
||||
to: string;
|
||||
from: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
}): Promise<boolean> => {
|
||||
mail.setApiKey(sendgridAPIKey);
|
||||
|
||||
const msg = {
|
||||
to,
|
||||
from: {
|
||||
email: from,
|
||||
name: "Cal AI",
|
||||
},
|
||||
text,
|
||||
html,
|
||||
subject,
|
||||
};
|
||||
|
||||
const res = await mail.send(msg);
|
||||
const success = !!res;
|
||||
|
||||
return success;
|
||||
};
|
||||
|
||||
export default send;
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Verifies that the request contains the correct parse key.
|
||||
* env.PARSE_KEY must be configured as a query param in the sendgrid inbound parse settings.
|
||||
*/
|
||||
export const verifyParseKey = (url: NextRequest["url"]) => {
|
||||
const verified = new URL(url).searchParams.get("parseKey") === env.PARSE_KEY;
|
||||
|
||||
return verified;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@calcom/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -70,6 +70,7 @@ const schemaEventTypeCreateParams = z
|
||||
recurringEvent: recurringEventInputSchema.optional(),
|
||||
seatsPerTimeSlot: z.number().optional(),
|
||||
seatsShowAttendees: z.boolean().optional(),
|
||||
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||
bookingFields: eventTypeBookingFields.optional(),
|
||||
scheduleId: z.number().optional(),
|
||||
})
|
||||
@@ -89,6 +90,7 @@ const schemaEventTypeEditParams = z
|
||||
length: z.number().int().optional(),
|
||||
seatsPerTimeSlot: z.number().optional(),
|
||||
seatsShowAttendees: z.boolean().optional(),
|
||||
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||
bookingFields: eventTypeBookingFields.optional(),
|
||||
scheduleId: z.number().optional(),
|
||||
})
|
||||
@@ -129,6 +131,7 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
seatsShowAttendees: true,
|
||||
seatsShowAvailabilityCount: true,
|
||||
bookingFields: true,
|
||||
bookingLimits: true,
|
||||
durationLimits: true,
|
||||
|
||||
@@ -13,16 +13,18 @@ const schemaMembershipRequiredParams = z.object({
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export const membershipCreateBodySchema = Membership.partial({
|
||||
accepted: true,
|
||||
role: true,
|
||||
disableImpersonation: true,
|
||||
}).transform((v) => ({
|
||||
accepted: false,
|
||||
role: MembershipRole.MEMBER,
|
||||
disableImpersonation: false,
|
||||
...v,
|
||||
}));
|
||||
export const membershipCreateBodySchema = Membership.omit({ id: true })
|
||||
.partial({
|
||||
accepted: true,
|
||||
role: true,
|
||||
disableImpersonation: true,
|
||||
})
|
||||
.transform((v) => ({
|
||||
accepted: false,
|
||||
role: MembershipRole.MEMBER,
|
||||
disableImpersonation: false,
|
||||
...v,
|
||||
}));
|
||||
|
||||
export const membershipEditBodySchema = Membership.omit({
|
||||
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */
|
||||
|
||||
@@ -40,6 +40,6 @@
|
||||
"typescript": "^4.9.4",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.20.2"
|
||||
"zod": "^3.22.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ import { defaultResponder } from "@calcom/lib/server";
|
||||
* seatsShowAttendees:
|
||||
* type: boolean
|
||||
* description: 'Share Attendee information in seats'
|
||||
* seatsShowAvailabilityCount:
|
||||
* type: boolean
|
||||
* description: 'Show the number of available seats'
|
||||
* smsReminderNumber:
|
||||
* type: number
|
||||
* description: 'SMS reminder number'
|
||||
|
||||
@@ -146,6 +146,9 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
|
||||
* seatsShowAttendees:
|
||||
* type: boolean
|
||||
* description: 'Share Attendee information in seats'
|
||||
* seatsShowAvailabilityCount:
|
||||
* type: boolean
|
||||
* description: 'Show the number of available seats'
|
||||
* locations:
|
||||
* type: array
|
||||
* description: A list of all available locations for the event type
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Meta } from "@storybook/addon-docs";
|
||||
<a href="https://www.figma.com/file/9MOufQNLtdkpnDucmNX10R/%E2%9D%96-Cal-DS" target="_blank">
|
||||
Figma
|
||||
</a>{" "}
|
||||
library is avalible for anyone to view and use. If you have any questions or concerns, please reach out to
|
||||
library is available for anyone to view and use. If you have any questions or concerns, please reach out to
|
||||
the design team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -248,10 +248,10 @@
|
||||
--cal-bg-inverted: #f3f4f6;
|
||||
|
||||
/* background -> components*/
|
||||
--cal-bg-info: #dee9fc;
|
||||
--cal-bg-success: #e2fbe8;
|
||||
--cal-bg-attention: #fceed8;
|
||||
--cal-bg-error: #f9e3e2;
|
||||
--cal-bg-info: #263fa9;
|
||||
--cal-bg-success: #306339;
|
||||
--cal-bg-attention: #8e3b1f;
|
||||
--cal-bg-error: #8c2822;
|
||||
--cal-bg-dark-error: #752522;
|
||||
|
||||
/* Borders */
|
||||
@@ -269,10 +269,10 @@
|
||||
--cal-text-inverted: #101010;
|
||||
|
||||
/* Content/Text -> components */
|
||||
--cal-text-info: #253985;
|
||||
--cal-text-success: #285231;
|
||||
--cal-text-attention: #73321b;
|
||||
--cal-text-error: #752522;
|
||||
--cal-text-info: #dee9fc;
|
||||
--cal-text-success: #e2fbe8;
|
||||
--cal-text-attention: #fceed8;
|
||||
--cal-text-error: #f9e3e2;
|
||||
|
||||
/* Brand shenanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
@@ -80,7 +81,13 @@ 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">
|
||||
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
||||
{logo ? (
|
||||
<img
|
||||
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
||||
src={logo}
|
||||
alt={`${title} logo`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex grow flex-col gap-y-1 truncate">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3>
|
||||
|
||||
@@ -116,6 +116,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
type={item.integration.type}
|
||||
isChecked={cal.isSelected}
|
||||
destination={cal.externalId === props.destinationCalendarId}
|
||||
credentialId={cal.credentialId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, TextField } from "@calcom/ui";
|
||||
|
||||
export default function TwoFactor({ center = true }) {
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
|
||||
return (
|
||||
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
|
||||
<Label className="mt-4">{t("backup_code")}</Label>
|
||||
|
||||
<p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>
|
||||
|
||||
<TextField
|
||||
id="backup-code"
|
||||
label=""
|
||||
defaultValue=""
|
||||
placeholder="XXXXX-XXXXX"
|
||||
minLength={10} // without dash
|
||||
maxLength={11} // with dash
|
||||
required
|
||||
{...methods.register("backupCode")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useFormContext } from "react-hook-form";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, Input } from "@calcom/ui";
|
||||
|
||||
export default function TwoFactor({ center = true }) {
|
||||
export default function TwoFactor({ center = true, autoFocus = true }) {
|
||||
const [value, onChange] = useState("");
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
@@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) {
|
||||
name={`2fa${index + 1}`}
|
||||
inputMode="decimal"
|
||||
{...digit}
|
||||
autoFocus={index === 0}
|
||||
autoFocus={autoFocus && index === 0}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
}}
|
||||
/>
|
||||
{selectedLocation && LocationOptions}
|
||||
<DialogFooter>
|
||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
setEditingLocationType?.("");
|
||||
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
setEditingLocationType?.("");
|
||||
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button data-testid="update-location" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button data-testid="update-location" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent>
|
||||
<DialogContent enableOverflow>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<Clock className="m-auto h-6 w-6" />
|
||||
|
||||
@@ -377,6 +377,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import cx from "@calcom/lib/classNames";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
@@ -302,13 +301,7 @@ export const EventSetupTab = (
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={cx(
|
||||
"h-4 w-4",
|
||||
// invert all the icons except app icons
|
||||
eventLocationType.iconUrl &&
|
||||
!eventLocationType.iconUrl.startsWith("/app-store") &&
|
||||
"dark:invert"
|
||||
)}
|
||||
className="h-4 w-4 dark:invert-[.65]"
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
/>
|
||||
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { Options } from "react-select";
|
||||
import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Label, Select } from "@calcom/ui";
|
||||
@@ -18,13 +17,14 @@ interface IUserToValue {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
username: string | null;
|
||||
avatar: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const mapUserToValue = ({ id, name, username, email }: IUserToValue, pendingString: string) => ({
|
||||
const mapUserToValue = ({ id, name, username, avatar, email }: IUserToValue, pendingString: string) => ({
|
||||
value: `${id || ""}`,
|
||||
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
|
||||
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
|
||||
avatar,
|
||||
email,
|
||||
});
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ type Props = {
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
isUpdateMutationLoading?: boolean;
|
||||
availability?: AvailabilityOption;
|
||||
isUserOrganizationAdmin: boolean;
|
||||
};
|
||||
|
||||
function getNavigation(props: {
|
||||
@@ -133,6 +134,7 @@ function EventTypeSingleLayout({
|
||||
isUpdateMutationLoading,
|
||||
formMethods,
|
||||
availability,
|
||||
isUserOrganizationAdmin,
|
||||
}: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
@@ -142,7 +144,8 @@ function EventTypeSingleLayout({
|
||||
const hasPermsToDelete =
|
||||
currentUserMembership?.role !== "MEMBER" ||
|
||||
!currentUserMembership ||
|
||||
eventType.schedulingType === SchedulingType.MANAGED;
|
||||
eventType.schedulingType === SchedulingType.MANAGED ||
|
||||
isUserOrganizationAdmin;
|
||||
|
||||
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -53,6 +53,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
|
||||
<ul className="p-4">
|
||||
{calendars?.map((calendar, i) => (
|
||||
<CalendarSwitch
|
||||
credentialId={calendar.credentialId}
|
||||
key={calendar.externalId}
|
||||
externalId={calendar.externalId}
|
||||
title={calendar.name || "Nameless Calendar"}
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { FormEvent } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar, Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
type FormData = {
|
||||
@@ -99,11 +100,11 @@ const UserProfile = () => {
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
||||
{user && (
|
||||
<Avatar
|
||||
<OrganizationAvatar
|
||||
alt={user.username || "user avatar"}
|
||||
gravatarFallbackMd5={user.emailMd5}
|
||||
size="lg"
|
||||
imageSrc={imageSrc}
|
||||
organizationSlug={user.organization?.slug}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
||||
|
||||
import BackupCode from "@components/auth/BackupCode";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
@@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps {
|
||||
}
|
||||
|
||||
interface DisableTwoFactorValues {
|
||||
backupCode: string;
|
||||
totpCode: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -33,11 +35,19 @@ const DisableTwoFactorAuthModal = ({
|
||||
}: DisableTwoFactorAuthModalProps) => {
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||
const { t } = useLocale();
|
||||
|
||||
const form = useForm<DisableTwoFactorValues>();
|
||||
|
||||
async function handleDisable({ totpCode, password }: DisableTwoFactorValues) {
|
||||
const resetForm = (clearPassword = true) => {
|
||||
if (clearPassword) form.setValue("password", "");
|
||||
form.setValue("backupCode", "");
|
||||
form.setValue("totpCode", "");
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
async function handleDisable({ password, totpCode, backupCode }: DisableTwoFactorValues) {
|
||||
if (isDisabling) {
|
||||
return;
|
||||
}
|
||||
@@ -45,8 +55,10 @@ const DisableTwoFactorAuthModal = ({
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.disable(password, totpCode);
|
||||
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
|
||||
if (response.status === 200) {
|
||||
setTwoFactorLostAccess(false);
|
||||
resetForm();
|
||||
onDisable();
|
||||
return;
|
||||
}
|
||||
@@ -54,12 +66,14 @@ const DisableTwoFactorAuthModal = ({
|
||||
const body = await response.json();
|
||||
if (body.error === ErrorCode.IncorrectPassword) {
|
||||
setErrorMessage(t("incorrect_password"));
|
||||
}
|
||||
if (body.error === ErrorCode.SecondFactorRequired) {
|
||||
} else if (body.error === ErrorCode.SecondFactorRequired) {
|
||||
setErrorMessage(t("2fa_required"));
|
||||
}
|
||||
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
setErrorMessage(t("incorrect_2fa"));
|
||||
} else if (body.error === ErrorCode.IncorrectBackupCode) {
|
||||
setErrorMessage(t("incorrect_backup_code"));
|
||||
} else if (body.error === ErrorCode.MissingBackupCodes) {
|
||||
setErrorMessage(t("missing_backup_codes"));
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
@@ -78,6 +92,7 @@ const DisableTwoFactorAuthModal = ({
|
||||
<div className="mb-8">
|
||||
{!disablePassword && (
|
||||
<PasswordField
|
||||
required
|
||||
labelProps={{
|
||||
className: "block text-sm font-medium text-default",
|
||||
}}
|
||||
@@ -85,12 +100,25 @@ const DisableTwoFactorAuthModal = ({
|
||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||
/>
|
||||
)}
|
||||
<TwoFactor center={false} />
|
||||
{twoFactorLostAccess ? (
|
||||
<BackupCode center={false} />
|
||||
) : (
|
||||
<TwoFactor center={false} autoFocus={false} />
|
||||
)}
|
||||
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter showDivider className="relative mt-5">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="mr-auto"
|
||||
onClick={() => {
|
||||
setTwoFactorLostAccess(!twoFactorLostAccess);
|
||||
resetForm(false);
|
||||
}}>
|
||||
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
@@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps {
|
||||
|
||||
enum SetupStep {
|
||||
ConfirmPassword,
|
||||
DisplayBackupCodes,
|
||||
DisplayQrCode,
|
||||
EnterTotpCode,
|
||||
}
|
||||
@@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
|
||||
const setupDescriptions = {
|
||||
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
||||
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
|
||||
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
||||
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
||||
};
|
||||
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
||||
const [password, setPassword] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState([]);
|
||||
const [backupCodesUrl, setBackupCodesUrl] = useState("");
|
||||
const [dataUri, setDataUri] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const resetState = () => {
|
||||
setPassword("");
|
||||
setErrorMessage(null);
|
||||
setStep(SetupStep.ConfirmPassword);
|
||||
};
|
||||
|
||||
async function handleSetup(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
setBackupCodes(body.backupCodes);
|
||||
|
||||
// create backup codes download url
|
||||
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
|
||||
type: "text/plain",
|
||||
});
|
||||
if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl);
|
||||
setBackupCodesUrl(URL.createObjectURL(textBlob));
|
||||
|
||||
setDataUri(body.dataUri);
|
||||
setSecret(body.secret);
|
||||
setStep(SetupStep.DisplayQrCode);
|
||||
@@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
onEnable();
|
||||
setStep(SetupStep.DisplayBackupCodes);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,13 +160,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
}
|
||||
}, [form, handleEnableRef, totpCode]);
|
||||
|
||||
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
||||
<DialogContent
|
||||
title={step === SetupStep.DisplayBackupCodes ? t("backup_codes") : t("enable_2fa")}
|
||||
description={setupDescriptions[step]}
|
||||
type="creation">
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<form onSubmit={handleSetup}>
|
||||
<div className="mb-4">
|
||||
<TextField
|
||||
<PasswordField
|
||||
label={t("password")}
|
||||
type="password"
|
||||
name="password"
|
||||
@@ -173,6 +197,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
</p>
|
||||
</>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||
<>
|
||||
<div className="mt-5 grid grid-cols-2 gap-1 text-center font-mono md:pl-10 md:pr-10">
|
||||
{backupCodes.map((code) => (
|
||||
<div key={code}>{formatBackupCode(code)}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</WithStep>
|
||||
<Form handleSubmit={handleEnable} form={form}>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<div className="-mt-4 pb-2">
|
||||
@@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
</div>
|
||||
</WithStep>
|
||||
<DialogFooter className="mt-8" showDivider>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
{step !== SetupStep.DisplayBackupCodes ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
resetState();
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -218,6 +258,35 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="backup-codes-close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetState();
|
||||
onEnable();
|
||||
}}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="backup-codes-copy"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(backupCodes.map(formatBackupCode).join("\n"));
|
||||
showToast(t("backup_codes_copied"), "success");
|
||||
}}>
|
||||
{t("copy")}
|
||||
</Button>
|
||||
<a download="cal-backup-codes.txt" href={backupCodesUrl}>
|
||||
<Button color="primary" data-testid="backup-codes-download">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</a>
|
||||
</>
|
||||
</WithStep>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -19,10 +19,10 @@ const TwoFactorAuthAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
async disable(password: string, code: string) {
|
||||
async disable(password: string, code: string, backupCode: string) {
|
||||
return fetch("/api/auth/two-factor/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password, code }),
|
||||
body: JSON.stringify({ password, code, backupCode }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -57,12 +57,10 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
}),
|
||||
});
|
||||
|
||||
const formMethods = useForm<{
|
||||
username: string;
|
||||
email_address: string;
|
||||
full_name: string;
|
||||
password: string;
|
||||
}>({
|
||||
type formSchemaType = z.infer<typeof formSchema>;
|
||||
|
||||
const formMethods = useForm<formSchemaType>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
@@ -70,7 +68,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
props.onError();
|
||||
};
|
||||
|
||||
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
|
||||
const onSubmit = formMethods.handleSubmit(async (data) => {
|
||||
props.onSubmit();
|
||||
const response = await fetch("/api/auth/setup", {
|
||||
method: "POST",
|
||||
@@ -130,11 +128,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
|
||||
onBlur={onBlur}
|
||||
name="username"
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("username", e.target.value);
|
||||
await formMethods.trigger("username");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -148,11 +142,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<TextField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("full_name", e.target.value);
|
||||
await formMethods.trigger("full_name");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
color={formMethods.formState.errors.full_name ? "warn" : ""}
|
||||
type="text"
|
||||
name="full_name"
|
||||
@@ -172,11 +162,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<EmailField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("email_address", e.target.value);
|
||||
await formMethods.trigger("email_address");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="my-0"
|
||||
name="email_address"
|
||||
/>
|
||||
@@ -191,11 +177,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<PasswordField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("password", e.target.value);
|
||||
await formMethods.trigger("password");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
hintErrors={["caplow", "admin_min", "num"]}
|
||||
name="password"
|
||||
className="my-0"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { components } from "react-select";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import cx from "@calcom/lib/classNames";
|
||||
import { Select } from "@calcom/ui";
|
||||
|
||||
export type LocationOption = {
|
||||
@@ -23,14 +22,7 @@ export type GroupOptionType = GroupBase<LocationOption>;
|
||||
const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && (
|
||||
<img
|
||||
src={icon}
|
||||
alt="cover"
|
||||
// invert all the icons except app icons
|
||||
className={cx("h-3.5 w-3.5", icon && !icon.startsWith("/app-store") && "dark:invert")}
|
||||
/>
|
||||
)}
|
||||
{icon && <img src={icon} alt="cover" className="h-3.5 w-3.5 dark:invert-[.65]" />}
|
||||
<span className={classNames("text-sm font-medium")}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -57,7 +49,13 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
|
||||
}}
|
||||
formatOptionLabel={(e) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
||||
{e.icon && (
|
||||
<img
|
||||
src={e.icon}
|
||||
alt="app-icon"
|
||||
className={classNames(e.icon.includes("-dark") && "dark:invert", "h-5 w-5")}
|
||||
/>
|
||||
)}
|
||||
<span>{e.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,6 @@ import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { MetaProvider } from "@calcom/ui";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
@@ -222,19 +221,7 @@ function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function useOrgBrandingValues() {
|
||||
const session = useSession();
|
||||
|
||||
const res = trpc.viewer.organizations.getBrand.useQuery(undefined, {
|
||||
// Only fetch if we have a session to avoid flooding logs with errors
|
||||
enabled: session.status === "authenticated",
|
||||
});
|
||||
|
||||
if (res.status === "loading") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (res.status === "error") return null;
|
||||
|
||||
return res.data;
|
||||
return session?.data?.user.org;
|
||||
}
|
||||
|
||||
function OrgBrandProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ function getCspPolicy(nonce: string) {
|
||||
IS_PRODUCTION ? (useNonStrictPolicy ? "'unsafe-inline'" : "") : "'unsafe-inline'"
|
||||
} app.cal.com;
|
||||
font-src 'self';
|
||||
img-src 'self' ${WEBAPP_URL} https://www.gravatar.com https://img.youtube.com https://eu.ui-avatars.com/api/ data:;
|
||||
img-src 'self' ${WEBAPP_URL} https://img.youtube.com https://eu.ui-avatars.com/api/ data:;
|
||||
connect-src 'self'
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
export const defaultAvatarSrc = function ({ email, md5 }: { md5?: string; email?: string }) {
|
||||
if (!email && !md5) return "";
|
||||
|
||||
if (email && !md5) {
|
||||
md5 = crypto.createHash("md5").update(email).digest("hex");
|
||||
}
|
||||
|
||||
return `https://www.gravatar.com/avatar/${md5}?s=160&d=mp&r=PG`;
|
||||
};
|
||||
+47
-33
@@ -14,7 +14,8 @@ const {
|
||||
|
||||
if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
|
||||
|
||||
const isOrganizationsEnabled =
|
||||
process.env.ORGANIZATIONS_ENABLED === "1" || process.env.ORGANIZATIONS_ENABLED === "true";
|
||||
// To be able to use the version in the app without having to import package.json
|
||||
process.env.NEXT_PUBLIC_CALCOM_VERSION = version;
|
||||
|
||||
@@ -226,7 +227,7 @@ const nextConfig = {
|
||||
async rewrites() {
|
||||
const beforeFiles = [
|
||||
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
|
||||
...(process.env.ORGANIZATIONS_ENABLED
|
||||
...(isOrganizationsEnabled
|
||||
? [
|
||||
{
|
||||
...matcherConfigRootPath,
|
||||
@@ -253,6 +254,10 @@ const nextConfig = {
|
||||
source: "/org/:slug",
|
||||
destination: "/team/:slug",
|
||||
},
|
||||
{
|
||||
source: "/org/:orgSlug/avatar.png",
|
||||
destination: "/api/user/avatar?orgSlug=:orgSlug",
|
||||
},
|
||||
{
|
||||
source: "/team/:teamname/avatar.png",
|
||||
destination: "/api/user/avatar?teamname=:teamname",
|
||||
@@ -333,44 +338,46 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
...[
|
||||
{
|
||||
...matcherConfigRootPath,
|
||||
headers: [
|
||||
...(isOrganizationsEnabled
|
||||
? [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/team/:orgSlug",
|
||||
...matcherConfigRootPath,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/team/:orgSlug",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user",
|
||||
...matcherConfigUserRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserTypeRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type",
|
||||
...matcherConfigUserTypeRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...matcherConfigUserTypeEmbedRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type/embed",
|
||||
...matcherConfigUserTypeEmbedRoute,
|
||||
headers: [
|
||||
{
|
||||
key: "X-Cal-Org-path",
|
||||
value: "/org/:orgSlug/:user/:type/embed",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
@@ -447,6 +454,13 @@ const nextConfig = {
|
||||
},
|
||||
{
|
||||
source: "/support",
|
||||
missing: [
|
||||
{
|
||||
type: "header",
|
||||
key: "host",
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
destination: "/event-types?openIntercom=true",
|
||||
permanent: true,
|
||||
},
|
||||
@@ -463,7 +477,7 @@ const nextConfig = {
|
||||
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
|
||||
...(process.env.NODE_ENV === "development" &&
|
||||
// Safer to enable the redirect only when the user is opting to test out organizations
|
||||
process.env.ORGANIZATIONS_ENABLED &&
|
||||
isOrganizationsEnabled &&
|
||||
// Prevent infinite redirect by checking that we aren't already on localhost
|
||||
process.env.NEXT_PUBLIC_WEBAPP_URL !== "http://localhost:3000"
|
||||
? [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.2.5",
|
||||
"version": "3.2.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -129,7 +129,7 @@
|
||||
"turndown": "^7.1.1",
|
||||
"uuid": "^8.3.2",
|
||||
"web3": "^1.7.5",
|
||||
"zod": "^3.20.2"
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.6",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
@@ -25,7 +26,7 @@ import prisma from "@calcom/prisma";
|
||||
import type { EventType, User } from "@calcom/prisma/client";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Avatar, HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
import { Verified, ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
@@ -96,7 +97,12 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
"max-w-3xl px-4 py-24"
|
||||
)}>
|
||||
<div className="mb-8 text-center">
|
||||
<Avatar imageSrc={profile.image} size="xl" alt={profile.name} />
|
||||
<OrganizationAvatar
|
||||
imageSrc={profile.image}
|
||||
size="xl"
|
||||
alt={profile.name}
|
||||
organizationSlug={profile.organizationSlug}
|
||||
/>
|
||||
<h1 className="font-cal text-emphasis mb-1 text-3xl" data-testid="name-title">
|
||||
{profile.name}
|
||||
{user.verified && (
|
||||
@@ -218,6 +224,7 @@ export type UserPageProps = {
|
||||
theme: string | null;
|
||||
brandColor: string;
|
||||
darkBrandColor: string;
|
||||
organizationSlug: string | null;
|
||||
allowSEOIndexing: boolean;
|
||||
};
|
||||
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
|
||||
@@ -321,6 +328,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
organizationSlug: user.organization?.slug ?? null,
|
||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
|
||||
@@ -11,18 +10,9 @@ 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 { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
const signupSchema = z.object({
|
||||
username: z.string().refine((value) => !value.includes("+"), {
|
||||
message: "String should not contain a plus symbol (+).",
|
||||
}),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(7),
|
||||
language: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
});
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).end();
|
||||
|
||||
@@ -43,8 +43,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
|
||||
}
|
||||
}
|
||||
// if user has 2fa
|
||||
if (user.twoFactorEnabled) {
|
||||
|
||||
// if user has 2fa and using backup code
|
||||
if (user.twoFactorEnabled && req.body.backupCode) {
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
|
||||
console.error("Missing encryption key; cannot proceed with backup code login.");
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
if (!user.backupCodes) {
|
||||
return res.status(400).json({ error: ErrorCode.MissingBackupCodes });
|
||||
}
|
||||
|
||||
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));
|
||||
|
||||
// check if user-supplied code matches one
|
||||
const index = backupCodes.indexOf(req.body.backupCode.replaceAll("-", ""));
|
||||
if (index === -1) {
|
||||
return res.status(400).json({ error: ErrorCode.IncorrectBackupCode });
|
||||
}
|
||||
|
||||
// we delete all stored backup codes at the end, no need to do this here
|
||||
|
||||
// if user has 2fa and NOT using backup code, try totp
|
||||
} else if (user.twoFactorEnabled) {
|
||||
if (!req.body.code) {
|
||||
return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
|
||||
// throw new Error(ErrorCode.SecondFactorRequired);
|
||||
@@ -82,6 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
id: session.user.id,
|
||||
},
|
||||
data: {
|
||||
backupCodes: null,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authenticator } from "otplib";
|
||||
import qrcode from "qrcode";
|
||||
@@ -56,11 +57,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
// bytes without updating the sanity checks in the enable and login endpoints.
|
||||
const secret = authenticator.generateSecret(20);
|
||||
|
||||
// generate backup codes with 10 character length
|
||||
const backupCodes = Array.from(Array(10), () => crypto.randomBytes(5).toString("hex"));
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
data: {
|
||||
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY),
|
||||
},
|
||||
@@ -70,5 +75,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const keyUri = authenticator.keyuri(name, "Cal", secret);
|
||||
const dataUri = await qrcode.toDataURL(keyUri);
|
||||
|
||||
return res.json({ secret, keyUri, dataUri });
|
||||
return res.json({ secret, keyUri, dataUri, backupCodes });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import prisma from "@calcom/prisma";
|
||||
const selectedCalendarSelectSchema = z.object({
|
||||
integration: z.string(),
|
||||
externalId: z.string(),
|
||||
credentialId: z.number().optional(),
|
||||
});
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -37,7 +38,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const { credentials, ...user } = userWithCredentials;
|
||||
|
||||
if (req.method === "POST") {
|
||||
const { integration, externalId } = selectedCalendarSelectSchema.parse(req.body);
|
||||
const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.body);
|
||||
await prisma.selectedCalendar.upsert({
|
||||
where: {
|
||||
userId_integration_externalId: {
|
||||
@@ -50,6 +51,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
userId: user.id,
|
||||
integration,
|
||||
externalId,
|
||||
credentialId,
|
||||
},
|
||||
// already exists
|
||||
update: {},
|
||||
|
||||
@@ -104,7 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
|
||||
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
@@ -127,7 +127,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
|
||||
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
|
||||
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
|
||||
};
|
||||
|
||||
await sendOrganizerRequestReminderEmail(evt);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/webhooks/lib/cron";
|
||||
@@ -187,7 +187,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", response.headers.get("content-type") as string);
|
||||
res.setHeader("Cache-Control", "s-maxage=86400");
|
||||
res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
res.statusCode = 404;
|
||||
|
||||
@@ -34,13 +34,17 @@ const triggerWebhook = async ({
|
||||
booking: {
|
||||
userId: number | undefined;
|
||||
eventTypeId: number | null;
|
||||
eventTypeParentId: number | null | undefined;
|
||||
teamId?: number | null;
|
||||
};
|
||||
}) => {
|
||||
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
|
||||
// Send Webhook call if hooked to BOOKING.RECORDING_READY
|
||||
|
||||
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
|
||||
|
||||
const subscriberOptions = {
|
||||
userId: booking.userId,
|
||||
userId: triggerForUser ? booking.userId : null,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
triggerEvent: eventTrigger,
|
||||
teamId: booking.teamId,
|
||||
@@ -183,6 +187,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
booking: {
|
||||
userId: booking?.user?.id,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
eventTypeParentId: booking.eventType?.parentId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
const querySchema = z
|
||||
.object({
|
||||
username: z.string(),
|
||||
teamname: z.string(),
|
||||
orgSlug: z.string(),
|
||||
/**
|
||||
* Allow fetching avatar of a particular organization
|
||||
* Avatars being public, we need not worry about others accessing it.
|
||||
@@ -21,7 +20,7 @@ const querySchema = z
|
||||
.partial();
|
||||
|
||||
async function getIdentityData(req: NextApiRequest) {
|
||||
const { username, teamname, orgId } = querySchema.parse(req.query);
|
||||
const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
@@ -61,7 +60,23 @@ async function getIdentityData(req: NextApiRequest) {
|
||||
org,
|
||||
name: teamname,
|
||||
email: null,
|
||||
avatar: team?.logo || getPlaceholderAvatar(null, teamname),
|
||||
avatar: getPlaceholderAvatar(team?.logo, teamname),
|
||||
};
|
||||
}
|
||||
if (orgSlug) {
|
||||
const org = await prisma.team.findFirst({
|
||||
where: getSlugOrRequestedSlug(orgSlug),
|
||||
select: {
|
||||
slug: true,
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
org: org?.slug,
|
||||
name: org?.name,
|
||||
email: null,
|
||||
avatar: getPlaceholderAvatar(org?.logo, org?.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -69,18 +84,15 @@ async function getIdentityData(req: NextApiRequest) {
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const identity = await getIdentityData(req);
|
||||
const img = identity?.avatar;
|
||||
// We cache for one day
|
||||
res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
|
||||
// If image isn't set or links to this route itself, use default avatar
|
||||
if (!img) {
|
||||
if (identity?.org) {
|
||||
res.setHeader("x-cal-org", identity.org);
|
||||
}
|
||||
res.writeHead(302, {
|
||||
Location: defaultAvatarSrc({
|
||||
md5: crypto
|
||||
.createHash("md5")
|
||||
.update(identity?.email || "guest@example.com")
|
||||
.digest("hex"),
|
||||
}),
|
||||
Location: AVATAR_FALLBACK,
|
||||
});
|
||||
|
||||
return res.end();
|
||||
|
||||
@@ -12,7 +12,14 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import type { HorizontalTabItemProps } from "@calcom/ui";
|
||||
import { AllApps, AppStoreCategories, HorizontalTabs, TextField, PopularAppsSlider } from "@calcom/ui";
|
||||
import {
|
||||
AllApps,
|
||||
AppStoreCategories,
|
||||
HorizontalTabs,
|
||||
TextField,
|
||||
PopularAppsSlider,
|
||||
RecentAppsSlider,
|
||||
} from "@calcom/ui";
|
||||
import { Search } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
@@ -81,6 +88,7 @@ export default function Apps({
|
||||
<>
|
||||
<AppStoreCategories categories={categories} />
|
||||
<PopularAppsSlider items={appStore} />
|
||||
<RecentAppsSlider items={appStore} />
|
||||
</>
|
||||
)}
|
||||
<AllApps
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
||||
import { ArrowLeft } from "@calcom/ui/components/icon";
|
||||
import { ArrowLeft, Lock } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
@@ -29,6 +29,7 @@ import withNonce from "@lib/withNonce";
|
||||
|
||||
import AddToHomescreen from "@components/AddToHomescreen";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import BackupCode from "@components/auth/BackupCode";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
|
||||
@@ -39,6 +40,7 @@ interface LoginValues {
|
||||
email: string;
|
||||
password: string;
|
||||
totpCode: string;
|
||||
backupCode: string;
|
||||
csrfToken: string;
|
||||
}
|
||||
export default function Login({
|
||||
@@ -50,6 +52,8 @@ export default function Login({
|
||||
totpEmail,
|
||||
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const isTeamInvite = searchParams.get("teamInvite");
|
||||
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const formSchema = z
|
||||
@@ -65,6 +69,7 @@ export default function Login({
|
||||
const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) });
|
||||
const { register, formState } = methods;
|
||||
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
|
||||
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const errorMessages: { [key: string]: string } = {
|
||||
@@ -92,21 +97,43 @@ export default function Login({
|
||||
callbackUrl = safeCallbackUrl || "";
|
||||
|
||||
const LoginFooter = (
|
||||
<a href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
|
||||
<a
|
||||
href={callbackUrl !== "" ? `${WEBSITE_URL}/signup?callbackUrl=${callbackUrl}` : `${WEBSITE_URL}/signup`}
|
||||
className="text-brand-500 font-medium">
|
||||
{t("dont_have_an_account")}
|
||||
</a>
|
||||
);
|
||||
|
||||
const TwoFactorFooter = (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTwoFactorRequired(false);
|
||||
methods.setValue("totpCode", "");
|
||||
}}
|
||||
StartIcon={ArrowLeft}
|
||||
color="minimal">
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (twoFactorLostAccess) {
|
||||
setTwoFactorLostAccess(false);
|
||||
methods.setValue("backupCode", "");
|
||||
} else {
|
||||
setTwoFactorRequired(false);
|
||||
methods.setValue("totpCode", "");
|
||||
}
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
StartIcon={ArrowLeft}
|
||||
color="minimal">
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
{!twoFactorLostAccess ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTwoFactorLostAccess(true);
|
||||
setErrorMessage(null);
|
||||
methods.setValue("totpCode", "");
|
||||
}}
|
||||
StartIcon={Lock}
|
||||
color="minimal">
|
||||
{t("lost_access")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const ExternalTotpFooter = (
|
||||
@@ -130,8 +157,9 @@ export default function Login({
|
||||
if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
|
||||
// we're logged in! let's do a hard refresh to the desired url
|
||||
else if (!res.error) router.push(callbackUrl);
|
||||
// reveal two factor input if required
|
||||
else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true);
|
||||
else if (res.error === ErrorCode.IncorrectBackupCode) setErrorMessage(t("incorrect_backup_code"));
|
||||
else if (res.error === ErrorCode.MissingBackupCodes) setErrorMessage(t("missing_backup_codes"));
|
||||
// fallback if error not found
|
||||
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
|
||||
};
|
||||
@@ -160,6 +188,9 @@ export default function Login({
|
||||
? LoginFooter
|
||||
: null
|
||||
}>
|
||||
{isTeamInvite && (
|
||||
<Alert severity="info" message={t("signin_or_signup_to_accept_invite")} className="mb-4 mt-4" />
|
||||
)}
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)} noValidate data-testid="login-form">
|
||||
<div>
|
||||
@@ -194,7 +225,7 @@ export default function Login({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{twoFactorRequired && <TwoFactor center />}
|
||||
{twoFactorRequired ? !twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center /> : null}
|
||||
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
<Button
|
||||
|
||||
@@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
|
||||
import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -17,11 +18,6 @@ import {
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Form,
|
||||
Label,
|
||||
showToast,
|
||||
@@ -32,7 +28,7 @@ import {
|
||||
Tooltip,
|
||||
VerticalDivider,
|
||||
} from "@calcom/ui";
|
||||
import { Info, MoreHorizontal, Plus, Trash } from "@calcom/ui/components/icon";
|
||||
import { Info, MoreVertical, ArrowLeft, Plus, Trash } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||
@@ -95,7 +91,7 @@ export default function Availability() {
|
||||
const scheduleId = searchParams?.get("schedule") ? Number(searchParams.get("schedule")) : -1;
|
||||
const fromEventType = searchParams?.get("fromEventType");
|
||||
const { timeFormat } = me.data || { timeFormat: null };
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [openSidebar, setOpenSidebar] = useState(false);
|
||||
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId },
|
||||
{
|
||||
@@ -225,33 +221,60 @@ export default function Availability() {
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
<VerticalDivider className="hidden sm:inline" />
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="sm:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent style={{ minWidth: "200px" }}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
StartIcon={Trash}
|
||||
onClick={() => setDeleteDialogOpen(true)}>
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<ConfirmationDialogContent
|
||||
isLoading={deleteMutation.isLoading}
|
||||
variety="danger"
|
||||
title={t("delete_schedule")}
|
||||
confirmBtnText={t("delete")}
|
||||
loadingText={t("delete")}
|
||||
onConfirm={() => {
|
||||
schedule !== undefined && deleteMutation.mutate({ scheduleId: schedule.id });
|
||||
}}>
|
||||
{t("delete_schedule_description")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex h-9 flex-row items-center justify-between px-4 py-2 hover:bg-gray-100">
|
||||
<div
|
||||
className={classNames(
|
||||
openSidebar
|
||||
? "fadeIn fixed inset-0 z-50 bg-neutral-800 bg-opacity-70 transition-opacity dark:bg-opacity-70 sm:hidden"
|
||||
: ""
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default fixed right-0 z-20 flex h-screen w-80 flex-col space-y-2 overflow-x-hidden rounded-md px-2 pb-3 transition-transform",
|
||||
openSidebar ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
|
||||
)}>
|
||||
<div className="flex flex-row items-center pt-5">
|
||||
<Button StartIcon={ArrowLeft} color="minimal" onClick={() => setOpenSidebar(false)} />
|
||||
<p className="-ml-2">{t("availability_settings")}</p>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
StartIcon={Trash}
|
||||
variant="icon"
|
||||
color="destructive"
|
||||
aria-label={t("delete")}
|
||||
className="ml-16 inline"
|
||||
disabled={schedule?.isLastSchedule}
|
||||
tooltip={schedule?.isLastSchedule ? t("requires_at_least_one_schedule") : t("delete")}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
isLoading={deleteMutation.isLoading}
|
||||
variety="danger"
|
||||
title={t("delete_schedule")}
|
||||
confirmBtnText={t("delete")}
|
||||
loadingText={t("delete")}
|
||||
onConfirm={() => {
|
||||
scheduleId && deleteMutation.mutate({ scheduleId });
|
||||
setOpenSidebar(false);
|
||||
}}>
|
||||
{t("delete_schedule_description")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex flex-col px-2 py-2">
|
||||
<Skeleton as={Label}>{t("name")}</Skeleton>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<input
|
||||
className="hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis focus:ring-brand-default disabled:bg-subtle disabled:hover:border-subtle mb-2 block h-9 w-full rounded-md border px-3 py-2 text-sm leading-4 focus:border-neutral-300 focus:outline-none focus:ring-2 disabled:cursor-not-allowed"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-9 flex-row-reverse items-center justify-end gap-3 px-2">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
@@ -267,9 +290,44 @@ export default function Availability() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
|
||||
<div className="min-w-40 col-span-3 space-y-2 px-2 py-4 lg:col-span-1">
|
||||
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
|
||||
<div>
|
||||
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
|
||||
{t("timezone")}
|
||||
</Skeleton>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="timeZone"
|
||||
render={({ field: { onChange, value } }) =>
|
||||
value ? (
|
||||
<TimezoneSelect
|
||||
value={value}
|
||||
className="focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm"
|
||||
onChange={(timezone) => onChange(timezone.value)}
|
||||
/>
|
||||
) : (
|
||||
<SelectSkeletonLoader className="mt-1 w-72" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<hr className="border-subtle my-7" />
|
||||
<div className="rounded-md md:block">
|
||||
<Skeleton as="h3" className="mb-0 inline-block text-sm font-medium">
|
||||
{t("something_doesnt_look_right")}
|
||||
</Skeleton>
|
||||
<div className="mt-3 flex">
|
||||
<Skeleton as={Button} href="/availability/troubleshoot" color="secondary">
|
||||
{t("launch_troubleshooter")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-default border-l-2" />
|
||||
<Button
|
||||
className="ml-4 lg:ml-0"
|
||||
@@ -278,6 +336,13 @@ export default function Availability() {
|
||||
loading={updateMutation.isLoading}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-3 sm:hidden"
|
||||
StartIcon={MoreVertical}
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
onClick={() => setOpenSidebar(true)}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<div className="mt-4 w-full md:mt-0">
|
||||
@@ -313,7 +378,7 @@ export default function Availability() {
|
||||
{schedule?.workingHours && <DateOverride workingHours={schedule.workingHours} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
|
||||
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1">
|
||||
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
|
||||
<div>
|
||||
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
|
||||
@@ -335,7 +400,7 @@ export default function Availability() {
|
||||
/>
|
||||
</div>
|
||||
<hr className="border-subtle my-6 mr-8" />
|
||||
<div className="hidden rounded-md md:block">
|
||||
<div className="rounded-md">
|
||||
<Skeleton as="h3" className="mb-0 inline-block text-sm font-medium">
|
||||
{t("something_doesnt_look_right")}
|
||||
</Skeleton>
|
||||
|
||||
@@ -917,6 +917,7 @@ const getEventTypesFromDB = async (id: number) => {
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
seatsShowAttendees: true,
|
||||
seatsShowAvailabilityCount: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
},
|
||||
@@ -940,6 +941,7 @@ const handleSeatsEventTypeOnBooking = async (
|
||||
eventType: {
|
||||
seatsPerTimeSlot?: number | null;
|
||||
seatsShowAttendees: boolean | null;
|
||||
seatsShowAvailabilityCount: boolean | null;
|
||||
[x: string | number | symbol]: unknown;
|
||||
},
|
||||
bookingInfo: Partial<
|
||||
|
||||
@@ -113,6 +113,7 @@ export type FormValues = {
|
||||
periodDates: { startDate: Date; endDate: Date };
|
||||
seatsPerTimeSlot: number | null;
|
||||
seatsShowAttendees: boolean | null;
|
||||
seatsShowAvailabilityCount: boolean | null;
|
||||
seatsPerTimeSlotEnabled: boolean;
|
||||
minimumBookingNotice: number;
|
||||
minimumBookingNoticeInDurationType: number;
|
||||
@@ -183,12 +184,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
created: true,
|
||||
}))
|
||||
);
|
||||
showToast(
|
||||
t("event_type_updated_successfully", {
|
||||
eventTypeTitle: eventType.title,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
showToast(t("event_type_updated_successfully"), "success");
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
@@ -365,6 +361,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
afterBufferTime,
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees,
|
||||
seatsShowAvailabilityCount,
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
recurringEvent,
|
||||
@@ -381,9 +378,12 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
bookerLayouts,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
multipleDurationEnabled,
|
||||
length,
|
||||
...input
|
||||
} = values;
|
||||
|
||||
if (!Number(length)) throw new Error(t("event_setup_length_error"));
|
||||
|
||||
if (bookingLimits) {
|
||||
const isValid = validateIntervalLimitOrder(bookingLimits);
|
||||
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
|
||||
@@ -401,7 +401,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
if (metadata?.multipleDuration.length < 1) {
|
||||
throw new Error(t("event_setup_multiple_duration_error"));
|
||||
} else {
|
||||
if (!input.length && !metadata?.multipleDuration?.includes(input.length)) {
|
||||
if (!length && !metadata?.multipleDuration?.includes(length)) {
|
||||
throw new Error(t("event_setup_multiple_duration_default_error"));
|
||||
}
|
||||
}
|
||||
@@ -415,6 +415,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
const { availability, ...rest } = input;
|
||||
updateMutation.mutate({
|
||||
...rest,
|
||||
length,
|
||||
locations,
|
||||
recurringEvent,
|
||||
periodStartDate: periodDates.startDate,
|
||||
@@ -427,6 +428,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
durationLimits,
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees,
|
||||
seatsShowAvailabilityCount,
|
||||
metadata,
|
||||
customInputs,
|
||||
children,
|
||||
@@ -448,7 +450,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
isUpdateMutationLoading={updateMutation.isLoading}
|
||||
formMethods={formMethods}
|
||||
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||
currentUserMembership={currentUserMembership}>
|
||||
currentUserMembership={currentUserMembership}
|
||||
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
|
||||
<Form
|
||||
form={formMethods}
|
||||
id="event-type-form"
|
||||
@@ -460,6 +463,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
afterBufferTime,
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees,
|
||||
seatsShowAvailabilityCount,
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
recurringEvent,
|
||||
@@ -471,9 +475,12 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
seatsPerTimeSlotEnabled,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
multipleDurationEnabled,
|
||||
length,
|
||||
...input
|
||||
} = values;
|
||||
|
||||
if (!Number(length)) throw new Error(t("event_setup_length_error"));
|
||||
|
||||
if (bookingLimits) {
|
||||
const isValid = validateIntervalLimitOrder(bookingLimits);
|
||||
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
|
||||
@@ -491,7 +498,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
if (metadata?.multipleDuration.length < 1) {
|
||||
throw new Error(t("event_setup_multiple_duration_error"));
|
||||
} else {
|
||||
if (!input.length && !metadata?.multipleDuration?.includes(input.length)) {
|
||||
if (!length && !metadata?.multipleDuration?.includes(length)) {
|
||||
throw new Error(t("event_setup_multiple_duration_default_error"));
|
||||
}
|
||||
}
|
||||
@@ -500,6 +507,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
const { availability, ...rest } = input;
|
||||
updateMutation.mutate({
|
||||
...rest,
|
||||
length,
|
||||
locations,
|
||||
recurringEvent,
|
||||
periodStartDate: periodDates.startDate,
|
||||
@@ -512,6 +520,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
durationLimits,
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees,
|
||||
seatsShowAvailabilityCount,
|
||||
metadata,
|
||||
customInputs,
|
||||
});
|
||||
|
||||
@@ -52,10 +52,9 @@ import {
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
ArrowButton,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Clipboard,
|
||||
Code,
|
||||
Copy,
|
||||
@@ -109,8 +108,10 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
||||
const orgBranding = useOrgBranding();
|
||||
const tabs = eventTypeGroups.map((item) => ({
|
||||
name: item.profile.name ?? "",
|
||||
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types",
|
||||
avatar: item.profile.image ?? `${orgBranding?.fullDomain ?? WEBAPP_URL}/${item.profile.slug}/avatar.png`,
|
||||
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types?noTeam",
|
||||
avatar: orgBranding
|
||||
? `${orgBranding.fullDomain}${item.teamId ? "/team" : ""}/${item.profile.slug}/avatar.png`
|
||||
: item.profile.image ?? `${WEBAPP_URL + (item.teamId && "/team")}/${item.profile.slug}/avatar.png`,
|
||||
}));
|
||||
const { data } = useTypedQuery(querySchema);
|
||||
const events = eventTypeGroups.filter((item) => item.teamId === data.teamId);
|
||||
@@ -118,13 +119,15 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
||||
return (
|
||||
<div>
|
||||
<HorizontalTabs tabs={tabs} />
|
||||
{events.length && (
|
||||
{events.length > 0 ? (
|
||||
<EventTypeList
|
||||
types={events[0].eventTypes}
|
||||
group={events[0]}
|
||||
groupIndex={0}
|
||||
readOnly={events[0].metadata.readOnly}
|
||||
/>
|
||||
) : (
|
||||
<CreateFirstEventTypeView />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -359,6 +362,10 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!types.length) {
|
||||
return group.teamId ? <EmptyEventTypeList group={group} /> : <CreateFirstEventTypeView />;
|
||||
}
|
||||
|
||||
const firstItem = types[0];
|
||||
const lastItem = types[types.length - 1];
|
||||
const isManagedEventPrefix = () => {
|
||||
@@ -378,19 +385,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<div className="hover:bg-muted flex w-full items-center justify-between">
|
||||
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
|
||||
{!(firstItem && firstItem.id === type.id) && (
|
||||
<button
|
||||
className="bg-default text-muted hover:text-emphasis border-default hover:border-emphasis invisible absolute left-[5px] -ml-4 -mt-4 mb-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
|
||||
onClick={() => moveEventType(index, -1)}>
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
</button>
|
||||
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
|
||||
)}
|
||||
|
||||
{!(lastItem && lastItem.id === type.id) && (
|
||||
<button
|
||||
className="bg-default text-muted border-default hover:text-emphasis hover:border-emphasis invisible absolute left-[5px] -ml-4 mt-8 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
|
||||
onClick={() => moveEventType(index, 1)}>
|
||||
<ArrowDown className="h-5 w-5" />
|
||||
</button>
|
||||
<ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" />
|
||||
)}
|
||||
<MemoizedItem type={type} group={group} readOnly={readOnly} />
|
||||
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
||||
@@ -400,23 +399,27 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
className="relative right-3 top-1"
|
||||
size="sm"
|
||||
truncateAfter={4}
|
||||
items={type.users.map(
|
||||
(organizer: { name: string | null; username: string | null }) => ({
|
||||
alt: organizer.name || "",
|
||||
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
|
||||
organizer.username
|
||||
}/avatar.png`,
|
||||
title: organizer.name || "",
|
||||
})
|
||||
)}
|
||||
items={
|
||||
type?.users
|
||||
? type.users.map(
|
||||
(organizer: { name: string | null; username: string | null }) => ({
|
||||
alt: organizer.name || "",
|
||||
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
|
||||
organizer.username
|
||||
}/avatar.png`,
|
||||
title: organizer.name || "",
|
||||
})
|
||||
)
|
||||
: []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isManagedEventType && (
|
||||
{isManagedEventType && type?.children && type.children?.length > 0 && (
|
||||
<AvatarGroup
|
||||
className="relative right-3 top-1"
|
||||
size="sm"
|
||||
truncateAfter={4}
|
||||
items={type.children
|
||||
items={type?.children
|
||||
.flatMap((ch) => ch.users)
|
||||
.map((user: Pick<User, "name" | "username">) => ({
|
||||
alt: user.name || "",
|
||||
@@ -671,6 +674,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
title={t(`delete${isManagedEventPrefix()}_event_type`)}
|
||||
confirmBtnText={t(`confirm_delete_event_type`)}
|
||||
loadingText={t(`confirm_delete_event_type`)}
|
||||
isLoading={deleteMutation.isLoading}
|
||||
onConfirm={(e) => {
|
||||
e.preventDefault();
|
||||
deleteEventTypeHandler(deleteDialogTypeId);
|
||||
@@ -711,12 +715,17 @@ const EventTypeListHeading = ({
|
||||
},
|
||||
});
|
||||
const bookerUrl = useBookerUrl();
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center space-x-2">
|
||||
<Avatar
|
||||
alt={profile?.name || ""}
|
||||
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
|
||||
imageSrc={`${orgBranding?.fullDomain ?? WEBAPP_URL}/${profile.slug}/avatar.png` || undefined}
|
||||
imageSrc={
|
||||
orgBranding?.fullDomain
|
||||
? `${orgBranding.fullDomain}${teamId ? "/team" : ""}/${profile.slug}/avatar.png`
|
||||
: profile.image
|
||||
}
|
||||
size="md"
|
||||
className="mt-1 inline-flex justify-center"
|
||||
/>
|
||||
@@ -763,6 +772,12 @@ const CreateFirstEventTypeView = () => {
|
||||
Icon={LinkIcon}
|
||||
headline={t("new_event_type_heading")}
|
||||
description={t("new_event_type_description")}
|
||||
className="mb-16"
|
||||
buttonRaw={
|
||||
<Button href="?dialog=new" variant="button">
|
||||
{t("create")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -830,6 +845,25 @@ const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<EmptyScreen
|
||||
headline={t("team_no_event_types")}
|
||||
buttonRaw={
|
||||
<Button
|
||||
href={`?dialog=new&eventPage=${group.profile.slug}&teamId=${group.teamId}`}
|
||||
variant="button"
|
||||
className="mt-5">
|
||||
{t("create")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Main = ({
|
||||
status,
|
||||
errorMessage,
|
||||
@@ -863,7 +897,7 @@ const Main = ({
|
||||
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
|
||||
) : (
|
||||
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
|
||||
<div className="flex flex-col" key={group.profile.slug}>
|
||||
<div className="mt-4 flex flex-col" key={group.profile.slug}>
|
||||
<EventTypeListHeading
|
||||
profile={group.profile}
|
||||
membershipCount={group.metadata.membershipCount}
|
||||
@@ -871,12 +905,18 @@ const Main = ({
|
||||
orgSlug={orgBranding?.slug}
|
||||
/>
|
||||
|
||||
<EventTypeList
|
||||
types={group.eventTypes}
|
||||
group={group}
|
||||
groupIndex={index}
|
||||
readOnly={group.metadata.readOnly}
|
||||
/>
|
||||
{group.eventTypes.length ? (
|
||||
<EventTypeList
|
||||
types={group.eventTypes}
|
||||
group={group}
|
||||
groupIndex={index}
|
||||
readOnly={group.metadata.readOnly}
|
||||
/>
|
||||
) : group.teamId ? (
|
||||
<EmptyEventTypeList group={group} />
|
||||
) : (
|
||||
<CreateFirstEventTypeView />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -182,6 +182,7 @@ const CalendarsView = () => {
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
credentialId={cal.credentialId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
name={cal.name || "Nameless calendar"}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -14,10 +15,10 @@ import turndown from "@calcom/lib/turndownService";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -223,6 +224,7 @@ const ProfileView = () => {
|
||||
key={JSON.stringify(defaultValues)}
|
||||
defaultValues={defaultValues}
|
||||
isLoading={updateProfileMutation.isLoading}
|
||||
userOrganization={user.organization}
|
||||
onSubmit={(values) => {
|
||||
if (values.email !== user.email && isCALIdentityProvider) {
|
||||
setTempFormValues(values);
|
||||
@@ -364,11 +366,13 @@ const ProfileForm = ({
|
||||
onSubmit,
|
||||
extraField,
|
||||
isLoading = false,
|
||||
userOrganization,
|
||||
}: {
|
||||
defaultValues: FormValues;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
extraField?: React.ReactNode;
|
||||
isLoading: boolean;
|
||||
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
@@ -406,7 +410,12 @@ const ProfileForm = ({
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Avatar alt="" imageSrc={value} gravatarFallbackMd5="fallback" size="lg" />
|
||||
<OrganizationAvatar
|
||||
alt={formMethods.getValues("username")}
|
||||
imageSrc={value}
|
||||
size="lg"
|
||||
organizationSlug={userOrganization.slug}
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
|
||||
+26
-17
@@ -13,10 +13,12 @@ import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
|
||||
|
||||
@@ -25,14 +27,7 @@ import PageWrapper from "@components/PageWrapper";
|
||||
import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants";
|
||||
import { ssrInit } from "../server/lib/ssr";
|
||||
|
||||
const signupSchema = z.object({
|
||||
username: z.string().refine((value) => !value.includes("+"), {
|
||||
message: "String should not contain a plus symbol (+).",
|
||||
}),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(7),
|
||||
language: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
const signupSchema = apiSignupSchema.extend({
|
||||
apiError: z.string().optional(), // Needed to display API errors doesnt get passed to the API
|
||||
});
|
||||
|
||||
@@ -40,12 +35,30 @@ type FormValues = z.infer<typeof signupSchema>;
|
||||
|
||||
type SignupProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
const getSafeCallbackUrl = (url: string | null) => {
|
||||
if (!url) return null;
|
||||
|
||||
let callbackUrl = url;
|
||||
|
||||
if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1);
|
||||
|
||||
// If not absolute URL, make it absolute
|
||||
if (!/^https?:\/\//.test(callbackUrl)) {
|
||||
callbackUrl = `${WEBAPP_URL}/${callbackUrl}`;
|
||||
}
|
||||
|
||||
const safeCallbackUrl = getSafeRedirectUrl(callbackUrl);
|
||||
|
||||
return safeCallbackUrl;
|
||||
};
|
||||
|
||||
export default function Signup({ prepopulateFormValues, token, orgSlug }: SignupProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const telemetry = useTelemetry();
|
||||
const { t, i18n } = useLocale();
|
||||
const flags = useFlagMap();
|
||||
const methods = useForm<FormValues>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(signupSchema),
|
||||
defaultValues: prepopulateFormValues,
|
||||
});
|
||||
@@ -60,6 +73,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
throw new Error(err.message);
|
||||
}
|
||||
};
|
||||
const callbackUrl = getSafeCallbackUrl(searchParams.get("callbackUrl"));
|
||||
|
||||
const signUp: SubmitHandler<FormValues> = async (data) => {
|
||||
await fetch("/api/auth/signup", {
|
||||
@@ -77,13 +91,10 @@ 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";
|
||||
|
||||
await signIn<"credentials">("credentials", {
|
||||
...data,
|
||||
callbackUrl: `${
|
||||
searchParams?.get("callbackUrl")
|
||||
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
|
||||
: `${WEBAPP_URL}/${verifyOrGettingStarted}`
|
||||
}?from=signup`,
|
||||
callbackUrl: `${callbackUrl ? callbackUrl : `${WEBAPP_URL}/${verifyOrGettingStarted}`}?from=signup`,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -131,7 +142,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
<TextField
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? getOrgFullDomain(orgSlug, { protocol: true })
|
||||
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
|
||||
}
|
||||
{...register("username")}
|
||||
@@ -162,9 +173,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||
className="w-full justify-center"
|
||||
onClick={() =>
|
||||
signIn("Cal.com", {
|
||||
callbackUrl: searchParams?.get("callbackUrl")
|
||||
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
|
||||
: `${WEBAPP_URL}/getting-started`,
|
||||
callbackUrl: callbackUrl ? callbackUrl : `${WEBAPP_URL}/getting-started`,
|
||||
})
|
||||
}>
|
||||
{t("login_instead")}
|
||||
|
||||
@@ -153,11 +153,9 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
) : (
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
||||
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
|
||||
{" " + t("org_no_teams_yet")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-md">{t("org_no_teams_yet_description")}</p>
|
||||
<div className="text-muted p-8 text-center">
|
||||
<h2 className="font-cal text-emphasis mb-2 text-3xl">{" " + t("org_no_teams_yet")}</h2>
|
||||
<p className="text-emphasis mx-auto max-w-md">{t("org_no_teams_yet_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,7 +210,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
|
||||
<EventTypes />
|
||||
|
||||
{/* Hide "Book a team member button when team is private or hideBookATeamMember is true" */}
|
||||
{(!team.hideBookATeamMember || team.isPrivate) && (
|
||||
{!team.hideBookATeamMember && !team.isPrivate && (
|
||||
<div>
|
||||
<div className="relative mt-12">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui";
|
||||
@@ -41,6 +43,21 @@ 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 = context.query?.token;
|
||||
const resolvedUrl = context.resolvedUrl;
|
||||
|
||||
const callbackUrl = token ? getSafeRedirectUrl(`${WEBAPP_URL}${resolvedUrl}`) : null;
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}&teamInvite=true` : "/auth/login",
|
||||
permanent: false,
|
||||
},
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
|
||||
return { props: { trpcState: ssr.dehydrate() } };
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
|
||||
let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
|
||||
process.env.NEXT_PUBLIC_WEBAPP_URL || "https://" + process.env.VERCEL_URL
|
||||
));
|
||||
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\..*`;
|
||||
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
|
||||
|
||||
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes);
|
||||
exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`;
|
||||
|
||||
@@ -158,9 +158,10 @@ test.describe("pro user", () => {
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
additionalGuests.forEach(async (email) => {
|
||||
const promises = additionalGuests.map(async (email) => {
|
||||
await expect(page.locator(`[data-testid="attendee-email-${email}"]`)).toHaveText(email);
|
||||
});
|
||||
await Promise.all(promises);
|
||||
});
|
||||
|
||||
test("Time slots should be reserved when selected", async ({ context, page }) => {
|
||||
@@ -267,3 +268,60 @@ test.describe("prefill", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking on different layouts", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await page.goto(`/${user.username}`);
|
||||
});
|
||||
|
||||
test("Book on week layout", async ({ page }) => {
|
||||
// Click first event type
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
||||
await page.click('[data-testid="toggle-group-item-week_view"]');
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
await page.locator('[data-testid="calendar-empty-cell"]').nth(0).click();
|
||||
|
||||
// Fill what is this meeting about? name email and notes
|
||||
await page.locator('[name="name"]').fill("Test name");
|
||||
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
|
||||
await page.locator('[name="notes"]').fill("Test notes");
|
||||
|
||||
await page.click('[data-testid="confirm-book-button"]');
|
||||
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
});
|
||||
|
||||
// expect page to be booking page
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Book on column layout", async ({ page }) => {
|
||||
// Click first event type
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
||||
await page.click('[data-testid="toggle-group-item-column_view"]');
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
await page.locator('[data-testid="time"]').nth(0).click();
|
||||
|
||||
// Fill what is this meeting about? name email and notes
|
||||
await page.locator('[name="name"]').fill("Test name");
|
||||
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
|
||||
await page.locator('[name="notes"]').fill("Test notes");
|
||||
|
||||
await page.click('[data-testid="confirm-book-button"]');
|
||||
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
});
|
||||
|
||||
// expect page to be booking page
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +1,21 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { uuid } from "short-uuid";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
createNewSeatedEventType,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
createUserWithSeatedEventAndAttendees,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
async function createUserWithSeatedEvent(users: Fixtures["users"]) {
|
||||
const slug = "seats";
|
||||
const user = await users.create({
|
||||
eventTypes: [
|
||||
{
|
||||
title: "Seated event",
|
||||
slug,
|
||||
seatsPerTimeSlot: 10,
|
||||
requiresConfirmation: true,
|
||||
length: 30,
|
||||
disableGuests: true, // should always be true for seated events
|
||||
},
|
||||
],
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
|
||||
return { user, eventType };
|
||||
}
|
||||
|
||||
async function createUserWithSeatedEventAndAttendees(
|
||||
fixtures: Pick<Fixtures, "users" | "bookings">,
|
||||
attendees: Prisma.AttendeeCreateManyBookingInput[]
|
||||
) {
|
||||
const { user, eventType } = await createUserWithSeatedEvent(fixtures.users);
|
||||
const booking = await fixtures.bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
// startTime with 1 day from now and endTime half hour after
|
||||
startTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
endTime: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
|
||||
attendees: {
|
||||
createMany: {
|
||||
data: attendees,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { user, eventType, booking };
|
||||
}
|
||||
|
||||
test.describe("Booking with Seats", () => {
|
||||
test("User can create a seated event (2 seats as example)", async ({ users, page }) => {
|
||||
const user = await users.create({ name: "Seated event" });
|
||||
@@ -64,7 +25,7 @@ test.describe("Booking with Seats", () => {
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
const eventTitle = "My 2-seated event";
|
||||
await createNewSeatedEventType(page, { eventTitle });
|
||||
await expect(page.locator(`text=${eventTitle} event type updated successfully`)).toBeVisible();
|
||||
await expect(page.locator(`text=Event type updated successfully`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Multiple Attendees can book a seated event time slot", async ({ users, page }) => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
type Route = {
|
||||
id: string;
|
||||
action: {
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
isFallback: boolean;
|
||||
queryValue: {
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const createRoutingFormsFixture = () => {
|
||||
return {
|
||||
async create({
|
||||
userId,
|
||||
teamId,
|
||||
name,
|
||||
fields,
|
||||
routes = [],
|
||||
}: {
|
||||
name: string;
|
||||
userId: number;
|
||||
teamId: number | null;
|
||||
routes?: Route[];
|
||||
fields: {
|
||||
type: string;
|
||||
label: string;
|
||||
identifier?: string;
|
||||
required: boolean;
|
||||
}[];
|
||||
}) {
|
||||
return await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name,
|
||||
userId,
|
||||
teamId,
|
||||
routes: [
|
||||
...routes,
|
||||
// Add a fallback route always, this is taken care of tRPC route normally but do it manually while running the query directly.
|
||||
{
|
||||
id: "898899aa-4567-489a-bcde-f1823f708646",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
|
||||
},
|
||||
],
|
||||
fields: fields.map((f) => ({
|
||||
id: uuidv4(),
|
||||
...f,
|
||||
})),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||
import { createBookingsFixture } from "../fixtures/bookings";
|
||||
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
|
||||
import { createPaymentsFixture } from "../fixtures/payments";
|
||||
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
||||
import { createServersFixture } from "../fixtures/servers";
|
||||
import { createUsersFixture } from "../fixtures/users";
|
||||
|
||||
@@ -23,6 +24,7 @@ export interface Fixtures {
|
||||
servers: ReturnType<typeof createServersFixture>;
|
||||
prisma: typeof prisma;
|
||||
emails?: API;
|
||||
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -71,6 +73,9 @@ export const test = base.extend<Fixtures>({
|
||||
prisma: async ({}, use) => {
|
||||
await use(prisma);
|
||||
},
|
||||
routingForms: async ({}, use) => {
|
||||
await use(createRoutingFormsFixture());
|
||||
},
|
||||
emails: async ({}, use) => {
|
||||
if (IS_MAILHOG_ENABLED) {
|
||||
const mailhogAPI = mailhog();
|
||||
|
||||
@@ -6,6 +6,10 @@ import { createServer } from "http";
|
||||
import { noop } from "lodash";
|
||||
import type { API, Messages } from "mailhog";
|
||||
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import type { Fixtures } from "./fixtures";
|
||||
import { test } from "./fixtures";
|
||||
|
||||
export function todo(title: string) {
|
||||
@@ -192,6 +196,7 @@ export async function installAppleCalendar(page: Page) {
|
||||
await page.waitForURL("/apps/apple-calendar");
|
||||
await page.click('[data-testid="install-app-button"]');
|
||||
}
|
||||
|
||||
export async function getEmailsReceivedByUser({
|
||||
emails,
|
||||
userEmail,
|
||||
@@ -228,3 +233,44 @@ export async function expectEmailsToHaveSubject({
|
||||
expect(organizerFirstEmail.subject).toBe(emailSubject);
|
||||
expect(bookerFirstEmail.subject).toBe(emailSubject);
|
||||
}
|
||||
|
||||
// this method is not used anywhere else
|
||||
// but I'm keeping it here in case we need in the future
|
||||
async function createUserWithSeatedEvent(users: Fixtures["users"]) {
|
||||
const slug = "seats";
|
||||
const user = await users.create({
|
||||
eventTypes: [
|
||||
{
|
||||
title: "Seated event",
|
||||
slug,
|
||||
seatsPerTimeSlot: 10,
|
||||
requiresConfirmation: true,
|
||||
length: 30,
|
||||
disableGuests: true, // should always be true for seated events
|
||||
},
|
||||
],
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
|
||||
return { user, eventType };
|
||||
}
|
||||
|
||||
export async function createUserWithSeatedEventAndAttendees(
|
||||
fixtures: Pick<Fixtures, "users" | "bookings">,
|
||||
attendees: Prisma.AttendeeCreateManyBookingInput[]
|
||||
) {
|
||||
const { user, eventType } = await createUserWithSeatedEvent(fixtures.users);
|
||||
|
||||
const booking = await fixtures.bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
// startTime with 1 day from now and endTime half hour after
|
||||
startTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
endTime: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
|
||||
attendees: {
|
||||
createMany: {
|
||||
data: attendees,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { user, eventType, booking };
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
// TODO: add more backup code tests, e.g. login + disabling 2fa with backup
|
||||
|
||||
// a test to logout requires both a succesfull login as logout, to prevent
|
||||
// a doubling of tests failing on logout & logout, we can group them.
|
||||
test.describe("2FA Tests", async () => {
|
||||
@@ -45,6 +47,8 @@ test.describe("2FA Tests", async () => {
|
||||
secret: secret!,
|
||||
});
|
||||
|
||||
// FIXME: this passes even when switch is not checked, compare to test
|
||||
// below which checks for data-state="checked" and works as expected
|
||||
await page.waitForSelector(`[data-testid=two-factor-switch]`);
|
||||
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
|
||||
|
||||
@@ -103,6 +107,23 @@ test.describe("2FA Tests", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await fillOtp({ page, secret: secret! });
|
||||
|
||||
// backup codes are now showing, so run a few tests
|
||||
|
||||
// click download button
|
||||
const promise = page.waitForEvent("download");
|
||||
await page.getByTestId("backup-codes-download").click();
|
||||
const download = await promise;
|
||||
expect(download.suggestedFilename()).toBe("cal-backup-codes.txt");
|
||||
// TODO: check file content
|
||||
|
||||
// click copy button
|
||||
await page.getByTestId("backup-codes-copy").click();
|
||||
await page.getByTestId("toast-success").waitFor();
|
||||
// TODO: check clipboard content
|
||||
|
||||
// close backup code dialog
|
||||
await page.getByTestId("backup-codes-close").click();
|
||||
|
||||
await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible();
|
||||
|
||||
return user;
|
||||
|
||||
@@ -135,6 +135,24 @@ test.describe("Manage Booking Questions", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Verify that we can prefill name field with no lastname", async () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append("name", "FirstName");
|
||||
await doOnFreshPreviewWithSearchParams(searchParams, page, context, async (page) => {
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await expectSystemFieldsToBeThereOnBookingPage({
|
||||
page,
|
||||
isFirstAndLastNameVariant: true,
|
||||
values: {
|
||||
name: {
|
||||
firstName: "FirstName",
|
||||
lastName: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -506,6 +524,27 @@ async function doOnFreshPreview(
|
||||
return previewTabPage;
|
||||
}
|
||||
|
||||
async function doOnFreshPreviewWithSearchParams(
|
||||
searchParams: URLSearchParams,
|
||||
page: Page,
|
||||
context: PlaywrightTestArgs["context"],
|
||||
callback: (page: Page) => Promise<void>,
|
||||
persistTab = false
|
||||
) {
|
||||
const previewUrl = (await page.locator('[data-testid="preview-button"]').getAttribute("href")) || "";
|
||||
const previewUrlObj = new URL(previewUrl);
|
||||
searchParams.forEach((value, key) => {
|
||||
previewUrlObj.searchParams.append(key, value);
|
||||
});
|
||||
const previewTabPage = await context.newPage();
|
||||
await previewTabPage.goto(previewUrlObj.toString());
|
||||
await callback(previewTabPage);
|
||||
if (!persistTab) {
|
||||
await previewTabPage.close();
|
||||
}
|
||||
return previewTabPage;
|
||||
}
|
||||
|
||||
async function toggleQuestionAndSave({ name, page }: { name: string; page: Page }) {
|
||||
await page.locator(`[data-testid="field-${name}"]`).locator('[data-testid="toggle-field"]').click();
|
||||
await saveEventType(page);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/client";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
@@ -7,10 +12,37 @@ import {
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
waitFor,
|
||||
gotoRoutingLink,
|
||||
createUserWithSeatedEventAndAttendees,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
// remove dynamic properties that differs depending on where you run the tests
|
||||
const dynamic = "[redacted/dynamic]";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
async function createWebhookReceiver(page: Page) {
|
||||
const webhookReceiver = createHttpServer();
|
||||
|
||||
await page.goto(`/settings/developer/webhooks`);
|
||||
|
||||
// --- add webhook
|
||||
await page.click('[data-testid="new_webhook"]');
|
||||
|
||||
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
|
||||
|
||||
await page.fill('[name="secret"]', "secret");
|
||||
|
||||
await Promise.all([
|
||||
page.click("[type=submit]"),
|
||||
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
|
||||
]);
|
||||
|
||||
// page contains the url
|
||||
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
|
||||
|
||||
return webhookReceiver;
|
||||
}
|
||||
|
||||
test.describe("BOOKING_CREATED", async () => {
|
||||
test("add webhook & test that creating an event triggers a webhook call", async ({
|
||||
page,
|
||||
@@ -55,8 +87,6 @@ test.describe("BOOKING_CREATED", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const body: any = request.body;
|
||||
|
||||
// remove dynamic properties that differs depending on where you run the tests
|
||||
const dynamic = "[redacted/dynamic]";
|
||||
body.createdAt = dynamic;
|
||||
body.payload.startTime = dynamic;
|
||||
body.payload.endTime = dynamic;
|
||||
@@ -187,8 +217,6 @@ test.describe("BOOKING_REJECTED", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const body = request.body as any;
|
||||
|
||||
// remove dynamic properties that differs depending on where you run the tests
|
||||
const dynamic = "[redacted/dynamic]";
|
||||
body.createdAt = dynamic;
|
||||
body.payload.startTime = dynamic;
|
||||
body.payload.endTime = dynamic;
|
||||
@@ -246,7 +274,7 @@ test.describe("BOOKING_REJECTED", async () => {
|
||||
},
|
||||
],
|
||||
location: "[redacted/dynamic]",
|
||||
destinationCalendar: null,
|
||||
destinationCalendar: [],
|
||||
// hideCalendarNotes: false,
|
||||
requiresConfirmation: "[redacted/dynamic]",
|
||||
eventTypeId: "[redacted/dynamic]",
|
||||
@@ -311,8 +339,6 @@ test.describe("BOOKING_REQUESTED", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const body = request.body as any;
|
||||
|
||||
// remove dynamic properties that differs depending on where you run the tests
|
||||
const dynamic = "[redacted/dynamic]";
|
||||
body.createdAt = dynamic;
|
||||
body.payload.startTime = dynamic;
|
||||
body.payload.endTime = dynamic;
|
||||
@@ -390,55 +416,278 @@ test.describe("BOOKING_REQUESTED", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("FORM_SUBMITTED", async () => {
|
||||
test("can submit a form and get a submission event", async ({ page, users }) => {
|
||||
const webhookReceiver = createHttpServer();
|
||||
test.describe("BOOKING_RESCHEDULED", async () => {
|
||||
test("can reschedule a booking and get a booking rescheduled event", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
const [eventType] = user.eventTypes;
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/settings/teams/new");
|
||||
await page.waitForLoadState("networkidle");
|
||||
const teamName = `${user.username}'s Team`;
|
||||
// Create a new team
|
||||
await page.locator('input[name="name"]').fill(teamName);
|
||||
await page.locator('input[name="slug"]').fill(teamName);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
const webhookReceiver = await createWebhookReceiver(page);
|
||||
|
||||
await page.locator("text=Publish team").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
});
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto(`/${user.username}/${eventType.slug}?rescheduleUid=${booking.uid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking?.uid } })!;
|
||||
expect(newBooking).not.toBeNull();
|
||||
|
||||
// --- check that webhook was called
|
||||
await waitFor(() => {
|
||||
expect(webhookReceiver.requestList.length).toBe(1);
|
||||
});
|
||||
|
||||
const [request] = webhookReceiver.requestList;
|
||||
|
||||
expect(request.body).toMatchObject({
|
||||
triggerEvent: "BOOKING_RESCHEDULED",
|
||||
payload: {
|
||||
uid: newBooking?.uid,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("when rescheduling to a booking that already exists, should send a booking rescheduled event with the existant booking uid", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const { user, eventType, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
|
||||
await prisma.eventType.update({
|
||||
where: { id: eventType.id },
|
||||
data: { requiresConfirmation: false },
|
||||
});
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
const webhookReceiver = await createWebhookReceiver(page);
|
||||
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = bookingAttendees.map((attendee) => ({
|
||||
bookingId: booking.id,
|
||||
attendeeId: attendee.id,
|
||||
referenceUid: uuidv4(),
|
||||
}));
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
include: { attendee: true },
|
||||
});
|
||||
|
||||
await page.goto(`/reschedule/${references[0].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
const newBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
attendees: {
|
||||
some: {
|
||||
email: bookingAttendees[0].email,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// --- ensuring that new booking was created
|
||||
expect(newBooking).not.toBeNull();
|
||||
|
||||
// --- check that webhook was called
|
||||
await waitFor(() => {
|
||||
expect(webhookReceiver.requestList.length).toBe(1);
|
||||
});
|
||||
|
||||
const [firstRequest] = webhookReceiver.requestList;
|
||||
|
||||
expect(firstRequest?.body).toMatchObject({
|
||||
triggerEvent: "BOOKING_RESCHEDULED",
|
||||
payload: {
|
||||
uid: newBooking?.uid,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/reschedule/${references[1].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(webhookReceiver.requestList.length).toBe(2);
|
||||
});
|
||||
|
||||
const [_, secondRequest] = webhookReceiver.requestList;
|
||||
|
||||
expect(secondRequest?.body).toMatchObject({
|
||||
triggerEvent: "BOOKING_RESCHEDULED",
|
||||
payload: {
|
||||
// in the current implementation, it is the same as the first booking
|
||||
uid: newBooking?.uid,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("FORM_SUBMITTED", async () => {
|
||||
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => {
|
||||
const webhookReceiver = createHttpServer();
|
||||
const user = await users.create(null, {
|
||||
hasTeam: true,
|
||||
});
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto(`/settings/developer/webhooks/new`);
|
||||
|
||||
// Add webhook
|
||||
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
|
||||
await page.fill('[name="secret"]', "secret");
|
||||
await Promise.all([page.click("[type=submit]"), page.goForward()]);
|
||||
await page.click("[type=submit]");
|
||||
|
||||
// Page contains the url
|
||||
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto("/routing-forms/forms");
|
||||
await page.click('[data-testid="new-routing-form"]');
|
||||
// Choose to create the Form for the user(which is the first option) and not the team
|
||||
await page.click('[data-testid="option-0"]');
|
||||
await page.fill("input[name]", "TEST FORM");
|
||||
await page.click('[data-testid="add-form"]');
|
||||
await page.waitForSelector('[data-testid="add-field"]');
|
||||
|
||||
const url = page.url();
|
||||
const formId = new URL(url).pathname.split("/").at(-1);
|
||||
const form = await routingForms.create({
|
||||
name: "Test Form",
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await gotoRoutingLink({ page, formId: formId });
|
||||
await gotoRoutingLink({ page, formId: form.id });
|
||||
const fieldName = "name";
|
||||
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
|
||||
page.click('button[type="submit"]');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(webhookReceiver.requestList.length).toBe(1);
|
||||
});
|
||||
const [request] = webhookReceiver.requestList;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const body = request.body as any;
|
||||
body.createdAt = dynamic;
|
||||
expect(body).toEqual({
|
||||
triggerEvent: "FORM_SUBMITTED",
|
||||
createdAt: dynamic,
|
||||
payload: {
|
||||
formId: form.id,
|
||||
formName: form.name,
|
||||
teamId: null,
|
||||
responses: {
|
||||
name: {
|
||||
value: "John Doe",
|
||||
},
|
||||
},
|
||||
},
|
||||
name: "John Doe",
|
||||
});
|
||||
|
||||
webhookReceiver.close();
|
||||
});
|
||||
|
||||
test("on submitting team form, triggers team webhook", async ({ page, users, routingForms }) => {
|
||||
const webhookReceiver = createHttpServer();
|
||||
const user = await users.create(null, {
|
||||
hasTeam: true,
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto(`/settings/developer/webhooks`);
|
||||
const teamId = await clickFirstTeamWebhookCta(page);
|
||||
|
||||
// Add webhook
|
||||
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
|
||||
await page.fill('[name="secret"]', "secret");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
const form = await routingForms.create({
|
||||
name: "Test Form",
|
||||
userId: user.id,
|
||||
teamId: teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await gotoRoutingLink({ page, formId: form.id });
|
||||
const fieldName = "name";
|
||||
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
|
||||
page.click('button[type="submit"]');
|
||||
await waitFor(() => {
|
||||
expect(webhookReceiver.requestList.length).toBe(1);
|
||||
});
|
||||
const [request] = webhookReceiver.requestList;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const body = request.body as any;
|
||||
body.createdAt = dynamic;
|
||||
expect(body).toEqual({
|
||||
triggerEvent: "FORM_SUBMITTED",
|
||||
createdAt: dynamic,
|
||||
payload: {
|
||||
formId: form.id,
|
||||
formName: form.name,
|
||||
teamId,
|
||||
responses: {
|
||||
name: {
|
||||
value: "John Doe",
|
||||
},
|
||||
},
|
||||
},
|
||||
name: "John Doe",
|
||||
});
|
||||
|
||||
webhookReceiver.close();
|
||||
});
|
||||
});
|
||||
|
||||
async function clickFirstTeamWebhookCta(page: Page) {
|
||||
await page.click('[data-testid="new_webhook"]');
|
||||
await page.click('[data-testid="option-team-1"]');
|
||||
await page.waitForURL((u) => u.pathname === "/settings/developer/webhooks/new");
|
||||
const url = page.url();
|
||||
const teamId = Number(new URL(url).searchParams.get("teamId")) as number;
|
||||
return teamId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><rect width="32" height="32" rx="16" fill="#E5E7EB"/><path d="M16 21.333A6.667 6.667 0 1 0 16 8a6.667 6.667 0 0 0 0 13.333Z" fill="#1F2937"/><path d="M26.667 32a10.667 10.667 0 1 0-21.334 0" fill="#1F2937"/><path d="M16 21.333A6.667 6.667 0 1 0 16 8a6.667 6.667 0 0 0 0 13.333Zm0 0A10.667 10.667 0 0 1 26.667 32M16 21.333A10.666 10.666 0 0 0 5.333 32" stroke="#1F2937" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g><defs><clipPath id="a"><rect width="32" height="32" rx="16" fill="#fff"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 635 B |
@@ -13,7 +13,7 @@
|
||||
"reset_password_subject": "{{appName}}: إرشادات إعادة تعيين كلمة المرور",
|
||||
"verify_email_subject": "{{appName}}: تأكيد حسابك",
|
||||
"check_your_email": "تحقق من بريدك الإلكتروني",
|
||||
"verify_email_page_body": "أرسلنا رسالة إلكترونية إلى {{email}}. من المهم تأكيد عنوان بريدك الإلكتروني لضمان أفضل تسليم للبريد الإلكتروني والتقويم من {{appName}}.",
|
||||
"verify_email_page_body": "أرسلنا رسالة إلكترونية إلى {{email}}. من المهم تأكيد عنوان بريدك الإلكتروني لضمان تسليم البريد الإلكتروني والتقويم من {{appName}}.",
|
||||
"verify_email_banner_body": "قم بتأكيد عنوان بريدك الإلكتروني لضمان أفضل تسليم للبريد الإلكتروني والتقويم",
|
||||
"verify_email_email_header": "تأكيد عنوان بريدك الإلكتروني",
|
||||
"verify_email_email_button": "تأكيد البريد الإلكتروني",
|
||||
@@ -130,7 +130,7 @@
|
||||
"upgrade_banner_action": "قم بالترقية هنا",
|
||||
"team_upgraded_successfully": "تمت ترقية فريقك بنجاح!",
|
||||
"org_upgrade_banner_description": "شكرًا لك على تجربة خطة Organization الجديدة التي نقدمها. لقد لاحظنا أن منظمتك \"{{teamName}}\" بحاجة إلى الترقية.",
|
||||
"org_upgraded_successfully": "تمت ترقية منظمتك بنجاح!",
|
||||
"org_upgraded_successfully": "تمت ترقية خطة Organization بنجاح!",
|
||||
"use_link_to_reset_password": "استخدم الرابط أدناه لإعادة تعيين كلمة المرور",
|
||||
"hey_there": "مرحبًا،",
|
||||
"forgot_your_password_calcom": "نسيت كلمة المرور الخاصة بك؟ - {{appName}}",
|
||||
@@ -550,7 +550,7 @@
|
||||
"team_description": "بضع جمل عن فريقك. ستظهر على صفحة رابط فريقك.",
|
||||
"org_description": "بضع جمل عن منظمتك. ستظهر على صفحة رابط منظمتك.",
|
||||
"members": "الأعضاء",
|
||||
"organization_members": "أعضاء المنظمة",
|
||||
"organization_members": "أعضاء Organization",
|
||||
"member": "العضو",
|
||||
"number_member_one": "{{count}} عضو",
|
||||
"number_member_other": "{{count}} من الأعضاء",
|
||||
@@ -1074,11 +1074,11 @@
|
||||
"event_cancelled": "تم إلغاء هذا الحدث",
|
||||
"emailed_information_about_cancelled_event": "لقد أرسلنا إليك وإلى الحاضرين الآخرين بريداً إلكترونياً لإعلامهم.",
|
||||
"this_input_will_shown_booking_this_event": "سيُعرَض هذا المُدخَل عند حجز هذا الحدث",
|
||||
"meeting_url_in_conformation_email": "عنوان رابط الاجتماع موجود في رسالة التأكيد الإلكترونية",
|
||||
"meeting_url_in_confirmation_email": "عنوان رابط الاجتماع موجود في رسالة التأكيد الإلكترونية",
|
||||
"url_start_with_https": "يجب أن يبدأ العنوان بـ http:// أو https://",
|
||||
"number_provided": "سيتم توفير رقم الهاتف",
|
||||
"before_event_trigger": "قبل بدء الحدث",
|
||||
"event_cancelled_trigger": "متى تم إلغاء هذا الحدث",
|
||||
"event_cancelled_trigger": "عند إلغاء هذا الحدث",
|
||||
"new_event_trigger": "متى تم حجز الحدث الجديد",
|
||||
"email_host_action": "إرسال رسالة إلكترونية إلى المضيف",
|
||||
"email_attendee_action": "إرسال رسالة إلكترونية إلى الحضور",
|
||||
@@ -1652,9 +1652,8 @@
|
||||
"delete_sso_configuration_confirmation_description": "هل تريد بالتأكيد حذف تكوين {{connectionType}}؟ لن يتمكن أعضاء فريقك الذين يستخدمون معلومات تسجيل الدخول إلى {{connectionType}} من الوصول إلى Cal.com بعد الآن.",
|
||||
"organizer_timezone": "منظم المناطق الزمنية",
|
||||
"email_user_cta": "عرض الدعوة",
|
||||
"email_no_user_invite_heading": "تمت دعوتك للانضمام إلى {{appName}} {{entity}}",
|
||||
"email_no_user_invite_subheading": "دعاك {{invitedBy}} للانضمام إلى فريقه على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت وفريقك من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.",
|
||||
"email_user_invite_subheading": "لقد دعاك {{invitedBy}} للانضمام إلى {{entity}} `{{teamName}}` على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت و{{entity}} من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.",
|
||||
"email_user_invite_subheading_team": "دعاك {{invitedBy}} للانضمام إلى فريقه {{teamName}} على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت وفريقك من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.",
|
||||
"email_no_user_invite_steps_intro": "سنرشدك خلال بضع خطوات قصيرة وستستمتع بجدول زمني خالٍ من التوتر مع {{entity}} في لمح البصر.",
|
||||
"email_no_user_step_one": "اختر اسم المستخدم الخاص بك",
|
||||
"email_no_user_step_two": "ربط حساب التقويم الخاص بك",
|
||||
@@ -1834,7 +1833,7 @@
|
||||
"invite_link_copied": "تم نسخ رابط الدعوة",
|
||||
"invite_link_deleted": "تم حذف رابط الدعوة",
|
||||
"invite_link_updated": "تم حفظ إعدادات رابط الدعوة",
|
||||
"link_expires_after": "تم تعيين الروابط للانتهاء بعد...",
|
||||
"link_expires_after": "تم تعيين انتهاء صلاحية الروابط بعد...",
|
||||
"one_day": "1 يوم",
|
||||
"seven_days": "7 أيام",
|
||||
"thirty_days": "30 يومًا",
|
||||
@@ -1871,7 +1870,6 @@
|
||||
"first_event_type_webhook_description": "قم بإنشاء أول شبكة ويب هوك لهذا النوع من الأحداث",
|
||||
"install_app_on": "تثبيت التطبيق على",
|
||||
"create_for": "إنشاء من أجل",
|
||||
"setup_organization": "إعداد منظمة",
|
||||
"organization_banner_description": "إنشاء بيئات حيث يمكن لفرقك إنشاء تطبيقات مشتركة ومهام سير العمل وأنواع الأحداث باستخدام الجدولة الدوارة والجماعية.",
|
||||
"organization_banner_title": "إدارة المنظمات ذات الفرق المتعددة",
|
||||
"set_up_your_organization": "إعداد منظمتك",
|
||||
@@ -1886,7 +1884,7 @@
|
||||
"organization_verify_email_body": "الرجاء استخدام الرمز أدناه لتأكيد عنوان بريدك الإلكتروني لمواصلة إعداد منظمتك.",
|
||||
"additional_url_parameters": "معلمات الرابط الإضافية",
|
||||
"about_your_organization": "حول منظمتك",
|
||||
"about_your_organization_description": "المنظمات هي بيئات مشتركة حيث يمكنك إنشاء فرق متعددة مع أعضاء مشتركين وأنواع الأحداث والتطبيقات ومهام سير العمل والمزيد.",
|
||||
"about_your_organization_description": "المنظمات هي بيئات مشتركة حيث يمكنك إنشاء فرق متعددة مع أعضاء مشتركين وأنواع الأحداث والتطبيقات ومهام سير العمل المشتركة والمزيد.",
|
||||
"create_your_teams": "إنشاء فرقك",
|
||||
"create_your_teams_description": "ابدأوا الجدولة معًا عن طريق إضافة أعضاء فريقك إلى منظمتك",
|
||||
"invite_organization_admins": "دعوة مشرفي منظمتك",
|
||||
@@ -1927,7 +1925,7 @@
|
||||
"404_the_org": "المنظمة",
|
||||
"404_the_team": "الفريق",
|
||||
"404_claim_entity_org": "المطالبة بنطاقك الفرعي لمنظمتك",
|
||||
"404_claim_entity_team": "المطالبة بهذا الفريق والبدء في إدارة الجداول الزمنية بشكل جماعي",
|
||||
"404_claim_entity_team": "انضم لهذا الفريق وابدأ في إدارة الجداول الزمنية بشكل جماعي",
|
||||
"insights_all_org_filter": "الكل",
|
||||
"insights_team_filter": "الفريق: {{teamName}}",
|
||||
"insights_user_filter": "المستخدم: {{userName}}",
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"team_info": "Informace o týmu",
|
||||
"request_another_invitation_email": "Pokud si nepřejete použít e-mail {{toEmail}} pro {{appName}} nebo už účet na {{appName}} máte, požádejte prosím o další pozvánku na jiný e-mail.",
|
||||
"you_have_been_invited": "Byli jste pozváni do týmu {{teamName}}",
|
||||
"user_invited_you": "Uživatel {{user}} vás pozval, abyste se připojili k subjektu {{entity}} {{team}} v aplikaci {{appName}}",
|
||||
"user_invited_you": "Uživatel {{user}} vás pozval, abyste se připojili k {{entity}} {{team}} v aplikaci {{appName}}",
|
||||
"hidden_team_member_title": "V tomto týmu nejste viditelní",
|
||||
"hidden_team_member_message": "Vaše místo není zaplacené. Buď přejděte na tarif Pro, nebo informujte vlastníka týmu, že za vaše místo může zaplatit.",
|
||||
"hidden_team_owner_message": "Pro využívání týmů potřebujete tarif Pro, budete skrytí, než přejdete na vyšší verzi.",
|
||||
@@ -130,7 +130,7 @@
|
||||
"upgrade_banner_action": "Upgradovat zde",
|
||||
"team_upgraded_successfully": "Váš tým byl úspěšně přešel na vyšší verzi!",
|
||||
"org_upgrade_banner_description": "Děkujeme, že jste vyzkoušeli náš tarif Organizace. Všimli jsme si, že vaše organizace „{{teamName}}“ vyžaduje upgrade.",
|
||||
"org_upgraded_successfully": "Vaše organizace byla upgradována!",
|
||||
"org_upgraded_successfully": "Váš upgrade na tarif Organization byl úspěšně dokončen!",
|
||||
"use_link_to_reset_password": "Pro obnovení hesla použijte odkaz níž",
|
||||
"hey_there": "Zdravíme,",
|
||||
"forgot_your_password_calcom": "Zapomněli jste heslo? - {{appName}}",
|
||||
@@ -307,7 +307,7 @@
|
||||
"layout": "Rozvržení",
|
||||
"bookerlayout_default_title": "Výchozí zobrazení",
|
||||
"bookerlayout_description": "Můžete si jich vybrat více a vaši rezervující si mohou zobrazení přepínat.",
|
||||
"bookerlayout_user_settings_title": "Rozvržení rezervace",
|
||||
"bookerlayout_user_settings_title": "Rozvržení rezervací",
|
||||
"bookerlayout_user_settings_description": "Můžete si jich vybrat více a vaši rezervující si mohou zobrazení přepínat. Lze nadefinovat pro každou událost zvlášť.",
|
||||
"bookerlayout_month_view": "Měsíční",
|
||||
"bookerlayout_week_view": "Týdenní",
|
||||
@@ -403,7 +403,7 @@
|
||||
"recording_ready": "Odkaz ke stažení záznamu je připraven",
|
||||
"booking_created": "Rezervace vytvořena",
|
||||
"booking_rejected": "Rezervace byla zamítnuta",
|
||||
"booking_requested": "Byla vyžádána rezervace",
|
||||
"booking_requested": "Váš požadavek na rezervaci byl úspěšně odeslán",
|
||||
"meeting_ended": "Schůzka skončila",
|
||||
"form_submitted": "Formulář byl odeslán",
|
||||
"event_triggers": "Eventy na základě akce",
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Tato událost je zrušena",
|
||||
"emailed_information_about_cancelled_event": "Poslali jsme vám a ostatním účastníkům informační e-mail.",
|
||||
"this_input_will_shown_booking_this_event": "Toto pole se zobrazí při rezervaci této události",
|
||||
"meeting_url_in_conformation_email": "Adresa URL schůzky je v potvrzovacím e-mailu",
|
||||
"meeting_url_in_confirmation_email": "Adresa URL schůzky je v potvrzovacím e-mailu",
|
||||
"url_start_with_https": "Adresa URL musí začínat http:// nebo https://",
|
||||
"number_provided": "Bude uvedeno telefonní číslo",
|
||||
"before_event_trigger": "před zahájením události",
|
||||
@@ -1659,9 +1659,8 @@
|
||||
"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": "Byli jste pozváni, abyste se připojili k subjektu {{entity}} v aplikaci {{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": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k týmu {{teamName}} subjektu {{entity}} v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu subjektu {{entity}} umožňuje plánovat schůzky bez e-mailového pingpongu.",
|
||||
"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_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",
|
||||
@@ -1878,7 +1877,6 @@
|
||||
"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",
|
||||
"setup_organization": "Nastavení organizace",
|
||||
"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",
|
||||
@@ -1889,12 +1887,12 @@
|
||||
"admin_username": "Uživatelské jméno správce",
|
||||
"organization_name": "Název organizace",
|
||||
"organization_url": "Adresa URL organizace",
|
||||
"organization_verify_header": "Ověřte e-mailovou adresu organizace",
|
||||
"organization_verify_header": "Ověřte svou e-mailovou adresu organizace",
|
||||
"organization_verify_email_body": "Pomocí uvedeného kódu ověřte vaši e-mailovou adresu, abyste mohli pokračovat v nastavení vaší organizace.",
|
||||
"additional_url_parameters": "Další parametry adresy URL",
|
||||
"about_your_organization": "Informace o vaší organizaci",
|
||||
"about_your_organization_description": "Organizace jsou sdílená prostředí, ve kterých můžete vytvořit několik týmů se sdílenými členy, typy událostí, aplikacemi, pracovními postupy a mnoha dalšími věcmi.",
|
||||
"create_your_teams": "Vytvoření týmů",
|
||||
"create_your_teams": "Vytvořit tým",
|
||||
"create_your_teams_description": "Přidejte do své organizace členy a pusťte se do společného plánování",
|
||||
"invite_organization_admins": "Pozvěte správce své organizace",
|
||||
"invite_organization_admins_description": "Tito správci budou mít přístup ke všem týmům ve vaší organizaci. Správce a členy týmů můžete přidat později.",
|
||||
@@ -1934,11 +1932,11 @@
|
||||
"404_the_org": "Organizace",
|
||||
"404_the_team": "Tým",
|
||||
"404_claim_entity_org": "Vyžádejte si subdoménu pro svou organizaci",
|
||||
"404_claim_entity_team": "Vyžádejte si tento tým a začněte kolektivně spravovat rozvrhy",
|
||||
"404_claim_entity_team": "Vyžádejte si přístup k tomuto týmu a začněte kolektivně spravovat rozvrhy",
|
||||
"insights_all_org_filter": "Všechny aplikace",
|
||||
"insights_team_filter": "Tým: {{teamName}}",
|
||||
"insights_user_filter": "Uživatel: {{userName}}",
|
||||
"insights_subtitle": "Zobrazte si přehled o rezervacích napříč vašimi událostmi",
|
||||
"insights_subtitle": "Zobrazte si Insight rezervací napříč vašimi událostmi",
|
||||
"custom_plan": "Vlastní plán",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -960,7 +960,7 @@
|
||||
"reschedule_placeholder": "Lad andre vide, hvorfor du har brug for at omlægge",
|
||||
"emailed_information_about_cancelled_event": "Vi har sendt en e-mail til dig og de andre deltagere for at give dem besked.",
|
||||
"this_input_will_shown_booking_this_event": "Dette input vil blive vist ved booking af denne begivenhed",
|
||||
"meeting_url_in_conformation_email": "Møde-url er i bekræftelses-mailen",
|
||||
"meeting_url_in_confirmation_email": "Møde-url er i bekræftelses-mailen",
|
||||
"url_start_with_https": "URL'en skal starte med http:// eller https://",
|
||||
"number_provided": "Telefonnummer vil blive angivet",
|
||||
"before_event_trigger": "før begivenheden starter",
|
||||
|
||||
@@ -225,6 +225,7 @@
|
||||
"create_account": "Konto erstellen",
|
||||
"confirm_password": "Passwort bestätigen",
|
||||
"confirm_auth_change": "Dies ändert die Art und Weise, wie Sie sich anmelden",
|
||||
"confirm_auth_email_change": "Wenn Sie Ihre E-Mail-Adresse ändern, wird Ihre derzeitige Authentifizierungsmethode für die Anmeldung bei Cal.com unterbrochen. Wir werden Sie bitten, Ihre neue E-Mail-Adresse zu verifizieren. In der Folge werden Sie abgemeldet und verwenden Ihre neue E-Mail-Adresse, um sich anstelle Ihrer aktuellen Authentifizierungsmethode anzumelden, nachdem Sie Ihr Passwort anhand der Anweisungen, die Ihnen per E-Mail zugesandt werden, festgelegt haben.",
|
||||
"reset_your_password": "Legen Sie Ihr neues Passwort mit den Anweisungen fest, die an Ihre E-Mail-Adresse gesendet wurden.",
|
||||
"email_change": "Melden Sie sich mit Ihrer neuen E-Mail-Adresse und Ihrem Passwort wieder an.",
|
||||
"create_your_account": "Erstellen Sie Ihr Konto",
|
||||
@@ -255,6 +256,7 @@
|
||||
"available_apps": "Verfügbare Apps",
|
||||
"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>",
|
||||
"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",
|
||||
@@ -527,7 +529,7 @@
|
||||
"location": "Ort",
|
||||
"address": "Adresse",
|
||||
"enter_address": "Geben Sie eine Adresse ein",
|
||||
"in_person_attendee_address": "In Person (Adresse von Ihnen)",
|
||||
"in_person_attendee_address": "Vor Ort (Adresse von Ihnen)",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"additional_notes": "Zusätzliche Notizen",
|
||||
@@ -537,7 +539,7 @@
|
||||
"booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}",
|
||||
"booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um",
|
||||
"in_person_meeting": "Vor-Ort-Termin",
|
||||
"in_person": "Persönlich (Organisator-Adresse)",
|
||||
"in_person": "Vor Ort (Organisator-Adresse)",
|
||||
"link_meeting": "Termin verknüpfen",
|
||||
"phone_number": "Telefonnummer",
|
||||
"attendee_phone_number": "Telefonnummer",
|
||||
@@ -559,6 +561,7 @@
|
||||
"leave": "Verlassen",
|
||||
"profile": "Profil",
|
||||
"my_team_url": "Meine Team-URL",
|
||||
"my_teams": "Meine Teams",
|
||||
"team_name": "Teamname",
|
||||
"your_team_name": "Ihr Teamname",
|
||||
"team_updated_successfully": "Team erfolgreich aktualisiert",
|
||||
@@ -1092,7 +1095,7 @@
|
||||
"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.",
|
||||
"this_input_will_shown_booking_this_event": "Diese Eingabe wird bei der Buchung dieses Termins angezeigt",
|
||||
"meeting_url_in_conformation_email": "Termin-URL ist in der Bestätigungsmail",
|
||||
"meeting_url_in_confirmation_email": "Termin-URL ist in der Bestätigungsmail",
|
||||
"url_start_with_https": "URL muss mit http:// oder https:// beginnen",
|
||||
"number_provided": "Telefonnummer wird angegeben",
|
||||
"before_event_trigger": "vor Beginn des Termins",
|
||||
@@ -1676,10 +1679,9 @@
|
||||
"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": "Sie wurden eingeladen, einem {{entity}} in {{appName}} 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": "{{invitedBy}} hat Sie eingeladen, dem {{entity}} „{{teamName}}“ in {{appName}} beizutreten. {{appName}} ist der Terminplaner, der es Ihnen und Ihrem {{entity}} 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 Ihrem {{entity}} genießen.",
|
||||
"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_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",
|
||||
"email_no_user_step_three": "Legen Sie Ihre Verfügbarkeit fest",
|
||||
@@ -1899,7 +1901,6 @@
|
||||
"install_app_on": "App installieren auf",
|
||||
"create_for": "Erstellen für",
|
||||
"currency": "Währung",
|
||||
"setup_organization": "Eine Organization einrichten",
|
||||
"organization_banner_description": "Schaffen Sie Umgebungen, in der Teams gemeinsame Apps, Workflows und Termintypen mit Round Robin und kollektiver Terminplanung erstellen können.",
|
||||
"organization_banner_title": "Verwalten Sie Organizations mit mehreren Teams",
|
||||
"set_up_your_organization": "Ihre Organization einrichten",
|
||||
@@ -1974,6 +1975,8 @@
|
||||
"org_team_names_example_5": "z.B. Data Analytics Team",
|
||||
"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",
|
||||
"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:",
|
||||
"org_admin_other_teams": "Weitere Teams",
|
||||
"no_other_teams_found": "Keine weiteren Teams gefunden",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"invite_team_individual_segment": "Invite individual",
|
||||
"invite_team_bulk_segment": "Bulk import",
|
||||
"invite_team_notifcation_badge": "Inv.",
|
||||
"signin_or_signup_to_accept_invite": "You need to Sign in or Sign up to see team invitation.",
|
||||
"your_event_has_been_scheduled": "Your event has been scheduled",
|
||||
"your_event_has_been_scheduled_recurring": "Your recurring event has been scheduled",
|
||||
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
|
||||
@@ -266,6 +267,7 @@
|
||||
"nearly_there_instructions": "Last thing, a brief description about you and a photo really helps you get bookings and let people know who they’re booking with.",
|
||||
"set_availability_instructions": "Define ranges of time when you are available on a recurring basis. You can create more of these later and assign them to different calendars.",
|
||||
"set_availability": "Set your availability",
|
||||
"availability_settings": "Availability Settings",
|
||||
"continue_without_calendar": "Continue without calendar",
|
||||
"connect_your_calendar": "Connect your calendar",
|
||||
"connect_your_video_app": "Connect your video apps",
|
||||
@@ -290,9 +292,9 @@
|
||||
"add_another_calendar": "Add another calendar",
|
||||
"other": "Other",
|
||||
"email_sign_in_subject": "Your sign-in link for {{appName}}",
|
||||
"emailed_you_and_attendees": "We emailed you and the other attendees a calendar invitation with all the details.",
|
||||
"emailed_you_and_attendees_recurring": "We emailed you and the other attendees a calendar invitation for the first of these recurring events.",
|
||||
"emailed_you_and_any_other_attendees": "You and any other attendees have been emailed with this information.",
|
||||
"emailed_you_and_attendees": "We sent an email with a calendar invitation with the details to everyone.",
|
||||
"emailed_you_and_attendees_recurring": "We sent an email with a calendar invitation with the details to everyone for the first of these recurring events.",
|
||||
"emailed_you_and_any_other_attendees": "We sent an email to everyone with this information.",
|
||||
"needs_to_be_confirmed_or_rejected": "Your booking still needs to be confirmed or rejected.",
|
||||
"needs_to_be_confirmed_or_rejected_recurring": "Your recurring meeting still needs to be confirmed or rejected.",
|
||||
"user_needs_to_confirm_or_reject_booking": "{{user}} still needs to confirm or reject the booking.",
|
||||
@@ -590,6 +592,8 @@
|
||||
"invite_new_member": "Invite a new team member",
|
||||
"invite_new_member_description": "Note: This will <1>cost an extra seat ($15/m)</1> on your subscription.",
|
||||
"invite_new_team_member": "Invite someone to your team.",
|
||||
"upload_csv_file": "Upload a .csv file",
|
||||
"invite_via_email": "Invite via email",
|
||||
"change_member_role": "Change team member role",
|
||||
"disable_cal_branding": "Disable {{appName}} branding",
|
||||
"disable_cal_branding_description": "Hide all {{appName}} branding from your public pages.",
|
||||
@@ -678,8 +682,8 @@
|
||||
"new_event_type_btn": "New event type",
|
||||
"new_event_type_heading": "Create your first event type",
|
||||
"new_event_type_description": "Event types enable you to share links that show available times on your calendar and allow people to make bookings with you.",
|
||||
"event_type_created_successfully": "{{eventTypeTitle}} event type created successfully",
|
||||
"event_type_updated_successfully": "{{eventTypeTitle}} event type updated successfully",
|
||||
"event_type_created_successfully": "Event type created successfully",
|
||||
"event_type_updated_successfully": "Event type updated successfully",
|
||||
"event_type_deleted_successfully": "Event type deleted successfully",
|
||||
"hours": "Hours",
|
||||
"people": "People",
|
||||
@@ -972,6 +976,8 @@
|
||||
"offer_seats_description": "Offer seats for booking. This automatically disables guest & opt-in bookings.",
|
||||
"seats_available_one": "Seat available",
|
||||
"seats_available_other": "Seats available",
|
||||
"seats_nearly_full": "Seats almost full",
|
||||
"seats_half_full": "Seats filling fast",
|
||||
"number_of_seats": "Number of seats per booking",
|
||||
"enter_number_of_seats": "Enter number of seats",
|
||||
"you_can_manage_your_schedules": "You can manage your schedules on the Availability page.",
|
||||
@@ -1052,12 +1058,15 @@
|
||||
"how_you_want_add_cal_site": "How do you want to add {{appName}} to your site?",
|
||||
"choose_ways_put_cal_site": "Choose one of the following ways to put {{appName}} on your site.",
|
||||
"setting_up_zapier": "Setting up your Zapier integration",
|
||||
"setting_up_make": "Setting up your Make integration",
|
||||
"generate_api_key": "Generate API key",
|
||||
"generate_api_key_description": "Generate an API key to use with {{appName}} at",
|
||||
"your_unique_api_key": "Your unique API key",
|
||||
"copy_safe_api_key": "Copy this API key and save it somewhere safe. If you lose this key you have to generate a new one.",
|
||||
"zapier_setup_instructions": "<0>Log into your Zapier account and create a new Zap.</0><1>Select Cal.com as your Trigger app. Also choose a Trigger event.</1><2>Choose your account and then enter your Unique API Key.</2><3>Test your Trigger.</3><4>You're set!</4>",
|
||||
"make_setup_instructions": "<0>Go to <1><0>Make Invite Link</0></1> and install the Cal.com app.</0><1>Log into your Make account and create a new Scenario.</1><2>Select Cal.com as your Trigger app. Also choose a Trigger event.</2><3>Choose your account and then enter your Unique API Key.</3><4>Test your Trigger.</4><5>You're set!</5>",
|
||||
"install_zapier_app": "Please first install the Zapier App in the app store.",
|
||||
"install_make_app": "Please first install the Make App in the app store.",
|
||||
"connect_apple_server": "Connect to Apple Server",
|
||||
"calendar_url": "Calendar URL",
|
||||
"apple_server_generate_password": "Generate an app specific password to use with {{appName}} at",
|
||||
@@ -1095,9 +1104,9 @@
|
||||
"reschedule_optional": "Reason for rescheduling (optional)",
|
||||
"reschedule_placeholder": "Let others know why you need to reschedule",
|
||||
"event_cancelled": "This event is canceled",
|
||||
"emailed_information_about_cancelled_event": "We emailed you and the other attendees to let them know.",
|
||||
"emailed_information_about_cancelled_event": "We sent an email to everyone to let them know.",
|
||||
"this_input_will_shown_booking_this_event": "This input will be shown when booking this event",
|
||||
"meeting_url_in_conformation_email": "Meeting url is in the confirmation email",
|
||||
"meeting_url_in_confirmation_email": "Meeting url is in the confirmation email",
|
||||
"url_start_with_https": "URL needs to start with http:// or https://",
|
||||
"number_provided": "Phone number will be provided",
|
||||
"before_event_trigger": "before event starts",
|
||||
@@ -1107,6 +1116,7 @@
|
||||
"email_attendee_action": "send email to attendees",
|
||||
"sms_attendee_action": "Send SMS to attendee",
|
||||
"sms_number_action": "send SMS to a specific number",
|
||||
"send_reminder_sms": "Easily send meeting reminders via SMS to your attendees",
|
||||
"whatsapp_number_action": "send WhatsApp message to a specific number",
|
||||
"whatsapp_attendee_action": "send WhatsApp message to attendee",
|
||||
"workflows": "Workflows",
|
||||
@@ -1449,6 +1459,7 @@
|
||||
"add_limit": "Add Limit",
|
||||
"team_name_required": "Team name required",
|
||||
"show_attendees": "Share attendee information between guests",
|
||||
"show_available_seats_count": "Show the number of available seats",
|
||||
"how_booking_questions_as_variables": "How to use booking questions as variables?",
|
||||
"format": "Format",
|
||||
"uppercase_for_letters": "Use uppercase for all letters",
|
||||
@@ -1682,9 +1693,11 @@
|
||||
"delete_sso_configuration_confirmation_description": "Are you sure you want to delete the {{connectionType}} configuration? Your team members who use {{connectionType}} login will no longer be able to access Cal.com.",
|
||||
"organizer_timezone": "Organizer timezone",
|
||||
"email_user_cta": "View Invitation",
|
||||
"email_no_user_invite_heading": "You’ve been invited to join a {{appName}} {{entity}}",
|
||||
"email_no_user_invite_heading_team": "You’ve been invited to join a {{appName}} team",
|
||||
"email_no_user_invite_heading_org": "You’ve been invited to join a {{appName}} organization",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
|
||||
"email_user_invite_subheading": "{{invitedBy}} has invited you to join their {{entity}} `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your {{entity}} to schedule meetings without the email tennis.",
|
||||
"email_user_invite_subheading_team": "{{invitedBy}} has invited you to join their team `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
|
||||
"email_user_invite_subheading_org": "{{invitedBy}} has invited you to join their organization `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your organization to schedule meetings without the email tennis.",
|
||||
"email_no_user_invite_steps_intro": "We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your {{entity}} in no time.",
|
||||
"email_no_user_step_one": "Choose your username",
|
||||
"email_no_user_step_two": "Connect your calendar account",
|
||||
@@ -1907,7 +1920,6 @@
|
||||
"install_app_on": "Install app on",
|
||||
"create_for": "Create for",
|
||||
"currency": "Currency",
|
||||
"setup_organization": "Setup an Organization",
|
||||
"organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.",
|
||||
"organization_banner_title": "Manage organizations with multiple teams",
|
||||
"set_up_your_organization": "Set up your organization",
|
||||
@@ -2010,6 +2022,13 @@
|
||||
"member_removed": "Member removed",
|
||||
"my_availability": "My Availability",
|
||||
"team_availability": "Team Availability",
|
||||
"backup_code": "Backup Code",
|
||||
"backup_codes": "Backup Codes",
|
||||
"backup_code_instructions": "Each backup code can be used exactly once to grant access without your authenticator.",
|
||||
"backup_codes_copied": "Backup codes copied!",
|
||||
"incorrect_backup_code": "Backup code is incorrect.",
|
||||
"lost_access": "Lost access",
|
||||
"missing_backup_codes": "No backup codes found. Please generate them in your settings.",
|
||||
"admin_org_notification_email_subject": "New organization created: pending action",
|
||||
"hi_admin": "Hi Administrator",
|
||||
"admin_org_notification_email_title": "An organization requires DNS setup",
|
||||
@@ -2025,6 +2044,12 @@
|
||||
"mark_dns_configured": "Mark as DNS configured",
|
||||
"value": "Value",
|
||||
"your_organization_updated_sucessfully": "Your organization updated successfully",
|
||||
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
|
||||
"team_no_event_types": "This team has no event types",
|
||||
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
|
||||
"include_calendar_event": "Include calendar event",
|
||||
"recently_added":"Recently added",
|
||||
"no_members_found": "No members found",
|
||||
"event_setup_length_error":"Event Setup: The duration must be at least 1 minute.",
|
||||
"availability_schedules":"Availability Schedules",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
"team_upgrade_banner_description": "Gracias por probar nuestro nuevo plan de equipo. Notamos que su equipo \"{{teamName}}\" necesita actualizarse.",
|
||||
"upgrade_banner_action": "Actualizar aquí",
|
||||
"team_upgraded_successfully": "¡Tu equipo se actualizó con éxito!",
|
||||
"org_upgrade_banner_description": "Gracias por probar nuestro nuevo plan de equipo. Notamos que su equipo \"{{teamName}}\" necesita actualizarse.",
|
||||
"org_upgrade_banner_description": "Gracias por probar nuestro nuevo plan Organization. Notamos que su Organization \"{{teamName}}\" necesita actualizarse.",
|
||||
"org_upgraded_successfully": "Su Organization se actualizó con éxito.",
|
||||
"use_link_to_reset_password": "Utilice el enlace de abajo para restablecer su contraseña",
|
||||
"hey_there": "Hola,",
|
||||
@@ -306,7 +306,7 @@
|
||||
"password_has_been_reset_login": "Su contraseña ha sido restablecida. Ahora puede iniciar sesión con su nueva contraseña.",
|
||||
"layout": "Diseño",
|
||||
"bookerlayout_default_title": "Vista predeterminada",
|
||||
"bookerlayout_description": "Puede seleccionar varios y quienes le reserven pueden cambiar de vista.",
|
||||
"bookerlayout_description": "Puede seleccionar varias y quienes le reserven pueden cambiar de vista.",
|
||||
"bookerlayout_user_settings_title": "Diseño de reserva",
|
||||
"bookerlayout_user_settings_description": "Puede seleccionar varios y quienes le reservan pueden cambiar de vista. Esto se puede anular por evento.",
|
||||
"bookerlayout_month_view": "Mes",
|
||||
@@ -315,7 +315,7 @@
|
||||
"bookerlayout_error_min_one_enabled": "Al menos un diseño tiene que estar habilitado.",
|
||||
"bookerlayout_error_default_not_enabled": "El diseño que seleccionó como vista predeterminada no es parte de los diseños habilitados.",
|
||||
"bookerlayout_error_unknown_layout": "El diseño seleccionado no es un diseño válido.",
|
||||
"bookerlayout_override_global_settings": "Puede gestionar esto para todos sus tipos de eventos en <2>configuración / apariencia</2> o <6>sobrescribir solo para este evento</6>.",
|
||||
"bookerlayout_override_global_settings": "Puede gestionar esto para todos sus tipos de eventos en <2>configuración / apariencia</2> o <6>anular solo para este evento</6>.",
|
||||
"unexpected_error_try_again": "Ocurrió un error inesperado. Inténtelo de nuevo.",
|
||||
"sunday_time_error": "Hora inválida del domingo",
|
||||
"monday_time_error": "Hora inválida del lunes",
|
||||
@@ -551,7 +551,7 @@
|
||||
"team_description": "Comentarios sobre tu equipo. Esta información aparecerá en la página de la URL de tu equipo.",
|
||||
"org_description": "Algunas frases sobre su organización. Esto aparecerá en la página de la URL de su organización.",
|
||||
"members": "Miembros",
|
||||
"organization_members": "Miembros de la organización",
|
||||
"organization_members": "Miembros de Organization",
|
||||
"member": "Miembro",
|
||||
"number_member_one": "{{count}} miembro",
|
||||
"number_member_other": "{{count}} miembros",
|
||||
@@ -699,7 +699,7 @@
|
||||
"create_team_to_get_started": "Crea un equipo para empezar",
|
||||
"teams": "Equipos",
|
||||
"team": "Equipo",
|
||||
"organization": "Organización",
|
||||
"organization": "Organization",
|
||||
"team_billing": "Facturación del equipo",
|
||||
"team_billing_description": "Gestione la facturación para su equipo",
|
||||
"upgrade_to_flexible_pro_title": "Hemos cambiado la facturación de los equipos",
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Este evento se canceló",
|
||||
"emailed_information_about_cancelled_event": "Le enviamos un correo electrónico a usted y a los demás asistentes para informarles.",
|
||||
"this_input_will_shown_booking_this_event": "Esta entrada se mostrará al reservar este evento.",
|
||||
"meeting_url_in_conformation_email": "La URL de la reunión está en el correo de confirmación",
|
||||
"meeting_url_in_confirmation_email": "La URL de la reunión está en el correo de confirmación",
|
||||
"url_start_with_https": "La URL debe empezar por http:// o https://",
|
||||
"number_provided": "Se proporcionará el número de teléfono",
|
||||
"before_event_trigger": "antes del inicio del evento",
|
||||
@@ -1659,9 +1659,8 @@
|
||||
"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": "Lo invitaron a unirse a {{appName}} {{entity}}",
|
||||
"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": "{{invitedBy}} lo ha invitado a unirse a su {{entity}} \"{{teamName}}\" en {{appName}}. {{appName}} es el planificador de eventos que le permite a usted y a su {{entity}} programar reuniones sin necesidad de 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_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",
|
||||
@@ -1878,7 +1877,6 @@
|
||||
"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",
|
||||
"setup_organization": "Configurar una organización",
|
||||
"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",
|
||||
@@ -1890,7 +1888,7 @@
|
||||
"organization_name": "Nombre de la organización",
|
||||
"organization_url": "URL de la organización",
|
||||
"organization_verify_header": "Verifique el correo electrónico de su organización",
|
||||
"organization_verify_email_body": "Utilice el código a continuación para verificar su dirección de correo electrónico para seguir configurando su organización.",
|
||||
"organization_verify_email_body": "Utilice el siguiente código para verificar su dirección de correo electrónico y continuar con la configuración de su organización.",
|
||||
"additional_url_parameters": "Parámetros adicionales de URL",
|
||||
"about_your_organization": "Acerca de su organización",
|
||||
"about_your_organization_description": "Las organizaciones son entornos compartidos donde puede crear varios equipos con miembros, tipos de eventos, aplicaciones, flujos de trabajo compartidos y más.",
|
||||
@@ -1918,7 +1916,7 @@
|
||||
"org_no_teams_yet_description": "Si usted es un administrador, asegúrese de crear equipos para que se muestren aquí.",
|
||||
"set_up": "Configurar",
|
||||
"set_up_your_profile": "Configure su perfil",
|
||||
"set_up_your_profile_description": "Informe a las personas quién es usted dentro de {{orgName}} cuándo interactúan con su enlace público.",
|
||||
"set_up_your_profile_description": "Informe a las personas quién es usted dentro de {{orgName}} y cuándo interactúen con su enlace público.",
|
||||
"my_profile": "Mi perfil",
|
||||
"my_settings": "Mi configuración",
|
||||
"crm": "CRM",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"invite_team_individual_segment": "Inviter une personne",
|
||||
"invite_team_bulk_segment": "Importation multiple",
|
||||
"invite_team_notifcation_badge": "Inv.",
|
||||
"signin_or_signup_to_accept_invite": "Vous devez vous connecter ou vous inscrire pour voir l'invitation d'équipe.",
|
||||
"your_event_has_been_scheduled": "Votre événement a été planifié",
|
||||
"your_event_has_been_scheduled_recurring": "Votre événement récurrent a été planifié",
|
||||
"accept_our_license": "Acceptez notre licence en changeant la variable .env <1>NEXT_PUBLIC_LICENSE_CONSENT</1> en « {{agree}} ».",
|
||||
@@ -266,6 +267,7 @@
|
||||
"nearly_there_instructions": "Pour finir, une brève description de vous et une photo vous aideront vraiment à obtenir des réservations et à faire savoir aux gens avec qui ils prennent rendez-vous.",
|
||||
"set_availability_instructions": "Définissez des plages de temps pendant lesquelles vous êtes disponible de manière récurrente. Vous pourrez en créer d'autres ultérieurement et les assigner à différents calendriers.",
|
||||
"set_availability": "Définissez vos disponibilités",
|
||||
"availability_settings": "Paramètres de disponibilité",
|
||||
"continue_without_calendar": "Continuer sans calendrier",
|
||||
"connect_your_calendar": "Connectez votre calendrier",
|
||||
"connect_your_video_app": "Connectez vos applications vidéo",
|
||||
@@ -290,9 +292,9 @@
|
||||
"add_another_calendar": "Ajouter un autre calendrier",
|
||||
"other": "Autre",
|
||||
"email_sign_in_subject": "Votre lien de connexion pour {{appName}}",
|
||||
"emailed_you_and_attendees": "Vous et tous les autres participants avez reçu ces informations par e-mail.",
|
||||
"emailed_you_and_attendees_recurring": "Vous et tous les autres participants avez reçu par e-mail les informations pour le premier de ces événements récurrents.",
|
||||
"emailed_you_and_any_other_attendees": "Vous et tous les autres participants avez reçu ces informations par e-mail.",
|
||||
"emailed_you_and_attendees": "Nous avons envoyé un e-mail avec une invitation de calendrier comprenant les détails à tout le monde.",
|
||||
"emailed_you_and_attendees_recurring": "Nous avons envoyé un e-mail avec une invitation de calendrier comprenant les détails à tout le monde pour le premier de ces événements récurrents.",
|
||||
"emailed_you_and_any_other_attendees": "Nous avons envoyé cette information à tout le monde par e-mail.",
|
||||
"needs_to_be_confirmed_or_rejected": "Votre réservation doit encore être confirmée ou refusée.",
|
||||
"needs_to_be_confirmed_or_rejected_recurring": "Votre rendez-vous récurrent doit encore être confirmé ou refusé.",
|
||||
"user_needs_to_confirm_or_reject_booking": "{{user}} doit encore confirmer ou refuser la réservation.",
|
||||
@@ -590,6 +592,8 @@
|
||||
"invite_new_member": "Inviter un nouveau membre d'équipe",
|
||||
"invite_new_member_description": "Remarque : cela <1>coûtera une place supplémentaire (15 $/mois)</1> sur votre abonnement.",
|
||||
"invite_new_team_member": "Invitez quelqu'un à rejoindre votre équipe.",
|
||||
"upload_csv_file": "Télécharger un fichier .csv",
|
||||
"invite_via_email": "Inviter par e-mail",
|
||||
"change_member_role": "Changer le rôle du membre de l'équipe",
|
||||
"disable_cal_branding": "Désactiver les références à la marque {{appName}}",
|
||||
"disable_cal_branding_description": "Supprimez toutes les références à la marque {{appName}} de vos pages publiques.",
|
||||
@@ -678,8 +682,8 @@
|
||||
"new_event_type_btn": "Nouveau type d'événement",
|
||||
"new_event_type_heading": "Créez votre premier type d'événement",
|
||||
"new_event_type_description": "Les types d'événements vous permettent de partager des liens qui affichent vos disponibilités sur votre calendrier et permettent aux personnes de réserver des créneaux.",
|
||||
"event_type_created_successfully": "Type d'événement {{eventTypeTitle}} créé avec succès",
|
||||
"event_type_updated_successfully": "Type d'événement {{eventTypeTitle}} mis à jour avec succès",
|
||||
"event_type_created_successfully": "Type d'événement créé avec succès",
|
||||
"event_type_updated_successfully": "Type d'événement mis à jour avec succès",
|
||||
"event_type_deleted_successfully": "Type d'événement supprimé avec succès",
|
||||
"hours": "heures",
|
||||
"people": "Personnes",
|
||||
@@ -972,6 +976,8 @@
|
||||
"offer_seats_description": "Proposez des places de réservation. Cela désactive automatiquement les réservations d'invités et d'opt-in.",
|
||||
"seats_available_one": "Place disponible",
|
||||
"seats_available_other": "Places disponibles",
|
||||
"seats_nearly_full": "Places presque toutes occupées",
|
||||
"seats_half_full": "Les places partent vite",
|
||||
"number_of_seats": "Nombre de places par réservation",
|
||||
"enter_number_of_seats": "Saisir le nombre de sièges",
|
||||
"you_can_manage_your_schedules": "Vous pouvez gérer vos disponibilités sur la page Disponibilité.",
|
||||
@@ -1052,12 +1058,15 @@
|
||||
"how_you_want_add_cal_site": "Comment souhaitez-vous ajouter {{appName}} à votre site ?",
|
||||
"choose_ways_put_cal_site": "Choisissez l'une des méthodes suivantes pour mettre {{appName}} sur votre site.",
|
||||
"setting_up_zapier": "Configuration de votre intégration Zapier",
|
||||
"setting_up_make": "Configuration de votre intégration Make",
|
||||
"generate_api_key": "Générer une clé API",
|
||||
"generate_api_key_description": "Générer une clé API à utiliser avec {{appName}} sur",
|
||||
"your_unique_api_key": "Votre clé API unique",
|
||||
"copy_safe_api_key": "Copiez cette clé API et conservez-la dans un endroit sûr. Si vous perdez cette clé, vous devrez en générer une nouvelle.",
|
||||
"zapier_setup_instructions": "<0>Connectez-vous à votre compte Zapier et créez un nouveau Zap.</0><1>Sélectionnez Cal.com comme application Trigger. Choisissez également un événement Trigger.</1><2>Choisissez votre compte, puis saisissez votre clé API unique.</2><3>Testez votre Trigger.</3><4>Vous êtes prêt !</4>",
|
||||
"zapier_setup_instructions": "<0>Connectez-vous à votre compte Zapier et créez un nouveau Zap.</0><1>Sélectionnez Cal.com comme application déclencheur. Choisissez également un événement déclencheur.</1><2>Choisissez votre compte, puis saisissez votre clé API unique.</2><3>Testez votre déclencheur.</3><4>Vous êtes prêt !</4>",
|
||||
"make_setup_instructions": "<0>Accédez au <1><0>lien d'invitation Make</0></1> et installez l'application Cal.com.</0><1>Connectez-vous à votre compte Make et créez un nouveau scénario.</1><2>Sélectionnez Cal.com comme application déclencheur. Choisissez également un événement déclencheur.</2><3>Choisissez votre compte, puis saisissez votre clé API unique.</3><4>Testez votre déclencheur.</4><5>Vous êtes prêt !</5>",
|
||||
"install_zapier_app": "Veuillez d'abord installer l'application Zapier dans l'App Store.",
|
||||
"install_make_app": "Veuillez d'abord installer l'application Make dans l'App Store.",
|
||||
"connect_apple_server": "Se connecter au serveur d'Apple",
|
||||
"calendar_url": "Lien du calendrier",
|
||||
"apple_server_generate_password": "Générez un mot de passe pour application à utiliser avec {{appName}} sur",
|
||||
@@ -1095,9 +1104,9 @@
|
||||
"reschedule_optional": "Raison de la replanification (facultatif)",
|
||||
"reschedule_placeholder": "Faites savoir aux autres pourquoi vous devez replanifier le rendez-vous.",
|
||||
"event_cancelled": "Cet événement est annulé",
|
||||
"emailed_information_about_cancelled_event": "Nous vous avons envoyé un e-mail à vous et aux autres participants pour les en informer.",
|
||||
"emailed_information_about_cancelled_event": "Nous avons envoyé un e-mail à tout le monde pour les en informer.",
|
||||
"this_input_will_shown_booking_this_event": "Ce champ sera affiché lors de la réservation de cet événement.",
|
||||
"meeting_url_in_conformation_email": "Le lien du rendez-vous est dans l'e-mail de confirmation",
|
||||
"meeting_url_in_confirmation_email": "Le lien du rendez-vous est dans l'e-mail de confirmation",
|
||||
"url_start_with_https": "Le lien doit commencer par http:// ou https://",
|
||||
"number_provided": "Un numéro de téléphone sera fourni",
|
||||
"before_event_trigger": "avant le début de l'événement",
|
||||
@@ -1107,6 +1116,7 @@
|
||||
"email_attendee_action": "envoyer un e-mail aux participants",
|
||||
"sms_attendee_action": "Envoyer un SMS au participant",
|
||||
"sms_number_action": "envoyer un SMS à un numéro spécifique",
|
||||
"send_reminder_sms": "Envoyez facilement des rappels de rendez-vous par SMS à vos participants",
|
||||
"whatsapp_number_action": "envoyer un message WhatsApp à un numéro spécifique",
|
||||
"whatsapp_attendee_action": "envoyer un message WhatsApp au participant",
|
||||
"workflows": "Workflows",
|
||||
@@ -1114,7 +1124,7 @@
|
||||
"add_new_workflow": "Ajouter un nouveau workflow",
|
||||
"reschedule_event_trigger": "lorsque l'événement est replanifié",
|
||||
"trigger": "Déclencheur",
|
||||
"triggers": "Déclencher",
|
||||
"triggers": "Déclencheurs",
|
||||
"action": "Action",
|
||||
"workflows_to_automate_notifications": "Créez des workflows pour automatiser les notifications et les rappels.",
|
||||
"workflow_name": "Nom du workflow",
|
||||
@@ -1449,6 +1459,7 @@
|
||||
"add_limit": "Ajouter une limite",
|
||||
"team_name_required": "Nom d'équipe requis",
|
||||
"show_attendees": "Partagez les informations des participants entre les invités",
|
||||
"show_available_seats_count": "Afficher le nombre de places disponibles",
|
||||
"how_booking_questions_as_variables": "Comment utiliser les questions de réservation comme variables ?",
|
||||
"format": "Format",
|
||||
"uppercase_for_letters": "Utilisez des majuscules pour toutes les lettres.",
|
||||
@@ -1682,9 +1693,11 @@
|
||||
"delete_sso_configuration_confirmation_description": "Voulez-vous vraiment supprimer la configuration {{connectionType}} ? Les membres de votre équipe utilisant la connexion {{connectionType}} ne pourront plus accéder à Cal.com.",
|
||||
"organizer_timezone": "Fuseau horaire de l'organisateur",
|
||||
"email_user_cta": "Voir l'invitation",
|
||||
"email_no_user_invite_heading": "Vous avez été invité à rejoindre une {{entity}} {{appName}}",
|
||||
"email_no_user_invite_heading_team": "Vous avez été invité à rejoindre une équipe {{appName}}",
|
||||
"email_no_user_invite_heading_org": "Vous avez été invité à rejoindre une organisation {{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} vous a invité à rejoindre son équipe sur {{appName}}. {{appName}} est le planificateur d'événements qui vous permet à vous et à votre équipe d'organiser des rendez-vous sans échanges d'e-mails.",
|
||||
"email_user_invite_subheading": "{{invitedBy}} vous a invité à rejoindre son {{entity}} « {{teamName}} » sur {{appName}}. {{appName}} est le planificateur d'événements qui vous permet à vous et à votre {{entity}} d'organiser des rendez-vous sans échanges d'e-mails.",
|
||||
"email_user_invite_subheading_team": "{{invitedBy}} vous a invité à rejoindre son équipe « {{teamName}} » sur {{appName}}. {{appName}} est le planificateur d'événements qui vous permet à vous et à votre équipe d'organiser des rendez-vous sans échanges d'e-mails.",
|
||||
"email_user_invite_subheading_org": "{{invitedBy}} vous a invité à rejoindre son organisation « {{teamName}} » sur {{appName}}. {{appName}} est le planificateur d'événements qui vous permet à vous et à votre organisation d'organiser des rendez-vous sans échanges d'e-mails.",
|
||||
"email_no_user_invite_steps_intro": "Nous vous guiderons à travers quelques étapes courtes et vous profiterez d'une planification sans stress avec votre {{entity}} en un rien de temps.",
|
||||
"email_no_user_step_one": "Choisissez votre nom d'utilisateur",
|
||||
"email_no_user_step_two": "Connectez votre compte de calendrier",
|
||||
@@ -1907,7 +1920,6 @@
|
||||
"install_app_on": "Installer l’application sur",
|
||||
"create_for": "Créer pour",
|
||||
"currency": "Devise",
|
||||
"setup_organization": "Configurer une organisation",
|
||||
"organization_banner_description": "Créez un environnement où vos équipes peuvent créer des applications partagées, des workflows et des types d'événements avec une planification round-robin et collective.",
|
||||
"organization_banner_title": "Gérer les organisations avec plusieurs équipes",
|
||||
"set_up_your_organization": "Configurer votre organisation",
|
||||
@@ -1988,6 +2000,8 @@
|
||||
"remove_users_from_org_confirm": "Voulez-vous vraiment supprimer {{userCount}} utilisateurs de cette organisation ?",
|
||||
"user_has_no_schedules": "Cet utilisateur n'a pas encore configuré de planning",
|
||||
"user_isnt_in_any_teams": "Cet utilisateur n'est dans aucune équipe",
|
||||
"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>",
|
||||
@@ -2001,16 +2015,28 @@
|
||||
"no_other_teams_found_description": "Il n'y a pas d'autres équipes dans cette organisation.",
|
||||
"attendee_first_name_variable": "Prénom du participant",
|
||||
"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",
|
||||
"me": "Moi",
|
||||
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
|
||||
"member_removed": "Membre supprimé",
|
||||
"my_availability": "Mes disponibilités",
|
||||
"team_availability": "Disponibilités de l'équipe",
|
||||
"backup_code": "Code de récupération",
|
||||
"backup_codes": "Codes de récupération",
|
||||
"backup_code_instructions": "Chaque code de récupération peut être utilisé une seule fois pour accorder l'accès sans votre authentificateur.",
|
||||
"backup_codes_copied": "Codes de récupération copiés !",
|
||||
"incorrect_backup_code": "Le code de récupération est incorrect.",
|
||||
"lost_access": "Accès perdu",
|
||||
"missing_backup_codes": "Aucun code de récupération trouvé. Merci de les générer dans vos paramètres.",
|
||||
"admin_org_notification_email_subject": "Nouvelle organisation créée : action en attente",
|
||||
"hi_admin": "Bonjour administrateur",
|
||||
"admin_org_notification_email_title": "Une organisation nécessite une configuration DNS",
|
||||
"admin_org_notification_email_body_part1": "Une organisation avec le slug « {{orgSlug}} » a été créée.<br /><br />Assurez-vous de configurer votre registre DNS pour pointer le sous-domaine correspondant à la nouvelle organisation où l'application principale est en cours d'exécution. Autrement, l'organisation ne fonctionnera pas.<br /><br />Voici les options de base pour configurer un sous-domaine pour qu'il pointe vers son application afin qu'il charge la page de profil de l'organisation.<br /><br />Vous pouvez le faire soit avec l'enregistrement A :",
|
||||
"admin_org_notification_email_body_part2": "Ou l'enregistrement CNAME :",
|
||||
"admin_org_notification_email_body_part3": "Une fois le sous-domaine configuré, veuillez marquer la configuration DNS comme terminée dans les paramètres d'administration des organisations.",
|
||||
"admin_org_notification_email_cta": "Accéder aux paramètres d'administration des organisations",
|
||||
"org_has_been_processed": "L'organisation a été traitée",
|
||||
"org_error_processing": "Une erreur s'est produite lors du traitement de cette organisation",
|
||||
"orgs_page_description": "Une liste de toutes les organisations. Accepter une organisation permettra à tous les utilisateurs avec ce domaine de messagerie de s'inscrire SANS vérification d'e-mail.",
|
||||
"unverified": "Non vérifié",
|
||||
@@ -2018,5 +2044,12 @@
|
||||
"mark_dns_configured": "Marquer comme DNS configuré",
|
||||
"value": "Valeur",
|
||||
"your_organization_updated_sucessfully": "Votre organisation a été mise à jour avec succès",
|
||||
"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",
|
||||
"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é",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "האירוע בוטל",
|
||||
"emailed_information_about_cancelled_event": "שלחנו לך ולשאר המשתתפים הודעת דוא״ל כדי ליידע אותם על כך.",
|
||||
"this_input_will_shown_booking_this_event": "הקלט יוצג בעת הזמנת אירוע זה",
|
||||
"meeting_url_in_conformation_email": "כתובת ה-URL של הפגישה נמצאת בהודעת הדוא״ל שנשלחה לאישור הפגישה",
|
||||
"meeting_url_in_confirmation_email": "כתובת ה-URL של הפגישה נמצאת בהודעת הדוא״ל שנשלחה לאישור הפגישה",
|
||||
"url_start_with_https": "כתובת ה-URL צריכה להתחיל עם הקידומת http:// או https://",
|
||||
"number_provided": "מספר הטלפון יסופק",
|
||||
"before_event_trigger": "לפני שהאירוע מתחיל",
|
||||
@@ -1659,9 +1659,8 @@
|
||||
"delete_sso_configuration_confirmation_description": "אתה בטוח שאתה רוצה למחוק את הגדרות {{connectionType}}? חברי הצוות שלך שמשתמשים ב- {{connectionType}} להזדהות לא יוכלו להשתמש בו להכנס ל- Cal.com.",
|
||||
"organizer_timezone": "מארגן אזורי זמן",
|
||||
"email_user_cta": "צפה בהזמנה",
|
||||
"email_no_user_invite_heading": "הוזמנת להצטרף ל{{entity}} של {{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} הזמין אותך להצטרף לצוות שלו ב- {{appName}}. {{appName}} הינה מתזמן זימונים שמאפשר לך ולצוות שלך לזמן פגישות בלי כל הפינג פונג במיילים.",
|
||||
"email_user_invite_subheading": "{{invitedBy}} הזמין/ה אותך להצטרף ל-{{entity}} שלו/ה בשם ״{{teamName}}״ באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ול-{{entity}} שלך לתזמן פגישות בלי הצורך לנהל התכתבויות ארוכות בדוא״ל.",
|
||||
"email_user_invite_subheading_team": "{{invitedBy}} הזמין/ה אותך להצטרף לצוות שלו/ה בשם '{{teamName}}' באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ולצוות שלך לתזמן פגישות בלי כל הפינג פונג במיילים.",
|
||||
"email_no_user_invite_steps_intro": "נדריך אותך במספר קטן של צעדים ותוכל/י להתחיל ליהנות מקביעת מועדים עם ה-{{entity}} שלך במהירות ובלי בעיות.",
|
||||
"email_no_user_step_one": "בחר שם משתמש",
|
||||
"email_no_user_step_two": "קשר את לוח השנה שלך",
|
||||
@@ -1878,7 +1877,6 @@
|
||||
"first_event_type_webhook_description": "צור/צרי את ה-Webhook הראשון שלך עבור סוג האירוע הזה",
|
||||
"install_app_on": "התקנת האפליקציה ב-",
|
||||
"create_for": "צור/צרי עבור",
|
||||
"setup_organization": "הגדרת Organization",
|
||||
"organization_banner_description": "צור/צרי סביבות שבהן הצוותים שלך יוכלו ליצור אפליקציות, תהליכי עבודה וסוגי אירועים משותפים, עם תכונות כמו סבב וקביעת מועדים שיתופית.",
|
||||
"organization_banner_title": "ניהול ארגונים עם צוותים מרובים",
|
||||
"set_up_your_organization": "הגדרת הארגון שלך",
|
||||
|
||||
@@ -210,6 +210,8 @@
|
||||
"done": "Učinjeno",
|
||||
"all_done": "Završeno!",
|
||||
"all": "Sve",
|
||||
"available_apps": "Dostupne Aplikacije",
|
||||
"available_apps_lower_case": "Dostupne aplikacije",
|
||||
"check_email_reset_password": "Provjerite svoju e-poštu. Poslali smo vam link za resetiranje lozinke.",
|
||||
"finish": "Završi",
|
||||
"few_sentences_about_yourself": "Nekoliko rečenica o sebi. Ovo će se pojaviti na vašoj osobnoj stranici.",
|
||||
@@ -331,5 +333,6 @@
|
||||
"dark": "Tamna",
|
||||
"automatically_adjust_theme": "Automatski prilagodite temu na temelju preferencija pozvanih osoba",
|
||||
"user_dynamic_booking_disabled": "Neki od korisnika u grupi trenutno su onemogućili dinamičke grupne rezervacije",
|
||||
"full_name": "Puno ime",
|
||||
"insights_all_org_filter": "Sve"
|
||||
}
|
||||
|
||||
@@ -403,7 +403,7 @@
|
||||
"recording_ready": "Il link per scaricare la registrazione è pronto",
|
||||
"booking_created": "Prenotazione Creata",
|
||||
"booking_rejected": "Prenotazione Rifiutata",
|
||||
"booking_requested": "Prenotazione Richiesta",
|
||||
"booking_requested": "Richiesta di prenotazione inviata",
|
||||
"meeting_ended": "Riunione terminata",
|
||||
"form_submitted": "Modulo inviato",
|
||||
"event_triggers": "Attivatori Evento",
|
||||
@@ -1075,7 +1075,7 @@
|
||||
"event_cancelled": "Questo evento è stato cancellato",
|
||||
"emailed_information_about_cancelled_event": "Abbiamo inviato un'e-mail di notifica a te e agli altri partecipanti.",
|
||||
"this_input_will_shown_booking_this_event": "Questo input verrà mostrato durante la prenotazione di questo evento",
|
||||
"meeting_url_in_conformation_email": "L'URL della riunione è riportato nell'e-mail di conferma",
|
||||
"meeting_url_in_confirmation_email": "L'URL della riunione è riportato nell'e-mail di conferma",
|
||||
"url_start_with_https": "L'URL deve iniziare con http:// o https://",
|
||||
"number_provided": "Verrà fornito il numero di telefono",
|
||||
"before_event_trigger": "prima dell'inizio dell'evento",
|
||||
@@ -1659,9 +1659,8 @@
|
||||
"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": "Hai ricevuto un invito a partecipare a un/una {{entity}} di {{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": "{{invitedBy}} ti ha invitato a partecipare al/alla {{entity}} `{{teamName}}` su {{appName}}. {{appName}} è uno strumento di pianificazione di eventi che permette a te e al tuo/tua {{entity}} di pianificare le riunioni senza scambiarvi 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_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",
|
||||
@@ -1878,7 +1877,6 @@
|
||||
"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",
|
||||
"setup_organization": "Imposta un'organizzazione",
|
||||
"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",
|
||||
@@ -1889,7 +1887,7 @@
|
||||
"admin_username": "Nome utente dell'amministratore",
|
||||
"organization_name": "Nome dell'organizzazione",
|
||||
"organization_url": "URL dell'organizzazione",
|
||||
"organization_verify_header": "Verifica l'e-mail della tua organizzazione",
|
||||
"organization_verify_header": "Verifica il tuo indirizzo e-mail",
|
||||
"organization_verify_email_body": "Usa il codice sottostante per verificare il tuo indirizzo e-mail e proseguire la configurazione dell'organizzazione.",
|
||||
"additional_url_parameters": "Parametri aggiuntivi dell'URL",
|
||||
"about_your_organization": "Informazioni sulla tua organizzazione",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user