Merge branch 'main' into util/typed-query

This commit is contained in:
zomars 2022-12-15 15:37:43 -07:00
commit d0a6ea7ac9
47 changed files with 814 additions and 139 deletions

View File

@ -87,6 +87,7 @@ TWILIO_TOKEN=
TWILIO_MESSAGING_SID= TWILIO_MESSAGING_SID=
TWILIO_PHONE_NUMBER= TWILIO_PHONE_NUMBER=
NEXT_PUBLIC_SENDER_ID= NEXT_PUBLIC_SENDER_ID=
TWILIO_VERIFY_SID=
# This is used so we can bypass emails in auth flows for E2E testing # This is used so we can bypass emails in auth flows for E2E testing
# Set it to "1" if you need to run E2E tests locally # Set it to "1" if you need to run E2E tests locally

View File

@ -429,6 +429,8 @@ following
12. Leave all other fields as they are 12. Leave all other fields as they are
13. Complete setup and click View my new Messaging Service 13. Complete setup and click View my new Messaging Service
14. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field 14. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field
15. Create a verify service
16. Copy Verify Service SID to your .env file into the TWILIO_VERIFY_SID field
<!-- LICENSE --> <!-- LICENSE -->

View File

@ -74,7 +74,7 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))} excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))}
onChange={(ranges) => append({ ranges })} onChange={(ranges) => append({ ranges })}
Trigger={ Trigger={
<Button color="secondary" StartIcon={Icon.FiPlus}> <Button color="secondary" StartIcon={Icon.FiPlus} data-testid="add-override">
Add an override Add an override
</Button> </Button>
} }
@ -128,7 +128,9 @@ export default function Availability({ schedule }: { schedule: number }) {
<Controller <Controller
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => <EditableHeading isReady={!isLoading} {...field} />} render={({ field }) => (
<EditableHeading isReady={!isLoading} {...field} data-testid="availablity-title" />
)}
/> />
} }
subtitle={ subtitle={

View File

@ -1,10 +1,10 @@
import { useState } from "react";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterOutputs, trpc } from "@calcom/trpc/react"; import { RouterOutputs, trpc } from "@calcom/trpc/react";
import { Shell, SkeletonText } from "@calcom/ui"; import { Shell, SkeletonText } from "@calcom/ui";
import useRouterQuery from "@lib/hooks/useRouterQuery";
type User = RouterOutputs["viewer"]["me"]; type User = RouterOutputs["viewer"]["me"];
export interface IBusySlot { export interface IBusySlot {
@ -16,7 +16,9 @@ export interface IBusySlot {
const AvailabilityView = ({ user }: { user: User }) => { const AvailabilityView = ({ user }: { user: User }) => {
const { t } = useLocale(); const { t } = useLocale();
const [selectedDate, setSelectedDate] = useState(dayjs()); const { date, setQuery: setSelectedDate } = useRouterQuery("date");
const selectedDate = dayjs(date);
const formattedSelectedDate = selectedDate.format("YYYY-MM-DD");
const { data, isLoading } = trpc.viewer.availability.user.useQuery( const { data, isLoading } = trpc.viewer.availability.user.useQuery(
{ {
@ -30,6 +32,17 @@ const AvailabilityView = ({ user }: { user: User }) => {
} }
); );
const overrides =
data?.dateOverrides.reduce((acc, override) => {
if (
formattedSelectedDate !== dayjs(override.start).format("YYYY-MM-DD") &&
formattedSelectedDate !== dayjs(override.end).format("YYYY-MM-DD")
)
return acc;
acc.push({ ...override, source: "Date override" });
return acc;
}, [] as IBusySlot[]) || [];
return ( return (
<div className="max-w-xl overflow-hidden rounded-md bg-white shadow"> <div className="max-w-xl overflow-hidden rounded-md bg-white shadow">
<div className="px-4 py-5 sm:p-6"> <div className="px-4 py-5 sm:p-6">
@ -37,9 +50,9 @@ const AvailabilityView = ({ user }: { user: User }) => {
<input <input
type="date" type="date"
className="inline h-8 border-none p-0" className="inline h-8 border-none p-0"
defaultValue={selectedDate.format("YYYY-MM-DD")} defaultValue={formattedSelectedDate}
onChange={(e) => { onChange={(e) => {
if (e.target.value) setSelectedDate(dayjs(e.target.value)); if (e.target.value) setSelectedDate(e.target.value);
}} }}
/> />
<small className="block text-neutral-400">{t("hover_over_bold_times_tip")}</small> <small className="block text-neutral-400">{t("hover_over_bold_times_tip")}</small>
@ -49,39 +62,47 @@ const AvailabilityView = ({ user }: { user: User }) => {
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)} {t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div> </div>
</div> </div>
{isLoading ? ( {(() => {
<> if (isLoading)
<SkeletonText className="block h-16 w-full" /> return (
<SkeletonText className="block h-16 w-full" /> <>
</> <SkeletonText className="block h-16 w-full" />
) : data && data.busy.length > 0 ? ( <SkeletonText className="block h-16 w-full" />
data.busy </>
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1)) );
.map((slot: IBusySlot) => (
<div if (data && (data.busy.length > 0 || overrides.length > 0))
key={dayjs(slot.start).format("HH:mm")} return [...data.busy, ...overrides]
className="overflow-hidden rounded-md bg-neutral-100"> .sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
<div className="px-4 py-5 text-black sm:p-6"> .map((slot: IBusySlot) => (
{t("calendar_shows_busy_between")}{" "} <div
<span className="font-medium text-neutral-800" title={dayjs(slot.start).format("HH:mm")}> key={dayjs(slot.start).format("HH:mm")}
{dayjs(slot.start).format("HH:mm")} className="overflow-hidden rounded-md bg-neutral-100"
</span>{" "} data-testid="troubleshooter-busy-time">
{t("and")}{" "} <div className="px-4 py-5 text-black sm:p-6">
<span className="font-medium text-neutral-800" title={dayjs(slot.end).format("HH:mm")}> {t("calendar_shows_busy_between")}{" "}
{dayjs(slot.end).format("HH:mm")} <span
</span>{" "} className="font-medium text-neutral-800"
{t("on")} {dayjs(slot.start).format("D")}{" "} title={dayjs(slot.start).format("HH:mm")}>
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")} {dayjs(slot.start).format("HH:mm")}
{slot.title && ` - (${slot.title})`} </span>{" "}
{slot.source && <small>{` - (source: ${slot.source})`}</small>} {t("and")}{" "}
<span className="font-medium text-neutral-800" title={dayjs(slot.end).format("HH:mm")}>
{dayjs(slot.end).format("HH:mm")}
</span>{" "}
{t("on")} {dayjs(slot.start).format("D")}{" "}
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")}
{slot.title && ` - (${slot.title})`}
{slot.source && <small>{` - (source: ${slot.source})`}</small>}
</div>
</div> </div>
</div> ));
)) return (
) : ( <div className="overflow-hidden rounded-md bg-neutral-100">
<div className="overflow-hidden rounded-md bg-neutral-100"> <div className="px-4 py-5 text-black sm:p-6">{t("calendar_no_busy_slots")}</div>
<div className="px-4 py-5 text-black sm:p-6">{t("calendar_no_busy_slots")}</div> </div>
</div> );
)} })()}
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md"> <div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6"> <div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">

View File

@ -0,0 +1,54 @@
import { expect } from "@playwright/test";
import dayjs from "@calcom/dayjs";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("Availablity tests", () => {
test.beforeEach(async ({ page, users }) => {
const user = await users.create();
await user.login();
await page.goto("/availability");
// We wait until loading is finished
await page.waitForSelector('[data-testid="schedules"]');
});
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("Date Overrides", async ({ page }) => {
await test.step("Can add a date override", async () => {
await page.locator('[data-testid="schedules"] > li a').click();
await page.locator('[data-testid="add-override"]').click();
await page.locator('[id="modal-title"]').waitFor();
await page.locator('[data-testid="incrementMonth"]').click();
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
await page.locator('[data-testid="date-override-mark-unavailable"]').click();
await page.locator('[data-testid="add-override-submit-btn"]').click();
await expect(page.locator('[data-testid="date-overrides-list"] > li')).toHaveCount(1);
await page.locator('[form="availability-form"][type="submit"]').click();
});
await test.step("Date override is displayed in troubleshooter", async () => {
const response = await page.waitForResponse("**/api/trpc/viewer.availability.schedule.update?batch=1");
const json = await response.json();
// @ts-expect-error trust me bro
const date = json[0].result.data.json.schedule.availability.find((a) => !!a.date);
const troubleshooterURL = `/availability/troubleshoot?date=${dayjs(date.date).format("YYYY-MM-DD")}`;
await page.goto(troubleshooterURL);
await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1);
});
});
test("Availablity pages", async ({ page }) => {
await test.step("Can add a new schedule", async () => {
await page.locator('[data-testid="new-schedule"]').click();
await page.locator('[id="name"]').fill("More working hours");
page.locator('[type="submit"]').click();
await expect(page.locator("[data-testid=availablity-title]")).toHaveValue("More working hours");
});
});
});

View File

@ -67,6 +67,7 @@ test("add webhook & test that creating an event triggers a webhook call", async
body.payload.eventTypeId = dynamic; body.payload.eventTypeId = dynamic;
body.payload.videoCallData = dynamic; body.payload.videoCallData = dynamic;
body.payload.appsStatus = dynamic; body.payload.appsStatus = dynamic;
body.payload.metadata.videoCallUrl = dynamic;
// if we change the shape of our webhooks, we can simply update this by clicking `u` // if we change the shape of our webhooks, we can simply update this by clicking `u`
// console.log("BODY", body); // console.log("BODY", body);

View File

@ -1 +1 @@
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between Nameless and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Nameless","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","eventTypeId":"[redacted/dynamic]","seatsShowAttendees":false,"uid":"[redacted/dynamic]","videoCallData":"[redacted/dynamic]","appsStatus":"[redacted/dynamic]","eventTitle":"30 min","eventDescription":null,"price":0,"currency":"usd","length":30,"bookingId":"[redacted/dynamic]","metadata":{},"status":"ACCEPTED","additionalInformation":"[redacted/dynamic]"}} {"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between Nameless and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Nameless","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","eventTypeId":"[redacted/dynamic]","seatsShowAttendees":false,"uid":"[redacted/dynamic]","videoCallData":"[redacted/dynamic]","appsStatus":"[redacted/dynamic]","eventTitle":"30 min","eventDescription":null,"price":0,"currency":"usd","length":30,"bookingId":"[redacted/dynamic]","metadata":{"videoCallUrl":"[redacted/dynamic]"},"status":"ACCEPTED","additionalInformation":"[redacted/dynamic]"}}

View File

@ -1426,6 +1426,7 @@
"disable_app": "تعطيل التطبيق", "disable_app": "تعطيل التطبيق",
"disable_app_description": "يمكن لتعطيل هذا التطبيق أن يسبب مشاكل مع كيفية تفاعل المستخدمين مع Cal", "disable_app_description": "يمكن لتعطيل هذا التطبيق أن يسبب مشاكل مع كيفية تفاعل المستخدمين مع Cal",
"edit_keys": "تحرير المفاتيح", "edit_keys": "تحرير المفاتيح",
"admin_apps_description": "تمكين تطبيقات مثيل Cal الخاص بك",
"no_available_apps": "لا توجد تطبيقات متاحة", "no_available_apps": "لا توجد تطبيقات متاحة",
"no_available_apps_description": "الرجاء التأكد من وجود تطبيقات في نشرك تحت 'حزم/متجر تطبيقات'", "no_available_apps_description": "الرجاء التأكد من وجود تطبيقات في نشرك تحت 'حزم/متجر تطبيقات'",
"no_apps": "لا توجد تطبيقات مفعلة في هذا المظهر من Cal", "no_apps": "لا توجد تطبيقات مفعلة في هذا المظهر من Cal",

View File

@ -1337,5 +1337,6 @@
"test_preview": "Vorschau testen", "test_preview": "Vorschau testen",
"route_to": "Weiterleiten zu", "route_to": "Weiterleiten zu",
"test_preview_description": "Testen Sie Ihr Weiterleitungsformular, ohne Daten zu senden", "test_preview_description": "Testen Sie Ihr Weiterleitungsformular, ohne Daten zu senden",
"test_routing": "Testweiterleitung" "test_routing": "Testweiterleitung",
"admin_apps_description": "Hier findest du eine Auflistung deiner Apps"
} }

View File

@ -9,5 +9,103 @@
"reset_password_subject": "{{appName}}: Οδηγίες επαναφοράς κωδικού πρόσβασης", "reset_password_subject": "{{appName}}: Οδηγίες επαναφοράς κωδικού πρόσβασης",
"event_declined_subject": "Απορρίφθηκε: {{eventType}} με {{name}} στις {{date}}", "event_declined_subject": "Απορρίφθηκε: {{eventType}} με {{name}} στις {{date}}",
"event_cancelled_subject": "Ακύρωση: {{eventType}} με {{name}} στις {{date}}", "event_cancelled_subject": "Ακύρωση: {{eventType}} με {{name}} στις {{date}}",
"cancellation_reason": "Λόγος ακύρωσης (προαιρετικό)" "need_to_reschedule_or_cancel": "Χρειάζεται να επαναπρογραμματίσετε ή να ακυρώσετε;",
"cancellation_reason": "Λόγος ακύρωσης (προαιρετικό)",
"rejection_reason": "Λόγος απόρριψης",
"rejection_reason_title": "Απόρριψη του αιτήματος κράτησης;",
"rejection_confirmation": "Απόρριψη κράτησης",
"error_message": "Το μήνυμα σφάλματος ήταν: '{{errorMessage}}'",
"refund_failed_subject": "Η επιστροφή χρημάτων απέτυχε: {{name}} - {{date}} - {{eventType}}",
"refund_failed": "Η επιστροφή χρημάτων για την εκδήλωση {{eventType}} με {{userName}} στις {{date}} απέτυχε.",
"a_refund_failed": "Αποτυχία επιστροφής χρημάτων",
"awaiting_payment_subject": "Σε αναμονή Πληρωμής: {{eventType}} με {{name}} στις {{date}}",
"meeting_awaiting_payment": "Η πληρωμή της συνάντησής σας εκκρεμεί",
"help": "Βοήθεια",
"price": "Τιμή",
"payment": "Πληρωμή",
"missing_card_fields": "Λείπουν τα πεδία της κάρτας",
"pay_now": "Πληρωμή τώρα",
"terms_summary": "Περίληψη όρων",
"open_env": "Ανοίξτε το .env και συμφωνήστε με την Άδεια χρήσης μας",
"env_changed": "Έχω αλλάξει το .env μου",
"accept_license": "Αποδοχή Άδειας",
"no_more_results": "Δεν υπάρχουν άλλα αποτελέσματα",
"no_results": "Δεν υπάρχουν αποτελέσματα",
"load_more_results": "Φόρτωση περισσότερων αποτελεσμάτων",
"integration_meeting_id": "ID συνάντησης {{integrationName}} : {{meetingId}}",
"confirmed_event_type_subject": "Επιβεβαίωση: {{eventType}} με {{name}} στις {{date}}",
"new_event_request": "Νέο αίτημα εκδήλωσης: {{attendeeName}} - {{date}} - {{eventType}}",
"confirm_or_reject_request": "Επιβεβαιώστε ή απορρίψτε το αίτημα",
"check_bookings_page_to_confirm_or_reject": "Ελέγξτε τη σελίδα κρατήσεων για να επιβεβαιώσετε ή να απορρίψετε την κράτηση.",
"event_awaiting_approval": "Ένα γεγονός περιμένει την έγκρισή σας",
"event_type": "Τύπος Συμβάντος",
"meeting_password": "Συνθηματικό Συνάντησης",
"meeting_url": "URL Συνάντησης",
"meeting_request_rejected": "Το αίτημα συνάντησής σας απορρίφθηκε",
"hi": "Γεια",
"manage_this_team": "Διαχείριση αυτής της ομάδας",
"team_info": "Πληροφορίες Ομάδας",
"hidden_team_member_title": "Είστε κρυμμένοι σε αυτήν την ομάδα",
"edit_webhook": "Επεξεργασία Webhook",
"delete_webhook": "Διαγραφή Webhook",
"webhook_enabled": "Ενεργοποιημένο Webhook",
"webhook_disabled": "Απενεργοποιημένο Webhook",
"webhook_response": "Απάντηση Webhook",
"webhook_test": "Έλεγχος Webhook",
"webhook_created_successfully": "Το Webhook δημιουργήθηκε επιτυχώς!",
"webhook_updated_successfully": "Το Webhook ενημερώθηκε επιτυχώς!",
"webhook_removed_successfully": "Το Webhook αφαιρέθηκε επιτυχώς!",
"dismiss": "Παράβλεψη",
"no_data_yet": "Δεν υπάρχουν δεδομένα ακόμη",
"upcoming": "Επερχόμενα",
"recurring": "Επαναλαμβανόμενα",
"past": "Παρελθοντικά",
"choose_a_file": "Επιλογή αρχείου...",
"upload_image": "Μεταφόρτωση εικόνας",
"username": "Όνομα χρήστη",
"is_still_available": "είναι ακόμα διαθέσιμο.",
"documentation": "Τεκμηρίωση",
"blog": "Ιστολόγιο",
"blog_description": "Διαβάστε τα τελευταία μας νέα και άρθρα",
"popular_pages": "Δημοφιλείς σελίδες",
"register_now": "Εγγραφή τώρα",
"register": "Εγγραφή",
"page_doesnt_exist": "Η σελίδα δεν υπάρχει.",
"check_spelling_mistakes_or_go_back": "Έλεγχος για ορθογραφικά λάθη ή επιστροφή στην προηγούμενη σελίδα.",
"404_page_not_found": "404: Η σελίδα δεν βρέθηκε.",
"getting_started": "Ξεκινήστε",
"already_have_an_account": "Έχετε ήδη λογαριασμό;",
"create_account": "Δημιουργία Λογαριασμού",
"confirm_password": "Επιβεβαίωση κωδικού πρόσβασης",
"create_your_account": "Δημιουργία λογαριασμού",
"sign_up": "Εγγραφή",
"youve_been_logged_out": "Έχετε αποσυνδεθεί",
"hope_to_see_you_soon": "Ελπίζουμε να σας ξαναδούμε σύντομα!",
"please_try_again_and_contact_us": "Παρακαλούμε δοκιμάστε ξανά και επικοινωνήστε μαζί μας αν το πρόβλημα παραμένει.",
"no_account_exists": "Δεν υπάρχει λογαριασμός που να ταιριάζει με τη διεύθυνση email.",
"2fa_enter_six_digit_code": "Εισάγετε τον εξαψήφιο κωδικό από την εφαρμογή ελέγχου ταυτότητας παρακάτω.",
"create_an_account": "Δημιουργία λογαριασμού",
"dont_have_an_account": "Δεν έχετε λογαριασμό;",
"sign_in_account": "Συνδεθείτε στο λογαριασμό σας",
"sign_in": "Είσοδος",
"connect": "Σύνδεση",
"try_for_free": "Δοκιμάστε δωρεάν",
"add_to_calendar": "Προσθήκη στο ημερολόγιο",
"add_another_calendar": "Προσθήκη άλλου ημερολογίου",
"meeting_is_scheduled": "Η συνάντηση έχει προγραμματιστεί",
"submitted": "Η κράτησή σας έχει υποβληθεί",
"reset_password": "Επαναφορά Κωδικού Πρόσβασης",
"change_your_password": "Αλλαγή κωδικού πρόσβασης",
"show_password": "Εμφάνιση κωδικού πρόσβασης",
"hide_password": "Απόκρυψη κωδικού πρόσβασης",
"try_again": "Δοκιμάστε ξανά",
"sunday_time_error": "Μη έγκυρη ώρα την Κυριακή",
"monday_time_error": "Μη έγκυρη ώρα τη Δευτέρα",
"tuesday_time_error": "Μη έγκυρη ώρα την Τρίτη",
"wednesday_time_error": "Μη έγκυρη ώρα την Τετάρτη",
"thursday_time_error": "Μη έγκυρη ώρα την Πέμπτη",
"friday_time_error": "Μη έγκυρη ώρα την Παρασκευή",
"saturday_time_error": "Μη έγκυρη ώρα το Σάββατο",
"no_meeting_found": "Δε βρέθηκε συνάντηση",
"bookings": "Κρατήσεις"
} }

View File

@ -920,7 +920,7 @@
"allow_booker_to_select_duration": "Allow booker to select duration", "allow_booker_to_select_duration": "Allow booker to select duration",
"impersonate_user_tip": "All uses of this feature is audited.", "impersonate_user_tip": "All uses of this feature is audited.",
"impersonating_user_warning": "Impersonating username \"{{user}}\".", "impersonating_user_warning": "Impersonating username \"{{user}}\".",
"impersonating_stop_instructions": "<0>Click Here to stop</0>.", "impersonating_stop_instructions": "Click here to stop",
"event_location_changed": "Updated - Your event changed the location", "event_location_changed": "Updated - Your event changed the location",
"location_changed_event_type_subject": "Location Changed: {{eventType}} with {{name}} at {{date}}", "location_changed_event_type_subject": "Location Changed: {{eventType}} with {{name}} at {{date}}",
"current_location": "Current Location", "current_location": "Current Location",
@ -1455,6 +1455,12 @@
"event_type_duplicate_copy_text": "{{slug}}-copy", "event_type_duplicate_copy_text": "{{slug}}-copy",
"set_as_default": "Set as default", "set_as_default": "Set as default",
"hide_eventtype_details": "Hide EventType Details", "hide_eventtype_details": "Hide EventType Details",
"verification_code_sent": "Verification code sent",
"verified_successfully": "Verified successfully",
"wrong_code": "Wong verification code",
"not_verified": "Not yet verified",
"no_availability_in_month": "No availability in {{month}}", "no_availability_in_month": "No availability in {{month}}",
"view_next_month": "View next month" "view_next_month": "View next month",
"send_code" : "Send code",
"number_verified": "Number Verified"
} }

View File

@ -562,6 +562,7 @@
"available_durations": "Durées disponibles", "available_durations": "Durées disponibles",
"default_duration": "Durée par défaut", "default_duration": "Durée par défaut",
"default_duration_no_options": "Veuillez d'abord choisir les durées disponibles", "default_duration_no_options": "Veuillez d'abord choisir les durées disponibles",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"minutes": "Minutes", "minutes": "Minutes",
"round_robin": "Round Robin", "round_robin": "Round Robin",
"round_robin_description": "Faites tourner les réunions entre plusieurs membres de l'équipe.", "round_robin_description": "Faites tourner les réunions entre plusieurs membres de l'équipe.",
@ -627,6 +628,7 @@
"teams": "Équipes", "teams": "Équipes",
"team": "Équipe", "team": "Équipe",
"team_billing": "Facturation d'équipe", "team_billing": "Facturation d'équipe",
"team_billing_description": "Gérer la facturation pour votre équipe",
"upgrade_to_flexible_pro_title": "Nous avons modifié la facturation pour les équipes", "upgrade_to_flexible_pro_title": "Nous avons modifié la facturation pour les équipes",
"upgrade_to_flexible_pro_message": "Des membres dans votre équipe n'ont pas de place. Mettez à niveau votre offre pro pour couvrir les places manquantes.", "upgrade_to_flexible_pro_message": "Des membres dans votre équipe n'ont pas de place. Mettez à niveau votre offre pro pour couvrir les places manquantes.",
"changed_team_billing_info": "Depuis janvier 2022, nous facturons chaque place aux membres de l'équipe. Les membres de votre équipe qui ont eu la version Pro gratuitement disposent maintenant d'un essai de 14 jours. Une fois leur période d'essai expirée, ces membres seront cachés pour votre équipe, sauf si vous mettez à niveau maintenant.", "changed_team_billing_info": "Depuis janvier 2022, nous facturons chaque place aux membres de l'équipe. Les membres de votre équipe qui ont eu la version Pro gratuitement disposent maintenant d'un essai de 14 jours. Une fois leur période d'essai expirée, ces membres seront cachés pour votre équipe, sauf si vous mettez à niveau maintenant.",
@ -702,6 +704,7 @@
"hide_event_type": "Masquer le type d'événement", "hide_event_type": "Masquer le type d'événement",
"edit_location": "Modifier le lieu", "edit_location": "Modifier le lieu",
"into_the_future": "dans le futur", "into_the_future": "dans le futur",
"when_booked_with_less_than_notice": "Si réservé avec moins de <time></time> de préavis",
"within_date_range": "Dans une plage de dates", "within_date_range": "Dans une plage de dates",
"indefinitely_into_future": "Sans doute dans le futur", "indefinitely_into_future": "Sans doute dans le futur",
"add_new_custom_input_field": "Ajouter un nouveau champ de saisie personnalisé", "add_new_custom_input_field": "Ajouter un nouveau champ de saisie personnalisé",
@ -721,6 +724,7 @@
"delete_account_confirmation_message": "Êtes-vous sûr de vouloir supprimer votre compte {{appName}} ? Toute personne avec qui vous avez partagé le lien de votre compte ne pourra plus réserver en utilisant ce lien et toutes les préférences que vous avez enregistrées seront perdues.", "delete_account_confirmation_message": "Êtes-vous sûr de vouloir supprimer votre compte {{appName}} ? Toute personne avec qui vous avez partagé le lien de votre compte ne pourra plus réserver en utilisant ce lien et toutes les préférences que vous avez enregistrées seront perdues.",
"integrations": "Intégrations", "integrations": "Intégrations",
"apps": "Applications", "apps": "Applications",
"apps_listing": "Liste des applications",
"category_apps": "Applications {{category}}", "category_apps": "Applications {{category}}",
"app_store": "App Store", "app_store": "App Store",
"app_store_description": "Connecter les personnes, la technologie et l'espace de travail.", "app_store_description": "Connecter les personnes, la technologie et l'espace de travail.",
@ -744,6 +748,7 @@
"toggle_calendars_conflict": "Activer/désactiver les calendriers pour lesquels vous souhaiter vérifier les conflits afin d'éviter les doubles réservations.", "toggle_calendars_conflict": "Activer/désactiver les calendriers pour lesquels vous souhaiter vérifier les conflits afin d'éviter les doubles réservations.",
"select_destination_calendar": "Créer des événements le", "select_destination_calendar": "Créer des événements le",
"connect_additional_calendar": "Connecter un calendrier supplémentaire", "connect_additional_calendar": "Connecter un calendrier supplémentaire",
"calendar_updated_successfully": "Calendrier mis à jour avec succès",
"conferencing": "Conférence", "conferencing": "Conférence",
"calendar": "Calendrier", "calendar": "Calendrier",
"payments": "Paiements", "payments": "Paiements",
@ -776,6 +781,7 @@
"trending_apps": "Applications populaires", "trending_apps": "Applications populaires",
"explore_apps": "{{category}} applications", "explore_apps": "{{category}} applications",
"installed_apps": "Applications installées", "installed_apps": "Applications installées",
"free_to_use_apps": "Gratuit",
"no_category_apps": "Aucune application {{category}}", "no_category_apps": "Aucune application {{category}}",
"no_category_apps_description_calendar": "Ajouter une application de calendrier pour vérifier les conflits et éviter les doubles réservations", "no_category_apps_description_calendar": "Ajouter une application de calendrier pour vérifier les conflits et éviter les doubles réservations",
"no_category_apps_description_conferencing": "Essayez d'ajouter une application de conférence pour interconnecter les appels vidéo avec vos clients", "no_category_apps_description_conferencing": "Essayez d'ajouter une application de conférence pour interconnecter les appels vidéo avec vos clients",
@ -814,6 +820,8 @@
"verify_wallet": "Vérifier le portefeuille", "verify_wallet": "Vérifier le portefeuille",
"connect_metamask": "Connecter Metamask", "connect_metamask": "Connecter Metamask",
"create_events_on": "Créer des événements le :", "create_events_on": "Créer des événements le :",
"enterprise_license": "Il s'agit d'une fonctionnalité d'entreprise",
"enterprise_license_description": "Pour activer cette fonctionnalité, obtenez une clé de déploiement sur la console {{consoleUrl}} et ajoutez-la à votre .env en tant que CALCOM_LICENSE_KEY. Si votre équipe a déjà une licence, veuillez contacter {{supportMail}} pour obtenir de l'aide.",
"missing_license": "Licence manquante", "missing_license": "Licence manquante",
"signup_requires": "Licence commerciale requise", "signup_requires": "Licence commerciale requise",
"signup_requires_description": "{{companyName}} ne propose pas actuellement de version open source gratuite de la page d'inscription. Pour obtenir un accès complet aux composants d'inscription, vous devez acquérir une licence commerciale. Pour une utilisation personnelle, nous recommandons la Plateforme de Données Prisma ou toute autre interface Postgres pour créer des comptes.", "signup_requires_description": "{{companyName}} ne propose pas actuellement de version open source gratuite de la page d'inscription. Pour obtenir un accès complet aux composants d'inscription, vous devez acquérir une licence commerciale. Pour une utilisation personnelle, nous recommandons la Plateforme de Données Prisma ou toute autre interface Postgres pour créer des comptes.",
@ -905,6 +913,7 @@
"user_impersonation_heading": "Connexion en tant qu'autre utilisateur", "user_impersonation_heading": "Connexion en tant qu'autre utilisateur",
"user_impersonation_description": "Permet à notre équipe d'assistance de se connecter temporairement lorsque vous nous aidez à résoudre rapidement tous les problèmes que vous nous signalez.", "user_impersonation_description": "Permet à notre équipe d'assistance de se connecter temporairement lorsque vous nous aidez à résoudre rapidement tous les problèmes que vous nous signalez.",
"team_impersonation_description": "Permet aux administrateurs de votre équipe de se connecter temporairement en tant que vous-même.", "team_impersonation_description": "Permet aux administrateurs de votre équipe de se connecter temporairement en tant que vous-même.",
"allow_booker_to_select_duration": "Autoriser l'organisateur à sélectionner la durée",
"impersonate_user_tip": "Toutes les utilisations de cette fonctionnalité sont vérifiées.", "impersonate_user_tip": "Toutes les utilisations de cette fonctionnalité sont vérifiées.",
"impersonating_user_warning": "Identification du nom d'utilisateur \"{{user}}\".", "impersonating_user_warning": "Identification du nom d'utilisateur \"{{user}}\".",
"impersonating_stop_instructions": "<0>Cliquez ici pour arrêter</0>.", "impersonating_stop_instructions": "<0>Cliquez ici pour arrêter</0>.",
@ -1022,6 +1031,9 @@
"error_removing_app": "Erreur lors de la suppression de l'application", "error_removing_app": "Erreur lors de la suppression de l'application",
"web_conference": "Conférence en ligne", "web_conference": "Conférence en ligne",
"requires_confirmation": "Nécessite une confirmation", "requires_confirmation": "Nécessite une confirmation",
"always_requires_confirmation": "Toujours",
"requires_confirmation_threshold": "Nécessite une confirmation si réservé avec un préavis de < {{time}} $t({{unit}}_timeUnit)",
"may_require_confirmation": "Peut nécessiter une confirmation",
"nr_event_type_one": "{{count}} type d'événement", "nr_event_type_one": "{{count}} type d'événement",
"nr_event_type_other": "{{count}} types d'événements", "nr_event_type_other": "{{count}} types d'événements",
"add_action": "Ajouter une action", "add_action": "Ajouter une action",
@ -1109,6 +1121,9 @@
"event_limit_tab_description": "Fréquence de réservation", "event_limit_tab_description": "Fréquence de réservation",
"event_advanced_tab_description": "Paramètres du calendrier & plus...", "event_advanced_tab_description": "Paramètres du calendrier & plus...",
"event_advanced_tab_title": "Avancé", "event_advanced_tab_title": "Avancé",
"event_setup_multiple_duration_error": "Configuration de l'événement : plusieurs durées requièrent au moins une option.",
"event_setup_multiple_duration_default_error": "Configuration de l'événement: veuillez sélectionner une durée par défaut valide.",
"event_setup_booking_limits_error": "Les limites de réservation doivent être en ordre croissant. [jour,semaine,mois,année]",
"select_which_cal": "Sélectionnez le calendrier auquel ajouter des réservations", "select_which_cal": "Sélectionnez le calendrier auquel ajouter des réservations",
"custom_event_name": "Nom de l'événement personnalisé", "custom_event_name": "Nom de l'événement personnalisé",
"custom_event_name_description": "Créer des noms d'événements personnalisés à afficher sur l'événement du calendrier", "custom_event_name_description": "Créer des noms d'événements personnalisés à afficher sur l'événement du calendrier",
@ -1162,8 +1177,11 @@
"invoices": "Factures", "invoices": "Factures",
"embeds": "Intègre", "embeds": "Intègre",
"impersonation": "Identification", "impersonation": "Identification",
"impersonation_description": "Paramètres de gestion de l'identité de l'utilisateur",
"users": "Utilisateurs", "users": "Utilisateurs",
"profile_description": "Gérer les paramètres de votre profil {{appName}}", "profile_description": "Gérer les paramètres de votre profil {{appName}}",
"users_description": "Vous trouverez ici une liste de tous les utilisateurs",
"users_listing": "Liste des utilisateurs",
"general_description": "Gérer les paramètres pour votre langue et votre fuseau horaire", "general_description": "Gérer les paramètres pour votre langue et votre fuseau horaire",
"calendars_description": "Configurez la manière dont vos types d'événements interagissent avec vos calendriers", "calendars_description": "Configurez la manière dont vos types d'événements interagissent avec vos calendriers",
"appearance_description": "Gérer les paramètres pour votre apparence de réservation", "appearance_description": "Gérer les paramètres pour votre apparence de réservation",
@ -1368,6 +1386,7 @@
"number_sms_notifications": "Numéro de téléphone (notifications SMS)", "number_sms_notifications": "Numéro de téléphone (notifications SMS)",
"attendee_email_workflow": "E-mail du participant", "attendee_email_workflow": "E-mail du participant",
"attendee_email_info": "E-mail de la personne ayant réservé", "attendee_email_info": "E-mail de la personne ayant réservé",
"kbar_search_placeholder": "Saisissez une commande ou une recherche...",
"invalid_credential": "Oh non ! L'autorisation semble avoir expiré ou avoir été révoquée. Veuillez la réinstaller.", "invalid_credential": "Oh non ! L'autorisation semble avoir expiré ou avoir été révoquée. Veuillez la réinstaller.",
"choose_common_schedule_team_event": "Choisissez un horaire commun", "choose_common_schedule_team_event": "Choisissez un horaire commun",
"choose_common_schedule_team_event_description": "Activez cette option si vous souhaitez utiliser un horaire commun entre les hôtes. Si désactivée, chaque hôte sera réservé en fonction de son planning par défaut.", "choose_common_schedule_team_event_description": "Activez cette option si vous souhaitez utiliser un horaire commun entre les hôtes. Si désactivée, chaque hôte sera réservé en fonction de son planning par défaut.",
@ -1378,5 +1397,8 @@
"test_preview": "Tester l'aperçu", "test_preview": "Tester l'aperçu",
"route_to": "Router vers", "route_to": "Router vers",
"test_preview_description": "Tester votre formulaire de routage sans envoyer de données", "test_preview_description": "Tester votre formulaire de routage sans envoyer de données",
"test_routing": "Tester le routage" "test_routing": "Tester le routage",
"payment_app_disabled": "Un administrateur a désactivé une application de paiement",
"edit_event_type": "Modifier le type d'événement",
"admin_apps_description": "Activer les applications pour votre instance de Cal"
} }

