Compare commits

...
58 Commits
Author SHA1 Message Date
Omar López 7d4cc59cd2 Update package.json
release.yml / release (push) Canceled after 0s
2023-02-08 16:07:19 -07:00
Alex van AndelandOmar López a94465ff04 Feature/maintenance mode (#6930)
* Implement maintenance mode with Vercel Edge Config

* Error log is spam during development/ added \n in .env.example

* Exclude _next, /api for /maintenance page

* Re-instate previous config

* rtl: begone

* Added note to explain why /auth/login covers the maintenance page.

---------

Co-authored-by: Omar López <zomars@me.com>
2023-02-08 21:51:58 +00:00
Leo Giovanetti 4d54ac3ca2 Fixing admin wizard step done (#6954) 2023-02-08 14:36:46 -07:00
15da690270 fix: use typedquery hook in duplicate dialog (#6730)
* fix: use typedquery hook in duplicate dialog

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Update packages/features/eventtypes/components/DuplicateDialog.tsx

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Omar López <zomars@me.com>
2023-02-08 21:29:16 +00:00
Joe Au-Yeung 2462e8e597 Small UI fixes for seats & destination calendars (#6859)
* Do not add former time for events on seats

* Default display destination calendar

* Add seats badge to event type item

* Add string

* Actively watch seats enabled option for requires confirmation

* Only show former time when there is a rescheduleUid
2023-02-08 21:13:10 +00:00
2f628a17df feat/payment-service-6438-cal-767 (#6677)
* WIP paymentService

* Changes for payment Service

* Fix for stripe payment flow

* Remove logs/comments

* Refactored refund for stripe app

* Move stripe handlePayment to own lib

* Move stripe delete payments to paymentService

* lint fix

* Change handleRefundError as generic function

* remove log

* remove logs

* remove logs

* Return stripe default export to lib/server

* Fixing types

* Fix types

* Upgrades typescript

* Update yarn lock

* Typings

* Hotfix: ping,riverside,whereby and around not showing up in list (#6712)

* Hotfix: ping,riverside,whereby and around not showing up in list (#6712) (#6713)

* Adds deployment settings to DB (#6706)

* WIP

* Adds DeploymentTheme

* Add missing migrations

* Adds client extensions for deployment

* Cleanup

* Revert "lint fix"

This reverts commit e1a2e4a357e58e6673c47399888ae2e00d1351a6.

* Add validation

* Revert changes removed in force push

* Removing abstract class and just leaving interface implementation

* Fix types for handlePayments

* Fix payment test appStore import

* Fix stripe metadata in event type

* Move migration to separate PR

* Revert "Move migration to separate PR"

This reverts commit 48aa64e0724a522d3cc2fefaaaee5792ee9cd9e6.

* Update packages/prisma/migrations/20230125175109_remove_type_from_payment_and_add_app_relationship/migration.sql

Co-authored-by: Omar López <zomars@me.com>

---------

Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-02-08 13:36:22 -07:00
Deepak Prabhakara 1ee3783db3 Allow account linking for Google and SAML providers (#6874)
* allow account linking for self-hosted instances, both Google and SAML are verified emails

* allow account linking for Google and SSO if emails match with existing username/password account

* Tweaked find user by email since we now have multiple providers (other than credentials provider)
2023-02-08 11:39:56 -07:00
Carina WollendorferandCarinaWolli b3d8d7e944 Fixes localisation of {EVENT_DATE} in workflows (#6907)
* translate {EVENT_DATE} variable to correct language

* fix locale for cron schedule reminder emails/sms

* fix type error

* add missing locale to attendees

* fix type error

* code clean up

* revert last commit

* using Intl for date translations

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-02-08 17:06:25 +00:00
Carina WollendorferandCarinaWolli a4b7f1af65 fix start icon in button (#6950)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-02-08 17:02:08 +00:00
Alex van Andel f7445bf8a1 Block /auth/:path, nothing else. (#6949)
* Block /auth/:path, nothing else.

* Also add /signup
2023-02-08 16:27:18 +00:00
Hariom Balhara 9cad7086d7 Give trackingId a default value because if user doesnt interact with trackingId input it is not set (#6945) 2023-02-08 16:20:13 +00:00
Peer Richelsen 3345ec61ee minor timezone-select improvements (#6944)
* fixed timezone select positioning and hover

* fixed timezone select positioning and hover
2023-02-08 14:34:17 +00:00
Alex van Andel 7797421ce9 Remove _subject translations for zh-CN, needs retranslation 2023-02-08 14:31:29 +00:00
Udit Takkar 075a274207 Email Variables Bug (#6943) 2023-02-08 15:17:45 +01:00
Udit Takkar a46a31040a fix: add sortByLabel (#6797)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-02-08 12:48:47 +00:00
Udit Takkar 7a28e1d6f4 fix: display provider name instead of url (#6914)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-02-08 12:45:09 +00:00
Ben Hybert 51fd7fdb3e fixes the broken max size of members on teams page (#6926) 2023-02-08 12:44:28 +00:00
Joe Shajan d01963303a fix: dropdown title in bookings page (#6924) 2023-02-08 12:43:45 +00:00
Udit Takkar 6877d0f693 fix: use destination calendar email (#6886)
* fix: use destination calendar email
to display correct primary email

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: simplify logic

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-02-08 12:19:02 +00:00
Udit Takkar 39040fca9a fix: color and line height of icon (#6913) 2023-02-08 13:14:52 +01:00
GitStart-Cal.comandgitstart-calcom 6fa4befc9b [CAL-488] Timezone selection has a weird double dropdown (#6851)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-02-08 12:08:00 +00:00
Peer Richelsen 8c9871c6ce added two new tips (#6915) 2023-02-08 11:21:07 +00:00
+8 a9af2fb255 Admin Wizard Choose License (#6574)
* Implementation

* i18n

* More i18n

* extracted i18n, needs api to get most recent price, added hint: update later

* Fixing i18n var

* Fix booking filters not working for admin (#6576)

* fix: react-select overflow issue in some modals. (#6587)

* feat: add a disable overflow prop

* feat: use the disable overflow prop

* Tailwind Merge (#6596)

* Tailwind Merge

* Fix merge classNames

* [CAL-808] /availability/single - UI issue on buttons beside time inputs (#6561)

* [CAL-808] /availability/single - UI issue on buttons beside time inputs

* Update apps/web/public/static/locales/en/common.json

* Update packages/features/schedules/components/Schedule.tsx

* create new translation for tooltip

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>

* Bye bye submodules (#6585)

* WIP

* Uses ssh instead

* Update .gitignore

* Update .gitignore

* Update Makefile

* Update git-setup.sh

* Update git-setup.sh

* Replaced Makefile with bash script

* Update package.json

* fix: show button on empty string (#6601)

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: add delete in dropdown (#6599)

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Update README.md

* Update README.md

* Changed a neutral- classes to gray (#6603)

* Changed a neutral- classes to gray

* Changed all border-1 to border

* Update package.json

* Test fixes

* Yarn lock fixes

* Fix string equality check in git-setup.sh

* [CAL-811] Avatar icon not redirecting user back to the main page (#6586)

* Remove cursor-pointer, remove old Avatar* files

* Fixed styling for checkedSelect + some cleanup

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>

* Harsh/add member invite (#6598)

Co-authored-by: Guest <guest@pop-os.localdomain>
Co-authored-by: root <harsh.singh@gocomet.com>

* Regenerated lockfile without upgrade (#6610)

* fix: remove annoying outline when <Button /> clicked (#6537)

* fix: remove annoying outline when <Button /> clicked

* Delete yarn.lock

* remove 1 on 1 icon (#6609)

* removed 1-on-1 badge

* changed user to users for group events

* fix: case-sensitivity in apps path (#6552)

* fix: lowercase slug

* fix: make fallback blocking

* Fix FAB (#6611)

* feat: add LocationSelect component (#6571)

* feat: add LocationSelect component

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: type error

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore: type error

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Update booking filters design  (#6543)

* Update booking filters

* Add filter on YOUR bookings

* Fix pending members showing up in list

* Reduce the avatar size to 'sm' for now

* Bugfix/dropdown menu trigger as child remove class names (#6614)

* Fix UsernameTextfield to take right height

* Remove className side-effect

* Incorrect resolution version fixed

* Converted mobile DropdownMenuTrigger styles into Button

* v2.5.3

* fix: use items-center (#6618)

* fix tooltip and modal stacking issues (#6491)

* fix tooltip and modal stacking issues

* use z-index in larger screens and less

Co-authored-by: Alex van Andel <me@alexvanandel.com>

* Temporary fix (#6626)

* Fix Ga4 tracking (#6630)

* generic <UpgradeScreen> component (#6594)

* first attempt of <UpgradeScreen>

* changes to icons

* reverted changes back to initial state, needs fix: teams not showing

* WIP

* Fix weird reactnode error

* Fix loading text

* added upgradeTip to routing forms

* icon colors

* create and use hook to check if user has team plan

* use useTeamPlan for upgradeTeamsBadge

* replace huge svg with compressed jpeg

* responsive fixes

* Update packages/ui/components/badge/UpgradeTeamsBadge.tsx

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Give team plan features to E2E tests

* Allow option to make a user part of team int ests

* Remove flash of paywall for team user

* Add team user for typeform tests as well

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* Removing env var to rely on db

* Restoring i18n keys, set loading moved

* Fixing tailwind-preset glob

* Wizard width fix for md+ screens

* Converting licenses options to radix radio

* Applying feedback + other tweaks

* Reverting this, not this PR related

* Unneeded code removal

* Reverting unneeded style change

* Applying feedback

* Removing licenseType

* Upgrades typescript

* Update yarn lock

* Typings

* Hotfix: ping,riverside,whereby and around not showing up in list (#6712)

* Hotfix: ping,riverside,whereby and around not showing up in list (#6712) (#6713)

* Adds deployment settings to DB (#6706)

* WIP

* Adds DeploymentTheme

* Add missing migrations

* Adds client extensions for deployment

* Cleanup

* Delete migration.sql

* Relying on both, env var and new model

* Restoring env example doc for backward compat

* Maximum call stack size exceeded fix?

* Revert upgrade

* Update index.ts

* Delete index.ts

* Not exposing license key, fixed radio behavior

* Covering undefined env var

* Self contained checkLicense

* Feedback

* Moar feedback

* Feedback

* Feedback

* Feedback

* Cleanup

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Nafees Nazik <84864519+G3root@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Harsh Singh <51085015+harshsinghatz@users.noreply.github.com>
Co-authored-by: Guest <guest@pop-os.localdomain>
Co-authored-by: root <harsh.singh@gocomet.com>
Co-authored-by: Luis Cadillo <luiscaf3r@gmail.com>
Co-authored-by: Mohammed Cherfaoui <hi@cherfaoui.dev>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2023-02-07 17:23:42 -07:00
alannnc 8c150264f4 add-console-vars (#6929) 2023-02-07 15:40:38 -07:00
Udit Takkar cfc3644d51 fix: add req.headers (#6921)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-02-07 12:42:47 +00:00
Omar López bdb4a7353b v2.5.10
release.yml / release (push) Canceled after 0s
2023-02-06 16:12:11 -07:00
Hariom Balhara 30c0e6d1d7 Beginning of Strict CSP Compliance (#6841)
* Add CSP Support and enable it initially for Login page

* Update README

* Make sure that CSP is not enabled if CSP_POLICY isnt set

* Add a new value for x-csp header that tells if instance has opted-in to CSP or not

* Add more src to CSP

* Fix typo in header name

* Remove duplicate headers fn

* Add https://eu.ui-avatars.com/api/

* Add CSP_POLICY to env.example
2023-02-06 22:50:08 +00:00
Nafees Nazik 390967990a fix: metadata not saved while creating a booking. (#6866)
* feat: add metadata to booking creation

* fix: bug
2023-02-06 18:05:30 +00:00
Peer Richelsen cc56602474 nit border change (#6899) 2023-02-06 15:59:20 +01:00
Peer Richelsen 43a044d59e theme for storybook 2023-02-06 15:15:26 +01:00
Alex van Andel 5b8ed9c3ef When unchecking the common schedule, schedule should be nulled (#6898) 2023-02-06 15:01:54 +01:00
Sai Deepesh c3d6df2dcf fix(date-overrides): alignment of edit & delete btns (#6892) 2023-02-06 01:31:05 +00:00
Alex van Andel 9d02cb493c Fixed some inconsistencies within single event type page (#6887)
* Fixed some inconsistencies within single event type page

* Fix layout shift on AvailabilityTab
2023-02-06 00:09:27 +01:00
sean-brydon 26ed478e50 Hide button (#6895) 2023-02-05 20:30:51 +00:00
Syed Ali Shahbaz 09e338970d Updates heroku deployment template (#6879) 2023-02-05 14:50:04 +00:00
Udit Takkar f60399ca8b fix: get location url from metadata (#6774)
* fix: get location url from metadata

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: replace location

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: type error

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: use zod

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-02-05 14:43:37 +00:00
Carina Wollendorfer 5cf7e32852 fix workflows when duplicating event types (#6873) 2023-02-04 13:06:37 +01:00
24be8a63f2 fix: filter booking in upcoming (#6406)
* fix: filter booking in upcoming

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: test

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: wipe-my-cal failing test

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
2023-02-03 22:03:35 +00:00
Hariom Balhara 75509c97fd Hotfix: For old Plausible installs enabled in an EventType, give a default value (#6860)
* Add default for trackingId for old plausible installs in event-types

* Fix types
2023-02-03 21:38:06 +00:00
Peer Richelsen 9b26c7aee6 lots of small changes by me and ciaran (#6871) 2023-02-03 16:49:33 +00:00
Alex van Andel f18e80cfcf Use Calendar component view=day for drop-in replacement troubleshooter (#6869)
* Use Calendar component view=day for drop-in replacement troubleshooter

* Setting the id to undefined makes the busy time selected

* Updated event title to include title+source
2023-02-03 16:43:52 +00:00
Ben Hybert 0a4b1ca014 version 2.5.9 (#6868) 2023-02-03 15:36:31 +00:00
Nafees Nazik 9fa6aced33 feat: make key unique (#6861)
release.yml / release (push) Canceled after 0s
2023-02-03 11:20:45 +01:00
Hariom Balhara 9093909075 Hotfix: Event Name (#6863) (#6864) 2023-02-03 11:20:10 +01:00
Aaron Presley 832fce91e0 Left / Right buttons now bottom-aligned and slightly shorter (#6857)
Fixes https://github.com/calcom/cal.com/issues/6589
2023-02-02 20:49:20 +01:00
Peer Richelsen 6da41fce63 fixed availabilty layout 2023-02-02 19:28:46 +01:00
Alex van Andel 3b51cc9aef v2.5.8
release.yml / release (push) Canceled after 0s
2023-02-02 15:54:38 +00:00
Hariom Balhara 50c8959cfa Use trackingId only for trackingDomain as thats the only variable supported (#6856) 2023-02-02 15:13:42 +00:00
Hariom Balhara c96d258f5e Give highest possible value to z-index (#6853) 2023-02-02 15:04:38 +00:00
Alex van Andel 6e45b1c383 Don't override but decorate the built-in encode function (#6850)
* Don't override but decorate the built-in encode function

* Removed jose + synced lockfile
2023-02-02 14:26:00 +00:00
GitStart-Cal.comandgitstart-calcom 551ebe863b [CAL-90] Event Types Single / Allow multiple physical locations (#6692)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-02-02 12:42:56 +00:00
Peer Richelsen fa7b94bfe9 Merge branch 'main' of https://github.com/calcom/cal.com 2023-02-02 13:33:49 +01:00
Peer Richelsen 56c5d39cd3 i18n fix 2023-02-02 12:50:28 +01:00
Peer Richelsen 7574fb6f44 i18n fix 2023-02-02 12:49:11 +01:00
Nafees Nazik 901d1f0e01 fix: make cal video default value (#6716) 2023-02-02 12:24:34 +01:00
GitStart-Cal.comandgitstart-calcom 828fcbe1d9 [CAL-713] /availability single - layout issues. Check spacing, margin, alignment with spec'd design (#6724)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-02-02 12:21:05 +01:00
Joe Au-Yeung 34db46869e Move e2e to buildjet (#6840) 2023-02-02 08:42:28 +00:00
Nafees Nazik 1f1cab2be1 fix: padding in shell for mobile (#6825)
* fix: padding

* fix: breakpoints in fab

* fix: padding
2023-02-02 09:24:31 +01:00
159 changed files with 3283 additions and 1142 deletions
+11 -4
View File
@@ -1,20 +1,21 @@
# ********** INDEX **********
#
# - LICENSE
# - LICENSE (DEPRECATED)
# - DATABASE
# - SHARED
# - NEXTAUTH
# - E-MAIL SETTINGS
# - LICENSE *************************************************************************************************
# https://github.com/calendso/calendso/blob/main/LICENSE
# - LICENSE (DEPRECATED) ************************************************************************************
# https://github.com/calcom/cal.com/blob/main/LICENSE
#
# Summary of terms:
# - The codebase has to stay open source, whether it was modified or not
# - You can not repackage or sell the codebase
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
#
# To enable enterprise-only features, fill your license key in here.
# To enable enterprise-only features, as an admin, go to /auth/setup to select your license and follow
# instructions. This environment variable is deprecated although still supported for backward compatibility.
# @see https://console.cal.com
CALCOM_LICENSE_KEY=
# ***********************************************************************************************************
@@ -157,3 +158,9 @@ NEXT_PUBLIC_COMPANY_NAME="Cal.com, Inc."
# Set this to true in to disable new signups
# NEXT_PUBLIC_DISABLE_SIGNUP=true
NEXT_PUBLIC_DISABLE_SIGNUP=
# Content Security Policy
CSP_POLICY=
# Vercel Edge Config
EDGE_CONFIG=
+1 -1
View File
@@ -13,7 +13,7 @@ concurrency:
jobs:
e2e:
timeout-minutes: 20
runs-on: ubuntu-latest
runs-on: buildjet-4vcpu-ubuntu-2204
env:
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
+2
View File
File diff suppressed because one or more lines are too long
+7 -4
View File
@@ -6,7 +6,7 @@
"keywords": ["react", "typescript", "node", "nextjs", "prisma", "postgres", "trpc"],
"addons": [
{
"plan": "heroku-postgresql:hobby-dev"
"plan": "heroku-postgresql:mini"
}
],
"env": {
@@ -43,15 +43,18 @@
},
"SENDGRID_API_KEY": {
"description": "Sendgrid api key. Used for email reminders in workflows",
"value": ""
"value": "",
"required": false
},
"SENDGRID_SYNC_API_KEY": {
"description": "Sendgrid internal sync service",
"value": ""
"value": "",
"required": false
},
"SENDGRID_EMAIL": {
"description": "Sendgrid email. Used for email reminders in workflows",
"value": ""
"value": "",
"required": false
},
"EMAIL_FROM": {
"description": "Configures the global From: header whilst sending emails",
+12
View File
@@ -0,0 +1,12 @@
import { create } from "@storybook/theming";
export default create({
base: "light",
colorPrimary: "#111827",
colorSecondary: "#111827",
appContentBg: "#F9FAFC",
brandTitle: "Cal.com UI",
brandUrl: "https://cal.com",
brandImage: "https://cal.com/logo.svg",
brandTarget: "_self",
});
+7
View File
@@ -0,0 +1,7 @@
import { addons } from "@storybook/addons";
import yourTheme from "./YourTheme";
addons.setConfig({
theme: yourTheme,
});
-1
View File
@@ -65,7 +65,6 @@ export default function AppListCard(props: AppListCardProps) {
<div className={`p-4 ${highlight ? "bg-yellow-100" : ""}`}>
<div className="flex gap-x-3">
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
<div className="flex grow flex-col gap-y-1 truncate">
<div className="flex items-center gap-x-2">
<h3 className="truncate text-sm font-semibold text-gray-900">{title}</h3>
@@ -10,15 +10,15 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
const { t } = useLocale();
return locations.length ? (
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-2 break-words text-sm text-gray-600">
{locations.map((location) => {
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-4 break-words text-sm text-gray-600">
{locations.map((location, index) => {
const eventLocationType = getEventLocationType(location.type);
if (!eventLocationType) {
// It's possible that the location app got uninstalled
return null;
}
return (
<div key={location.type} className="flex flex-row items-center text-sm font-medium">
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
{eventLocationType.iconUrl === "/link.svg" ? (
<FiLink className="dark:text-darkgray-600 ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
) : (
@@ -26,7 +26,7 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
src={eventLocationType.iconUrl}
className={classNames(
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
!eventLocationType.iconUrl?.includes("api") ? "dark:invert-[.65]" : ""
)}
alt={`${eventLocationType.label} icon`}
/>
@@ -53,7 +53,7 @@ function BookingListItem(booking: BookingItemProps) {
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data.status === BookingStatus.REJECTED) {
if (data?.status === BookingStatus.REJECTED) {
setRejectionDialogIsOpen(false);
showToast(t("booking_rejection_success"), "success");
} else {
+59 -14
View File
@@ -1,8 +1,10 @@
import { FC, useEffect, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { ITimezoneOption, TimezoneSelect } from "@calcom/ui";
import useMeQuery from "@lib/hooks/useMeQuery";
import { timeZone } from "../../lib/clock";
type Props = {
@@ -11,7 +13,8 @@ type Props = {
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const { t } = useLocale();
const query = useMeQuery();
const userTheme = useTheme(query?.data?.theme).resolvedTheme;
useEffect(() => {
setSelectedTimeZone(timeZone());
@@ -23,18 +26,60 @@ const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
}
}, [selectedTimeZone, onSelectTimeZone]);
return selectedTimeZone !== "" ? (
<div className="dark:border-darkgray-300 dark:bg-darkgray-200 rounded-sm border border-gray-200 bg-white px-4 pt-4 pb-3 shadow-sm">
<div className="mb-4 flex">
<div className="text-sm font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
</div>
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
className="focus:border-brand mt-1 mb-2 block w-full rounded-md border-gray-300 text-sm focus:ring-black"
/>
</div>
const customStyles = {
option: (_provided: object, state: { isSelected: boolean; isFocused: boolean }) => ({
padding: "10px 12px !important",
color: state.isSelected || state.isFocused ? "#101010" : "#374151",
backgroundColor: state.isSelected ? "#E5E7EB !important" : "white",
...(userTheme === "dark" && {
color: state.isSelected || state.isFocused ? "white" : "#80868B",
}),
}),
control: () => ({
display: "flex",
cursor: "pointer",
backgroundColor: "transparent !important",
minWidth: "5rem",
height: "24px",
minHeight: "24px !important",
boxShadow: "none !important",
}),
singleValue: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
...provided,
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
...(userTheme === "dark" && {
color: "#a5a5a5 !important",
}),
}),
menu: (provided: object) => ({
...provided,
minWidth: "20rem",
margin: "8px 0 0 -20px",
}),
valueContainer: (provided: object) => ({
...provided,
padding: "0 0 0 6px",
}),
dropdownIndicator: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
...provided,
transform: state.selectProps.menuIsOpen ? "rotate(180deg)" : "",
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
marginLeft: 4,
padding: 0,
...(userTheme === "dark" && {
color: "#80868B",
}),
}),
};
return !!selectedTimeZone ? (
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
className="flex h-6 text-sm font-medium"
styles={customStyles}
/>
) : null;
};
@@ -1,6 +1,5 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { EventType } from "@prisma/client";
import * as Popover from "@radix-ui/react-popover";
import { useRouter } from "next/router";
import { useReducer, useEffect, useMemo, useState } from "react";
import { Toaster } from "react-hot-toast";
@@ -20,7 +19,7 @@ import {
import DatePicker from "@calcom/features/calendars/DatePicker";
import CustomBranding from "@calcom/lib/CustomBranding";
import classNames from "@calcom/lib/classNames";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import notEmpty from "@calcom/lib/notEmpty";
@@ -30,7 +29,7 @@ import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import { HeadSeo } from "@calcom/ui";
import { FiChevronDown, FiChevronUp, FiCreditCard, FiGlobe, FiRefreshCcw } from "@calcom/ui/components/icon";
import { FiCreditCard, FiGlobe, FiRefreshCcw } from "@calcom/ui/components/icon";
import { timeZone as localStorageTimeZone } from "@lib/clock";
import useRouterQuery from "@lib/hooks/useRouterQuery";
@@ -212,41 +211,22 @@ const SlotPicker = ({
function TimezoneDropdown({
onChangeTimeZone,
timeZone,
}: {
onChangeTimeZone: (newTimeZone: string) => void;
timeZone?: string;
}) {
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
const handleSelectTimeZone = (newTimeZone: string) => {
onChangeTimeZone(newTimeZone);
localStorageTimeZone(newTimeZone);
setIsTimeOptionsOpen(false);
};
return (
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 !mt-2 inline-block self-start rounded-md px-2 py-2 text-left text-gray-600">
<p className="flex items-center text-sm font-medium">
<FiGlobe className="min-h-4 min-w-4 ml-[2px] -mt-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]" />
{timeZone}
{isTimeOptionsOpen ? (
<FiChevronUp className="min-h-4 min-w-4 ml-1 inline-block" />
) : (
<FiChevronDown className="min-h-4 min-w-4 ml-1 inline-block" />
)}
</p>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
hideWhenDetached
align="start"
className="animate-fade-in-up absolute left-0 top-2 w-80 max-w-[calc(100vw_-_1.5rem)]">
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<>
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white">
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-[2px] rtl:ml-[2px]" />
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
</div>
</>
);
}
@@ -311,7 +291,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
() => <TimezoneDropdown timeZone={timeZone} onChangeTimeZone={setTimeZone} />,
[timeZone]
);
const stripeAppData = getStripeAppData(eventType);
const paymentAppData = getPaymentAppData(eventType);
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
const rawSlug = profile.slug ? profile.slug.split("/") : [];
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
@@ -362,10 +342,10 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
isBackgroundTransparent
? ""
: "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white pb-4 md:pb-0",
"border-bookinglightest overflow-hidden md:rounded-md md:border",
"border-bookinglightest md:rounded-md md:border",
isEmbed && "mx-auto"
)}>
<div className="overflow-hidden md:flex">
<div className="md:flex">
{showEventTypeDetails && (
<div
className={classNames(
@@ -399,14 +379,14 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
</div>
</div>
)}
{stripeAppData.price > 0 && (
{paymentAppData.price > 0 && (
<p className="-ml-2 px-2 text-sm font-medium">
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
<IntlProvider locale="en">
<FormattedNumber
value={stripeAppData.price / 100.0}
value={paymentAppData.price / 100.0}
style="currency"
currency={stripeAppData.currency.toUpperCase()}
currency={paymentAppData.currency?.toUpperCase()}
/>
</IntlProvider>
</p>
@@ -32,7 +32,7 @@ import {
import CustomBranding from "@calcom/lib/CustomBranding";
import classNames from "@calcom/lib/classNames";
import { APP_NAME } from "@calcom/lib/constants";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { HttpError } from "@calcom/lib/http-error";
@@ -122,7 +122,7 @@ const BookingPage = ({
}),
{}
);
const stripeAppData = getStripeAppData(eventType);
const paymentAppData = getPaymentAppData(eventType);
// Define duration now that we support multiple duration eventTypes
let duration = eventType.length;
if (
@@ -148,6 +148,7 @@ const BookingPage = ({
const mutation = useMutation(createBooking, {
onSuccess: async (responseData) => {
const { uid, paymentUid } = responseData;
if (paymentUid) {
return await router.push(
createPaymentLink({
@@ -166,7 +167,7 @@ const BookingPage = ({
isSuccessBookingPage: true,
email: bookingForm.getValues("email"),
eventTypeSlug: eventType.slug,
formerTime: booking?.startTime.toString(),
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
},
});
},
@@ -557,14 +558,14 @@ const BookingPage = ({
{showEventTypeDetails && (
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
{stripeAppData.price > 0 && (
{paymentAppData.price > 0 && (
<p className="text-bookinglight -ml-2 px-2 text-sm ">
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
<IntlProvider locale="en">
<FormattedNumber
value={stripeAppData.price / 100.0}
value={paymentAppData.price / 100.0}
style="currency"
currency={stripeAppData.currency.toUpperCase()}
currency={paymentAppData?.currency?.toUpperCase()}
/>
</IntlProvider>
</p>
@@ -99,100 +99,105 @@ const format = (date: Date, hour12: boolean) =>
new Date(dayjs.utc(date).format("YYYY-MM-DDTHH:mm:ss"))
);
export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
const EventTypeScheduleDetails = () => {
const { data: loggedInUser } = useMeQuery();
const timeFormat = loggedInUser?.timeFormat;
const { t, i18n } = useLocale();
const { watch } = useFormContext<FormValues>();
const EventTypeSchedule = () => {
const me = useMeQuery();
const timeFormat = me?.data?.timeFormat;
return (
<div className="space-y-4">
<div>
<div className="min-w-4 mb-2">
<label htmlFor="availability" className="mt-0 flex text-sm font-medium text-gray-700">
{t("availability")}
</label>
</div>
<Controller
name="schedule"
render={({ field }) => (
<AvailabilitySelect
value={field.value}
onBlur={field.onBlur}
name={field.name}
onChange={(selected) => {
field.onChange(selected?.value || null);
}}
/>
)}
/>
</div>
<div className="space-y-4 rounded border p-4 py-6 pt-2 md:p-8">
<ol className="table border-collapse text-sm">
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
const isAvailable = !!filterDays(index).length;
return (
<li key={day} className="my-6 flex border-transparent last:mb-2">
<span
className={classNames(
"w-20 font-medium sm:w-32",
!isAvailable && "text-gray-500 opacity-50"
)}>
{day}
</span>
{isLoading ? (
<SkeletonText className="block h-5 w-60" />
) : isAvailable ? (
<div className="space-y-3 text-right">
{filterDays(index).map((dayRange, i) => (
<div key={i} className="flex items-center leading-4">
<span className="w-16 sm:w-28 sm:text-left">
{format(dayRange.startTime, timeFormat === 12)}
</span>
<span className="ltr:ml-4 rtl:mr-4">-</span>
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
</div>
))}
</div>
) : (
<span className="ml-6 text-gray-500 opacity-50 sm:ml-0">{t("unavailable")}</span>
)}
</li>
);
})}
</ol>
<hr />
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
<FiGlobe className="ltr:mr-2 rtl:ml-2" />
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
</span>
<Button
href={`/availability/${schedule?.schedule.id}`}
color="minimal"
EndIcon={FiExternalLink}
target="_blank"
className="justify-center border sm:border-0"
rel="noopener noreferrer">
{t("edit_availability")}
</Button>
</div>
</div>
</div>
);
};
const scheduleId = watch("schedule");
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery({ scheduleId });
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined },
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId }
);
const filterDays = (dayNum: number) =>
schedule?.schedule.availability.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
if (!isTeamEvent) {
return <EventTypeSchedule />;
}
return (
<div className="space-y-4 rounded border px-6 pb-4">
<ol className="table border-collapse text-sm">
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
const isAvailable = !!filterDays(index).length;
return (
<li key={day} className="my-6 flex border-transparent last:mb-2">
<span
className={classNames(
"w-20 font-medium sm:w-32",
!isAvailable && "text-gray-500 opacity-50"
)}>
{day}
</span>
{isLoading ? (
<SkeletonText className="block h-5 w-60" />
) : isAvailable ? (
<div className="space-y-3 text-right">
{filterDays(index).map((dayRange, i) => (
<div key={i} className="flex items-center leading-4">
<span className="w-16 sm:w-28 sm:text-left">
{format(dayRange.startTime, timeFormat === 12)}
</span>
<span className="ltr:ml-4 rtl:mr-4">-</span>
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
</div>
))}
</div>
) : (
<span className="ml-6 text-gray-500 opacity-50 sm:ml-0">{t("unavailable")}</span>
)}
</li>
);
})}
</ol>
<hr />
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
<FiGlobe className="ltr:mr-2 rtl:ml-2" />
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
</span>
<Button
href={`/availability/${schedule?.schedule.id}`}
color="minimal"
EndIcon={FiExternalLink}
target="_blank"
rel="noopener noreferrer">
{t("edit_availability")}
</Button>
</div>
</div>
);
};
const EventTypeSchedule = () => {
const { t } = useLocale();
return (
<div className="space-y-4">
<div>
<label htmlFor="availability" className="mb-2 block text-sm font-medium leading-none text-gray-700">
{t("availability")}
</label>
<Controller
name="schedule"
render={({ field }) => (
<AvailabilitySelect
value={field.value}
onBlur={field.onBlur}
name={field.name}
onChange={(selected) => {
field.onChange(selected?.value || null);
}}
/>
)}
/>
</div>
<EventTypeScheduleDetails />
</div>
);
};
const UseCommonScheduleSettingsToggle = () => {
const { t } = useLocale();
const { resetField, setValue } = useFormContext<FormValues>();
return (
<Controller
name="metadata.config.useHostSchedulesForTeamEvent"
@@ -201,6 +206,11 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
checked={!value}
onCheckedChange={(checked) => {
onChange(!checked);
if (checked) {
resetField("schedule");
} else {
setValue("schedule", null);
}
}}
title={t("choose_common_schedule_team_event")}
description={t("choose_common_schedule_team_event_description")}>
@@ -210,3 +220,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
/>
);
};
export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
return isTeamEvent ? <UseCommonScheduleSettingsToggle /> : <EventTypeSchedule />;
};
@@ -74,7 +74,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
const removeCustom = (index: number) => {
formMethods.getValues("customInputs").splice(index, 1);
@@ -1,13 +1,13 @@
import { EventTypeSetupProps } from "pages/event-types/[type]";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import RecurringEventController from "./RecurringEventController";
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
const stripeAppData = getStripeAppData(eventType);
const paymentAppData = getPaymentAppData(eventType);
const requirePayment = stripeAppData.price > 0;
const requirePayment = paymentAppData.price > 0;
return (
<div className="">
+33 -10
View File
@@ -9,7 +9,12 @@ import { Controller, useForm, useFormContext } from "react-hook-form";
import { MultiValue } from "react-select";
import { z } from "zod";
import { EventLocationType, getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
import {
EventLocationType,
getEventLocationType,
MeetLocationType,
LocationType,
} from "@calcom/app-store/locations";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { slugify } from "@calcom/lib/slugify";
@@ -34,6 +39,16 @@ const getLocationFromType = (
}
};
const getDefaultLocationValue = (options: EventTypeSetupProps["locationOptions"], type: string) => {
for (const locationType of options) {
for (const location of locationType.options) {
if (location.value === type && location.disabled === false) {
return location;
}
}
}
};
export const EventSetupTab = (
props: Pick<EventTypeSetupProps, "eventType" | "locationOptions" | "team" | "teamMembers">
) => {
@@ -69,7 +84,12 @@ export const EventSetupTab = (
const removeLocation = (selectedLocation: typeof eventType.locations[number]) => {
formMethods.setValue(
"locations",
formMethods.getValues("locations").filter((location) => location.type !== selectedLocation.type),
formMethods.getValues("locations").filter((location) => {
if (location.type === LocationType.InPerson) {
return location.address !== selectedLocation.address;
}
return location.type !== selectedLocation.type;
}),
{ shouldValidate: true }
);
};
@@ -84,14 +104,14 @@ export const EventSetupTab = (
...details,
type: newLocationType,
};
} else {
copy[existingIdx] = {
...formMethods.getValues("locations")[existingIdx],
...details,
};
}
formMethods.setValue("locations", copy);
formMethods.setValue("locations", [
...copy,
...(newLocationType === LocationType.InPerson && editingLocationType === ""
? [{ ...details, type: newLocationType }]
: []),
]);
} else {
formMethods.setValue(
"locations",
@@ -138,7 +158,8 @@ export const EventSetupTab = (
return true;
});
const defaultValue = locationOptions.find((item) => item.label === "video")?.options;
const defaultValue = getDefaultLocationValue(locationOptions, "integrations:daily");
return (
<div className="w-full">
{validLocations.length === 0 && (
@@ -176,7 +197,9 @@ export const EventSetupTab = (
return null;
}
return (
<li key={location.type} className="mb-2 rounded-md border border-gray-300 py-1.5 px-2">
<li
key={`${location.type}${index}`}
className="mb-2 rounded-md border border-gray-300 py-1.5 px-2">
<div className="flex max-w-full justify-between">
<div key={index} className="flex flex-grow items-center">
<img
+13 -5
View File
@@ -25,6 +25,16 @@ const mapUserToValue = ({ id, name, username, email }: IMemberToValue) => ({
email,
});
const sortByLabel = (a: ReturnType<typeof mapUserToValue>, b: ReturnType<typeof mapUserToValue>) => {
if (a.label < b.label) {
return -1;
}
if (a.label > b.label) {
return 1;
}
return 0;
};
const FixedHosts = ({
control,
labelText,
@@ -103,7 +113,6 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
];
const teamMembersOptions = teamMembers.map(mapUserToValue);
return (
<div>
{team && (
@@ -128,17 +137,16 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
{schedulingType === SchedulingType.COLLECTIVE && (
<FixedHosts
options={teamMembersOptions}
options={teamMembersOptions.sort(sortByLabel)}
placeholder={t("add_attendees")}
labelText={t("team")}
control={formMethods.control}
/>
)}
{schedulingType === SchedulingType.ROUND_ROBIN && (
<>
<FixedHosts
options={teamMembersOptions}
options={teamMembersOptions.sort(sortByLabel)}
isOptionDisabled={(option) =>
!!formMethods.getValues("hosts").find((host) => host.userId.toString() === option.value)
}
@@ -173,7 +181,7 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
)
.filter(Boolean)}
controlShouldRenderValue={false}
options={teamMembers.map(mapUserToValue)}
options={teamMembersOptions.sort(sortByLabel)}
isOptionDisabled={(option) =>
!!formMethods
.getValues("hostsFixed")
@@ -100,7 +100,7 @@ export const EventTeamWebhooksTab = ({
<>
{webhooks.length ? (
<>
<div className="mt-4 mb-8 rounded-md border">
<div className="mb-8 rounded-md border">
{webhooks.map((webhook, index) => {
return (
<WebhookListItem
@@ -286,6 +286,7 @@ function EventTypeSingleLayout({
<DropdownMenuItem className="focus:ring-gray-100">
<DropdownItem
type="button"
color="destructive"
StartIcon={FiTrash}
disabled={!hasPermsToDelete}
onClick={() => setDeleteDialogOpen(true)}>
@@ -338,7 +339,7 @@ function EventTypeSingleLayout({
<div
className={classNames(
"mt-4 rounded-md border-gray-200 bg-white sm:mx-0 xl:mt-0",
disableBorder ? "border-0 xl:-mt-4 " : "p-2 md:border md:p-6"
disableBorder ? "border-0 " : "p-2 md:border md:p-6"
)}>
{children}
</div>
@@ -1,17 +1,39 @@
import { zodResolver } from "@hookform/resolvers/zod";
import classNames from "classnames";
import { signIn } from "next-auth/react";
import { useRouter } from "next/router";
import React from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import * as z from "zod";
import { isPasswordValid } from "@calcom/lib/auth";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmailField, Label, PasswordField, TextField } from "@calcom/ui";
import { EmailField, EmptyScreen, Label, PasswordField, TextField } from "@calcom/ui";
import { FiUserCheck } from "@calcom/ui/components/icon";
const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
const router = useRouter();
export const AdminUserContainer = (props: React.ComponentProps<typeof AdminUser> & { userCount: number }) => {
const { t } = useLocale();
if (props.userCount > 0)
return (
<form
id="wizard-step-1"
name="wizard-step-1"
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
props.onSuccess();
}}>
<EmptyScreen
Icon={FiUserCheck}
headline={t("admin_user_created")}
description={t("admin_user_created_description")}
/>
</form>
);
return <AdminUser {...props} />;
};
export const AdminUser = (props: { onSubmit: () => void; onError: () => void; onSuccess: () => void }) => {
const { t } = useLocale();
const formSchema = z.object({
@@ -45,11 +67,11 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
});
const onError = () => {
props.setIsLoading(false);
props.onError();
};
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
props.setIsLoading(true);
props.onSubmit();
const response = await fetch("/api/auth/setup", {
method: "POST",
body: JSON.stringify({
@@ -69,9 +91,9 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
email: data.email_address.toLowerCase(),
password: data.password,
});
router.replace(`/auth/setup?step=2&category=calendar`);
props.onSuccess();
} else {
router.replace("/auth/setup");
props.onError();
}
}, onError);
@@ -186,5 +208,3 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
</FormProvider>
);
};
export default SetupFormStep1;
@@ -0,0 +1,76 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import classNames from "classnames";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
const ENTERPRISE_BOOKING_FEE = "$99"; // TODO: get this from a new API endpoint
const ChooseLicense = (
props: {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
} & Omit<JSX.IntrinsicElements["form"], "onSubmit" | "onChange">
) => {
const { value: initialValue = "FREE", onChange, onSubmit, ...rest } = props;
const [value, setValue] = useState(initialValue);
const { t } = useLocale();
return (
<form
{...rest}
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
onSubmit(value);
}}>
<RadioGroup.Root
defaultValue={initialValue}
value={value}
aria-label={t("choose_a_license")}
className="grid grid-rows-2 gap-4 md:grid-cols-2 md:grid-rows-1"
onValueChange={(value) => {
onChange(value);
setValue(value);
}}>
<RadioGroup.Item value="FREE">
<div
className={classNames(
"cursor-pointer space-y-2 rounded-md border bg-white p-4 hover:border-black",
value === "FREE" && "ring-2 ring-black"
)}>
<h2 className="font-cal text-xl text-black">{t("agplv3_license")}</h2>
<p className="font-medium text-green-800">{t("free_license_fee")}</p>
<p className="text-gray-500">{t("forever_open_and_free")}</p>
<ul className="ml-4 list-disc text-left text-xs text-gray-500">
<li>{t("required_to_keep_your_code_open_source")}</li>
<li>{t("cannot_repackage_and_resell")}</li>
<li>{t("no_enterprise_features")}</li>
</ul>
</div>
</RadioGroup.Item>
<RadioGroup.Item value="EE">
<div
className={classNames(
"cursor-pointer space-y-2 rounded-md border bg-white p-4 hover:border-black",
value === "EE" && "ring-2 ring-black"
)}>
<h2 className="font-cal text-xl text-black">{t("ee_enterprise_license")}</h2>
<p className="font-medium text-green-800">
{t("enterprise_booking_fee", { enterprise_booking_fee: ENTERPRISE_BOOKING_FEE })}
</p>
<p className="text-gray-500">{t("enterprise_license_includes")}</p>
<ul className="ml-4 list-disc text-left text-xs text-gray-500">
<li>{t("no_need_to_keep_your_code_open_source")}</li>
<li>{t("repackage_rebrand_resell")}</li>
<li>{t("a_vast_suite_of_enterprise_features")}</li>
</ul>
</div>
</RadioGroup.Item>
</RadioGroup.Root>
</form>
);
};
export default ChooseLicense;
@@ -0,0 +1,144 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { noop } from "lodash";
import { useCallback, useState } from "react";
import { Controller, FormProvider, useForm, useFormState } from "react-hook-form";
import * as z from "zod";
import { classNames } from "@calcom/lib";
import { CONSOLE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterInputs, RouterOutputs, trpc } from "@calcom/trpc/react";
import { Button, TextField } from "@calcom/ui";
import { FiCheck, FiExternalLink, FiLoader } from "@calcom/ui/components/icon";
type EnterpriseLicenseFormValues = {
licenseKey: string;
};
const makeSchemaLicenseKey = (args: { callback: (valid: boolean) => void; onSuccessValidate: () => void }) =>
z.object({
licenseKey: z
.string()
.uuid({
message: "License key must follow UUID format: 8-4-4-4-12",
})
.superRefine(async (data, ctx) => {
const parse = z.string().uuid().safeParse(data);
if (parse.success) {
args.callback(true);
const response = await fetch(`${CONSOLE_URL}/api/license?key=${data}`);
args.callback(false);
const json = await response.json();
if (!json.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `License key ${json.message.toLowerCase()}`,
});
} else {
args.onSuccessValidate();
}
}
}),
});
const EnterpriseLicense = (
props: {
licenseKey?: string;
initialValue?: Partial<EnterpriseLicenseFormValues>;
onSuccessValidate: () => void;
onSubmit: (value: EnterpriseLicenseFormValues) => void;
onSuccess?: (
data: RouterOutputs["viewer"]["deploymentSetup"]["update"],
variables: RouterInputs["viewer"]["deploymentSetup"]["update"]
) => void;
} & Omit<JSX.IntrinsicElements["form"], "onSubmit">
) => {
const { onSubmit, onSuccess = noop, onSuccessValidate = noop, ...rest } = props;
const { t } = useLocale();
const [checkLicenseLoading, setCheckLicenseLoading] = useState(false);
const mutation = trpc.viewer.deploymentSetup.update.useMutation({
onSuccess,
});
const schemaLicenseKey = useCallback(
() =>
makeSchemaLicenseKey({
callback: setCheckLicenseLoading,
onSuccessValidate,
}),
[setCheckLicenseLoading, onSuccessValidate]
);
const formMethods = useForm<EnterpriseLicenseFormValues>({
defaultValues: {
licenseKey: props.licenseKey || "",
},
resolver: zodResolver(schemaLicenseKey()),
});
const handleSubmit = formMethods.handleSubmit((values) => {
onSubmit(values);
setCheckLicenseLoading(false);
mutation.mutate(values);
});
const { isDirty, errors } = useFormState(formMethods);
return (
<FormProvider {...formMethods}>
<form {...rest} className="space-y-4 rounded-md bg-white px-8 py-10" onSubmit={handleSubmit}>
<div>
<Button
className="w-full justify-center text-lg"
EndIcon={FiExternalLink}
href="https://console.cal.com"
target="_blank">
{t("purchase_license")}
</Button>
<div className="relative flex justify-center">
<hr className="my-8 w-full border-[1.5px] border-gray-200" />
<span className="absolute mt-[22px] bg-white px-3.5 text-sm">OR</span>
</div>
{t("already_have_key")}
<Controller
name="licenseKey"
control={formMethods.control}
render={({ field: { onBlur, onChange, value } }) => (
<TextField
{...formMethods.register("licenseKey")}
className={classNames(
"mb-0 group-hover:border-gray-400",
(checkLicenseLoading || (errors.licenseKey === undefined && isDirty)) && "border-r-0"
)}
placeholder="xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx"
labelSrOnly={true}
value={value}
addOnFilled={false}
addOnClassname={classNames(
"hover:border-gray-300",
errors.licenseKey === undefined && isDirty && "group-hover:border-gray-400"
)}
addOnSuffix={
checkLicenseLoading ? (
<FiLoader className="h-5 w-5 animate-spin" />
) : errors.licenseKey === undefined && isDirty ? (
<FiCheck className="h-5 w-5 text-green-700" />
) : undefined
}
color={errors.licenseKey ? "warn" : ""}
onBlur={onBlur}
onChange={async (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
formMethods.setValue("licenseKey", e.target.value);
await formMethods.trigger("licenseKey");
}}
/>
)}
/>
</div>
</form>
</FormProvider>
);
};
export default EnterpriseLicense;
@@ -1,20 +1,26 @@
import { useRouter } from "next/router";
import { Dispatch, SetStateAction } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { FiCheck } from "@calcom/ui/components/icon";
const StepDone = () => {
const StepDone = (props: {
currentStep: number;
nextStepPath: string;
setIsLoading: Dispatch<SetStateAction<boolean>>;
}) => {
const router = useRouter();
const { t } = useLocale();
return (
<form
id="wizard-step-1"
name="wizard-step-1"
className="space-y-4"
id={`wizard-step-${props.currentStep}`}
name={`wizard-step-${props.currentStep}`}
className="flex justify-center space-y-4"
onSubmit={(e) => {
props.setIsLoading(true);
e.preventDefault();
router.replace(`/auth/setup?step=2&category=calendar`);
router.replace(props.nextStepPath);
}}>
<div className="min-h-36 my-6 flex flex-col items-center justify-center">
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-600 dark:bg-white">
+1 -1
View File
@@ -20,7 +20,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
return (
<Link key={member.id} href={`/${member.username}`}>
<div className="sm:min-w-80 sm:max-w-80 dark:bg-darkgray-200 dark:hover:bg-darkgray-300 group flex min-h-full w-[90%] flex-col space-y-2 rounded-md bg-white p-4 hover:cursor-pointer hover:bg-gray-50 ">
<div className="sm:min-w-80 sm:max-w-80 dark:bg-darkgray-200 dark:hover:bg-darkgray-300 group flex min-h-full flex-col space-y-2 rounded-md bg-white p-4 hover:cursor-pointer hover:bg-gray-50 ">
<Avatar
size="md"
alt={member.name || ""}
+1 -1
View File
@@ -21,7 +21,7 @@ const EditableHeading = function EditableHeading({
<label className="min-w-8 relative inline-block">
<span className="whitespace-pre text-xl tracking-normal text-transparent">{value}&nbsp;</span>
{!isEditing && isReady && (
<FiEdit2 className="ml-1 inline h-4 w-4 align-top text-gray-700 group-hover:text-gray-500" />
<FiEdit2 className=" ml-1 -mt-px inline h-3 w-3 text-gray-500 group-hover:text-gray-500" />
)}
<input
{...passThroughProps}
+4 -1
View File
@@ -13,18 +13,20 @@ import { trpc } from "@calcom/trpc/react";
import { MetaProvider } from "@calcom/ui";
import usePublicPage from "@lib/hooks/usePublicPage";
import { WithNonceProps } from "@lib/withNonce";
const I18nextAdapter = appWithTranslation<NextJsAppProps<SSRConfig> & { children: React.ReactNode }>(
({ children }) => <>{children}</>
);
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<NextAppProps, "Component"> & {
export type AppProps = Omit<NextAppProps<WithNonceProps & Record<string, unknown>>, "Component"> & {
Component: NextAppProps["Component"] & {
requiresLicense?: boolean;
isThemeSupported?: boolean | ((arg: { router: NextRouter }) => boolean);
getLayout?: (page: React.ReactElement, router: NextRouter) => ReactNode;
};
/** Will be defined only is there was an error */
err?: Error;
};
@@ -77,6 +79,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<ThemeProvider
nonce={props.pageProps.nonce}
enableColorScheme={false}
storageKey={storageKey}
forcedTheme={forcedTheme}
+83
View File
@@ -0,0 +1,83 @@
import crypto from "crypto";
import { IncomingMessage, OutgoingMessage } from "http";
import { z } from "zod";
import { IS_PRODUCTION } from "@calcom/lib/constants";
function getCspPolicy(nonce: string) {
//TODO: Do we need to explicitly define it in turbo.json
const CSP_POLICY = process.env.CSP_POLICY;
// Note: "non-strict" policy only allows inline styles otherwise it's the same as "strict"
// We can remove 'unsafe-inline' from style-src when we add nonces to all style tags
// Maybe see how @next-safe/middleware does it if it's supported.
const useNonStrictPolicy = CSP_POLICY === "non-strict";
return `
default-src 'self' ${IS_PRODUCTION ? "" : "data:"};
script-src ${
IS_PRODUCTION
? // 'self' 'unsafe-inline' https: added for Browsers not supporting strict-dynamic not supporting strict-dynamic
"'nonce-" + nonce + "' 'strict-dynamic' 'self' 'unsafe-inline' https:"
: // Note: We could use 'strict-dynamic' with 'nonce-..' instead of unsafe-inline but there are some streaming related scripts that get blocked(because they don't have nonce on them). It causes a really frustrating full page error model by Next.js to show up sometimes
"'unsafe-inline' 'unsafe-eval' https: http:"
};
object-src 'none';
base-uri 'none';
child-src app.cal.com;
style-src 'self' ${
IS_PRODUCTION ? (useNonStrictPolicy ? "'unsafe-inline'" : "") : "'unsafe-inline'"
} app.cal.com;
font-src 'self';
img-src 'self' https://www.gravatar.com https://img.youtube.com https://eu.ui-avatars.com/api/ data:
`;
}
// Taken from @next-safe/middleware
const isPagePathRequest = (url: URL) => {
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
const isFile = /\..*$/;
const { pathname } = url;
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
};
export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) {
if (!req) {
return { nonce: undefined };
}
const existingNonce = req.headers["x-nonce"];
if (existingNonce) {
const existingNoneParsed = z.string().safeParse(existingNonce);
return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" };
}
if (!req.url) {
return { nonce: undefined };
}
const CSP_POLICY = process.env.CSP_POLICY;
const cspEnabledForInstance = CSP_POLICY;
const nonce = crypto.randomBytes(16).toString("base64");
const parsedUrl = new URL(req.url, "http://base_url");
const cspEnabledForPage = cspEnabledForInstance && isPagePathRequest(parsedUrl);
if (!cspEnabledForPage) {
return {
nonce: undefined,
};
}
// Set x-nonce request header to be used by `getServerSideProps` or similar fns and `Document.getInitialProps` to read the nonce from
// It is generated for all page requests but only used by pages that need CSP
req.headers["x-nonce"] = nonce;
if (res) {
res.setHeader(
req.headers["x-csp-enforce"] === "true"
? "Content-Security-Policy"
: "Content-Security-Policy-Report-Only",
getCspPolicy(nonce)
.replace(/\s{2,}/g, " ")
.trim()
);
}
return { nonce };
}
+41
View File
@@ -0,0 +1,41 @@
import { GetServerSideProps, GetServerSidePropsContext } from "next";
import { csp } from "@lib/csp";
export type WithNonceProps = {
nonce?: string;
};
/**
* Make any getServerSideProps fn return the nonce so that it can be used by Components in the page to add any script tag.
* Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages
* There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag.
*/
export default function withNonce(getServerSideProps: GetServerSideProps) {
return async (context: GetServerSidePropsContext) => {
const ssrResponse = await getServerSideProps(context);
const { nonce } = csp(context.req, context.res);
// Skip nonce property if it's not available instead of setting it to undefined because undefined can't be serialized.
const nonceProps = nonce
? {
nonce,
}
: null;
if (!("props" in ssrResponse)) {
return ssrResponse;
}
// Helps in debugging that withNonce was used but a valid nonce couldn't be set
context.res.setHeader("x-csp", nonce ? "ssr" : "false");
return {
...ssrResponse,
props: {
...ssrResponse.props,
...nonceProps,
},
};
};
}
+41 -1
View File
@@ -1,3 +1,4 @@
import { get } from "@vercel/edge-config";
import { collectEvents } from "next-collect/server";
import { NextMiddleware, NextResponse, userAgent } from "next/server";
@@ -8,6 +9,28 @@ import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry
const middleware: NextMiddleware = async (req) => {
const url = req.nextUrl;
if (!url.pathname.startsWith("/api")) {
//
// NOTE: When tRPC hits an error a 500 is returned, when this is received
// by the application the user is automatically redirected to /auth/login.
//
// - For this reason our matchers are sufficient for an app-wide maintenance page.
//
try {
// Check whether the maintenance page should be shown
const isInMaintenanceMode = await get<boolean>("isInMaintenanceMode");
// If is in maintenance mode, point the url pathname to the maintenance page
if (isInMaintenanceMode) {
req.nextUrl.pathname = `/maintenance`;
return NextResponse.rewrite(req.nextUrl);
}
} catch (error) {
// show the default page if EDGE_CONFIG env var is missing,
// but log the error to the console
// console.error(error);
}
}
if (["/api/collect-events", "/api/auth"].some((p) => url.pathname.startsWith(p))) {
const callbackUrl = url.searchParams.get("callbackUrl");
const { isBot } = userAgent(req);
@@ -36,11 +59,28 @@ const middleware: NextMiddleware = async (req) => {
return NextResponse.rewrite(url);
}
if (url.pathname.startsWith("/auth/login")) {
const moreHeaders = new Headers(req.headers);
// Use this header to actually enforce CSP, otherwise it is running in Report Only mode on all pages.
moreHeaders.set("x-csp-enforce", "true");
return NextResponse.next({
request: {
headers: moreHeaders,
},
});
}
return NextResponse.next();
};
export const config = {
matcher: ["/api/collect-events/:path*", "/api/auth/:path*", "/apps/routing_forms/:path*", "/:path*/embed"],
matcher: [
"/api/collect-events/:path*",
"/api/auth/:path*",
"/apps/routing_forms/:path*",
"/:path*/embed",
"/auth/login",
],
};
export default collectEvents({
+29 -2
View File
@@ -35,6 +35,12 @@ if (!process.env.NEXT_PUBLIC_WEBSITE_URL) {
process.env.NEXT_PUBLIC_WEBSITE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL;
}
if (process.env.CSP_POLICY === "strict" && process.env.NODE_ENV === "production") {
throw new Error(
"Strict CSP policy(for style-src) is not yet supported in production. You can experiment with it in Dev Mode"
);
}
if (!process.env.EMAIL_FROM) {
console.warn(
"\x1b[33mwarn",
@@ -179,8 +185,7 @@ const nextConfig = {
async headers() {
return [
{
// prettier-ignore
source: "/:path*((?<!\/embed$)(?<!\/embed\/preview\.html$))",
source: "/auth/:path*",
headers: [
{
key: "X-Frame-Options",
@@ -188,6 +193,28 @@ const nextConfig = {
},
],
},
{
source: "/signup",
headers: [
{
key: "X-Frame-Options",
value: "DENY",
},
],
},
{
source: "/:path*",
headers: [
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
],
},
];
},
async redirects() {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "2.5.7",
"version": "2.5.11",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -61,6 +61,7 @@
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^4.3.9",
"@types/turndown": "^5.0.1",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.0.21",
"accept-language-parser": "^1.5.0",
@@ -74,7 +75,6 @@
"handlebars": "^4.7.7",
"ical.js": "^1.4.0",
"ics": "^2.37.0",
"jose": "^4.11.1",
"kbar": "^0.1.0-beta.36",
"libphonenumber-js": "^1.10.12",
"lodash": "^4.17.21",
+15 -1
View File
@@ -29,13 +29,27 @@ function MyApp(props: AppProps) {
} else if (router.pathname === "/500") {
pageStatus = "500";
}
// On client side don't let nonce creep into DOM
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
// See https://github.com/kentcdodds/nonce-hydration-issues
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
const nonce = typeof window !== "undefined" ? (pageProps.nonce ? "" : undefined) : pageProps.nonce;
const providerProps = {
...props,
pageProps: {
...props.pageProps,
nonce,
},
};
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout ?? ((page) => page);
return (
<AppProviders {...props}>
<AppProviders {...providerProps}>
<DefaultSeo {...seoConfig.defaultNextSeo} />
<I18nLanguageHandler />
<Script
nonce={nonce}
id="page-status"
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
/>
+18 -49
View File
@@ -1,58 +1,36 @@
import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps } from "next/document";
import Script from "next/script";
import { z } from "zod";
import { getDirFromLang } from "@calcom/lib/i18n";
import { csp } from "@lib/csp";
type Props = Record<string, unknown> & DocumentProps;
function toRunBeforeReactOnClient() {
const calEmbedMode = typeof new URL(document.URL).searchParams.get("embed") === "string";
/* Iframe Name */
window.name.includes("cal-embed");
window.isEmbed = () => {
// Once an embed mode always an embed mode
return calEmbedMode;
};
window.resetEmbedStatus = () => {
try {
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
window.sessionStorage.removeItem("calEmbedMode");
} catch (e) {}
};
window.getEmbedTheme = () => {
const url = new URL(document.URL);
return url.searchParams.get("theme") as "dark" | "light";
};
window.getEmbedNamespace = () => {
const url = new URL(document.URL);
const namespace = url.searchParams.get("embed");
return namespace;
};
window.isPageOptimizedForEmbed = () => {
// Those pages are considered optimized, which know at backend that they are rendering for embed.
// Such pages can be shown straightaway without a loader for a better embed experience
return location.pathname.includes("forms/");
};
}
class MyDocument extends Document<Props> {
static async getInitialProps(ctx: DocumentContext) {
const { nonce } = csp(ctx.req || null, ctx.res || null);
if (!process.env.CSP_POLICY) {
ctx.res?.setHeader("x-csp", "not-opted-in");
} else if (!ctx.res?.getHeader("x-csp")) {
// If x-csp not set by gSSP, then it's initialPropsOnly
ctx.res?.setHeader("x-csp", "initialPropsOnly");
}
const isEmbed = ctx.asPath?.includes("/embed") || ctx.asPath?.includes("embedType=");
const initialProps = await Document.getInitialProps(ctx);
return { isEmbed, ...initialProps };
return { isEmbed, nonce, ...initialProps };
}
render() {
const { locale } = this.props.__NEXT_DATA__;
const { isEmbed } = this.props;
const nonceParsed = z.string().safeParse(this.props.nonce);
const nonce = nonceParsed.success ? nonceParsed.data : "";
const dir = getDirFromLang(locale);
return (
<Html lang={locale} dir={dir}>
<Head>
<Head nonce={nonce}>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@@ -68,22 +46,13 @@ class MyDocument extends Document<Props> {
crossOrigin="anonymous"
/>
<link rel="preload" href="/fonts/cal.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
{/* Define isEmbed here so that it can be shared with App(embed-iframe) as well as the following code to change background and hide body
Persist the embed mode in sessionStorage because query param might get lost during browsing.
*/}
<Script
id="run-before-client"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `(${toRunBeforeReactOnClient.toString()})()`,
}}
/>
<Script src="/embed-init-iframe.js" strategy="beforeInteractive" />
</Head>
<body
className="dark:bg-darkgray-50 desktop-transparent bg-gray-100 antialiased"
style={
this.props.isEmbed
isEmbed
? {
background: "transparent",
// Keep the embed hidden till parent initializes and
@@ -95,7 +64,7 @@ class MyDocument extends Document<Props> {
: {}
}>
<Main />
<NextScript />
<NextScript nonce={nonce} />
</body>
</Html>
);
+34 -36
View File
@@ -1,9 +1,8 @@
import { IdentityProvider, UserPermissionRole } from "@prisma/client";
import { BinaryLike, hkdfSync, KeyObject } from "crypto";
import { readFileSync } from "fs";
import Handlebars from "handlebars";
import * as jose from "jose";
import NextAuth, { Session } from "next-auth";
import { encode } from "next-auth/jwt";
import { Provider } from "next-auth/providers";
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
@@ -11,7 +10,6 @@ import GoogleProvider from "next-auth/providers/google";
import nodemailer, { TransportOptions } from "nodemailer";
import { authenticator } from "otplib";
import path from "path";
import { v4 as uuidv4 } from "uuid";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
@@ -166,6 +164,7 @@ if (IS_GOOGLE_LOGIN_ENABLED) {
GoogleProvider({
clientId: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
})
);
}
@@ -204,6 +203,7 @@ if (isSAMLLoginEnabled) {
clientId: "dummy",
clientSecret: "dummy",
},
allowDangerousEmailAccountLinking: true,
});
}
@@ -238,10 +238,13 @@ if (true) {
})
);
}
function isNumber(n: string) {
return !isNaN(parseFloat(n)) && !isNaN(+n);
}
const calcomAdapter = CalComAdapter(prisma);
const getDerivedEncryptionKey = async (secret: BinaryLike | KeyObject) => {
return await hkdfSync("sha256", secret, "", "NextAuth.js Generated Encryption Key", 32);
};
export default NextAuth({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@@ -250,34 +253,23 @@ export default NextAuth({
strategy: "jwt",
},
jwt: {
encode: async ({ secret, token }) => {
if (!token || token.sub === undefined) throw new Error("Not valid token");
const user = await prisma.user.findFirst({
where: { id: Number(token.sub) },
select: { metadata: true },
});
const encryptionSecret = await getDerivedEncryptionKey(secret);
const metadata = userMetadata.parse(user?.metadata);
return await new jose.EncryptJWT({
sub: token?.sub,
name: token?.name,
email: token?.email,
})
.setProtectedHeader({
alg: "dir",
enc: "A256GCM",
})
.setIssuedAt()
.setExpirationTime(metadata?.sessionTimeout ? `${metadata.sessionTimeout}m` : "30d")
.setJti(uuidv4())
.encrypt(new Uint8Array(encryptionSecret));
},
decode: async ({ secret, token }) => {
const encryptionSecret = await getDerivedEncryptionKey(secret);
const { payload } = await jose.jwtDecrypt(token || "", new Uint8Array(encryptionSecret), {
clockTolerance: 15,
});
return payload;
// decorate the native JWT encode function
// Impl. detail: We don't pass through as this function is called with encode/decode functions.
encode: async ({ token, maxAge, secret }) => {
if (token?.sub && isNumber(token.sub)) {
const user = await prisma.user.findFirst({
where: { id: Number(token.sub) },
select: { metadata: true },
});
// if no user is found, we still don't want to crash here.
if (user) {
const metadata = userMetadata.parse(user.metadata);
if (metadata?.sessionTimeout) {
maxAge = metadata.sessionTimeout / 60;
}
}
}
return encode({ secret, token, maxAge });
},
},
cookies: defaultCookies(WEBAPP_URL?.startsWith("https://")),
@@ -370,7 +362,7 @@ export default NextAuth({
return token;
},
async session({ session, token }) {
const hasValidLicense = await checkLicense(process.env.CALCOM_LICENSE_KEY || "");
const hasValidLicense = await checkLicense(prisma);
const calendsoSession: Session = {
...session,
hasValidLicense,
@@ -507,7 +499,13 @@ export default NextAuth({
return true;
}
if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
// User signs up with email/password and then tries to login with Google/SAML using the same email
if (
existingUserWithEmail.identityProvider === IdentityProvider.CAL &&
(idP === IdentityProvider.GOOGLE || idP === IdentityProvider.SAML)
) {
return true;
} else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
return "/auth/error?error=use-password-login";
}
+1 -1
View File
@@ -43,7 +43,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
OR: [
{ username },
{
AND: [{ email: userEmail }, { password: { not: null } }, { username: { not: null } }],
AND: [{ email: userEmail }],
},
],
},
+7 -4
View File
@@ -19,6 +19,7 @@ import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
import { FiArrowLeft } from "@calcom/ui/components/icon";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import withNonce, { WithNonceProps } from "@lib/withNonce";
import AddToHomescreen from "@components/AddToHomescreen";
import TwoFactor from "@components/auth/TwoFactor";
@@ -40,7 +41,7 @@ export default function Login({
isSAMLLoginEnabled,
samlTenantID,
samlProductID,
}: inferSSRProps<typeof getServerSideProps>) {
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
const { t } = useLocale();
const router = useRouter();
const methods = useForm<LoginValues>();
@@ -205,7 +206,8 @@ export default function Login({
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
// TODO: Once we understand how to retrieve prop types automatically from getServerSideProps, remove this temporary variable
const _getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
const { req } = context;
const session = await getSession({ req });
const ssr = await ssrInit(context);
@@ -229,7 +231,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
};
}
return {
props: {
csrfToken: await getCsrfToken(context),
@@ -240,4 +241,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
samlProductID,
},
};
}
};
export const getServerSideProps = withNonce(_getServerSideProps);
+122 -13
View File
@@ -1,40 +1,125 @@
import { UserPermissionRole } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { useState } from "react";
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
import { getDeploymentKey } from "@calcom/features/ee/deployment/lib/getDeploymentKey";
import { getSession } from "@calcom/lib/auth";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { WizardForm } from "@calcom/ui";
import { Meta, WizardForm } from "@calcom/ui";
import SetupFormStep1 from "./steps/SetupFormStep1";
import StepDone from "./steps/StepDone";
import { AdminUserContainer as AdminUser } from "@components/setup/AdminUser";
import ChooseLicense from "@components/setup/ChooseLicense";
import EnterpriseLicense from "@components/setup/EnterpriseLicense";
import { ssrInit } from "@server/lib/ssr";
export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
const [isLoadingStep1, setIsLoadingStep1] = useState(false);
const shouldDisable = props.userCount !== 0;
const router = useRouter();
const [value, setValue] = useState(props.isFreeLicense ? "FREE" : "EE");
const isFreeLicense = value === "FREE";
const [isEnabledEE, setIsEnabledEE] = useState(!props.isFreeLicense);
const setStep = (newStep: number) => {
router.replace(`/auth/setup?step=${newStep || 1}`, undefined, { shallow: true });
};
const steps = [
const steps: React.ComponentProps<typeof WizardForm>["steps"] = [
{
title: t("administrator_user"),
description: t("lets_create_first_administrator_user"),
content: shouldDisable ? <StepDone /> : <SetupFormStep1 setIsLoading={setIsLoadingStep1} />,
isLoading: isLoadingStep1,
content: (setIsLoading) => (
<AdminUser
onSubmit={() => {
setIsLoading(true);
}}
onSuccess={() => {
setStep(2);
}}
onError={() => {
setIsLoading(false);
}}
userCount={props.userCount}
/>
),
},
{
title: t("enable_apps"),
description: t("enable_apps_description"),
content: <AdminAppsList baseURL="/auth/setup?step=2" useQueryParam={true} />,
isLoading: false,
title: t("choose_a_license"),
description: t("choose_license_description"),
content: (setIsLoading) => {
return (
<ChooseLicense
id="wizard-step-2"
name="wizard-step-2"
value={value}
onChange={setValue}
onSubmit={() => {
setIsLoading(true);
setStep(3);
}}
/>
);
},
},
];
if (!isFreeLicense) {
steps.push({
title: t("step_enterprise_license"),
description: t("step_enterprise_license_description"),
content: (setIsLoading) => {
const currentStep = 3;
return (
<EnterpriseLicense
id={`wizard-step-${currentStep}`}
name={`wizard-step-${currentStep}`}
onSubmit={() => {
setIsLoading(true);
}}
onSuccess={() => {
setStep(currentStep + 1);
}}
onSuccessValidate={() => {
setIsEnabledEE(true);
}}
/>
);
},
isEnabled: isEnabledEE,
});
}
steps.push({
title: t("enable_apps"),
description: t("enable_apps_description"),
contentClassname: "!pb-0 mb-[-1px]",
content: (setIsLoading) => {
const currentStep = isFreeLicense ? 3 : 4;
return (
<AdminAppsList
classNames={{
form: "mb-4 rounded-md bg-white px-0 pt-0 md:max-w-full",
appCategoryNavigationContainer: " max-h-[400px] overflow-y-auto md:p-4",
verticalTabsItem: "!w-48 md:p-4",
}}
baseURL={`/auth/setup?step=${currentStep}`}
useQueryParam={true}
onSubmit={() => {
setIsLoading(true);
router.replace("/");
}}
/>
);
},
});
return (
<>
<main className="flex items-center bg-gray-100 print:h-full">
<Meta title={t("setup")} description={t("setup_description")} />
<main className="flex items-center bg-gray-100 print:h-full md:h-screen">
<WizardForm
href="/auth/setup"
steps={steps}
@@ -42,6 +127,7 @@ export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
finishLabel={t("finish")}
prevLabel={t("prev_step")}
stepLabel={(currentStep, maxSteps) => t("current_step_of_total", { currentStep, maxSteps })}
containerClassname="md:w-[800px]"
/>
</main>
</>
@@ -49,6 +135,7 @@ export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
const userCount = await prisma.user.count();
const { req } = context;
const session = await getSession({ req });
@@ -62,8 +149,30 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
}
let deploymentKey = await getDeploymentKey(prisma);
// Check existant CALCOM_LICENSE_KEY env var and acccount for it
if (!!process.env.CALCOM_LICENSE_KEY && !deploymentKey) {
await prisma.deployment.upsert({
where: { id: 1 },
update: {
licenseKey: process.env.CALCOM_LICENSE_KEY,
agreedLicenseAt: new Date(),
},
create: {
licenseKey: process.env.CALCOM_LICENSE_KEY,
agreedLicenseAt: new Date(),
},
});
deploymentKey = await getDeploymentKey(prisma);
}
const isFreeLicense = deploymentKey === "";
return {
props: {
trpcState: ssr.dehydrate(),
isFreeLicense,
userCount,
},
};
+25 -21
View File
@@ -51,7 +51,7 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
});
const { t } = useLocale();
return (
<div className="px-4 py-5 sm:p-6">
<div className="p-6">
<h3 className="font-medium leading-6 text-gray-900">
{t("date_overrides")}{" "}
<Tooltip content={t("date_overrides_info")}>
@@ -60,8 +60,8 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
</span>
</Tooltip>
</h3>
<p className="mb-4 text-sm text-gray-500 ltr:mr-4 rtl:ml-4">{t("date_overrides_subtitle")}</p>
<div className="mt-1 space-y-2">
<p className="mb-4 text-sm text-gray-500">{t("date_overrides_subtitle")}</p>
<div className="space-y-2">
<DateOverrideList
excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))}
remove={remove}
@@ -185,25 +185,29 @@ export default function Availability({ schedule }: { schedule: number }) {
...values,
});
}}
className="flex flex-col pb-16 sm:mx-0 xl:flex-row xl:space-x-6">
<div className="flex-1 divide-y divide-gray-200 rounded-md border">
<div className=" py-5 sm:p-6">
{typeof me.data?.weekStart === "string" && (
<Schedule
control={control}
name="schedule"
weekStart={
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
me.data?.weekStart
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
}
/>
)}
className="flex flex-col sm:mx-0 xl:flex-row xl:space-x-6">
<div className="flex-1 flex-row xl:mr-0">
<div className="mb-6 rounded-md border">
<div>
{typeof me.data?.weekStart === "string" && (
<Schedule
control={control}
name="schedule"
weekStart={
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
me.data?.weekStart
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
}
/>
)}
</div>
</div>
<div className="my-6 rounded-md border">
{data?.workingHours && <DateOverride workingHours={data.workingHours} />}
</div>
{data?.workingHours && <DateOverride workingHours={data.workingHours} />}
</div>
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
<div className="xl:max-w-80 mt-4 w-full pr-4 sm:p-0">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
{t("timezone")}
@@ -223,8 +227,8 @@ export default function Availability({ schedule }: { schedule: number }) {
}
/>
</div>
<hr className="my-8" />
<div className="rounded-md">
<hr className="my-6 mr-8" />
<div className="hidden rounded-md md:block">
<h3 className="text-sm font-medium text-gray-900">{t("something_doesnt_look_right")}</h3>
<div className="mt-3 flex">
<Button href="/availability/troubleshoot" color="secondary">
+23 -73
View File
@@ -1,5 +1,7 @@
import dayjs from "@calcom/dayjs";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import Shell from "@calcom/features/shell/Shell";
import { yyyymmdd } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterOutputs, trpc } from "@calcom/trpc/react";
import { SkeletonText } from "@calcom/ui";
@@ -16,13 +18,13 @@ export interface IBusySlot {
}
const AvailabilityView = ({ user }: { user: User }) => {
const { t } = useLocale();
const { date, setQuery: setSelectedDate } = useRouterQuery("date");
const selectedDate = dayjs(date);
const formattedSelectedDate = selectedDate.format("YYYY-MM-DD");
const { data, isLoading } = trpc.viewer.availability.user.useQuery(
const { data } = trpc.viewer.availability.user.useQuery(
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
username: user.username!,
dateFrom: selectedDate.startOf("day").utc().format(),
dateTo: selectedDate.endOf("day").utc().format(),
@@ -45,70 +47,26 @@ const AvailabilityView = ({ user }: { user: User }) => {
}, [] as IBusySlot[]) || [];
return (
<div className="max-w-xl overflow-hidden rounded-md bg-white shadow">
<div className="px-4 py-5 sm:p-6">
{t("overview_of_day")}{" "}
<input
type="date"
className="inline h-8 border-none p-0"
defaultValue={formattedSelectedDate}
onChange={(e) => {
if (e.target.value) setSelectedDate(e.target.value);
}}
<div className="max-w-xl overflow-hidden rounded-md border border-gray-200 bg-white">
<div className="px-4">
<Calendar
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
events={[...(data?.busy || []), ...overrides]
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
.map((slot) => ({
// TODO: Modify the Calendar view to be better at displaying blocks.
id: undefined,
title:
(slot.title ? `(${slot.title}) - ` : "") + (slot.source ? `(source: ${slot.source})` : ""),
start: new Date(slot.start),
end: new Date(slot.end),
}))}
onDateChange={(e) => setSelectedDate(yyyymmdd(e))}
startDate={selectedDate.startOf("day").toDate()}
endDate={selectedDate.endOf("day").toDate()}
view="day"
/>
<small className="block text-gray-400">{t("hover_over_bold_times_tip")}</small>
<div className="mt-4 space-y-4">
<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">
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div>
</div>
{(() => {
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-gray-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-gray-800" title={dayjs(slot.start).format("HH:mm")}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{t("and")}{" "}
<span className="font-medium text-gray-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>
));
return (
<div className="overflow-hidden rounded-md bg-gray-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">
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
</div>
</div>
</div>
</div>
</div>
);
@@ -125,11 +83,3 @@ export default function Troubleshoot() {
</div>
);
}
function convertMinsToHrsMins(mins: number) {
const h = Math.floor(mins / 60);
const m = mins % 60;
const hs = h < 10 ? "0" + h : h;
const ms = m < 10 ? "0" + m : m;
return `${hs}:${ms}`;
}
+45 -17
View File
@@ -11,7 +11,11 @@ import { RRule } from "rrule";
import { z } from "zod";
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import { getEventLocationValue, getSuccessPageLocationMessage } from "@calcom/app-store/locations";
import {
getEventLocationValue,
getSuccessPageLocationMessage,
guessEventLocationType,
} from "@calcom/app-store/locations";
import { getEventTypeAppData } from "@calcom/app-store/utils";
import { getEventName } from "@calcom/core/event";
import dayjs, { ConfigType } from "@calcom/dayjs";
@@ -34,9 +38,10 @@ import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/t
import { localStorage } from "@calcom/lib/webstorage";
import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { Button, EmailInput, HeadSeo } from "@calcom/ui";
import { FiX, FiChevronLeft, FiCheck, FiCalendar } from "@calcom/ui/components/icon";
import { FiX, FiExternalLink, FiChevronLeft, FiCheck, FiCalendar } from "@calcom/ui/components/icon";
import { timeZone } from "@lib/clock";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -188,16 +193,19 @@ export default function Success(props: SuccessProps) {
window.scrollTo(0, document.body.scrollHeight);
}
const location: ReturnType<typeof getEventLocationValue> = Array.isArray(props.bookingInfo.location)
? props.bookingInfo.location[0]
const location: ReturnType<typeof getEventLocationValue> = props.bookingInfo.location
? props.bookingInfo.location
: // If there is no location set then we default to Cal Video
"integrations:daily";
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
props?.bookingInfo?.metadata || {}
)?.videoCallUrl;
if (!location) {
// Can't use logger.error because it throws error on client. stdout isn't available to it.
console.error(`No location found `);
}
const status = props.bookingInfo?.status;
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
const cancellationReason = props.bookingInfo.cancellationReason;
@@ -240,7 +248,7 @@ export default function Success(props: SuccessProps) {
const giphyAppData = getEventTypeAppData(eventType, "giphy");
const giphyImage = giphyAppData?.thankYouPage;
const eventName = getEventName(eventNameObject);
const eventName = getEventName(eventNameObject, true);
const needsConfirmation = eventType.requiresConfirmation && reschedule != true;
const isCancelled = status === "CANCELLED" || status === "REJECTED";
const telemetry = useTelemetry();
@@ -281,8 +289,8 @@ export default function Success(props: SuccessProps) {
function eventLink(): string {
const optional: { location?: string } = {};
if (location) {
optional["location"] = location;
if (locationVideoCallUrl) {
optional["location"] = locationVideoCallUrl;
}
const event = createEvent({
@@ -332,12 +340,17 @@ export default function Success(props: SuccessProps) {
);
const customInputs = bookingInfo?.customInputs;
const locationToDisplay = getSuccessPageLocationMessage(location, t);
const locationToDisplay = getSuccessPageLocationMessage(
locationVideoCallUrl ? locationVideoCallUrl : location,
t,
bookingInfo.status
);
const hasSMSAttendeeAction =
eventType.workflows.find((workflowEventType) =>
workflowEventType.workflow.steps.find((step) => step.action === WorkflowActions.SMS_ATTENDEE)
) !== undefined;
const providerName = guessEventLocationType(location)?.label;
return (
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
@@ -490,8 +503,14 @@ export default function Success(props: SuccessProps) {
<div className="mt-3 font-medium">{t("where")}</div>
<div className="col-span-2 mt-3">
{locationToDisplay.startsWith("http") ? (
<a title="Meeting Link" href={locationToDisplay}>
{locationToDisplay}
<a
href={locationToDisplay}
target="_blank"
title={locationToDisplay}
className="flex items-center gap-2 text-gray-700 underline dark:text-gray-50"
rel="noreferrer">
{providerName || "Link"}
<FiExternalLink className="inline h-4 w-4 text-gray-700 dark:text-white" />
</a>
) : (
locationToDisplay
@@ -517,7 +536,7 @@ export default function Success(props: SuccessProps) {
<>
{eventTypeCustomFound?.type === "RADIO" && (
<>
<div className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
<div className="border-bookinglightest dark:border-darkgray-300 col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
{eventTypeCustomFound.label}
</div>
<div className="col-span-3 mt-1 mb-2">
@@ -541,7 +560,9 @@ export default function Success(props: SuccessProps) {
)}
{eventTypeCustomFound?.type !== "RADIO" && customInput !== "" && (
<>
<div className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">{key}</div>
<div className="border-bookinglightest dark:border-darkgray-300 col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
{key}
</div>
<div className="col-span-3 mt-2 mb-2">
{typeof customInput === "boolean" ? (
<p>{customInput ? "true" : "false"}</p>
@@ -632,8 +653,8 @@ export default function Success(props: SuccessProps) {
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
props.eventType.description
}` +
(typeof location === "string"
? "&location=" + encodeURIComponent(location)
(typeof locationVideoCallUrl === "string"
? "&location=" + encodeURIComponent(locationVideoCallUrl)
: "") +
(props.eventType.recurringEvent
? "&recur=" +
@@ -661,7 +682,10 @@ export default function Success(props: SuccessProps) {
date.utc().format() +
"&subject=" +
eventName
) + (location ? "&location=" + location : "")
) +
(locationVideoCallUrl
? "&location=" + encodeURIComponent(locationVideoCallUrl)
: "")
}
className="mx-2 h-10 w-10 rounded-sm border border-gray-200 px-3 py-2 dark:border-gray-700 dark:text-white"
target="_blank">
@@ -685,7 +709,10 @@ export default function Success(props: SuccessProps) {
date.utc().format() +
"&subject=" +
eventName
) + (location ? "&location=" + location : "")
) +
(locationVideoCallUrl
? "&location=" + encodeURIComponent(locationVideoCallUrl)
: "")
}
className="mx-2 h-10 w-10 rounded-sm border border-gray-200 px-3 py-2 dark:border-gray-700 dark:text-white"
target="_blank">
@@ -984,6 +1011,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
endTime: true,
location: true,
status: true,
metadata: true,
cancellationReason: true,
user: {
select: {
+3 -2
View File
@@ -88,6 +88,8 @@ export default function Bookings() {
return false;
}
shownBookings[booking.recurringEventId] = [booking];
} else if (status === "upcoming") {
return new Date(booking.startTime).toDateString() !== new Date().toDateString();
}
return true;
};
@@ -121,7 +123,7 @@ export default function Bookings() {
<p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("today")}</p>
<div className="overflow-hidden rounded-md border border-gray-200">
<table className="w-full max-w-full table-fixed">
<tbody className="divide-y divide-gray-200 bg-white">
<tbody className="divide-y divide-gray-200 bg-white" data-testid="today-bookings">
<Fragment>
{bookingsToday.map((booking: BookingOutput) => (
<BookingListItem
@@ -138,7 +140,6 @@ export default function Bookings() {
</div>
)}
<div className="pt-2 xl:pt-0">
{/* <p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("all")}</p> */}
<div className="overflow-hidden rounded-md border border-gray-200">
<table className="w-full max-w-full table-fixed">
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
+1 -1
View File
@@ -62,7 +62,7 @@ export type FormValues = {
phone?: string;
}[];
customInputs: CustomInputParsed[];
schedule: number;
schedule: number | null;
periodType: PeriodType;
periodDays: number;
periodCountCalendarDays: "1" | "0";
+1 -1
View File
@@ -136,7 +136,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
)}
</div>
<EventTypeDescription
// @ts-expect-error FIXME We have a type mismtach here @hariombalhara @sean-brydon
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
eventType={type}
/>
</Link>
@@ -105,7 +105,7 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
</p>
))}
</header>
<Steps maxSteps={steps.length} currentStep={currentStepIndex} navigateToStep={goToIndex} />
<Steps maxSteps={steps.length} currentStep={currentStepIndex + 1} navigateToStep={goToIndex} />
</div>
<StepCard>
{currentStep === "user-settings" && <UserSettings user={user} nextStep={() => goToIndex(1)} />}
+27
View File
@@ -0,0 +1,27 @@
import Head from "next/head";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
export default function MaintenancePage() {
const { t, isLocaleReady } = useLocale();
if (!isLocaleReady) return null;
return (
<div className="flex h-screen bg-gray-100">
<Head>
<title>
{t("under_maintenance")} | {APP_NAME}
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="m-auto rounded-md bg-white p-10 text-right ltr:text-left">
<h1 className="text-2xl font-medium text-black">{t("under_maintenance")}</h1>
<p className="mt-4 mb-6 max-w-2xl text-sm text-gray-600">
{t("under_maintenance_description", { appName: APP_NAME })}
</p>
<Button href={`${WEBSITE_URL}/support`}>{t("contact_support")}</Button>
</div>
</div>
);
}
+10 -9
View File
@@ -32,15 +32,16 @@ test.describe("Availablity tests", () => {
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);
});
// The troubleshooter is unaware of the selected schedule, kept the override logic for now but needs rework.
// 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 }) => {
+5 -1
View File
@@ -22,7 +22,11 @@ export const createPaymentsFixture = (page: Page) => {
currency: "usd",
success,
refunded,
type: "STRIPE",
app: {
connect: {
slug: "stripe",
},
},
data: {},
externalId: "DEMO_PAYMENT_FROM_DB_" + Date.now(),
booking: {
+1 -1
View File
@@ -24,7 +24,7 @@ test.describe("Onboarding", () => {
await page.locator("input[name=username]").fill("new user onboarding");
await page.locator("input[name=name]").fill("new user 2");
await page.locator("input[role=combobox]").click();
await page.locator("text=Eastern Time").click();
await page.locator("text=Europe/London GMT +0:00").click();
await page.locator("button[type=submit]").click();
+16 -1
View File
@@ -113,7 +113,22 @@ test.describe("Reschedule Tests", async () => {
status: BookingStatus.CANCELLED,
paid: false,
});
await prisma.eventType.update({
where: {
id: eventType.id,
},
data: {
metadata: {
apps: {
stripe: {
price: 20000,
enabled: true,
currency: "usd",
},
},
},
},
});
const payment = await payments.create(booking.id);
await page.goto(`/${user.username}/${eventType.slug}?rescheduleUid=${booking.uid}`);
+4 -11
View File
@@ -38,22 +38,15 @@ test.describe("Wipe my Cal App Test", () => {
await expect(page.locator("data-testid=wipe-today-button")).toBeVisible();
const $openBookingCount = await page.locator('[data-testid="bookings"] > *').count();
expect($openBookingCount).toBe(3);
const $todayBookingCount = await page.locator('[data-testid="today-bookings"] > *').count();
expect($openBookingCount + $todayBookingCount).toBe(3);
await page.locator("data-testid=wipe-today-button").click();
// Don't await send_request click, otherwise mutation can possibly occur before observer is attached
page.locator("data-testid=send_request").click();
const $openBookings = page.locator('[data-testid="bookings"]');
await $openBookings.evaluate((ul) => {
return new Promise<void>((resolve) =>
new window.MutationObserver(() => {
if (ul.childElementCount === 2) {
resolve();
}
}).observe(ul, { childList: true })
);
});
// There will not be any today-bookings
await expect(page.locator('[data-testid="today-bookings"]')).toBeHidden();
await users.deleteAll();
});
+26
View File
@@ -0,0 +1,26 @@
const calEmbedMode = typeof new URL(document.URL).searchParams.get("embed") === "string";
/* Iframe Name */
window.name.includes("cal-embed");
window.isEmbed = () => {
// Once an embed mode always an embed mode
return calEmbedMode;
};
window.resetEmbedStatus = () => {
try {
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
window.sessionStorage.removeItem("calEmbedMode");
} catch (e) {}
};
window.getEmbedTheme = () => {
const url = new URL(document.URL);
return url.searchParams.get("theme");
};
window.getEmbedNamespace = () => {
const url = new URL(document.URL);
const namespace = url.searchParams.get("embed");
return namespace;
};
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} هو بديل Calendly مفتوح المصدر الذي يتيح لك التحكم في بياناتك وسير عملك والمظهر على التطبيق أو موقع الويب.",
"have_any_questions": "هل لديك أسئلة؟ نحن هنا للمساعدة.",
"reset_password_subject": "{{appName}}: إرشادات إعادة تعيين كلمة المرور",
"event_declined_subject": "تم الرفض: {{eventType}} مع {{name}} في {{date}}",
"event_cancelled_subject": "تم الإلغاء: {{eventType}} مع {{name}} في {{date}}",
"event_declined_subject": "تم الرفض: {{title}} في {{date}}",
"event_cancelled_subject": "تم الإلغاء: {{title}} في {{date}}",
"event_request_declined": "تم رفض طلب الحدث الخاص بك",
"event_request_declined_recurring": "تم رفض طلب الحدث المتكرر الخاص بك",
"event_request_cancelled": "تم إلغاء حدثك المُجَدْوَل",
@@ -32,7 +32,7 @@
"refund_failed": "فشل استرداد مقابل الحدث {{eventType}} مع {{userName}} في {{date}}.",
"check_with_provider_and_user": "يُرجى التحقق من مزود الدفع لديك و{{user}} بشأن كيفية التعامل مع الأمر.",
"a_refund_failed": "فشل الاسترداد",
"awaiting_payment_subject": "في انتظار الدفع: {{eventType}} مع {{name}} في {{date}}",
"awaiting_payment_subject": "في انتظار الدفع: {{title}} في {{date}}",
"meeting_awaiting_payment": "اجتماعك في انتظار الدفع",
"help": "المساعدة",
"price": "السعر",
@@ -63,11 +63,11 @@
"someone_requested_an_event": "طلب شخص ما جدولة حدث في تقويمك.",
"someone_requested_password_reset": "طلب شخص ما رابطا لتغيير كلمة المرور الخاصة بك.",
"password_reset_instructions": "إذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني بأمان ولن يتم تغيير كلمة المرور الخاصة بك.",
"event_awaiting_approval_subject": "في انتظار الموافقة: {{eventType}} مع {{name}} في {{date}}",
"event_awaiting_approval_subject": "في انتظار الموافقة: {{title}} في {{date}}",
"event_still_awaiting_approval": "الحدث لا يزال في انتظار موافقتك",
"booking_submitted_subject": "تم إرسال الحجز: {{eventType}} مع {{name}} في {{date}}",
"booking_submitted_subject": "تم إرسال الحجز: {{title}} في {{date}}",
"your_meeting_has_been_booked": "لقد تم حجز الاجتماع الخاص بك",
"event_type_has_been_rescheduled_on_time_date": "تم إعادة جدولة {{eventType}} مع {{name}} إلى {{date}}.",
"event_type_has_been_rescheduled_on_time_date": "تم إعادة جدولة {{title}} إلى {{date}}.",
"event_has_been_rescheduled": "تم التحديث - تم إعادة جدولة الحدث الخاص بك",
"request_reschedule_title_attendee": "طلب إعادة جدولة الحجز الخاص بك",
"request_reschedule_subtitle": "ألغى {{organizer}} الحجز وطلب منك اختيار حجز في وقت آخر.",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} je open source alternativa, která vám dává kontrolu nad vlastními daty, workflow a vzhledem.",
"have_any_questions": "Máte otázky? Jsme tu, abychom vám pomohli.",
"reset_password_subject": "{{appName}}: Pokyny pro obnovení hesla",
"event_declined_subject": "Odmítnuto: {{eventType}} s {{name}} v {{date}}",
"event_cancelled_subject": "Zrušeno: {{eventType}} s {{name}} v {{date}}",
"event_declined_subject": "Odmítnuto: {{title}} v {{date}}",
"event_cancelled_subject": "Zrušeno: {{title}} v {{date}}",
"event_request_declined": "Žádost o událost byla zamítnuta",
"event_request_declined_recurring": "Žádost o opakovanou událost byla zamítnuta",
"event_request_cancelled": "Vaše naplánovaná událost byla zrušena",
@@ -32,7 +32,7 @@
"refund_failed": "Vrácení peněz za událost {{eventType}} pro {{userName}} během {{date}} selhalo.",
"check_with_provider_and_user": "Pro řešení prosím zkontaktujte vašeho správce plateb a/nebo uživatele {{user}}.",
"a_refund_failed": "Vrácení platby selhalo",
"awaiting_payment_subject": "Čekám na platbu: {{eventType}} s {{name}} během {{date}}",
"awaiting_payment_subject": "Čekám na platbu: {{title}} během {{date}}",
"meeting_awaiting_payment": "Vaše schůzka čeká na platbu",
"help": "Nápověda",
"price": "Cena",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Někdo požádal o naplánování události ve vašem kalendáři.",
"someone_requested_password_reset": "Někdo požádal o odkaz pro změnu vašeho hesla.",
"password_reset_instructions": "Pokud jste o obnovení hesla nepožádali, můžete tento e-mail ignorovat. Vaše heslo se nezmění.",
"event_awaiting_approval_subject": "Čeká se na schválení: {{eventType}} s {{name}} dne {{date}}",
"event_awaiting_approval_subject": "Čeká se na schválení: {{title}} dne {{date}}",
"event_still_awaiting_approval": "Událost stále čeká na schválení",
"booking_submitted_subject": "Rezervace odeslána: {{eventType}} s {{name}} během {{date}}",
"booking_submitted_subject": "Rezervace odeslána: {{title}} během {{date}}",
"your_meeting_has_been_booked": "Vaše schůzka byla rezervována",
"event_type_has_been_rescheduled_on_time_date": "Váš {{title}} byl přeplánován na {{date}}.",
"event_has_been_rescheduled": "Změna - Vaše událost byla přesunuta na jindy",
@@ -8,7 +8,7 @@
"accept_invitation": "Einladung annehmen",
"have_any_questions": "Haben Sie Fragen? Wir sind hier um Ihnen zu helfen.",
"reset_password_subject": "{{appName}}: Anleitung zum Zurücksetzen des Passworts",
"event_declined_subject": "Abgelehnt: {{eventType}} mit {{name}} am {{date}}",
"event_declined_subject": "Abgelehnt: {{title}} am {{date}}",
"event_cancelled_subject": "Storniert: {{title}} um {{date}}",
"event_request_declined": "Ihre Event-Anfrage wurde abgelehnt",
"event_request_declined_recurring": "Ihre wiederkehrende Terminanfrage wurde abgelehnt",
@@ -31,7 +31,7 @@
"refund_failed": "Die Rückerstattung für den Termin {{eventType}} mit {{userName}} am {{date}} ist fehlgeschlagen.",
"check_with_provider_and_user": "Bitte überprüfen Sie bei Ihrem Zahlungsanbieter und {{userName}}, wie Sie damit umgehen.",
"a_refund_failed": "Erstattung fehlgeschlagen",
"awaiting_payment_subject": "Warte auf Zahlung: {{eventType}} mit {{name}} am {{date}}",
"awaiting_payment_subject": "Warte auf Zahlung: {{title}} am {{date}}",
"meeting_awaiting_payment": "Zahlung für den Termin noch offen",
"help": "Hilfe",
"price": "Preis",
@@ -62,9 +62,9 @@
"someone_requested_an_event": "Jemand hat eine Buchungsanfrage gestellt.",
"someone_requested_password_reset": "Jemand hat einen Link angefordert, um Ihr Passwort zu ändern.",
"password_reset_instructions": "Wenn Sie dies nicht angefordert haben, können Sie diese E-Mail sicher ignorieren und Ihr Passwort wird nicht geändert.",
"event_awaiting_approval_subject": "Warte auf Bestätigung: {{eventType}} mit {{name}} um {{date}}",
"event_awaiting_approval_subject": "Warte auf Bestätigung: {{title}} um {{date}}",
"event_still_awaiting_approval": "Ein Termin wartet noch auf Ihre Bestätigung",
"booking_submitted_subject": "Buchung eingereicht: {{eventType}} mit {{name}} am {{date}}",
"booking_submitted_subject": "Buchung eingereicht: {{title}} am {{date}}",
"your_meeting_has_been_booked": "Dein Termin wurde gebucht",
"event_type_has_been_rescheduled_on_time_date": "Ihr {{title}} wurde auf den {{date}} verschoben.",
"event_has_been_rescheduled": "Ihr Termin wurde verschoben.",
@@ -7,8 +7,8 @@
"accept_invitation": "Αποδοχή Πρόσκλησης",
"have_any_questions": "Έχετε ερωτήσεις? Είμαστε εδώ για να βοηθήσουμε.",
"reset_password_subject": "{{appName}}: Οδηγίες επαναφοράς κωδικού πρόσβασης",
"event_declined_subject": "Απορρίφθηκε: {{eventType}} με {{name}} στις {{date}}",
"event_cancelled_subject": "Ακύρωση: {{eventType}} με {{name}} στις {{date}}",
"event_declined_subject": "Απορρίφθηκε: {{title}} στις {{date}}",
"event_cancelled_subject": "Ακύρωση: {{title}} στις {{date}}",
"need_to_reschedule_or_cancel": "Χρειάζεται να επαναπρογραμματίσετε ή να ακυρώσετε;",
"cancellation_reason": "Λόγος ακύρωσης (προαιρετικό)",
"rejection_reason": "Λόγος απόρριψης",
@@ -18,7 +18,7 @@
"refund_failed_subject": "Η επιστροφή χρημάτων απέτυχε: {{name}} - {{date}} - {{eventType}}",
"refund_failed": "Η επιστροφή χρημάτων για την εκδήλωση {{eventType}} με {{userName}} στις {{date}} απέτυχε.",
"a_refund_failed": "Αποτυχία επιστροφής χρημάτων",
"awaiting_payment_subject": "Σε αναμονή Πληρωμής: {{eventType}} με {{name}} στις {{date}}",
"awaiting_payment_subject": "Σε αναμονή Πληρωμής: {{title}} στις {{date}}",
"meeting_awaiting_payment": "Η πληρωμή της συνάντησής σας εκκρεμεί",
"help": "Βοήθεια",
"price": "Τιμή",
+36 -8
View File
@@ -515,6 +515,8 @@
"admin": "Admin",
"administrator_user": "Administrator user",
"lets_create_first_administrator_user": "Let's create the first administrator user.",
"admin_user_created": "Administrator user setup",
"admin_user_created_description": "You have already created an administrator user. You can now log in to your account.",
"new_member": "New Member",
"invite": "Invite",
"add_team_members": "Add team members",
@@ -796,7 +798,7 @@
"number_apps_one": "{{count}} App",
"number_apps_other": "{{count}} Apps",
"trending_apps": "Trending Apps",
"most_popular":"Most Popular",
"most_popular": "Most Popular",
"explore_apps": "{{category}} apps",
"installed_apps": "Installed Apps",
"free_to_use_apps": "Free",
@@ -841,7 +843,7 @@
"connect_metamask": "Connect Metamask",
"create_events_on": "Create events on",
"enterprise_license": "This is an enterprise feature",
"enterprise_license_description": "To enable this feature, get a deployment key at {{consoleUrl}} console and add it to your .env as CALCOM_LICENSE_KEY. If your team already has a license, please contact {{supportMail}} for help.",
"enterprise_license_description": "To enable this feature, have an administrator go to {{setupUrl}} to enter a license key. If a license key is already in place, please contact {{supportMail}} for help.",
"missing_license": "Missing License",
"signup_requires": "Commercial license required",
"signup_requires_description": "{{companyName}} currently does not offer a free open source version of the sign up page. To receive full access to the signup components you need to acquire a commercial license. For personal use we recommend the Prisma Data Platform or any other Postgres interface to create accounts.",
@@ -1114,8 +1116,8 @@
"close": "Close",
"upgrade": "Upgrade",
"upgrade_to_access_recordings_title": "Upgrade to access recordings",
"upgrade_to_access_recordings_description":"Recordings are only available as part of our teams plan. Upgrade to start recording your calls",
"recordings_are_part_of_the_teams_plan":"Recordings are part of the teams plan",
"upgrade_to_access_recordings_description": "Recordings are only available as part of our teams plan. Upgrade to start recording your calls",
"recordings_are_part_of_the_teams_plan": "Recordings are part of the teams plan",
"team_feature_teams": "This is a Team feature. Upgrade to Team to see your team's availability.",
"team_feature_workflows": "This is a Team feature. Upgrade to Team to automate your event notifications and reminders with Workflows.",
"show_eventtype_on_profile": "Show on Profile",
@@ -1414,7 +1416,7 @@
"team_url_required": "Must enter a team URL",
"team_url_taken": "This URL is already taken",
"team_publish": "Publish team",
"number_sms_notifications": "Phone number (SMS\u00a0notifications)",
"number_sms_notifications": "Phone number (SMS notifications)",
"attendee_email_variable": "Attendee email",
"attendee_email_info": "The person booking's email",
"kbar_search_placeholder": "Type a command or search...",
@@ -1451,6 +1453,9 @@
"disabled_calendar": "If you have another calendar installed new bookings will be added to it. If not then connect a new calendar so you do not miss any new bookings.",
"enable_apps": "Enable Apps",
"enable_apps_description": "Enable apps that users can integrate with Cal.com",
"purchase_license": "Purchase a License",
"already_have_key": "I already have a key:",
"already_have_key_suggestion": "Please copy your existing CALCOM_LICENSE_KEY environment variable here.",
"app_is_enabled": "{{appName}} is enabled",
"app_is_disabled": "{{appName}} is disabled",
"keys_have_been_saved": "Keys have been saved",
@@ -1496,11 +1501,11 @@
"not_verified": "Not yet verified",
"no_availability_in_month": "No availability in {{month}}",
"view_next_month": "View next month",
"send_code" : "Send code",
"send_code": "Send code",
"number_verified": "Number Verified",
"create_your_first_team_webhook_description": "Create your first webhook for this team event type",
"create_webhook_team_event_type": "Create a webhook for this team event type",
"disable_success_page":"Disable Success Page (only works if you have a redirect URL)",
"disable_success_page": "Disable Success Page (only works if you have a redirect URL)",
"invalid_admin_password": "You are set as an admin but you do not have a password length of at least 15 characters",
"change_password_admin": "Change Password to gain admin access",
"username_already_taken": "Username is already taken",
@@ -1526,6 +1531,25 @@
"reporting_feature": "See all incoming from data and download it as a CSV",
"teams_plan_required": "Teams plan required",
"routing_forms_are_a_great_way": "Routing forms are a great way to route your incoming leads to the right person. Upgrade to a Teams plan to access this feature.",
"choose_a_license": "Choose a license",
"choose_license_description": "Cal.com comes with an accessible and free AGPLv3 license which some limitations which can be upgraded to an Enterprise license at any time. You can upgrade at anytime later.",
"license": "License",
"agplv3_license": "AGPLv3 License",
"ee_enterprise_license": "“/ee” Enterprise License",
"enterprise_booking_fee": "Starting at {{enterprise_booking_fee}}/month",
"enterprise_license_includes": "Everything for a commercial use case",
"no_need_to_keep_your_code_open_source": "No need to keep your code open-source",
"repackage_rebrand_resell": "Repackage, rebrand and resell easily",
"a_vast_suite_of_enterprise_features": "A vast suite of enterprise features",
"free_license_fee": "$0.00/month",
"forever_open_and_free": "Forever Open & Free",
"required_to_keep_your_code_open_source": "Required to keep your code open source",
"cannot_repackage_and_resell": "Cannot repackage, rebrand and resell easily",
"no_enterprise_features": "No enterprise features",
"step_enterprise_license": "Enterprise License",
"step_enterprise_license_description": "Everything for a commercial use case with private hosting, repackaging, rebranding and reselling and access exclusive enterprise components.",
"setup": "Setup",
"setup_description": "Setup Cal.com instance",
"configure": "Configure",
"sso_configuration": "Single Sign-On",
"sso_configuration_description": "Configure SAML/OIDC SSO and allow team members to login using an Identity Provider",
@@ -1562,6 +1586,10 @@
"recommended_next_steps": "Recommended next steps",
"create_a_managed_event": "Create a managed event type",
"meetings_are_better_with_the_right": "Meetings are better with the right team members there. Invite them now.",
"create_a_one_one_template": "Create a one-one one template for an event type and distribute it to multiple members.",
"collective_or_roundrobin": "Collective or round-robin",
"book_your_team_members": "Book your team members together with collective events or cycle through to get the right person with round-robin."
"book_your_team_members": "Book your team members together with collective events or cycle through to get the right person with round-robin.",
"under_maintenance": "Down for maintenance",
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
"event_type_seats": "{{numberOfSeats}} seats"
}
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} es la alternativa de código abierto de Calendly poniéndole en control de sus propios datos, flujo de trabajo y apariencia.",
"have_any_questions": "¿Tienes preguntas? Estamos aquí para ayudar.",
"reset_password_subject": "{{appName}}: Instrucciones para restablecer la contraseña",
"event_declined_subject": "Rechazado: {{eventType}} con {{name}} en {{date}}",
"event_cancelled_subject": "Cancelado: {{eventType}} con {{name}} en {{date}}",
"event_declined_subject": "Rechazado: {{title}} en {{date}}",
"event_cancelled_subject": "Cancelado: {{title}} en {{date}}",
"event_request_declined": "Su solicitud de reunión ha sido rechazada",
"event_request_declined_recurring": "Su solicitud de evento recurrente se ha rechazado",
"event_request_cancelled": "Tu evento programado fue cancelado",
@@ -32,7 +32,7 @@
"refund_failed": "El reembolso del evento {{eventType}} con {{userName}} en {{date}} falló.",
"check_with_provider_and_user": "Por favor, compruebe con su proveedor de pago y {{userName}} cómo manejar esto.",
"a_refund_failed": "Un reembolso falló",
"awaiting_payment_subject": "Esperando pago: {{eventType}} con {{name}} en {{date}}",
"awaiting_payment_subject": "Esperando pago: {{title}} en {{date}}",
"meeting_awaiting_payment": "Su reunión está a la espera de pago",
"help": "Ayuda",
"price": "Precio",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Alguien ha solicitado programar un evento en su calendario.",
"someone_requested_password_reset": "Alguien ha solicitado un enlace para cambiar su contraseña.",
"password_reset_instructions": "Si usted no solicitó esto, puede ignorar este correo electrónico de forma segura y su contraseña no se cambiará.",
"event_awaiting_approval_subject": "Esperando aprobación: {{eventType}} con {{name}} en {{date}}",
"event_awaiting_approval_subject": "Esperando aprobación: {{title}} en {{date}}",
"event_still_awaiting_approval": "Un evento aún está esperando su aprobación",
"booking_submitted_subject": "Reserva enviada: {{eventType}} con {{name}} el {{date}}",
"booking_submitted_subject": "Reserva enviada:{{title}} el {{date}}",
"your_meeting_has_been_booked": "Su reunión ha sido reservada",
"event_type_has_been_rescheduled_on_time_date": "Tu {{title}} ha sido reprogramado para {{date}}.",
"event_has_been_rescheduled": "Tu evento ha sido reprogramado.",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Quelqu'un a demandé à planifier un événement sur votre calendrier.",
"someone_requested_password_reset": "Quelqu'un a demandé un lien pour modifier votre mot de passe.",
"password_reset_instructions": "Si vous n'en avez pas fait la demande, vous pouvez ignorer cet e-mail en toute sécurité et votre mot de passe ne sera pas modifié.",
"event_awaiting_approval_subject": "En attente de validation : {{eventType}} à {{date}}",
"event_awaiting_approval_subject": "En attente de validation : {{title}} à {{date}}",
"event_still_awaiting_approval": "Un événement est toujours en attente de votre validation",
"booking_submitted_subject": "Réservation confirmée : {{eventType}} avec {{name}} le {{date}}",
"booking_submitted_subject": "Réservation confirmée : {{title}} le {{date}}",
"your_meeting_has_been_booked": "Votre rendez-vous a été réservé",
"event_type_has_been_rescheduled_on_time_date": "Votre {{title}} a été replanifié à {{date}}.",
"event_has_been_rescheduled": "Mise à jour - Votre événement a été replanifié",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} הוא חלופת הקוד הפתוח של Calendly המאפשרת לך לשלוט בנתונים, בתהליך העבודה ובמראה של האתר שלך.",
"have_any_questions": "יש לך שאלות? אנחנו כאן כדי לעזור.",
"reset_password_subject": "{{appName}}: הנחיות לאיפוס סיסמה",
"event_declined_subject": "נדחה: {{eventType}} עם {{name}} בתאריך {{date}}",
"event_cancelled_subject": "בוטל: {{eventType}} עם {{name}} בתאריך {{date}}",
"event_declined_subject": "נדחה: {{title}} בתאריך {{date}}",
"event_cancelled_subject": "בוטל: {{title}} בתאריך {{date}}",
"event_request_declined": "בקשת האירוע שלך נדחתה",
"event_request_declined_recurring": "בקשת האירוע החוזר שלך נדחתה",
"event_request_cancelled": "האירוע המתוכנן בוטל",
@@ -32,7 +32,7 @@
"refund_failed": "ההחזר הכספי עבור האירוע {{eventType}} עם {{userName}} בתאריך {{date}} נכשל.",
"check_with_provider_and_user": "יש לברר עם ספק התשלום ועם {{user}} כיצד לטפל בעניין.",
"a_refund_failed": "ההחזר הכספי נכשל",
"awaiting_payment_subject": "בהמתנה לתשלום: {{eventType}} עם {{name}} בתאריך {{date}}",
"awaiting_payment_subject": "בהמתנה לתשלום: {{title}} בתאריך {{date}}",
"meeting_awaiting_payment": "התשלום על הפגישה שלך טרם בוצע",
"help": "עזרה",
"price": "מחיר",
@@ -63,11 +63,11 @@
"someone_requested_an_event": "מישהו ביקש לקבוע מועד לאירוע בלוח השנה שלך.",
"someone_requested_password_reset": "מישהו ביקש קישור כדי לשנות את הסיסמה שלך.",
"password_reset_instructions": "אם הבקשה לא היתה שלך, ניתן להתעלם מהודעת הדוא\"ל הזו בבטחה, והסיסמה שלך לא תשתנה.",
"event_awaiting_approval_subject": "בהמתנה לאישור: {{eventType}} עם {{name}} בתאריך {{date}}",
"event_awaiting_approval_subject": "בהמתנה לאישור: {{title}} בתאריך {{date}}",
"event_still_awaiting_approval": "אירוע עדיין ממתין לאישורך",
"booking_submitted_subject": "ההזמנה נשלחה: {{eventType}} עם {{name}} בתאריך {{date}}",
"booking_submitted_subject": "ההזמנה נשלחה: {{title}} בתאריך {{date}}",
"your_meeting_has_been_booked": "הפגישה שלך הוזמנה",
"event_type_has_been_rescheduled_on_time_date": "ה{{eventType}} שלך עם {{name}} הועבר לתאריך {{date}}.",
"event_type_has_been_rescheduled_on_time_date": "{{title}} הועבר לתאריך {{date}}.",
"event_has_been_rescheduled": "עודכן – מועד האירוע שלך השתנה",
"request_reschedule_title_attendee": "בקשה לשינוי מועד ההזמנה",
"request_reschedule_subtitle": "{{organizer}} ביטל/ה את ההזמנה וביקש/ה שתבחר/י מועד אחר.",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} è l'alternativa Calendly open source che ti consente di controllare i tuoi dati, il flusso di lavoro e l'aspetto.",
"have_any_questions": "Hai domande? Siamo qui per aiutare.",
"reset_password_subject": "{{appName}}: istruzioni per reimpostare la password",
"event_declined_subject": "Rifiutato: {{eventType}} con {{name}} il {{date}}",
"event_cancelled_subject": "Cancellato: {{eventType}} con {{name}} il {{date}}",
"event_declined_subject": "Rifiutato: {{title}} il {{date}}",
"event_cancelled_subject": "Cancellato: {{title}} il {{date}}",
"event_request_declined": "La tua richiesta per l'evento è stata rifiutata",
"event_request_declined_recurring": "La tua richiesta per l'evento ricorrente è stata rifiutata",
"event_request_cancelled": "Il tuo evento programmato è stato cancellato",
@@ -32,7 +32,7 @@
"refund_failed": "Il rimborso per l'evento {{eventType}} di {{userName}} del {{date}} non è riuscito.",
"check_with_provider_and_user": "Verifica con il tuo fornitore di servizi di pagamento e {{user}} come gestirlo.",
"a_refund_failed": "Un rimborso non è riuscito",
"awaiting_payment_subject": "In attesa del pagamento: {{eventType}} con {{name}} il {{date}}",
"awaiting_payment_subject": "In attesa del pagamento: {{title}} il {{date}}",
"meeting_awaiting_payment": "La tua riunione è in attesa di pagamento",
"help": "Guida",
"price": "Prezzo",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Qualcuno ha richiesto di programmare un evento sul tuo calendario.",
"someone_requested_password_reset": "Qualcuno ha richiesto un link per cambiare la tua password.",
"password_reset_instructions": "Se non l'hai richiesto, puoi tranquillamente ignorare questa email e la tua password non verrà modificata.",
"event_awaiting_approval_subject": "In attesa di approvazione: {{eventType}} con {{name}} il {{date}}",
"event_awaiting_approval_subject": "In attesa di approvazione: {{title}} il {{date}}",
"event_still_awaiting_approval": "Un evento è ancora in attesa della tua approvazione",
"booking_submitted_subject": "Prenotazione inviata: {{eventType}} con {{name}} il {{date}}",
"booking_submitted_subject": "Prenotazione inviata: {{title}} il {{date}}",
"your_meeting_has_been_booked": "Il tuo meeting è stata prenotato",
"event_type_has_been_rescheduled_on_time_date": "Il tuo {{title}} è stato riprogrammato per il {{date}}.",
"event_has_been_rescheduled": "Aggiornato: il tuo evento è stato riprogrammato",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} は、Calendly の代替となるオープンソースのツールです。データ、ワークフロー、外観などを自由にコントロールできます。",
"have_any_questions": "ご不明な点があれば、お気軽にお問い合わせください。",
"reset_password_subject": "{{appName}}: パスワードのリセット手順",
"event_declined_subject": "拒否されました: {{date}} の {{name}} との {{eventType}}",
"event_cancelled_subject": "キャンセルされました: {{date}} の {{name}} との {{eventType}}",
"event_declined_subject": "拒否されました: {{date}} の {{title}}",
"event_cancelled_subject": "キャンセルされました: {{date}} の {{title}}",
"event_request_declined": "イベントのリクエストが拒否されました",
"event_request_declined_recurring": "繰り返しイベントのリクエストが拒否されました",
"event_request_cancelled": "スケジュールされていた予定がキャンセルされました",
@@ -32,7 +32,7 @@
"refund_failed": "{{userName}} の {{date}} における {{eventType}} の返金に失敗しました。",
"check_with_provider_and_user": "これを処理する方法については、支払い業者と {{user}} にご確認ください。",
"a_refund_failed": "返金に失敗しました",
"awaiting_payment_subject": "お支払いを待機しています: {{date}} の {{name}} との {{eventType}}",
"awaiting_payment_subject": "お支払いを待機しています: {{date}} の {{title}}",
"meeting_awaiting_payment": "会議の支払いを待っています",
"help": "ヘルプ",
"price": "料金",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "別のユーザーがあなたのカレンダーにイベントを予定するリクエストしています。",
"someone_requested_password_reset": "別のユーザーがあなたのパスワードを変更するためのリンクをリクエストしています。",
"password_reset_instructions": "このリクエストをしていない場合は、このメールを無視しても大丈夫です。パスワードも変更されません。",
"event_awaiting_approval_subject": "承認を待っています: {{date}} の {{name}} との {{eventType}}",
"event_awaiting_approval_subject": "承認を待っています: {{date}} の {{title}}",
"event_still_awaiting_approval": "イベントはまだあなたの承認待ちです",
"booking_submitted_subject": "予約を送信しました: {{date}} の {{name}} との {{eventType}}",
"booking_submitted_subject": "予約を送信しました: {{date}} の {{title}}",
"your_meeting_has_been_booked": "ミーティングが予約されました",
"event_type_has_been_rescheduled_on_time_date": "あなたの {{title}} は {{date}} に再スケジュールされました。",
"event_has_been_rescheduled": "更新 - イベントのスケジュールが変更されました",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}}은 오픈 소스 Calendly 대안으로 자신의 데이터, 워크플로 및 형태를 컨트롤할 수 있습니다.",
"have_any_questions": "질문이 있나요? 도와드릴게요.",
"reset_password_subject": "{{appName}}: 비밀번호 재설정 방법",
"event_declined_subject": "거절됨: {{date}} {{name}} 의 {{eventType}}",
"event_cancelled_subject": "취소됨: {{date}} {{name}} 의 {{eventType}}",
"event_declined_subject": "거절됨: {{date}} {{title}}",
"event_cancelled_subject": "취소됨: {{date}} {{title}}",
"event_request_declined": "회의 요청이 거절되었습니다.",
"event_request_declined_recurring": "되풀이 이벤트 요청이 거부되었습니다",
"event_request_cancelled": "회의 일정이 취소되었습니다.",
@@ -32,7 +32,7 @@
"refund_failed": "{{date}} {{userName}} 의 {{eventType}} 이벤트에 대한 환불이 이루어지지 않았습니다.",
"check_with_provider_and_user": "이 문제의 처리 방법은 결제 제공업체 및 {{user}}에서 확인하십시오.",
"a_refund_failed": "환불이 이루어지지 않았습니다.",
"awaiting_payment_subject": "결제를 기다리고 있습니다: {{date}} {{name}} 의 {{eventType}}",
"awaiting_payment_subject": "결제를 기다리고 있습니다: {{date}} {{title}}",
"meeting_awaiting_payment": "회의를 위해 결제해 주십시오.",
"help": "Help",
"price": "가격",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "누군가 귀하의 캘린더에 일정을 예약하도록 요청했습니다.",
"someone_requested_password_reset": "누군가 귀하의 비밀번호 변경을 위한 링크를 요청했습니다.",
"password_reset_instructions": "요청하지 않으셨다면 이 이메일을 무시하십시오. 비밀번호는 변경되지 않습니다.",
"event_awaiting_approval_subject": "승인을 기다리고 있습니다: {{date}} {{name}} 의 {{eventType}}",
"event_awaiting_approval_subject": "승인을 기다리고 있습니다: {{date}} {{title}}",
"event_still_awaiting_approval": "이벤트가 아직 승인을 기다리고 있습니다.",
"booking_submitted_subject": "예약 제출됨: {{date}}에 {{name}}에 의한 {{eventType}}",
"booking_submitted_subject": "예약 제출됨: {{date}}에 {{title}}",
"your_meeting_has_been_booked": "회의가 예약되었습니다.",
"event_type_has_been_rescheduled_on_time_date": "귀하의 {{title}} 일정이 {{date}}로 변경되었습니다.",
"event_has_been_rescheduled": "업데이트 - 이벤트 일정이 변경되었습니다.",
@@ -9,7 +9,7 @@
"calcom_explained": "{{appName}} is het open source Calendly-alternatief waarmee u uw eigen gegevens, werkstroom en uiterlijk in de hand houdt.",
"have_any_questions": "Heeft u vragen? We zijn er om te helpen.",
"reset_password_subject": "{{appName}}: Instructies voor het opnieuw instellen van uw wachtwoord",
"event_declined_subject": "Afgewezen: {{title}} op {{date}}",
"event_declined_subject": "Afgewezen: {{title}}",
"event_cancelled_subject": "Geannuleerd: {{title}} op {{date}}",
"event_request_declined": "Uw gebeurtenisverzoek is geweigerd",
"event_request_declined_recurring": "Uw verzoek voor een terugkerende gebeurtenis is geweigerd",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} er en åpen kildekodeversjon av Calendly som gir deg kontroll over dine egne data, arbeidsflyt og utseende.",
"have_any_questions": "Har du spørsmål? Vi er her for å hjelpe.",
"reset_password_subject": "{{appName}}: Instruksjoner for tilbakestilling av passord",
"event_declined_subject": "Avslått: {{eventType}} med {{name}} kl. {{date}}",
"event_cancelled_subject": "Avbrutt: {{eventType}} med {{name}} kl. {{date}}",
"event_declined_subject": "Avslått: {{title}} kl. {{date}}",
"event_cancelled_subject": "Avbrutt: {{title}} kl. {{date}}",
"event_request_declined": "Din hendelsesforespørsel har blitt avvist",
"event_request_declined_recurring": "Din gjentagende hendelsesforespørsel har blitt avvist",
"event_request_cancelled": "Din planlagte hendelse ble kansellert",
@@ -32,7 +32,7 @@
"refund_failed": "Tilbakebetalingen for hendelsen {{eventType}} med {{userName}} på {{date}} mislyktes.",
"check_with_provider_and_user": "Sjekk med betalingsleverandøren din og {{user}} hvordan du håndterer dette.",
"a_refund_failed": "En tilbakebetaling mislyktes",
"awaiting_payment_subject": "Venter på betaling: {{eventType}} med {{name}} {{date}}",
"awaiting_payment_subject": "Venter på betaling: {{title}} {{date}}",
"meeting_awaiting_payment": "Møtet ditt venter på betaling",
"help": "Hjelp",
"price": "Pris",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Noen har bedt om å planlegge en hendelse i kalenderen din.",
"someone_requested_password_reset": "Noen har bedt om en lenke for å endre passordet ditt.",
"password_reset_instructions": "Hvis du ikke har bedt om dette, kan du trygt ignorere denne e-posten og passordet ditt vil ikke bli endret.",
"event_awaiting_approval_subject": "Venter på godkjenning: {{eventType}} med {{name}} kl. {{date}}",
"event_awaiting_approval_subject": "Venter på godkjenning: {{title}} kl. {{date}}",
"event_still_awaiting_approval": "En hendelse venter fortsatt på din godkjenning",
"booking_submitted_subject": "Booking sendt inn: {{eventType}} med {{name}} kl. {{date}}",
"booking_submitted_subject": "Booking sendt inn: {{title}} kl. {{date}}",
"your_meeting_has_been_booked": "Møtet ditt er booket",
"event_type_has_been_rescheduled_on_time_date": "Din {{title}} har blitt flyttet til {{date}}.",
"event_has_been_rescheduled": "Oppdatert – Hendelsen din har blitt endret",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} to alternatywa open source Calendly, która daje Ci kontrolę nad własnymi danymi, przepływem pracy i wyglądem.",
"have_any_questions": "Masz pytania? Jesteśmy tutaj, aby pomóc.",
"reset_password_subject": "{{appName}}: Instrukcje resetowania hasła",
"event_declined_subject": "Odrzucono: {{eventType}} z {{name}} w dniu {{date}}",
"event_cancelled_subject": "Anulowano: {{eventType}} z {{name}} w {{date}}",
"event_declined_subject": "Odrzucono: {{title}} w dniu {{date}}",
"event_cancelled_subject": "Anulowano: {{title}} w {{date}}",
"event_request_declined": "Twoja prośba o wydarzenie została odrzucona",
"event_request_declined_recurring": "Twoja prośba o wydarzenie cykliczne została odrzucona",
"event_request_cancelled": "Twoje zaplanowane wydarzenie zostało anulowane",
@@ -32,7 +32,7 @@
"refund_failed": "Zwrot środków za {{eventType}} z {{userName}} w {{date}} nie powiódł się.",
"check_with_provider_and_user": "Proszę, sprawdź z dostawcą płatności i {{user}} jak to zrobić.",
"a_refund_failed": "Zwrot środków nie powiódł się",
"awaiting_payment_subject": "Oczekiwanie na płatność: {{eventType}} z {{name}} w {{date}}",
"awaiting_payment_subject": "Oczekiwanie na płatność: {{title}} w {{date}}",
"meeting_awaiting_payment": "Twoje spotkanie oczekuje na płatność",
"help": "Pomoc",
"price": "Cena",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Ktoś poprosił o zaplanowanie wydarzenia w Twoim kalendarzu.",
"someone_requested_password_reset": "Ktoś poprosił o link do zmiany Twojego hasła.",
"password_reset_instructions": "Jeśli nie poprosiłeś o to, możesz bezpiecznie zignorować ten e-mail, a Twoje hasło nie zostanie zmienione.",
"event_awaiting_approval_subject": "Oczekiwanie na zatwierdzenie: {{eventType}} z {{name}} w {{date}}",
"event_awaiting_approval_subject": "Oczekiwanie na zatwierdzenie: {{title}} w {{date}}",
"event_still_awaiting_approval": "Wydarzenie nadal oczekuje na Twoje zatwierdzenie",
"booking_submitted_subject": "Rezerwacja potwierdzona: {{eventType}} z {{name}} w {{date}}",
"booking_submitted_subject": "Rezerwacja potwierdzona: {{title}} w {{date}}",
"your_meeting_has_been_booked": "Twoje spotkanie zostało zarezerwowane",
"event_type_has_been_rescheduled_on_time_date": "Twój {{title}} został przełożony na {{date}}.",
"event_has_been_rescheduled": "Zaktualizowano - Twoje wydarzenie zostało przełożone",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} é a alternativa de código aberto ao Calendly que coloca você no controle dos seus dados, fluxo de trabalho e aparência.",
"have_any_questions": "Precisa de ajuda? Estamos aqui para ajudar.",
"reset_password_subject": "{{appName}}: Instruções para redefinir sua senha",
"event_declined_subject": "Recusado: {{eventType}} com {{name}} em {{date}}",
"event_cancelled_subject": "Cancelado: {{eventType}} com {{name}} em {{date}}",
"event_declined_subject": "Recusado: {{title}} em {{date}}",
"event_cancelled_subject": "Cancelado: {{title}} em {{date}}",
"event_request_declined": "Sua solicitação de reunião foi recusada",
"event_request_declined_recurring": "Sua solicitação de evento recorrente foi recusada",
"event_request_cancelled": "Seu evento agendado foi cancelado",
@@ -32,7 +32,7 @@
"refund_failed": "O reembolso do evento {{eventType}} com {{userName}} em {{date}} falhou.",
"check_with_provider_and_user": "Por favor, tente resolver junto do seu fornecedor de pagamentos e com o {{user}}.",
"a_refund_failed": "Um reembolso falhou",
"awaiting_payment_subject": "Aguardando pagamento: {{eventType}} com {{name}} em {{date}}",
"awaiting_payment_subject": "Aguardando pagamento: {{title}} em {{date}}",
"meeting_awaiting_payment": "Sua reunião está aguardando o pagamento",
"help": "Ajuda",
"price": "Preço",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Alguém solicitou o agendamento de um evento em sua agenda.",
"someone_requested_password_reset": "Alguém solicitou um link para alterar sua senha.",
"password_reset_instructions": "Se você não fez essa solicitação, você pode ignorar este e-mail e sua senha não será alterada.",
"event_awaiting_approval_subject": "Aguardando aprovação: {{eventType}} com {{name}} em {{date}}",
"event_awaiting_approval_subject": "Aguardando aprovação: {{title}} em {{date}}",
"event_still_awaiting_approval": "Um evento está aguardando a sua aprovação",
"booking_submitted_subject": "Reserva enviada: {{eventType}} com {{name}} em {{date}}",
"booking_submitted_subject": "Reserva enviada: {{title}} em {{date}}",
"your_meeting_has_been_booked": "A sua reunião foi agendada",
"event_type_has_been_rescheduled_on_time_date": "Seu {{title}} foi remarcado para {{date}}.",
"event_has_been_rescheduled": "Atualização - O seu evento foi reagendado",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} é a alternativa de código aberto de Calendly que lhe dá o controlo dos seus próprios dados, do seu fluxo de trabalho e da apresentação.",
"have_any_questions": "Tem perguntas? Estamos disponíveis para ajudar.",
"reset_password_subject": "{{appName}}: Instruções de redefinição da senha",
"event_declined_subject": "Declinado: {{eventType}} com {{name}} em {{date}}",
"event_cancelled_subject": "Cancelado: {{eventType}} com {{name}} em {{date}}",
"event_declined_subject": "Declinado: {{title}} em {{date}}",
"event_cancelled_subject": "Cancelado: {{title}} em {{date}}",
"event_request_declined": "O seu pedido de evento foi declinado",
"event_request_declined_recurring": "O seu pedido de evento recorrente foi recusado",
"event_request_cancelled": "O seu evento agendado foi cancelado",
@@ -32,7 +32,7 @@
"refund_failed": "Falhou o reembolso do evento {{eventType}} com {{userName}} em {{date}}.",
"check_with_provider_and_user": "Por favor, tente resolver junto do seu fornecedor de pagamentos e com {{userName}}.",
"a_refund_failed": "Falhou ao reembolsar",
"awaiting_payment_subject": "A aguardar pagamento: {{eventType}} com {{name}} em {{date}}",
"awaiting_payment_subject": "A aguardar pagamento: {{title}} em {{date}}",
"meeting_awaiting_payment": "A sua reunião aguarda pagamento",
"help": "Ajuda",
"price": "Preço",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Alguém pediu para agendar um evento no seu calendário.",
"someone_requested_password_reset": "Alguém pediu uma ligação para alterar a sua senha.",
"password_reset_instructions": "Se não fez este pedido, pode ignorar este email com segurança e a sua senha não será alterada.",
"event_awaiting_approval_subject": "A aguardar aprovação: {{eventType}} com {{name}} em {{date}}",
"event_awaiting_approval_subject": "A aguardar aprovação: {{title}} em {{date}}",
"event_still_awaiting_approval": "Um evento ainda aguarda a sua aprovação",
"booking_submitted_subject": "Reserva submetida: {{eventType}} com {{name}} em {{date}}",
"booking_submitted_subject": "Reserva submetida: {{title}} em {{date}}",
"your_meeting_has_been_booked": "A sua reunião foi reservada",
"event_type_has_been_rescheduled_on_time_date": "Seu {{title}} foi remarcado para {{date}}.",
"event_has_been_rescheduled": "O seu evento foi reagendado.",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} este alternativa open-source la Calendly, care vă oferă control asupra propriilor date, asupra fluxului de lucru și asupra aspectului site-ului.",
"have_any_questions": "Aveți întrebări? Suntem aici pentru a vă ajuta.",
"reset_password_subject": "{{appName}}: instrucțiuni de resetare a parolei",
"event_declined_subject": "Refuzat: {{eventType}} cu {{name}} în {{date}}",
"event_cancelled_subject": "Anulat: {{eventType}} cu {{name}} în {{date}}",
"event_declined_subject": "Refuzat: {{title}} în {{date}}",
"event_cancelled_subject": "Anulat: {{title}} în {{date}}",
"event_request_declined": "Solicitarea pentru eveniment a fost refuzată",
"event_request_declined_recurring": "Solicitarea dvs. pentru evenimentul recurent a fost refuzată",
"event_request_cancelled": "Evenimentul programat a fost anulat",
@@ -32,7 +32,7 @@
"refund_failed": "Rambursarea pentru eveniment {{eventType}} cu {{userName}} în {{date}} a eșuat.",
"check_with_provider_and_user": "Vă rugăm să verificați cu furnizorul dvs. de plăți și cu {{userName}} cum să gestionați această activitate.",
"a_refund_failed": "Rambursare eșuată",
"awaiting_payment_subject": "Se așteaptă plata: {{eventType}} cu {{name}} în {{date}}",
"awaiting_payment_subject": "Se așteaptă plata: {{title}} în {{date}}",
"meeting_awaiting_payment": "Întâlnirea dvs. așteaptă plata",
"help": "Ajutor",
"price": "Prețul",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Cineva a solicitat să programeze un eveniment în calendar.",
"someone_requested_password_reset": "Cineva a solicitat un link pentru a vă schimba parola.",
"password_reset_instructions": "Dacă nu ați solicitat acest lucru, puteți ignora acest e-mail, iar parola nu va fi schimbată.",
"event_awaiting_approval_subject": "Se așteaptă aprobarea: {{eventType}} cu {{name}} în {{date}}",
"event_awaiting_approval_subject": "Se așteaptă aprobarea: {{title}} în {{date}}",
"event_still_awaiting_approval": "Un eveniment încă așteaptă aprobarea dvs.",
"booking_submitted_subject": "Rezervare transmisă: {{eventType}} cu {{name}} în {{date}}",
"booking_submitted_subject": "Rezervare transmisă: {{title}} în {{date}}",
"your_meeting_has_been_booked": "Întâlnirea dvs. a fost programată",
"event_type_has_been_rescheduled_on_time_date": "{{title}} dvs. a fost reprogramat la {{date}}.",
"event_has_been_rescheduled": "Evenimentul dvs. a fost reprogramat.",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} - это альтернатива Calendly с открытым исходным кодом, позволяющая контролировать собственные данные, рабочий процесс и внешний вид.",
"have_any_questions": "Есть вопросы? Мы здесь, чтобы помочь.",
"reset_password_subject": "{{appName}}: Инструкция по сбросу пароля",
"event_declined_subject": "Отклонено: {{eventType}} с {{name}} на {{date}}",
"event_cancelled_subject": "Отменено: {{eventType}} с {{name}} на {{date}}",
"event_declined_subject": "Отклонено: {{title}} на {{date}}",
"event_cancelled_subject": "Отменено:{{title}} на {{date}}",
"event_request_declined": "Ваш запрос на встречу отклонен",
"event_request_declined_recurring": "Ваш запрос на повторяющееся событие отклонен",
"event_request_cancelled": "Ваша запланированная встреча была отменена",
@@ -32,7 +32,7 @@
"refund_failed": "Возврат за встречу {{eventType}} с {{userName}} на {{date}} не удался.",
"check_with_provider_and_user": "Пожалуйста, проверьте в вашей платежной системе и {{userName}} как это сделать.",
"a_refund_failed": "Возврат не удался",
"awaiting_payment_subject": "Ожидание оплаты: {{eventType}} с {{name}} на {{date}}",
"awaiting_payment_subject": "Ожидание оплаты: {{title}} на {{date}}",
"meeting_awaiting_payment": "Ваша встреча ожидает оплаты",
"help": "Помощь",
"price": "Цена",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Кто-то записался на встречу в вашем календаре.",
"someone_requested_password_reset": "Кто-то запросил ссылку для изменения вашего пароля.",
"password_reset_instructions": "Если вы не запрашивали это, проигнорируйте это письмо и пароль не будет изменен.",
"event_awaiting_approval_subject": "Ожидает подтверждения: {{eventType}} с {{name}} на {{date}}",
"event_awaiting_approval_subject": "Ожидает подтверждения: {{title}} на {{date}}",
"event_still_awaiting_approval": "Событие все еще ждет вашего подтверждения",
"booking_submitted_subject": "Бронирование отправлено: {{eventType}} с {{name}}, {{date}}",
"booking_submitted_subject": "Бронирование отправлено: {{title}}, {{date}}",
"your_meeting_has_been_booked": "Ваша встреча была забронирована",
"event_type_has_been_rescheduled_on_time_date": "Ваше {{title}} было перенесено на {{date}}.",
"event_has_been_rescheduled": "Ваше событие было перенесено.",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} je Calendly alternativa sa otvorenim kodom koja vam omogućava kontrolu nad vašim podacima, procesom rada i izgledom.",
"have_any_questions": "Imate pitanja? Tu smo da pomognemo.",
"reset_password_subject": "{{appName}}: Upustva za resetovanje lozinke",
"event_declined_subject": "Odbijeno: {{eventType}} sa {{name}} datuma {{date}}",
"event_cancelled_subject": "Otkazano: {{eventType}} sa {{name}} datuma {{date}}",
"event_declined_subject": "Odbijeno: {{title}} datuma {{date}}",
"event_cancelled_subject": "Otkazano: {{title}} datuma {{date}}",
"event_request_declined": "Vaš zahtev za dogadjaj je odbijen",
"event_request_declined_recurring": "Vaš zahtev za ponavljajući događaj je odbijen",
"event_request_cancelled": "Vaš zakazan dogadjaj je odbijen",
@@ -32,7 +32,7 @@
"refund_failed": "Povraćaj novca za dogadjaj {{eventType}} sa {{userName}} datuma {{date}} je neuspešan.",
"check_with_provider_and_user": "Molimo vas proverite sa vašom uslugom plaćanja i {{user}} kako bi rešili ovo.",
"a_refund_failed": "Povraćaj novca neuspešan",
"awaiting_payment_subject": "Čekanje na uplatu: {{eventType}} sa {{name}} datuma {{date}}",
"awaiting_payment_subject": "Čekanje na uplatu: {{title}} datuma {{date}}",
"meeting_awaiting_payment": "Vaš sastanak čeka na uplatu",
"help": "Pomoć",
"price": "Cena",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Neko zahteva da rezerviše dogadjaj na vašem kalendaru.",
"someone_requested_password_reset": "Neko zahteva link da promeni vašu lozinku.",
"password_reset_instructions": "Ako niste pokrenuli ovaj zahtev, slobodno ignorišite ovu e-poštu i vaša lozinka se neće promeniti.",
"event_awaiting_approval_subject": "Čeka na odobrenje: {{eventType}} sa {{name}} datuma {{date}}",
"event_awaiting_approval_subject": "Čeka na odobrenje: {{title}} datuma {{date}}",
"event_still_awaiting_approval": "Događaj još čeka na vaše odobrenje",
"booking_submitted_subject": "Rezervacija podneta: {{eventType}} sa {{name}} datuma {{date}}",
"booking_submitted_subject": "Rezervacija podneta:{{title}} datuma {{date}}",
"your_meeting_has_been_booked": "Sastanak je zakazan",
"event_type_has_been_rescheduled_on_time_date": "Ваш {{титле}} је померен за {{дате}}.",
"event_has_been_rescheduled": "Ažurirano - Vaš dogadjaj je odložen",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} är öppen källkods-alternativet till Calendly som ger dig kontroll över din egen data, ditt arbetsflöde och ditt utseende.",
"have_any_questions": "Har du frågor? Vi är här för att hjälpa till.",
"reset_password_subject": "{{appName}}: Instruktioner för att återställa ditt lösenord",
"event_declined_subject": "Avböjd: {{eventType}} med {{name}} vid {{date}}",
"event_cancelled_subject": "Inställd: {{eventType}} med {{name}} vid {{date}}",
"event_declined_subject": "Avböjd: {{title}} vid {{date}}",
"event_cancelled_subject": "Inställd: {{title}} vid {{date}}",
"event_request_declined": "Din bokningsförfrågan har avbjöts",
"event_request_declined_recurring": "Din återkommande händelse har avböjts",
"event_request_cancelled": "Din schemalagda bokning ställdes in",
@@ -32,7 +32,7 @@
"refund_failed": "Återbetalningen för bokningen {{eventType}} med {{userName}} den {{date}} misslyckades.",
"check_with_provider_and_user": "Vänligen kontrollera med din betalningsleverantör och {{user}} hur du hanterar detta.",
"a_refund_failed": "Återbetalning misslyckades",
"awaiting_payment_subject": "Inväntar betalning: {{eventType}} med {{name}} på {{date}}",
"awaiting_payment_subject": "Inväntar betalning: {{title}} på {{date}}",
"meeting_awaiting_payment": "Din bokning väntar på betalning",
"help": "Hjälp",
"price": "Pris",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Någon har begärt att schemalägga en händelse i din kalender.",
"someone_requested_password_reset": "Någon har begärt en länk för att ändra ditt lösenord.",
"password_reset_instructions": "Om du inte begärde detta kan du ignorera detta e-postmeddelande och ditt lösenord kommer inte att ändras.",
"event_awaiting_approval_subject": "Inväntar godkännande: {{eventType}} med {{name}} den {{date}}",
"event_awaiting_approval_subject": "Inväntar godkännande: {{title}} den {{date}}",
"event_still_awaiting_approval": "En bokning väntar fortfarande på ditt godkännande",
"booking_submitted_subject": "Bokning skickad: {{eventType}} med {{name}} {{date}}",
"booking_submitted_subject": "Bokning skickad: {{title}} {{date}}",
"your_meeting_has_been_booked": "Ditt möte har bokats",
"event_type_has_been_rescheduled_on_time_date": "Din {{title}} har ändrats till {{date}}.",
"event_has_been_rescheduled": "Uppdatering - Din bokning har blivit omplanerad",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}}, kendi verilerinizin, iş akışınızın ve web görünümünüzün kontrolünü size veren açık kaynaklı Calendly uygulamasının alternatifidir.",
"have_any_questions": "Sorularınız mı var? Size yardımcı olmak için buradayız.",
"reset_password_subject": "{{appName}}: Şifre sıfırlama talimatları",
"event_declined_subject": "Reddedildi: {{date}} tarihinde {{name}} ile {{eventType}}",
"event_cancelled_subject": "İptal edildi: {{date}} tarihinde {{name}} ile {{eventType}}",
"event_declined_subject": "Reddedildi: {{date}} tarihinde {{title}}",
"event_cancelled_subject": "İptal edildi: {{date}} tarihinde {{title}}",
"event_request_declined": "Etkinlik talebiniz reddedildi",
"event_request_declined_recurring": "Yinelenen etkinlik talebiniz reddedildi",
"event_request_cancelled": "Planlanan etkinliğiniz iptal edildi",
@@ -32,7 +32,7 @@
"refund_failed": "{{date}} tarihindeki {{userName}} ile {{eventType}} etkinliğinin iadesi başarısız oldu.",
"check_with_provider_and_user": "Lütfen ödeme sağlayıcınızla ve {{user}} ile bunu nasıl ele alınacağınızı kontrol edin.",
"a_refund_failed": "İade başarısız oldu",
"awaiting_payment_subject": "Ödeme Bekleniyor: {{date}} tarihinde {{name}} ile {{eventType}}",
"awaiting_payment_subject": "Ödeme Bekleniyor: {{date}} tarihinde {{title}}",
"meeting_awaiting_payment": "Toplantınız için ödeme bekleniyor",
"help": "Yardım",
"price": "Fiyat",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Birisi takviminizde bir etkinlik planlamak istiyor.",
"someone_requested_password_reset": "Biri şifrenizi değiştirmek için bir bağlantı talebinde bulundu.",
"password_reset_instructions": "Böyle bir talepte bulunmadıysanız bu e-postayı güvenle yok sayabilirsiniz. Şifreniz değiştirilmeyecektir.",
"event_awaiting_approval_subject": "Onay Bekleniyor: {{date}} tarihinde {{name}} ile {{eventType}}",
"event_awaiting_approval_subject": "Onay Bekleniyor: {{date}} tarihinde {{title}}",
"event_still_awaiting_approval": "Bir etkinlik hâlâ onayınızı bekliyor",
"booking_submitted_subject": "Rezervasyon Gönderildi: {{date}} tarihinde {{name}} ile {{eventType}}",
"booking_submitted_subject": "Rezervasyon Gönderildi: {{date}} tarihinde {{title}}",
"your_meeting_has_been_booked": "Toplantı rezervasyonunuz yapıldı",
"event_type_has_been_rescheduled_on_time_date": "{{title}}, {{date}} olarak yeniden planlandı.",
"event_has_been_rescheduled": "Güncellendi - Etkinliğiniz yeniden planlandı",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} – це альтернатива Calendly з відкритим вихідним кодом, що дає змогу керувати вашими власними даними, робочими процесами й оформленням.",
"have_any_questions": "Маєте запитання? Ми допоможемо.",
"reset_password_subject": "{{appName}}: інструкції зі скидання пароля",
"event_declined_subject": "Відхилено: {{eventType}} «{{name}}», {{date}}",
"event_cancelled_subject": "Скасовано: {{eventType}} «{{name}}», {{date}}",
"event_declined_subject": "Відхилено: {{title}}, {{date}}",
"event_cancelled_subject": "Скасовано: {{title}}, {{date}}",
"event_request_declined": "Ваш запит на захід відхилено",
"event_request_declined_recurring": "Ваш запит на періодичний захід відхилено",
"event_request_cancelled": "Ваш запланований захід скасовано",
@@ -32,7 +32,7 @@
"refund_failed": "Не вдалося повернути кошти за захід «{{eventType}}» із користувачем «{{userName}}» ({{date}}).",
"check_with_provider_and_user": "Зверніться до свого постачальника платіжних послуг і користувача {{user}} щодо вирішення цієї проблеми.",
"a_refund_failed": "Не вдалося повернути кошти",
"awaiting_payment_subject": "Очікування платежу: {{eventType}} «{{name}}», {{date}}",
"awaiting_payment_subject": "Очікування платежу: {{title}}, {{date}}",
"meeting_awaiting_payment": "Ваша нарада очікує на оплату",
"help": "Довідка",
"price": "Ціна",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Хтось хоче запланувати захід у вашому календарі.",
"someone_requested_password_reset": "Хтось хоче змінити ваш пароль.",
"password_reset_instructions": "Якщо ви не надсилали цього запиту, можете не звертати уваги на цей лист. Пароль не зміниться.",
"event_awaiting_approval_subject": "Очікування на схвалення: {{eventType}} «{{name}}», {{date}}",
"event_awaiting_approval_subject": "Очікування на схвалення: {{title}}, {{date}}",
"event_still_awaiting_approval": "Захід досі чекає на ваше схвалення",
"booking_submitted_subject": "Бронювання надіслано: {{eventType}} «{{name}}», {{date}}",
"booking_submitted_subject": "Бронювання надіслано: {{title}}, {{date}}",
"your_meeting_has_been_booked": "Вашу нараду заброньовано",
"event_type_has_been_rescheduled_on_time_date": "Ваш {{title}} перенесено на {{date}}.",
"event_has_been_rescheduled": "Оновлено – ваш захід перенесено",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} là Calendly mã nguồn mở, giúp bạn quản lý dữ liệu, ngoại hình và cách làm việc của mình.",
"have_any_questions": "Có câu hỏi? Chúng tôi luôn ở đây để giúp bạn.",
"reset_password_subject": "{{appName}}: Hướng dẫn thay đổi mật khẩu",
"event_declined_subject": "Đã từ chối: {{eventType}} với {{name}} tại {{date}}",
"event_cancelled_subject": "Đã huỷ: {{eventType}} với {{name}} tại {{date}}",
"event_declined_subject": "Đã từ chối: {{title}} tại {{date}}",
"event_cancelled_subject": "Đã huỷ: {{title}} tại {{date}}",
"event_request_declined": "Lời mời sự kiện của bạn đã bị từ chối",
"event_request_declined_recurring": "Lời mời sự kiện định kỳ của bạn đã bị từ chối",
"event_request_cancelled": "Sự kiện của bạn đã bị huỷ",
@@ -32,7 +32,7 @@
"refund_failed": "Hoàn tiền cho sự kiện {{eventType}} với {{userName}} tại {{date}} đã thất bại.",
"check_with_provider_and_user": "Hãy kiểm tra với nhà cung cấp thanh toán và {{user}} phương thức xử lý.",
"a_refund_failed": "Một giao dịch hoàn tiền đã thất bại",
"awaiting_payment_subject": "Chờ đợi thanh toán: {{eventType}} với {{name}} tại {{date}}",
"awaiting_payment_subject": "Chờ đợi thanh toán: {{title}} tại {{date}}",
"meeting_awaiting_payment": "Buổi họp của bạn đang chờ được thanh toán",
"help": "Trợ giúp",
"price": "Giá",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "Ai đó đã yêu cầu đặt sự kiện trên lịch của bạn.",
"someone_requested_password_reset": "Ai đó đã yêu cầu link để đổi mật khẩu của bạn.",
"password_reset_instructions": "Nếu bạn không yêu cầu link này, bạn có thể yên tâm bỏ qua email này và mật khẩu của bạn sẽ không bị đổi.",
"event_awaiting_approval_subject": "Đang chờ phê duyệt: {{eventType}} với {{name}} lúc {{date}}",
"event_awaiting_approval_subject": "Đang chờ phê duyệt: {{title}} lúc {{date}}",
"event_still_awaiting_approval": "Một sự kiện vẫn đang chờ bạn phê duyệt",
"booking_submitted_subject": "Đặt chỗ đã được gửi: {{eventType}} với {{name}} lúc {{date}}",
"booking_submitted_subject": "Đặt chỗ đã được gửi: {{title}} lúc {{date}}",
"your_meeting_has_been_booked": "Cuộc họp của bạn đã được đặt",
"event_type_has_been_rescheduled_on_time_date": "{{title}} của bạn đã được lên lịch lại vào {{date}}.",
"event_has_been_rescheduled": "Đã cập nhật - Sự kiện của bạn đã được đổi",
@@ -9,8 +9,6 @@
"calcom_explained": "{{appName}}是Calendly的开源版替代,您能够掌控自己的数据、定制工作流程和外观。",
"have_any_questions": "有疑问?获取我们的帮助。",
"reset_password_subject": "{{appName}}: 重置密码教程",
"event_declined_subject": "已拒绝: 和 {{name}} 在 {{date}} 的 {{eventType}}",
"event_cancelled_subject": "已取消: 和 {{name}} 在 {{date}} 的 {{eventType}}",
"event_request_declined": "您的活动预约请求已被拒绝",
"event_request_declined_recurring": "您的定期活动请求已被拒绝",
"event_request_cancelled": "您预约的活动已被取消",
@@ -32,7 +30,6 @@
"refund_failed": "{{userName}}在{{date}}的{{eventType}}活动退款失败",
"check_with_provider_and_user": "请联系您的支付提供商与 {{user}} 来解决该问题。",
"a_refund_failed": "退款失败",
"awaiting_payment_subject": "待付款: 和 {{name}} 在 {{date}} 的 {{eventType}}",
"meeting_awaiting_payment": "您有会议待付款",
"help": "帮助",
"price": "价格",
@@ -63,9 +60,7 @@
"someone_requested_an_event": "有人请求在您的日历上安排一个活动。",
"someone_requested_password_reset": "有人请求了更改密码链接。",
"password_reset_instructions": "如果您没有这样请求,您可以安全地忽略此邮件,您的密码将不会被更改。",
"event_awaiting_approval_subject": "待批准: 和 {{name}} 在 {{date}} 的 {{eventType}}",
"event_still_awaiting_approval": "有活动仍在等待您的批准",
"booking_submitted_subject": "预约已提交: 名称为 {{name}} 的 {{eventType}},日期为 {{date}}",
"your_meeting_has_been_booked": "您的会议已预定",
"event_type_has_been_rescheduled_on_time_date": "您的 {{title}} 已改期至 {{date}}。",
"event_has_been_rescheduled": "已更新 - 您的活动已被重新安排",
@@ -9,8 +9,8 @@
"calcom_explained": "{{appName}} 是 Calendly 的開源替代方案,將自己置身於自有資料、工作流程與外觀的掌控之中。",
"have_any_questions": "有問題嗎?我們隨時在此幫助。",
"reset_password_subject": "{{appName}}: 重新設置密碼說明",
"event_declined_subject": "已拒絕:{{date}} 與 {{name}} 的 {{eventType}}",
"event_cancelled_subject": "已取消:{{date}} 與 {{name}} 的 {{eventType}}",
"event_declined_subject": "已拒絕:{{date}} 與 {{title}}",
"event_cancelled_subject": "已取消:{{date}} 與 {{title}}",
"event_request_declined": "活動請求遭到拒絕",
"event_request_declined_recurring": "您的定期活動請求已遭到拒絕",
"event_request_cancelled": "預定的活動取消了",
@@ -32,7 +32,7 @@
"refund_failed": "{{date}} 與 {{userName}} 的 {{eventType}} 活動退款失敗了。",
"check_with_provider_and_user": "請向付款提供方和 {{user}} 確認要如何處理。",
"a_refund_failed": "有一筆退款失敗",
"awaiting_payment_subject": "等待付款:{{date}} 與 {{name}} 的 {{eventType}}",
"awaiting_payment_subject": "等待付款:{{date}} 與 {{title}}",
"meeting_awaiting_payment": "活動正在等待付款",
"help": "幫助",
"price": "價格",
@@ -63,9 +63,9 @@
"someone_requested_an_event": "有人在行事曆上要求預定活動。",
"someone_requested_password_reset": "有人索取變更密碼的連結。",
"password_reset_instructions": "如果沒有索取的話,忽略這封電子郵件是安全的,密碼也不會變更。",
"event_awaiting_approval_subject": "等待核准:{{date}} 與 {{name}} 的 {{eventType}}",
"event_awaiting_approval_subject": "等待核准:{{date}} 與 {{title}}",
"event_still_awaiting_approval": "活動還在等待核准",
"booking_submitted_subject": "已提出預約:{{date}} 與 {{name}} 的 {{eventType}}",
"booking_submitted_subject": "已提出預約:{{date}} 與 {{title}}",
"your_meeting_has_been_booked": "會議已經預定",
"event_type_has_been_rescheduled_on_time_date": "您的 {{title}} 已改期至 {{date}}。",
"event_has_been_rescheduled": "已更新 - 已經重新預定活動時間",
@@ -22,6 +22,7 @@ export default function BookingPageTagManager({
return null;
}
const parseValue = <T extends string | undefined>(val: T): T =>
//TODO: Support more template variables.
val ? (val.replace(/\{TRACKING_ID\}/g, trackingId) as T) : val;
return tag.scripts.map((script, index) => {
@@ -1,7 +1,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useMemo } from "react";
import { classNames } from "@calcom/lib";
import { classNames as cs } from "@calcom/lib";
import { HorizontalTabs, VerticalTabs } from "@calcom/ui";
import getAppCategories from "../_utils/getAppCategories";
@@ -11,33 +11,39 @@ const AppCategoryNavigation = ({
children,
containerClassname,
className,
fromAdmin,
classNames,
useQueryParam = false,
}: {
baseURL: string;
children: React.ReactNode;
containerClassname: string;
/** @deprecated use classNames instead */
containerClassname?: string;
/** @deprecated use classNames instead */
className?: string;
fromAdmin?: boolean;
classNames?: {
root?: string;
container?: string;
verticalTabsItem?: string;
};
useQueryParam?: boolean;
}) => {
const [animationRef] = useAutoAnimate<HTMLDivElement>();
const appCategories = useMemo(() => getAppCategories(baseURL, useQueryParam), [baseURL, useQueryParam]);
return (
<div className={classNames("flex flex-col gap-x-6 p-2 md:p-0 xl:flex-row", className)}>
<div className={cs("flex flex-col gap-x-6 p-2 md:p-0 xl:flex-row", classNames?.root ?? className)}>
<div className="hidden xl:block">
<VerticalTabs
tabs={appCategories}
sticky
linkProps={{ shallow: true }}
itemClassname={classNames(fromAdmin && "w-60")}
itemClassname={classNames?.verticalTabsItem}
/>
</div>
<div className="mb-4 block overflow-x-scroll xl:hidden">
<HorizontalTabs tabs={appCategories} linkProps={{ shallow: true }} />
</div>
<main className={containerClassname} ref={animationRef}>
<main className={classNames?.container ?? containerClassname} ref={animationRef}>
{children}
</main>
</div>
@@ -20,7 +20,6 @@ export const InstallAppButtonMap = {
larkcalendar: dynamic(() => import("./larkcalendar/components/InstallAppButton")),
office365calendar: dynamic(() => import("./office365calendar/components/InstallAppButton")),
office365video: dynamic(() => import("./office365video/components/InstallAppButton")),
plausible: dynamic(() => import("./plausible/components/InstallAppButton")),
riverside: dynamic(() => import("./riverside/components/InstallAppButton")),
tandemvideo: dynamic(() => import("./tandemvideo/components/InstallAppButton")),
vital: dynamic(() => import("./vital/components/InstallAppButton")),
@@ -3,7 +3,7 @@ import { Dispatch, SetStateAction } from "react";
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import { Response, SerializableForm } from "../types/types";
import { SerializableForm, Response } from "../types/types";
type Props = {
form: SerializableForm<App_RoutingForms_Form>;
+1 -1
View File
@@ -4,7 +4,7 @@ import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
trackingId: z.string(),
trackingId: z.string().default("").optional(),
})
);
+1 -1
View File
@@ -4,7 +4,7 @@ import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
trackingId: z.string(),
trackingId: z.string().default("").optional(),
})
);
@@ -9,7 +9,7 @@ import { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) {
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
const trackedDomain = getAppData("trackedDomain");
const trackingId = getAppData("trackingId");
const [enabled, setEnabled] = useState(getAppData("enabled"));
return (
@@ -26,9 +26,9 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
switchChecked={enabled}>
<TextField
name="Tracked Domain"
value={trackedDomain}
value={trackingId}
onChange={(e) => {
setAppData("trackedDomain", e.target.value);
setAppData("trackingId", e.target.value);
}}
/>
</AppCard>
@@ -1,18 +0,0 @@
import type { InstallAppButtonProps } from "@calcom/app-store/types";
import useAddAppMutation from "../../_utils/useAddAppMutation";
export default function InstallAppButton(props: InstallAppButtonProps) {
const mutation = useAddAppMutation("plausible_analytics");
return (
<>
{props.render({
onClick() {
mutation.mutate("");
},
loading: mutation.isLoading,
})}
</>
);
}
@@ -1 +0,0 @@
export { default as InstallAppButton } from "./InstallAppButton";
+1 -1
View File
@@ -17,7 +17,7 @@
{
"src": "https://plausible.io/js/script.js",
"attrs": {
"data-domain": "{TRACKED_DOMAIN}"
"data-domain": "{TRACKING_ID}"
}
}
]
+1 -1
View File
@@ -4,7 +4,7 @@ import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
trackedDomain: z.string(),
trackingId: z.string().default("").optional(),
})
);
@@ -1,2 +1,3 @@
export * as api from "./api";
export * as lib from "./lib";
export { metadata } from "./_metadata";
@@ -0,0 +1,215 @@
import { Booking, Payment, Prisma } from "@prisma/client";
import Stripe from "stripe";
import { v4 as uuidv4 } from "uuid";
import z from "zod";
import { sendAwaitingPaymentEmail } from "@calcom/emails";
import { IAbstractPaymentService } from "@calcom/lib/PaymentService";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import prisma from "@calcom/prisma";
import { CalendarEvent } from "@calcom/types/Calendar";
import { createPaymentLink } from "./client";
import { StripePaymentData } from "./server";
const stripeCredentialKeysSchema = z.object({
stripe_user_id: z.string(),
default_currency: z.string(),
stripe_publishable_key: z.string(),
});
const stripeAppKeysSchema = z.object({
client_id: z.string(),
payment_fee_fixed: z.number(),
payment_fee_percentage: z.number(),
});
export class PaymentService implements IAbstractPaymentService {
private stripe: Stripe;
private credentials: z.infer<typeof stripeCredentialKeysSchema>;
constructor(credentials: { key: Prisma.JsonValue }) {
// parse credentials key
this.credentials = stripeCredentialKeysSchema.parse(credentials.key);
this.stripe = new Stripe(process.env.STRIPE_PRIVATE_KEY || "", {
apiVersion: "2020-08-27",
});
}
async create(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"]
) {
try {
// Load stripe keys
const stripeAppKeys = await prisma?.app.findFirst({
select: {
keys: true,
},
where: {
slug: "stripe",
},
});
// Parse keys with zod
const { client_id, payment_fee_fixed, payment_fee_percentage } = stripeAppKeysSchema.parse(
stripeAppKeys?.keys
);
const paymentFee = Math.round(payment.amount * payment_fee_percentage + payment_fee_fixed);
const params: Stripe.PaymentIntentCreateParams = {
amount: payment.amount,
currency: this.credentials.default_currency,
payment_method_types: ["card"],
application_fee_amount: paymentFee,
};
const paymentIntent = await this.stripe.paymentIntents.create(params, {
stripeAccount: this.credentials.stripe_user_id,
});
const paymentData = await prisma?.payment.create({
data: {
uid: uuidv4(),
app: {
connect: {
slug: "stripe",
},
},
booking: {
connect: {
id: bookingId,
},
},
amount: payment.amount,
currency: payment.currency,
externalId: paymentIntent.id,
data: Object.assign({}, paymentIntent, {
stripe_publishable_key: this.credentials.stripe_publishable_key,
stripeAccount: this.credentials.stripe_user_id,
}) as unknown as Prisma.InputJsonValue,
fee: paymentFee,
refunded: false,
success: false,
},
});
if (!paymentData) {
throw new Error();
}
return paymentData;
} catch (error) {
console.error(error);
throw new Error("Payment could not be created");
}
}
async update(): Promise<Payment> {
throw new Error("Method not implemented.");
}
async refund(paymentId: Payment["id"]): Promise<Payment> {
try {
const payment = await prisma.payment.findFirst({
where: {
id: paymentId,
success: true,
refunded: false,
},
});
if (!payment) {
throw new Error("Payment not found");
}
const refund = await this.stripe.refunds.create(
{
payment_intent: payment.externalId,
},
{ stripeAccount: (payment.data as unknown as StripePaymentData)["stripeAccount"] }
);
if (!refund || refund.status === "failed") {
throw new Error("Refund failed");
}
const updatedPayment = await prisma.payment.update({
where: {
id: payment.id,
},
data: {
refunded: true,
},
});
return updatedPayment;
} catch (e) {
const err = getErrorFromUnknown(e);
throw err;
}
}
async afterPayment(
event: CalendarEvent,
booking: {
user: { email: string | null; name: string | null; timeZone: string } | null;
id: number;
startTime: { toISOString: () => string };
uid: string;
},
paymentData: Payment
): Promise<void> {
await sendAwaitingPaymentEmail({
...event,
paymentInfo: {
link: createPaymentLink({
paymentUid: paymentData.uid,
name: booking.user?.name,
email: booking.user?.email,
date: booking.startTime.toISOString(),
}),
},
});
}
async deletePayment(paymentId: Payment["id"]): Promise<boolean> {
try {
const payment = await prisma.payment.findFirst({
where: {
id: paymentId,
},
});
if (!payment) {
throw new Error("Payment not found");
}
const stripeAccount = (payment.data as unknown as StripePaymentData).stripeAccount;
if (!stripeAccount) {
throw new Error("Stripe account not found");
}
// Expire all current sessions
const sessions = await this.stripe.checkout.sessions.list(
{
payment_intent: payment.externalId,
},
{ stripeAccount }
);
for (const session of sessions.data) {
await this.stripe.checkout.sessions.expire(session.id, { stripeAccount });
}
// Then cancel the payment intent
await this.stripe.paymentIntents.cancel(payment.externalId, { stripeAccount });
return true;
} catch (e) {
console.error(e);
return false;
}
}
getPaymentPaidStatus(): Promise<string> {
throw new Error("Method not implemented.");
}
getPaymentDetails(): Promise<Payment> {
throw new Error("Method not implemented.");
}
}
@@ -1 +1,2 @@
export * from "./constants";
export * from "./PaymentService";
+1 -179
View File
@@ -1,19 +1,7 @@
import { PaymentType, Prisma } from "@prisma/client";
import Stripe from "stripe";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@calcom/emails";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import prisma from "@calcom/prisma";
import { EventTypeModel } from "@calcom/prisma/zod";
import type { CalendarEvent } from "@calcom/types/Calendar";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { createPaymentLink } from "./client";
export type PaymentData = Stripe.Response<Stripe.PaymentIntent> & {
export type StripePaymentData = Stripe.Response<Stripe.PaymentIntent> & {
stripe_publishable_key: string;
stripeAccount: string;
};
@@ -40,170 +28,4 @@ const stripe = new Stripe(stripePrivateKey, {
apiVersion: "2020-08-27",
});
const stripeKeysSchema = z.object({
payment_fee_fixed: z.number(),
payment_fee_percentage: z.number(),
});
const stripeCredentialSchema = z.object({
stripe_user_id: z.string(),
stripe_publishable_key: z.string(),
});
export async function handlePayment(
evt: CalendarEvent,
selectedEventType: Pick<z.infer<typeof EventTypeModel>, "price" | "currency" | "metadata">,
stripeCredential: { key: Prisma.JsonValue },
booking: {
user: { email: string | null; name: string | null; timeZone: string } | null;
id: number;
startTime: { toISOString: () => string };
uid: string;
}
) {
const appKeys = await getAppKeysFromSlug("stripe");
const { payment_fee_fixed, payment_fee_percentage } = stripeKeysSchema.parse(appKeys);
const stripeAppData = getStripeAppData(selectedEventType);
const paymentFee = Math.round(stripeAppData.price * payment_fee_percentage + payment_fee_fixed);
const { stripe_user_id, stripe_publishable_key } = stripeCredentialSchema.parse(stripeCredential.key);
const params: Stripe.PaymentIntentCreateParams = {
amount: stripeAppData.price,
currency: stripeAppData.currency,
payment_method_types: ["card"],
application_fee_amount: paymentFee,
};
const paymentIntent = await stripe.paymentIntents.create(params, { stripeAccount: stripe_user_id });
const payment = await prisma.payment.create({
data: {
type: PaymentType.STRIPE,
uid: uuidv4(),
booking: {
connect: {
id: booking.id,
},
},
amount: stripeAppData.price,
fee: paymentFee,
currency: stripeAppData.currency,
success: false,
refunded: false,
data: Object.assign({}, paymentIntent, {
stripe_publishable_key,
stripeAccount: stripe_user_id,
}) /* We should treat this */ as PaymentData /* but Prisma doesn't know how to handle it, so it we treat it */ as unknown /* and then */ as Prisma.InputJsonValue,
externalId: paymentIntent.id,
},
});
await sendAwaitingPaymentEmail({
...evt,
paymentInfo: {
link: createPaymentLink({
paymentUid: payment.uid,
name: booking.user?.name,
email: booking.user?.email,
date: booking.startTime.toISOString(),
}),
},
});
return payment;
}
export async function refund(
booking: {
id: number;
uid: string;
startTime: Date;
payment: {
id: number;
success: boolean;
refunded: boolean;
externalId: string;
data: Prisma.JsonValue;
type: PaymentType;
}[];
},
calEvent: CalendarEvent
) {
try {
const payment = booking.payment.find((e) => e.success && !e.refunded);
if (!payment) return;
if (payment.type !== PaymentType.STRIPE) {
await handleRefundError({
event: calEvent,
reason: "cannot refund non Stripe payment",
paymentId: "unknown",
});
return;
}
const refund = await stripe.refunds.create(
{
payment_intent: payment.externalId,
},
{ stripeAccount: (payment.data as unknown as PaymentData)["stripeAccount"] }
);
if (!refund || refund.status === "failed") {
await handleRefundError({
event: calEvent,
reason: refund?.failure_reason || "unknown",
paymentId: payment.externalId,
});
return;
}
await prisma.payment.update({
where: {
id: payment.id,
},
data: {
refunded: true,
},
});
} catch (e) {
const err = getErrorFromUnknown(e);
console.error(err, "Refund failed");
await handleRefundError({
event: calEvent,
reason: err.message || "unknown",
paymentId: "unknown",
});
}
}
export const closePayments = async (paymentIntentId: string, stripeAccount: string) => {
try {
// Expire all current sessions
const sessions = await stripe.checkout.sessions.list(
{
payment_intent: paymentIntentId,
},
{ stripeAccount }
);
for (const session of sessions.data) {
await stripe.checkout.sessions.expire(session.id, { stripeAccount });
}
// Then cancel the payment intent
await stripe.paymentIntents.cancel(paymentIntentId, { stripeAccount });
return;
} catch (e) {
console.error(e);
return;
}
};
async function handleRefundError(opts: { event: CalendarEvent; reason: string; paymentId: string }) {
console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`);
await sendOrganizerPaymentRefundFailedEmail({
...opts.event,
paymentInfo: { reason: opts.reason, id: opts.paymentId },
});
}
export default stripe;
+1 -1
View File
@@ -6,7 +6,7 @@ module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
"../../packages/app-store/**/{components,pages}/**/*.{js,ts,jsx,tsx}",
"../../packages/app-store/**/*{components,pages}/**/*.{js,ts,jsx,tsx}",
"../../packages/features/**/*.{js,ts,jsx,tsx}",
"../../packages/ui/**/*.{js,ts,jsx,tsx}",
],
+7 -2
View File
@@ -11,7 +11,7 @@ export type EventNameObjectType = {
t: TFunction;
};
export function getEventName(eventNameObj: EventNameObjectType) {
export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView = false) {
if (!eventNameObj.eventName)
return eventNameObj.t("event_between_users", {
eventName: eventNameObj.eventType,
@@ -22,12 +22,13 @@ export function getEventName(eventNameObj: EventNameObjectType) {
let eventName = eventNameObj.eventName;
let locationString = eventNameObj.location || "";
if (eventNameObj.eventName.includes("{Location}")) {
if (eventNameObj.eventName.includes("{Location}") || eventNameObj.eventName.includes("{LOCATION}")) {
const eventLocationType = guessEventLocationType(eventNameObj.location);
if (eventLocationType) {
locationString = eventLocationType.label;
}
eventName = eventName.replace("{Location}", locationString);
eventName = eventName.replace("{LOCATION}", locationString);
}
return (
@@ -36,5 +37,9 @@ export function getEventName(eventNameObj: EventNameObjectType) {
.replace("{Event type title}", eventNameObj.eventType)
.replace("{Scheduler}", eventNameObj.attendeeName)
.replace("{Organiser}", eventNameObj.host)
.replace("{USER}", eventNameObj.attendeeName)
.replace("{ATTENDEE}", eventNameObj.attendeeName)
.replace("{HOST}", eventNameObj.host)
.replace("{HOST/ATTENDEE}", forAttendeeView ? eventNameObj.host : eventNameObj.attendeeName)
);
}
@@ -11,7 +11,7 @@ const getHtml = ({
}) => {
// IT IS A REQUIREMENT THAT ALL POSSIBLE CLASSES ARE HERE OTHERWISE TAILWIND WONT GENERATE THE CSS FOR CONDITIONAL CLASSES
// To not let all these classes apply and visible, keep it hidden initially
return `<button class="hidden fixed md:bottom-6 bottom-4 md:right-10 right-4 md:left-10 left-4 ${buttonClasses.join(
return `<button class="z-[10000000000] hidden fixed md:bottom-6 bottom-4 md:right-10 right-4 md:left-10 left-4 ${buttonClasses.join(
" "
)} flex h-16 origin-center bg-red-50 transform cursor-pointer items-center
rounded-full py-4 px-6 text-base outline-none drop-shadow-md transition focus:outline-none fo
@@ -19,7 +19,6 @@ declare global {
resetEmbedStatus: () => void;
getEmbedNamespace: () => string | null;
getEmbedTheme: () => "dark" | "light" | null;
isPageOptimizedForEmbed: (calLink: string) => boolean;
}
}
+25 -10
View File
@@ -1,12 +1,14 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { AppCategories } from "@prisma/client";
import { noop } from "lodash";
import { useRouter } from "next/router";
import { useState, useReducer, FC } from "react";
import { FC, useReducer, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import AppCategoryNavigation from "@calcom/app-store/_components/AppCategoryNavigation";
import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated";
import { classNames as cs } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterOutputs, trpc } from "@calcom/trpc/react";
import {
@@ -32,9 +34,9 @@ import {
} from "@calcom/ui";
import {
FiAlertCircle,
FiCheckCircle,
FiEdit,
FiMoreHorizontal,
FiCheckCircle,
FiXCircle,
} from "@calcom/ui/components/icon";
@@ -148,26 +150,39 @@ const AdminAppsList = ({
baseURL,
className,
useQueryParam = false,
classNames,
onSubmit = noop,
...rest
}: {
baseURL: string;
classNames?: {
form?: string;
appCategoryNavigationRoot?: string;
appCategoryNavigationContainer?: string;
verticalTabsItem?: string;
};
className?: string;
useQueryParam?: boolean;
}) => {
const router = useRouter();
onSubmit?: () => void;
} & Omit<JSX.IntrinsicElements["form"], "onSubmit">) => {
return (
<form
id="wizard-step-2"
name="wizard-step-2"
{...rest}
className={
classNames?.form ?? "max-w-80 mb-4 rounded-md bg-white px-0 pt-0 md:max-w-full md:px-8 md:pt-10"
}
onSubmit={(e) => {
e.preventDefault();
router.replace("/");
onSubmit();
}}>
<AppCategoryNavigation
baseURL={baseURL}
fromAdmin
useQueryParam={useQueryParam}
containerClassname="min-w-0 w-full"
className={className}>
classNames={{
root: className,
verticalTabsItem: classNames?.verticalTabsItem,
container: cs("min-w-0 w-full", classNames?.appCategoryNavigationContainer ?? "max-w-[500px]"),
}}>
<AdminAppsListContainer />
</AppCategoryNavigation>
</form>
@@ -1,3 +1,5 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { AnimatedPopover, Avatar } from "@calcom/ui";
@@ -9,6 +11,7 @@ export const PeopleFilter = () => {
const { t } = useLocale();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeByKey } = useFilterQuery();
const { data } = trpc.viewer.teams.listMembers.useQuery({});
const [dropdownTitle, setDropdownTitle] = useState<string>(t("all_users_filter_label"));
if (!data || !data.length) return null;
@@ -16,8 +19,7 @@ export const PeopleFilter = () => {
const userNames = data?.filter((user) => query.userIds?.includes(user.id)).map((user) => user.name);
return (
<AnimatedPopover
text={userNames && userNames.length > 0 ? `${userNames.join(", ")}` : t("all_users_filter_label")}>
<AnimatedPopover text={userNames && userNames.length > 0 ? `${userNames.join(", ")}` : dropdownTitle}>
<div className="item-center flex px-4 py-[6px] focus-within:bg-gray-100 hover:cursor-pointer hover:bg-gray-50">
<div className="flex h-6 w-6 items-center justify-center ltr:mr-2 rtl:ml-2">
<FiUser className="h-5 w-5" />
@@ -31,6 +33,7 @@ export const PeopleFilter = () => {
type="checkbox"
checked={!query.userIds}
onChange={() => {
setDropdownTitle(t("all_users_filter_label"));
// Always clear userIds on toggle as this is the toggle box for all users. No params means we are currently selecting all users
removeByKey("userIds");
}}
@@ -62,6 +65,7 @@ export const PeopleFilter = () => {
type="checkbox"
checked={query.userIds?.includes(user.id)}
onChange={(e) => {
setDropdownTitle(user.name ?? "NamelessUser");
if (e.target.checked) {
pushItemToKey("userIds", user.id);
} else if (!e.target.checked) {
@@ -1,4 +1,5 @@
import { useSession } from "next-auth/react";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@@ -12,6 +13,7 @@ export const TeamsMemberFilter = () => {
const session = useSession();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeByKey } = useFilterQuery();
const { data } = trpc.viewer.teams.list.useQuery();
const [dropdownTitle, setDropdownTitle] = useState<string>(t("all_bookings_filter_label"));
if (!data || !data.length) return null;
@@ -19,8 +21,7 @@ export const TeamsMemberFilter = () => {
const teamNames = data?.filter((team) => query.teamIds?.includes(team.id)).map((team) => team.name);
return (
<AnimatedPopover
text={teamNames && teamNames.length > 0 ? `${teamNames.join(", ")}` : t("all_bookings_filter_label")}>
<AnimatedPopover text={teamNames && teamNames.length > 0 ? `${teamNames.join(", ")}` : dropdownTitle}>
<div className="item-center flex px-4 py-[6px] focus-within:bg-gray-100 hover:cursor-pointer hover:bg-gray-50">
<div className="flex h-6 w-6 items-center justify-center ltr:mr-2 rtl:ml-2">
<FiLayers className="h-5 w-5" />
@@ -36,6 +37,7 @@ export const TeamsMemberFilter = () => {
type="checkbox"
checked={!query.teamIds && !query.userIds?.includes(session.data?.user.id || 0)}
onChange={() => {
setDropdownTitle(t("all_bookings_filter_label"));
removeByKey("teamIds"); // Always clear on toggle or not toggle (seems weird but when you know the behviour it works well )
}}
className="text-primary-600 focus:ring-primary-500 inline-flex h-4 w-4 place-self-center justify-self-end rounded border-gray-300 "
@@ -57,6 +59,7 @@ export const TeamsMemberFilter = () => {
disabled={session.status === "loading"}
checked={query.userIds?.includes(session.data?.user.id || 0)}
onChange={(e) => {
setDropdownTitle(t("your_bookings_filter_label"));
if (e.target.checked) {
pushItemToKey("userIds", session.data?.user.id || 0);
} else if (!e.target.checked) {
@@ -91,6 +94,7 @@ export const TeamsMemberFilter = () => {
type="checkbox"
checked={query.teamIds?.includes(team.id)}
onChange={(e) => {
setDropdownTitle(team.name);
if (e.target.checked) {
pushItemToKey("teamIds", team.id);
} else if (!e.target.checked) {
@@ -36,7 +36,7 @@ export default function BookingLayout({
return (
<Shell {...rest}>
<div className="flex max-w-6xl flex-col">
<div className="flex flex-col gap-2 lg:flex-row">
<div className="flex flex-col lg:flex-row">
<HorizontalTabs tabs={tabs} />
<div className="overflow-x-auto lg:ml-auto">
<FiltersContainer />
@@ -1,5 +1,6 @@
import {
BookingStatus,
MembershipRole,
Prisma,
PrismaPromise,
WebhookTriggerEvents,
@@ -8,10 +9,10 @@ import {
} from "@prisma/client";
import { NextApiRequest } from "next";
import appStore from "@calcom/app-store";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { DailyLocationType } from "@calcom/app-store/locations";
import { refund } from "@calcom/app-store/stripepayment/lib/server";
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
import { deleteMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
@@ -23,6 +24,7 @@ import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload, { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { HttpError } from "@calcom/lib/http-error";
import { handleRefundError } from "@calcom/lib/payment/handleRefundError";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
@@ -65,6 +67,8 @@ async function handler(req: NextApiRequest & { userId?: number }) {
paid: true,
eventType: {
select: {
owner: true,
teamId: true,
recurringEvent: true,
title: true,
description: true,
@@ -377,7 +381,76 @@ async function handler(req: NextApiRequest & { userId?: number }) {
uid: bookingToDelete.uid ?? "",
destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar,
};
await refund(bookingToDelete, evt);
const successPayment = bookingToDelete.payment.find((payment) => payment.success);
if (!successPayment) {
throw new Error("Cannot reject a booking without a successful payment");
}
let eventTypeOwnerId;
if (bookingToDelete.eventType?.owner) {
eventTypeOwnerId = bookingToDelete.eventType.owner.id;
} else if (bookingToDelete.eventType?.teamId) {
const teamOwner = await prisma.membership.findFirst({
where: {
teamId: bookingToDelete.eventType.teamId,
role: MembershipRole.OWNER,
},
select: {
userId: true,
},
});
eventTypeOwnerId = teamOwner?.userId;
}
if (!eventTypeOwnerId) {
throw new Error("Event Type owner not found for obtaining payment app credentials");
}
const paymentAppCredentials = await prisma.credential.findMany({
where: {
userId: eventTypeOwnerId,
appId: successPayment.appId,
},
select: {
key: true,
appId: true,
app: {
select: {
categories: true,
dirName: true,
},
},
},
});
const paymentAppCredential = paymentAppCredentials.find((credential) => {
return credential.appId === successPayment.appId;
});
if (!paymentAppCredential) {
throw new Error("Payment app credentials not found");
}
// Posible to refactor TODO:
const paymentApp = appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore];
if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) {
console.warn(`payment App service of type ${paymentApp} is not implemented`);
return null;
}
const PaymentService = paymentApp.lib.PaymentService;
const paymentInstance = new PaymentService(paymentAppCredential);
try {
await paymentInstance.refund(successPayment.id);
} catch (error) {
await handleRefundError({
event: evt,
reason: error?.toString() || "unknown",
paymentId: successPayment.externalId,
});
}
await prisma.booking.update({
where: {
id: bookingToDelete.id,

Some files were not shown because too many files have changed in this diff Show More