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_PHONE_NUMBER=
NEXT_PUBLIC_SENDER_ID=
TWILIO_VERIFY_SID=
# 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

View File

@ -429,6 +429,8 @@ following
12. Leave all other fields as they are
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
15. Create a verify service
16. Copy Verify Service SID to your .env file into the TWILIO_VERIFY_SID field
<!-- LICENSE -->

View File

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

View File

@ -1,10 +1,10 @@
import { useState } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterOutputs, trpc } from "@calcom/trpc/react";
import { Shell, SkeletonText } from "@calcom/ui";
import useRouterQuery from "@lib/hooks/useRouterQuery";
type User = RouterOutputs["viewer"]["me"];
export interface IBusySlot {
@ -16,7 +16,9 @@ export interface IBusySlot {
const AvailabilityView = ({ user }: { user: User }) => {
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(
{
@ -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 (
<div className="max-w-xl overflow-hidden rounded-md bg-white shadow">
<div className="px-4 py-5 sm:p-6">
@ -37,9 +50,9 @@ const AvailabilityView = ({ user }: { user: User }) => {
<input
type="date"
className="inline h-8 border-none p-0"
defaultValue={selectedDate.format("YYYY-MM-DD")}
defaultValue={formattedSelectedDate}
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>
@ -49,39 +62,47 @@ const AvailabilityView = ({ user }: { user: User }) => {
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div>
</div>
{isLoading ? (
<>
<SkeletonText className="block h-16 w-full" />
<SkeletonText className="block h-16 w-full" />
</>
) : data && data.busy.length > 0 ? (
data.busy
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
.map((slot: IBusySlot) => (
<div
key={dayjs(slot.start).format("HH:mm")}
className="overflow-hidden rounded-md bg-neutral-100">
<div className="px-4 py-5 text-black sm:p-6">
{t("calendar_shows_busy_between")}{" "}
<span className="font-medium text-neutral-800" title={dayjs(slot.start).format("HH:mm")}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{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>}
{(() => {
if (isLoading)
return (
<>
<SkeletonText className="block h-16 w-full" />
<SkeletonText className="block h-16 w-full" />
</>
);
if (data && (data.busy.length > 0 || overrides.length > 0))
return [...data.busy, ...overrides]
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
.map((slot: IBusySlot) => (
<div
key={dayjs(slot.start).format("HH:mm")}
className="overflow-hidden rounded-md bg-neutral-100"
data-testid="troubleshooter-busy-time">
<div className="px-4 py-5 text-black sm:p-6">
{t("calendar_shows_busy_between")}{" "}
<span
className="font-medium text-neutral-800"
title={dayjs(slot.start).format("HH:mm")}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{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 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>
)}
));
return (
<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>
);
})()}
<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">

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.videoCallData = 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`
// 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_description": "يمكن لتعطيل هذا التطبيق أن يسبب مشاكل مع كيفية تفاعل المستخدمين مع Cal",
"edit_keys": "تحرير المفاتيح",
"admin_apps_description": "تمكين تطبيقات مثيل Cal الخاص بك",
"no_available_apps": "لا توجد تطبيقات متاحة",
"no_available_apps_description": "الرجاء التأكد من وجود تطبيقات في نشرك تحت 'حزم/متجر تطبيقات'",
"no_apps": "لا توجد تطبيقات مفعلة في هذا المظهر من Cal",

View File

@ -1337,5 +1337,6 @@
"test_preview": "Vorschau testen",
"route_to": "Weiterleiten zu",
"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}}: Οδηγίες επαναφοράς κωδικού πρόσβασης",
"event_declined_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",
"impersonate_user_tip": "All uses of this feature is audited.",
"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",
"location_changed_event_type_subject": "Location Changed: {{eventType}} with {{name}} at {{date}}",
"current_location": "Current Location",
@ -1455,6 +1455,12 @@
"event_type_duplicate_copy_text": "{{slug}}-copy",
"set_as_default": "Set as default",
"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}}",
"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",
"default_duration": "Durée par défaut",
"default_duration_no_options": "Veuillez d'abord choisir les durées disponibles",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"minutes": "Minutes",
"round_robin": "Round Robin",
"round_robin_description": "Faites tourner les réunions entre plusieurs membres de l'équipe.",
@ -627,6 +628,7 @@
"teams": "Équipes",
"team": "É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_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.",
@ -702,6 +704,7 @@
"hide_event_type": "Masquer le type d'événement",
"edit_location": "Modifier le lieu",
"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",
"indefinitely_into_future": "Sans doute dans le futur",
"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.",
"integrations": "Intégrations",
"apps": "Applications",
"apps_listing": "Liste des applications",
"category_apps": "Applications {{category}}",
"app_store": "App Store",
"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.",
"select_destination_calendar": "Créer des événements le",
"connect_additional_calendar": "Connecter un calendrier supplémentaire",
"calendar_updated_successfully": "Calendrier mis à jour avec succès",
"conferencing": "Conférence",
"calendar": "Calendrier",
"payments": "Paiements",
@ -776,6 +781,7 @@
"trending_apps": "Applications populaires",
"explore_apps": "{{category}} applications",
"installed_apps": "Applications installées",
"free_to_use_apps": "Gratuit",
"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_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",
"connect_metamask": "Connecter Metamask",
"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",
"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.",
@ -905,6 +913,7 @@
"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.",
"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.",
"impersonating_user_warning": "Identification du nom d'utilisateur \"{{user}}\".",
"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",
"web_conference": "Conférence en ligne",
"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_other": "{{count}} types d'événements",
"add_action": "Ajouter une action",
@ -1109,6 +1121,9 @@
"event_limit_tab_description": "Fréquence de réservation",
"event_advanced_tab_description": "Paramètres du calendrier & plus...",
"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",
"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",
@ -1162,8 +1177,11 @@
"invoices": "Factures",
"embeds": "Intègre",
"impersonation": "Identification",
"impersonation_description": "Paramètres de gestion de l'identité de l'utilisateur",
"users": "Utilisateurs",
"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",
"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",
@ -1368,6 +1386,7 @@
"number_sms_notifications": "Numéro de téléphone (notifications SMS)",
"attendee_email_workflow": "E-mail du participant",
"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.",
"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.",
@ -1378,5 +1397,8 @@
"test_preview": "Tester l'aperçu",
"route_to": "Router vers",
"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_description": "La disattivazione di questa app potrebbe causare problemi con il modo in cui i tuoi utenti interagiscono con Cal",
"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_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",

View File

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

View File

@ -1426,6 +1426,7 @@
"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",
"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_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",

View File

@ -1426,6 +1426,7 @@
"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",
"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_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",

View File

@ -1426,6 +1426,7 @@
"disable_app": "Inaktivera app",
"disable_app_description": "Inaktivering av den här appen kan orsaka problem med hur dina användare interagerar med Cal",
"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_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",

View File

@ -704,6 +704,7 @@
"hide_event_type": "Etkinlik türünü gizle",
"edit_location": "Konumu düzenle",
"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",
"indefinitely_into_future": "Süresiz olarak gelecekte",
"add_new_custom_input_field": "Yeni özel veri girdi alanı ekle",
@ -1032,6 +1033,7 @@
"web_conference": "Web konferansı",
"requires_confirmation": "Onay gerekli",
"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",
"nr_event_type_one": "{{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ı",
"edit_event_type": "Etkinlik türünü düzenle",
"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",
"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ı",
"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_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ı",
"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.",
@ -1414,13 +1424,18 @@
"app_is_disabled": "{{appName}} devre dışı bırakıldı",
"keys_have_been_saved": "Anahtarlar kaydedildi",
"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",
"admin_apps_description": "Cal örneğiniz için uygulamayı etkinleştirin",
"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ı",
"fill_this_field": "Lütfen bu alanı doldurun",
"options": "Seçenekler",
"enter_option": "{{index}} Seçeneğini Girin",
"add_an_option": "Bir seçenek ekle",
"radio": "Radio",
"event_type_duplicate_copy_text": "{{slug}}-kopyala",
"set_as_default": "Varsayılan olarak ayarla"
}

View File

@ -561,6 +561,8 @@
"duration": "Тривалість",
"available_durations": "Доступні варіанти тривалості",
"default_duration": "Тривалість за замовчуванням",
"default_duration_no_options": "Спочатку виберіть доступні варіанти тривалості",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"minutes": "Хвилини",
"round_robin": "Ротація",
"round_robin_description": "Кілька учасників команди призначаються для нарад циклічно й по черзі.",
@ -626,6 +628,7 @@
"teams": "Команди",
"team": "Команда",
"team_billing": "Виставлення рахунків для команд",
"team_billing_description": "Керуйте виставленням рахунків у своїй команді",
"upgrade_to_flexible_pro_title": "Ми змінили умови оплати для команд",
"upgrade_to_flexible_pro_message": "У вашій команді є учасники без придбаних місць. Перейдіть на план Pro, щоб отримати всі потрібні місця.",
"changed_team_billing_info": "Станом на січень 2022 року оплата з учасників команди стягується за кількістю місць. Учасників вашої команди, які безкоштовно користувалися функціями Pro, тепер переведено на 14-денні пробні версії. Щойно пробний період завершиться, учасників вашої команди, для яких не придбано план Pro, буде приховано.",
@ -701,6 +704,7 @@
"hide_event_type": "Приховати тип заходу",
"edit_location": "Змінити розташування",
"into_the_future": "у майбутньому",
"when_booked_with_less_than_notice": "Якщо бронювання створено менше ніж за <time></time> до заходу",
"within_date_range": "У діапазоні дат",
"indefinitely_into_future": "Колись у майбутньому",
"add_new_custom_input_field": "Додати нове власне поле введення",
@ -720,6 +724,7 @@
"delete_account_confirmation_message": "Справді видалити обліковий запис {{appName}}? Усі, кому ви надавали посилання на свій обліковий запис, більше не зможуть бронювати ваш час за його допомогою. Усі збережені налаштування буде втрачено.",
"integrations": "Інтеграції",
"apps": "Додатки",
"apps_listing": "Список додатків",
"category_apps": "Додатки з категорії «{{category}}»",
"app_store": "App Store",
"app_store_description": "Спілкування та технології на робочому місці.",
@ -743,6 +748,7 @@
"toggle_calendars_conflict": "Увімкніть ті календарі, які потрібно перевірити на наявність конфліктів, щоб уникнути подвійних бронювань.",
"select_destination_calendar": "Створюйте заходи в календарі",
"connect_additional_calendar": "Підключити додатковий календар",
"calendar_updated_successfully": "Календар оновлено",
"conferencing": "Відеоконференції",
"calendar": "Календар",
"payments": "Платежі",
@ -775,6 +781,7 @@
"trending_apps": "Популярні додатки",
"explore_apps": "Додатки з категорії «{{category}}»",
"installed_apps": "Установлені додатки",
"free_to_use_apps": "Безкоштовні",
"no_category_apps": "{{category}} — немає додатків",
"no_category_apps_description_calendar": "Додайте додаток для календаря, щоб перевіряти, чи немає конфліктів у розкладі, і уникати подвійних бронювань",
"no_category_apps_description_conferencing": "Спробуйте додати додаток для конференцій, щоб інтегрувати можливість відеорозмов зі своїми клієнтами",
@ -813,6 +820,8 @@
"verify_wallet": "Пройдіть перевірку гаманця",
"connect_metamask": "Підключіть Metamask",
"create_events_on": "Створюйте заходи в календарі",
"enterprise_license": "Це корпоративна функція",
"enterprise_license_description": "Щоб увімкнути цю функцію, отримайте ключ розгортання в консолі {{consoleUrl}} і додайте його у свій файл .env як CALCOM_LICENSE_KEY. Якщо у вашої команди вже є ліцензія, зверніться по допомогу за адресою {{supportMail}}.",
"missing_license": "Відсутня ліцензія",
"signup_requires": "Потрібна комерційна ліцензія",
"signup_requires_description": "{{companyName}} зараз не надає безкоштовну версію сторінки реєстрації з відкритим кодом. Щоб отримати повний доступ до складових функціоналу реєстрації, потрібно придбати комерційну ліцензію. Для особистого використання та створення облікових записів радимо Prisma Data Platform або будь-який інший інтерфейс Postgres.",
@ -904,6 +913,7 @@
"user_impersonation_heading": "Виконання ролі користувача",
"user_impersonation_description": "Ви можете дозволити нашій команді підтримки тимчасово входити в систему під вашим іменем, щоб швидко вирішувати проблеми, про які ви повідомляєте.",
"team_impersonation_description": "Дозвольте адміністраторам своєї команди тимчасово входити під вашим іменем.",
"allow_booker_to_select_duration": "Дозволити автору бронювання вибирати тривалість",
"impersonate_user_tip": "Усі випадки використання цієї функції підпадають під аудит.",
"impersonating_user_warning": "Виконується роль користувача {{user}}.",
"impersonating_stop_instructions": "<0>Натисніть тут, щоб зупинити</0>.",
@ -1021,6 +1031,9 @@
"error_removing_app": "Не вдалося вилучити додаток",
"web_conference": "Вебконференція",
"requires_confirmation": "Потрібне підтвердження",
"always_requires_confirmation": "Завжди",
"requires_confirmation_threshold": "Вимагати підтвердження, якщо бронювання створено менше ніж за {{time}} $t({{unit}}_timeUnit) до заходу",
"may_require_confirmation": "Може вимагати підтвердження",
"nr_event_type_one": "{{count}} тип заходу",
"nr_event_type_other": "Типів заходів: {{count}}",
"add_action": "Додати дію",
@ -1108,6 +1121,9 @@
"event_limit_tab_description": "Як часто ваш час можуть бронювати",
"event_advanced_tab_description": "Налаштування календаря та інше…",
"event_advanced_tab_title": "Додатково",
"event_setup_multiple_duration_error": "Налаштування заходу: якщо мають бути доступні різні варіанти, потрібно вказати принаймні один.",
"event_setup_multiple_duration_default_error": "Налаштування заходу: виберіть припустиму тривалість за замовчуванням.",
"event_setup_booking_limits_error": "Ліміти на бронювання мають бути впорядковані за зростанням. [день,тиждень,місяць,рік]",
"select_which_cal": "Виберіть календар, у який додаватимуться бронювання",
"custom_event_name": "Користувацька назва події",
"custom_event_name_description": "Вибирайте для заходів власні назви, що показуватимуться в календарі",
@ -1161,8 +1177,11 @@
"invoices": "Рахунки-фактури",
"embeds": "Вставки",
"impersonation": "Вхід під іншим іменем",
"impersonation_description": "Налаштування входу під іншим іменем",
"users": "Користувачі",
"profile_description": "Керуйте налаштуваннями свого профілю {{appName}}",
"users_description": "Тут наведено список усіх користувачів",
"users_listing": "Список користувачів",
"general_description": "Налаштуйте параметри мови й часового поясу",
"calendars_description": "Налаштуйте, як типи заходів мають взаємодіяти з вашими календарями",
"appearance_description": "Налаштуйте варіанти оформлення свого бронювання",
@ -1367,6 +1386,7 @@
"number_sms_notifications": "Номер телефону (SMS-сповіщення)",
"attendee_email_workflow": "Адреса ел. пошти учасника",
"attendee_email_info": "Адреса ел. пошти особи, яка бронює",
"kbar_search_placeholder": "Введіть команду або пошуковий запис…",
"invalid_credential": "Отакої! Схоже, дозвіл більше не дійсний або його відкликано. Перевстановіть додаток знову.",
"choose_common_schedule_team_event": "Виберіть спільний розклад",
"choose_common_schedule_team_event_description": "Увімкніть цей параметр, щоб використовувати спільний для двох ведучих розклад. Якщо цей параметр вимкнено, бронювання для кожного з ведучих відбуватиметься за їхніми власними графіками.",
@ -1377,5 +1397,43 @@
"test_preview": "Перевірити попередній перегляд",
"route_to": "Кінцева точка",
"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_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",
"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",
"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.",
@ -624,6 +628,7 @@
"teams": "Các nhóm",
"team": "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_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.",
@ -699,6 +704,7 @@
"hide_event_type": "Ẩn loại sự kiện",
"edit_location": "Chỉnh sửa vị trí",
"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",
"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",
@ -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.",
"integrations": "Tích hợp",
"apps": "Ứng dụng",
"apps_listing": "Danh sách ứng dụng",
"category_apps": "Ứng dụng {{category}}",
"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.",
@ -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.",
"select_destination_calendar": "Tạo sự kiện trên",
"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ị",
"calendar": "Lịch",
"payments": "Thanh toán",
@ -773,6 +781,7 @@
"trending_apps": "Ứng dụng thịnh hành",
"explore_apps": "{{category}} ứng dụng",
"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_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",
@ -811,6 +820,8 @@
"verify_wallet": "Xác minh Ví",
"connect_metamask": "Kết nối Metamask",
"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",
"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.",
@ -902,6 +913,7 @@
"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.",
"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.",
"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>.",
@ -1019,6 +1031,9 @@
"error_removing_app": "Lỗi khi gỡ bỏ ứng dụng",
"web_conference": "Hội nghị web",
"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_other": "{{count}} loại sự kiện",
"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_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_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",
"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",
@ -1159,8 +1177,11 @@
"invoices": "Hoá đơn",
"embeds": "Nhúng",
"impersonation": "Mạo danh",
"impersonation_description": "Cài đặt để quản lý mạo danh 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",
"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",
"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",
@ -1365,6 +1386,7 @@
"number_sms_notifications": "Số điện thoại (thông báo SMS)",
"attendee_email_workflow": "Email người tham dự",
"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.",
"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ọ.",
@ -1375,5 +1397,9 @@
"test_preview": "Kiểm tra Xem trước",
"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_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_description": "禁用此应用可能会导致您的用户与 Cal 的交互方式出现问题",
"edit_keys": "编辑密钥",
"admin_apps_description": "为您的 Cal 实例启用应用",
"no_available_apps": "没有可用的应用",
"no_available_apps_description": "请确保您在“packages/app-store”下的部署中有应用",
"no_apps": "此 Cal 实例中未启用任何应用",

View File

@ -40,8 +40,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await prisma.credential.create({
data,
});
} catch (reason) {
logger.error("Could not add this caldav account", reason);
} catch (e) {
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" });
}

View File

@ -18,6 +18,7 @@ export default function CalDavCalendarSetup() {
});
const [errorMessage, setErrorMessage] = useState("");
const [errorActionUrl, setErrorActionUrl] = useState("");
return (
<div className="flex h-screen bg-gray-200">
@ -49,6 +50,9 @@ export default function CalDavCalendarSetup() {
const json = await res.json();
if (!res.ok) {
setErrorMessage(json?.message || t("something_went_wrong"));
if (json.actionUrl) {
setErrorActionUrl(json.actionUrl);
}
} else {
router.push(json.url);
}
@ -78,7 +82,24 @@ export default function CalDavCalendarSetup() {
/>
</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">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}

View File

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

View File

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

View File

@ -42,7 +42,7 @@ export const TimeTimeUnitInput = (props: Props) => {
<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">
<div className="flex">
<div className="w-3/4">
<div className="mr-1 w-3/4">
{timeUnit ? t(`${timeUnit.toLowerCase()}_timeUnit`) : "undefined"}{" "}
</div>
<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 { useLocale } from "@calcom/lib/hooks/useLocale";
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 { Button, Icon, Label, MultiSelectCheckboxes, TextField } from "@calcom/ui";
import type { FormValues } from "../pages/workflow";
import { AddActionDialog } from "./AddActionDialog";
@ -74,6 +72,7 @@ export default function WorkflowDetailsPage(props: Props) {
template: WorkflowTemplates.CUSTOM,
numberRequired: numberRequired || false,
sender: sender || SENDER_ID,
numberVerificationPending: false,
};
steps?.push(step);
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 "react-phone-number-input/style.css";
import { classNames } from "@calcom/lib";
import { SENDER_ID } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc, TRPCClientError } from "@calcom/trpc/react";
import {
Badge,
Button,
Checkbox,
ConfirmationDialogContent,
@ -35,11 +37,7 @@ import {
} from "@calcom/ui";
import { AddVariablesDropdown } from "../components/AddVariablesDropdown";
import {
getWorkflowActionOptions,
getWorkflowTemplateOptions,
getWorkflowTriggerOptions,
} from "../lib/getOptions";
import { getWorkflowTemplateOptions, getWorkflowTriggerOptions } from "../lib/getOptions";
import { translateVariablesToEnglish } from "../lib/variableTranslations";
import type { FormValues } from "../pages/workflow";
import Editor from "./TextEditor/Editor";
@ -54,10 +52,15 @@ type WorkflowStepProps = {
export default function WorkflowStepContainer(props: WorkflowStepProps) {
const { t, i18n } = useLocale();
const utils = trpc.useContext();
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 [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [verificationCode, setVerificationCode] = useState("");
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(
step?.action === WorkflowActions.SMS_NUMBER ? true : false
);
@ -108,6 +111,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
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) => {
if (step) {
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({
onSuccess: async () => {
showToast(t("notification_sent"), "success");
@ -137,6 +170,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
if (err instanceof TRPCClientError) {
if (err.message === "rate-limit-exceeded") {
message = t("rate_limit_exceeded");
} else {
message = err.message;
}
}
if (err instanceof HttpError) {
@ -311,7 +346,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(val.value === WorkflowActions.SMS_NUMBER);
setNumberVerified(false);
if (!wasSMSAction) {
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, "");
}
@ -356,20 +391,76 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
{isPhoneNumberNeeded && (
<>
<Label className="pt-4">{t("custom_phone_number")}</Label>
<PhoneInput<FormValues>
control={form.control}
name={`steps.${step.stepNumber - 1}.sendTo`}
placeholder={t("phone_number")}
id={`steps.${step.stepNumber - 1}.sendTo`}
className="w-full rounded-md"
required
/>
<div className="block sm:flex">
<PhoneInput<FormValues>
control={form.control}
name={`steps.${step.stepNumber - 1}.sendTo`}
placeholder={t("phone_number")}
id={`steps.${step.stepNumber - 1}.sendTo`}
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[step.stepNumber - 1]?.sendTo && (
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</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 && (
@ -525,6 +616,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
className="mt-7 w-full"
onClick={() => {
let isEmpty = false;
if (!form.getValues(`steps.${step.stepNumber - 1}.sendTo`) && isPhoneNumberNeeded) {
form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
type: "custom",
@ -532,6 +624,13 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
});
isEmpty = true;
}
if (!numberVerified && isPhoneNumberNeeded) {
form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
type: "custom",
message: t("not_verified"),
});
}
if (
form.getValues(`steps.${step.stepNumber - 1}.template`) === WorkflowTemplates.CUSTOM
) {
@ -565,11 +664,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
);
testActionMutation.mutate({
action: step.action,
step,
emailSubject,
reminderBody,
template: step.template,
sender: step.sender || SENDER_ID,
});
} else {
const isNumberValid =
@ -578,7 +675,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
? false
: true;
if (isPhoneNumberNeeded && isNumberValid && !isEmpty) {
if (isPhoneNumberNeeded && isNumberValid && !isEmpty && numberVerified) {
setConfirmationDialogOpen(true);
}
}
@ -603,12 +700,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
);
testActionMutation.mutate({
action: step.action,
step,
emailSubject: "",
reminderBody: reminderBody || "",
template: step.template,
sendTo: step.sendTo || "",
sender: step.sender || SENDER_ID,
});
setConfirmationDialogOpen(false);
}}>

View File

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

View File

@ -49,3 +49,26 @@ export const cancelSMS = async (referenceId: string) => {
assertTwilio(twilio);
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,
workflowStepId: number,
template: WorkflowTemplates,
sender: string
sender: string,
userId: number,
isVerificationPending = false
) => {
const { startTime, endTime } = evt;
const uid = evt.uid as string;
@ -60,6 +62,18 @@ export const scheduleSMSReminder = async (
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) {
scheduledDate = timeSpan.time && timeUnit ? dayjs(startTime).subtract(timeSpan.time, timeUnit) : null;
} else if (triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) {
@ -93,7 +107,7 @@ export const scheduleSMSReminder = async (
break;
}
if (message.length > 0 && reminderPhone) {
if (message.length > 0 && reminderPhone && isNumberVerified) {
//send SMS when event is booked/cancelled/rescheduled
if (
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(() => {
if (workflow && !isLoading) {
setSelectedEventTypes(
@ -175,6 +177,7 @@ function WorkflowPage() {
handleSubmit={async (values) => {
let activeOnEventTypeIds: number[] = [];
let isEmpty = false;
let isVerified = true;
values.steps.forEach((step) => {
const isSMSAction =
@ -199,9 +202,22 @@ function WorkflowPage() {
step.emailSubject = translateVariablesToEnglish(step.emailSubject, { locale: i18n.language, t });
}
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) {
activeOnEventTypeIds = values.activeOn.map((option) => {
return parseInt(option.value, 10);
@ -216,6 +232,7 @@ function WorkflowPage() {
time: values.time || null,
timeUnit: values.timeUnit || null,
});
utils.viewer.workflows.getVerifiedNumbers.invalidate();
}
}}>
<Shell

View File

@ -146,10 +146,16 @@ const DateOverrideForm = ({
label={t("date_overrides_mark_all_day_unavailable_one")}
checked={datesUnavailable}
onCheckedChange={setDatesUnavailable}
data-testid="date-override-mark-unavailable"
/>
</div>
<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")}
</Button>
<DialogClose onClick={onClose} />

View File

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

View File

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

View File

@ -54,6 +54,7 @@ export const buildBooking = (booking?: Partial<Booking>): Booking => {
recurringEventId: null,
smsReminderNumber: null,
scheduledJobs: [],
metadata: null,
...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")
workflows Workflow[]
routingForms App_RoutingForms_Form[] @relation("routing-form")
verifiedNumbers VerifiedNumber[]
@@map(name: "users")
}
@ -300,6 +301,8 @@ model Booking {
smsReminderNumber String?
workflowReminders WorkflowReminder[]
scheduledJobs String[]
/// @zod.custom(imports.bookingMetadataSchema)
metadata Json?
}
model Schedule {
@ -559,18 +562,19 @@ enum WorkflowActions {
}
model WorkflowStep {
id Int @id @default(autoincrement())
stepNumber Int
action WorkflowActions
workflowId Int
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
sendTo String?
reminderBody String?
emailSubject String?
template WorkflowTemplates @default(REMINDER)
workflowReminders WorkflowReminder[]
numberRequired Boolean?
sender String?
id Int @id @default(autoincrement())
stepNumber Int
action WorkflowActions
workflowId Int
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
sendTo String?
reminderBody String?
emailSubject String?
template WorkflowTemplates @default(REMINDER)
workflowReminders WorkflowReminder[]
numberRequired Boolean?
sender String?
numberVerificationPending Boolean @default(true)
}
model Workflow {
@ -620,3 +624,10 @@ enum WorkflowMethods {
EMAIL
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()
.nullable();
export const bookingMetadataSchema = z
.object({
videoCallUrl: z.string().optional(),
})
.nullable();
export const customInputOptionSchema = z.array(
z.object({
label: z.string(),

View File

@ -27,6 +27,10 @@ import {
deleteScheduledSMSReminder,
scheduleSMSReminder,
} 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 { getErrorFromUnknown } from "@calcom/lib/errors";
import { getTranslation } from "@calcom/lib/server/i18n";
@ -162,6 +166,7 @@ export const workflowsRouter = router({
template: WorkflowTemplates.REMINDER,
workflowId: workflow.id,
sender: SENDER_ID,
numberVerificationPending: false,
},
});
return { workflow };
@ -481,7 +486,8 @@ export const workflowsRouter = router({
step.reminderBody || "",
step.id,
step.template,
step.sender || SENDER_ID
step.sender || SENDER_ID,
user.id
);
}
});
@ -555,6 +561,7 @@ export const workflowsRouter = router({
template: newStep.template,
numberRequired: newStep.numberRequired,
sender: newStep.sender || SENDER_ID,
numberVerificationPending: false,
},
});
//cancel all reminders of step and create new ones (not for newEventTypes)
@ -666,7 +673,8 @@ export const workflowsRouter = router({
newStep.reminderBody || "",
newStep.id || 0,
newStep.template,
newStep.sender || SENDER_ID
newStep.sender || SENDER_ID,
user.id
);
}
});
@ -695,7 +703,7 @@ export const workflowsRouter = router({
const newStep = step;
newStep.sender = step.sender || SENDER_ID;
const createdStep = await ctx.prisma.workflowStep.create({
data: step,
data: { ...step, numberVerificationPending: false },
});
if (
(trigger === WorkflowTriggerEvents.BEFORE_EVENT ||
@ -782,7 +790,8 @@ export const workflowsRouter = router({
step.reminderBody || "",
createdStep.id,
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 })
.input(
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(),
reminderBody: z.string(),
template: z.enum(WORKFLOW_TEMPLATES),
sendTo: z.string().optional(),
sender: z.string().optional(),
})
)
.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 {
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({
orderBy: {
createdAt: "desc",
@ -919,7 +974,8 @@ export const workflowsRouter = router({
reminderBody,
0,
template,
sender || SENDER_ID
senderID,
ctx.user.id
);
return { message: "Notification sent" };
}
@ -973,15 +1029,7 @@ export const workflowsRouter = router({
if (!eventTypeWorkflow)
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
const isActive = await ctx.prisma.workflowsOnEventTypes.findFirst({
where: {
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 }) => {
const userId = ctx.user.id;
const hasTeamPlan = (await ctx.prisma.membership.count({ where: { userId } })) > 0;

View File

@ -9,15 +9,22 @@ export type PhoneInputProps<FormValues> = Props<
required: boolean;
},
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 (
<BasePhoneInput
{...rest}
international
name={name}
control={control}
onChange={onChange}
countrySelectProps={{ className: "text-black" }}
numberInputProps={{
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 { useLocale } from "@calcom/lib/hooks/useLocale";
type DefaultStep = {
title: string;
};
@ -10,13 +12,14 @@ function Stepper<T extends DefaultStep>(props: {
steps: T[];
disableSteps?: boolean;
}) {
const { t } = useLocale();
const { href, steps } = props;
return (
<>
{steps.length > 1 && (
<nav className="flex items-center justify-center" aria-label="Progress">
<p className="text-sm font-medium">
Step {props.step} of {steps.length}
{t("current_step_of_total", { currentStep: props.step, maxSteps: steps.length })}
</p>
<ol role="list" className="ml-8 flex items-center space-x-5">
{steps.map((mapStep, index) => (

View File

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

View File

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

View File

@ -7903,16 +7903,11 @@
dependencies:
"@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"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708"
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":
version "6.4.5"
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==
loader-utils@^1.2.3:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613"
integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==
version "1.4.2"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.2.tgz#29a957f3a63973883eb684f10ffd3d151fec01a3"
integrity sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==
dependencies:
big.js "^5.2.2"
emojis-list "^3.0.0"
@ -19117,11 +19112,16 @@ minimist-options@4.1.0:
is-plain-obj "^1.1.0"
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"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
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:
version "1.0.2"
resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617"
@ -26914,6 +26914,13 @@ zustand@^4.0.0:
dependencies:
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:
version "1.0.5"
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"