View File

@ -1426,6 +1426,7 @@
"disable_app": "Disabilita app", "disable_app": "Disabilita app",
"disable_app_description": "La disattivazione di questa app potrebbe causare problemi con il modo in cui i tuoi utenti interagiscono con Cal", "disable_app_description": "La disattivazione di questa app potrebbe causare problemi con il modo in cui i tuoi utenti interagiscono con Cal",
"edit_keys": "Modifica chiavi", "edit_keys": "Modifica chiavi",
"admin_apps_description": "Abilita le app per la tua istanza di Cal",
"no_available_apps": "Nessuna app disponibile", "no_available_apps": "Nessuna app disponibile",
"no_available_apps_description": "Assicurarsi che ci siano delle applicazioni nella propria distribuzione in 'packages/app-store'", "no_available_apps_description": "Assicurarsi che ci siano delle applicazioni nella propria distribuzione in 'packages/app-store'",
"no_apps": "Non ci sono applicazioni abilitate in questa istanza di Cal", "no_apps": "Non ci sono applicazioni abilitate in questa istanza di Cal",

View File

@ -1426,6 +1426,7 @@
"disable_app": "앱 비활성화", "disable_app": "앱 비활성화",
"disable_app_description": "이 앱을 비활성화하면 사용자가 Cal과 상호 작용하는 방식에 문제가 발생할 수 있습니다", "disable_app_description": "이 앱을 비활성화하면 사용자가 Cal과 상호 작용하는 방식에 문제가 발생할 수 있습니다",
"edit_keys": "키 편집", "edit_keys": "키 편집",
"admin_apps_description": "Cal 인스턴스용 앱 활성화",
"no_available_apps": "사용 가능한 앱이 없습니다", "no_available_apps": "사용 가능한 앱이 없습니다",
"no_available_apps_description": "'packages/app-store' 아래 배포에 앱이 있는지 확인하세요", "no_available_apps_description": "'packages/app-store' 아래 배포에 앱이 있는지 확인하세요",
"no_apps": "Cal의 이 인스턴스에서 활성화된 앱이 없습니다", "no_apps": "Cal의 이 인스턴스에서 활성화된 앱이 없습니다",

