Compare commits

...
33 Commits
Author SHA1 Message Date
Alex van Andel 29923fe304 v3.3.1
release.yml / release (push) Canceled after 0s
2023-09-15 16:28:09 +01:00
Richard PoelderlandAlex van Andel e8998414fc feat(i18n): changed Japanese translations on booking page (#11388)
* regen yarn.lock?

* chore: enable i18n-unused GH action in `website`

* Generated yarn.lock with submodules

* Added yarn.lock auth

* add datocms webhook secret

* update crowdin to latest version

* cherry-picked https://github.com/calcom/cal.com/pull/10416/files

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-09-15 16:25:49 +01:00
Richard PoelderlandAlex van Andel 2e58e73e95 chore: Update GH action from crowdin to latest version (#11387)
* regen yarn.lock?

* chore: enable i18n-unused GH action in `website`

* Generated yarn.lock with submodules

* Added yarn.lock auth

* add datocms webhook secret

* update crowdin to latest version

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-09-15 14:43:49 +00:00
Crowdin Bot b7168948ca New Crowdin translations by Github Action 2023-09-15 14:01:21 +00:00
sean-brydon c188a91d64 fix: Safari overflowing issues (#11385) 2023-09-15 15:57:49 +02:00
Crowdin Bot 4724b1312c New Crowdin translations by Github Action 2023-09-15 13:57:43 +00:00
Hariom Balhara d349f6536a fix: Embed - app.cal.com instead of cal.com (#11384)
* Fix yarn.lock

* Force app.cal.com because Safari and rewrites using referrer

* Add comment
2023-09-15 14:54:25 +01:00
Crowdin Bot f35f506ddf New Crowdin translations by Github Action 2023-09-15 13:43:44 +00:00
Crowdin Bot 46d0b67f81 New Crowdin translations by Github Action 2023-09-15 13:40:27 +00:00
Crowdin Bot 7000cf7581 New Crowdin translations by Github Action 2023-09-15 13:36:53 +00:00
Crowdin Bot 1c0faf4eb6 New Crowdin translations by Github Action 2023-09-15 13:33:26 +00:00
Alex van Andel 6d0f0d6bdd fix: Include returns all credential fields, select excludes (#11383) 2023-09-15 13:29:44 +00:00
Crowdin Bot cb4a3dd2a6 New Crowdin translations by Github Action 2023-09-15 13:25:56 +00:00
Crowdin Bot fc623fc511 New Crowdin translations by Github Action 2023-09-15 13:22:33 +00:00
Crowdin Bot 9db0834b50 New Crowdin translations by Github Action 2023-09-15 13:18:41 +00:00
Crowdin Bot 7a3cc7d263 New Crowdin translations by Github Action 2023-09-15 13:14:24 +00:00
Crowdin Bot f1381d8a82 New Crowdin translations by Github Action 2023-09-15 13:11:06 +00:00
Crowdin Bot 5c136df1bb New Crowdin translations by Github Action 2023-09-15 13:07:33 +00:00
Crowdin Bot e18f08249b New Crowdin translations by Github Action 2023-09-15 13:04:29 +00:00
Crowdin Bot 5b8b165a3b New Crowdin translations by Github Action 2023-09-15 13:01:21 +00:00
Somay ChauhanandShivam Kalra ab39c8f581 fix: a new email can signin with any password once it's invited to a team (#11359)
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-09-15 12:57:41 +00:00
Crowdin Bot abc077bfb6 New Crowdin translations by Github Action 2023-09-15 12:55:16 +00:00
Crowdin Bot bb167e7763 New Crowdin translations by Github Action 2023-09-15 12:52:03 +00:00
Crowdin Bot 3b92ff607a New Crowdin translations by Github Action 2023-09-15 12:47:56 +00:00
Crowdin Bot 129d00a69d New Crowdin translations by Github Action 2023-09-15 12:44:42 +00:00
Crowdin Bot c86e766aee New Crowdin translations by Github Action 2023-09-15 12:41:46 +00:00
Leo Giovanetti a2873c740b fix: team page data (#11382) 2023-09-15 13:38:55 +01:00
Crowdin Bot 6e3a1369f8 New Crowdin translations by Github Action 2023-09-15 11:55:51 +00:00
Chiranjeev Vishnoi e44e24ca42 fix: 2FA password visibility toggle show/hide state (#11361) 2023-09-15 17:22:58 +05:30
Somay Chauhan 768a3132fb fix: settings/organization/members table filtering issue (#11313) 2023-09-15 17:20:40 +05:30
Crowdin Bot 41812bb280 New Crowdin translations by Github Action 2023-09-15 10:19:46 +00:00
alannnc cb1d3487af chore: edit and update paypal setup instructions (#11325) 2023-09-15 15:46:28 +05:30
Omar López c95917cceb feat: Calendar Cache (#11185) 2023-09-14 15:19:39 -07:00
23 changed files with 784 additions and 291 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: crowdin action
uses: crowdin/github-action@1.5.1
uses: crowdin/github-action@v1.13.0
with:
upload_sources: true
upload_translations: true
@@ -173,7 +173,6 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
<div className="mb-4">
<PasswordField
label={t("password")}
type="password"
name="password"
id="password"
required
+1 -1
View File
@@ -9,7 +9,7 @@ import { Avatar } from "@calcom/ui";
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
type MembersType = TeamType["members"];
type MemberType = MembersType[number] & { safeBio: string | null };
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username"> & { safeBio: string | null };
type TeamTypeWithSafeHtml = Omit<TeamType, "members" | "inviteToken"> & { members: MemberType[] };
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.3.0",
"version": "3.3.1",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+1 -1
View File
@@ -154,7 +154,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
<div className="flex flex-wrap items-center">
<h2 className=" text-default pr-2 text-sm font-semibold">{type.title}</h2>
</div>
<EventTypeDescription eventType={type} isPublic={true} />
<EventTypeDescription eventType={type} isPublic={true} shortenDescription />
</Link>
</div>
</div>
@@ -0,0 +1,16 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const deleted = await prisma.calendarCache.deleteMany({
where: {
// Delete all cache entries that expired before now
expiresAt: {
lte: new Date(Date.now()),
},
},
});
res.json({ ok: true, count: deleted.count });
}
+7 -1
View File
@@ -312,7 +312,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const safeBio = markdownToSafeHTML(team.bio) || "";
const members = team.members.map((member) => {
return { ...member, safeBio: markdownToSafeHTML(member.bio || "") };
return {
name: member.name,
id: member.id,
bio: member.bio,
username: member.username,
safeBio: markdownToSafeHTML(member.bio || ""),
};
});
const markdownStrippedBio = stripMarkdown(team?.bio || "");
@@ -267,10 +267,10 @@
"connect": "接続",
"try_for_free": "無料で試す",
"create_booking_link_with_calcom": "{{appName}} で独自の予約リンクを作成する",
"who": "誰が",
"what": "何を",
"when": "いつ",
"where": "どこで",
"who": "ホスト",
"what": "イベント",
"when": "日時",
"where": "参加方法",
"add_to_calendar": "カレンダーに追加",
"add_another_calendar": "別のカレンダーを追加",
"other": "その他",
@@ -282,7 +282,7 @@
"needs_to_be_confirmed_or_rejected_recurring": "繰り返しミーティングは、まだ確認または拒否が必要です。",
"user_needs_to_confirm_or_reject_booking": "{{user}} はまだ予約を承認または拒否する必要があります",
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} は、まだ繰り返しミーティングの各予約を確認または拒否する必要があります。",
"meeting_is_scheduled": "このミーティングは予定されています",
"meeting_is_scheduled": "このミーティングを予定しました",
"meeting_is_scheduled_recurring": "繰り返しイベントがスケジュールされています",
"booking_submitted": "予約が送信されました",
"booking_submitted_recurring": "繰り返しミーティングが送信されました",
@@ -17,9 +17,15 @@
"verify_email_banner_body": "이메일 및 일정을 가능한 한 차질 없이 보내드릴 수 있도록 이메일 주소를 인증하세요.",
"verify_email_email_header": "이메일 주소 인증",
"verify_email_email_button": "이메일 인증",
"copy_somewhere_safe": "이 API 키를 안전한 곳에 보관하세요. 이 키는 다시 볼 수 없게 됩니다.",
"verify_email_email_body": "아래 버튼을 클릭하여 이메일 주소를 인증하세요.",
"verify_email_by_code_email_body": "아래 코드를 사용하여 이메일 주소를 인증하세요.",
"verify_email_email_link_text": "버튼 클릭이 마음에 들지 않는 경우 다음 링크를 사용하세요.",
"email_verification_code": "인증 코드 입력",
"email_verification_code_placeholder": "메일로 전송된 인증번호를 입력하세요",
"incorrect_email_verification_code": "인증 코드가 올바르지 않습니다.",
"email_sent": "이메일이 성공적으로 전송되었습니다",
"email_not_sent": "이메일을 보내는 중 오류가 발생했습니다",
"event_declined_subject": "거절됨: {{date}} {{title}}",
"event_cancelled_subject": "취소됨: {{title}}, 날짜 {{date}}",
"event_request_declined": "회의 요청이 거절되었습니다.",
@@ -78,6 +84,7 @@
"event_awaiting_approval_recurring": "되풀이 이벤트가 승인을 기다리고 있습니다",
"someone_requested_an_event": "누군가 귀하의 캘린더에 일정을 예약하도록 요청했습니다.",
"someone_requested_password_reset": "누군가 귀하의 비밀번호 변경을 위한 링크를 요청했습니다.",
"password_reset_email_sent": "이 이메일이 당사 시스템에 존재하는 경우, 재설정 이메일을 받게 됩니다.",
"password_reset_instructions": "요청하지 않으셨다면 이 이메일을 무시하십시오. 비밀번호는 변경되지 않습니다.",
"event_awaiting_approval_subject": "승인을 기다리고 있습니다: {{date}} {{title}}",
"event_still_awaiting_approval": "이벤트가 아직 승인을 기다리고 있습니다.",
@@ -217,6 +224,10 @@
"already_have_an_account": "이미 계정이 있으신가요?",
"create_account": "계정 만들기",
"confirm_password": "비밀번호 확인",
"confirm_auth_change": "이것으로 로그인 방식이 변경됩니다",
"confirm_auth_email_change": "이메일 주소를 변경하면 Cal.com에 로그인하는 기존 인증 방법이 연결 해제됩니다. 새 이메일 주소를 인증하라는 메시지가 표시됩니다. 앞으로는 메일로 전송되는 지침에 따라 비밀번호를 설정한 후 로그아웃하고 기존 인증 방법 대신 새 이메일 주소를 사용하여 로그인하게 됩니다.",
"reset_your_password": "이메일 주소로 전송된 지침에 따라 새 비밀번호를 설정하십시오.",
"email_change": "새 이메일 주소와 비밀번호로 다시 로그인하십시오.",
"create_your_account": "계정 만들기",
"sign_up": "가입하기",
"youve_been_logged_out": "로그아웃 되었습니다.",
@@ -243,6 +254,10 @@
"all": "모두",
"yours": "사용자 계정",
"available_apps": "사용 가능한 앱",
"available_apps_lower_case": "사용 가능한 앱",
"available_apps_desc": "설치된 앱이 없습니다. 아래에서 인기 앱을 확인하고 <1>App Store</1>에서 더 많은 앱을 탐색해 보세요",
"fixed_host_helper": "이벤트에 참석해야 하는 인원을 추가하세요. <1>자세히 알아보기</1>",
"round_robin_helper": "그룹의 인원들은 차례로 돌아가며 한 명만 이벤트에 참석하게 됩니다.",
"check_email_reset_password": "이메일을 확인하세요. 비밀번호 재설정을 위한 링크를 보냈습니다.",
"finish": "끝",
"organization_general_description": "팀 언어 및 시간대 설정 관리",
@@ -251,6 +266,7 @@
"nearly_there_instructions": "마지막으로, 예약에 도움이 되고 사람들이 본인에 대해 알 수 있도록 본인에 대한 간단한 소개와 사진을 올려주세요.",
"set_availability_instructions": "주기적으로 시간이 되는 때가 언제인지 알려주세요. 나중에 더 추가할 수 있습니다.",
"set_availability": "언제 여유가 있는지 설정하세요.",
"availability_settings": "사용 가능한 설정",
"continue_without_calendar": "캘린더 없이 계속하기",
"connect_your_calendar": "캘린더 연결하기",
"connect_your_video_app": "비디오 앱 연결",
@@ -392,6 +408,8 @@
"allow_dynamic_booking_tooltip": "'+' 기호로 여러 사용자 이름을 추가하여 동적으로 생성할 수 있는 그룹 예약 링크. 예: '{{appName}}/bailey+peer'",
"allow_dynamic_booking": "참석자가 동적 그룹 예약을 통해 귀하를 예약하도록 허용",
"dynamic_booking": "동적 그룹 링크",
"allow_seo_indexing": "검색 엔진이 공개 콘텐츠에 액세스하도록 허용",
"seo_indexing": "SEO 인덱싱 허용",
"email": "이메일",
"email_placeholder": "jdoe@example.com",
"full_name": "이름",
@@ -406,6 +424,7 @@
"booking_requested": "예약 요청됨",
"meeting_ended": "회의 종료됨",
"form_submitted": "양식 제출됨",
"booking_paid": "예약 결제됨",
"event_triggers": "이벤트 트리거",
"subscriber_url": "구독자 URL",
"create_new_webhook": "새 웹훅 만들기",
@@ -506,6 +525,7 @@
"your_name": "당신의 이름",
"your_full_name": "전체 이름",
"no_name": "이름 없음",
"enter_number_between_range": "1에서 {{maxOccurences}} 사이의 숫자를 입력하세요",
"email_address": "이메일 주소",
"enter_valid_email": "유효한 이메일을 입력하세요",
"location": "위치",
@@ -543,6 +563,7 @@
"leave": "떠나기",
"profile": "프로필",
"my_team_url": "우리팀 URL",
"my_teams": "내 팀",
"team_name": "팀 이름",
"your_team_name": "팀 이름",
"team_updated_successfully": "팀이 성공적으로 업데이트되었습니다.",
@@ -570,6 +591,8 @@
"invite_new_member": "새 팀원 초대",
"invite_new_member_description": "참고: 이로 인해 해당 구독에 <1>추가 시트 비용($15/m)</1>이 발생합니다.",
"invite_new_team_member": "누군가를 팀에 초대하기",
"upload_csv_file": ".csv 파일 업로드",
"invite_via_email": "이메일을 통한 초대",
"change_member_role": "팀원 역할 변경",
"disable_cal_branding": "{{appName}} 브랜딩 비활성화",
"disable_cal_branding_description": "공개 페이지에서 모든 {{appName}} 브랜딩 숨기기",
@@ -842,6 +865,7 @@
"team_view_user_availability_disabled": "사용 가능 여부를 보려면 사용자가 초대를 수락해야 합니다",
"set_as_away": "본인을 공석으로 설정",
"set_as_free": "공석 상태 비활성화",
"toggle_away_error": "부재중 상태를 업데이트하는 중 오류가 발생했습니다",
"user_away": "이 사용자는 현재 자리를 비웠습니다.",
"user_away_description": "예약하려는 사람이 자신을 공석으로 설정했기 때문에 새 예약을 받고 있지 않습니다.",
"meet_people_with_the_same_tokens": "같은 토큰을 가진 사람들을 만나세요",
@@ -851,6 +875,7 @@
"account_managed_by_identity_provider_description": "이메일, 비밀번호를 변경하고 이중 인증을 활성화하려면 {{provider}} 계정 설정을 방문하세요.",
"signin_with_google": "Google로 로그인",
"signin_with_saml": "SAML로 로그인",
"signin_with_saml_oidc": "SAML/OIDC로 로그인",
"you_will_need_to_generate": "이전 일정 관리 도구에서 액세스 토큰을 생성해야 합니다.",
"import": "가져오기",
"import_from": "가져오기 위치",
@@ -950,6 +975,8 @@
"offer_seats_description": "예약에 좌석 제공(게스트를 비활성화하고 예약을 선택함)",
"seats_available_one": "사용 가능한 좌석",
"seats_available_other": "사용 가능한 좌석",
"seats_nearly_full": "좌석이 거의 꽉 찼습니다",
"seats_half_full": "좌석이 빠르게 채워지는 중",
"number_of_seats": "예약당 좌석 수",
"enter_number_of_seats": "좌석 수 입력",
"you_can_manage_your_schedules": "가용성 페이지에서 자신의 일정을 관리할 수 있습니다.",
@@ -1030,12 +1057,15 @@
"how_you_want_add_cal_site": "귀하의 사이트에 {{appName}}을 어떻게 추가하시겠습니까?",
"choose_ways_put_cal_site": "귀하의 사이트에 {{appName}}을 추가하는 다음 방법 중 하나를 선택하십시오.",
"setting_up_zapier": "Zapier 통합을 설정하는 중",
"setting_up_make": "Make 통합을 설정하는 중",
"generate_api_key": "API 키 생성",
"generate_api_key_description": "{{appName}}에서 사용할 API 키 생성 위치",
"your_unique_api_key": "귀하의 고유 API 키",
"copy_safe_api_key": "이 API 키를 복사하여 안전한 곳에 보관하십시오. 이 키를 분실한 경우 새 키를 생성해야 합니다.",
"zapier_setup_instructions": "<0>Zapier 계정에 로그인하고 새 Zap을 만듭니다.</0><1>Cal.com을 트리거 앱으로 선택합니다. 또한 트리거 이벤트를 선택합니다.</1><2>계정을 선택한 후 고유 API 키를 입력합니다.</2><3>트리거를 테스트합니다.</3><4>설정이 완료되었습니다!</4 >",
"make_setup_instructions": "<0><1><0>초대 링크 만들기</0></1>로 이동하여 Cal.com 앱을 설치합니다.</0><1>Make 계정에 로그인하고 새 시나리오를 만듭니다.</1><2>Cal.com을 Trigger 앱으로 선택합니다. 트리거 이벤트도 선택합니다.</2><3>계정을 선택한 후 고유 API 키를 입력합니다.</3><4>트리거를 테스트합니다.</4><5>이제 준비되었습니다!</5 >",
"install_zapier_app": "먼저 앱 스토어에서 Zapier 앱을 설치하십시오.",
"install_make_app": "먼저 앱 스토어에서 Make 앱을 설치하십시오.",
"connect_apple_server": "Apple 서버에 연결하기",
"calendar_url": "캘린더 URL",
"apple_server_generate_password": "{{appName}}에서 사용할 앱 비밀번호 생성:",
@@ -1085,6 +1115,7 @@
"email_attendee_action": "참석자들에게 이메일 전송",
"sms_attendee_action": "참석자에게 SMS 발송",
"sms_number_action": "특정 번호로 SMS 발송",
"send_reminder_sms": "SMS를 통해 참석자에게 회의 알림을 쉽게 보낼 수 있습니다",
"whatsapp_number_action": "특정 번호로 Whatsapp 발송",
"whatsapp_attendee_action": "참석자에게 Whatsapp 발송",
"workflows": "워크플로",
@@ -1244,6 +1275,7 @@
"error_updating_settings": "설정 업데이트 중 오류 발생",
"personal_cal_url": "내 개인 {{appName}} URL",
"bio_hint": "자신에 대해 몇 마디를 써보세요. 개인 URL 페이지에 보이는 내용입니다.",
"user_has_no_bio": "이 사용자는 아직 사용자 정보를 추가하지 않았습니다.",
"delete_account_modal_title": "계정 삭제",
"confirm_delete_account_modal": "{{appName}} 계정을 삭제하시겠습니까?",
"delete_my_account": "내 계정 삭제",
@@ -1426,6 +1458,7 @@
"add_limit": "제한 추가",
"team_name_required": "팀 이름 필수",
"show_attendees": "게스트 간에 참석자 정보 공유",
"show_available_seats_count": "사용 가능한 좌석 수 표시",
"how_booking_questions_as_variables": "예약 질문을 어떻게 변수로 사용합니까?",
"format": "형식",
"uppercase_for_letters": "모든 문자에 대문자 사용",
@@ -1659,8 +1692,11 @@
"delete_sso_configuration_confirmation_description": "{{connectionType}} 구성을 삭제하시겠습니까? {{connectionType}} 로그인을 사용하는 팀원은 더 이상 Cal.com에 액세스할 수 없습니다.",
"organizer_timezone": "주최자 시간대",
"email_user_cta": "초대 보기",
"email_no_user_invite_heading_team": "{{appName}} 팀에 참여 초대를 받으셨습니다",
"email_no_user_invite_heading_org": "{{appName}} 조직에 참여 초대를 받으셨습니다",
"email_no_user_invite_subheading": "{{invitedBy}} 님이 귀하를 {{appName}}에서 자신의 팀에 초대했습니다. {{appName}} 앱은 귀하와 귀하의 팀이 이메일을 주고 받지 않고 회의 일정을 잡을 수 있게 해주는 이벤트 정리 스케줄러입니다.",
"email_user_invite_subheading_team": "{{invitedBy}}님이 {{appName}}에서 자신의 `{{teamName}}` 팀에 가입하도록 당신을 초대했습니다. {{appName}}은 유저와 팀이 이메일을 주고 받지 않고도 회의 일정을 잡을 수 있게 하는 이벤트 조율 스케줄러입니다.",
"email_user_invite_subheading_org": "{{invitedBy}} 님이 {{appName}}에서 자신의 조직의 `{{teamName}}`에 귀하의 참여를 초대했습니다. {{appName}}은(는) 사용자와 조직이 이메일을 주고 받지 않고도 회의 일정을 잡을 수 있게 해주는 이벤트 조정 스케줄러입니다.",
"email_no_user_invite_steps_intro": "안내해 드릴 몇 가지 간단한 단계만 거치면 곧 스트레스 없이 편리하게 {{entity}} 일정을 관리할 수 있게 될 것입니다.",
"email_no_user_step_one": "사용자 이름 선택",
"email_no_user_step_two": "캘린더 계정 연결",
@@ -1831,6 +1867,8 @@
"insights_no_data_found_for_filter": "선택한 필터 또는 날짜에 대한 데이터를 찾을 수 없습니다.",
"acknowledge_booking_no_show_fee": "본인은 이 이벤트에 참석하지 않을 경우 {{amount, currency}}에 대한 노쇼 수수료가 내 카드에 부과됨을 인정합니다.",
"card_details": "카드 세부정보",
"something_went_wrong_on_our_end": "문제가 발생했습니다. 지원팀에 연락하시면 즉시 문제를 해결해 드리겠습니다.",
"please_provide_following_text_to_suppport": "더 나은 도움을 받으려면 지원팀에 문의할 때 다음 텍스트를 제공해 주세요",
"seats_and_no_show_fee_error": "현재 좌석을 활성화할 수 없으므로 노쇼 수수료를 부과할 수 없습니다",
"complete_your_booking": "예약을 완료하세요",
"complete_your_booking_subject": "예약 완료: {{title}} 날짜 {{date}}",
@@ -1864,6 +1902,9 @@
"open_dialog_with_element_click": "누군가 요소를 클릭하면 Cal 대화 상자를 엽니다.",
"need_help_embedding": "도움이 필요하신가요? Wix, Squarespace, 또는 WordPress에서 Cal을 임베드하기 위한 가이드를 참조하거나, 자주 묻는 질문을 확인하거나, 고급 임베드 옵션을 살펴보세요.",
"book_my_cal": "내 Cal 예약",
"first_name": "이름",
"last_name": "성",
"first_last_name": "이름, 성",
"invite_as": "다른 이름으로 초대",
"form_updated_successfully": "양식이 성공적으로 업데이트되었습니다.",
"disable_attendees_confirmation_emails": "참석자에 대한 기본 확인 이메일 사용 안 함",
@@ -1877,6 +1918,7 @@
"first_event_type_webhook_description": "이 이벤트 유형에 대한 첫 번째 웹훅 만들기",
"install_app_on": "앱 설치 대상",
"create_for": "작성 대상",
"currency": "통화",
"organization_banner_description": "팀이 라운드 로빈 및 공동 예약을 통해 공유 앱, 워크플로 및 이벤트 유형을 생성할 수 있는 환경을 만듭니다.",
"organization_banner_title": "여러 팀으로 조직 관리",
"set_up_your_organization": "조직 설정",
@@ -1937,6 +1979,79 @@
"insights_team_filter": "팀: {{teamName}}",
"insights_user_filter": "사용자: {{userName}}",
"insights_subtitle": "이벤트 전반에 걸친 예약 Insights 보기",
"location_options": "{{locationCount}}개의 위치 옵션",
"custom_plan": "사용자 정의 플랜",
"email_embed": "이메일 포함",
"add_times_to_your_email": "사용 가능한 시간을 몇 개 선택하여 이메일에 포함시키세요",
"select_time": "시간 선택",
"select_date": "날짜 선택",
"see_all_available_times": "모든 사용 가능한 시간 보기",
"org_team_names_example": "예: 마케팅 팀",
"org_team_names_example_1": "예: 마케팅 팀",
"org_team_names_example_2": "예: 세일즈 팀",
"org_team_names_example_3": "예: 디자인 팀",
"org_team_names_example_4": "예: 엔지니어링 팀",
"org_team_names_example_5": "예: 데이터 분석 팀",
"org_max_team_warnings": "나중에 더 많은 팀을 추가할 수 있습니다.",
"what_is_this_meeting_about": "이 회의는 어떤 주제를 다루나요?",
"add_to_team": "팀에 추가",
"remove_users_from_org": "조직에서 사용자 제거",
"remove_users_from_org_confirm": "이 조직에서 {{userCount}}명의 사용자를 제거하시겠습니까?",
"user_has_no_schedules": "이 사용자는 아직 일정을 설정하지 않았습니다",
"user_isnt_in_any_teams": "이 사용자는 어떤 팀의 소속도 아닙니다",
"requires_booker_email_verification": "예약자 이메일 인증이 필요합니다",
"description_requires_booker_email_verification": "이벤트를 예약하기 전 예약자의 이메일 인증하기",
"requires_confirmation_mandatory": "문자 메시지는 이벤트 유형에 확인이 필요한 경우에만 참석자에게 보낼 수 있습니다.",
"kyc_verification_information": "보안이 되게 하려면 참석자에게 문자 메시지를 보내기 전에 {{teamOrAccount}} 인증은 필수입니다. <a>{{supportEmail}}</a>에 연락하여 다음 정보를 제공해 주시기 바랍니다.",
"kyc_verification_documents": "<ul><li>귀하의 {{teamOrUser}}</li><li>기업의 경우: 비즈니스 확인 서류 첨부</li><li>개인의 경우: 정부 발급 신분증 첨부</li></ul >",
"verify_team_or_account": "{{teamOrAccount}} 인증",
"verify_account": "계정 인증",
"kyc_verification": "KYC 인증",
"organizations": "조직",
"org_admin_other_teams": "다른 팀",
"org_admin_other_teams_description": "여기에서 귀하의 소속이 아닌 조직 내부의 팀을 볼 수 있습니다. 필요한 경우 여기에 자신을 추가할 수 있습니다.",
"no_other_teams_found": "다른 팀 없음",
"no_other_teams_found_description": "이 조직에는 다른 팀이 없습니다.",
"attendee_first_name_variable": "참석자 이름",
"attendee_last_name_variable": "참석자 성",
"attendee_first_name_info": "예약자 이름",
"attendee_last_name_info": "예약자 성",
"me": "나",
"verify_team_tooltip": "참석자에게 메시지를 보낼 수 있도록 팀을 인증하세요",
"member_removed": "구성원 제거됨",
"my_availability": "내 가용성",
"team_availability": "팀 가용성",
"backup_code": "백업 코드",
"backup_codes": "백업 코드",
"backup_code_instructions": "각 백업 코드는 인증자 없이 액세스 권한을 부여하기 위해 정확히 한 번만 사용할 수 있습니다.",
"backup_codes_copied": "백업 코드가 복사되었습니다!",
"incorrect_backup_code": "백업 코드가 올바르지 않습니다.",
"lost_access": "액세스 상실",
"missing_backup_codes": "백업 코드를 찾을 수 없습니다. 설정에서 생성하세요.",
"admin_org_notification_email_subject": "새 조직 생성됨: 작업 대기 중",
"hi_admin": "안녕하세요 관리자 님",
"admin_org_notification_email_title": "조직에 DNS 설정이 필요합니다",
"admin_org_notification_email_body_part1": "슬러그 \"{{orgSlug}}\" 있는 조직이 생성되었습니다.<br /><br />새 조직에 해당하는 하위 도메인이 기본 앱이 실행되는 위치를 가리키도록 DNS 레지스트리를 구성하십시오. 구성하지 않으면 조직은 작동하지 않습니다.<br /><br />다음은 하위 도메인이 앱을 가리키도록 구성하여 조직 프로필 페이지를 로드하는 매우 기본적인 옵션입니다.<br /><br />할 수 있는 작업은 A 레코드를 사용하는 것입니다:",
"admin_org_notification_email_body_part2": "또는 CNAME 레코드를 사용하는 것입니다:",
"admin_org_notification_email_body_part3": "하위 도메인을 구성한 후 조직 관리 설정에서 DNS 구성을 완료로 표시하십시오.",
"admin_org_notification_email_cta": "조직 관리자 설정으로 이동",
"org_has_been_processed": "조직이 처리되었습니다",
"org_error_processing": "이 조직을 처리하는 중 오류가 발생했습니다",
"orgs_page_description": "모든 조직의 목록입니다. 조직을 수락하면 해당 이메일 도메인을 가진 모든 사용자가 이메일 인증 없이 가입할 수 있습니다.",
"unverified": "미인증됨",
"dns_missing": "DNS 없음",
"mark_dns_configured": "DNS 구성됨으로 표시",
"value": "값",
"your_organization_updated_sucessfully": "조직이 업데이트되었습니다",
"team_no_event_types": "이 팀에는 이벤트 유형이 없습니다",
"seat_options_doesnt_multiple_durations": "좌석 옵션은 여러 기간을 지원하지 않습니다",
"include_calendar_event": "캘린더 이벤트 포함",
"recently_added": "최근 추가됨",
"no_members_found": "구성원 없음",
"event_setup_length_error": "이벤트 설정: 지속 시간은 1분 이상이어야 합니다.",
"availability_schedules": "사용 가능한 일정",
"view_only_edit_availability_not_onboarded": "이 사용자는 온보딩을 완료하지 않았습니다. 온보딩이 완료될 때까지는 가용성을 설정할 수 없습니다.",
"view_only_edit_availability": "이 사용자의 가용성을 보고 계십니다. 본인의 가용성만 편집할 수 있습니다.",
"edit_users_availability": "사용자 가용성 편집: {{username}}",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 여기에 새 문자열을 추가하세요 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -17,9 +17,15 @@
"verify_email_banner_body": "Підтвердьте свою електронну адресу, щоб уникнути проблем із доставкою листів і даних календаря",
"verify_email_email_header": "Підтвердьте свою електронну адресу",
"verify_email_email_button": "Підтвердити ел. адресу",
"copy_somewhere_safe": "Збережіть цей ключ API у надійному місці. Ви не зможете знову його переглянути.",
"verify_email_email_body": "Підтвердьте свою електронну адресу, натиснувши кнопку нижче.",
"verify_email_by_code_email_body": "Підтвердьте свою електронну адресу, використавши нижчезазначений код.",
"verify_email_email_link_text": "Можете скористатися цим посиланням, якщо не любите натискати кнопки:",
"email_verification_code": "Ведіть код перевірки",
"email_verification_code_placeholder": "Введіть код перевірки, надісланий на вашу пошту",
"incorrect_email_verification_code": "Код перевірки неправильний.",
"email_sent": "Лист надіслано",
"email_not_sent": "Під час надсилання електронного листа сталася помилка",
"event_declined_subject": "Відхилено: {{title}}, {{date}}",
"event_cancelled_subject": "Скасовано: {{title}}, {{date}}",
"event_request_declined": "Ваш запит на захід відхилено",
@@ -78,6 +84,7 @@
"event_awaiting_approval_recurring": "Періодичний захід чекає на ваше схвалення",
"someone_requested_an_event": "Хтось хоче запланувати захід у вашому календарі.",
"someone_requested_password_reset": "Хтось хоче змінити ваш пароль.",
"password_reset_email_sent": "Якщо ця електронна адреса є в нашій системі, ви отримаєте електронний лист для скидання.",
"password_reset_instructions": "Якщо ви не надсилали цього запиту, можете не звертати уваги на цей лист. Пароль не зміниться.",
"event_awaiting_approval_subject": "Очікування на схвалення: {{title}}, {{date}}",
"event_still_awaiting_approval": "Захід досі чекає на ваше схвалення",
@@ -217,6 +224,10 @@
"already_have_an_account": "Уже маєте обліковий запис?",
"create_account": "Створити обліковий запис",
"confirm_password": "Підтвердити пароль",
"confirm_auth_change": "Це змінить спосіб входу",
"confirm_auth_email_change": "У результаті зміни електронної адреси буде від’єднано ваш поточний метод автентифікації для входу на Cal.com. Ми попросимо вас підтвердити вашу нову електронну адресу. Після налаштування пароля відповідно до інструкцій надісланих вам поштою вам потрібно буде вийти із системи й увійти за допомогою нової електронної адреси замість вашого поточного методу автентифікації.",
"reset_your_password": "Установіть новий пароль, дотримуючись інструкцій, надісланих на вашу електронну адресу.",
"email_change": "Увійдіть у систему за допомогою нової електронної адреси й пароля.",
"create_your_account": "Створіть власний обліковий запис",
"sign_up": "Реєстрація",
"youve_been_logged_out": "Ви вийшли із системи",
@@ -243,6 +254,10 @@
"all": "Усі",
"yours": "Ваш обліковий запис",
"available_apps": "Доступні додатки",
"available_apps_lower_case": "Доступні додатки",
"available_apps_desc": "У вас немає встановлених додатків. Перегляньте популярні додатки нижче й дізнайтеся більше в <1>App Store</1>",
"fixed_host_helper": "Додайте осіб, які повинні бути присутніми на заході. <1>Докладніше</1>",
"round_robin_helper": "Учасники групи беруть участь по черзі, і на заході буде присутньою лише одна особа.",
"check_email_reset_password": "Перевірте електронну пошту. Ми надіслали вам посилання для скидання пароля.",
"finish": "Готово",
"organization_general_description": "Налаштуйте параметри мови й часового поясу для своєї команди",
@@ -251,6 +266,7 @@
"nearly_there_instructions": "І останнє: стислі відомості про вас і ваше фото заохочуватимуть інших бронюванювати ваш час, адже вони бачитимуть, з ким домовляються.",
"set_availability_instructions": "Визначте повторювані діапазони часу, у які ви доступні. Ви зможете додати більше таких діапазонів пізніше та призначити їх різним календарям.",
"set_availability": "Укажіть, коли ви доступні",
"availability_settings": "Налаштування доступності",
"continue_without_calendar": "Продовжити без календаря",
"connect_your_calendar": "Підключити календар",
"connect_your_video_app": "Підключіть улюблені відеозастосунки",
@@ -392,6 +408,8 @@
"allow_dynamic_booking_tooltip": "Посилання для групового бронювання, які можна створювати динамічно, додаючи кілька імен користувачів за допомогою «+». Приклад: «{{appName}}/bailey+peer»",
"allow_dynamic_booking": "Дозволити учасникам бронювати ваш час за допомогою динамічних групових бронювань",
"dynamic_booking": "Динамічні групові посилання",
"allow_seo_indexing": "Надати пошуковим системам доступ до вашого загальнодоступного вмісту",
"seo_indexing": "Дозволити SEO індексування",
"email": "Ел. адреса",
"email_placeholder": "vpetrenko@zrazok.com",
"full_name": "Ім’я і прізвище",
@@ -406,6 +424,7 @@
"booking_requested": "Запит на бронювання надіслано",
"meeting_ended": "Нарада завершилася",
"form_submitted": "Форму надіслано",
"booking_paid": "Бронювання оплачено",
"event_triggers": "Тригери заходів",
"subscriber_url": "URL-адреса підписника",
"create_new_webhook": "Створити новий вебгук",
@@ -506,6 +525,7 @@
"your_name": "Ваше ім’я",
"your_full_name": "Ваше повне ім’я",
"no_name": "Немає імені",
"enter_number_between_range": "Введіть число від 1 до {{maxOccurences}}",
"email_address": "Електронна адреса",
"enter_valid_email": "Введіть дійсну адресу електронної пошти",
"location": "Розташування",
@@ -543,6 +563,7 @@
"leave": "Вийти",
"profile": "Профіль",
"my_team_url": "URL моєї команди",
"my_teams": "Мої команди",
"team_name": "Назва команди",
"your_team_name": "Назва вашої команди",
"team_updated_successfully": "Відомості про команду оновлено",
@@ -570,6 +591,8 @@
"invite_new_member": "Запросити нового учасника команди",
"invite_new_member_description": "Зверніть увагу: у вашій підписці потрібно буде оплатити <1>одне додаткове місце (15 дол. США)</1>.",
"invite_new_team_member": "Запросіть когось у свою команду.",
"upload_csv_file": "Передати CSV-файл",
"invite_via_email": "Запросити електронною поштою",
"change_member_role": "Змініть роль учасника команди",
"disable_cal_branding": "Вимкнути фірмове оформлення {{appName}}",
"disable_cal_branding_description": "Приховайте весь брендинг {{appName}} зі своїх загальнодоступних сторінок.",
@@ -842,6 +865,7 @@
"team_view_user_availability_disabled": "Користувач має прийняти запрошення, щоб переглядати відомості про доступність команди",
"set_as_away": "Установити для себе стан «Не на місці»",
"set_as_free": "Вимкнути стан «Не на місці»",
"toggle_away_error": "Помилка оновлення стану «Не на місці»",
"user_away": "Цей користувач зараз не на місці.",
"user_away_description": "Користувач, час якого ви намагаєтеся забронювати, указав, що він зараз не на місці. Через це він не приймає нові бронювання.",
"meet_people_with_the_same_tokens": "Зустрічі з користувачами, у яких такі самі токени",
@@ -851,6 +875,7 @@
"account_managed_by_identity_provider_description": "Щоб змінити електронну адресу чи пароль, увімкнути двоетапну автентифікацію тощо, перейдіть до налаштувань свого облікового запису {{provider}}.",
"signin_with_google": "Увійдіть через Google",
"signin_with_saml": "Увійдіть через SAML",
"signin_with_saml_oidc": "Увійдіть через SAML/OIDC",
"you_will_need_to_generate": "Вам потрібно буде згенерувати токен доступу зі старого інструменту планування.",
"import": "Імпорт",
"import_from": "Імпортувати з",
@@ -950,6 +975,8 @@
"offer_seats_description": "Пропонуйте місця під час бронювання (при цьому вимикається гостьовий режим і бронювання з підтвердженням).",
"seats_available_one": "Є доступне місце",
"seats_available_other": "Доступні місця",
"seats_nearly_full": "Місць майже не залишилось",
"seats_half_full": "Місця швидко займають",
"number_of_seats": "Кількість місць на одне бронювання",
"enter_number_of_seats": "Введіть кількість місць",
"you_can_manage_your_schedules": "Керувати розкладами можна на сторінці «Доступність».",
@@ -1030,12 +1057,15 @@
"how_you_want_add_cal_site": "Як саме ви хочете додати {{appName}} на свій сайт?",
"choose_ways_put_cal_site": "Виберіть один із наведених нижче способів розмістити {{appName}} на сайті.",
"setting_up_zapier": "Налаштування інтеграції із Zapier",
"setting_up_make": "Налаштування інтеграції з Make",
"generate_api_key": "Створити ключ API",
"generate_api_key_description": "Створіть ключ API для використання з {{appName}} на сайті",
"your_unique_api_key": "Ваш унікальний ключ API",
"copy_safe_api_key": "Скопіюйте цей ключ API і збережіть його в надійному місці. Якщо ви втратите цей ключ, потрібно буде створити новий.",
"zapier_setup_instructions": "<0>Увійдіть в обліковий запис Zapier та створіть Zap.</0><1>Виберіть Cal.com як додаток Trigger, а також укажіть подію Trigger.</1><2>Виберіть свій обліковий запис і введіть унікальний ключ API.</2><3>Перевірте тригер.</3><4>Усе готово!</4>",
"make_setup_instructions": "<0>Перейдіть<1><0>за посиланням у запрошенні Make</0></1> і встановіть додаток Cal.com.</0><1>Увійдіть у свій обліковий запис Make і створіть новий сценарій.</1><2>Виберіть Cal.com як додаток Trigger, а також укажіть подію Trigger.</2><3>Виберіть свій обліковий запис і введіть унікальний ключ API.</3><4>Перевірте Trigger.</4><5>Усе готово!</5>",
"install_zapier_app": "Спочатку встановіть додаток Zapier з App Store.",
"install_make_app": "Спочатку встановіть додаток Make з App Store.",
"connect_apple_server": "Підключитися до сервера Apple",
"calendar_url": "URL-адреса календаря",
"apple_server_generate_password": "Згенеруйте спеціальний пароль додатка для використання з {{appName}} за адресою",
@@ -1085,6 +1115,7 @@
"email_attendee_action": "надіслати ел. лист учасникам",
"sms_attendee_action": "Надіслати SMS учаснику",
"sms_number_action": "надсилати SMS на певний номер",
"send_reminder_sms": "Легко відправляйте нагадування про зустріч в SMS вашим відвідувачам",
"whatsapp_number_action": "надсилати повідомлення у Whatsapp на певний номер",
"whatsapp_attendee_action": "надсилати повідомлення у Whatsapp учаснику",
"workflows": "Робочі процеси",
@@ -1244,6 +1275,7 @@
"error_updating_settings": "Помилка оновлення параметрів",
"personal_cal_url": "Моя персональна URL-адреса {{appName}}",
"bio_hint": "Кілька речень про вас. Ця інформація з’явиться на сторінці за вашою URL-адресою.",
"user_has_no_bio": "Цей користувач ще не додав біографію.",
"delete_account_modal_title": "Видалення облікового запису",
"confirm_delete_account_modal": "Справді видалити свій обліковий запис {{appName}}?",
"delete_my_account": "Видалити мій обліковий запис",
@@ -1426,6 +1458,7 @@
"add_limit": "Додати ліміт",
"team_name_required": "Назва команди обов’язкова",
"show_attendees": "Поширювати інформацію про учасників серед гостей",
"show_available_seats_count": "Показати кількість доступних місць",
"how_booking_questions_as_variables": "Як використовувати запитання про бронювання як змінні?",
"format": "Формат",
"uppercase_for_letters": "Використовувати всі великі літери",
@@ -1659,8 +1692,11 @@
"delete_sso_configuration_confirmation_description": "Справді видалити конфігурацію {{connectionType}}? Учасники вашої команди, які входять через {{connectionType}}, не зможуть ввійти в Cal.com.",
"organizer_timezone": "Часовий пояс організатора",
"email_user_cta": "Переглянути запрошення",
"email_no_user_invite_heading_team": "Вас запрошено приєднатися до команди в застосунку {{appName}}",
"email_no_user_invite_heading_org": "Вас запрошено приєднатися до організації в застосунку {{appName}}",
"email_no_user_invite_subheading": "{{invitedBy}} запрошує вас приєднатися до команди в {{appName}}. {{appName}} — планувальник подій, який дає змогу вам і вашій команді планувати зустрічі без тривалої переписки електронною поштою.",
"email_user_invite_subheading_team": "{{invitedBy}} запрошує вас приєднатися до команди «{{teamName}}» в {{appName}}. {{appName}} — планувальник подій, який дає змогу вам і вашій команді планувати зустрічі без тривалої переписки електронною поштою.",
"email_user_invite_subheading_org": "{{invitedBy}} запрошує вас приєднатися до організації «{{teamName}}» в застосунку {{appName}}. {{appName}} — планувальник подій, завдяки якому ви й ваша організація можете планувати наради без довгого листування.",
"email_no_user_invite_steps_intro": "Ми ознайомимо вас із застосунком, і вже скоро ви й ваша {{entity}} насолоджуватиметеся плануванням без жодного клопоту.",
"email_no_user_step_one": "Виберіть ім’я користувача",
"email_no_user_step_two": "Підключіть обліковий запис календаря",
@@ -1831,6 +1867,8 @@
"insights_no_data_found_for_filter": "Не знайдено жодних даних для вибраного фільтра або вибраних дат.",
"acknowledge_booking_no_show_fee": "Я усвідомлюю, що в разі моєї відсутності на цьому заході з моєї картки буде стягнуто плату в розмірі {{amount, currency}}.",
"card_details": "Карткові реквізити",
"something_went_wrong_on_our_end": "Щось пішло не так на нашому боці. Зв’яжіться з нашою службою підтримки й ми одразу все виправимо.",
"please_provide_following_text_to_suppport": "Надайте нижчезазначений текст, звертаючись до служби підтримки, щоб нам було легше допомогти вам",
"seats_and_no_show_fee_error": "Зараз неможливо активувати місця і стягнути плату за відсутність",
"complete_your_booking": "Завершіть бронювання",
"complete_your_booking_subject": "Завершіть бронювання: {{title}} від {{date}}",
@@ -1864,6 +1902,9 @@
"open_dialog_with_element_click": "Відкриває діалогове вікно з Cal у разі натискання елемента.",
"need_help_embedding": "Потрібна допомога? Перегляньте наші посібники з вбудовування Cal на Wix, Squarespace чи WordPress і ознайомтеся з поширеними запитаннями або розширеними параметрами вбудовування.",
"book_my_cal": "Забронювати на Cal.com",
"first_name": "Ім’я",
"last_name": "Прізвище",
"first_last_name": "Ім’я, прізвище",
"invite_as": "Запросити як",
"form_updated_successfully": "Форму оновлено.",
"disable_attendees_confirmation_emails": "Вимкнути листи підтвердження за замовчуванням для учасників",
@@ -1877,6 +1918,7 @@
"first_event_type_webhook_description": "Створіть свій перший вебгук для цього типу заходів",
"install_app_on": "Установити застосунок у",
"create_for": "Створити для",
"currency": "Валюта",
"organization_banner_description": "Підготуйте середовища, де ваші команди можуть створювати спільні застосунки, робочі процеси й типи заходів із циклічним і колективним плануванням.",
"organization_banner_title": "Керування організаціями з кількома командами",
"set_up_your_organization": "Налаштуйте свою організацію",
@@ -1937,6 +1979,79 @@
"insights_team_filter": "Команда: {{teamName}}",
"insights_user_filter": "Користувач: {{userName}}",
"insights_subtitle": "Переглядайте дані Insights про бронювання для своїх заходів",
"location_options": "Варіантів місць проведення: {{locationCount}}",
"custom_plan": "Користувацький план",
"email_embed": "Вбудовування в ел. пошту",
"add_times_to_your_email": "Виберіть кілька доступних часових проміжків і вбудуйте їх в електронний лист",
"select_time": "Виберіть час",
"select_date": "Виберіть дату",
"see_all_available_times": "Переглянути доступні часові проміжки",
"org_team_names_example": "напр. команда маркетингу",
"org_team_names_example_1": "напр. маркетинговий відділ",
"org_team_names_example_2": "напр. відділ продажів",
"org_team_names_example_3": "напр. дизайнерський відділ",
"org_team_names_example_4": "e.g. інженерний відділ",
"org_team_names_example_5": "e.g. відділ аналітики даних",
"org_max_team_warnings": "Ви зможете додати більше команд пізніше.",
"what_is_this_meeting_about": "Про що ця зустріч?",
"add_to_team": "Додати в команду",
"remove_users_from_org": "Видалити користувачів з організації",
"remove_users_from_org_confirm": "Справді видалити користувачів ({{userCount}}) із цієї організації?",
"user_has_no_schedules": "Цей користувач ще не налаштував розкладу",
"user_isnt_in_any_teams": "Цей користувач не входить до жодної команди",
"requires_booker_email_verification": "Потрібно підтвердження ел. адреси особи, які здійснює бронювання",
"description_requires_booker_email_verification": "Щоб забезпечити підтвердження ел. адреси особи, яка здійснює бронювання, до планування заходів",
"requires_confirmation_mandatory": "Текстові повідомлення можна надсилати лише учасникам, коли тип події вимагає підтвердження.",
"kyc_verification_information": "З міркувань безпеки потрібно підтвердити {{teamOrAccount}}, перш ніж надсилати текстові повідомлення учасникам. Зверніться до нас за адресою <a>{{supportEmail}}</a> і надайте таку інформацію:",
"kyc_verification_documents": "<ul><li>{{teamOrUser}}</li><li>Для бізнесу: надайте документи для перевірки бізнесу</li><li>Для фізичних осіб: надайте посвідчення особи державного зразка</li></ul>",
"verify_team_or_account": "Підтвердити {{teamOrAccount}}",
"verify_account": "Підтвердити обліковий запис",
"kyc_verification": "Перевірка KYC",
"organizations": "Організації",
"org_admin_other_teams": "Інші команди",
"org_admin_other_teams_description": "Тут ви можете побачити команди у вашій організації, до яких ви не приєдналися. За потреби ви можете приєднатися до них.",
"no_other_teams_found": "Не знайдено інших команд",
"no_other_teams_found_description": "У цій організації немає інших команд.",
"attendee_first_name_variable": "Ім’я учасника",
"attendee_last_name_variable": "Прізвище учасника",
"attendee_first_name_info": "Ім’я особи, яка бронює",
"attendee_last_name_info": "Прізвище особи, яка бронює",
"me": "Я",
"verify_team_tooltip": "Верифікуйте свою команду, щоб увімкнути надсилання повідомлень відвідувачам",
"member_removed": "Учасника вилучено",
"my_availability": "Моя доступність",
"team_availability": "Доступність команди",
"backup_code": "Резервний код",
"backup_codes": "Резервні коди",
"backup_code_instructions": "Кожен резервний код можна використовувати один раз, щоб надати доступ без автентифікатора.",
"backup_codes_copied": "Резервні коди скопійовані!",
"incorrect_backup_code": "Неправильний резервний код.",
"lost_access": "Втрачено доступ",
"missing_backup_codes": "Не знайдено резервних кодів. Створіть їх у налаштуваннях.",
"admin_org_notification_email_subject": "Створено нову організацію: очікується дія",
"hi_admin": "Вітаємо, адміністраторе",
"admin_org_notification_email_title": "Організації потрібно налаштувати DNS",
"admin_org_notification_email_body_part1": "Створено організацію з URL-адресою {{orgSlug}}.<br /><br />Налаштуйте реєстр DNS, щоб субдомен, який відповідає новій організації, вказував на те місце, де запущено основний застосунок. Інакше організація не працюватиме.<br /><br />Ось кілька дуже базових варіантів налаштування, щоб субдомен вказував на відповідний застосунок і завантажував сторінку профілю організації.<br /><br />Це можна зробити за допомогою запису А:",
"admin_org_notification_email_body_part2": "Або запису CNAME:",
"admin_org_notification_email_body_part3": "Щойно ви налаштуєте піддомен, вкажіть в налаштуваннях адміністратора організації, що налаштування DNS виконано.",
"admin_org_notification_email_cta": "Перейти до налаштувань адміністратора організації",
"org_has_been_processed": "Організація обробляється",
"org_error_processing": "Під час обробки цієї організації сталася помилка",
"orgs_page_description": "Список всіх організацій. Після прийняття організації всі користувачі із цим доменом електронної пошти зможуть зареєструватися без підтвердження електронної адреси.",
"unverified": "Не підтверджено",
"dns_missing": "Відсутній DNS",
"mark_dns_configured": "Позначити DNS налаштованим",
"value": "Значення",
"your_organization_updated_sucessfully": "Організацію оновлено",
"team_no_event_types": "У цієї команди немає типів заходів",
"seat_options_doesnt_multiple_durations": "Параметр «Місця» не підтримує декілька варіантів тривалості",
"include_calendar_event": "Включити календарний захід",
"recently_added": "Нещодавно додані",
"no_members_found": "Учасників не знайдено",
"event_setup_length_error": "Налаштування заходу: мінімальна тривалість — 1 хвилина.",
"availability_schedules": "Розклад доступності",
"view_only_edit_availability_not_onboarded": "Цей користувач не завершив ознайомлення. Доки він не зробить цього, ви не зможете налаштувати доступність для нього.",
"view_only_edit_availability": "Ви переглядаєте доступність цього користувача. Ви можете редагувати лише свою доступність.",
"edit_users_availability": "Редагувати доступність користувача: {{username}}",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -17,6 +17,7 @@
"verify_email_banner_body": "Xác minh địa chỉ email của bạn nhằm bảo đảm khả năng gửi email và lịch hẹn tốt nhất",
"verify_email_email_header": "Xác minh địa chỉ email của bạn",
"verify_email_email_button": "Xác minh email",
"copy_somewhere_safe": "Lưu khoá API này ở nơi an toàn. Bạn sẽ không thể xem nó lại.",
"verify_email_email_body": "Vui lòng xác minh địa chỉ email của bạn bằng cách nhấp vào nút bên dưới.",
"verify_email_email_link_text": "Đây là liên kết phòng trường hợp bạn không thích nhấp vào nút:",
"email_sent": "Email đã gửi thành công",
+8
View File
@@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/cron/calendar-cache-cleanup",
"schedule": "0 5 * * *"
}
]
}
@@ -0,0 +1,101 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import { afterEach, expect, test, vi } from "vitest";
import CalendarService from "./CalendarService";
afterEach(() => {
vi.resetAllMocks();
});
vi.mock("@calcom/features/flags/server/utils", () => ({
getFeatureFlagMap: vi.fn().mockResolvedValue({
"calendar-cache": true,
}),
}));
vi.mock("./getGoogleAppKeys", () => ({
getGoogleAppKeys: vi.fn().mockResolvedValue({
client_id: "xxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com",
client_secret: "xxxxxxxxxxxxxxxxxx",
redirect_uris: ["http://localhost:3000/api/integrations/googlecalendar/callback"],
}),
}));
const googleTestCredential = {
scope: "https://www.googleapis.com/auth/calendar.events",
token_type: "Bearer",
expiry_date: 1625097600000,
access_token: "",
refresh_token: "",
};
const testCredential = {
appId: "test",
id: 1,
invalid: false,
key: googleTestCredential,
type: "test",
userId: 1,
user: { email: "example@cal.com" },
teamId: 1,
};
const testSelectedCalendar = {
userId: 1,
integration: "google_calendar",
externalId: "example@cal.com",
};
const testFreeBusyResponse = {
kind: "calendar#freeBusy",
timeMax: "2024-01-01T20:59:59.000Z",
timeMin: "2023-11-30T20:00:00.000Z",
calendars: {
"example@cal.com": {
busy: [
{ end: "2023-12-01T19:00:00Z", start: "2023-12-01T18:00:00Z" },
{ end: "2023-12-04T19:00:00Z", start: "2023-12-04T18:00:00Z" },
],
},
"xxxxxxxxxxxxxxxxxxxxxxxxxx@group.calendar.google.com": { busy: [] },
},
};
const calendarCacheResponse = {
key: "dummy",
expiresAt: new Date(),
credentialId: 1,
value: testFreeBusyResponse,
};
test("Calendar Cache is being called", async () => {
prismaMock.calendarCache.findUnique
// First call won't have a cache
.mockResolvedValueOnce(null)
// Second call will have a cache
.mockResolvedValueOnce(calendarCacheResponse);
// prismaMock.calendarCache.create.mock.
const calendarService = new CalendarService(testCredential);
// @ts-expect-error authedCalendar is a private method, hence the TS error
vi.spyOn(calendarService, "authedCalendar").mockReturnValue(
// @ts-expect-error trust me bro
{
freebusy: {
query: vi.fn().mockReturnValue({
data: testFreeBusyResponse,
}),
},
}
);
await calendarService.getAvailability(new Date().toISOString(), new Date().toISOString(), [
testSelectedCalendar,
]);
await calendarService.getAvailability(new Date().toISOString(), new Date().toISOString(), [
testSelectedCalendar,
]);
expect(prismaMock.calendarCache.findUnique).toHaveBeenCalled();
expect(prismaMock.calendarCache.upsert).toHaveBeenCalledOnce();
});
@@ -4,6 +4,7 @@ import type { calendar_v3 } from "googleapis";
import { google } from "googleapis";
import { MeetLocationType } from "@calcom/app-store/locations";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import type CalendarService from "@calcom/lib/CalendarService";
import logger from "@calcom/lib/logger";
@@ -24,6 +25,36 @@ interface GoogleCalError extends Error {
code?: number;
}
const ONE_MINUTE_MS = 60 * 1000;
const CACHING_TIME = ONE_MINUTE_MS;
/** Expand the start date to the start of the month */
function getTimeMin(timeMin: string) {
const dateMin = new Date(timeMin);
return new Date(dateMin.getFullYear(), dateMin.getMonth(), 1, 0, 0, 0, 0).toISOString();
}
/** Expand the end date to the end of the month */
function getTimeMax(timeMax: string) {
const dateMax = new Date(timeMax);
return new Date(dateMax.getFullYear(), dateMax.getMonth() + 1, 0, 0, 0, 0, 0).toISOString();
}
/**
* Enable or disable the expanded cache
* TODO: Make this configurable
* */
const ENABLE_EXPANDED_CACHE = true;
/**
* By expanding the cache to whole months, we can save round trips to the third party APIs.
* In this case we already have the data in the database, so we can just return it.
*/
function handleMinMax(min: string, max: string) {
if (!ENABLE_EXPANDED_CACHE) return { timeMin: min, timeMax: max };
return { timeMin: getTimeMin(min), timeMax: getTimeMax(max) };
}
export default class GoogleCalendarService implements Calendar {
private integrationName = "";
private auth: { getToken: () => Promise<MyGoogleAuth> };
@@ -32,6 +63,7 @@ export default class GoogleCalendarService implements Calendar {
constructor(credential: CredentialPayload) {
this.integrationName = "google_calendar";
this.credential = credential;
this.auth = this.googleAuth(credential);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.credential = credential;
@@ -86,6 +118,15 @@ export default class GoogleCalendarService implements Calendar {
};
};
private authedCalendar = async () => {
const myGoogleAuth = await this.auth.getToken();
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
return calendar;
};
private getAttendees = (event: CalendarEvent) => {
// When rescheduling events we know the external id of the calendar so we can just look for it in the destinationCalendar array.
const selectedHostDestinationCalendar = event.destinationCalendar?.find(
@@ -129,209 +170,236 @@ export default class GoogleCalendarService implements Calendar {
};
async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {
return new Promise(async (resolve, reject) => {
const myGoogleAuth = await this.auth.getToken();
const payload: calendar_v3.Schema$Event = {
summary: calEventRaw.title,
description: getRichDescription(calEventRaw),
start: {
dateTime: calEventRaw.startTime,
timeZone: calEventRaw.organizer.timeZone,
},
end: {
dateTime: calEventRaw.endTime,
timeZone: calEventRaw.organizer.timeZone,
},
attendees: this.getAttendees(calEventRaw),
reminders: {
useDefault: true,
},
guestsCanSeeOtherGuests: !!calEventRaw.seatsPerTimeSlot ? calEventRaw.seatsShowAttendees : true,
};
const payload: calendar_v3.Schema$Event = {
summary: calEventRaw.title,
description: getRichDescription(calEventRaw),
start: {
dateTime: calEventRaw.startTime,
timeZone: calEventRaw.organizer.timeZone,
},
end: {
dateTime: calEventRaw.endTime,
timeZone: calEventRaw.organizer.timeZone,
},
attendees: this.getAttendees(calEventRaw),
reminders: {
useDefault: true,
},
guestsCanSeeOtherGuests: !!calEventRaw.seatsPerTimeSlot ? calEventRaw.seatsShowAttendees : true,
};
if (calEventRaw.location) {
payload["location"] = getLocation(calEventRaw);
}
if (calEventRaw.location) {
payload["location"] = getLocation(calEventRaw);
}
if (calEventRaw.conferenceData && calEventRaw.location === MeetLocationType) {
payload["conferenceData"] = calEventRaw.conferenceData;
}
const calendar = google.calendar({
version: "v3",
if (calEventRaw.conferenceData && calEventRaw.location === MeetLocationType) {
payload["conferenceData"] = calEventRaw.conferenceData;
}
const calendar = await this.authedCalendar();
// Find in calEventRaw.destinationCalendar the one with the same credentialId
const selectedCalendar =
calEventRaw.destinationCalendar?.find((cal) => cal.credentialId === credentialId)?.externalId ||
"primary";
try {
const event = await calendar.events.insert({
calendarId: selectedCalendar,
requestBody: payload,
conferenceDataVersion: 1,
sendUpdates: "none",
});
// Find in calEventRaw.destinationCalendar the one with the same credentialId
const selectedCalendar =
calEventRaw.destinationCalendar?.find((cal) => cal.credentialId === credentialId)?.externalId ||
"primary";
calendar.events.insert(
{
auth: myGoogleAuth,
if (event && event.data.id && event.data.hangoutLink) {
await calendar.events.patch({
// Update the same event but this time we know the hangout link
calendarId: selectedCalendar,
requestBody: payload,
conferenceDataVersion: 1,
sendUpdates: "none",
},
function (error, event) {
if (error || !event?.data) {
console.error("There was an error contacting google calendar service: ", error);
return reject(error);
}
eventId: event.data.id || "",
requestBody: {
description: getRichDescription({
...calEventRaw,
additionalInformation: { hangoutLink: event.data.hangoutLink },
}),
},
});
}
if (event && event.data.id && event.data.hangoutLink) {
calendar.events.patch({
// Update the same event but this time we know the hangout link
calendarId: selectedCalendar,
auth: myGoogleAuth,
eventId: event.data.id || "",
requestBody: {
description: getRichDescription({
...calEventRaw,
additionalInformation: { hangoutLink: event.data.hangoutLink },
}),
},
});
}
return resolve({
uid: "",
...event.data,
id: event.data.id || "",
additionalInfo: {
hangoutLink: event.data.hangoutLink || "",
},
type: "google_calendar",
password: "",
url: "",
iCalUID: event.data.iCalUID,
});
}
);
});
return {
uid: "",
...event.data,
id: event.data.id || "",
additionalInfo: {
hangoutLink: event.data.hangoutLink || "",
},
type: "google_calendar",
password: "",
url: "",
iCalUID: event.data.iCalUID,
};
} catch (error) {
console.error("There was an error contacting google calendar service: ", error);
throw error;
}
}
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId: string): Promise<any> {
return new Promise(async (resolve, reject) => {
const myGoogleAuth = await this.auth.getToken();
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: getRichDescription(event),
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: this.getAttendees(event),
reminders: {
useDefault: true,
},
guestsCanSeeOtherGuests: !!event.seatsPerTimeSlot ? event.seatsShowAttendees : true,
};
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: getRichDescription(event),
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: this.getAttendees(event),
reminders: {
useDefault: true,
},
guestsCanSeeOtherGuests: !!event.seatsPerTimeSlot ? event.seatsShowAttendees : true,
};
if (event.location) {
payload["location"] = getLocation(event);
}
if (event.location) {
payload["location"] = getLocation(event);
}
if (event.conferenceData && event.location === MeetLocationType) {
payload["conferenceData"] = event.conferenceData;
}
if (event.conferenceData && event.location === MeetLocationType) {
payload["conferenceData"] = event.conferenceData;
}
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
const calendar = await this.authedCalendar();
const selectedCalendar = externalCalendarId
? externalCalendarId
: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
try {
const evt = await calendar.events.update({
calendarId: selectedCalendar,
eventId: uid,
sendNotifications: true,
sendUpdates: "none",
requestBody: payload,
conferenceDataVersion: 1,
});
const selectedCalendar = externalCalendarId
? externalCalendarId
: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
calendar.events.update(
{
auth: myGoogleAuth,
if (evt && evt.data.id && evt.data.hangoutLink && event.location === MeetLocationType) {
calendar.events.patch({
// Update the same event but this time we know the hangout link
calendarId: selectedCalendar,
eventId: uid,
sendNotifications: true,
sendUpdates: "none",
requestBody: payload,
conferenceDataVersion: 1,
},
function (err, evt) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
if (evt && evt.data.id && evt.data.hangoutLink && event.location === MeetLocationType) {
calendar.events.patch({
// Update the same event but this time we know the hangout link
calendarId: selectedCalendar,
auth: myGoogleAuth,
eventId: evt.data.id || "",
requestBody: {
description: getRichDescription({
...event,
additionalInformation: { hangoutLink: evt.data.hangoutLink },
}),
},
});
return resolve({
uid: "",
...evt.data,
id: evt.data.id || "",
additionalInfo: {
hangoutLink: evt.data.hangoutLink || "",
},
type: "google_calendar",
password: "",
url: "",
iCalUID: evt.data.iCalUID,
});
}
return resolve(evt?.data);
}
);
});
eventId: evt.data.id || "",
requestBody: {
description: getRichDescription({
...event,
additionalInformation: { hangoutLink: evt.data.hangoutLink },
}),
},
});
return {
uid: "",
...evt.data,
id: evt.data.id || "",
additionalInfo: {
hangoutLink: evt.data.hangoutLink || "",
},
type: "google_calendar",
password: "",
url: "",
iCalUID: evt.data.iCalUID,
};
}
return evt?.data;
} catch (error) {
console.error("There was an error contacting google calendar service: ", error);
throw error;
}
}
async deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string | null): Promise<void> {
return new Promise(async (resolve, reject) => {
const myGoogleAuth = await this.auth.getToken();
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
const calendar = await this.authedCalendar();
const defaultCalendarId = "primary";
const calendarId = externalCalendarId
? externalCalendarId
: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
try {
const event = await calendar.events.delete({
calendarId: calendarId ? calendarId : defaultCalendarId,
eventId: uid,
sendNotifications: false,
sendUpdates: "none",
});
return event?.data;
} catch (error) {
const err = error as GoogleCalError;
/**
* 410 is when an event is already deleted on the Google cal before on cal.com
* 404 is when the event is on a different calendar
*/
if (err.code === 410) return;
console.error("There was an error contacting google calendar service: ", err);
if (err.code === 404) return;
throw err;
}
}
const defaultCalendarId = "primary";
const calendarId = externalCalendarId
? externalCalendarId
: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
calendar.events.delete(
{
auth: myGoogleAuth,
calendarId: calendarId ? calendarId : defaultCalendarId,
eventId: uid,
sendNotifications: false,
sendUpdates: "none",
async getCacheOrFetchAvailability(args: {
timeMin: string;
timeMax: string;
items: { id: string }[];
}): Promise<calendar_v3.Schema$FreeBusyResponse> {
const calendar = await this.authedCalendar();
const flags = await getFeatureFlagMap(prisma);
if (!flags["calendar-cache"]) {
this.log.warn("Calendar Cache is disabled - Skipping");
const { timeMin, timeMax, items } = args;
const apires = await calendar.freebusy.query({
requestBody: { timeMin, timeMax, items },
});
return apires.data;
}
const { timeMin: _timeMin, timeMax: _timeMax, items } = args;
const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax);
const key = JSON.stringify({ timeMin, timeMax, items });
const cached = await prisma.calendarCache.findUnique({
where: {
credentialId_key: {
credentialId: this.credential.id,
key,
},
function (err: GoogleCalError | null, event) {
if (err) {
/**
* 410 is when an event is already deleted on the Google cal before on cal.com
* 404 is when the event is on a different calendar
*/
if (err.code === 410) return resolve();
console.error("There was an error contacting google calendar service: ", err);
if (err.code === 404) return resolve();
return reject(err);
}
return resolve(event?.data);
}
);
expiresAt: { gte: new Date(Date.now()) },
},
});
if (cached) return cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
const apires = await calendar.freebusy.query({
requestBody: { timeMin, timeMax, items },
});
// Skipping await to respond faster
prisma.calendarCache.upsert({
where: {
credentialId_key: {
credentialId: this.credential.id,
key,
},
},
update: {
value: JSON.parse(JSON.stringify(apires.data)),
expiresAt: new Date(Date.now() + CACHING_TIME),
},
create: {
value: JSON.parse(JSON.stringify(apires.data)),
credentialId: this.credential.id,
key,
expiresAt: new Date(Date.now() + CACHING_TIME),
},
});
return apires.data;
}
async getAvailability(
@@ -339,96 +407,65 @@ export default class GoogleCalendarService implements Calendar {
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
return new Promise(async (resolve, reject) => {
const myGoogleAuth = await this.auth.getToken();
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
const calendar = await this.authedCalendar();
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
.map((e) => e.externalId);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
return [];
}
async function getCalIds() {
if (selectedCalendarIds.length !== 0) return selectedCalendarIds;
const cals = await calendar.calendarList.list({ fields: "items(id)" });
if (!cals.data.items) return [];
return cals.data.items.reduce((c, cal) => (cal.id ? [...c, cal.id] : c), [] as string[]);
}
try {
const calsIds = await getCalIds();
const freeBusyData = await this.getCacheOrFetchAvailability({
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id })),
});
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
.map((e) => e.externalId);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
resolve([]);
return;
}
(selectedCalendarIds.length === 0
? calendar.calendarList
.list({
fields: "items(id)",
})
.then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
)
.then((calsIds) => {
calendar.freebusy.query(
{
requestBody: {
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id: id })),
},
},
(err, apires) => {
if (err) return reject(err);
// If there's no calendar we just skip
if (!apires?.data.calendars) return resolve([]);
const result = Object.values(apires.data.calendars).reduce((c, i) => {
i.busy?.forEach((busyTime) => {
c.push({
start: busyTime.start || "",
end: busyTime.end || "",
});
});
return c;
}, [] as Prisma.PromiseReturnType<CalendarService["getAvailability"]>);
resolve(result);
}
);
})
.catch((err) => {
this.log.error("There was an error contacting google calendar service: ", err);
reject(err);
if (!freeBusyData?.calendars) throw new Error("No response from google calendar");
const result = Object.values(freeBusyData.calendars).reduce((c, i) => {
i.busy?.forEach((busyTime) => {
c.push({
start: busyTime.start || "",
end: busyTime.end || "",
});
});
});
return c;
}, [] as Prisma.PromiseReturnType<CalendarService["getAvailability"]>);
return result;
} catch (error) {
this.log.error("There was an error contacting google calendar service: ", error);
throw error;
}
}
async listCalendars(): Promise<IntegrationCalendar[]> {
return new Promise(async (resolve, reject) => {
const myGoogleAuth = await this.auth.getToken();
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.calendarList
.list({
fields: "items(id,summary,primary,accessRole)",
})
.then((cals) => {
resolve(
cals.data.items?.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No id",
integration: this.integrationName,
name: cal.summary ?? "No name",
primary: cal.primary ?? false,
readOnly: !(cal.accessRole === "writer" || cal.accessRole === "owner") && true,
email: cal.id ?? "",
};
return calendar;
}) || []
);
})
.catch((err: Error) => {
this.log.error("There was an error contacting google calendar service: ", err);
reject(err);
});
});
const calendar = await this.authedCalendar();
try {
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
if (!cals.data.items) return [];
return cals.data.items.map(
(cal) =>
({
externalId: cal.id ?? "No id",
integration: this.integrationName,
name: cal.summary ?? "No name",
primary: cal.primary ?? false,
readOnly: !(cal.accessRole === "writer" || cal.accessRole === "owner") && true,
email: cal.id ?? "",
} satisfies IntegrationCalendar)
);
} catch (error) {
this.log.error("There was an error contacting google calendar service: ", error);
throw error;
}
}
}
@@ -1,3 +1,4 @@
import { AlertCircleIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -79,29 +80,76 @@ export default function PayPalSetup() {
</div>
</form>
<div>
<p className="text-lgf text-default mt-5 font-bold">Setup instructions</p>
<p className="text-lgf text-default mt-5 font-bold">Getting started with Paypal APP</p>
<p className="text-default font-semi mt-2">
Here in Cal.com we offer Paypal as one of our payment gateway. You can use your own Paypal
Business account to receive payments from your customers enabling and setting up price and
currency for each of your event types.
</p>
<ol className="text-default ml-1 list-decimal pl-2">
<p className="text-lgf text-default mt-5 inline-flex font-bold">
<AlertCircleIcon className="mr-2 mt-1 h-4 w-4" /> Important requirements:
</p>
<ul className="text-default ml-1 mt-2 list-disc pl-2">
<li>Paypal Business account</li>
<li>Paypal Developer account</li>
</ul>
<p className="text-default mb-2 mt-5 font-bold">Resources:</p>
<a
className="text-orange-600 underline"
target="_blank"
href="https://developer.paypal.com/api/rest/#link-getclientidandclientsecret">
Link to Paypal developer API REST Setup Guide:
https://developer.paypal.com/api/rest/#link-getclientidandclientsecret
</a>
<p className="text-lgf text-default mt-5 font-bold">Setup instructions</p>
<p className="text-default font-semi mt-2">
Remember to only proceed with the following steps if your account has already been upgraded to
a business account. Also keep in mind that some of the following steps might be different
since Paypal offers different experiences based on your country.
</p>
<ol className="text-default ml-1 mt-5 list-decimal pl-2">
{/* @TODO: translate */}
<li>
Log into your Paypal Developer account and create a new app{" "}
<a
target="_blank"
href="https://developer.paypal.com/dashboard/applications/"
href="https://developer.paypal.com/dashboard/applications/live"
className="text-orange-600 underline">
{t("here")}
</a>
.
</li>
<li>Choose a name for your application.</li>
<li>Select Online payments solution.</li>
<li>Choose &quot;No&quot; for &quot;Using online platform&quot;.</li>
<li>CheckoutAPI as integration product.</li>
<li>Accept terms and Create APP.</li>
<li>Go back to dashboard, click on new app and copy the credentials.</li>
<li>Click on the Create App button</li>
<li>
Go back to{" "}
<a
className="text-orange-600 underline"
href="https://developer.paypal.com/dashboard/applications/live">
dashboard
</a>
, click on new app created.
</li>
<li>Copy the Client ID and Secret Key using copy buttons one by one.</li>
<li>Paste them on the required field and save them.</li>
<li>You&apos;re all setup.</li>
<li>You should be all setup after this.</li>
</ol>
<p className="text-default mt-5 inline-flex font-bold">
<AlertCircleIcon className="mr-2 mt-1 h-4 w-4" />
Reminder:
</p>
<p className="text-default mt-2">
Our integration creates a specific webhook on your Paypal account that we use to report back
transactions to our system. If you delete this webhook, we will not be able to report back and
you should Uninstall and Install the app again for this to work again. Uninstalling the app
won&apos;t delete your current event type price/currency configuration but you would not be
able to receive bookings.
</p>
</div>
</div>
</div>
+6 -1
View File
@@ -278,7 +278,12 @@ export class Cal {
}
}
const urlInstance = new URL(`${calOrigin || config.calOrigin}/${calLink}`);
// cal.com has rewrite issues on Safari that sometimes cause 404 for assets.
const originToUse = (calOrigin || config.calOrigin || "").replace(
"https://cal.com",
"https://app.cal.com"
);
const urlInstance = new URL(`${originToUse}/${calLink}`);
if (!urlInstance.pathname.endsWith("embed")) {
// TODO: Make a list of patterns that are embeddable. All except that should be allowed with a warning that "The page isn't optimized for embedding"
urlInstance.pathname = `${urlInstance.pathname}/embed`;
@@ -143,7 +143,9 @@ const providers: Provider[] = [
if (user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
}
if (!user.password && user.identityProvider == IdentityProvider.CAL) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
if (!user.password && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
@@ -47,7 +47,7 @@ export const EventTypeDescription = ({
<div
className={classNames(
"text-subtle line-clamp-3 break-words py-1 text-sm sm:max-w-[650px] [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600",
shortenDescription ? "line-clamp-4" : ""
shortenDescription ? "line-clamp-4 [&>*:not(:first-child)]:hidden" : ""
)}
dangerouslySetInnerHTML={{
__html: eventType.descriptionAsSafeHTML || "",
+1
View File
@@ -3,6 +3,7 @@
* Maybe later on we can add string variants or numeric ones
**/
export type AppFlags = {
"calendar-cache": boolean;
emails: boolean;
insights: boolean;
teams: boolean;
@@ -189,7 +189,7 @@ export function UserListTable() {
<Badge
variant={role === "MEMBER" ? "gray" : "blue"}
onClick={() => {
table.getColumn("role")?.setFilterValue(role);
table.getColumn("role")?.setFilterValue([role]);
}}>
{role}
</Badge>
+1 -1
View File
@@ -30,7 +30,7 @@ export async function getTeamWithMembers(args: {
},
selectedCalendars: true,
credentials: {
include: {
select: {
app: {
select: {
slug: true,
@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE
"CalendarCache" (
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"credentialId" INTEGER NOT NULL,
CONSTRAINT "CalendarCache_pkey" PRIMARY KEY ("credentialId", "key")
);
-- CreateIndex
CREATE UNIQUE INDEX "CalendarCache_credentialId_key_key" ON "CalendarCache" ("credentialId", "key");
-- AddForeignKey
ALTER TABLE "CalendarCache" ADD CONSTRAINT "CalendarCache_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "Credential" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Add Feature Flag
INSERT INTO
"Feature" (slug, enabled, description, "type")
VALUES
(
'calendar-cache',
false,
'Enable Third Party Calendar Cache - Cache third party calendar events to reduce the number of API calls to third party calendar providers.',
'OPERATIONAL'
) ON CONFLICT (slug) DO NOTHING;
+13
View File
@@ -139,6 +139,7 @@ model Credential {
destinationCalendars DestinationCalendar[]
selectedCalendars SelectedCalendar[]
invalid Boolean? @default(false)
CalendarCache CalendarCache[]
@@index([userId])
@@index([appId])
@@ -923,3 +924,15 @@ view BookingTimeStatus {
timeStatus String?
eventParentId Int?
}
model CalendarCache {
// The key would be the unique URL that is requested by the user
key String
value Json
expiresAt DateTime
credentialId Int
credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade)
@@id([credentialId, key])
@@unique([credentialId, key])
}