View File

@ -1426,6 +1426,7 @@
"disable_app": "Desativar a aplicação", "disable_app": "Desativar a aplicação",
"disable_app_description": "A desativação desta aplicação pode causar problemas na forma como os seus utilizadores interagem com o Cal", "disable_app_description": "A desativação desta aplicação pode causar problemas na forma como os seus utilizadores interagem com o Cal",
"edit_keys": "Editar chaves", "edit_keys": "Editar chaves",
"admin_apps_description": "Ativar aplicações para a sua instância do Cal",
"no_available_apps": "Não existem aplicações disponíveis", "no_available_apps": "Não existem aplicações disponíveis",
"no_available_apps_description": "Por favor, certifique-se que existem aplicações na sua instalação em 'packages/app-store'", "no_available_apps_description": "Por favor, certifique-se que existem aplicações na sua instalação em 'packages/app-store'",
"no_apps": "Não existem aplicações ativas nesta instância do Cal", "no_apps": "Não existem aplicações ativas nesta instância do Cal",

View File

@ -1426,6 +1426,7 @@
"disable_app": "Onemogući aplikaciju", "disable_app": "Onemogući aplikaciju",
"disable_app_description": "Onemogućavanje ove aplikacije može da stvori probleme u komunikaciji vaših korisnika sa Cal-om", "disable_app_description": "Onemogućavanje ove aplikacije može da stvori probleme u komunikaciji vaših korisnika sa Cal-om",
"edit_keys": "Izmeni ključeve", "edit_keys": "Izmeni ključeve",
"admin_apps_description": "Omogućite aplikacije za vašu instancu Cal-a",
"no_available_apps": "Nema dostupnih aplikacija", "no_available_apps": "Nema dostupnih aplikacija",
"no_available_apps_description": "Uverite se da ima aplikacija u vašoj fascikli za instalaciju pod „packages/app-store“", "no_available_apps_description": "Uverite se da ima aplikacija u vašoj fascikli za instalaciju pod „packages/app-store“",
"no_apps": "Nema omogućenih aplikacija u ovoj instanci Cal-a", "no_apps": "Nema omogućenih aplikacija u ovoj instanci Cal-a",

View File

@ -1426,6 +1426,7 @@
"disable_app": "Inaktivera app", "disable_app": "Inaktivera app",
"disable_app_description": "Inaktivering av den här appen kan orsaka problem med hur dina användare interagerar med Cal", "disable_app_description": "Inaktivering av den här appen kan orsaka problem med hur dina användare interagerar med Cal",
"edit_keys": "Redigera nycklar", "edit_keys": "Redigera nycklar",
"admin_apps_description": "Aktivera appar för din Cal-version",
"no_available_apps": "Det finns inga tillgängliga appar", "no_available_apps": "Det finns inga tillgängliga appar",
"no_available_apps_description": "Se till att det finns appar i din distribution under \"paket/app-store\"", "no_available_apps_description": "Se till att det finns appar i din distribution under \"paket/app-store\"",
"no_apps": "Det finns inga appar aktiverade i denna instans av Cal", "no_apps": "Det finns inga appar aktiverade i denna instans av Cal",

View File

@ -704,6 +704,7 @@
"hide_event_type": "Etkinlik türünü gizle", "hide_event_type": "Etkinlik türünü gizle",
"edit_location": "Konumu düzenle", "edit_location": "Konumu düzenle",
"into_the_future": "gelecekte", "into_the_future": "gelecekte",
"when_booked_with_less_than_notice": "<time></time> bildiriminden daha az süre ile rezervasyon yapıldığında",
"within_date_range": "Bir tarih aralığında", "within_date_range": "Bir tarih aralığında",
"indefinitely_into_future": "Süresiz olarak gelecekte", "indefinitely_into_future": "Süresiz olarak gelecekte",
"add_new_custom_input_field": "Yeni özel veri girdi alanı ekle", "add_new_custom_input_field": "Yeni özel veri girdi alanı ekle",
@ -1032,6 +1033,7 @@
"web_conference": "Web konferansı", "web_conference": "Web konferansı",
"requires_confirmation": "Onay gerekli", "requires_confirmation": "Onay gerekli",
"always_requires_confirmation": "Her zaman", "always_requires_confirmation": "Her zaman",
"requires_confirmation_threshold": "{{time}} $t({{unit}}_timeUnit) bildiriminden daha az süre ile rezervasyon yapıldığında onay gerekir",
"may_require_confirmation": "Onay gerekebilir", "may_require_confirmation": "Onay gerekebilir",
"nr_event_type_one": "{{count}} etkinlik türü", "nr_event_type_one": "{{count}} etkinlik türü",
"nr_event_type_other": "{{count}} etkinlik türü", "nr_event_type_other": "{{count}} etkinlik türü",
@ -1400,11 +1402,19 @@
"payment_app_disabled": "Bir yönetici, ödeme uygulamasını devre dışı bıraktı", "payment_app_disabled": "Bir yönetici, ödeme uygulamasını devre dışı bıraktı",
"edit_event_type": "Etkinlik türünü düzenle", "edit_event_type": "Etkinlik türünü düzenle",
"collective_scheduling": "Toplu Planlama", "collective_scheduling": "Toplu Planlama",
"make_it_easy_to_book": "Herkes müsait olduğunda ekibinizin rezervasyon yapmasını kolaylaştırın.",
"find_the_best_person": "Mevcut en iyi kişiyi bulun ve ekip üyeleriniz arasında rotasyon yapın.",
"fixed_round_robin": "Sabit döngü",
"add_one_fixed_attendee": "Tek bir sabit katılımcı ve birden fazla katılımcının olduğu bir döngü ekleyin.",
"calcom_is_better_with_team": "Cal.com ekiplerle daha iyidir", "calcom_is_better_with_team": "Cal.com ekiplerle daha iyidir",
"add_your_team_members": "Ekip üyelerinizi etkinlik türlerinize ekleyin. Herkesi eklemek için toplu planlamayı kullanın veya döngüsel planlama ile en uygun kişiyi bulun.", "add_your_team_members": "Ekip üyelerinizi etkinlik türlerinize ekleyin. Herkesi eklemek için toplu planlamayı kullanın veya döngüsel planlama ile en uygun kişiyi bulun.",
"booking_limit_reached": "Bu etkinlik türü için Rezervasyon Sınırına ulaşıldı", "booking_limit_reached": "Bu etkinlik türü için Rezervasyon Sınırına ulaşıldı",
"admin_has_disabled": "Bir yönetici {{appName}} uygulamasını devre dışı bıraktı", "admin_has_disabled": "Bir yönetici {{appName}} uygulamasını devre dışı bıraktı",
"disabled_app_affects_event_type": "Bir yönetici {{eventType}} etkinlik türünüzü etkileyen {{appName}} uygulamasını devre dışı bıraktı",
"disable_payment_app": "Yönetici, {{title}} etkinlik türünüzü etkileyebilecek {{appName}} uygulamasını devre dışı bıraktı. Katılımcılar yine de bu tür bir etkinlik için rezervasyon yaptırabilirler ancak herhangi bir ödeme yapmalarına gerek yoktur. Bu durumu önlemek için yöneticiniz ödeme yönteminizi yeniden etkinleştirene kadar etkinlik türünü gizleyebilirsiniz.",
"payment_disabled_still_able_to_book": "Katılımcılar yine de bu tür bir etkinlik için rezervasyon yaptırabilirler ancak herhangi bir ödeme yapmalarına gerek yoktur. Bu durumu önlemek için yöneticiniz ödeme yönteminizi yeniden etkinleştirene kadar etkinlik türünü gizleyebilirsiniz.",
"app_disabled_with_event_type": "Yönetici, etkinlik {{title}} türünüzü etkileyen {{appName}} uygulamasını devre dışı bıraktı.", "app_disabled_with_event_type": "Yönetici, etkinlik {{title}} türünüzü etkileyen {{appName}} uygulamasını devre dışı bıraktı.",
"app_disabled_video": "Yönetici, etkinlik türlerinizi etkileyebilecek {{appName}} uygulamasını devre dışı bıraktı. Konum olarak {{appName}} olan etkinlik türleriniz varsa varsayılan uygulama Cal Video olacaktır.",
"app_disabled_subject": "{{appName}} devre dışı bırakıldı", "app_disabled_subject": "{{appName}} devre dışı bırakıldı",
"navigate_installed_apps": "Yüklü uygulamalara git", "navigate_installed_apps": "Yüklü uygulamalara git",
"disabled_calendar": "Yüklü başka bir takviminiz varsa yeni randevular bu takvime eklenecektir. Aksi takdirde yeni bir takvim bağlamazsanız yeni rezervasyonları kaçırabilirsiniz.", "disabled_calendar": "Yüklü başka bir takviminiz varsa yeni randevular bu takvime eklenecektir. Aksi takdirde yeni bir takvim bağlamazsanız yeni rezervasyonları kaçırabilirsiniz.",
@ -1414,13 +1424,18 @@
"app_is_disabled": "{{appName}} devre dışı bırakıldı", "app_is_disabled": "{{appName}} devre dışı bırakıldı",
"keys_have_been_saved": "Anahtarlar kaydedildi", "keys_have_been_saved": "Anahtarlar kaydedildi",
"disable_app": "Uygulamaları Devre Dışı Bırak", "disable_app": "Uygulamaları Devre Dışı Bırak",
"disable_app_description": "Bu uygulamanın devre dışı bırakılması kullanıcılarınızın Cal ile etkileşimde bulunmaları konusunda sorunlara neden olabilir",
"edit_keys": "Anahtarları Düzenle", "edit_keys": "Anahtarları Düzenle",
"admin_apps_description": "Cal örneğiniz için uygulamayı etkinleştirin",
"no_available_apps": "Kullanılabilir uygulama yok", "no_available_apps": "Kullanılabilir uygulama yok",
"no_available_apps_description": "'Paketler/uygulama mağazası' altındaki dağıtım klasörünüzde uygulamaların olduğundan emin olun",
"no_apps": "Bu Cal örneğindeki hiçbir uygulama etkin değil",
"apps_settings": "Uygulama ayarları", "apps_settings": "Uygulama ayarları",
"fill_this_field": "Lütfen bu alanı doldurun", "fill_this_field": "Lütfen bu alanı doldurun",
"options": "Seçenekler", "options": "Seçenekler",
"enter_option": "{{index}} Seçeneğini Girin", "enter_option": "{{index}} Seçeneğini Girin",
"add_an_option": "Bir seçenek ekle", "add_an_option": "Bir seçenek ekle",
"radio": "Radio", "radio": "Radio",
"event_type_duplicate_copy_text": "{{slug}}-kopyala",
"set_as_default": "Varsayılan olarak ayarla" "set_as_default": "Varsayılan olarak ayarla"
} }

View File

@ -561,6 +561,8 @@
"duration": "Тривалість", "duration": "Тривалість",
"available_durations": "Доступні варіанти тривалості", "available_durations": "Доступні варіанти тривалості",
"default_duration": "Тривалість за замовчуванням", "default_duration": "Тривалість за замовчуванням",
"default_duration_no_options": "Спочатку виберіть доступні варіанти тривалості",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"minutes": "Хвилини", "minutes": "Хвилини",
"round_robin": "Ротація", "round_robin": "Ротація",
"round_robin_description": "Кілька учасників команди призначаються для нарад циклічно й по черзі.", "round_robin_description": "Кілька учасників команди призначаються для нарад циклічно й по черзі.",
@ -626,6 +628,7 @@
"teams": "Команди", "teams": "Команди",
"team": "Команда", "team": "Команда",
"team_billing": "Виставлення рахунків для команд", "team_billing": "Виставлення рахунків для команд",
"team_billing_description": "Керуйте виставленням рахунків у своїй команді",
"upgrade_to_flexible_pro_title": "Ми змінили умови оплати для команд", "upgrade_to_flexible_pro_title": "Ми змінили умови оплати для команд",
"upgrade_to_flexible_pro_message": "У вашій команді є учасники без придбаних місць. Перейдіть на план Pro, щоб отримати всі потрібні місця.", "upgrade_to_flexible_pro_message": "У вашій команді є учасники без придбаних місць. Перейдіть на план Pro, щоб отримати всі потрібні місця.",
"changed_team_billing_info": "Станом на січень 2022 року оплата з учасників команди стягується за кількістю місць. Учасників вашої команди, які безкоштовно користувалися функціями Pro, тепер переведено на 14-денні пробні версії. Щойно пробний період завершиться, учасників вашої команди, для яких не придбано план Pro, буде приховано.", "changed_team_billing_info": "Станом на січень 2022 року оплата з учасників команди стягується за кількістю місць. Учасників вашої команди, які безкоштовно користувалися функціями Pro, тепер переведено на 14-денні пробні версії. Щойно пробний період завершиться, учасників вашої команди, для яких не придбано план Pro, буде приховано.",
@ -701,6 +704,7 @@
"hide_event_type": "Приховати тип заходу", "hide_event_type": "Приховати тип заходу",
"edit_location": "Змінити розташування", "edit_location": "Змінити розташування",
"into_the_future": "у майбутньому", "into_the_future": "у майбутньому",
"when_booked_with_less_than_notice": "Якщо бронювання створено менше ніж за <time></time> до заходу",
"within_date_range": "У діапазоні дат", "within_date_range": "У діапазоні дат",
"indefinitely_into_future": "Колись у майбутньому", "indefinitely_into_future": "Колись у майбутньому",
"add_new_custom_input_field": "Додати нове власне поле введення", "add_new_custom_input_field": "Додати нове власне поле введення",
@ -720,6 +724,7 @@
"delete_account_confirmation_message": "Справді видалити обліковий запис {{appName}}? Усі, кому ви надавали посилання на свій обліковий запис, більше не зможуть бронювати ваш час за його допомогою. Усі збережені налаштування буде втрачено.", "delete_account_confirmation_message": "Справді видалити обліковий запис {{appName}}? Усі, кому ви надавали посилання на свій обліковий запис, більше не зможуть бронювати ваш час за його допомогою. Усі збережені налаштування буде втрачено.",
"integrations": "Інтеграції", "integrations": "Інтеграції",
"apps": "Додатки", "apps": "Додатки",
"apps_listing": "Список додатків",
"category_apps": "Додатки з категорії «{{category}}»", "category_apps": "Додатки з категорії «{{category}}»",
"app_store": "App Store", "app_store": "App Store",
"app_store_description": "Спілкування та технології на робочому місці.", "app_store_description": "Спілкування та технології на робочому місці.",
@ -743,6 +748,7 @@
"toggle_calendars_conflict": "Увімкніть ті календарі, які потрібно перевірити на наявність конфліктів, щоб уникнути подвійних бронювань.", "toggle_calendars_conflict": "Увімкніть ті календарі, які потрібно перевірити на наявність конфліктів, щоб уникнути подвійних бронювань.",
"select_destination_calendar": "Створюйте заходи в календарі", "select_destination_calendar": "Створюйте заходи в календарі",
"connect_additional_calendar": "Підключити додатковий календар", "connect_additional_calendar": "Підключити додатковий календар",
"calendar_updated_successfully": "Календар оновлено",
"conferencing": "Відеоконференції", "conferencing": "Відеоконференції",
"calendar": "Календар", "calendar": "Календар",
"payments": "Платежі", "payments": "Платежі",
@ -775,6 +781,7 @@
"trending_apps": "Популярні додатки", "trending_apps": "Популярні додатки",
"explore_apps": "Додатки з категорії «{{category}}»", "explore_apps": "Додатки з категорії «{{category}}»",
"installed_apps": "Установлені додатки", "installed_apps": "Установлені додатки",
"free_to_use_apps": "Безкоштовні",
"no_category_apps": "{{category}} — немає додатків", "no_category_apps": "{{category}} — немає додатків",
"no_category_apps_description_calendar": "Додайте додаток для календаря, щоб перевіряти, чи немає конфліктів у розкладі, і уникати подвійних бронювань", "no_category_apps_description_calendar": "Додайте додаток для календаря, щоб перевіряти, чи немає конфліктів у розкладі, і уникати подвійних бронювань",
"no_category_apps_description_conferencing": "Спробуйте додати додаток для конференцій, щоб інтегрувати можливість відеорозмов зі своїми клієнтами", "no_category_apps_description_conferencing": "Спробуйте додати додаток для конференцій, щоб інтегрувати можливість відеорозмов зі своїми клієнтами",
@ -813,6 +820,8 @@
"verify_wallet": "Пройдіть перевірку гаманця", "verify_wallet": "Пройдіть перевірку гаманця",
"connect_metamask": "Підключіть Metamask", "connect_metamask": "Підключіть Metamask",
"create_events_on": "Створюйте заходи в календарі", "create_events_on": "Створюйте заходи в календарі",
"enterprise_license": "Це корпоративна функція",
"enterprise_license_description": "Щоб увімкнути цю функцію, отримайте ключ розгортання в консолі {{consoleUrl}} і додайте його у свій файл .env як CALCOM_LICENSE_KEY. Якщо у вашої команди вже є ліцензія, зверніться по допомогу за адресою {{supportMail}}.",
"missing_license": "Відсутня ліцензія", "missing_license": "Відсутня ліцензія",
"signup_requires": "Потрібна комерційна ліцензія", "signup_requires": "Потрібна комерційна ліцензія",
"signup_requires_description": "{{companyName}} зараз не надає безкоштовну версію сторінки реєстрації з відкритим кодом. Щоб отримати повний доступ до складових функціоналу реєстрації, потрібно придбати комерційну ліцензію. Для особистого використання та створення облікових записів радимо Prisma Data Platform або будь-який інший інтерфейс Postgres.", "signup_requires_description": "{{companyName}} зараз не надає безкоштовну версію сторінки реєстрації з відкритим кодом. Щоб отримати повний доступ до складових функціоналу реєстрації, потрібно придбати комерційну ліцензію. Для особистого використання та створення облікових записів радимо Prisma Data Platform або будь-який інший інтерфейс Postgres.",
@ -904,6 +913,7 @@
"user_impersonation_heading": "Виконання ролі користувача", "user_impersonation_heading": "Виконання ролі користувача",
"user_impersonation_description": "Ви можете дозволити нашій команді підтримки тимчасово входити в систему під вашим іменем, щоб швидко вирішувати проблеми, про які ви повідомляєте.", "user_impersonation_description": "Ви можете дозволити нашій команді підтримки тимчасово входити в систему під вашим іменем, щоб швидко вирішувати проблеми, про які ви повідомляєте.",
"team_impersonation_description": "Дозвольте адміністраторам своєї команди тимчасово входити під вашим іменем.", "team_impersonation_description": "Дозвольте адміністраторам своєї команди тимчасово входити під вашим іменем.",
"allow_booker_to_select_duration": "Дозволити автору бронювання вибирати тривалість",
"impersonate_user_tip": "Усі випадки використання цієї функції підпадають під аудит.", "impersonate_user_tip": "Усі випадки використання цієї функції підпадають під аудит.",
"impersonating_user_warning": "Виконується роль користувача {{user}}.", "impersonating_user_warning": "Виконується роль користувача {{user}}.",
"impersonating_stop_instructions": "<0>Натисніть тут, щоб зупинити</0>.", "impersonating_stop_instructions": "<0>Натисніть тут, щоб зупинити</0>.",
@ -1021,6 +1031,9 @@
"error_removing_app": "Не вдалося вилучити додаток", "error_removing_app": "Не вдалося вилучити додаток",
"web_conference": "Вебконференція", "web_conference": "Вебконференція",
"requires_confirmation": "Потрібне підтвердження", "requires_confirmation": "Потрібне підтвердження",
"always_requires_confirmation": "Завжди",
"requires_confirmation_threshold": "Вимагати підтвердження, якщо бронювання створено менше ніж за {{time}} $t({{unit}}_timeUnit) до заходу",
"may_require_confirmation": "Може вимагати підтвердження",
"nr_event_type_one": "{{count}} тип заходу", "nr_event_type_one": "{{count}} тип заходу",
"nr_event_type_other": "Типів заходів: {{count}}", "nr_event_type_other": "Типів заходів: {{count}}",
"add_action": "Додати дію", "add_action": "Додати дію",
@ -1108,6 +1121,9 @@
"event_limit_tab_description": "Як часто ваш час можуть бронювати", "event_limit_tab_description": "Як часто ваш час можуть бронювати",
"event_advanced_tab_description": "Налаштування календаря та інше…", "event_advanced_tab_description": "Налаштування календаря та інше…",
"event_advanced_tab_title": "Додатково", "event_advanced_tab_title": "Додатково",
"event_setup_multiple_duration_error": "Налаштування заходу: якщо мають бути доступні різні варіанти, потрібно вказати принаймні один.",
"event_setup_multiple_duration_default_error": "Налаштування заходу: виберіть припустиму тривалість за замовчуванням.",
"event_setup_booking_limits_error": "Ліміти на бронювання мають бути впорядковані за зростанням. [день,тиждень,місяць,рік]",
"select_which_cal": "Виберіть календар, у який додаватимуться бронювання", "select_which_cal": "Виберіть календар, у який додаватимуться бронювання",
"custom_event_name": "Користувацька назва події", "custom_event_name": "Користувацька назва події",
"custom_event_name_description": "Вибирайте для заходів власні назви, що показуватимуться в календарі", "custom_event_name_description": "Вибирайте для заходів власні назви, що показуватимуться в календарі",
@ -1161,8 +1177,11 @@
"invoices": "Рахунки-фактури", "invoices": "Рахунки-фактури",
"embeds": "Вставки", "embeds": "Вставки",
"impersonation": "Вхід під іншим іменем", "impersonation": "Вхід під іншим іменем",
"impersonation_description": "Налаштування входу під іншим іменем",
"users": "Користувачі", "users": "Користувачі",
"profile_description": "Керуйте налаштуваннями свого профілю {{appName}}", "profile_description": "Керуйте налаштуваннями свого профілю {{appName}}",
"users_description": "Тут наведено список усіх користувачів",
"users_listing": "Список користувачів",
"general_description": "Налаштуйте параметри мови й часового поясу", "general_description": "Налаштуйте параметри мови й часового поясу",
"calendars_description": "Налаштуйте, як типи заходів мають взаємодіяти з вашими календарями", "calendars_description": "Налаштуйте, як типи заходів мають взаємодіяти з вашими календарями",
"appearance_description": "Налаштуйте варіанти оформлення свого бронювання", "appearance_description": "Налаштуйте варіанти оформлення свого бронювання",
@ -1367,6 +1386,7 @@
"number_sms_notifications": "Номер телефону (SMS-сповіщення)", "number_sms_notifications": "Номер телефону (SMS-сповіщення)",
"attendee_email_workflow": "Адреса ел. пошти учасника", "attendee_email_workflow": "Адреса ел. пошти учасника",
"attendee_email_info": "Адреса ел. пошти особи, яка бронює", "attendee_email_info": "Адреса ел. пошти особи, яка бронює",
"kbar_search_placeholder": "Введіть команду або пошуковий запис…",
"invalid_credential": "Отакої! Схоже, дозвіл більше не дійсний або його відкликано. Перевстановіть додаток знову.", "invalid_credential": "Отакої! Схоже, дозвіл більше не дійсний або його відкликано. Перевстановіть додаток знову.",
"choose_common_schedule_team_event": "Виберіть спільний розклад", "choose_common_schedule_team_event": "Виберіть спільний розклад",
"choose_common_schedule_team_event_description": "Увімкніть цей параметр, щоб використовувати спільний для двох ведучих розклад. Якщо цей параметр вимкнено, бронювання для кожного з ведучих відбуватиметься за їхніми власними графіками.", "choose_common_schedule_team_event_description": "Увімкніть цей параметр, щоб використовувати спільний для двох ведучих розклад. Якщо цей параметр вимкнено, бронювання для кожного з ведучих відбуватиметься за їхніми власними графіками.",
@ -1377,5 +1397,43 @@
"test_preview": "Перевірити попередній перегляд", "test_preview": "Перевірити попередній перегляд",
"route_to": "Кінцева точка", "route_to": "Кінцева точка",
"test_preview_description": "Перевірте свою форму переспрямування без надсилання даних", "test_preview_description": "Перевірте свою форму переспрямування без надсилання даних",
"test_routing": "Перевірка переспрямування" "test_routing": "Перевірка переспрямування",
"payment_app_disabled": "Додаток для оплати вимкнув адміністратор",
"edit_event_type": "Редагувати тип заходу",
"collective_scheduling": "Колективне планування",
"make_it_easy_to_book": "Просте бронювання у випадках, коли всі члени вашої команди доступні.",
"find_the_best_person": "Пошук найкращого доступного члена команди та циклічна ротація між ними.",
"fixed_round_robin": "Фіксована циклічна ротація",
"add_one_fixed_attendee": "Додавання одного фіксованого учасника та циклічна ротація між кількома учасниками.",
"calcom_is_better_with_team": "З Cal.com краще працювати в командах",
"add_your_team_members": "Додавайте членів своєї команди в типи заходів. Колективне планування дає змогу включати всіх або знаходити найвідповіднішого члена завдяки циклічній ротації.",
"booking_limit_reached": "Для цього типу заходу досягнуто ліміт бронювання",
"admin_has_disabled": "Адміністратор вимкнув {{appName}}",
"disabled_app_affects_event_type": "Адміністратор вимкнув {{appName}}, що впливає на ваш тип заходу «{{eventType}}»",
"disable_payment_app": "Адміністратор вимкнув {{appName}}, що впливає на ваш тип заходу «{{title}}». Учасники все одно можуть бронювати події такого типу, але від них не вимагатиметься оплата. Ви можете приховати цей тип заходу, щоб цього не ставалося, та дочекатися на активацію способу оплати з боку адміністратора.",
"payment_disabled_still_able_to_book": "Учасники все одно можуть бронювати події такого типу, але від них не вимагатиметься оплата. Ви можете приховати цей тип заходу, щоб цього не ставалося, та дочекатися на активацію способу оплати з боку адміністратора.",
"app_disabled_with_event_type": "Адміністратор вимкнув {{appName}}, що впливає на ваш тип заходу «{{title}}».",
"app_disabled_video": "Адміністратор вимкнув {{appName}}, що впливає на ваші типи заходів. Якщо у вас є типи заходів, де {{appName}} визначає розташування, за замовчуванням використовуватиметься Cal Video.",
"app_disabled_subject": "{{appName}} вимкнено",
"navigate_installed_apps": "Перейти до встановлених додатків",
"disabled_calendar": "Якщо ви встановите інший календар, у нього буде додано нові бронювання. Якщо цього не станеться, під’єднайте новий календар, щоб не пропускати нові бронювання.",
"enable_apps": "Увімкнення додатків",
"enable_apps_description": "Активуйте додатки, які користувачі зможуть інтегрувати з Cal.com",
"app_is_enabled": "{{appName}} увімкнено",
"app_is_disabled": "{{appName}} вимкнено",
"keys_have_been_saved": "Ключі збережено",
"disable_app": "Вимкнути додаток",
"disable_app_description": "Якщо вимкнути цей додаток, у користувачів можуть виникнути проблеми із роботою з Cal",
"edit_keys": "Редагувати ключі",
"no_available_apps": "Немає доступних додатків",
"no_available_apps_description": "Перевірте, чи в «packages/app-store» є додатки для розгортання",
"no_apps": "У цьому екземплярі Cal немає ввімкнених додатків",
"apps_settings": "Налаштування додатків",
"fill_this_field": "Заповніть це поле",
"options": "Варіанти",
"enter_option": "Введіть варіант {{index}}",
"add_an_option": "Додайте варіант",
"radio": "Радіо",
"event_type_duplicate_copy_text": "{{slug}}-копія",
"set_as_default": "Встановити за замовчуванням"
} }

View File

@ -559,6 +559,10 @@
"collective": "Tập thể", "collective": "Tập thể",
"collective_description": "Lên lịch họp khi tất cả các thành viên trong nhóm đã chọn đều có mặt.", "collective_description": "Lên lịch họp khi tất cả các thành viên trong nhóm đã chọn đều có mặt.",
"duration": "Khoảng thời gian", "duration": "Khoảng thời gian",
"available_durations": "Khoảng thời gian khả dụng",
"default_duration": "Khoảng thời gian mặc định",
"default_duration_no_options": "Vui lòng chọn trước tiên những khoảng thời gian khả dụng",
"multiple_duration_mins": "{{count}}$t(minute_timeUnit)",
"minutes": "Phút", "minutes": "Phút",
"round_robin": "Round Robin", "round_robin": "Round Robin",
"round_robin_description": "Luân phiên những cuộc họp giữa các thành viên trong nhóm.", "round_robin_description": "Luân phiên những cuộc họp giữa các thành viên trong nhóm.",
@ -624,6 +628,7 @@
"teams": "Các nhóm", "teams": "Các nhóm",
"team": "Nhóm", "team": "Nhóm",
"team_billing": "Thanh toán nhóm", "team_billing": "Thanh toán nhóm",
"team_billing_description": "Quản lí thanh toán cho đội ngũ của bạn",
"upgrade_to_flexible_pro_title": "Chúng tôi đã thay đổi thanh toán cho các nhóm", "upgrade_to_flexible_pro_title": "Chúng tôi đã thay đổi thanh toán cho các nhóm",
"upgrade_to_flexible_pro_message": "Có những thành viên trong nhóm của bạn không có gói. Nâng cấp gói PRO của bạn để trang trải những gói bị thiếu.", "upgrade_to_flexible_pro_message": "Có những thành viên trong nhóm của bạn không có gói. Nâng cấp gói PRO của bạn để trang trải những gói bị thiếu.",
"changed_team_billing_info": "Kể từ tháng 1 năm 2022, chúng tôi tính phí trên cơ sở từng người cho các thành viên trong nhóm. Các thành viên trong nhóm của bạn mà từng có PRO miễn phí nay sẽ chuyển sang dùng thử 14 ngày. Khi thời gian dùng thử hết hạn, các thành viên này sẽ bị ẩn khỏi nhóm của bạn trừ khi bạn nâng cấp.", "changed_team_billing_info": "Kể từ tháng 1 năm 2022, chúng tôi tính phí trên cơ sở từng người cho các thành viên trong nhóm. Các thành viên trong nhóm của bạn mà từng có PRO miễn phí nay sẽ chuyển sang dùng thử 14 ngày. Khi thời gian dùng thử hết hạn, các thành viên này sẽ bị ẩn khỏi nhóm của bạn trừ khi bạn nâng cấp.",
@ -699,6 +704,7 @@
"hide_event_type": "Ẩn loại sự kiện", "hide_event_type": "Ẩn loại sự kiện",
"edit_location": "Chỉnh sửa vị trí", "edit_location": "Chỉnh sửa vị trí",
"into_the_future": "trong tương lai", "into_the_future": "trong tương lai",
"when_booked_with_less_than_notice": "Khi đặt hẹn với khoảng thời gian thông báo ít hơn <time></time>",
"within_date_range": "Trong phạm vi ngày", "within_date_range": "Trong phạm vi ngày",
"indefinitely_into_future": "Vô thời hạn trong tương lai", "indefinitely_into_future": "Vô thời hạn trong tương lai",
"add_new_custom_input_field": "Thêm trường tùy chỉnh mới", "add_new_custom_input_field": "Thêm trường tùy chỉnh mới",
@ -718,6 +724,7 @@
"delete_account_confirmation_message": "Bạn có chắc chắn muốn xóa tài khoản {{appName}} của mình không? Bất kỳ ai mà bạn đã chia sẻ liên kết tài khoản của mình sẽ không thể đặt trước bằng liên kết đó nữa và mọi tùy chọn bạn đã lưu sẽ bị mất.", "delete_account_confirmation_message": "Bạn có chắc chắn muốn xóa tài khoản {{appName}} của mình không? Bất kỳ ai mà bạn đã chia sẻ liên kết tài khoản của mình sẽ không thể đặt trước bằng liên kết đó nữa và mọi tùy chọn bạn đã lưu sẽ bị mất.",
"integrations": "Tích hợp", "integrations": "Tích hợp",
"apps": "Ứng dụng", "apps": "Ứng dụng",
"apps_listing": "Danh sách ứng dụng",
"category_apps": "Ứng dụng {{category}}", "category_apps": "Ứng dụng {{category}}",
"app_store": "Cửa hàng ứng dụng", "app_store": "Cửa hàng ứng dụng",
"app_store_description": "Kết nối con người, công nghệ và nơi làm việc.", "app_store_description": "Kết nối con người, công nghệ và nơi làm việc.",
@ -741,6 +748,7 @@
"toggle_calendars_conflict": "Bật lịch mà bạn muốn kiểm tra trùng ngày để tránh đặt lịch hẹn trùng.", "toggle_calendars_conflict": "Bật lịch mà bạn muốn kiểm tra trùng ngày để tránh đặt lịch hẹn trùng.",
"select_destination_calendar": "Tạo sự kiện trên", "select_destination_calendar": "Tạo sự kiện trên",
"connect_additional_calendar": "Kết nối lịch bổ sung", "connect_additional_calendar": "Kết nối lịch bổ sung",
"calendar_updated_successfully": "Đã cập nhật lịch thành công",
"conferencing": "Hội nghị", "conferencing": "Hội nghị",
"calendar": "Lịch", "calendar": "Lịch",
"payments": "Thanh toán", "payments": "Thanh toán",
@ -773,6 +781,7 @@
"trending_apps": "Ứng dụng thịnh hành", "trending_apps": "Ứng dụng thịnh hành",
"explore_apps": "{{category}} ứng dụng", "explore_apps": "{{category}} ứng dụng",
"installed_apps": "Ứng dụng đã cài đặt", "installed_apps": "Ứng dụng đã cài đặt",
"free_to_use_apps": "Miễn phí",
"no_category_apps": "Không có ứng dụng {{category}}", "no_category_apps": "Không có ứng dụng {{category}}",
"no_category_apps_description_calendar": "Thêm một ứng dụng lịch để kiểm tra xung đột nhằm tránh đặt lịch hẹn trùng", "no_category_apps_description_calendar": "Thêm một ứng dụng lịch để kiểm tra xung đột nhằm tránh đặt lịch hẹn trùng",
"no_category_apps_description_conferencing": "Thử thêm vào một ứng dụng hội nghị để hợp nhất cuộc gọi video với khách hàng của bạn", "no_category_apps_description_conferencing": "Thử thêm vào một ứng dụng hội nghị để hợp nhất cuộc gọi video với khách hàng của bạn",
@ -811,6 +820,8 @@
"verify_wallet": "Xác minh Ví", "verify_wallet": "Xác minh Ví",
"connect_metamask": "Kết nối Metamask", "connect_metamask": "Kết nối Metamask",
"create_events_on": "Tạo sự kiện trên", "create_events_on": "Tạo sự kiện trên",
"enterprise_license": "Đây là tính năng doanh nghiệp",
"enterprise_license_description": "Để bật tính năng này, nhận khoá triển khai tại console {{consoleUrl}} và thêm nó vào .env của bạn ở dạng CALCOM_LICENSE_KEY. Nếu nhóm của bạn đã có giấy phép, vui lòng liên hệ {{supportMail}} để được trợ giúp.",
"missing_license": "Giấy phép bị thiếu", "missing_license": "Giấy phép bị thiếu",
"signup_requires": "Yêu cầu giấy phép thương mại", "signup_requires": "Yêu cầu giấy phép thương mại",
"signup_requires_description": "{{companyName}} hiện không cung cấp phiên bản nguồn mở miễn phí của trang đăng ký. Để nhận toàn quyền truy cập vào các thành phần đăng ký, bạn cần có giấy phép thương mại. Đối với mục đích sử dụng cá nhân, chúng tôi khuyên bạn nên sử dụng Nền tảng dữ liệu Prisma hoặc bất kỳ giao diện Postgres nào khác để tạo tài khoản.", "signup_requires_description": "{{companyName}} hiện không cung cấp phiên bản nguồn mở miễn phí của trang đăng ký. Để nhận toàn quyền truy cập vào các thành phần đăng ký, bạn cần có giấy phép thương mại. Đối với mục đích sử dụng cá nhân, chúng tôi khuyên bạn nên sử dụng Nền tảng dữ liệu Prisma hoặc bất kỳ giao diện Postgres nào khác để tạo tài khoản.",
@ -902,6 +913,7 @@
"user_impersonation_heading": "Mạo danh người dùng", "user_impersonation_heading": "Mạo danh người dùng",
"user_impersonation_description": "Cho phép đội ngũ hỗ trợ tạm thời đăng nhập với tư cách là bạn nhằm giúp chúng tôi nhanh chóng giải quyết mọi vấn đề mà bạn báo cáo cho chúng tôi.", "user_impersonation_description": "Cho phép đội ngũ hỗ trợ tạm thời đăng nhập với tư cách là bạn nhằm giúp chúng tôi nhanh chóng giải quyết mọi vấn đề mà bạn báo cáo cho chúng tôi.",
"team_impersonation_description": "Cho phép các quản trị viên của nhóm bạn tạm thời đăng nhập bằng danh tính của bạn.", "team_impersonation_description": "Cho phép các quản trị viên của nhóm bạn tạm thời đăng nhập bằng danh tính của bạn.",
"allow_booker_to_select_duration": "Cho phép người đặt hẹn chọn khoảng thời gian",
"impersonate_user_tip": "Tất cả những lần dùng tính năng này đều bị kiểm toán.", "impersonate_user_tip": "Tất cả những lần dùng tính năng này đều bị kiểm toán.",
"impersonating_user_warning": "Đang mạo danh tên người dùng \"{{user}}\".", "impersonating_user_warning": "Đang mạo danh tên người dùng \"{{user}}\".",
"impersonating_stop_instructions": "<0>Nhấp vào đây để ngừng lại</0>.", "impersonating_stop_instructions": "<0>Nhấp vào đây để ngừng lại</0>.",
@ -1019,6 +1031,9 @@
"error_removing_app": "Lỗi khi gỡ bỏ ứng dụng", "error_removing_app": "Lỗi khi gỡ bỏ ứng dụng",
"web_conference": "Hội nghị web", "web_conference": "Hội nghị web",
"requires_confirmation": "Yêu cầu xác nhận", "requires_confirmation": "Yêu cầu xác nhận",
"always_requires_confirmation": "Luôn luôn",
"requires_confirmation_threshold": "Cần xác nhận nếu đặt hẹn với thông báo trước chưa đầy {{time}}$t({{unit}}_timeUnit)",
"may_require_confirmation": "Có thể yêu cầu xác nhận",
"nr_event_type_one": "{{count}} loại sự kiện", "nr_event_type_one": "{{count}} loại sự kiện",
"nr_event_type_other": "{{count}} loại sự kiện", "nr_event_type_other": "{{count}} loại sự kiện",
"add_action": "Thêm hoạt động", "add_action": "Thêm hoạt động",
@ -1106,6 +1121,9 @@
"event_limit_tab_description": "Bạn có thể được đặt lịch bao lần", "event_limit_tab_description": "Bạn có thể được đặt lịch bao lần",
"event_advanced_tab_description": "Cài đặt cho lịch & nhiều tính năng khác...", "event_advanced_tab_description": "Cài đặt cho lịch & nhiều tính năng khác...",
"event_advanced_tab_title": "Nâng cao", "event_advanced_tab_title": "Nâng cao",
"event_setup_multiple_duration_error": "Thiết lập sự kiện: Nhiều khoảng thời gian cần ít nhất 1 tuỳ chọn.",
"event_setup_multiple_duration_default_error": "Thiết lập sự kiện: Vui lòng chọn một khoảng thời gian mặc định hợp lệ.",
"event_setup_booking_limits_error": "Giới hạn lịch hẹn phải theo thứ tự tăng dần. [day,week,month,year]",
"select_which_cal": "Chọn lịch nào cần thêm lịch hẹn vào", "select_which_cal": "Chọn lịch nào cần thêm lịch hẹn vào",
"custom_event_name": "Tên sự kiện tuỳ chỉnh", "custom_event_name": "Tên sự kiện tuỳ chỉnh",
"custom_event_name_description": "Tạo những tên sự kiện tuỳ chỉnh để hiển thị ở phần sự kiện lịch", "custom_event_name_description": "Tạo những tên sự kiện tuỳ chỉnh để hiển thị ở phần sự kiện lịch",
@ -1159,8 +1177,11 @@
"invoices": "Hoá đơn", "invoices": "Hoá đơn",
"embeds": "Nhúng", "embeds": "Nhúng",
"impersonation": "Mạo danh", "impersonation": "Mạo danh",
"impersonation_description": "Cài đặt để quản lý mạo danh người dùng",
"users": "Người dùng", "users": "Người dùng",
"profile_description": "Quản lí cài đặt cho hồ sơ {{appName}} của bạn", "profile_description": "Quản lí cài đặt cho hồ sơ {{appName}} của bạn",
"users_description": "Tại đây bạn có thể tìm thấy danh sách tất cả người dùng",
"users_listing": "Danh sách người dùng",
"general_description": "Quản lí cài đặt cho ngôn ngữ và múi giờ của bạn", "general_description": "Quản lí cài đặt cho ngôn ngữ và múi giờ của bạn",
"calendars_description": "Cấu hình cách các loại sự kiện của bạn tương tác với lịch của bạn", "calendars_description": "Cấu hình cách các loại sự kiện của bạn tương tác với lịch của bạn",
"appearance_description": "Quản lí cài đặt cho giao diện lịch hẹn của bạn", "appearance_description": "Quản lí cài đặt cho giao diện lịch hẹn của bạn",
@ -1365,6 +1386,7 @@
"number_sms_notifications": "Số điện thoại (thông báo SMS)", "number_sms_notifications": "Số điện thoại (thông báo SMS)",
"attendee_email_workflow": "Email người tham dự", "attendee_email_workflow": "Email người tham dự",
"attendee_email_info": "Email người tham gia lịch hẹn", "attendee_email_info": "Email người tham gia lịch hẹn",
"kbar_search_placeholder": "Nhập một lệnh hoặc tìm kiếm...",
"invalid_credential": "Ôi không! Có vẻ như quyền đã hết hạn hoặc đã bị thu hồi. Vui lòng cài đặt lại.", "invalid_credential": "Ôi không! Có vẻ như quyền đã hết hạn hoặc đã bị thu hồi. Vui lòng cài đặt lại.",
"choose_common_schedule_team_event": "Chọn một lịch chung", "choose_common_schedule_team_event": "Chọn một lịch chung",
"choose_common_schedule_team_event_description": "Bật mục này nếu bạn muốn dùng lịch thông thường giữa các chủ sự kiện. Khi tắt đi, mỗi chủ sự kiện sẽ được đặt lịch theo lịch mặc định của họ.", "choose_common_schedule_team_event_description": "Bật mục này nếu bạn muốn dùng lịch thông thường giữa các chủ sự kiện. Khi tắt đi, mỗi chủ sự kiện sẽ được đặt lịch theo lịch mặc định của họ.",
@ -1375,5 +1397,9 @@
"test_preview": "Kiểm tra Xem trước", "test_preview": "Kiểm tra Xem trước",
"route_to": "Định hướng đến", "route_to": "Định hướng đến",
"test_preview_description": "Kiểm tra biểu mẫu định hướng mà không cần gửi bất kỳ dữ liệu nào", "test_preview_description": "Kiểm tra biểu mẫu định hướng mà không cần gửi bất kỳ dữ liệu nào",
"test_routing": "Kiểm tra định hướng" "test_routing": "Kiểm tra định hướng",
"payment_app_disabled": "Một quản trị viên đã vô hiệu hoá một ứng dụng thanh toán",
"edit_event_type": "Sửa loại sự kiện",
"collective_scheduling": "Lên lịch tập thể",
"admin_apps_description": "Bật các ứng dụng cho thực thể Cal của bạn"
} }

View File

@ -1418,6 +1418,7 @@
"disable_app": "禁用应用", "disable_app": "禁用应用",
"disable_app_description": "禁用此应用可能会导致您的用户与 Cal 的交互方式出现问题", "disable_app_description": "禁用此应用可能会导致您的用户与 Cal 的交互方式出现问题",
"edit_keys": "编辑密钥", "edit_keys": "编辑密钥",
"admin_apps_description": "为您的 Cal 实例启用应用",
"no_available_apps": "没有可用的应用", "no_available_apps": "没有可用的应用",
"no_available_apps_description": "请确保您在“packages/app-store”下的部署中有应用", "no_available_apps_description": "请确保您在“packages/app-store”下的部署中有应用",
"no_apps": "此 Cal 实例中未启用任何应用", "no_apps": "此 Cal 实例中未启用任何应用",

View File

@ -40,8 +40,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await prisma.credential.create({ await prisma.credential.create({
data, data,
}); });
} catch (reason) { } catch (e) {
logger.error("Could not add this caldav account", reason); logger.error("Could not add this caldav account", e);
if (e instanceof Error) {
let message = e.message;
if (e.message.indexOf("Invalid credentials") > -1 && url.indexOf("dav.php") > -1) {
const parsedUrl = new URL(url);
const adminUrl =
parsedUrl.protocol +
"//" +
parsedUrl.hostname +
(parsedUrl.port ? ":" + parsedUrl.port : "") +
"/admin/?/settings/standard/";
message = `Couldn\'t connect to caldav account, please verify WebDAV authentication type is set to "Basic"`;
return res.status(500).json({ message, actionUrl: adminUrl });
}
}
return res.status(500).json({ message: "Could not add this caldav account" }); return res.status(500).json({ message: "Could not add this caldav account" });
} }

View File

@ -18,6 +18,7 @@ export default function CalDavCalendarSetup() {
}); });
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [errorActionUrl, setErrorActionUrl] = useState("");
return ( return (
<div className="flex h-screen bg-gray-200"> <div className="flex h-screen bg-gray-200">
@ -49,6 +50,9 @@ export default function CalDavCalendarSetup() {
const json = await res.json(); const json = await res.json();
if (!res.ok) { if (!res.ok) {
setErrorMessage(json?.message || t("something_went_wrong")); setErrorMessage(json?.message || t("something_went_wrong"));
if (json.actionUrl) {
setErrorActionUrl(json.actionUrl);
}
} else { } else {
router.push(json.url); router.push(json.url);
} }
@ -78,7 +82,24 @@ export default function CalDavCalendarSetup() {
/> />
</fieldset> </fieldset>
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />} {errorMessage && (
<Alert
severity="error"
title={errorMessage}
actions={
errorActionUrl !== "" ? (
<Button
href={errorActionUrl}
color="secondary"
target="_blank"
className="ml-5 w-32 !p-5">
Go to Admin
</Button>
) : undefined
}
className="my-4"
/>
)}
<div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex"> <div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex">
<Button type="button" color="secondary" onClick={() => router.back()}> <Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")} {t("cancel")}

View File

@ -835,6 +835,8 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
evt.appsStatus = Object.values(calcAppsStatus); evt.appsStatus = Object.values(calcAppsStatus);
} }
let videoCallUrl;
if (originalRescheduledBooking?.uid) { if (originalRescheduledBooking?.uid) {
// Use EventManager to conditionally use all needed integrations. // Use EventManager to conditionally use all needed integrations.
const updateManager = await eventManager.reschedule( const updateManager = await eventManager.reschedule(
@ -869,9 +871,9 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
metadata.conferenceData = updatedEvent.conferenceData; metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints; metadata.entryPoints = updatedEvent.entryPoints;
handleAppsStatus(results, booking); handleAppsStatus(results, booking);
videoCallUrl = metadata.hangoutLink || videoCallUrl;
} }
} }
if (noEmail !== true) { if (noEmail !== true) {
await sendRescheduledEmails({ await sendRescheduledEmails({
...evt, ...evt,
@ -893,6 +895,9 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
results = createManager.results; results = createManager.results;
referencesToCreate = createManager.referencesToCreate; referencesToCreate = createManager.referencesToCreate;
videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null;
if (results.length > 0 && results.every((res) => !res.success)) { if (results.length > 0 && results.every((res) => !res.success)) {
const error = { const error = {
errorCode: "BookingCreatingMeetingFailed", errorCode: "BookingCreatingMeetingFailed",
@ -909,6 +914,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
metadata.conferenceData = results[0].createdEvent?.conferenceData; metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints; metadata.entryPoints = results[0].createdEvent?.entryPoints;
handleAppsStatus(results, booking); handleAppsStatus(results, booking);
videoCallUrl = metadata.hangoutLink || videoCallUrl;
} }
if (noEmail !== true) { if (noEmail !== true) {
await sendScheduledEmails({ await sendScheduledEmails({
@ -945,7 +951,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
} }
log.debug(`Booking ${organizerUser.username} completed`); log.debug(`Booking ${organizerUser.username} completed`);
const metadata = videoCallUrl ? { videoCallUrl } : undefined;
if (isConfirmedByDefault) { if (isConfirmedByDefault) {
const eventTrigger: WebhookTriggerEvents = rescheduleUid const eventTrigger: WebhookTriggerEvents = rescheduleUid
? WebhookTriggerEvents.BOOKING_RESCHEDULED ? WebhookTriggerEvents.BOOKING_RESCHEDULED
@ -1001,7 +1007,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
...eventTypeInfo, ...eventTypeInfo,
bookingId, bookingId,
rescheduleUid, rescheduleUid,
metadata: reqBody.metadata, metadata: { ...metadata, ...reqBody.metadata },
eventTypeId, eventTypeId,
status: "ACCEPTED", status: "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined, smsReminderNumber: booking?.smsReminderNumber || undefined,
@ -1043,6 +1049,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
uid: booking.uid, uid: booking.uid,
}, },
data: { data: {
metadata,
references: { references: {
createMany: { createMany: {
data: referencesToCreate, data: referencesToCreate,

View File

@ -16,7 +16,7 @@ function ImpersonatingBanner() {
variant="warning" variant="warning"
actions={ actions={
<a className="border-b border-b-black" href="/auth/logout"> <a className="border-b border-b-black" href="/auth/logout">
Click Here To stop {t("impersonating_stop_instructions")}
</a> </a>
} }
/> />

View File

@ -42,7 +42,7 @@ export const TimeTimeUnitInput = (props: Props) => {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="-ml-1 h-9 w-24 rounded-none rounded-r-md border border-gray-300 bg-gray-50 px-3 py-1 text-sm"> <button className="-ml-1 h-9 w-24 rounded-none rounded-r-md border border-gray-300 bg-gray-50 px-3 py-1 text-sm">
<div className="flex"> <div className="flex">
<div className="w-3/4"> <div className="mr-1 w-3/4">
{timeUnit ? t(`${timeUnit.toLowerCase()}_timeUnit`) : "undefined"}{" "} {timeUnit ? t(`${timeUnit.toLowerCase()}_timeUnit`) : "undefined"}{" "}
</div> </div>
<div className="w-1/4 pt-1"> <div className="w-1/4 pt-1">

View File

@ -6,10 +6,8 @@ import { Controller, UseFormReturn } from "react-hook-form";
import { SENDER_ID } from "@calcom/lib/constants"; import { SENDER_ID } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Button, Label, TextField } from "@calcom/ui";
import { MultiSelectCheckboxes } from "@calcom/ui";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui"; import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui";
import { Button, Icon, Label, MultiSelectCheckboxes, TextField } from "@calcom/ui";
import type { FormValues } from "../pages/workflow"; import type { FormValues } from "../pages/workflow";
import { AddActionDialog } from "./AddActionDialog"; import { AddActionDialog } from "./AddActionDialog";
@ -74,6 +72,7 @@ export default function WorkflowDetailsPage(props: Props) {
template: WorkflowTemplates.CUSTOM, template: WorkflowTemplates.CUSTOM,
numberRequired: numberRequired || false, numberRequired: numberRequired || false,
sender: sender || SENDER_ID, sender: sender || SENDER_ID,
numberVerificationPending: false,
}; };
steps?.push(step); steps?.push(step);
form.setValue("steps", steps); form.setValue("steps", steps);

View File

@ -9,11 +9,13 @@ import { Dispatch, SetStateAction, useRef, useState } from "react";
import { Controller, UseFormReturn } from "react-hook-form"; import { Controller, UseFormReturn } from "react-hook-form";
import "react-phone-number-input/style.css"; import "react-phone-number-input/style.css";
import { classNames } from "@calcom/lib";
import { SENDER_ID } from "@calcom/lib/constants"; import { SENDER_ID } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import { trpc, TRPCClientError } from "@calcom/trpc/react"; import { trpc, TRPCClientError } from "@calcom/trpc/react";
import { import {
Badge,
Button, Button,
Checkbox, Checkbox,
ConfirmationDialogContent, ConfirmationDialogContent,
@ -35,11 +37,7 @@ import {
} from "@calcom/ui"; } from "@calcom/ui";
import { AddVariablesDropdown } from "../components/AddVariablesDropdown"; import { AddVariablesDropdown } from "../components/AddVariablesDropdown";
import { import { getWorkflowTemplateOptions, getWorkflowTriggerOptions } from "../lib/getOptions";
getWorkflowActionOptions,
getWorkflowTemplateOptions,
getWorkflowTriggerOptions,
} from "../lib/getOptions";
import { translateVariablesToEnglish } from "../lib/variableTranslations"; import { translateVariablesToEnglish } from "../lib/variableTranslations";
import type { FormValues } from "../pages/workflow"; import type { FormValues } from "../pages/workflow";
import Editor from "./TextEditor/Editor"; import Editor from "./TextEditor/Editor";
@ -54,10 +52,15 @@ type WorkflowStepProps = {
export default function WorkflowStepContainer(props: WorkflowStepProps) { export default function WorkflowStepContainer(props: WorkflowStepProps) {
const { t, i18n } = useLocale(); const { t, i18n } = useLocale();
const utils = trpc.useContext();
const { step, form, reload, setReload } = props; const { step, form, reload, setReload } = props;
const { data: _verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery();
const verifiedNumbers = _verifiedNumbers?.map((number) => number.phoneNumber);
const [isAdditionalInputsDialogOpen, setIsAdditionalInputsDialogOpen] = useState(false); const [isAdditionalInputsDialogOpen, setIsAdditionalInputsDialogOpen] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [verificationCode, setVerificationCode] = useState("");
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState( const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(
step?.action === WorkflowActions.SMS_NUMBER ? true : false step?.action === WorkflowActions.SMS_NUMBER ? true : false
); );
@ -108,6 +111,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
const refReminderBody = useRef<HTMLTextAreaElement | null>(null); const refReminderBody = useRef<HTMLTextAreaElement | null>(null);
const [numberVerified, setNumberVerified] = useState(
verifiedNumbers && step
? !!verifiedNumbers.find((number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`))
: false
);
const addVariable = (variable: string, isEmailSubject?: boolean) => { const addVariable = (variable: string, isEmailSubject?: boolean) => {
if (step) { if (step) {
if (isEmailSubject) { if (isEmailSubject) {
@ -128,6 +137,30 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
} }
}; };
const sendVerificationCodeMutation = trpc.viewer.workflows.sendVerificationCode.useMutation({
onSuccess: async () => {
showToast(t("verification_code_sent"), "success");
},
onError: async (error) => {
showToast(error.message, "error");
},
});
const verifyPhoneNumberMutation = trpc.viewer.workflows.verifyPhoneNumber.useMutation({
onSuccess: async (isVerified) => {
showToast(isVerified ? t("verified_successfully") : t("wrong_code"), "success");
setNumberVerified(isVerified);
utils.viewer.workflows.getVerifiedNumbers.invalidate();
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
setNumberVerified(false);
}
},
});
const testActionMutation = trpc.viewer.workflows.testAction.useMutation({ const testActionMutation = trpc.viewer.workflows.testAction.useMutation({
onSuccess: async () => { onSuccess: async () => {
showToast(t("notification_sent"), "success"); showToast(t("notification_sent"), "success");
@ -137,6 +170,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
if (err instanceof TRPCClientError) { if (err instanceof TRPCClientError) {
if (err.message === "rate-limit-exceeded") { if (err.message === "rate-limit-exceeded") {
message = t("rate_limit_exceeded"); message = t("rate_limit_exceeded");
} else {
message = err.message;
} }
} }
if (err instanceof HttpError) { if (err instanceof HttpError) {
@ -311,7 +346,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
setIsSenderIdNeeded(true); setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false); setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(val.value === WorkflowActions.SMS_NUMBER); setIsPhoneNumberNeeded(val.value === WorkflowActions.SMS_NUMBER);
setNumberVerified(false);
if (!wasSMSAction) { if (!wasSMSAction) {
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, ""); form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, "");
} }
@ -356,20 +391,76 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
{isPhoneNumberNeeded && ( {isPhoneNumberNeeded && (
<> <>
<Label className="pt-4">{t("custom_phone_number")}</Label> <Label className="pt-4">{t("custom_phone_number")}</Label>
<PhoneInput<FormValues> <div className="block sm:flex">
control={form.control} <PhoneInput<FormValues>
name={`steps.${step.stepNumber - 1}.sendTo`} control={form.control}
placeholder={t("phone_number")} name={`steps.${step.stepNumber - 1}.sendTo`}
id={`steps.${step.stepNumber - 1}.sendTo`} placeholder={t("phone_number")}
className="w-full rounded-md" id={`steps.${step.stepNumber - 1}.sendTo`}
required className="min-w-fit sm:rounded-tl-md sm:rounded-bl-md sm:border-r-transparent"
/> required
onChange={() => {
const isAlreadyVerified = !!verifiedNumbers
?.concat([])
.find(
(number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`)
);
setNumberVerified(isAlreadyVerified);
}}
/>
<Button
color="secondary"
disabled={numberVerified || false}
className={classNames(
"-ml-[3px] h-[40px] min-w-fit sm:block sm:rounded-tl-none sm:rounded-bl-none ",
numberVerified ? "hidden" : "mt-3 sm:mt-0"
)}
onClick={() =>
sendVerificationCodeMutation.mutate({
phoneNumber: form.getValues(`steps.${step.stepNumber - 1}.sendTo`) || "",
})
}>
{t("send_code")}
</Button>
</div>
{form.formState.errors.steps && {form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && ( form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="mt-1 text-xs text-red-500"> <p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""} {form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p> </p>
)} )}
{numberVerified ? (
<div className="mt-1">
<Badge variant="green">{t("number_verified")}</Badge>
</div>
) : (
<>
<div className="mt-3 flex">
<TextField
className=" border-r-transparent"
placeholder="Verification code"
value={verificationCode}
onChange={(e) => {
setVerificationCode(e.target.value);
}}
required
/>
<Button
color="secondary"
className="-ml-[3px] rounded-tl-none rounded-bl-none "
disabled={verifyPhoneNumberMutation.isLoading}
onClick={() => {
verifyPhoneNumberMutation.mutate({
phoneNumber: form.getValues(`steps.${step.stepNumber - 1}.sendTo`) || "",
code: verificationCode,
});
}}>
Verify
</Button>
</div>
</>
)}
</> </>
)} )}
{isSenderIdNeeded && ( {isSenderIdNeeded && (
@ -525,6 +616,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
className="mt-7 w-full" className="mt-7 w-full"
onClick={() => { onClick={() => {
let isEmpty = false; let isEmpty = false;
if (!form.getValues(`steps.${step.stepNumber - 1}.sendTo`) && isPhoneNumberNeeded) { if (!form.getValues(`steps.${step.stepNumber - 1}.sendTo`) && isPhoneNumberNeeded) {
form.setError(`steps.${step.stepNumber - 1}.sendTo`, { form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
type: "custom", type: "custom",
@ -532,6 +624,13 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
}); });
isEmpty = true; isEmpty = true;
} }
if (!numberVerified && isPhoneNumberNeeded) {
form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
type: "custom",
message: t("not_verified"),
});
}
if ( if (
form.getValues(`steps.${step.stepNumber - 1}.template`) === WorkflowTemplates.CUSTOM form.getValues(`steps.${step.stepNumber - 1}.template`) === WorkflowTemplates.CUSTOM
) { ) {
@ -565,11 +664,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
); );
testActionMutation.mutate({ testActionMutation.mutate({
action: step.action, step,
emailSubject, emailSubject,
reminderBody, reminderBody,
template: step.template,
sender: step.sender || SENDER_ID,
}); });
} else { } else {
const isNumberValid = const isNumberValid =
@ -578,7 +675,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
? false ? false
: true; : true;
if (isPhoneNumberNeeded && isNumberValid && !isEmpty) { if (isPhoneNumberNeeded && isNumberValid && !isEmpty && numberVerified) {
setConfirmationDialogOpen(true); setConfirmationDialogOpen(true);
} }
} }
@ -603,12 +700,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
); );
testActionMutation.mutate({ testActionMutation.mutate({
action: step.action, step,
emailSubject: "", emailSubject: "",
reminderBody: reminderBody || "", reminderBody: reminderBody || "",
template: step.template,
sendTo: step.sendTo || "",
sender: step.sender || SENDER_ID,
}); });
setConfirmationDialogOpen(false); setConfirmationDialogOpen(false);
}}> }}>

View File

@ -52,7 +52,9 @@ export const scheduleWorkflowReminders = async (
step.reminderBody || "", step.reminderBody || "",
step.id, step.id,
step.template, step.template,
step.sender || SENDER_ID step.sender || SENDER_ID,
workflow.userId,
step.numberVerificationPending
); );
} else if ( } else if (
step.action === WorkflowActions.EMAIL_ATTENDEE || step.action === WorkflowActions.EMAIL_ATTENDEE ||
@ -121,7 +123,9 @@ export const sendCancelledReminders = async (
step.reminderBody || "", step.reminderBody || "",
step.id, step.id,
step.template, step.template,
step.sender || SENDER_ID step.sender || SENDER_ID,
workflow.userId,
step.numberVerificationPending
); );
} else if ( } else if (
step.action === WorkflowActions.EMAIL_ATTENDEE || step.action === WorkflowActions.EMAIL_ATTENDEE ||

View File

@ -49,3 +49,26 @@ export const cancelSMS = async (referenceId: string) => {
assertTwilio(twilio); assertTwilio(twilio);
await twilio.messages(referenceId).update({ status: "canceled" }); await twilio.messages(referenceId).update({ status: "canceled" });
}; };
export const sendVerificationCode = async (phoneNumber: string) => {
assertTwilio(twilio);
if (process.env.TWILIO_VERIFY_SID) {
await twilio.verify
.services(process.env.TWILIO_VERIFY_SID)
.verifications.create({ to: phoneNumber, channel: "sms" });
}
};
export const verifyNumber = async (phoneNumber: string, code: string) => {
assertTwilio(twilio);
if (process.env.TWILIO_VERIFY_SID) {
try {
const verification_check = await twilio.verify.v2
.services(process.env.TWILIO_VERIFY_SID)
.verificationChecks.create({ to: phoneNumber, code: code });
return verification_check.status;
} catch (e) {
return "failed";
}
}
};

View File

@ -50,7 +50,9 @@ export const scheduleSMSReminder = async (
message: string, message: string,
workflowStepId: number, workflowStepId: number,
template: WorkflowTemplates, template: WorkflowTemplates,
sender: string sender: string,
userId: number,
isVerificationPending = false
) => { ) => {
const { startTime, endTime } = evt; const { startTime, endTime } = evt;
const uid = evt.uid as string; const uid = evt.uid as string;
@ -60,6 +62,18 @@ export const scheduleSMSReminder = async (
const senderID = getSenderId(reminderPhone, sender); const senderID = getSenderId(reminderPhone, sender);
//SMS_ATTENDEE action does not need to be verified
//isVerificationPending is from all already existing workflows (once they edit their workflow, they will also have to verify the number)
async function getIsNumberVerified() {
if (action === WorkflowActions.SMS_ATTENDEE) return true;
const verifiedNumber = await prisma.verifiedNumber.findFirst({
where: { userId, phoneNumber: reminderPhone || "" },
});
if (!!verifiedNumber) return true;
return isVerificationPending;
}
const isNumberVerified = await getIsNumberVerified();
if (triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT) { if (triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT) {
scheduledDate = timeSpan.time && timeUnit ? dayjs(startTime).subtract(timeSpan.time, timeUnit) : null; scheduledDate = timeSpan.time && timeUnit ? dayjs(startTime).subtract(timeSpan.time, timeUnit) : null;
} else if (triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) { } else if (triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) {
@ -93,7 +107,7 @@ export const scheduleSMSReminder = async (
break; break;
} }
if (message.length > 0 && reminderPhone) { if (message.length > 0 && reminderPhone && isNumberVerified) {
//send SMS when event is booked/cancelled/rescheduled //send SMS when event is booked/cancelled/rescheduled
if ( if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT || triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||

View File

@ -0,0 +1,22 @@
import prisma from "@calcom/prisma";
import * as twilio from "./smsProviders/twilioProvider";
export const sendVerificationCode = async (phoneNumber: string) => {
return twilio.sendVerificationCode(phoneNumber);
};
export const verifyPhoneNumber = async (phoneNumber: string, code: string, userId: number) => {
const verificationStatus = await twilio.verifyNumber(phoneNumber, code);
if (verificationStatus === "approved") {
await prisma.verifiedNumber.create({
data: {
userId,
phoneNumber,
},
});
return true;
}
return false;
};

View File

@ -104,6 +104,8 @@ function WorkflowPage() {
} }
); );
const { data: verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery();
useEffect(() => { useEffect(() => {
if (workflow && !isLoading) { if (workflow && !isLoading) {
setSelectedEventTypes( setSelectedEventTypes(
@ -175,6 +177,7 @@ function WorkflowPage() {
handleSubmit={async (values) => { handleSubmit={async (values) => {
let activeOnEventTypeIds: number[] = []; let activeOnEventTypeIds: number[] = [];
let isEmpty = false; let isEmpty = false;
let isVerified = true;
values.steps.forEach((step) => { values.steps.forEach((step) => {
const isSMSAction = const isSMSAction =
@ -199,9 +202,22 @@ function WorkflowPage() {
step.emailSubject = translateVariablesToEnglish(step.emailSubject, { locale: i18n.language, t }); step.emailSubject = translateVariablesToEnglish(step.emailSubject, { locale: i18n.language, t });
} }
isEmpty = !isEmpty ? isBodyEmpty : isEmpty; isEmpty = !isEmpty ? isBodyEmpty : isEmpty;
//check if phone number is verified
if (
step.action === WorkflowActions.SMS_NUMBER &&
!verifiedNumbers?.find((verifiedNumber) => verifiedNumber.phoneNumber === step.sendTo)
) {
isVerified = false;
form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
type: "custom",
message: t("not_verified"),
});
}
}); });
if (!isEmpty) { if (!isEmpty && isVerified) {
if (values.activeOn) { if (values.activeOn) {
activeOnEventTypeIds = values.activeOn.map((option) => { activeOnEventTypeIds = values.activeOn.map((option) => {
return parseInt(option.value, 10); return parseInt(option.value, 10);
@ -216,6 +232,7 @@ function WorkflowPage() {
time: values.time || null, time: values.time || null,
timeUnit: values.timeUnit || null, timeUnit: values.timeUnit || null,
}); });
utils.viewer.workflows.getVerifiedNumbers.invalidate();
} }
}}> }}>
<Shell <Shell

View File

@ -146,10 +146,16 @@ const DateOverrideForm = ({
label={t("date_overrides_mark_all_day_unavailable_one")} label={t("date_overrides_mark_all_day_unavailable_one")}
checked={datesUnavailable} checked={datesUnavailable}
onCheckedChange={setDatesUnavailable} onCheckedChange={setDatesUnavailable}
data-testid="date-override-mark-unavailable"
/> />
</div> </div>
<div className="flex flex-row-reverse"> <div className="flex flex-row-reverse">
<Button className="ml-2" color="primary" type="submit" disabled={!date}> <Button
className="ml-2"
color="primary"
type="submit"
disabled={!date}
data-testid="add-override-submit-btn">
{value ? t("date_overrides_update_btn") : t("date_overrides_add_btn")} {value ? t("date_overrides_update_btn") : t("date_overrides_add_btn")}
</Button> </Button>
<DialogClose onClick={onClose} /> <DialogClose onClick={onClose} />

View File

@ -38,7 +38,7 @@ const DateOverrideList = ({
}; };
return ( return (
<ul className="rounded border border-gray-200"> <ul className="rounded border border-gray-200" data-testid="date-overrides-list">
{items.map((item, index) => ( {items.map((item, index) => (
<li key={item.id} className="flex justify-between border-b px-5 py-4 last:border-b-0"> <li key={item.id} className="flex justify-between border-b px-5 py-4 last:border-b-0">
<div> <div>

View File

@ -193,6 +193,7 @@ export async function processBookingConfirmation(
// count changed, parsing again to get the new value in // count changed, parsing again to get the new value in
evt.recurringEvent = parseRecurringEvent(recurringEvent); evt.recurringEvent = parseRecurringEvent(recurringEvent);
} }
let videoCallUrl;
if (confirmed) { if (confirmed) {
const eventManager = new EventManager(user); const eventManager = new EventManager(user);
@ -200,6 +201,8 @@ export async function processBookingConfirmation(
const results = scheduleResult.results; const results = scheduleResult.results;
videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null;
if (results.length > 0 && results.every((res) => !res.success)) { if (results.length > 0 && results.every((res) => !res.success)) {
const error = { const error = {
errorCode: "BookingCreatingMeetingFailed", errorCode: "BookingCreatingMeetingFailed",
@ -215,6 +218,7 @@ export async function processBookingConfirmation(
metadata.hangoutLink = results[0].createdEvent?.hangoutLink; metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData; metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints; metadata.entryPoints = results[0].createdEvent?.entryPoints;
videoCallUrl = metadata.hangoutLink || videoCallUrl;
} }
try { try {
await sendScheduledEmails({ ...evt, additionalInformation: metadata }); await sendScheduledEmails({ ...evt, additionalInformation: metadata });
@ -237,6 +241,7 @@ export async function processBookingConfirmation(
})[]; })[];
} | null; } | null;
}[] = []; }[] = [];
const metadata = videoCallUrl ? { videoCallUrl } : undefined;
if (recurringEventId) { if (recurringEventId) {
// The booking to confirm is a recurring event and comes from /booking/recurring, proceeding to mark all related // The booking to confirm is a recurring event and comes from /booking/recurring, proceeding to mark all related
@ -255,6 +260,7 @@ export async function processBookingConfirmation(
}, },
data: { data: {
status: BookingStatus.ACCEPTED, status: BookingStatus.ACCEPTED,
metadata,
references: { references: {
create: scheduleResult.referencesToCreate, create: scheduleResult.referencesToCreate,
}, },
@ -293,6 +299,7 @@ export async function processBookingConfirmation(
}, },
data: { data: {
status: BookingStatus.ACCEPTED, status: BookingStatus.ACCEPTED,
metadata,
references: { references: {
create: scheduleResult.referencesToCreate, create: scheduleResult.referencesToCreate,
}, },
@ -366,13 +373,11 @@ export async function processBookingConfirmation(
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded); const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
subscribersMeetingEnded.forEach( subscribersMeetingEnded.forEach((subscriber) => {
(subscriber: { subscriberUrl: string; id: string; appId: string | null }) => { updatedBookings.forEach((booking) => {
updatedBookings.forEach((booking) => { scheduleTrigger(booking, subscriber.subscriberUrl, subscriber);
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber); });
}); });
}
);
const eventTypeInfo: EventTypeInfo = { const eventTypeInfo: EventTypeInfo = {
eventTitle: booking.eventType?.title, eventTitle: booking.eventType?.title,

View File

@ -54,6 +54,7 @@ export const buildBooking = (booking?: Partial<Booking>): Booking => {
recurringEventId: null, recurringEventId: null,
smsReminderNumber: null, smsReminderNumber: null,
scheduledJobs: [], scheduledJobs: [],
metadata: null,
...booking, ...booking,
}; };
}; };

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "metadata" JSONB;

View File

@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "WorkflowStep" ADD COLUMN "numberVerificationPending" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "VerifiedNumber" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"phoneNumber" TEXT NOT NULL,
CONSTRAINT "VerifiedNumber_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "VerifiedNumber" ADD CONSTRAINT "VerifiedNumber_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -186,6 +186,7 @@ model User {
ownedEventTypes EventType[] @relation("owner") ownedEventTypes EventType[] @relation("owner")
workflows Workflow[] workflows Workflow[]
routingForms App_RoutingForms_Form[] @relation("routing-form") routingForms App_RoutingForms_Form[] @relation("routing-form")
verifiedNumbers VerifiedNumber[]
@@map(name: "users") @@map(name: "users")
} }
@ -300,6 +301,8 @@ model Booking {
smsReminderNumber String? smsReminderNumber String?
workflowReminders WorkflowReminder[] workflowReminders WorkflowReminder[]
scheduledJobs String[] scheduledJobs String[]
/// @zod.custom(imports.bookingMetadataSchema)
metadata Json?
} }
model Schedule { model Schedule {
@ -559,18 +562,19 @@ enum WorkflowActions {
} }
model WorkflowStep { model WorkflowStep {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
stepNumber Int stepNumber Int
action WorkflowActions action WorkflowActions
workflowId Int workflowId Int
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
sendTo String? sendTo String?
reminderBody String? reminderBody String?
emailSubject String? emailSubject String?
template WorkflowTemplates @default(REMINDER) template WorkflowTemplates @default(REMINDER)
workflowReminders WorkflowReminder[] workflowReminders WorkflowReminder[]
numberRequired Boolean? numberRequired Boolean?
sender String? sender String?
numberVerificationPending Boolean @default(true)
} }
model Workflow { model Workflow {
@ -620,3 +624,10 @@ enum WorkflowMethods {
EMAIL EMAIL
SMS SMS
} }
model VerifiedNumber {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
phoneNumber String
}

View File

@ -220,6 +220,12 @@ export const teamMetadataSchema = z
.partial() .partial()
.nullable(); .nullable();
export const bookingMetadataSchema = z
.object({
videoCallUrl: z.string().optional(),
})
.nullable();
export const customInputOptionSchema = z.array( export const customInputOptionSchema = z.array(
z.object({ z.object({
label: z.string(), label: z.string(),

View File

@ -27,6 +27,10 @@ import {
deleteScheduledSMSReminder, deleteScheduledSMSReminder,
scheduleSMSReminder, scheduleSMSReminder,
} from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager"; } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import {
verifyPhoneNumber,
sendVerificationCode,
} from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber";
import { SENDER_ID } from "@calcom/lib/constants"; import { SENDER_ID } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors";
import { getTranslation } from "@calcom/lib/server/i18n"; import { getTranslation } from "@calcom/lib/server/i18n";
@ -162,6 +166,7 @@ export const workflowsRouter = router({
template: WorkflowTemplates.REMINDER, template: WorkflowTemplates.REMINDER,
workflowId: workflow.id, workflowId: workflow.id,
sender: SENDER_ID, sender: SENDER_ID,
numberVerificationPending: false,
}, },
}); });
return { workflow }; return { workflow };
@ -481,7 +486,8 @@ export const workflowsRouter = router({
step.reminderBody || "", step.reminderBody || "",
step.id, step.id,
step.template, step.template,
step.sender || SENDER_ID step.sender || SENDER_ID,
user.id
); );
} }
}); });
@ -555,6 +561,7 @@ export const workflowsRouter = router({
template: newStep.template, template: newStep.template,
numberRequired: newStep.numberRequired, numberRequired: newStep.numberRequired,
sender: newStep.sender || SENDER_ID, sender: newStep.sender || SENDER_ID,
numberVerificationPending: false,
}, },
}); });
//cancel all reminders of step and create new ones (not for newEventTypes) //cancel all reminders of step and create new ones (not for newEventTypes)
@ -666,7 +673,8 @@ export const workflowsRouter = router({
newStep.reminderBody || "", newStep.reminderBody || "",
newStep.id || 0, newStep.id || 0,
newStep.template, newStep.template,
newStep.sender || SENDER_ID newStep.sender || SENDER_ID,
user.id
); );
} }
}); });
@ -695,7 +703,7 @@ export const workflowsRouter = router({
const newStep = step; const newStep = step;
newStep.sender = step.sender || SENDER_ID; newStep.sender = step.sender || SENDER_ID;
const createdStep = await ctx.prisma.workflowStep.create({ const createdStep = await ctx.prisma.workflowStep.create({
data: step, data: { ...step, numberVerificationPending: false },
}); });
if ( if (
(trigger === WorkflowTriggerEvents.BEFORE_EVENT || (trigger === WorkflowTriggerEvents.BEFORE_EVENT ||
@ -782,7 +790,8 @@ export const workflowsRouter = router({
step.reminderBody || "", step.reminderBody || "",
createdStep.id, createdStep.id,
step.template, step.template,
step.sender || SENDER_ID step.sender || SENDER_ID,
user.id
); );
} }
}); });
@ -825,17 +834,63 @@ export const workflowsRouter = router({
testAction: authedRateLimitedProcedure({ intervalInMs: 10000, limit: 3 }) testAction: authedRateLimitedProcedure({ intervalInMs: 10000, limit: 3 })
.input( .input(
z.object({ z.object({
action: z.enum(WORKFLOW_ACTIONS), step: z.object({
id: z.number(),
stepNumber: z.number(),
action: z.enum(WORKFLOW_ACTIONS),
workflowId: z.number(),
sendTo: z.string().optional().nullable(),
reminderBody: z.string().optional().nullable(),
emailSubject: z.string().optional().nullable(),
template: z.enum(WORKFLOW_TEMPLATES),
numberRequired: z.boolean().nullable(),
sender: z.string().optional().nullable(),
}),
emailSubject: z.string(), emailSubject: z.string(),
reminderBody: z.string(), reminderBody: z.string(),
template: z.enum(WORKFLOW_TEMPLATES),
sendTo: z.string().optional(),
sender: z.string().optional(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { action, emailSubject, reminderBody, template, sendTo, sender } = input; const { user } = ctx;
const { step, emailSubject, reminderBody } = input;
const { action, template, sendTo, sender } = step;
const senderID = sender || SENDER_ID;
if (action === WorkflowActions.SMS_NUMBER) {
if (!sendTo) throw new TRPCError({ code: "BAD_REQUEST", message: "Missing sendTo" });
const verifiedNumbers = await ctx.prisma.verifiedNumber.findFirst({
where: {
userId: ctx.user.id,
phoneNumber: sendTo,
},
});
if (!verifiedNumbers)
throw new TRPCError({ code: "UNAUTHORIZED", message: "Phone number is not verified" });
}
try { try {
const userWorkflow = await ctx.prisma.workflow.findUnique({
where: {
id: step.workflowId,
},
select: {
userId: true,
steps: true,
},
});
if (!userWorkflow || userWorkflow.userId !== user.id) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (isSMSAction(step.action)) {
const hasTeamPlan = (await ctx.prisma.membership.count({ where: { userId: user.id } })) > 0;
if (!hasTeamPlan) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Team plan needed" });
}
}
const booking = await ctx.prisma.booking.findFirst({ const booking = await ctx.prisma.booking.findFirst({
orderBy: { orderBy: {
createdAt: "desc", createdAt: "desc",
@ -919,7 +974,8 @@ export const workflowsRouter = router({
reminderBody, reminderBody,
0, 0,
template, template,
sender || SENDER_ID senderID,
ctx.user.id
); );
return { message: "Notification sent" }; return { message: "Notification sent" };
} }
@ -973,15 +1029,7 @@ export const workflowsRouter = router({
if (!eventTypeWorkflow) if (!eventTypeWorkflow)
throw new TRPCError({ code: "UNAUTHORIZED", message: "This event type does not belong to the user" }); throw new TRPCError({ code: "UNAUTHORIZED", message: "This event type does not belong to the user" });
// NOTE: This was unused
// const eventType = await ctx.prisma.eventType.findFirst({
// where: {
// id: eventTypeId,
// },
// });
//check if event type is already active //check if event type is already active
const isActive = await ctx.prisma.workflowsOnEventTypes.findFirst({ const isActive = await ctx.prisma.workflowsOnEventTypes.findFirst({
where: { where: {
workflowId, workflowId,
@ -1005,6 +1053,39 @@ export const workflowsRouter = router({
}); });
} }
}), }),
sendVerificationCode: authedProcedure
.input(
z.object({
phoneNumber: z.string(),
})
)
.mutation(async ({ input }) => {
const { phoneNumber } = input;
return sendVerificationCode(phoneNumber);
}),
verifyPhoneNumber: authedProcedure
.input(
z.object({
phoneNumber: z.string(),
code: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { phoneNumber, code } = input;
const { user } = ctx;
const verifyStatus = await verifyPhoneNumber(phoneNumber, code, user.id);
return verifyStatus;
}),
getVerifiedNumbers: authedProcedure.query(async ({ ctx }) => {
const { user } = ctx;
const verifiedNumbers = await ctx.prisma.verifiedNumber.findMany({
where: {
userId: user.id,
},
});
return verifiedNumbers;
}),
getWorkflowActionOptions: authedProcedure.query(async ({ ctx }) => { getWorkflowActionOptions: authedProcedure.query(async ({ ctx }) => {
const userId = ctx.user.id; const userId = ctx.user.id;
const hasTeamPlan = (await ctx.prisma.membership.count({ where: { userId } })) > 0; const hasTeamPlan = (await ctx.prisma.membership.count({ where: { userId } })) > 0;

View File

@ -9,15 +9,22 @@ export type PhoneInputProps<FormValues> = Props<
required: boolean; required: boolean;
}, },
FormValues FormValues
>; > & { onChange?: (e: any) => void };
function PhoneInput<FormValues>({ control, name, className, ...rest }: PhoneInputProps<FormValues>) { function PhoneInput<FormValues>({
control,
name,
className,
onChange,
...rest
}: PhoneInputProps<FormValues>) {
return ( return (
<BasePhoneInput <BasePhoneInput
{...rest} {...rest}
international international
name={name} name={name}
control={control} control={control}
onChange={onChange}
countrySelectProps={{ className: "text-black" }} countrySelectProps={{ className: "text-black" }}
numberInputProps={{ numberInputProps={{
className: "border-0 text-sm focus:ring-0 dark:bg-darkgray-100 dark:placeholder:text-darkgray-600", className: "border-0 text-sm focus:ring-0 dark:bg-darkgray-100 dark:placeholder:text-darkgray-600",

View File

@ -1,5 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
type DefaultStep = { type DefaultStep = {
title: string; title: string;
}; };
@ -10,13 +12,14 @@ function Stepper<T extends DefaultStep>(props: {
steps: T[]; steps: T[];
disableSteps?: boolean; disableSteps?: boolean;
}) { }) {
const { t } = useLocale();
const { href, steps } = props; const { href, steps } = props;
return ( return (
<> <>
{steps.length > 1 && ( {steps.length > 1 && (
<nav className="flex items-center justify-center" aria-label="Progress"> <nav className="flex items-center justify-center" aria-label="Progress">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
Step {props.step} of {steps.length} {t("current_step_of_total", { currentStep: props.step, maxSteps: steps.length })}
</p> </p>
<ol role="list" className="ml-8 flex items-center space-x-5"> <ol role="list" className="ml-8 flex items-center space-x-5">
{steps.map((mapStep, index) => ( {steps.map((mapStep, index) => (

View File

@ -1,6 +1,7 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import classNames from "@calcom/lib/classNames"; import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Stepper } from "../.."; import { Button, Stepper } from "../..";
@ -19,6 +20,7 @@ function WizardForm<T extends DefaultStep>(props: {
containerClassname?: string; containerClassname?: string;
}) { }) {
const { href, steps } = props; const { href, steps } = props;
const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const step = parseInt((router.query.step as string) || "1"); const step = parseInt((router.query.step as string) || "1");
const currentStep = steps[step - 1]; const currentStep = steps[step - 1];
@ -51,7 +53,7 @@ function WizardForm<T extends DefaultStep>(props: {
onClick={() => { onClick={() => {
setStep(step - 1); setStep(step - 1);
}}> }}>
Back {t("prev_step")}
</Button> </Button>
)} )}
@ -65,7 +67,7 @@ function WizardForm<T extends DefaultStep>(props: {
onClick={() => { onClick={() => {
setStep(step + 1); setStep(step + 1);
}}> }}>
{step < steps.length ? "Next" : "Finish"} {step < steps.length ? t("next_step_text") : t("finish")}
</Button> </Button>
</div> </div>
)} )}

View File

@ -184,6 +184,7 @@
"$TWILIO_SID", "$TWILIO_SID",
"$TWILIO_MESSAGING_SID", "$TWILIO_MESSAGING_SID",
"$TWILIO_PHONE_NUMBER", "$TWILIO_PHONE_NUMBER",
"$TWILIO_VERIFY_SID",
"$CRON_API_KEY", "$CRON_API_KEY",
"$DAILY_API_KEY", "$DAILY_API_KEY",
"$DAILY_SCALE_PLAN", "$DAILY_SCALE_PLAN",

View File

@ -7903,16 +7903,11 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/node@*", "@types/node@16.9.1", "@types/node@>=4.0", "@types/node@>=8.1.0", "@types/node@^14.0.10 || ^16.0.0", "@types/node@^14.14.20 || ^16.0.0": "@types/node@*", "@types/node@16.9.1", "@types/node@>=4.0", "@types/node@>=8.1.0", "@types/node@^12.12.54", "@types/node@^12.12.6", "@types/node@^14.0.10 || ^16.0.0", "@types/node@^14.14.20 || ^16.0.0":
version "16.9.1" version "16.9.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708"
integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g== integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==
"@types/node@^12.12.54", "@types/node@^12.12.6":
version "12.20.55"
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240"
integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==
"@types/nodemailer@^6.4.5": "@types/nodemailer@^6.4.5":
version "6.4.5" version "6.4.5"
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.5.tgz#09011ac73259245475d1688e4ba101860567dc39" resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.5.tgz#09011ac73259245475d1688e4ba101860567dc39"
@ -17827,9 +17822,9 @@ loader-runner@^4.2.0:
integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==
loader-utils@^1.2.3: loader-utils@^1.2.3:
version "1.4.0" version "1.4.2"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.2.tgz#29a957f3a63973883eb684f10ffd3d151fec01a3"
integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== integrity sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==
dependencies: dependencies:
big.js "^5.2.2" big.js "^5.2.2"
emojis-list "^3.0.0" emojis-list "^3.0.0"
@ -19117,11 +19112,16 @@ minimist-options@4.1.0:
is-plain-obj "^1.1.0" is-plain-obj "^1.1.0"
kind-of "^6.0.3" kind-of "^6.0.3"
minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.5, minimist@^1.2.6:
version "1.2.6" version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
minimist@^1.2.0:
version "1.2.7"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
minipass-collect@^1.0.2: minipass-collect@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617"
@ -26914,6 +26914,13 @@ zustand@^4.0.0:
dependencies: dependencies:
use-sync-external-store "1.2.0" use-sync-external-store "1.2.0"
zustand@^4.1.4:
version "4.1.5"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.1.5.tgz#7402b511f5b23ccb0f9ba6d20ae01ec817e16eb6"
integrity sha512-PsdRT8Bvq22Yyh1tvpgdHNE7OAeFKqJXUxtJvj1Ixw2B9O2YZ1M34ImQ+xyZah4wZrR4lENMoDUutKPpyXCQ/Q==
dependencies:
use-sync-external-store "1.2.0"
zwitch@^1.0.0: zwitch@^1.0.0:
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"