Compare commits

..
173 Commits
Author SHA1 Message Date
zomars 9842c18d50 v2.1.0 2022-10-17 11:33:43 -06:00
Alex van Andel 68f05ce2c7 Uses utcOffset to display bookings according to current time & timeZone (#5050) 2022-10-17 17:28:57 +00:00
zomars 1aade4fc57 Hotfix: bookings/past browser crash 2022-10-17 11:10:23 -06:00
github-actions[bot]andCrowdin Bot 33d71f2390 New Crowdin translations by Github Action (#5043)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-17 17:50:30 +01:00
Peer RichelsenandHariom Balhara 44ef1f80e9 added QR code app, needs eventType.URL (#4701)
* wip added QR code app, needs settings

* added QR code settings, needs eventType.URL

* Make URL prop available to apps

* Add recurringEvent in available. It was missing earlier

* added autoAnimate to AppSettings

* Remove isSunrise demo prop

* Simplify schema even more

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2022-10-17 16:38:43 +00:00
9e3e1418c2 Fix disabled locations when rescheduling (#5046)
* fix phone number input when attendee phone number is default checked

* add selected location for rescheduling and remove default checked

* don't show locations when rescheduling on availability page

* fix type error

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2022-10-17 15:47:11 +02:00
Jeroen ReumkensandAlex van Andel 0284ffc75f Removed v2 pages directory, moved all pages to root and deleted old v1 pages and middleware redirect. (#5044)
* Removed v2 pages directory, moved all pages to root and deleted old v1 pages and middleware redirect.

* Removed v2 from imports path.

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2022-10-17 18:14:43 +05:30
Hariom Balhara aee1b3b596 Fix RHF data on bg update (#5045) 2022-10-17 11:21:49 +00:00
c1bc0286d1 Additional Notes Required (#4897)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-17 09:43:16 +00:00
48d1e95509 use v2 button (#5042)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-17 09:19:15 +00:00
Alex van Andel d8489a662e Adds a iso8601 helper function to parse a ISO Date String into a Date (#5039) 2022-10-17 09:07:49 +00:00
github-actions[bot]andCrowdin Bot 0fc1449dd9 New Crowdin translations by Github Action (#5037)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-17 09:53:41 +01:00
d5024d1b70 Fixed dropdown width according to items (#4995)
* Fixed dropdown width according to items

* Add the “Add” before the calendar type item

* Fix dropdown cut off on mobile

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2022-10-17 08:46:37 +00:00
Alex van Andelandkodiakhq[bot] b7161694a5 Update to message for invalid method (#5038)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-10-17 06:25:23 +00:00
Peer Richelsen cc7e4b3b33 fixed team URL in settings (#5031) 2022-10-17 11:40:06 +05:30
github-actions[bot]andCrowdin Bot 3b9653df0e New Crowdin translations by Github Action (#5028)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-16 22:30:33 +01:00
Leo Giovanetti 83442caf86 Fixing rescheduled email date (#5035) 2022-10-16 18:46:10 +01:00
Leo Giovanetti e6f912bf0c Fixing reschedule button in success page (#5033) 2022-10-15 19:38:39 -06:00
Carina Wollendorfer e75f200ff7 fix type (#5032) 2022-10-15 19:37:05 -06:00
Hariom Balhara 2d30f673e3 Fixes double bottom border in Integration List Item (#5026)
* Fixes double bottom border in Integration List Item

* Remove unused import
2022-10-15 11:08:01 -06:00
Leo GiovanettiandPeer Richelsen 2e83c7fbf8 Fixing bookings for all calendar-related apps (#5018)
* Fixing bookings for apps

* Missing assigning let var

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-15 11:02:24 -06:00
github-actions[bot]andCrowdin Bot 1b215e4faf New Crowdin translations by Github Action (#5022)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-15 11:15:17 +01:00
49429c87cb New Crowdin translations by Github Action (#5021)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-15 11:05:47 +01:00
cf423dda70 Add AppStore General Settings support (#4998)
* added weather in calendar wrapper

* added textfield to weather app, needs GeneralSettings abstraction

* add automation cateogry for n8n and zapier

* fix border radius for seperated apps

* create app specific settings in installed apps

* Fixed design issues caused by app settings

* add first version of template cards with logos

* Add link to zapier template

* Add missing translations

* fix mobile view

* better readable app description in mobile view

* code clean up

* fix issue that lisItem was always expanded

* code clean up

* code clean up

* fix import

* fix typo

* code clean up

* Add AppStore General Settings support

* add missing alt to img

* use FiShare2 icon instead of FiZap

* Remove duplicate entries

* Add border to AppSettings consistently

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.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>
2022-10-15 10:05:14 +00:00
6ee9de6dd8 Add links to Zapier templates in installed apps (#4982)
* add automation cateogry for n8n and zapier

* fix border radius for seperated apps

* create app specific settings in installed apps

* Fixed design issues caused by app settings

* add first version of template cards with logos

* Add link to zapier template

* Add missing translations

* fix mobile view

* better readable app description in mobile view

* code clean up

* fix issue that lisItem was always expanded

* code clean up

* code clean up

* fix import

* fix typo

* code clean up

* add missing alt to img

* use FiShare2 icon instead of FiZap

* Remove duplicate entries

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2022-10-15 08:05:37 +00:00
alannnc e54bdb4c46 Custom zoom response handler (#5017) 2022-10-14 18:24:57 -06:00
zomars 1455a6f603 Adds url schema to webhooks 2022-10-14 17:41:52 -06:00
zomars 9b980b3b85 Better handling of customInput error messages 2022-10-14 16:45:40 -06:00
github-actions[bot]andCrowdin Bot 6420218cb3 New Crowdin translations by Github Action (#5011)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-14 21:41:38 +01:00
Peer Richelsen f9ca763523 <Tips> in sidebar (#4999) 2022-10-14 19:38:05 +00:00
Joe Au-YeungandPeer Richelsen c4f3dec854 Fix time format on booking page (#5006)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-14 19:15:03 +00:00
zomars ff01da540d Avoids mixing metadata and tsx
Prevents circular dependencies
2022-10-14 12:51:28 -06:00
github-actions[bot]andCrowdin Bot 9021486cd7 New Crowdin translations by Github Action (#5007)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-14 19:41:28 +01:00
zomars 550f98a636 Splits appData schemas to prevent circular dependencies 2022-10-14 12:35:44 -06:00
Peer Richelsen 9a4746844f fixed booking page padding (#5009) 2022-10-14 18:04:54 +00:00
Hariom BalharaandPeer Richelsen 271d4319b9 Introduce EventTypeAppCard in app-store and make it super easy to add it through CLI - Also adds Fathom app (#4727)
* Add OmniInstall button

* Make AppCards configurable by the app itself

* Make OmniInstallAppButton not redirect

* Fixes

* Add extendsFeature support to CLI

* Move to automatic file generation approach as dynamic import checking doesnt work correctly

* Use zod everywhere consistenly for metadata and fix all TS issues

* Fix viewer.eventTypes endpoint. Make prisma base select and _ prefixed models consistent in expecting scalars only

* Remove unnecessary zod parsing of event-types as it is making the scope of the PR huge

* Fix UI TS errors

* wip

* Add zod types support in EventTypeAppCard.tsx

* Fixes during PR review and other failing tests

* Remove unused app

* Fix stripe installation flow

* More fixes

* Fix apps and active apps count

* self review

* Add loading attribute to OmniInsall button

* Handle empty state

* Improve types

* Fix stripe app installation bug

* added fathom app (#4804)

* added fathom app wrapper, needs script injection to public booking page

* new logo

* Add Fathom script support on booking pages and add it as an eventTypeapp

* Add automation and analytics apps

* Add missing pieces for analytics category

* Rename BookingPageScripts to BookingPageTagManager

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* Fix lint error

* Fix runtime error with legayAppData being undefined

* Remove duplicate automation key

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-14 10:24:43 -06:00
Peer Richelsen 215e3c93d6 remove unused workflow (#5000) 2022-10-14 09:33:08 -03:00
Nafees NazikandAlex van Andel 670aed9fe0 fix: add a unique key (#4711)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2022-10-14 12:33:32 +01:00
Hariom BalharaandAlex van Andel 8583d9b2e6 Embed Improvements: Add auto-scroll support back, reduce CLS, remove loader for Routing Form embed (#4819)
* Add back auto-scroll fix with proper check of visibilith

* Do improvement in CLS and dont show "Loading" text

* Improve experience with routing forms embed

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2022-10-14 10:10:54 +00:00
Carina WollendorferandCarinaWolli 88cad2e15f Workflow security fixes (#4996)
* security fix to not add even-types from other users

* throw unauthroized error if a step does not belong to workflow

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2022-10-14 10:52:20 +01:00
1c165bd934 Fix/fallback to calvideo (#4992)
* try/catch on createMeeting zoom

* add handle response body from zoom

* db invalid credential flag

* Update VideoApiAdapter.ts

* Added notes and try/catches

* Remove double response error validation

* Fallback to calvideo

* Fix types for credentials

* Add nullable to invalid field from credential table

* Fix calendar service credential creation with bad type

* revert db migration invalid on credential table

* Revert "Fix types for credentials"

This reverts commit 1623305aea23a33c39ba7b6fb5604dd71c6205d7.

* Revert "Fix calendar service credential creation with bad type"

This reverts commit 3fd3b08f2ee6c7019428a8ff454d371e23759ad7.

* Remove migration

* Fix for property name for api key on dailyvideo

* remove unused prop from credential

* Revert "Fix for property name for api key on dailyvideo"

This reverts commit 32976934e05e6923210718ccd5634c1357828f65.

* Tests fixes

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2022-10-14 02:36:43 +00:00
zomars 74a16e28f4 e2e now needs daily as fallback 2022-10-13 20:26:04 -06:00
github-actions[bot]andCrowdin Bot 3e417330b5 New Crowdin translations by Github Action (#4988)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-13 17:50:59 +01:00
Joe Au-YeungandPeer Richelsen 3af31860bb Close menu and prevent overscroll (#4739)
* Close menu and prevent overscroll

* Update Shell.tsx

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-13 09:50:08 -06:00
Carina WollendorferandCarinaWolli f0aded92db Add skeleton loader to offer seats (#4987)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2022-10-13 13:33:41 +01:00
Alex van Andel a28325e69a Adjust booking list startTime to the timeZone of the logged in user (#4984) 2022-10-13 12:01:11 +01:00
github-actions[bot]andCrowdin Bot f949cf1bd1 New Crowdin translations by Github Action (#4977)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-13 10:36:15 +01:00
zomars ba31c9dcb5 GitHub Workflow: when label: “help wanted” add to Project 2022-10-12 10:33:39 -06:00
Omar LópezandAlex van Andel e5af99f572 Moved booking logic to calcom/features (#4960)
* Moved booking logic to calcom/features

Needed to reuse on public API

* Type fixes

* Cleanup

* Cleanup

* Cleanup

* Restored still needed V1 pages

This reverts commit 790a304a6aa3b9e16525ea3b015bef773f36ded1.

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2022-10-12 13:04:51 +00:00
Peer Richelsen c40ff2e6bb added new license required /signup page (#4966) 2022-10-12 13:52:59 +01:00
Carina Wollendorfer b1656c7900 don't count unconfirmed recurring bookings that are in the past (#4968) 2022-10-12 11:08:30 +01:00
a9b18496e0 refactor: left booking information box component (#4251)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Jeroen Reumkens <hello@jeroenreumkens.nl>
2022-10-12 08:39:14 +00:00
github-actions[bot]andCrowdin Bot d35ad510c9 New Crowdin translations by Github Action (#4955)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-12 09:28:17 +01:00
+9 6e834aa8eb Delete Dialog once closed, makes the entire app unresponsive. (#4906)
Co-authored-by: gitstart <gitstart@users.noreply.github.com>
Co-authored-by: Thiago Nascimbeni <tnascimbeni@gmail.com>
Co-authored-by: Rafael Toledo <87545086+Toledodev@users.noreply.github.com>
Co-authored-by: RubensRafael <rubensrafael2@live.com>
Co-authored-by: gitstart <gitstart@gitstart.com>
Co-authored-by: Rafael <rafael.toledo@engenharia.ufjf.br>
Co-authored-by: Grace Nshokano <grace.devolop@gmail.com>
Co-authored-by: MuriloAmarals <muralha2000@gmail.com>
Co-authored-by: Murilo Amaral <87545137+MuriloAmarals@users.noreply.github.com>
Co-authored-by: Júlio Piubello da Silva Cabral <julio.piubello@gitstart.dev>
Co-authored-by: Matheus Benini Ferreira <88898100+MatheusBeniniF@users.noreply.github.com>
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev>
Co-authored-by: C000Ldude <coolmagnas@gmail.com>
Co-authored-by: Matheus Muniz <87545749+matheusmuniz03@users.noreply.github.com>
Co-authored-by: Klinger Matheus <50892465+KlingerMatheus@users.noreply.github.com>
Co-authored-by: Eman <emmanuelgatwech@gmail.com>
Co-authored-by: Olusanya Timothy <48022904+seunexplicit@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-10-12 12:55:37 +05:30
5293db29d9 Feat Booking Limits (#4759)
* Add db relevant stuff

* Basic UI there still buggy

* This UI is hard - some progress

* Fix awful state mangament

* Fix re-ordering

* Working UI logic!

* Partical working minMax function

* Fix min max

* bookingLImits api + tests

* Moved checkBookingLimits to backend only code

* Fix httperror import

* Return busy times

* Remove avaliablity calc

* Working for everything but year

* Remove redundant + fix async forloop

* Add compatible type

* Future proof with evenTypeId filter

* Fix commonjson

* Sorting + validation + tests + passing

* Add empty test

* Move validation check  to backend

* Add bookinglimits in trpc

* Add test for undefined

* Apply suggestions from code review

Co-authored-by: Jeroen Reumkens <hello@jeroenreumkens.nl>

* Update apps/web/components/v2/eventtype/EventLimitsTab.tsx

Co-authored-by: Jeroen Reumkens <hello@jeroenreumkens.nl>

* Rename value for eligiability

* Rename keyof type

* status code

* Fix toggle not toggling off

* Update apps/web/pages/v2/event-types/[type]/index.tsx

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

* Update apps/web/pages/v2/event-types/[type]/index.tsx

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

* Change back to undefined as it is working for sean. See if it fails on testapp

* Fixing test builder

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Jeroen Reumkens <hello@jeroenreumkens.nl>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
2022-10-12 05:29:04 +00:00
Alex van Andel e772b4203e Unlicensed should return false, not error 2022-10-12 02:40:52 +01:00
Alex van Andel 6cc8fcae52 Handle double quotes situation in checkLicense function (#4961) 2022-10-11 19:34:27 -06:00
Hariom Balhara 7e5c686e26 Switch to using tRPC as router.push weirdly doesnt refetch when using middleware and that too after build (#4954) 2022-10-11 15:52:18 +00:00
2c6c768145 New Crowdin translations by Github Action (#4949)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-11 16:32:52 +01:00
Peer Richelsen a5fee47989 fixed n8n link (#4953) 2022-10-11 16:29:52 +01:00
github-actions[bot]andCrowdin Bot bceeee70e0 New Crowdin translations by Github Action (#4940)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-11 15:17:42 +01:00
zomars baecd923af Makes title and uid non-empty for booking references 2022-10-10 21:33:50 -06:00
zomars 1fe1130af1 If a team is deleted, it cascades to memberships 2022-10-10 19:24:46 -06:00
zomars 49805054ba Updates deprecated nonempty 2022-10-10 18:28:48 -06:00
zomars e2362d8f2a Makes team names and slug required 2022-10-10 18:28:48 -06:00
Peer Richelsen 9453c1fc3a return null if impersonating (#4939) 2022-10-10 17:39:15 -06:00
47e496fded Remove <Loader/> from app setup pages and team user availability view (#4931)
* remove loader from setup screens + zapier loading

* remove loader from team user availability

* create skeleton loader

* code clean up

* code clean up

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-10 17:31:16 -06:00
zomars 0289e727ad sync monorepo packages 2022-10-10 17:22:52 -06:00
zomars 04b5125121 Sync submodules 2022-10-10 17:22:36 -06:00
zomars 368d1afe27 v2.0.5 2022-10-10 17:01:53 -06:00
zomars 2069fd7248 Update settings.json 2022-10-10 16:27:31 -06:00
7ed21b692e #4306: Added darkmode support for select by styling via tailwind (#4824)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-10-10 18:50:43 +00:00
4fcdc7bf5c added badge for unconfirmed bookings (#4912)
* added badge for unconfirmed bookings

* added link to unconfirmed bookings

* added rounded prop to badge, added unconfirmed badge to botto nav

* Supporting recurring events + bug fix

* Tooltip and hover style

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
2022-10-10 17:00:09 +00:00
2a0a293f8c Add new workflow action: Send email to specific email address (#4929)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-10 13:40:20 +00:00
1b15b71a24 Feat/improve animation on public booking page (#4848)
* Improve responsive layout for timezone select on booking page.

* Close TZ dropdown on booking page when it's hidden for mobile/desktop, this prevents having a dropdown menu floating around.

* Improved animation for showing timeslots on booking page.

* Changed back button on booking page to sm+ to md+ like it was before.

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-10-10 13:24:06 +00:00
github-actions[bot]andCrowdin Bot 5d9889a90c New Crowdin translations by Github Action (#4927)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-10 13:42:19 +01:00
Peer Richelsen a7ee3366eb Update CONTRIBUTING.md 2022-10-10 09:31:33 +01:00
c68e7595fe New Crowdin translations by Github Action (#4923)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-10 09:28:18 +01:00
Herbert Mühlburger 3f6be3b2c0 correct german spelling (#4925) 2022-10-10 09:27:44 +01:00
Hariom Balhara f1647248a2 PaymentPage: Fix localStorage access issue in incognito (#4922)
* localStorage isnt available at server and in incognito mode

* Do translation server side for Payment Page
2022-10-10 08:11:47 +00:00
Leo Giovanettiandkodiakhq[bot] d7db1ad303 Hiding event type loses recurring config (#4911)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-10-09 12:36:39 +00:00
Udit Takkar bd48fd01b1 fix: remove hr add spacing, fix link in env file (#4916) 2022-10-09 11:08:47 +00:00
github-actions[bot]andCrowdin Bot fabf692a08 New Crowdin translations by Github Action (#4904)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-08 18:10:55 +01:00
Alex van Andel ab62fc5d47 Bugfix/dst available hour mismatch (#4909)
* Need to use local time, not inviteeDate

* Use selected timeZone to perform localWorkingHours logic

* Adding comment

* More comments
2022-10-07 16:10:48 -06:00
zomars 0c4cb2e2b0 Added denullish utils for zod
Needed for API refactoring
2022-10-07 14:48:41 -06:00
Carina WollendorferandCarinaWolli 7d77132102 Add 'after event ends' trigger to workflows (#4882)
* add after event ends workflow trigger to enum

* add after meetings ends trigger

* fix label for time span

* add deleted label for message template

* add lost changes from merge conflict

* set reminder for already existing bookings

* fix label 'How long before event starts/ends'

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2022-10-07 12:18:28 -06:00
Joe Au-Yeung 50ac5f2892 Fix MS Teams missing refresh token (#4910) 2022-10-07 13:59:58 -04:00
Peer Richelsen fd12fcc4b8 v2 for help dialog (#4901) 2022-10-07 13:21:49 +01:00
+2 58453d0f2f /event-types/detail wrong empty screen for workflows (#4757)
Co-authored-by: gitstart <gitstart@gitstart.com>
Co-authored-by: gitstart <gitstart@users.noreply.github.com>
Co-authored-by: Rafael Toledo <87545086+Toledodev@users.noreply.github.com>
Co-authored-by: Matheus Muniz <matheusmuniz100@hotmail.com>
Co-authored-by: Matheus Muniz <87545749+matheusmuniz03@users.noreply.github.com>
Co-authored-by: Thiago Nascimbeni <tnascimbeni@gmail.com>
Co-authored-by: Klinger Matheus <50892465+KlingerMatheus@users.noreply.github.com>
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev>
Co-authored-by: Grace Nshokano <grace.devolop@gmail.com>
Co-authored-by: Olusanya Timothy <48022904+seunexplicit@users.noreply.github.com>

Co-authored-by: gitstart <gitstart@users.noreply.github.com>
Co-authored-by: gitstart <gitstart@gitstart.com>
Co-authored-by: Rafael Toledo <87545086+Toledodev@users.noreply.github.com>
Co-authored-by: Matheus Muniz <matheusmuniz100@hotmail.com>
Co-authored-by: Matheus Muniz <87545749+matheusmuniz03@users.noreply.github.com>
Co-authored-by: Thiago Nascimbeni <tnascimbeni@gmail.com>
Co-authored-by: Klinger Matheus <50892465+KlingerMatheus@users.noreply.github.com>
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev>
Co-authored-by: Grace Nshokano <grace.devolop@gmail.com>
Co-authored-by: Olusanya Timothy <48022904+seunexplicit@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2022-10-07 09:25:30 +00:00
Hariom Balhara 44fd1b9fef Improve default app README.mdex (#4893) 2022-10-07 07:29:09 +00:00
Leo Giovanetti 0098591a56 Applying feedback from a merged PR (#4887) 2022-10-06 17:38:50 -03:00
Leo Giovanetti 41c1e960f8 Adjusting jest and expanding test builders (#4837)
* Adjusting jest and expanding test builders

* Removing unneeded jest config
2022-10-06 13:29:20 -06:00
Leo Giovanetti 4e49d32c61 Fixing unconfirmed/recurring bookings (#4881) 2022-10-06 13:23:22 -06:00
+9 0b29c5063a Wrong css in event configuration page buttons (#4869)
Co-authored-by: gitstart <gitstart@users.noreply.github.com>
Co-authored-by: gitstart <gitstart@gitstart.com>
Co-authored-by: Rafael <rafael.toledo@engenharia.ufjf.br>
Co-authored-by: Thiago Nascimbeni <tnascimbeni@gmail.com>
Co-authored-by: Rafael Toledo <87545086+Toledodev@users.noreply.github.com>
Co-authored-by: Grace Nshokano <grace.devolop@gmail.com>
Co-authored-by: Júlio Piubello da Silva Cabral <julio.piubello@gitstart.dev>
Co-authored-by: Matheus Benini Ferreira <88898100+MatheusBeniniF@users.noreply.github.com>
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev>
Co-authored-by: C000Ldude <coolmagnas@gmail.com>
Co-authored-by: Matheus Muniz <87545749+matheusmuniz03@users.noreply.github.com>
Co-authored-by: Klinger Matheus <50892465+KlingerMatheus@users.noreply.github.com>
Co-authored-by: Eman <emmanuelgatwech@gmail.com>
Co-authored-by: Olusanya Timothy <48022904+seunexplicit@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Omar López <zomars@me.com>
2022-10-06 12:16:32 -06:00
zomars 95a92567cb Update change-username.e2e.ts 2022-10-06 12:15:14 -06:00
+7 9e183e6ad1 Remove black ring on focus when copying private link (#4883)
Co-authored-by: gitstart <gitstart@users.noreply.github.com>
Co-authored-by: Thiago Nascimbeni <tnascimbeni@gmail.com>
Co-authored-by: Rafael Toledo <87545086+Toledodev@users.noreply.github.com>
Co-authored-by: Grace Nshokano <grace.devolop@gmail.com>
Co-authored-by: MuriloAmarals <muralha2000@gmail.com>
Co-authored-by: Murilo Amaral <87545137+MuriloAmarals@users.noreply.github.com>
Co-authored-by: Júlio Piubello da Silva Cabral <julio.piubello@gitstart.dev>
Co-authored-by: Matheus Benini Ferreira <88898100+MatheusBeniniF@users.noreply.github.com>
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev>
Co-authored-by: C000Ldude <coolmagnas@gmail.com>
Co-authored-by: gitstart <gitstart@gitstart.com>
Co-authored-by: Matheus Muniz <87545749+matheusmuniz03@users.noreply.github.com>
Co-authored-by: Klinger Matheus <50892465+KlingerMatheus@users.noreply.github.com>
Co-authored-by: Eman <emmanuelgatwech@gmail.com>
Co-authored-by: Olusanya Timothy <48022904+seunexplicit@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-06 15:18:16 +00:00
Peer Richelsen 7900f8bab5 fixes to booking page (#4876) 2022-10-06 14:33:57 +00:00
Hariom Balharaandkodiakhq[bot] 50904d2588 useForm defaultValues should be available initially (#4863)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-10-06 13:37:52 +00:00
github-actions[bot]andCrowdin Bot 6f811f4f0a New Crowdin translations by Github Action (#4877)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-06 14:14:10 +01:00
sean-brydonandBailey Pumfleet d9000d0cbe Fix height on trash (#4875)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2022-10-06 13:33:51 +01:00
sean-brydonandBailey Pumfleet ff78e66051 Add successful booking redirect URL to booking page (#4871)
* Add redirect URL field and switch

* Remove and add new translation for description

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2022-10-06 13:32:11 +01:00
Hariom Balharaandkodiakhq[bot] 7c30f8ca4b Routing Forms Type nigration (#4861)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-10-06 12:21:04 +00:00
Jeroen Reumkens 0ebce77a7b #4851: Always take timeformat on booking page from profile if user is logged in. Also hides timeformat toggle on booking page for logged in users. For non loggedin users we still infer by looking at browser timezone, after that we set a localstorage which we will look at, and which will get updated when the user updates the toggle in the timezone dropdown. (#4865) 2022-10-06 10:25:41 +00:00
sean-brydon 4282adac4b Fix text wrap (#4867) 2022-10-06 10:57:57 +01:00
Nafees Nazikandkodiakhq[bot] ef611e2360 fix: fixes multiple issues in routing form (#4676)
* feat: update forms procedure to match getServerSideProps

* feat: init react query and use prefetched data

* feat: invalidate onSettled while creating a form

* feat: use optimistic updates when deleting

* fix: add optional chaining

* feat: add server-side translation

* feat: add correct translation key

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-10-06 06:49:03 +00:00
github-actions[bot]andCrowdin Bot 10ab86b332 New Crowdin translations by Github Action (#4772)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-10-05 20:23:28 +01:00
Peer Richelsen f04cfef0af reduced padding top on mobile onboarding (#4856) 2022-10-05 18:46:20 +00:00
Alex van Andel 50151403c4 v2.0.4 2022-10-05 16:27:01 +01:00
Peer RichelsenandAlex van Andel 7aa131f349 added TimezoneChangeDialog (#4818)
* nit

* added timezoneChangeDialog

* prevent interactOutside

* nit

* fixed title prop clash

* nit

* remind in one day if clicked outside

* Use utcOffset instead of tz string for checking diff

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2022-10-05 14:33:29 +00:00
Jeroen Reumkens d88bdc368b Ensure we have to correct tabindexes for addons. Before addons AFTER the input still would get tab focus before the input. (#4849) 2022-10-05 14:22:06 +00:00
Alex van Andel 255938fda0 Remove 'avatar' from query (#4846) 2022-10-05 13:37:42 +00:00
Jeroen ReumkensandPeer Richelsen 5766d6f5f4 Improve responsive layout of timezone select on booking page (#4839)
* Improve responsive layout for timezone select on booking page.

* Close TZ dropdown on booking page when it's hidden for mobile/desktop, this prevents having a dropdown menu floating around.

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-05 13:11:26 +00:00
Peer Richelsen 9d299c6d5f fixed event type z-index (#4844) 2022-10-05 12:58:59 +00:00
f49783b453 Bugfix/reinstate tab name with fixed test (#4842)
* added ?tabName=setup to event-type detail link to give it active status (#4830)

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>

* Use pathname instead of href directly

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-10-05 11:55:11 +00:00
Jeroen Reumkens 246fce3ab8 #3785: Converted billing settings page to v2. (#4795) 2022-10-04 22:54:15 +00:00
zomars 82f262f60d Quickfix for settings layout on mobile 2022-10-04 15:24:59 -05:00
zomars da8d2da368 v2.0.3 2022-10-04 14:46:51 -05:00
alannncandOmar López f01058e073 fix/error-while-confirm-booking (#4834)
* Add try/catch on workflows while confirming a booking

* Revert "added ?tabName=setup to event-type detail link to give it active status (#4830)"

This reverts commit 11c44db609.

Co-authored-by: Omar López <zomars@me.com>
2022-10-04 14:46:17 -05:00
zomars 8e369d3fb5 Submodule sync 2022-10-04 13:10:16 -05:00
Peer Richelsenandkodiakhq[bot] 11c44db609 added ?tabName=setup to event-type detail link to give it active status (#4830)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-10-04 17:03:31 +01:00
Peer RichelsenandAlex van Andel 9d911cbb59 fixed dropdown in safari (#4828)
* fixed dropdown in safari

* nit

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2022-10-04 15:50:15 +00:00
Alex van Andel 30a887df6f Some tweaks to the ui - use button - use flex justify-between (#4826) 2022-10-04 15:37:20 +00:00
62fea25eb9 fix/connected calendar list different states (#4821)
* Display error if trpc query failed, catch error on getting calendar and cleanup to don't leak credentials to client

* Add calendar connection broke error

* Edit style on alert and disconnecting button

* Ts errors and added translation

* Type fix

* Key and text change for broken calendar connection

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2022-10-04 14:56:38 +00:00
Alex van Andel d43d8700d0 Fixes global console error (#4825) 2022-10-04 14:01:32 +00:00
Peer Richelsen e7ed20ea27 fixed border on dark mode success (#4823) 2022-10-04 13:43:49 +01:00
Peer RichelsenandBailey Pumfleet 5e12b5defe new button in logout (#4816)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2022-10-04 09:31:13 +00:00
Joe Au-YeungandBailey Pumfleet d241a522fb Add placeholder (#4803)
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2022-10-04 10:20:21 +01:00
+2 ae929f27b0 /workflows /detail-view - unusual scroll behaviour (#4806)
Co-authored-by: gitstart <gitstart@users.noreply.github.com>
Co-authored-by: Júlio Piubello da Silva Cabral <julio.piubello@gitstart.dev>
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev>
Co-authored-by: C000Ldude <coolmagnas@gmail.com>
Co-authored-by: Matheus Muniz <87545749+matheusmuniz03@users.noreply.github.com>
Co-authored-by: Klinger Matheus <50892465+KlingerMatheus@users.noreply.github.com>
Co-authored-by: Grace Nshokano <grace.devolop@gmail.com>
Co-authored-by: Olusanya Timothy <48022904+seunexplicit@users.noreply.github.com>
Co-authored-by: Thiago Nascimbeni <tnascimbeni@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2022-10-04 09:17:30 +00:00
Syed Ali Shahbaz 369d7c4e28 adds custom filter allowing eml format (#4815) 2022-10-04 10:02:28 +01:00
Jeroen Reumkens 4438ca2a63 Improve password peek positioning accross app and website. (#4810) 2022-10-03 19:52:57 +00:00
Hariom Balhara 75589db569 Hotfix: Fix Broken webhooks (#4807) 2022-10-04 00:33:47 +05:30
Alex van Andel 3752ac767a Remove eye for todays release (#4802) 2022-10-03 17:08:17 +01:00
+3 f3e2cf5075 Onboarding, input validation error isn't positioned correctly (#4758)
Co-authored-by: gitstart <gitstart@users.noreply.github.com>
Co-authored-by: gitstart <gitstart@gitstart.com>
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev>
Co-authored-by: Matheus Muniz <87545749+matheusmuniz03@users.noreply.github.com>
Co-authored-by: Klinger Matheus <50892465+KlingerMatheus@users.noreply.github.com>
Co-authored-by: Eman <emmanuelgatwech@gmail.com>
Co-authored-by: Grace Nshokano <grace.devolop@gmail.com>
Co-authored-by: Olusanya Timothy <48022904+seunexplicit@users.noreply.github.com>
Co-authored-by: Thiago Nascimbeni <tnascimbeni@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-10-01 21:01:41 +00:00
Peer Richelsen 92a29593aa Update README.md 2022-10-01 21:47:34 +01:00
Peer Richelsen 367c5d89cc removed capitalize from headlines (#4790) 2022-10-01 11:50:27 +01:00
f21f36547c /event-types /team-detail /scheduling type - double divider under team members (#4485)
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev>
Co-authored-by: gitstart <gitstart@users.noreply.github.com>
Co-authored-by: Matheus Muniz <87545749+matheusmuniz03@users.noreply.github.com>
Co-authored-by: Klinger Matheus <50892465+KlingerMatheus@users.noreply.github.com>
Co-authored-by: hustle-ps <grace.devolop@gmail.com>
Co-authored-by: gitstart <gitstart@gitstart.com>
Co-authored-by: Olusanya Timothy <48022904+seunexplicit@users.noreply.github.com>
Co-authored-by: Thiago Nascimbeni <tnascimbeni@gmail.com>

Co-authored-by: gitstart <gitstart@users.noreply.github.com>
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev>
Co-authored-by: Matheus Muniz <87545749+matheusmuniz03@users.noreply.github.com>
Co-authored-by: Klinger Matheus <50892465+KlingerMatheus@users.noreply.github.com>
Co-authored-by: hustle-ps <grace.devolop@gmail.com>
Co-authored-by: gitstart <gitstart@gitstart.com>
Co-authored-by: Olusanya Timothy <48022904+seunexplicit@users.noreply.github.com>
Co-authored-by: Thiago Nascimbeni <tnascimbeni@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-10-01 02:59:20 +00:00
Joe Au-Yeung d3f8d954e7 Update Close.com info (#4777) 2022-09-30 20:22:47 +01:00
27372d624d add skeleton loader for workflows in event type settings (#4774)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-09-30 19:39:23 +01:00
a5d2f2444a Fixes 'How to use Additional Inputs as Variables' dialog in workflows (#4773)
* add i18n translations and fix dialog width and height

* fix typo

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
2022-09-30 14:41:12 +00:00
6d45e1c7c7 #4533: Show correct time format on booking page (#4771)
* #4533: Store timeformat preferences in localstorage when changing profile. This way you see the correct format on the booking page. Also abstracted the reuse of the localstorage key for timeformat into a small util function, to prevent typos in the future.

* Update apps/web/components/booking/pages/AvailabilityPage.tsx

* 4533: Changed 24hfromlocalstorage values from strings to real booleans for better DX.

* Update apps/web/lib/clock.ts

Co-authored-by: alannnc <alannnc@gmail.com>

* Make code shorter by only handling remaining cases

Co-authored-by: alannnc <alannnc@gmail.com>

* Remove unnecessary boolean check

Co-authored-by: alannnc <alannnc@gmail.com>

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: alannnc <alannnc@gmail.com>
2022-09-30 14:45:28 +02:00
48419469a6 #4445: Fix to prevent bookings with long titles or a lot of content from overflowing on the page. (#4769)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2022-09-30 11:47:20 +01:00
Joe Au-Yeung 045ebb6e01 Fix RadixUI dropdown 1.0.0 (#4767)
* Add portal part

* Add portal part to pages

* Add portal part to pages
2022-09-30 12:29:22 +02:00
Jeroen Reumkens c78ea5de0b Added 100vh fix for ios to make sure it always fills the screen height. By default 100vh on ios sometimes will and sometimes won't take into account the address bar height, causing on eg the event types or bookings pages the content to be behind our bottom nav. (#4770) 2022-09-30 10:12:22 +01:00
github-actions[bot]andCrowdin Bot d1a787315c New Crowdin translations by Github Action (#4762)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-09-29 19:23:43 +01:00
ce1f1f3c47 Upgrade to Radix 1.0 (#4746)
* Change package.json version and yarn lock

* Add popover

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-09-29 18:22:07 +01:00
2f8bd3c07a add trpc v10 (#4683)
* revert me later

* let's see if this builds

* fix dupe proc

* fix: v10 works

* fix type error

* fix type error

* fix type errors

* fix more

* add example procedure

* spreading not needed

* Update yarn.lock

* Revert "revert me later"

This reverts commit 0c8c15d0577e0c287223039c7b6958b2b0c12c69.

Co-authored-by: Chris Bautista <chrisbautista@netflix.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2022-09-29 16:58:29 +00:00
Omar LópezandAlex van Andel d2f27a0463 Fixes for advanced tabs form logic (#4731)
* Fixes for advanced tabs form logic

* Typefix

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2022-09-29 16:46:47 +00:00
Jeroen Reumkensandkodiakhq[bot] 9134ad8c5e Improved team layout when blocks have unequal content length. (#4763)
* Improved team layout when blocks have unequal content length.

* Removed unused translation in members component.

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-09-29 16:34:01 +00:00
Alex van Andel 506036e751 Fixes few i18n related bugs on change password (#4755)
* Fixes few i18n related bugs

* Use PasswordField
2022-09-29 16:19:03 +00:00
5e80f5056d Ensure currentUsername is set before comparing it with username (#4750)
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Alan <alannnc@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-09-29 17:05:01 +01:00
9304ca1415 Fix long url and show password (#4738)
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-09-29 11:40:37 -03:00
github-actions[bot]andCrowdin Bot fb246fdee6 New Crowdin translations by Github Action (#4754)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-09-29 14:31:30 +01:00
7b9e77f43a Fixing border in installed apps (#4745)
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-09-29 13:26:36 +00:00
47df21322d Fixes translation issue of dynamic variables in workflows (#4734)
* replace all underscores for variable translations

* use g instead of replaceAll

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2022-09-29 15:07:22 +02:00
6017806763 Fix connected calendar list (#4743)
* Fix connected calendar list

* Fixed invalid params in setDestinationCalendar

* Attempt to fix tests by changing viewport size, bug on /booking pages

* Fixes BookingListItem + another user.id param removed

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2022-09-29 12:58:53 +00:00
Alex van Andel b347f29d6c Feature/deprecate title translations (#4682)
* wip

* Aims to deprecate _title keys

* Removed _title translation

* Revert "wip"

This reverts commit 3e33fc6b1ebc064ddfacd8ebc2b63b2ba009d416.
2022-09-29 12:41:40 +00:00
zomars c26cf74aec Revert "Increase timeouts (#4716)"
This reverts commit 145fb09b73.
2022-09-29 14:23:52 +02:00
zomars 760ed74a11 Fixes broken E2E tests
Introduced in #4657
2022-09-29 14:19:04 +02:00
Hariom BalharaandAlex van Andel 145fb09b73 Increase timeouts (#4716)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2022-09-29 10:06:57 +00:00
Peer Richelsen dd14e827e1 added cancel button to team creation (#4756)
* added cancel button

* nit

* nit
2022-09-29 10:47:51 +01:00
246c90c10f public page: border and padding (#4730)
Co-authored-by: gitstart <gitstart@users.noreply.github.com>
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev>
Co-authored-by: Klinger Matheus <50892465+KlingerMatheus@users.noreply.github.com>
Co-authored-by: Klinger Matheus <klinger.matheus@gitstart.dev>
Co-authored-by: Grace Nshokano <grace.devolop@gmail.com>
Co-authored-by: Matheus Muniz <87545749+matheusmuniz03@users.noreply.github.com>
Co-authored-by: Olusanya Timothy <48022904+seunexplicit@users.noreply.github.com>
Co-authored-by: Thiago Nascimbeni <tnascimbeni@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-09-29 10:24:28 +01:00
Alexey Pavlov a42f703c1d fix: calendar spacings for mobile on availability page (#4728) 2022-09-29 10:21:52 +01:00
Leo Giovanetti 7256dbf84f Fix updating slug (#4747) 2022-09-28 23:07:55 +01:00
github-actions[bot]andCrowdin Bot f5faebad67 New Crowdin translations by Github Action (#4744)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-09-28 22:19:00 +01:00
+3 be15affa5d fix padding (#4673)
Co-authored-by: gitstart <gitstart@users.noreply.github.com>
Co-authored-by: gitstart <gitstart@gitstart.com>
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev>
Co-authored-by: Rafael Toledo <87545086+Toledodev@users.noreply.github.com>
Co-authored-by: Matheus Muniz <matheusmuniz100@hotmail.com>
Co-authored-by: Grace Nshokano <grace.devolop@gmail.com>
Co-authored-by: Matheus Muniz <87545749+matheusmuniz03@users.noreply.github.com>
Co-authored-by: Olusanya Timothy <48022904+seunexplicit@users.noreply.github.com>
Co-authored-by: Thiago Nascimbeni <tnascimbeni@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-09-28 22:02:07 +01:00
58dd5308ac Improved load state (#4696)
* Improved load state

* Don't use editable heading if it is readonly

* Fix ts errors

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-09-28 19:05:28 +01:00
14a6c5a03f feat: add transition in toasts (#4658)
* feat: add transition in toasts

* feat: toast animation improved

* fix: type check in toast

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-09-28 19:01:19 +01:00
5af20f5a7b New Crowdin translations by Github Action (#4742)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-09-28 18:57:58 +01:00
Peer Richelsen 8478363a23 minor layout fixes (#4741)
* fixed back button, fixed app store detail on mobile

* fixed settings padding and go back button

* Update packages/ui/v2/core/Shell.tsx

* Update packages/ui/v2/core/navigation/tabs/VerticalTabItem.tsx
2022-09-28 18:56:59 +01:00
19b3157c94 New Crowdin translations by Github Action (#4729)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-09-28 18:51:19 +01:00
Peer Richelsen d4dd451c26 Update packages/ui/v2/core/Shell.tsx 2022-09-28 18:49:23 +01:00
Peer Richelsen 6c046f6507 fixed back button, fixed app store detail on mobile (#4737) 2022-09-28 18:46:14 +01:00
Peer Richelsen a792d31939 Update package.json 2022-09-28 17:13:04 +01:00
388 changed files with 9728 additions and 12180 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ LARK_OPEN_VERIFICATION_TOKEN=""
# - WEB3
# Used for the Web3 plugin
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/web3/README.md
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/rainbow/README.md
ALCHEMY_API_KEY=""
INFURA_API_KEY=""
# *********************************************************************************************************
-16
View File
@@ -1,16 +0,0 @@
name: Add PRs to project
on:
pull_request:
types:
- opened
jobs:
add-PR-to-project:
name: Add PR to project
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@v0.1.0
with:
project-url: https://github.com/orgs/calcom/projects/1
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
+2 -2
View File
@@ -17,6 +17,6 @@ jobs:
steps:
- uses: actions/add-to-project@v0.1.0
with:
project-url: https://github.com/orgs/calcom/projects/1
project-url: https://github.com/orgs/calcom/projects/9
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
labeled: 🐛 bug, 🎨 needs design, 🙋🏻‍♂️help wanted, 🚨 urgent
labeled: 🙋🏻‍♂️help wanted
+1
View File
@@ -25,6 +25,7 @@ jobs:
GOOGLE_LOGIN_ENABLED: true
# CRON_API_KEY: xxx
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
+1 -1
View File
@@ -6,5 +6,5 @@
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"spellright.language": ["en"],
"spellright.documentTypes": ["markdown"]
"spellright.documentTypes": ["markdown", "typescript"]
}
-17
View File
@@ -4,23 +4,6 @@ Contributions are what make the open source community such an amazing place to l
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
## Areas of expertise
### Legend
✅ = has knowledge
🥇 = is their main priority
⚠️ = is the only one with knowledge
👀 = has no knowledge but wants to be onboarded
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://dynamic-svgs.vercel.app/image.svg?dark">
<img alt="Areas of expertise table" src="https://dynamic-svgs.vercel.app/image.svg">
</picture>
## Developing
The development branch is `main`. This is the branch that all pull
+1 -1
View File
@@ -31,7 +31,7 @@
<a href="https://news.ycombinator.com/item?id=26817795"><img src="https://img.shields.io/badge/Hacker%20News-311-%23FF6600" alt="Hacker News"></a>
<a href="https://github.com/calcom/cal.com/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
<a href="https://github.com/calcom/cal.com/pulse"><img src="https://img.shields.io/github/commit-activity/m/calcom/cal.com" alt="Commits-per-month"></a>
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-%2412%2Fmonth-brightgreen" alt="Pricing"></a>
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-Free-brightgreen" alt="Pricing"></a>
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
<img src="https://api.checklyhq.com/v1/badges/checks/5e048048-1b51-47ba-9209-60607507622e?responseTime=true" alt="Checkly Availability" />
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
+9 -8
View File
@@ -15,14 +15,15 @@
"dependencies": {
"@calcom/dayjs": "*",
"@calcom/ui": "*",
"@radix-ui/react-avatar": "^0.1.4",
"@radix-ui/react-collapsible": "^0.1.0",
"@radix-ui/react-dialog": "^0.1.0",
"@radix-ui/react-dropdown-menu": "^0.1.1",
"@radix-ui/react-id": "^0.1.0",
"@radix-ui/react-radio-group": "^0.1.1",
"@radix-ui/react-slider": "^0.1.1",
"@radix-ui/react-switch": "^0.1.1",
"@radix-ui/react-avatar": "^1.0.0",
"@radix-ui/react-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-dropdown-menu": "^1.0.0",
"@radix-ui/react-id": "^1.0.0",
"@radix-ui/react-popover": "^1.0.0",
"@radix-ui/react-radio-group": "^1.0.0",
"@radix-ui/react-slider": "^1.0.0",
"@radix-ui/react-switch": "^1.0.0",
"@radix-ui/react-tooltip": "^1.0.0",
"next": "^12.2.5",
"next-transpile-modules": "^9.0.0",
+5 -5
View File
@@ -11,14 +11,14 @@ export type GateState = {
type GateProps = {
children: React.ReactNode;
gates: Gate[];
metadata: JSONObject;
appData: JSONObject;
dispatch: Dispatch<Partial<GateState>>;
};
const RainbowGate = dynamic(() => import("@calcom/app-store/rainbow/components/RainbowKit"));
// To add a new Gate just add the gate logic to the switch statement
const Gates: React.FC<GateProps> = ({ children, gates, metadata, dispatch }) => {
const Gates: React.FC<GateProps> = ({ children, gates, appData, dispatch }) => {
const [rainbowToken, setRainbowToken] = useState<string>();
useEffect(() => {
@@ -31,12 +31,12 @@ const Gates: React.FC<GateProps> = ({ children, gates, metadata, dispatch }) =>
for (const gate of gates) {
switch (gate) {
case "rainbow":
if (metadata.blockchainId && metadata.smartContractAddress && !rainbowToken) {
if (appData.blockchainId && appData.smartContractAddress && !rainbowToken) {
gateWrappers = (
<RainbowGate
setToken={setRainbowToken}
chainId={metadata.blockchainId as number}
tokenAddress={metadata.smartContractAddress as string}>
chainId={appData.blockchainId as number}
tokenAddress={appData.smartContractAddress as string}>
{gateWrappers}
</RainbowGate>
);
+3 -1
View File
@@ -10,7 +10,9 @@ export function useViewerI18n() {
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
* We intend to not cache i18n query
**/
context: { skipBatch: true },
trpc: {
context: { skipBatch: true },
},
});
}
@@ -16,7 +16,7 @@ type AvailableTimesProps = {
eventTypeId: number;
recurringCount: number | undefined;
eventTypeSlug: string;
date: Dayjs;
date?: Dayjs;
seatsPerTimeSlot?: number | null;
slots?: Slot[];
isLoading: boolean;
@@ -44,8 +44,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
setBrand(getComputedStyle(document.documentElement).getPropertyValue("--brand-color").trim());
}, []);
if (!date) return null;
return (
<div className="dark:bg-darkgray-100 mt-8 flex flex-col px-4 text-center sm:mt-0 sm:w-1/3 sm:p-5 md:-mb-5">
<div className="dark:bg-darkgray-100 mt-8 flex h-full w-full flex-col px-4 text-center sm:mt-0 sm:p-5 md:-mb-5 md:min-w-[200px] lg:min-w-[300px]">
<div className="mb-4 text-left text-base">
<span className="text-bookingdarker dark:text-darkgray-800 mb-8 w-1/2 break-words font-semibold text-gray-900">
{nameOfDay(i18n.language, Number(date.format("d")))}
@@ -55,7 +57,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
{date.toDate().toLocaleString(i18n.language, { month: "long" })}
</span>
</div>
<div className="grid flex-grow grid-cols-1 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
<div className="-mb-5 grid flex-grow grid-cols-1 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
{slots.length > 0 &&
slots.map((slot) => {
type BookingURL = {
@@ -150,4 +152,6 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
);
};
AvailableTimes.displayName = "AvailableTimes";
export default AvailableTimes;
@@ -0,0 +1,107 @@
import { SchedulingType } from "@prisma/client";
import { FC, ReactNode } from "react";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui/Icon";
import { UserAvatars } from "@components/booking/UserAvatars";
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
import type { AvailabilityPageProps } from "../../pages/[user]/[type]";
import type { BookPageProps } from "../../pages/[user]/book";
import type { DynamicAvailabilityPageProps } from "../../pages/d/[link]/[slug]";
import type { HashLinkPageProps } from "../../pages/d/[link]/book";
import type { AvailabilityTeamPageProps } from "../../pages/team/[slug]/[type]";
import type { TeamBookingPageProps } from "../../pages/team/[slug]/book";
import { AvailableEventLocations } from "./AvailableEventLocations";
interface Props {
profile:
| AvailabilityPageProps["profile"]
| HashLinkPageProps["profile"]
| TeamBookingPageProps["profile"]
| BookPageProps["profile"]
| AvailabilityTeamPageProps["profile"]
| DynamicAvailabilityPageProps["profile"];
eventType:
| AvailabilityPageProps["eventType"]
| HashLinkPageProps["eventType"]
| TeamBookingPageProps["eventType"]
| BookPageProps["eventType"]
| AvailabilityTeamPageProps["eventType"]
| DynamicAvailabilityPageProps["eventType"];
isBookingPage?: boolean;
children: ReactNode;
isMobile?: boolean;
rescheduleUid?: string;
}
const BookingDescription: FC<Props> = (props) => {
const { profile, eventType, isBookingPage = false, children } = props;
const { t } = useLocale();
return (
<>
<UserAvatars
profile={profile}
users={eventType.users}
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
size={10}
truncateAfter={3}
/>
<h2 className="mt-2 break-words text-sm font-medium text-gray-600 dark:text-gray-300">
{profile.name}
</h2>
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl font-semibold text-gray-900">
{eventType.title}
</h1>
<div className="dark:text-darkgray-600 flex flex-col space-y-3 text-sm font-medium text-gray-600">
{eventType?.description && (
<div
className={classNames(
"flex",
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
)}>
<div>
<Icon.FiInfo
className={classNames(
"mr-[10px] ml-[2px] inline-block h-4 w-4",
isBookingPage && "dark:text-darkgray-600 -mt-1 text-gray-500"
)}
/>
</div>
<EventTypeDescriptionSafeHTML eventType={eventType} />
</div>
)}
{eventType?.requiresConfirmation && (
<div
className={classNames(
"flex items-center",
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
)}>
<div>
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
</div>
{t("requires_confirmation")}
</div>
)}
{!isBookingPage && !props.rescheduleUid ? (
<AvailableEventLocations
locations={eventType.locations as AvailabilityPageProps["eventType"]["locations"]}
/>
) : null}
<p
className={classNames(
"text-sm font-medium",
isBookingPage && "dark:text-darkgray-600 text-gray-600"
)}>
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
{eventType.length} {t("minutes")}
</p>
{children}
</div>
</>
);
};
export default BookingDescription;
+66 -62
View File
@@ -17,8 +17,8 @@ import Badge from "@calcom/ui/v2/core/Badge";
import Button from "@calcom/ui/v2/core/Button";
import useMeQuery from "@lib/hooks/useMeQuery";
import { extractRecurringDates } from "@lib/parseDate";
// import { extractRecurringDates } from "@lib/parseDate";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import TableActions, { ActionType } from "@components/ui/TableActions";
@@ -29,7 +29,7 @@ type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
type BookingItemProps = BookingItem & {
listingStatus: BookingListingStatus;
recurringBookings?: BookingItem[];
recurringBookings: inferQueryOutput<"viewer.bookings">["recurringInfo"];
};
function BookingListItem(booking: BookingItemProps) {
@@ -44,10 +44,25 @@ function BookingListItem(booking: BookingItemProps) {
const mutation = trpc.useMutation(["viewer.bookings.confirm"], {
onSuccess: () => {
setRejectionDialogIsOpen(false);
showToast(t("booking_confirmation_success"), "success");
utils.invalidateQueries("viewer.bookings");
},
onError: () => {
showToast(t("booking_confirmation_failed"), "error");
utils.invalidateQueries("viewer.bookings");
},
});
const isUpcoming = new Date(booking.endTime) >= new Date();
const isPast = new Date(booking.endTime) < new Date();
const isCancelled = booking.status === BookingStatus.CANCELLED;
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
const isRejected = booking.status === BookingStatus.REJECTED;
const isPending = booking.status === BookingStatus.PENDING;
const isRecurring = booking.recurringEventId !== null;
const isTabRecurring = booking.listingStatus === "recurring";
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
const bookingConfirm = async (confirm: boolean) => {
let body = {
bookingId: booking.id,
@@ -56,28 +71,18 @@ function BookingListItem(booking: BookingItemProps) {
};
/**
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
* the "Recurring" tab, to support confirming discretionally in the "Recurring" tab.
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
*/
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
}
mutation.mutate(body);
};
const isUpcoming = new Date(booking.endTime) >= new Date();
const isPast = new Date(booking.endTime) < new Date();
const isCancelled = booking.status === BookingStatus.CANCELLED;
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
const isRejected = booking.status === BookingStatus.REJECTED;
const isPending = booking.status === BookingStatus.PENDING;
const pendingActions: ActionType[] = [
{
id: "reject",
label:
booking.listingStatus === "recurring" && booking.recurringEventId !== null
? t("reject_all")
: t("reject"),
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
onClick: () => {
setRejectionDialogIsOpen(true);
},
@@ -86,10 +91,7 @@ function BookingListItem(booking: BookingItemProps) {
},
{
id: "confirm",
label:
booking.listingStatus === "recurring" && booking.recurringEventId !== null
? t("confirm_all")
: t("confirm"),
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
onClick: () => {
bookingConfirm(true);
},
@@ -102,17 +104,10 @@ function BookingListItem(booking: BookingItemProps) {
let bookedActions: ActionType[] = [
{
id: "cancel",
label:
booking.listingStatus === "recurring" && booking.recurringEventId !== null
? t("cancel_all_remaining")
: t("cancel"),
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel"),
/* When cancelling we need to let the UI and the API know if the intention is to
cancel all remaining bookings or just that booking instance. */
href: `/cancel/${booking.uid}${
booking.listingStatus === "recurring" && booking.recurringEventId !== null
? "?allRemainingBookings=true"
: ""
}`,
href: `/cancel/${booking.uid}${isTabRecurring && isRecurring ? "?allRemainingBookings=true" : ""}`,
icon: Icon.FiX,
},
{
@@ -146,7 +141,7 @@ function BookingListItem(booking: BookingItemProps) {
},
];
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
if (isTabRecurring && isRecurring) {
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
}
@@ -180,16 +175,13 @@ function BookingListItem(booking: BookingItemProps) {
};
// Calculate the booking date(s) and setup recurring event data to show
let recurringStrings: string[] = [];
let recurringDates: Date[] = [];
const recurringStrings: string[] = [];
const recurringDates: Date[] = [];
if (booking.recurringBookings && booking.eventType.recurringEvent?.freq !== undefined) {
[recurringStrings, recurringDates] = extractRecurringDates(
booking.recurringBookings,
user?.timeZone,
i18n
);
}
// @FIXME: This is importing the RRULE library which is already heavy. Find out a more optimal way do this.
// if (booking.recurringBookings !== undefined && booking.eventType.recurringEvent?.freq !== undefined) {
// [recurringStrings, recurringDates] = extractRecurringDates(booking, user?.timeZone, i18n);
// }
const location = booking.location || "";
@@ -201,7 +193,7 @@ function BookingListItem(booking: BookingItemProps) {
// TODO: Booking when fetched should have id 0 already(for Dynamic Events).
type: booking.eventType.id || 0,
eventSlug: booking.eventType.slug,
user: user?.username || "",
username: user?.username || "",
name: booking.attendees[0] ? booking.attendees[0].name : undefined,
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
location: location,
@@ -214,6 +206,9 @@ function BookingListItem(booking: BookingItemProps) {
},
});
};
const utcOffset = dayjs().tz(user?.timeZone).utcOffset();
return (
<>
<RescheduleDialog
@@ -262,12 +257,19 @@ function BookingListItem(booking: BookingItemProps) {
</Dialog>
<tr className="flex flex-col hover:bg-neutral-50 sm:flex-row">
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:w-48" onClick={onClick}>
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[10rem]" onClick={onClick}>
<div className="cursor-pointer py-4">
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
<div className="text-sm text-gray-500">
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
{dayjs
.utc(booking.startTime)
.utcOffset(utcOffset)
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}{" "}
-{" "}
{dayjs
.utc(booking.endTime)
.utcOffset(utcOffset)
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
</div>
{isPending && (
@@ -296,14 +298,21 @@ function BookingListItem(booking: BookingItemProps) {
</div>
</div>
</td>
<td className={"flex-1 px-4" + (isRejected ? " line-through" : "")} onClick={onClick}>
<td className={"w-full px-4" + (isRejected ? " line-through" : "")} onClick={onClick}>
{/* Time and Badges for mobile */}
<div className="w-full pt-4 pb-2 sm:hidden">
<div className="flex w-full items-center justify-between sm:hidden">
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
<div className="pr-2 text-sm text-gray-500">
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
{dayjs
.utc(booking.startTime)
.utcOffset(utcOffset)
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}{" "}
-{" "}
{dayjs
.utc(booking.endTime)
.utcOffset(utcOffset)
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
</div>
</div>
@@ -335,7 +344,7 @@ function BookingListItem(booking: BookingItemProps) {
<div
title={booking.title}
className={classNames(
"max-w-10/12 sm:max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max",
"max-w-10/12 sm:max-w-56 text-sm font-medium leading-6 text-neutral-900 md:max-w-full",
isCancelled ? "line-through" : ""
)}>
{booking.title}
@@ -365,20 +374,8 @@ function BookingListItem(booking: BookingItemProps) {
</div>
)}
</div>
<div className="flex justify-end sm:hidden">
<div className="whitespace-nowrap pt-2 pb-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
{isUpcoming && !isCancelled ? (
<>
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
{isConfirmed && <TableActions actions={bookedActions} />}
{isRejected && <div className="text-sm text-gray-500">{t("rejected")}</div>}
</>
) : null}
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
</div>
</div>
</td>
<td className="hidden whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:block">
<td className="py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
{isUpcoming && !isCancelled ? (
<>
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
@@ -410,16 +407,21 @@ const RecurringBookingsTooltip = ({
recurringDates,
}: RecurringBookingsTooltipProps) => {
const { t } = useLocale();
const now = new Date();
return (
(booking.recurringBookings &&
booking.eventType?.recurringEvent?.freq &&
(booking.listingStatus === "recurring" || booking.listingStatus === "cancelled") && (
(booking.listingStatus === "recurring" ||
booking.listingStatus === "unconfirmed" ||
booking.listingStatus === "cancelled") && (
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
<div className="flex">
<Tooltip
content={recurringStrings.map((aDate, key) => (
<p key={key}>{aDate}</p>
<p key={key} className={classNames(recurringDates[key] < now && "line-through")}>
{aDate}
</p>
))}>
<div className="text-gray-600 dark:text-white">
<Icon.FiRefreshCcw
@@ -434,7 +436,9 @@ const RecurringBookingsTooltip = ({
: getEveryFreqFor({
t,
recurringEvent: booking.eventType.recurringEvent,
recurringCount: booking.recurringBookings.length,
recurringCount: recurringDates.filter((date) => {
return date >= now;
}).length,
})}
</p>
</div>
+18 -14
View File
@@ -1,8 +1,8 @@
import { FC, useEffect, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import TimezoneSelect, { ITimezoneOption } from "@calcom/ui/form/TimezoneSelect";
import { Switch } from "@calcom/ui/v2";
import TimezoneSelect, { ITimezoneOption } from "@calcom/ui/v2/core/TimezoneSelect";
import { is24h, timeZone } from "../../lib/clock";
@@ -10,9 +10,10 @@ type Props = {
onSelectTimeZone: (selectedTimeZone: string) => void;
onToggle24hClock: (is24hClock: boolean) => void;
timeFormat: string;
hideTimeFormatToggle?: boolean;
};
const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat }) => {
const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat, hideTimeFormatToggle }) => {
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const [is24hClock, setIs24hClock] = useState(timeFormat === "HH:mm" && true);
const { t } = useLocale();
@@ -27,26 +28,29 @@ const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat
onSelectTimeZone(timeZone(selectedTimeZone));
}
}, [selectedTimeZone, onSelectTimeZone]);
const handle24hClockToggle = (is24hClock: boolean) => {
setIs24hClock(is24hClock);
onToggle24hClock(is24h(is24hClock));
};
return selectedTimeZone !== "" ? (
<div className="max-w-80 dark:border-darkgray-300 dark:bg-darkgray-200 absolute z-10 w-full rounded-sm border border-gray-200 bg-white px-4 pt-4 pb-3 shadow-sm">
<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 className="ml-auto flex items-center">
<label className="ltl:mr-3 mr-2 align-text-top text-sm font-medium text-neutral-700 ltr:ml-3 rtl:mr-3 dark:text-white">
{t("am_pm")}
</label>
<Switch
name="24hClock"
label={t("24_h")}
defaultChecked={is24hClock}
onCheckedChange={handle24hClockToggle}
/>
</div>
{!hideTimeFormatToggle && (
<div className="ml-auto flex items-center">
<label className="ltl:mr-3 mr-2 align-text-top text-sm font-medium text-neutral-700 ltr:ml-3 rtl:mr-3 dark:text-white">
{t("am_pm")}
</label>
<Switch
name="24hClock"
label={t("24_h")}
defaultChecked={is24hClock}
onCheckedChange={handle24hClockToggle}
/>
</div>
)}
</div>
<TimezoneSelect
id="timeZone"
@@ -1,14 +1,16 @@
// Get router variables
import autoAnimate from "@formkit/auto-animate";
import { EventType } from "@prisma/client";
import { SchedulingType } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import * as Popover from "@radix-ui/react-popover";
import { TFunction } from "next-i18next";
import { useRouter } from "next/router";
import { useReducer, useEffect, useMemo, useState } from "react";
import { useReducer, useEffect, useMemo, useState, useRef } from "react";
import { Toaster } from "react-hot-toast";
import { FormattedNumber, IntlProvider } from "react-intl";
import { z } from "zod";
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import { getEventTypeAppData } from "@calcom/app-store/utils";
import dayjs, { Dayjs } from "@calcom/dayjs";
import {
useEmbedNonStylesConfig,
@@ -19,13 +21,13 @@ import {
import CustomBranding from "@calcom/lib/CustomBranding";
import classNames from "@calcom/lib/classNames";
import { WEBSITE_URL } from "@calcom/lib/constants";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import notEmpty from "@calcom/lib/notEmpty";
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
import { localStorage } from "@calcom/lib/webstorage";
import { detectBrowserTimeFormat, getIs24hClockFromLocalStorage } from "@calcom/lib/timeFormat";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import DatePicker from "@calcom/ui/v2/modules/booker/DatePicker";
@@ -37,16 +39,14 @@ import { isBrandingHidden } from "@lib/isBrandingHidden";
import Gates, { Gate, GateState } from "@components/Gates";
import AvailableTimes from "@components/booking/AvailableTimes";
import BookingDescription from "@components/booking/BookingDescription";
import TimeOptions from "@components/booking/TimeOptions";
import { UserAvatars } from "@components/booking/UserAvatars";
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
import { HeadSeo } from "@components/seo/head-seo";
import PoweredByCal from "@components/ui/PoweredByCal";
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
import { AvailableEventLocations } from "../AvailableEventLocations";
const GoBackToPreviousPage = ({ t }: { t: TFunction }) => {
const router = useRouter();
@@ -79,7 +79,7 @@ const useSlots = ({
usernameList: string[];
timeZone?: string;
}) => {
const { data, isLoading, isIdle } = trpc.useQuery(
const { data, isLoading, isPaused } = trpc.useQuery(
[
"viewer.public.slots.getSchedule",
{
@@ -101,8 +101,8 @@ const useSlots = ({
}
}, [data]);
// The very first time isIdle is set if auto-fetch is disabled, so isIdle should also be considered a loading state.
return { slots: cachedSlots, isLoading: isLoading || isIdle };
// The very first time isPaused is set if auto-fetch is disabled, so isPaused should also be considered a loading state.
return { slots: cachedSlots, isLoading: isLoading || isPaused };
};
const SlotPicker = ({
@@ -129,6 +129,11 @@ const SlotPicker = ({
const { date, setQuery: setDate } = useRouterQuery("date");
const { month, setQuery: setMonth } = useRouterQuery("month");
const router = useRouter();
const slotPickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
slotPickerRef.current && autoAnimate(slotPickerRef.current);
}, [slotPickerRef]);
useEffect(() => {
if (!router.isReady) return;
@@ -176,10 +181,8 @@ const SlotPicker = ({
<DatePicker
isLoading={isLoading}
className={classNames(
"mt-8 w-full px-4 sm:mt-0 sm:min-w-[455px] md:px-5",
selectedDate
? "sm:dark:border-darkgray-200 border-gray-200 sm:w-1/2 sm:border-r sm:p-4 sm:pr-6 md:w-1/3"
: "sm:p-4"
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-5 lg:min-w-[455px]",
selectedDate ? "sm:dark:border-darkgray-200 border-gray-200 sm:border-r sm:p-4 sm:pr-6" : "sm:p-4"
)}
includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)}
locale={isLocaleReady ? i18n.language : "en"}
@@ -194,10 +197,10 @@ const SlotPicker = ({
weekStart={weekStart}
/>
{selectedDate && (
<div ref={slotPickerRef}>
<AvailableTimes
isLoading={isLoading}
slots={slots[selectedDate.format("YYYY-MM-DD")]}
slots={selectedDate && slots[selectedDate.format("YYYY-MM-DD")]}
date={selectedDate}
timeFormat={timeFormat}
eventTypeId={eventType.id}
@@ -206,7 +209,7 @@ const SlotPicker = ({
recurringCount={recurringEventCount}
ethSignature={ethSignature}
/>
)}
</div>
</>
);
};
@@ -216,17 +219,18 @@ function TimezoneDropdown({
onChangeTimeZone,
timeZone,
timeFormat,
hideTimeFormatToggle,
}: {
onChangeTimeFormat: (newTimeFormat: string) => void;
onChangeTimeZone: (newTimeZone: string) => void;
timeZone?: string;
timeFormat: string;
hideTimeFormatToggle?: boolean;
}) {
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
useEffect(() => {
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
handleToggle24hClock(!!getIs24hClockFromLocalStorage());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -241,8 +245,8 @@ function TimezoneDropdown({
};
return (
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen} className="flex">
<Collapsible.Trigger className="min-w-32 dark:text-darkgray-600 mb-2 -ml-2 px-2 text-left text-gray-600">
<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 inline-block rounded-md px-2 py-2 text-left text-gray-600">
<p className="text-sm font-medium">
<Icon.FiGlobe className="mr-[10px] ml-[2px] -mt-[2px] inline-block h-4 w-4" />
{timeZone}
@@ -252,15 +256,21 @@ function TimezoneDropdown({
<Icon.FiChevronDown className="ml-1 inline-block h-4 w-4" />
)}
</p>
</Collapsible.Trigger>
<Collapsible.Content>
<TimeOptions
onSelectTimeZone={handleSelectTimeZone}
onToggle24hClock={handleToggle24hClock}
timeFormat={timeFormat}
/>
</Collapsible.Content>
</Collapsible.Root>
</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}
onToggle24hClock={handleToggle24hClock}
timeFormat={timeFormat}
hideTimeFormatToggle={hideTimeFormatToggle}
/>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
@@ -289,7 +299,14 @@ const useRouterQuery = <T extends string>(name: T) => {
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
const timeFormatTotimeFormatString = (timeFormat?: number | null) => {
if (!timeFormat) return null;
return timeFormat === 24 ? "HH:mm" : "h:mma";
};
const AvailabilityPage = ({ profile, eventType }: Props) => {
const { data: user } = trpc.useQuery(["viewer.me"]);
const timeFormatFromProfile = timeFormatTotimeFormatString(user?.timeFormat);
const router = useRouter();
const isEmbed = useIsEmbed();
const query = dateQuerySchema.parse(router.query);
@@ -302,8 +319,8 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
const isBackgroundTransparent = useIsBackgroundTransparent();
const [timeZone, setTimeZone] = useState<string>();
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
const [isAvailableTimesVisible, setIsAvailableTimesVisible] = useState<boolean>();
const [timeFormat, setTimeFormat] = useState<string>("HH:mm");
const [gateState, gateDispatcher] = useReducer(
(state: GateState, newState: Partial<GateState>) => ({
...state,
@@ -314,11 +331,8 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
useEffect(() => {
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
}, []);
useEffect(() => {
setIsAvailableTimesVisible(!!query.date);
}, [query.date]);
setTimeFormat(timeFormatFromProfile || detectBrowserTimeFormat);
}, [timeFormatFromProfile]);
// TODO: Improve this;
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
@@ -338,14 +352,7 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
// get dynamic user list here
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
// Recurring event sidebar requires more space
const maxWidth = isAvailableTimesVisible
? recurringEventCount
? "max-w-6xl"
: "max-w-5xl"
: recurringEventCount
? "max-w-4xl"
: "max-w-3xl";
const timezoneDropdown = useMemo(
() => (
<TimezoneDropdown
@@ -353,10 +360,15 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
onChangeTimeFormat={setTimeFormat}
timeZone={timeZone}
onChangeTimeZone={setTimeZone}
// Currently we don't allow the user to change the timeformat when they're logged in,
// the only way to change it is if they go to their profile.
hideTimeFormatToggle={!!timeFormatFromProfile}
/>
),
[timeZone, timeFormat]
[timeZone, timeFormat, timeFormatFromProfile]
);
const stripeAppData = getStripeAppData(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.
const slug = rawSlug.join("/");
@@ -364,13 +376,13 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
// Define conditional gates here
const gates = [
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
eventType.metadata && eventType.metadata.blockchainId && eventType.metadata.smartContractAddress
rainbowAppData && rainbowAppData.blockchainId && rainbowAppData.smartContractAddress
? ("rainbow" as Gate)
: undefined,
];
return (
<Gates gates={gates} metadata={eventType.metadata} dispatch={gateDispatcher}>
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
<HeadSeo
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
@@ -381,205 +393,80 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
noindex: eventType.hidden,
}}
/>
<BookingPageTagManager eventType={eventType} />
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
<div>
<main
className={classNames(
shouldAlignCentrally ? "mx-auto" : "",
isEmbed
? classNames(maxWidth)
: classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
"flex-col md:mx-4 lg:flex",
shouldAlignCentrally ? "items-center" : "items-start",
!isEmbed && classNames("mx-auto my-0 ease-in-out md:my-24")
)}>
<div
style={availabilityDatePickerEmbedStyles}
className={classNames(
isBackgroundTransparent ? "" : "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white",
"border-bookinglightest overflow-hidden rounded-md md:border",
isEmbed ? "mx-auto" : maxWidth
)}>
{/* mobile: details */}
<div className="block px-4 pt-4 sm:p-8 md:hidden">
<div>
<UserAvatars
profile={profile}
users={eventType.users}
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
size={9}
truncateAfter={5}
/>
<div className="mt-4">
<div className="dark:text-darkgray-900 mt-2 gap-2">
<p className="break-words text-sm font-medium text-gray-600 dark:text-gray-300">
{profile.name}
</p>
<h1 className="text-bookingdark dark:text-darkgray-900 mb-4 break-words text-xl font-semibold">
{eventType.title}
</h1>
<div className="flex flex-col space-y-3">
{eventType?.description && (
<div className="dark:text-darkgray-600 flex py-1 text-sm font-medium text-gray-600">
<div>
<Icon.FiInfo className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
</div>
<EventTypeDescriptionSafeHTML eventType={eventType} />
<div>
<div
style={availabilityDatePickerEmbedStyles}
className={classNames(
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",
isEmbed && "mx-auto"
)}>
<div className="overflow-hidden md:flex">
<div
className={classNames(
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
"min-w-full md:w-[280px] md:min-w-[280px]",
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
)}>
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
{!rescheduleUid && eventType.recurringEvent && (
<div className="flex items-start text-sm font-medium">
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
<div>
<p className="mb-1 -ml-2 inline px-2 py-1">
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
</p>
<input
type="number"
min="1"
max={eventType.recurringEvent.count}
className="w-15 dark:bg-darkgray-200 h-7 rounded-sm border-gray-300 bg-white text-sm font-medium [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500"
defaultValue={eventType.recurringEvent.count}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
}}
/>
<p className="inline">
{t("occurrence", {
count: recurringEventCount,
})}
</p>
</div>
)}
{eventType?.requiresConfirmation && (
<p className="dark:text-darkgray-600 text-gray-600">
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
{t("requires_confirmation")}
</p>
)}
<AvailableEventLocations locations={eventType.locations} />
<p className="dark:text-darkgray-600 text-gray-600">
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
{eventType.length} {t("minutes")}
</div>
)}
{stripeAppData.price > 0 && (
<p className="-ml-2 px-2 text-sm font-medium">
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
value={stripeAppData.price / 100.0}
style="currency"
currency={stripeAppData.currency.toUpperCase()}
/>
</IntlProvider>
</p>
{eventType.price > 0 && (
<div className="dark:text-darkgray-600 text-gray-600">
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
/>
</IntlProvider>
</div>
)}
{!rescheduleUid && eventType.recurringEvent && (
<div className="dark:text-darkgray-600 flex items-center text-gray-600">
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 shrink-0 text-gray-500" />
<div>
<p className="mb-1 -ml-2 inline px-2 py-1">
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
</p>
<input
type="number"
min="1"
max={eventType.recurringEvent.count}
className="w-15 dark:text-darkgray-600 h-7 rounded-sm border-gray-300 bg-white text-sm text-gray-600 [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600"
defaultValue={eventType.recurringEvent.count}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
}}
/>
<p className="dark:text-darkgray-600 inline text-gray-600">
{t("occurrence", {
count: recurringEventCount,
})}
</p>
</div>
</div>
)}
{timezoneDropdown}
)}
{timezoneDropdown}
</BookingDescription>
<div className="md:hidden">
{/* Temp disabled booking?.startTime && rescheduleUid && (
<div>
<p
className="mt-8 text-gray-600 dark:text-darkgray-600"
data-testid="former_time_p_mobile">
{t("former_time")}
</p>
<p className="text-gray-500 line-through dark:text-darkgray-600">
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-500" />
{typeof booking.startTime === "string" &&
parseDate(dayjs(booking.startTime), i18n)}
</p>
</div>
)*/}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="overflow-hidden sm:flex">
<div
className={
"sm:dark:border-darkgray-200 hidden overflow-hidden border-gray-200 p-5 sm:border-r md:flex md:flex-col " +
(isAvailableTimesVisible ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
}>
<UserAvatars
profile={profile}
users={eventType.users}
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
size={10}
truncateAfter={3}
/>
<h2 className="break-words text-sm font-medium text-gray-600 dark:text-gray-300 lg:mt-2">
{profile.name}
</h2>
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl text-gray-900">
{eventType.title}
</h1>
<div className="dark:text-darkgray-600 flex flex-col space-y-3 text-sm font-medium text-gray-600">
{eventType?.description && (
<div className="flex">
<div>
<Icon.FiInfo className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
</div>
<EventTypeDescriptionSafeHTML eventType={eventType} />
{!isEmbed && (
<div className="mt-auto hidden md:block">
<GoBackToPreviousPage t={t} />
</div>
)}
{eventType?.requiresConfirmation && (
<div className="flex items-center">
<div>
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
</div>
{t("requires_confirmation")}
</div>
)}
<AvailableEventLocations locations={eventType.locations} />
<p className="text-sm font-medium">
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
{eventType.length} {t("minutes")}
</p>
{!rescheduleUid && eventType.recurringEvent && (
<div className="flex items-start text-sm font-medium">
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
<div>
<p className="mb-1 -ml-2 inline px-2 py-1">
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
</p>
<input
type="number"
min="1"
max={eventType.recurringEvent.count}
className="w-15 dark:bg-darkgray-200 h-7 rounded-sm border-gray-300 bg-white text-sm font-medium [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500"
defaultValue={eventType.recurringEvent.count}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
}}
/>
<p className="inline">
{t("occurrence", {
count: recurringEventCount,
})}
</p>
</div>
</div>
)}
{eventType.price > 0 && (
<p className="-ml-2 px-2 text-sm font-medium">
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
/>
</IntlProvider>
</p>
)}
{timezoneDropdown}
</div>
{!isEmbed && <GoBackToPreviousPage t={t} />}
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
<div>
<p
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
@@ -592,26 +479,33 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
</p>
</div>
)*/}
</div>
<SlotPicker
weekStart={
typeof profile.weekStart === "string"
? ([
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
].indexOf(profile.weekStart) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
: profile.weekStart /* Allows providing weekStart as number */
}
eventType={eventType}
timeFormat={timeFormat}
timeZone={timeZone}
users={userList}
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
recurringEventCount={recurringEventCount}
ethSignature={gateState.rainbowToken}
/>
</div>
<SlotPicker
weekStart={
typeof profile.weekStart === "string"
? (["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
profile.weekStart
) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
: profile.weekStart /* Allows providing weekStart as number */
}
eventType={eventType}
timeFormat={timeFormat}
timeZone={timeZone}
users={userList}
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
recurringEventCount={recurringEventCount}
ethSignature={gateState.rainbowToken}
/>
</div>
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
</div>
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
</main>
</div>
<Toaster position="bottom-right" />
@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { EventTypeCustomInputType, WorkflowActions } from "@prisma/client";
import { SchedulingType } from "@prisma/client";
import { useMutation } from "@tanstack/react-query";
import { isValidPhoneNumber } from "libphonenumber-js";
import { useSession } from "next-auth/react";
import Head from "next/head";
@@ -9,17 +10,19 @@ import { useEffect, useMemo, useState, useReducer } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
import { useMutation } from "react-query";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import {
locationKeyToString,
getEventLocationValue,
getEventLocationType,
EventLocationType,
getHumanReadableLocationValue,
} from "@calcom/app-store/locations";
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
import { getEventTypeAppData } from "@calcom/app-store/utils";
import { LocationObject, LocationType } from "@calcom/core/location";
import dayjs from "@calcom/dayjs";
import {
@@ -29,6 +32,7 @@ import {
} from "@calcom/embed-core/embed-iframe";
import CustomBranding from "@calcom/lib/CustomBranding";
import classNames from "@calcom/lib/classNames";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { HttpError } from "@calcom/lib/http-error";
@@ -49,8 +53,7 @@ import { parseDate, parseRecurringDates } from "@lib/parseDate";
import slugify from "@lib/slugify";
import Gates, { Gate, GateState } from "@components/Gates";
import { UserAvatars } from "@components/booking/UserAvatars";
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
import BookingDescription from "@components/booking/BookingDescription";
import { BookPageProps } from "../../../pages/[user]/book";
import { HashLinkPageProps } from "../../../pages/d/[link]/book";
@@ -97,6 +100,7 @@ const BookingPage = ({
}),
{}
);
const stripeAppData = getStripeAppData(eventType);
useEffect(() => {
if (top !== window) {
@@ -130,7 +134,7 @@ const BookingPage = ({
date,
type: eventType.id,
eventSlug: eventType.slug,
user: profile.slug,
username: profile.slug,
reschedule: !!rescheduleUid,
name: attendees[0].name,
email: attendees[0].email,
@@ -163,7 +167,7 @@ const BookingPage = ({
type: eventType.id,
eventSlug: eventType.slug,
recur: recurringEventId,
user: profile.slug,
username: profile.slug,
reschedule: !!rescheduleUid,
name: attendees[0].name,
email: attendees[0].email,
@@ -409,17 +413,18 @@ const BookingPage = ({
}
});
}
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
// Define conditional gates here
const gates = [
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
eventType.metadata && eventType.metadata.blockchainId && eventType.metadata.smartContractAddress
rainbowAppData && rainbowAppData.blockchainId && rainbowAppData.smartContractAddress
? ("rainbow" as Gate)
: undefined,
];
return (
<Gates gates={gates} metadata={eventType.metadata} dispatch={gateDispatcher}>
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
<Head>
<title>
{rescheduleUid
@@ -435,6 +440,7 @@ const BookingPage = ({
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<BookingPageTagManager eventType={eventType} />
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
<main
className={classNames(
@@ -445,54 +451,20 @@ const BookingPage = ({
<div
className={classNames(
"main overflow-hidden",
isEmbed ? "" : "border border-gray-200",
isBackgroundTransparent ? "" : "dark:border-1 dark:bg-darkgray-100 bg-white",
"dark:border-darkgray-300 rounded-md sm:border"
)}>
<div className="sm:flex">
<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">
<UserAvatars
profile={profile}
users={eventType.users}
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
size={10}
truncateAfter={3}
/>
<h2 className="mt-2 break-words text-sm font-medium text-gray-500 dark:text-gray-300">
{profile.name}
</h2>
<h1 className="font-cal dark:text-darkgray-900 break-words text-2xl text-gray-900 ">
{eventType.title}
</h1>
<div className="mt-4 flex flex-col space-y-3 lg:mt-9">
{eventType?.description && (
<div className="dark:text-darkgray-600 flex text-sm font-medium text-gray-600">
<div>
<Icon.FiInfo className="dark:text-darkgray-600 mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
</div>
<EventTypeDescriptionSafeHTML eventType={eventType} />
</div>
)}
{eventType?.requiresConfirmation && (
<div className="dark:text-darkgray-600 flex items-center text-sm font-medium text-gray-600">
<div>
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
</div>
{t("requires_confirmation")}
</div>
)}
<p className="dark:text-darkgray-600 text-sm font-medium text-gray-600">
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
{eventType.length} {t("minutes")}
</p>
{eventType.price > 0 && (
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
{stripeAppData.price > 0 && (
<p className="text-bookinglight -ml-2 px-2 text-sm ">
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
value={stripeAppData.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
currency={stripeAppData.currency.toUpperCase()}
/>
</IntlProvider>
</p>
@@ -560,7 +532,7 @@ const BookingPage = ({
</p>
</div>
)}
</div>
</BookingDescription>
</div>
<div className="p-6 sm:w-1/2">
<Form form={bookingForm} handleSubmit={bookEvent}>
@@ -605,37 +577,48 @@ const BookingPage = ({
)}
</div>
</div>
{locations.length > 1 && (
<div className="mb-4">
<div className="mb-4">
<>
<span className="block text-sm font-medium text-gray-700 dark:text-white">
{t("location")}
</span>
{locations.map((location, i) => {
const locationString = locationKeyToString(location);
// TODO: Right now selectedLocationType isn't send by getSSP. Once that's available defaultChecked should work and show the location in the original booking
const defaultChecked = rescheduleUid ? selectedLocationType === location.type : i === 0;
if (typeof locationString !== "string") {
// It's possible that location app got uninstalled
return null;
}
return (
<label key={i} className="block">
<input
type="radio"
disabled={!!disableLocations}
className="location dark:bg-darkgray-300 dark:border-darkgray-300 h-4 w-4 border-gray-300 text-black focus:ring-black ltr:mr-2 rtl:ml-2"
{...bookingForm.register("locationType", { required: true })}
value={location.type}
defaultChecked={defaultChecked}
/>
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-white">
{locationKeyToString(location)}
</span>
</label>
);
})}
</div>
)}
{rescheduleUid ? (
<p className="mt-1 text-sm text-gray-500">
{getHumanReadableLocationValue(booking?.location, t)}
</p>
) : (
locations.length > 1 && (
<>
{locations.map((location, i) => {
const locationString = locationKeyToString(location);
if (!selectedLocationType) {
bookingForm.setValue("locationType", locations[0].type);
}
if (typeof locationString !== "string") {
// It's possible that location app got uninstalled
return null;
}
return (
<label key={i} className="block">
<input
type="radio"
disabled={!!disableLocations}
className="location dark:bg-darkgray-300 dark:border-darkgray-300 h-4 w-4 border-gray-300 text-black focus:ring-black ltr:mr-2 rtl:ml-2"
{...bookingForm.register("locationType", { required: true })}
value={location.type}
defaultChecked={i === 0}
/>
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-white">
{locationKeyToString(location)}
</span>
</label>
);
})}
</>
)
)}
</>
</div>
{/* TODO: Change name and id ="phone" to something generic */}
{AttendeeInput && (
<div className="mb-4">
@@ -713,23 +696,25 @@ const BookingPage = ({
/>
)}
{input.type === EventTypeCustomInputType.BOOL && (
<div className="flex h-5 items-center">
<input
type="checkbox"
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
required={input.required}
id={"custom_" + input.id}
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black disabled:bg-gray-200 ltr:mr-2 rtl:ml-2 disabled:dark:text-gray-500"
placeholder=""
disabled={disabledExceptForOwner}
/>
<label
htmlFor={"custom_" + input.id}
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
{input.label}
</label>
<div className="my-6">
<div className="flex">
<input
type="checkbox"
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
required={input.required}
id={"custom_" + input.id}
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black disabled:bg-gray-200 ltr:mr-2 rtl:ml-2 disabled:dark:text-gray-500"
placeholder=""
disabled={disabledExceptForOwner}
/>
<label
htmlFor={"custom_" + input.id}
className="-mt-px block text-sm font-medium text-gray-700 dark:text-white">
{input.label}
</label>
</div>
</div>
)}
</div>
@@ -830,6 +815,7 @@ const BookingPage = ({
) : (
<textarea
{...bookingForm.register("notes")}
required={!!eventType.metadata.additionalNotesRequired}
id="notes"
name="notes"
rows={3}
@@ -1,6 +1,6 @@
import { useMutation } from "@tanstack/react-query";
import { RescheduleResponse } from "pages/api/book/request-reschedule";
import React, { useState, Dispatch, SetStateAction } from "react";
import { useMutation } from "react-query";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
@@ -1,89 +0,0 @@
import { Prisma, SchedulingType } from "@prisma/client";
import { useMemo } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import { parseRecurringEvent } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { Icon } from "@calcom/ui/Icon";
import classNames from "@lib/classNames";
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
select: baseEventTypeSelect,
});
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
export type EventTypeDescriptionProps = {
eventType: EventType;
className?: string;
};
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
const { t } = useLocale();
const recurringEvent = useMemo(
() => parseRecurringEvent(eventType.recurringEvent),
[eventType.recurringEvent]
);
return (
<>
<div className={classNames("text-gray-600 dark:text-white", className)}>
{eventType.description && (
<h2 className="max-w-[280px] overflow-hidden text-ellipsis sm:max-w-[500px]">
{eventType.description.substring(0, 100)}
{eventType.description.length > 100 && "..."}
</h2>
)}
<ul className="mt-2 flex flex-wrap space-x-1 sm:flex-nowrap ">
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
<Icon.FiClock className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{eventType.length} {t("minutes")}
</li>
{eventType.schedulingType ? (
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
<Icon.FiUsers className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
{eventType.schedulingType === SchedulingType.COLLECTIVE && t("collective")}
</li>
) : (
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
<Icon.FiUser className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("1_on_1")}
</li>
)}
{recurringEvent?.count && recurringEvent.count > 0 && (
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
<Icon.FiRefreshCw className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("repeats_up_to", {
count: recurringEvent.count,
})}
</li>
)}
{eventType.price > 0 && (
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
<Icon.FiCreditCard className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
/>
</IntlProvider>
</li>
)}
{eventType.requiresConfirmation && (
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
<Icon.FiCheckSquare className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("requires_confirmation")}
</li>
)}
</ul>
</div>
</>
);
};
export default EventTypeDescription;
@@ -1,145 +0,0 @@
import type { FormValues } from "pages/event-types/[type]";
import { useState } from "react";
import { UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Frequency } from "@calcom/prisma/zod-utils";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Alert } from "@calcom/ui/Alert";
import Select from "@components/ui/form/Select";
type RecurringEventControllerProps = {
recurringEvent: RecurringEvent | null;
formMethods: UseFormReturn<FormValues>;
paymentEnabled: boolean;
onRecurringEventDefined: (value: boolean) => void;
};
/**
* @deprecated
* use component from `/apps/web/components/v2/eventtype/RecurringEventController` instead
**/
export default function RecurringEventController({
recurringEvent,
formMethods,
paymentEnabled,
onRecurringEventDefined,
}: RecurringEventControllerProps) {
const { t } = useLocale();
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
/* Just yearly-0, monthly-1 and weekly-2 */
const recurringEventFreqOptions = Object.entries(Frequency)
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
.map(([key, value]) => ({
label: t(`${key.toString().toLowerCase()}`, { count: recurringEventState?.interval }),
value: value.toString(),
}));
return (
<div className="block items-start sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<span className="flex text-sm font-medium text-neutral-700">{t("recurring_event")}</span>
</div>
<div className={!paymentEnabled ? "w-full" : ""}>
{paymentEnabled ? (
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
) : (
<>
<div className="flex items-start">
<div className="flex h-5 items-start">
<input
onChange={(event) => {
onRecurringEventDefined(event?.target.checked);
if (!event?.target.checked) {
formMethods.setValue("recurringEvent", null);
setRecurringEventState(null);
} else {
const newVal = recurringEvent || {
interval: 1,
count: 12,
freq: Frequency.WEEKLY,
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}
}}
type="checkbox"
className="text-primary-600 h-4 w-4 rounded border-gray-300"
defaultChecked={recurringEventState !== null}
data-testid="recurring-event-check"
id="recurringEvent"
/>
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<label htmlFor="recurringEvent" className="text-neutral-900">
{t("recurring_event_description")}
</label>
</div>
</div>
{recurringEventState && (
<div data-testid="recurring-event-collapsible" className="mt-4 text-sm">
<div className="flex items-center">
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
<input
type="number"
min="1"
max="20"
className="block w-16 rounded-sm border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
defaultValue={recurringEventState.interval}
onChange={(event) => {
const newVal = {
...recurringEventState,
interval: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<Select
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventState.freq]}
isSearchable={false}
className="w-18 block min-w-0 rounded-sm text-sm"
onChange={(event) => {
const newVal = {
...recurringEventState,
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
</div>
<div className="mt-4 flex items-center">
<p className="mr-2 text-neutral-900">{t("max")}</p>
<input
type="number"
min="1"
max="20"
className="block w-16 rounded-sm border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
defaultValue={recurringEventState.count}
onChange={(event) => {
const newVal = {
...recurringEventState,
count: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<p className="mr-2 text-neutral-900">
{t("occurrence", {
count: recurringEventState.count,
})}
</p>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -14,10 +14,11 @@ const CalendarItem = (props: ICalendarItem) => {
const { title, imageSrc, type } = props;
const { t } = useLocale();
return (
<div className="flex flex-row items-center p-5">
<img src={imageSrc} alt={title} className="h-8 w-8" />
<p className="mx-3 text-sm font-bold">{title}</p>
<div className="flex flex-row items-center justify-between p-5">
<div className="flex items-center space-x-3">
<img src={imageSrc} alt={title} className="h-8 w-8" />
<p className="text-sm font-bold">{title}</p>
</div>
<InstallAppButtonWithoutPlanCheck
type={type}
render={(buttonProps) => (
@@ -29,8 +30,7 @@ const CalendarItem = (props: ICalendarItem) => {
// Save cookie key to return url step
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
buttonProps && buttonProps.onClick && buttonProps?.onClick(event);
}}
className="ml-auto rounded-md border border-gray-200 py-[10px] px-4 text-sm font-bold">
}}>
{t("connect")}
</Button>
)}
@@ -1,4 +1,4 @@
import { useMutation } from "react-query";
import { useMutation } from "@tanstack/react-query";
import showToast from "@calcom/lib/notification";
import { trpc } from "@calcom/trpc/react";
@@ -76,7 +76,7 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
</List>
)}
{queryConnectedCalendars.isLoading && (
{queryIntegrations.isLoading && (
<ul className="divide-y divide-gray-200 rounded-md border border-gray-200 bg-white p-0 dark:bg-black">
{[0, 0, 0, 0].map((_item, index) => {
return (
@@ -149,7 +149,7 @@ const UserProfile = (props: IUserProfile) => {
}}
/>
{errors.bio && (
<p data-testid="required" className="text-xs italic text-red-500">
<p data-testid="required" className="py-2 text-xs text-red-500">
{t("required")}
</p>
)}
@@ -85,7 +85,7 @@ const UserSettings = (props: IUserSettingsProps) => {
className="w-full rounded-md border border-gray-300 text-sm"
/>
{errors.name && (
<p data-testid="required" className="text-xs italic text-red-500">
<p data-testid="required" className="py-2 text-xs text-red-500">
{t("required")}
</p>
)}
@@ -1,5 +1,5 @@
import { useMutation } from "@tanstack/react-query";
import { Fragment } from "react";
import { useMutation } from "react-query";
import { InstallAppButton } from "@calcom/app-store/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -1,5 +1,5 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useMutation } from "react-query";
import showToast from "@calcom/lib/notification";
import { ButtonBaseProps } from "@calcom/ui/Button";
+34 -37
View File
@@ -1,10 +1,7 @@
import Link from "next/link";
import { TeamPageProps } from "pages/team/[slug]";
import React from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import Button from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon";
import { Avatar } from "@calcom/ui/v2";
import { useLocale } from "@lib/hooks/useLocale";
@@ -13,46 +10,46 @@ type TeamType = TeamPageProps["team"];
type MembersType = TeamType["members"];
type MemberType = MembersType[number];
const Team = ({ team }: TeamPageProps) => {
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
const { t } = useLocale();
const Member = ({ member }: { member: MemberType }) => {
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 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 || ""}
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
/>
<section className="line-clamp-4 mt-2 w-full space-y-1">
<p className="font-medium text-neutral-900 dark:text-white">{member.name}</p>
<p className="line-clamp-3 overflow-ellipsis text-sm font-normal text-neutral-500 dark:text-white">
{member.bio || t("user_from_team", { user: member.name, team: team.name })}
</p>
</section>
</div>
</Link>
);
};
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 ">
<Avatar
size="md"
alt={member.name || ""}
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
/>
<section className="line-clamp-4 mt-2 w-full space-y-1">
<p className="font-medium text-neutral-900 dark:text-white">{member.name}</p>
<p className="line-clamp-3 overflow-ellipsis text-sm font-normal text-neutral-500 dark:text-white">
{member.bio || t("user_from_team", { user: member.name, team: teamName })}
</p>
</section>
</div>
</Link>
);
};
const Members = ({ members }: { members: MembersType }) => {
if (!members || members.length === 0) {
return null;
}
return (
<section className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
{members.map((member) => {
return member.username !== null && <Member key={member.id} member={member} />;
})}
</section>
);
};
const Members = ({ members, teamName }: { members: MembersType; teamName: string | null }) => {
if (!members || members.length === 0) {
return null;
}
return (
<section className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
{members.map((member) => {
return member.username !== null && <Member key={member.id} member={member} teamName={teamName} />;
})}
</section>
);
};
const Team = ({ team }: TeamPageProps) => {
return (
<div>
<Members members={team.members} />
<Members members={team.members} teamName={team.name} />
</div>
);
};
+30 -32
View File
@@ -1,50 +1,48 @@
import { useState } from "react";
import classNames from "classnames";
import React, { useState } from "react";
import { ControllerRenderProps } from "react-hook-form";
import { Icon } from "@calcom/ui/Icon";
const EditableHeading = ({
title,
const EditableHeading = function EditableHeading({
value,
onChange,
placeholder = "",
readOnly = false,
isReady,
...passThroughProps
}: {
title: string;
onChange?: (value: string) => void;
placeholder?: string;
readOnly?: boolean;
}) => {
isReady?: boolean;
} & Omit<JSX.IntrinsicElements["input"], "name" | "onChange"> &
ControllerRenderProps) {
const [isEditing, setIsEditing] = useState(false);
const enableEditing = () => !readOnly && setIsEditing(true);
const enableEditing = () => setIsEditing(true);
return (
<div className="group relative cursor-pointer" onClick={enableEditing}>
{!isEditing ? (
<>
<h1
style={{ fontSize: 22, letterSpacing: "-0.0009em" }}
className="inline pl-0 normal-case text-gray-900 focus:text-black group-hover:text-gray-500">
{title}
</h1>
{!readOnly ? (
<Icon.FiEdit2 className="ml-1 -mt-1 inline h-4 w-4 text-gray-700 group-hover:text-gray-500" />
) : null}
</>
) : (
<div style={{ marginBottom: -11 }}>
<div className="flex items-center">
<label className="min-w-8 relative inline-block">
<span className="whitespace-pre text-xl tracking-normal text-transparent">{value}&nbsp;</span>
{!isEditing && isReady && (
<Icon.FiEdit2 className="ml-1 inline h-4 w-4 align-top text-gray-700 group-hover:text-gray-500" />
)}
<input
{...passThroughProps}
type="text"
autoFocus
style={{ top: -6, fontSize: 22 }}
value={value}
required
className="relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
placeholder={placeholder}
defaultValue={title}
className={classNames(
"absolute top-0 left-0 w-full cursor-pointer border-none bg-transparent p-0 align-top text-xl text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
)}
onFocus={(e) => {
setIsEditing(true);
passThroughProps.onFocus && passThroughProps.onFocus(e);
}}
onBlur={(e) => {
setIsEditing(false);
onChange && onChange(e.target.value);
passThroughProps.onBlur && passThroughProps.onBlur(e);
}}
onChange={(e) => onChange && onChange(e.target.value)}
/>
</div>
)}
</label>
</div>
</div>
);
};
+25 -17
View File
@@ -1,6 +1,11 @@
import React, { FC } from "react";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@calcom/ui/Dropdown";
import { Icon } from "@calcom/ui/Icon";
import Button from "@calcom/ui/v2/core/Button";
@@ -43,22 +48,24 @@ const DropdownActions = ({
) : (
<DropdownMenuTrigger asChild>{actionTrigger}</DropdownMenuTrigger>
)}
<DropdownMenuContent portalled>
{actions.map((action) => (
<DropdownMenuItem key={action.id} className="focus-visible:outline-none">
<Button
type="button"
color="minimal"
className="w-full rounded-none font-normal"
href={action.href}
StartIcon={action.icon}
onClick={action.onClick || defaultAction}
data-testid={action.id}>
{action.label}
</Button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
<DropdownMenuPortal>
<DropdownMenuContent>
{actions.map((action) => (
<DropdownMenuItem key={action.id} className="focus-visible:outline-none">
<Button
type="button"
color="minimal"
className="w-full rounded-none font-normal"
href={action.href}
StartIcon={action.icon}
onClick={action.onClick || defaultAction}
data-testid={action.id}>
{action.label}
</Button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
);
};
@@ -70,6 +77,7 @@ const TableActions: FC<Props> = ({ actions }) => {
{actions.map((action) => {
const button = (
<Button
className="whitespace-nowrap"
key={action.id}
data-testid={action.id}
href={action.href}
@@ -91,11 +91,10 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
const isCurrentUsernamePremium =
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
const debouncedApiCall = useCallback(
debounce(async (username) => {
debounce(async (username: string) => {
const { data } = await fetchUsername(username);
setMarkAsError(!data.available && username && username !== currentUsername);
setMarkAsError(!data.available && !!currentUsername && username !== currentUsername);
setIsInputUsernamePremium(data.premium);
setUsernameIsAvailable(data.available);
}, 150),
@@ -1,5 +1,4 @@
import React from "react";
import Select from "react-select";
import { OptionProps } from "react-select";
import { InstallAppButton } from "@calcom/app-store/components";
@@ -8,6 +7,7 @@ import { trpc } from "@calcom/trpc/react";
import type { App } from "@calcom/types/App";
import { Button } from "@calcom/ui";
import { Icon } from "@calcom/ui/Icon";
import { Select } from "@calcom/ui/v2";
import { QueryCell } from "@lib/QueryCell";
@@ -27,7 +27,7 @@ const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App
{data.image && (
<img className="float-left mr-3 inline h-5 w-5" src={data.image} alt={data.label} />
)}
<p>{data.label}</p>
<p>{`${t("add")} ${data.label}`}</p>
</Button>
);
}}
@@ -35,7 +35,7 @@ const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App
) : (
<Button className="w-full" color="minimal" href="/apps/categories/calendar">
<Icon.FiPlus className="text-color mr-3 ml-1 h-4 w-4" />
<p>{t("add_new_calendar")}...</p>
<p>{t("install_new_calendar_app")}</p>
</Button>
);
};
@@ -63,27 +63,43 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
return (
<Select
name="additionalCalendar"
placeholder={t("install_another")}
placeholder={
<Button className="rounded-md" StartIcon={Icon.FiPlus} color="secondary">
{t("add")}
</Button>
}
options={options}
styles={{
placeholder: (defaultStyles) => {
return {
...defaultStyles,
color: "#3E3E3E",
marginLeft: "3px",
};
},
control: (defaultStyles) => {
return {
...defaultStyles,
borderRadius: "6px",
};
},
}}
isSearchable={false}
className="min-w-52 block w-full flex-1 rounded-none rounded-r-sm border-gray-300 text-sm font-medium text-gray-700"
isLoading={isLoading}
components={{ Option: ImageOption }}
styles={{
menu: (defaultStyles) => ({
...defaultStyles,
"@media only screen and (max-width: 640px)": {
left: "0",
},
width: "max-content",
right: "0",
}),
control: (defaultStyles) => ({
...defaultStyles,
padding: "0",
border: "0",
borderRadius: "inherit",
}),
dropdownIndicator: (defaultStyles) => ({
...defaultStyles,
display: "none",
}),
valueContainer: (defaultStyles) => ({
...defaultStyles,
padding: "0",
}),
placeholder: (defaultStyles) => ({
...defaultStyles,
margin: "0",
}),
}}
/>
);
}}
+1 -1
View File
@@ -66,7 +66,7 @@ const Component = ({
return (
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
{hasImages && (
<div className="flex-2 mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto min-w-[672px] snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:rounded-md">
<div className="flex-2 mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:rounded-md">
{images ? (
images.map((img) => (
<img
@@ -1,15 +1,14 @@
import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { Fragment } from "react";
import { useMutation } from "react-query";
import { InstallAppButton } from "@calcom/app-store/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon";
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
import { Alert, EmptyScreen } from "@calcom/ui/v2";
import { Alert, Button, EmptyScreen } from "@calcom/ui/v2";
import { List } from "@calcom/ui/v2/core/List";
import { ShellSubHeading } from "@calcom/ui/v2/core/Shell";
import Switch from "@calcom/ui/v2/core/Switch";
@@ -141,6 +140,7 @@ function CalendarList(props: Props) {
);
}
// todo: @hariom extract this into packages/apps-store as "GeneralAppSettings"
function ConnectedCalendarsList(props: Props) {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
@@ -163,6 +163,7 @@ function ConnectedCalendarsList(props: Props) {
title={item.integration.title}
logo={item.integration.logo}
description={item.primary?.externalId || "No external Id"}
separate={true}
actions={
<div className="flex w-32 justify-end">
<DisconnectIntegration
@@ -173,23 +174,27 @@ function ConnectedCalendarsList(props: Props) {
/>
</div>
}>
{!fromOnboarding && (
<>
<p className="px-4 pt-4 text-sm text-neutral-500">{t("toggle_calendars_conflict")}</p>
<ul className="space-y-2 p-4">
{item.calendars.map((cal) => (
<CalendarSwitch
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
type={item.integration.type}
defaultSelected={cal.isSelected}
destination={cal.externalId === props.destinationCalendarId}
/>
))}
</ul>
</>
)}
<div className="border-t border-gray-200">
{!fromOnboarding && (
<>
<p className="px-4 pt-4 text-sm text-neutral-500">
{t("toggle_calendars_conflict")}
</p>
<ul className="space-y-2 p-4">
{item.calendars.map((cal) => (
<CalendarSwitch
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
type={item.integration.type}
defaultSelected={cal.isSelected}
destination={cal.externalId === props.destinationCalendarId}
/>
))}
</ul>
</>
)}
</div>
</IntegrationListItem>
) : (
<Alert
@@ -258,11 +263,11 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
<ShellSubHeading
title={t("calendar")}
subtitle={t("installed_app_calendar_description")}
className="mb-0 flex flex-wrap items-center gap-4 md:mb-3 md:gap-0"
className="mb-0 flex flex-wrap items-center gap-4 sm:flex-nowrap md:mb-3 md:gap-0"
actions={
<div className="flex flex-col xl:flex-row xl:space-x-5">
{!!data.connectedCalendars.length && (
<div className=" flex items-center">
<div className="flex items-center">
<AdditionalCalendarSelector isLoading={mutation.isLoading} />
</div>
)}
@@ -16,6 +16,7 @@ function IntegrationListItem(props: {
children?: ReactNode;
logo: string;
destination?: boolean;
separate?: boolean;
}): JSX.Element {
const router = useRouter();
const { hl } = router.query;
@@ -32,7 +33,8 @@ function IntegrationListItem(props: {
<ListItem
expanded={!!props.children}
className={classNames(
"my-0 flex-col rounded-md border transition-colors duration-500",
props.separate ? "rounded-md" : "first:rounded-t-md last:rounded-b-md",
"my-0 flex-col border transition-colors duration-500 ",
highlight ? "bg-yellow-100" : ""
)}>
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
@@ -45,7 +47,7 @@ function IntegrationListItem(props: {
</div>
<div>{props.actions}</div>
</div>
{props.children && <div className="w-full border-t border-gray-200">{props.children}</div>}
{props.children && <div className="w-full">{props.children}</div>}
</ListItem>
);
}
@@ -0,0 +1,63 @@
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { InstallAppButton } from "@calcom/app-store/components";
import { classNames } from "@calcom/lib";
import useApp from "@calcom/lib/hooks/useApp";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import { showToast } from "@calcom/ui/v2";
import Button from "@calcom/ui/v2/core/Button";
/**
* Use this component to allow installing an app from anywhere on the app.
* Use of this component requires you to remove custom InstallAppButtonComponent so that it can manage the redirection itself
*/
export default function OmniInstallAppButton({ appId, className }: { appId: string; className: string }) {
const { t } = useLocale();
const { data: app } = useApp(appId);
const utils = trpc.useContext();
const mutation = useAddAppMutation(null, {
onSuccess: () => {
//TODO: viewer.appById might be replaced with viewer.apps so that a single query needs to be invalidated.
utils.invalidateQueries(["viewer.appById", { appId }]);
utils.invalidateQueries(["viewer.apps", { extendsFeature: "EventType" }]);
showToast(t("app_successfully_installed"), "success");
},
onError: (error) => {
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
},
});
if (!app) {
return null;
}
return (
<InstallAppButton
type={app.type}
isProOnly={app.isProOnly}
wrapperClassName={classNames("[@media(max-width:260px)]:w-full", className)}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
onClick: () => {
mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug, isOmniInstall: true });
},
};
}
return (
<Button
loading={mutation.isLoading}
color="secondary"
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
StartIcon={Icon.FiPlus}
{...props}>
{t("install")}
</Button>
);
}}
/>
);
}
@@ -1,3 +1,4 @@
import classNames from "classnames";
import React from "react";
import { SkeletonText } from "@calcom/ui/v2";
@@ -32,9 +33,13 @@ function SkeletonItem() {
);
}
export const AvailabilitySelectSkeletonLoader = () => {
export const SelectSkeletonLoader = ({ className }: { className?: string }) => {
return (
<li className="group flex w-full items-center justify-between rounded-sm border border-gray-200 px-[10px] py-3">
<li
className={classNames(
"group flex w-full items-center justify-between rounded-sm border border-gray-200 px-[10px] py-3",
className
)}>
<div className="flex-grow truncate text-sm">
<div className="flex justify-between">
<SkeletonText className="h-4 w-32" />
@@ -1,4 +1,4 @@
import { FormValues } from "pages/v2/event-types/[type]";
import { FormValues } from "pages/event-types/[type]";
import { Controller, useFormContext } from "react-hook-form";
import dayjs from "@calcom/dayjs";
@@ -8,10 +8,10 @@ import { weekdayNames } from "@calcom/lib/weekday";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import Button from "@calcom/ui/v2/core/Button";
import Select from "@calcom/ui/v2/core/form/Select";
import Select from "@calcom/ui/v2/core/form/select";
import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
import { AvailabilitySelectSkeletonLoader } from "@components/v2/availability/SkeletonLoader";
import { SelectSkeletonLoader } from "@components/v2/availability/SkeletonLoader";
type AvailabilityOption = {
label: string;
@@ -30,7 +30,7 @@ const AvailabilitySelect = ({
}) => {
const { data, isLoading } = trpc.useQuery(["viewer.availability.list"]);
if (isLoading) {
return <AvailabilitySelectSkeletonLoader />;
return <SelectSkeletonLoader />;
}
const schedules = data?.schedules || [];
@@ -1,7 +1,7 @@
import autoAnimate from "@formkit/auto-animate";
import { EventTypeCustomInput } from "@prisma/client/";
import Link from "next/link";
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import { useEffect, useRef, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import short from "short-uuid";
@@ -40,9 +40,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
const { t } = useLocale();
const [showEventNameTip, setShowEventNameTip] = useState(false);
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
const [seatsInputVisible, setSeatsInputVisible] = useState(!!eventType.seatsPerTimeSlot);
const [seatsPerTimeSlot, setSeatsPerTimeSlot] = useState(eventType.seatsPerTimeSlot || 2);
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
eventType.customInputs.sort((a, b) => a.id - b.id) || []
);
@@ -51,7 +51,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
const animationRef = useRef(null);
const seatsEnabled = !!eventType.seatsPerTimeSlot;
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
useEffect(() => {
animationRef.current && autoAnimate(animationRef.current);
@@ -249,6 +249,71 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
)}
/>
<hr />
<Controller
name="metadata.additionalNotesRequired"
control={formMethods.control}
defaultValue={!!eventType.metadata.additionalNotesRequired}
render={({ field: { value, onChange } }) => (
<div className="flex space-x-3 ">
<Switch
name="additionalNotesRequired"
fitToHeight={true}
checked={value}
onCheckedChange={(e) => onChange(e)}
/>
<div className="flex flex-col">
<Skeleton as={Label} className="text-sm font-semibold leading-none text-black">
{t("require_additional_notes")}
</Skeleton>
<Skeleton as="p" className="-mt-2 text-sm leading-normal text-gray-600">
{t("require_additional_notes_description")}
</Skeleton>
</div>
</div>
)}
/>
<hr />
<Controller
name="successRedirectUrl"
control={formMethods.control}
defaultValue={hashedUrl}
render={({ field: { value, onChange } }) => (
<>
<div className="flex space-x-3 ">
<Switch
name="successRedirectUrlCheck"
fitToHeight={true}
defaultChecked={redirectUrlVisible}
onCheckedChange={(e) => {
setRedirectUrlVisible(e);
onChange(e ? value : "");
}}
/>
<div className="flex flex-col">
<Skeleton as={Label} className="text-sm font-semibold leading-none text-black">
{t("redirect_success_booking")}
</Skeleton>
<Skeleton as="p" className="-mt-2 text-sm leading-normal text-gray-600">
{t("redirect_url_description")}
</Skeleton>
</div>
</div>
{redirectUrlVisible && (
<div className="">
<TextField
label={t("redirect_success_booking")}
placeholder={t("external_redirect_url")}
required={redirectUrlVisible}
type="text"
defaultValue={eventType.successRedirectUrl || ""}
{...formMethods.register("successRedirectUrl")}
/>
</div>
)}
</>
)}
/>
<hr />
<Controller
name="hashedLink"
control={formMethods.control}
@@ -313,45 +378,61 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
/>
<hr />
<Controller
name="seatsPerTimeSlot"
name="seatsPerTimeSlotEnabled"
control={formMethods.control}
defaultValue={seatsPerTimeSlot}
defaultValue={!!eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<>
<div className="flex space-x-3">
<Switch
name="seatsPerTimeSlot"
checked={seatsInputVisible}
onCheckedChange={(e) => {
setSeatsInputVisible(e);
formMethods.setValue("seatsPerTimeSlot", seatsPerTimeSlot);
onChange(e ? seatsPerTimeSlot : null);
}}
fitToHeight={true}
/>
<div className="flex flex-col">
<Label className="text-sm font-semibold leading-none text-black">{t("offer_seats")}</Label>
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("offer_seats_description")}</p>
</div>
<div className="flex space-x-3">
<Switch
name="seatsPerTimeSlotEnabled"
checked={value}
onCheckedChange={(e) => {
setSeatsInputVisible(e);
// Enabling seats will disable guests and requiring confimation until fully supported
if (e) {
formMethods.setValue("disableGuests", true);
formMethods.setValue("requiresConfirmation", false);
formMethods.setValue("seatsPerTimeSlot", 2);
} else {
formMethods.setValue("seatsPerTimeSlot", null);
}
onChange(e);
}}
fitToHeight={true}
/>
<div className="flex flex-col">
<Skeleton as={Label} className="text-sm font-semibold leading-none text-black">
{t("offer_seats")}
</Skeleton>
<Skeleton as="p" className="-mt-2 text-sm leading-normal text-gray-600">
{t("offer_seats_description")}
</Skeleton>
</div>
{seatsInputVisible && (
<div className="">
<TextField
required
name="seatsPerTimeSlot"
label={t("number_of_seats")}
type="number"
defaultValue={seatsPerTimeSlot || 2}
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
formMethods.setValue("seatsPerTimeSlot", Number(e.target.value));
}}
/>
</div>
)}
</>
</div>
)}
/>
{seatsInputVisible && (
<Controller
name="seatsPerTimeSlot"
control={formMethods.control}
defaultValue={eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<div className="">
<TextField
required
name="seatsPerTimeSlot"
label={t("number_of_seats")}
type="number"
defaultValue={value || 2}
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
onChange(Number(e.target.value));
}}
/>
</div>
)}
/>
)}
{showEventNameTip && (
<Dialog open={showEventNameTip} onOpenChange={setShowEventNameTip}>
<DialogContent
+120 -194
View File
@@ -1,208 +1,134 @@
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import React, { useState } from "react";
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import React from "react";
import { useFormContext } from "react-hook-form";
import { Controller } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import { SelectGifInput } from "@calcom/app-store/giphy/components";
import RainbowInstallForm from "@calcom/app-store/rainbow/components/RainbowInstallForm";
import EventTypeAppContext, { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
import { EventTypeAddonMap } from "@calcom/app-store/apps.browser.generated";
import { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
import { EventTypeAppsList } from "@calcom/app-store/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Alert, Button, EmptyScreen, Select, Switch, TextField } from "@calcom/ui/v2";
import ErrorBoundary from "@calcom/ui/ErrorBoundary";
import { Button, EmptyScreen } from "@calcom/ui/v2";
const AppCard = ({
logo,
name,
description,
switchOnClick,
switchChecked,
children,
}: {
logo: string;
name: string;
description: React.ReactNode;
switchChecked?: boolean;
switchOnClick?: (e: boolean) => void;
children?: React.ReactNode;
}) => {
return (
<div className="mb-4 rounded-md border border-gray-200 p-8">
<div className="flex w-full">
<img src={logo} alt={name} className="mr-3 h-auto w-[42px] rounded-sm" />
<div className="flex flex-col">
<span className="font-semibold leading-none text-black">{name}</span>
<p className="pt-2 text-sm font-normal text-gray-600">{description}</p>
</div>
<div className="ml-auto flex items-center">
<Switch onCheckedChange={switchOnClick} checked={switchChecked} />
</div>
</div>
{children}
</div>
);
};
type EventType = Pick<EventTypeSetupInfered, "eventType">["eventType"] &
EventTypeAppCardComponentProps["eventType"];
export const EventAppsTab = ({
hasPaymentIntegration,
currency,
function AppCardWrapper({
app,
eventType,
hasGiphyIntegration,
hasRainbowIntegration,
}: Pick<
EventTypeSetupInfered,
"eventType" | "hasPaymentIntegration" | "hasGiphyIntegration" | "hasRainbowIntegration" | "currency"
>) => {
const formMethods = useFormContext<FormValues>();
const [showGifSelection, setShowGifSelection] = useState(
hasGiphyIntegration && !!eventType.metadata["giphyThankYouPage"]
);
const [showRainbowSection, setShowRainbowSection] = useState(
hasRainbowIntegration &&
!!eventType.metadata["blockchainId"] &&
!!eventType.metadata["smartContractAddress"]
);
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
getAppData,
setAppData,
}: {
app: inferQueryOutput<"viewer.apps">[number];
eventType: EventType;
getAppData: GetAppData;
setAppData: SetAppData;
}) {
const dirName = app.slug === "stripe" ? "stripepayment" : app.slug;
const Component = EventTypeAddonMap[dirName as keyof typeof EventTypeAddonMap];
const getCurrencySymbol = (locale: string, currency: string) =>
(0)
.toLocaleString(locale, {
style: "currency",
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})
.replace(/\d/g, "")
.trim();
const { t } = useLocale();
const installedApps = [hasPaymentIntegration, hasGiphyIntegration].filter(Boolean).length;
if (installedApps === 0) {
return (
<div className="pt-4 before:border-0">
<EmptyScreen
Icon={Icon.FiGrid}
headline={t("empty_installed_apps_headline")}
description={t("empty_installed_apps_description")}
buttonRaw={
<Button target="_blank" color="secondary" href="/apps">
{t("empty_installed_apps_button")}{" "}
</Button>
}
/>
</div>
);
if (!Component) {
throw new Error('No component found for "' + dirName + '"');
}
return (
<div className="pt-4 before:border-0">
{/* TODO:Strip isnt fully setup yet */}
{hasPaymentIntegration && (
<AppCard
name="Stripe"
switchChecked={requirePayment}
switchOnClick={(e) => {
if (!e) {
formMethods.setValue("price", 0);
setRequirePayment(false);
} else {
setRequirePayment(true);
}
}}
description={
<>
<div className="">
{t("require_payment")} (0.5% +{" "}
<IntlProvider locale="en">
<FormattedNumber value={0.1} style="currency" currency={currency} />
</IntlProvider>{" "}
{t("commission_per_transaction")})
</div>
</>
}
logo="/api/app-store/stripepayment/icon.svg">
<>
{recurringEventDefined ? (
<Alert severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<div className="block items-center sm:flex">
<Controller
defaultValue={eventType.price}
control={formMethods.control}
name="price"
render={({ field }) => (
<TextField
label=""
addOnLeading={<>{getCurrencySymbol("en", currency)}</>}
{...field}
step="0.01"
min="0.5"
type="number"
required
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
placeholder="Price"
onChange={(e) => {
field.onChange(e.target.valueAsNumber * 100);
}}
value={field.value > 0 ? field.value / 100 : undefined}
/>
)}
/>
</div>
)
)}
</>
</AppCard>
)}
{hasGiphyIntegration && (
<AppCard
name="Giphy"
description={t("confirmation_page_gif")}
logo="/api/app-store/giphy/icon.svg"
switchOnClick={(e) => {
if (!e) {
setShowGifSelection(false);
formMethods.setValue("giphyThankYouPage", "");
} else {
setShowGifSelection(true);
}
}}
switchChecked={showGifSelection}>
{showGifSelection && (
<SelectGifInput
defaultValue={eventType.metadata["giphyThankYouPage"] as string}
onChange={(url: string) => {
formMethods.setValue("giphyThankYouPage", url);
}}
/>
)}
</AppCard>
)}
{hasRainbowIntegration && (
<AppCard
name="Rainbow"
description={t("confirmation_page_rainbow")}
logo="/api/app-store/rainbow/icon.svg"
switchOnClick={(e) => {
if (!e) {
formMethods.setValue("blockchainId", 1);
formMethods.setValue("smartContractAddress", "");
}
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
<EventTypeAppContext.Provider value={[getAppData, setAppData]}>
<Component key={app.slug} app={app} eventType={eventType} />
</EventTypeAppContext.Provider>
</ErrorBoundary>
);
}
setShowRainbowSection(e);
}}
switchChecked={showRainbowSection}>
{showRainbowSection && (
<RainbowInstallForm
formMethods={formMethods}
blockchainId={(eventType.metadata.blockchainId as number) || 1}
smartContractAddress={(eventType.metadata.smartContractAddress as string) || ""}
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
const { t } = useLocale();
const { data: eventTypeApps, isLoading } = trpc.useQuery([
"viewer.apps",
{
extendsFeature: "EventType",
},
]);
const methods = useFormContext<FormValues>();
const installedApps = eventTypeApps?.filter((app) => app.credentials.length);
const notInstalledApps = eventTypeApps?.filter((app) => !app.credentials.length);
const allAppsData = methods.watch("metadata")?.apps || {};
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
methods.setValue("metadata", {
...methods.getValues("metadata"),
apps: _allAppsData,
});
};
const getAppDataGetter = (appId: EventTypeAppsList): GetAppData => {
return function (key) {
const appData = allAppsData[appId as keyof typeof allAppsData] || {};
if (key) {
return appData[key as keyof typeof appData];
}
return appData;
};
};
const getAppDataSetter = (appId: EventTypeAppsList): SetAppData => {
return function (key, value) {
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
const allAppsDataFromForm = methods.getValues("metadata")?.apps || {};
const appData = allAppsDataFromForm[appId];
setAllAppsData({
...allAppsDataFromForm,
[appId]: {
...appData,
[key]: value,
},
});
};
};
return (
<>
<div>
<div className="before:border-0">
{!isLoading && !installedApps?.length ? (
<EmptyScreen
Icon={Icon.FiGrid}
headline={t("empty_installed_apps_headline")}
description={t("empty_installed_apps_description")}
buttonRaw={
<Button target="_blank" color="secondary" href="/apps">
{t("empty_installed_apps_button")}{" "}
</Button>
}
/>
)}
</AppCard>
)}
</div>
) : null}
{installedApps?.map((app) => (
<AppCardWrapper
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}
app={app}
eventType={eventType}
/>
))}
</div>
</div>
<div>
{!isLoading && notInstalledApps?.length ? (
<h2 className="mt-0 mb-2 text-lg font-semibold text-gray-900">Available Apps</h2>
) : null}
<div className="before:border-0">
{notInstalledApps?.map((app) => (
<AppCardWrapper
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}
app={app}
eventType={eventType}
/>
))}
</div>
</div>
</>
);
};
@@ -1,19 +1,21 @@
import autoAnimate from "@formkit/auto-animate";
import * as RadioGroup from "@radix-ui/react-radio-group";
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { useState } from "react";
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import { useEffect, useRef, useState } from "react";
import { useFormContext, Controller, useWatch } from "react-hook-form";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { PeriodType } from "@calcom/prisma/client";
import { Select, Switch, Label, Input, MinutesField } from "@calcom/ui/v2";
import type { BookingLimit } from "@calcom/types/Calendar";
import { Icon } from "@calcom/ui";
import { Select, Switch, Label, Input, MinutesField, Button } from "@calcom/ui/v2";
import DateRangePicker from "@calcom/ui/v2/core/form/date-range-picker/DateRangePicker";
export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { eventType } = props;
const PERIOD_TYPES = [
{
type: "ROLLING" as const,
@@ -154,7 +156,49 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
/>
</div>
</div>
<hr className="my-8" />
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
<fieldset className="block flex-col sm:flex">
<div className="flex space-x-3">
<Controller
name="bookingLimits"
control={formMethods.control}
render={({ field: { value } }) => (
<Switch
fitToHeight={true}
checked={Object.keys(value ?? {}).length > 0}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("bookingLimits", {
PER_DAY: 1,
});
} else {
formMethods.setValue("bookingLimits", undefined);
}
}}
/>
)}
/>
<div className="">
<Label className="text-sm font-semibold leading-none text-black">
{t("limit_booking_frequency")}
</Label>
<p className="-mt-2 text-sm leading-normal text-gray-600">
{t("limit_booking_frequency_description")}
</p>
</div>
</div>
<div className="mt-4 lg:ml-14">
<BookingLimits />
</div>
</fieldset>
</div>
<hr className="my-8" />
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
<fieldset className="block flex-col sm:flex">
<div className="flex space-x-3">
@@ -261,3 +305,130 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
</div>
);
};
const validationOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
type BookingLimitsKey = keyof BookingLimit;
const BookingLimits = () => {
const { watch, setValue, control } = useFormContext<FormValues>();
const watchBookingLimits = watch("bookingLimits");
const animateRef = useRef(null);
const { t } = useLocale();
useEffect(() => {
animateRef.current && autoAnimate(animateRef.current);
}, [animateRef]);
const BOOKING_LIMIT_OPTIONS: {
value: keyof BookingLimit;
label: string;
}[] = [
{
value: "PER_DAY",
label: "Per Day",
},
{
value: "PER_WEEK",
label: "Per Week",
},
{
value: "PER_MONTH",
label: "Per Month",
},
{
value: "PER_YEAR",
label: "Per Year",
},
];
return (
<Controller
name="bookingLimits"
control={control}
render={({ field: { value, onChange } }) => {
const currentBookingLimits = value;
return (
<ul ref={animateRef}>
{currentBookingLimits &&
watchBookingLimits &&
Object.entries(currentBookingLimits)
.sort(([limitkeyA], [limitKeyB]) => {
return (
validationOrderKeys.indexOf(limitkeyA as BookingLimitsKey) -
validationOrderKeys.indexOf(limitKeyB as BookingLimitsKey)
);
})
.map(([key, bookingAmount]) => {
const bookingLimitKey = key as BookingLimitsKey;
return (
<div className="mb-2 flex items-center space-x-2 text-sm" key={bookingLimitKey}>
<Input
id={`${bookingLimitKey}-limit`}
type="number"
className="mb-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]"
placeholder="1"
min={1}
defaultValue={bookingAmount}
onChange={(e) => {
const val = e.target.value;
setValue(`bookingLimits.${bookingLimitKey}`, parseInt(val));
}}
/>
<Select
options={BOOKING_LIMIT_OPTIONS.filter(
(option) => !Object.keys(currentBookingLimits).includes(option.value)
)}
isSearchable={false}
defaultValue={BOOKING_LIMIT_OPTIONS.find((option) => option.value === key)}
onChange={(val) => {
const current = currentBookingLimits;
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
delete current[bookingLimitKey];
const newData = {
...current,
// Set limit to new selected value (in the example above this means we set the limit to per_week here).
[val?.value as BookingLimitsKey]: watchBookingLimits[bookingLimitKey],
};
onChange(newData);
}}
/>
<Button
size="icon"
StartIcon={Icon.FiTrash}
color="destructive"
onClick={() => {
const current = currentBookingLimits;
delete current[key as BookingLimitsKey];
onChange(current);
}}
/>
</div>
);
})}
{currentBookingLimits && Object.keys(currentBookingLimits).length <= 3 && (
<Button
color="minimal"
StartIcon={Icon.FiPlus}
onClick={() => {
if (!currentBookingLimits || !watchBookingLimits) return;
const currentKeys = Object.keys(watchBookingLimits);
const rest = Object.values(BOOKING_LIMIT_OPTIONS).filter(
(option) => !currentKeys.includes(option.value)
);
if (!rest || !currentKeys) return;
//currentBookingLimits is always defined so can be casted
setValue("bookingLimits", {
...watchBookingLimits,
[rest[0].value]: undefined,
});
}}>
{t("add_limit")}
</Button>
)}
</ul>
);
}}
/>
);
};
@@ -1,25 +1,18 @@
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { EventTypeSetupInfered } from "pages/event-types/[type]";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import RecurringEventController from "./RecurringEventController";
export const EventRecurringTab = ({
eventType,
hasPaymentIntegration,
}: Pick<EventTypeSetupInfered, "eventType" | "hasPaymentIntegration">) => {
const requirePayment = eventType.price > 0;
const [recurringEventDefined, setRecurringEventDefined] = useState(
eventType.recurringEvent?.count !== undefined
);
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
const stripeAppData = getStripeAppData(eventType);
const requirePayment = stripeAppData.price > 0;
return (
<div className="">
<RecurringEventController
paymentEnabled={hasPaymentIntegration && requirePayment}
onRecurringEventDefined={setRecurringEventDefined}
recurringEvent={eventType.recurringEvent}
/>
<RecurringEventController paymentEnabled={requirePayment} recurringEvent={eventType.recurringEvent} />
</div>
);
};
@@ -1,7 +1,7 @@
import autoAnimate from "@formkit/auto-animate";
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import { useEffect, useRef, useState } from "react";
import { Controller, useForm, useFormContext } from "react-hook-form";
import { z } from "zod";
@@ -1,5 +1,5 @@
import { SchedulingType } from "@prisma/client/";
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import { useMemo } from "react";
import { Controller, useFormContext } from "react-hook-form";
@@ -1,6 +1,6 @@
import { TFunction } from "next-i18next";
import { useRouter } from "next/router";
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import { useMemo, useState } from "react";
import { Loader } from "react-feather";
import { UseFormReturn } from "react-hook-form";
@@ -42,6 +42,7 @@ type Props = {
team: EventTypeSetupInfered["team"];
disableBorder?: boolean;
enabledAppsNumber: number;
installedAppsNumber: number;
enabledWorkflowsNumber: number;
formMethods: UseFormReturn<FormValues>;
};
@@ -51,8 +52,9 @@ function getNavigation(props: {
eventType: Props["eventType"];
enabledAppsNumber: number;
enabledWorkflowsNumber: number;
installedAppsNumber: number;
}) {
const { eventType, t, enabledAppsNumber, enabledWorkflowsNumber } = props;
const { eventType, t, enabledAppsNumber, installedAppsNumber, enabledWorkflowsNumber } = props;
return [
{
name: "event_setup_tab_title",
@@ -88,7 +90,8 @@ function getNavigation(props: {
name: "apps",
href: `/event-types/${eventType.id}?tabName=apps`,
icon: Icon.FiGrid,
info: `${enabledAppsNumber} ${t("active")}`,
//TODO: Handle proper translation with count handling
info: `${installedAppsNumber} apps, ${enabledAppsNumber} ${t("active")}`,
},
{
name: "workflows",
@@ -106,6 +109,7 @@ function EventTypeSingleLayout({
team,
disableBorder,
enabledAppsNumber,
installedAppsNumber,
enabledWorkflowsNumber,
formMethods,
}: Props) {
@@ -136,7 +140,13 @@ function EventTypeSingleLayout({
// Define tab navigation here
const EventTypeTabs = useMemo(() => {
const navigation = getNavigation({ t, eventType, enabledAppsNumber, enabledWorkflowsNumber });
const navigation = getNavigation({
t,
eventType,
enabledAppsNumber,
installedAppsNumber,
enabledWorkflowsNumber,
});
// If there is a team put this navigation item within the tabs
if (team)
navigation.splice(2, 0, {
@@ -146,7 +156,7 @@ function EventTypeSingleLayout({
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
});
return navigation;
}, [t, eventType, enabledAppsNumber, enabledWorkflowsNumber, team]);
}, [t, eventType, installedAppsNumber, enabledAppsNumber, enabledWorkflowsNumber, team]);
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
eventType.slug
@@ -157,7 +167,7 @@ function EventTypeSingleLayout({
return (
<Shell
backPath="/event-types"
title={t("event_type_title", { eventTypeTitle: eventType.title })}
title={eventType.title + " | " + t("event_type")}
heading={eventType.title}
subtitle={eventType.description || ""}
CTA={
@@ -228,27 +238,29 @@ function EventTypeSingleLayout({
<Icon.FiMoreHorizontal className="group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownMenuItem className="focus:ring-gray-100">
<Button
color="minimal"
StartIcon={Icon.FiExternalLink}
target="_blank"
href={permalink}
rel="noreferrer">
rel="noreferrer"
className="min-w-full">
{t("preview")}
</Button>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem className="focus:ring-gray-100">
<Button color="minimal" StartIcon={Icon.FiLink}>
{t("copy_link")}
</Button>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem className="focus:ring-gray-100">
<Button
color="minimal"
StartIcon={Icon.FiTrash}
disabled={!hasPermsToDelete}
onClick={() => setDeleteDialogOpen(true)}>
onClick={() => setDeleteDialogOpen(true)}
className="min-w-full">
{t("delete")}
</Button>
</DropdownMenuItem>
@@ -11,13 +11,11 @@ import { Label, Select, Switch } from "@calcom/ui/v2";
type RecurringEventControllerProps = {
recurringEvent: RecurringEvent | null;
paymentEnabled: boolean;
onRecurringEventDefined: (value: boolean) => void;
};
export default function RecurringEventController({
recurringEvent,
paymentEnabled,
onRecurringEventDefined,
}: RecurringEventControllerProps) {
const { t } = useLocale();
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
@@ -1,4 +1,4 @@
import { useMutation } from "react-query";
import { useMutation } from "@tanstack/react-query";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@@ -1,245 +0,0 @@
import { Webhook } from "@prisma/client";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import customTemplate, { hasTemplateIntegration } from "@calcom/features/webhooks/utils/integrationTemplate";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import { DialogFooter } from "@calcom/ui/Dialog";
import Switch from "@calcom/ui/Switch";
import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@calcom/ui/form/fields";
import { WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP } from "@lib/webhooks/constants";
import { TWebhook } from "@components/webhook/WebhookListItem";
import WebhookTestDisclosure from "@components/webhook/WebhookTestDisclosure";
/** @deprecated Moved to `packages/features/webhooks` */
export default function WebhookDialogForm(props: {
eventTypeId?: number;
defaultValues?: TWebhook;
app?: string;
handleClose: () => void;
webhooks: Webhook[];
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const appId = props.app;
const webhooks = props.webhooks;
const triggers = !appId
? WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP["core"]
: WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP[appId as keyof typeof WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP];
const {
defaultValues = {
id: "",
eventTriggers: triggers,
subscriberUrl: "",
active: true,
payloadTemplate: null,
secret: null,
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId" | "appId">,
} = props;
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
const [changeSecret, setChangeSecret] = useState(false);
const [newSecret, setNewSecret] = useState("");
const hasSecretKey = !!defaultValues.secret;
const currentSecret = defaultValues.secret;
const subscriberUrlReserved = (subscriberUrl: string, id: string): boolean => {
return !!webhooks.find((webhook) => webhook.subscriberUrl === subscriberUrl && webhook.id !== id);
};
const form = useForm({
defaultValues,
});
const handleInput = (event: React.FormEvent<HTMLInputElement>) => {
setNewSecret(event.currentTarget.value);
};
useEffect(() => {
if (changeSecret) {
form.unregister("secret", { keepDefaultValue: false });
}
}, [changeSecret]);
return (
<Form
data-testid="WebhookDialogForm"
form={form}
handleSubmit={async (event) => {
if (subscriberUrlReserved(event.subscriberUrl, event.id)) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}
const e = changeSecret
? { ...event, eventTypeId: props.eventTypeId, appId }
: { ...event, secret: currentSecret, eventTypeId: props.eventTypeId, appId };
if (!useCustomPayloadTemplate && event.payloadTemplate) {
event.payloadTemplate = null;
}
if (event.id) {
await utils.client.mutation("viewer.webhook.edit", e);
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_updated_successfully"), "success");
} else {
await utils.client.mutation("viewer.webhook.create", e);
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_created_successfully"), "success");
}
props.handleClose();
}}
className="space-y-4">
<div>
<input type="hidden" {...form.register("id")} />
</div>
<fieldset className="space-y-2">
<InputGroupBox className="border-0 bg-gray-50">
<Controller
control={form.control}
name="active"
render={({ field }) => (
<Switch
label={field.value ? t("webhook_enabled") : t("webhook_disabled")}
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("active", isChecked);
}}
/>
)}
/>
</InputGroupBox>
</fieldset>
<div>
<TextField
label={t("subscriber_url")}
{...form.register("subscriberUrl")}
required
type="url"
onChange={(e) => {
form.setValue("subscriberUrl", e.target.value);
if (hasTemplateIntegration({ url: e.target.value })) {
setUseCustomPayloadTemplate(true);
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
}
}}
/>
</div>
<fieldset className="space-y-2">
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
<InputGroupBox className="border-0 bg-gray-50">
{triggers.map((key) => (
<Controller
key={key}
control={form.control}
name="eventTriggers"
render={({ field }) => (
<Switch
label={t(key.toLowerCase())}
defaultChecked={field.value.includes(key)}
onCheckedChange={(isChecked) => {
const value = field.value;
const newValue = isChecked ? [...value, key] : value.filter((v) => v !== key);
form.setValue("eventTriggers", newValue, {
shouldDirty: true,
});
}}
/>
)}
/>
))}
</InputGroupBox>
</fieldset>
<fieldset className="space-y-2">
{!!hasSecretKey && !changeSecret && (
<>
<FieldsetLegend>{t("secret")}</FieldsetLegend>
<div className="rounded-sm bg-gray-50 p-2 text-xs text-neutral-900">
{t("forgotten_secret_description")}
</div>
<Button
color="secondary"
type="button"
className="py-1 text-xs"
onClick={() => {
setChangeSecret(true);
}}>
{t("change_secret")}
</Button>
</>
)}
{!!hasSecretKey && changeSecret && (
<>
<TextField
autoComplete="off"
label={t("secret")}
{...form.register("secret")}
value={newSecret}
onChange={handleInput}
type="text"
placeholder={t("leave_blank_to_remove_secret")}
/>
<Button
color="secondary"
type="button"
className="py-1 text-xs"
onClick={() => {
setChangeSecret(false);
}}>
{t("cancel")}
</Button>
</>
)}
{!hasSecretKey && (
<TextField autoComplete="off" label={t("secret")} {...form.register("secret")} type="text" />
)}
</fieldset>
<fieldset className="space-y-2">
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
<div className="space-x-3 text-sm rtl:space-x-reverse">
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
type="radio"
name="useCustomPayloadTemplate"
onChange={(value) => setUseCustomPayloadTemplate(!value.target.checked)}
defaultChecked={!useCustomPayloadTemplate}
/>{" "}
Default
</label>
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
onChange={(value) => setUseCustomPayloadTemplate(value.target.checked)}
name="useCustomPayloadTemplate"
type="radio"
defaultChecked={useCustomPayloadTemplate}
/>{" "}
Custom
</label>
</div>
{useCustomPayloadTemplate && (
<TextArea
{...form.register("payloadTemplate")}
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}
rows={3}
/>
)}
</fieldset>
<WebhookTestDisclosure />
<DialogFooter>
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</DialogFooter>
</Form>
);
}
@@ -1,103 +0,0 @@
import { useRouter } from "next/router";
import { useState } from "react";
import { trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
import { Icon } from "@calcom/ui/Icon";
import { List } from "@calcom/ui/List";
import { ShellSubHeading } from "@calcom/ui/Shell";
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
import { QueryCell } from "@lib/QueryCell";
import WebhookDialogForm from "@components/webhook/WebhookDialogForm";
import WebhookListItem, { TWebhook } from "@components/webhook/WebhookListItem";
export type WebhookListContainerType = {
title: string;
subtitle: string;
eventTypeId?: number;
appId?: string;
};
/** @deprecated Moved to `packages/features/webhooks` */
export default function WebhookListContainer(props: WebhookListContainerType) {
const router = useRouter();
const query = trpc.useQuery(
["viewer.webhook.list", { eventTypeId: props.eventTypeId, appId: props.appId }],
{
suspense: true,
enabled: router.isReady,
}
);
const [newWebhookModal, setNewWebhookModal] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editing, setEditing] = useState<TWebhook | null>(null);
return (
<QueryCell
query={query}
customLoader={<SkeletonLoader />}
success={({ data }) => (
<div className="border-b border-gray-200 py-8 pl-2 pr-1">
<ShellSubHeading
className="mt-2"
title={props.title}
subtitle={props.subtitle}
actions={
<Button
color="minimal"
size="icon"
StartIcon={Icon.FiPlus}
onClick={() => setNewWebhookModal(true)}
data-testid="new_webhook"
/>
}
/>
{data.length ? (
<List className="mt-6">
{data.map((item) => (
<WebhookListItem
key={item.id}
webhook={item}
onEditWebhook={() => {
setEditing(item);
setEditModalOpen(true);
}}
/>
))}
</List>
) : null}
{/* New webhook dialog */}
<Dialog open={newWebhookModal} onOpenChange={(isOpen) => !isOpen && setNewWebhookModal(false)}>
<DialogContent>
<WebhookDialogForm
app={props.appId}
webhooks={data}
eventTypeId={props.eventTypeId}
handleClose={() => setNewWebhookModal(false)}
/>
</DialogContent>
</Dialog>
{/* Edit webhook dialog */}
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
<DialogContent>
{editing && (
<WebhookDialogForm
app={props.appId}
key={editing.id}
webhooks={data}
eventTypeId={props.eventTypeId || undefined}
handleClose={() => setEditModalOpen(false)}
defaultValues={editing}
/>
)}
</DialogContent>
</Dialog>
</div>
)}
/>
);
}
@@ -1,94 +0,0 @@
import classNames from "@calcom/lib/classNames";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { Icon } from "@calcom/ui/Icon";
import { ListItem } from "@calcom/ui/List";
import { Tooltip } from "@calcom/ui/Tooltip";
import { useLocale } from "@lib/hooks/useLocale";
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
/** @deprecated Moved to `packages/features/webhooks` */
export default function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) {
const { t } = useLocale();
const utils = trpc.useContext();
const deleteWebhook = trpc.useMutation("viewer.webhook.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.webhook.list"]);
},
});
return (
<ListItem className={classNames("flex w-full p-4", props.webhook.active ? "bg-white" : "bg-gray-100")}>
<div className="flex w-full justify-between">
<div className="flex max-w-full flex-col truncate">
<div className="flex space-y-1">
<span
className={classNames(
"truncate text-sm",
props.webhook.active ? "text-neutral-700" : "text-neutral-400"
)}>
{props.webhook.subscriberUrl}
</span>
</div>
<div className="mt-2 flex">
<span className="flex flex-col space-x-2 space-y-1 text-xs sm:flex-row sm:space-y-0 sm:rtl:space-x-reverse">
{props.webhook.eventTriggers.map((eventTrigger, ind) => (
<span
key={ind}
className={classNames(
"w-max rounded-sm px-1 text-xs",
props.webhook.active ? "text-grey-200 bg-gray-200" : "bg-grey-50 text-neutral-400"
)}>
{t(`${eventTrigger.toLowerCase()}`)}
</span>
))}
</span>
</div>
</div>
<div className="flex">
<Tooltip side="top" content={t("edit_webhook")}>
<Button
onClick={() => props.onEditWebhook()}
color="minimal"
size="icon"
StartIcon={Icon.FiEdit2}
className="ml-4 w-full self-center p-2"
/>
</Tooltip>
<Dialog>
<Tooltip side="top" content={t("delete_webhook")}>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
size="icon"
color="warn"
StartIcon={Icon.FiTrash}
className="ml-2 w-full self-center p-2"
/>
</DialogTrigger>
</Tooltip>
<ConfirmationDialogContent
variety="danger"
title={t("delete_webhook")}
confirmBtnText={t("confirm_delete_webhook")}
cancelBtnText={t("cancel")}
onConfirm={() =>
deleteWebhook.mutate({
id: props.webhook.id,
eventTypeId: props.webhook.eventTypeId || undefined,
})
}>
{t("delete_webhook_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</ListItem>
);
}
@@ -1,63 +0,0 @@
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import { useState } from "react";
import { useWatch } from "react-hook-form";
import classNames from "@calcom/lib/classNames";
import showToast from "@calcom/lib/notification";
import { trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon";
import { InputGroupBox } from "@calcom/ui/form/fields";
import { useLocale } from "@lib/hooks/useLocale";
/** @deprecated Moved to `packages/features/webhooks` */
export default function WebhookTestDisclosure() {
const subscriberUrl: string = useWatch({ name: "subscriberUrl" });
const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null;
const { t } = useLocale();
const [open, setOpen] = useState(false);
const mutation = trpc.useMutation("viewer.webhook.testTrigger", {
onError(err) {
showToast(err.message, "error");
},
});
return (
<Collapsible open={open} onOpenChange={() => setOpen(!open)}>
<CollapsibleTrigger type="button" className="flex w-full cursor-pointer">
<Icon.FiChevronRight className={`${open ? "rotate-90 transform" : ""} h-5 w-5 text-neutral-500`} />
<span className="text-sm font-medium text-gray-700">{t("webhook_test")}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<InputGroupBox className="space-y-0 border-0 px-0">
<div className="flex justify-between bg-gray-50 p-2">
<h3 className="self-center text-gray-700">{t("webhook_response")}</h3>
<Button
type="button"
color="minimal"
disabled={mutation.isLoading}
onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING", payloadTemplate })}>
{t("ping_test")}
</Button>
</div>
<div className="border-8 border-gray-50 p-2 text-gray-500">
{!mutation.data && <em>{t("no_data_yet")}</em>}
{mutation.status === "success" && (
<>
<div
className={classNames(
"ml-auto w-max px-2 py-1 text-xs",
mutation.data.ok ? "bg-green-50 text-green-500" : "bg-red-50 text-red-500"
)}>
{mutation.data.ok ? t("success") : t("failed")}
</div>
<pre className="overflow-x-auto">{JSON.stringify(mutation.data, null, 4)}</pre>
</>
)}
</div>
</InputGroupBox>
</CollapsibleContent>
</Collapsible>
);
}
+5 -9
View File
@@ -1,16 +1,14 @@
import { ReactNode } from "react";
import {
QueryObserverIdleResult,
QueryObserverLoadingErrorResult,
QueryObserverLoadingResult,
QueryObserverRefetchErrorResult,
QueryObserverSuccessResult,
UseQueryResult,
} from "react-query";
} from "@tanstack/react-query";
import { ReactNode } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import type { UseTRPCQueryOptions } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type {
inferHandlerInput,
@@ -22,6 +20,8 @@ import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import { Alert } from "@calcom/ui/Alert";
import Loader from "@calcom/ui/Loader";
import type { UseTRPCQueryOptions } from "@trpc/react/shared";
type ErrorLike = {
message: string;
};
@@ -34,7 +34,6 @@ interface QueryCellOptionsBase<TData, TError extends ErrorLike> {
query: QueryObserverLoadingErrorResult<TData, TError> | QueryObserverRefetchErrorResult<TData, TError>
) => JSXElementOrNull;
loading?: (query: QueryObserverLoadingResult<TData, TError> | null) => JSXElementOrNull;
idle?: (query: QueryObserverIdleResult<TData, TError>) => JSXElementOrNull;
}
interface QueryCellOptionsNoEmpty<TData, TError extends ErrorLike>
@@ -84,14 +83,11 @@ export function QueryCell<TData, TError extends ErrorLike>(
);
}
if (query.status === "idle") {
return opts.idle?.(query) ?? StatusLoader;
}
// impossible state
return null;
}
type inferProcedures<TObj extends ProcedureRecord<any, any, any, any, any, any>> = {
type inferProcedures<TObj extends ProcedureRecord> = {
[TPath in keyof TObj]: {
input: inferProcedureInput<TObj[TPath]>;
output: inferProcedureOutput<TObj[TPath]>;
+4 -3
View File
@@ -9,7 +9,7 @@ import { ComponentProps, ReactNode } from "react";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
import { trpc } from "@calcom/trpc/react";
import { trpc, proxy } from "@calcom/trpc/react";
import { MetaProvider } from "@calcom/ui/v2/core/Meta";
import usePublicPage from "@lib/hooks/usePublicPage";
@@ -38,7 +38,8 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
* We intend to not cache i18n query
**/
const { i18n, locale } = trpc.useQuery(["viewer.public.i18n"], { context: { skipBatch: true } }).data ?? {
const { i18n, locale } = trpc.useQuery(["viewer.public.i18n"], { trpc: { context: { skipBatch: true } } })
.data ?? {
locale: "en",
};
@@ -54,7 +55,7 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
};
const AppProviders = (props: AppPropsWithChildren) => {
const session = trpc.useQuery(["viewer.public.session"]).data;
const session = proxy.public.session.useQuery().data;
// No need to have intercom on public pages - Good for Page Performance
const isPublicPage = usePublicPage();
const isThemeSupported =
+8 -4
View File
@@ -1,6 +1,10 @@
// handles logic related to user clock display using 24h display / timeZone options.
import dayjs from "@calcom/dayjs";
import { isBrowserLocale24h } from "@calcom/lib/timeFormat";
import {
getIs24hClockFromLocalStorage,
isBrowserLocale24h,
setIs24hClockInLocalStorage,
} from "@calcom/lib/timeFormat";
import { localStorage } from "@calcom/lib/webstorage";
interface TimeOptions {
@@ -20,8 +24,8 @@ const initClock = () => {
return;
}
// This only sets browser locale if there's no preference on localStorage.
if (!localStorage.getItem("timeOption.is24hClock")) set24hClock(isBrowserLocale24h());
timeOptions.is24hClock = localStorage.getItem("timeOption.is24hClock") === "true";
if (getIs24hClockFromLocalStorage() === null) set24hClock(isBrowserLocale24h());
timeOptions.is24hClock = !!getIs24hClockFromLocalStorage();
timeOptions.inviteeTimeZone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
};
@@ -32,7 +36,7 @@ const is24h = (is24hClock?: boolean) => {
};
const set24hClock = (is24hClock: boolean) => {
localStorage.setItem("timeOption.is24hClock", is24hClock.toString());
setIs24hClockInLocalStorage(is24hClock);
timeOptions.is24hClock = is24hClock;
};
+1
View File
@@ -10,6 +10,7 @@ async function getBooking(prisma: PrismaClient, uid: string) {
description: true,
customInputs: true,
smsReminderNumber: true,
location: true,
attendees: {
select: {
email: true,
+31 -7
View File
@@ -39,22 +39,46 @@ export const parseRecurringDates = (
const rule = new RRule({
...restRecurringEvent,
count: recurringCount,
dtstart: dayjs(startDate).toDate(),
dtstart: new Date(
Date.UTC(
dayjs.utc(startDate).get("year"),
dayjs.utc(startDate).get("month"),
dayjs.utc(startDate).get("date"),
dayjs.utc(startDate).get("hour"),
dayjs.utc(startDate).get("minute")
)
),
});
const utcOffset = dayjs(startDate).tz(timeZone).utcOffset();
const dateStrings = rule.all().map((r) => {
return processDate(dayjs(r).tz(timeZone), i18n);
return processDate(dayjs.utc(r).utcOffset(utcOffset), i18n);
});
return [dateStrings, rule.all()];
};
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
export const extractRecurringDates = (
bookings: BookingItem[],
booking: inferQueryOutput<"viewer.bookings">["bookings"][number] & {
eventType: { recurringEvent: RecurringEvent | null };
recurringEventId: string | null;
recurringBookings: inferQueryOutput<"viewer.bookings">["recurringInfo"];
},
timeZone: string | undefined,
i18n: I18n
): [string[], Date[]] => {
const dateStrings = bookings.map((booking) => processDate(dayjs(booking.startTime).tz(timeZone), i18n));
const allDates = dateStrings.map((dateString) => dayjs(dateString).tz(timeZone).toDate());
const { count = 0, ...rest } =
booking.eventType.recurringEvent !== null ? booking.eventType.recurringEvent : {};
const recurringInfo = booking.recurringBookings.find(
(val) => val.recurringEventId === booking.recurringEventId
);
const allDates = new RRule({
...rest,
count: recurringInfo?._count.recurringEventId,
dtstart: recurringInfo?._min.startTime,
}).all();
const utcOffset = dayjs(recurringInfo?._min.startTime).tz(timeZone).utcOffset();
const dateStrings = allDates.map((r) => {
return processDate(dayjs.utc(r).utcOffset(utcOffset), i18n);
});
return [dateStrings, allDates];
};
-18
View File
@@ -1,18 +0,0 @@
import { WebhookTriggerEvents } from "@prisma/client";
// this is exported as we can't use `WebhookTriggerEvents` in the frontend straight-off
export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
core: [
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.MEETING_ENDED,
] as const,
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as ["FORM_SUBMITTED"],
};
export const WEBHOOK_TRIGGER_EVENTS = [
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.core,
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP["routing-forms"],
] as const;
-45
View File
@@ -1,45 +0,0 @@
import { WebhookTriggerEvents } from "@prisma/client";
import prisma from "@calcom/prisma";
export type GetSubscriberOptions = {
userId: number;
eventTypeId: number;
triggerEvent: WebhookTriggerEvents;
};
/** @deprecated use `packages/lib/webhooks/subscriptions.tsx` */
const getWebhooks = async (options: GetSubscriberOptions) => {
const { userId, eventTypeId } = options;
const allWebhooks = await prisma.webhook.findMany({
where: {
OR: [
{
userId,
},
{
eventTypeId,
},
],
AND: {
eventTriggers: {
has: options.triggerEvent,
},
active: {
equals: true,
},
},
},
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
appId: true,
secret: true,
},
});
return allWebhooks;
};
export default getWebhooks;
-32
View File
@@ -5,28 +5,6 @@ import { CONSOLE_URL, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { isIpInBanlist } from "@calcom/lib/getIP";
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
const V2_WHITELIST = [
"/settings/admin",
"/settings/developer/webhooks",
"/settings/developer/api-keys",
"/settings/my-account",
"/settings/security",
"/settings/teams",
"/availability",
"/bookings",
"/event-types",
"/workflows",
"/apps",
"/teams",
"/success",
"/auth/login",
];
// For pages
// - which has V1 versions being modified as V2
// - Add routing_forms to keep old links working
const V2_BLACKLIST = ["/apps/routing_forms/", "/apps/routing-forms/", "/apps/typeform/"];
const middleware: NextMiddleware = async (req) => {
const url = req.nextUrl;
@@ -51,16 +29,6 @@ const middleware: NextMiddleware = async (req) => {
return NextResponse.rewrite(url);
}
/** Display available V2 pages */
if (
!V2_BLACKLIST.some((p) => url.pathname.startsWith(p)) &&
V2_WHITELIST.some((p) => url.pathname.startsWith(p))
) {
// rewrite to the current subdomain under the pages/sites folder
url.pathname = `/v2${url.pathname}`;
return NextResponse.rewrite(url);
}
return NextResponse.next();
};
+5
View File
@@ -154,6 +154,11 @@ const nextConfig = {
destination: "/app-store/:path*",
permanent: true,
},
{
source: "/auth/signup",
destination: "/signup",
permanent: true,
},
{
source: "/settings",
destination: "/settings/my-account/profile",
+13 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "2.0.2",
"version": "2.1.0",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -44,19 +44,21 @@
"@hookform/error-message": "^2.0.0",
"@hookform/resolvers": "^2.9.7",
"@next-auth/prisma-adapter": "^1.0.4",
"@next/bundle-analyzer": "12.1.6",
"@radix-ui/react-avatar": "^0.1.4",
"@radix-ui/react-collapsible": "^0.1.0",
"@radix-ui/react-dialog": "^0.1.0",
"@radix-ui/react-dropdown-menu": "^0.1.1",
"@radix-ui/react-id": "^0.1.0",
"@radix-ui/react-radio-group": "^0.1.1",
"@radix-ui/react-slider": "^0.1.1",
"@radix-ui/react-switch": "^0.1.1",
"@radix-ui/react-toggle-group": "^0.1.5",
"@next/bundle-analyzer": "^12.2.5",
"@radix-ui/react-avatar": "^1.0.0",
"@radix-ui/react-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-dropdown-menu": "^1.0.0",
"@radix-ui/react-id": "^1.0.0",
"@radix-ui/react-popover": "^1.0.0",
"@radix-ui/react-radio-group": "^1.0.0",
"@radix-ui/react-slider": "^1.0.0",
"@radix-ui/react-switch": "^1.0.0",
"@radix-ui/react-toggle-group": "^1.0.0",
"@radix-ui/react-tooltip": "^1.0.0",
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^4.3.9",
"@vercel/edge-functions-ui": "^0.2.1",
"@wojtekmaj/react-daterange-picker": "^3.3.1",
"accept-language-parser": "^1.5.0",
@@ -105,7 +107,6 @@
"react-live-chat-loader": "^2.7.3",
"react-multi-email": "^0.5.3",
"react-phone-number-input": "^3.2.7",
"react-query": "^3.39.2",
"react-select": "^5.4.0",
"react-timezone-select": "^1.3.2",
"react-use-intercom": "1.5.1",
+39 -35
View File
@@ -6,7 +6,6 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { Toaster } from "react-hot-toast";
import { JSONObject } from "superjson/dist/types";
import {
sdkActionManager,
@@ -26,6 +25,7 @@ import useTheme from "@calcom/lib/hooks/useTheme";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { BadgeCheckIcon, Icon } from "@calcom/ui/Icon";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
@@ -182,7 +182,6 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
<h2 className="dark:text-darkgray-700 pr-2 text-sm font-semibold text-gray-700">
{type.title}
</h2>
<p className="dark:text-darkgray-600 hidden text-sm font-normal leading-none text-gray-600 md:block">{`/${user.username}/${type.slug}`}</p>
</div>
<EventTypeDescription eventType={type} />
</a>
@@ -209,42 +208,47 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
}
User.isThemeSupported = true;
const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) => {
return await prisma.eventType.findMany({
where: {
AND: [
{
teamId: null,
},
{
OR: [
{
userId,
},
{
users: {
some: {
id: userId,
const getEventTypesWithHiddenFromDB = async (userId: number) => {
return (
await prisma.eventType.findMany({
where: {
AND: [
{
teamId: null,
},
{
OR: [
{
userId,
},
{
users: {
some: {
id: userId,
},
},
},
},
],
],
},
],
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
},
orderBy: [
{
position: "desc",
select: {
...baseEventTypeSelect,
metadata: true,
},
{
id: "asc",
},
],
select: {
metadata: true,
...baseEventTypeSelect,
},
});
})
).map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
}));
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
@@ -310,7 +314,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
darkBrandColor: user.darkBrandColor,
};
const eventTypesWithHidden = isDynamicGroup ? [] : await getEventTypesWithHiddenFromDB(user.id, user.plan);
const eventTypesWithHidden = isDynamicGroup ? [] : await getEventTypesWithHiddenFromDB(user.id);
const dataFetchEnd = Date.now();
if (context.query.log === "1") {
context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`);
@@ -319,7 +323,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const eventTypes = eventTypesRaw.map((eventType) => ({
...eventType,
metadata: (eventType.metadata || {}) as JSONObject,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
}));
const isSingleUser = users.length === 1;
+2 -2
View File
@@ -1,4 +1,3 @@
import { UserPlan } from "@prisma/client";
import { GetStaticPaths, GetStaticPropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { z } from "zod";
@@ -9,6 +8,7 @@ import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defa
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -148,7 +148,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
//TODO: Use zodSchema to verify it instead of using Type Assertion
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
const eventTypeObject = Object.assign({}, eventType, {
metadata: (eventType.metadata || {}) as JSONObject,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: privacyFilteredLocations(locations),
users: eventType.users.map((user) => ({
+9 -2
View File
@@ -35,6 +35,12 @@ function toRunBeforeReactOnClient() {
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> {
@@ -76,7 +82,6 @@ class MyDocument extends Document<Props> {
/>
</Head>
{/* Keep the embed hidden till parent initializes and gives it the appropriate styles */}
<body className="dark:bg-darkgray-50 desktop-transparent bg-gray-100">
<Main />
<NextScript />
@@ -86,7 +91,9 @@ class MyDocument extends Document<Props> {
dangerouslySetInnerHTML={{
__html: `
if (isEmbed()) {
document.body.style.display="none";
if(!isPageOptimizedForEmbed()) {
document.body.style.display="none";
}
document.body.style.background="transparent";
}`,
}}
+1 -1
View File
@@ -76,7 +76,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
// get user's credentials + their connected integrations
const calendarCredentials = getCalendarCredentials(user.credentials, user.id);
const calendarCredentials = getCalendarCredentials(user.credentials);
// get all the connected integrations' calendars (from third party)
const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty);
+7 -908
View File
@@ -1,915 +1,14 @@
import { BookingStatus, Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
import async from "async";
import type { NextApiRequest } from "next";
import { RRule } from "rrule";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getLocationValueForDB, LocationObject } from "@calcom/app-store/locations";
import { handleEthSignature } from "@calcom/app-store/rainbow/utils/ethereum";
import { handlePayment } from "@calcom/app-store/stripepayment/lib/server";
import { cancelScheduledJobs, scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
import EventManager from "@calcom/core/EventManager";
import { getEventName } from "@calcom/core/event";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import dayjs from "@calcom/dayjs";
import {
sendAttendeeRequestEmail,
sendOrganizerRequestEmail,
sendRescheduledEmails,
sendScheduledEmails,
sendScheduledSeatsEmails,
} from "@calcom/emails";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import getWebhooks from "@calcom/features/webhooks/utils/getWebhooks";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger";
import { defaultResponder, getLuckyUser } from "@calcom/lib/server";
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
import prisma, { userSelect } from "@calcom/prisma";
import { extendedBookingCreateBody, requiredCustomInputSchema } from "@calcom/prisma/zod-utils";
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import { getSession } from "@calcom/lib/auth";
import { defaultResponder } from "@calcom/lib/server";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
import sendPayload, { EventTypeInfo } from "@lib/webhooks/sendPayload";
import { getTranslation } from "@server/lib/i18n";
const translator = short();
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
type User = Prisma.UserGetPayload<typeof userSelect>;
type BufferedBusyTimes = BufferedBusyTime[];
/**
* Refreshes a Credential with fresh data from the database.
*
* @param credential
*/
async function refreshCredential(credential: Credential): Promise<Credential> {
const newCredential = await prisma.credential.findUnique({
where: {
id: credential.id,
},
});
if (!newCredential) {
return credential;
} else {
return newCredential;
}
}
/**
* Refreshes the given set of credentials.
*
* @param credentials
*/
async function refreshCredentials(credentials: Array<Credential>): Promise<Array<Credential>> {
return await async.mapLimit(credentials, 5, refreshCredential);
}
function isAvailable(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, length: number): boolean {
// Check for conflicts
let t = true;
// Early return
if (!Array.isArray(busyTimes) || busyTimes.length < 1) return t;
let i = 0;
while (t === true && i < busyTimes.length) {
const busyTime = busyTimes[i];
i++;
const startTime = dayjs(busyTime.start);
const endTime = dayjs(busyTime.end);
// Check if time is between start and end times
if (dayjs(time).isBetween(startTime, endTime, null, "[)")) {
t = false;
break;
}
// Check if slot end time is between start and end time
if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) {
t = false;
break;
}
// Check if startTime is between slot
if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) {
t = false;
break;
}
}
return t;
}
const getEventTypesFromDB = async (eventTypeId: number) => {
const eventType = await prisma.eventType.findUniqueOrThrow({
where: {
id: eventTypeId,
},
select: {
id: true,
customInputs: true,
users: userSelect,
team: {
select: {
id: true,
name: true,
},
},
title: true,
length: true,
eventName: true,
schedulingType: true,
description: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
userId: true,
price: true,
currency: true,
metadata: true,
destinationCalendar: true,
hideCalendarNotes: true,
seatsPerTimeSlot: true,
recurringEvent: true,
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
locations: true,
timeZone: true,
schedule: {
select: {
availability: true,
timeZone: true,
},
},
availability: {
select: {
startTime: true,
endTime: true,
days: true,
},
},
},
});
return {
...eventType,
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: (eventType.locations ?? []) as LocationObject[],
};
};
async function ensureAvailableUsers(
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
users: User[];
},
input: { dateFrom: string; dateTo: string }
) {
const availableUsers: typeof eventType.users = [];
/** Let's start checking for availability */
for (const user of eventType.users) {
const { busy: bufferedBusyTimes } = await getUserAvailability(
{
userId: user.id,
eventTypeId: eventType.id,
...input,
},
{ user, eventType }
);
console.log("calendarBusyTimes==>>>", bufferedBusyTimes);
let isAvailableToBeBooked = true;
try {
if (eventType.recurringEvent) {
const recurringEvent = parseRecurringEvent(eventType.recurringEvent);
const allBookingDates = new RRule({ dtstart: new Date(input.dateFrom), ...recurringEvent }).all();
// Go through each date for the recurring event and check if each one's availability
// DONE: Decreased computational complexity from O(2^n) to O(n) by refactoring this loop to stop
// running at the first unavailable time.
let i = 0;
while (isAvailableToBeBooked === true && i < allBookingDates.length) {
const aDate = allBookingDates[i];
i++;
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, aDate, eventType.length);
// We bail at the first false, we don't need to keep checking
if (!isAvailableToBeBooked) break;
}
} else {
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, input.dateFrom, eventType.length);
}
} catch {
log.debug({
message: "Unable set isAvailableToBeBooked. Using true. ",
});
}
if (isAvailableToBeBooked) {
availableUsers.push(user);
}
}
if (!availableUsers.length) {
throw new Error("No available users found.");
}
return availableUsers;
}
async function handler(req: NextApiRequest) {
async function handler(req: NextApiRequest & { userId?: number }) {
const session = await getSession({ req });
const currentUser = session?.user;
const { recurringCount, noEmail, eventTypeSlug, eventTypeId, hasHashedBookingLink, language, ...reqBody } =
extendedBookingCreateBody.parse(req.body);
// handle dynamic user
const dynamicUserList = Array.isArray(reqBody.user)
? getGroupName(reqBody.user)
: getUsernameList(reqBody.user);
const tAttendees = await getTranslation(language ?? "en", "common");
const tGuests = await getTranslation("en", "common");
log.debug(`Booking eventType ${eventTypeId} started`);
const eventType =
!eventTypeId && !!eventTypeSlug ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId);
if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" });
// Check if required custom inputs exist
if (eventType.customInputs) {
eventType.customInputs.forEach((customInput) => {
if (customInput.required) {
requiredCustomInputSchema.parse(
reqBody.customInputs.find((userInput) => userInput.label === customInput.label)?.value
);
}
});
}
let timeOutOfBounds = false;
try {
timeOutOfBounds = isOutOfBounds(reqBody.start, {
periodType: eventType.periodType,
periodDays: eventType.periodDays,
periodEndDate: eventType.periodEndDate,
periodStartDate: eventType.periodStartDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
});
} catch (error) {
if (error instanceof BookingDateInPastError) {
// TODO: HttpError should not bleed through to the console.
log.info(`Booking eventType ${eventTypeId} failed`, error);
throw new HttpError({ statusCode: 400, message: error.message });
}
log.debug({
message: "Unable set timeOutOfBounds. Using false. ",
});
}
if (timeOutOfBounds) {
const error = {
errorCode: "BookingTimeOutOfBounds",
message: `EventType '${eventType.eventName}' cannot be booked at this time.`,
};
throw new HttpError({ statusCode: 400, message: error.message });
}
let users = !eventTypeId
? await prisma.user.findMany({
where: {
username: {
in: dynamicUserList,
},
},
...userSelect,
})
: eventType.users;
const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking);
if (!isDynamicAllowed && !eventTypeId) {
throw new HttpError({
message: "Some of the users in this group do not allow dynamic booking",
statusCode: 400,
});
}
// If this event was pre-relationship migration
// TODO: Establish whether this is dead code.
if (!users.length && eventType.userId) {
const eventTypeUser = await prisma.user.findUnique({
where: {
id: eventType.userId,
},
...userSelect,
});
if (!eventTypeUser) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
users.push(eventTypeUser);
}
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
if (!eventType.seatsPerTimeSlot) {
const availableUsers = await ensureAvailableUsers(
{
...eventType,
users,
},
{
dateFrom: reqBody.start,
dateTo: reqBody.end,
}
);
// Add an if conditional if there are no seats on the event type
// Assign to only one user when ROUND_ROBIN
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
users = [await getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers, eventTypeId: eventType.id })];
} else {
// excluding ROUND_ROBIN, all users have availability required.
if (availableUsers.length !== users.length) {
throw new Error("Some users are unavailable for booking.");
}
users = availableUsers;
}
}
console.log("available users", users);
// @TODO: use the returned address somewhere in booking creation?
// const address: string | undefined = await ...
await handleEthSignature(eventType.metadata, reqBody.ethSignature);
const [organizerUser] = users;
const tOrganizer = await getTranslation(organizerUser.locale ?? "en", "common");
const invitee = [
{
email: reqBody.email,
name: reqBody.name,
timeZone: reqBody.timeZone,
language: { translate: tAttendees, locale: language ?? "en" },
},
];
const guests = (reqBody.guests || []).map((guest) => ({
email: guest,
name: "",
timeZone: reqBody.timeZone,
language: { translate: tGuests, locale: "en" },
}));
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const bookingLocation = getLocationValueForDB(reqBody.location, eventType.locations);
console.log(bookingLocation, reqBody.location, eventType.locations);
const customInputs = {} as NonNullable<CalendarEvent["customInputs"]>;
const teamMemberPromises =
eventType.schedulingType === SchedulingType.COLLECTIVE
? users.slice(1).map(async function (user) {
return {
email: user.email || "",
name: user.name || "",
timeZone: user.timeZone,
language: {
translate: await getTranslation(user.locale ?? "en", "common"),
locale: user.locale ?? "en",
},
};
})
: [];
const teamMembers = await Promise.all(teamMemberPromises);
const attendeesList = [...invitee, ...guests, ...teamMembers];
const eventNameObject = {
attendeeName: reqBody.name || "Nameless",
eventType: eventType.title,
eventName: eventType.eventName,
host: organizerUser.name || "Nameless",
location: bookingLocation,
t: tOrganizer,
};
const additionalNotes = reqBody.notes;
let evt: CalendarEvent = {
type: eventType.title,
title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
description: eventType.description,
additionalNotes,
customInputs,
startTime: dayjs(reqBody.start).utc().format(),
endTime: dayjs(reqBody.end).utc().format(),
organizer: {
name: organizerUser.name || "Nameless",
email: organizerUser.email || "Email-less",
timeZone: organizerUser.timeZone,
language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" },
},
attendees: attendeesList,
location: bookingLocation, // Will be processed by the EventManager later.
/** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */
destinationCalendar: eventType.destinationCalendar || organizerUser.destinationCalendar,
hideCalendarNotes: eventType.hideCalendarNotes,
requiresConfirmation: eventType.requiresConfirmation ?? false,
eventTypeId: eventType.id,
};
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
if (reqBody.bookingUid) {
if (!eventType.seatsPerTimeSlot)
throw new HttpError({ statusCode: 404, message: "Event type does not have seats" });
const booking = await prisma.booking.findUnique({
where: {
uid: reqBody.bookingUid,
},
select: {
id: true,
attendees: true,
userId: true,
references: {
select: {
type: true,
uid: true,
meetingId: true,
meetingPassword: true,
meetingUrl: true,
externalCalendarId: true,
},
},
},
});
if (!booking) throw new HttpError({ statusCode: 404, message: "Booking not found" });
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
const bookingAttendees = booking.attendees.map((attendee) => {
return { ...attendee, language: { translate: tAttendees, locale: language ?? "en" } };
});
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
if (eventType.seatsPerTimeSlot <= booking.attendees.length)
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
if (booking.attendees.some((attendee) => attendee.email === invitee[0].email))
throw new HttpError({ statusCode: 409, message: "Already signed up for time slot" });
await prisma.booking.update({
where: {
uid: reqBody.bookingUid,
},
data: {
attendees: {
create: {
email: invitee[0].email,
name: invitee[0].name,
timeZone: invitee[0].timeZone,
locale: invitee[0].language.locale,
},
},
},
});
const newSeat = booking.attendees.length !== 0;
await sendScheduledSeatsEmails(evt, invitee[0], newSeat);
const credentials = await refreshCredentials(organizerUser.credentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
await eventManager.updateCalendarAttendees(evt, booking);
req.statusCode = 201;
return booking;
}
if (reqBody.customInputs.length > 0) {
reqBody.customInputs.forEach(({ label, value }) => {
customInputs[label] = value;
});
}
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
evt.team = {
members: users.map((user) => user.name || user.username || "Nameless"),
name: eventType.team?.name || "Nameless",
}; // used for invitee emails
}
if (reqBody.recurringEventId && eventType.recurringEvent) {
// Overriding the recurring event configuration count to be the actual number of events booked for
// the recurring event (equal or less than recurring event configuration count)
eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { count: recurringCount });
evt.recurringEvent = eventType.recurringEvent;
}
// Initialize EventManager with credentials
const rescheduleUid = reqBody.rescheduleUid;
async function getOriginalRescheduledBooking(uid: string) {
return prisma.booking.findFirst({
where: {
uid,
status: {
in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING],
},
},
include: {
attendees: {
select: {
name: true,
email: true,
locale: true,
timeZone: true,
},
},
user: {
select: {
id: true,
name: true,
email: true,
locale: true,
timeZone: true,
},
},
payment: true,
},
});
}
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
let originalRescheduledBooking: BookingType = null;
if (rescheduleUid) {
originalRescheduledBooking = await getOriginalRescheduledBooking(rescheduleUid);
}
async function createBooking() {
if (originalRescheduledBooking) {
evt.title = originalRescheduledBooking?.title || evt.title;
evt.description = originalRescheduledBooking?.description || evt.additionalNotes;
evt.location = originalRescheduledBooking?.location;
}
const eventTypeRel = !eventTypeId
? {}
: {
connect: {
id: eventTypeId,
},
};
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
// If the user is not the owner of the event, new booking should be always pending.
// Otherwise, an owner rescheduling should be always accepted.
const userReschedulingIsOwner = currentUser
? originalRescheduledBooking?.user?.id === currentUser.id
: false;
const isConfirmedByDefault =
(!eventType.requiresConfirmation && !eventType.price) || userReschedulingIsOwner;
const newBookingData: Prisma.BookingCreateInput = {
uid,
title: evt.title,
startTime: dayjs.utc(evt.startTime).toDate(),
endTime: dayjs.utc(evt.endTime).toDate(),
description: evt.additionalNotes,
customInputs: isPrismaObjOrUndefined(evt.customInputs),
status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
location: evt.location,
eventType: eventTypeRel,
smsReminderNumber: reqBody.smsReminderNumber,
attendees: {
createMany: {
data: evt.attendees.map((attendee) => {
//if attendee is team member, it should fetch their locale not booker's locale
//perhaps make email fetch request to see if his locale is stored, else
const retObj = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
locale: attendee.language.locale,
};
return retObj;
}),
},
},
dynamicEventSlugRef,
dynamicGroupSlugRef,
user: {
connect: {
id: organizerUser.id,
},
},
destinationCalendar: evt.destinationCalendar
? {
connect: { id: evt.destinationCalendar.id },
}
: undefined,
};
if (reqBody.recurringEventId) {
newBookingData.recurringEventId = reqBody.recurringEventId;
}
if (originalRescheduledBooking) {
newBookingData["paid"] = originalRescheduledBooking.paid;
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
if (newBookingData.attendees?.createMany?.data) {
newBookingData.attendees.createMany.data = originalRescheduledBooking.attendees;
}
if (originalRescheduledBooking.recurringEventId) {
newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId;
}
}
const createBookingObj = {
include: {
user: {
select: { email: true, name: true, timeZone: true },
},
attendees: true,
payment: true,
},
data: newBookingData,
};
if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) {
const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success);
if (bookingPayment) {
createBookingObj.data.payment = {
connect: { id: bookingPayment.id },
};
}
}
if (typeof eventType.price === "number" && eventType.price > 0) {
/* Validate if there is any stripe_payment credential for this user */
/* note: removes custom error message about stripe */
await prisma.credential.findFirstOrThrow({
where: {
type: "stripe_payment",
userId: organizerUser.id,
},
select: {
id: true,
},
});
}
return prisma.booking.create(createBookingObj);
}
let results: EventResult<AdditionalInformation>[] = [];
let referencesToCreate: PartialReference[] = [];
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
let booking: Booking | null = null;
try {
booking = await createBooking();
// Sync Services
await syncServicesUpdateWebUser(
currentUser &&
(await prisma.user.findFirst({
where: { id: currentUser.id },
select: { id: true, email: true, name: true, plan: true, username: true, createdDate: true },
}))
);
evt.uid = booking?.uid ?? null;
} catch (_err) {
const err = getErrorFromUnknown(_err);
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
if (err.code === "P2002") {
throw new HttpError({ statusCode: 409, message: "booking.conflict" });
}
throw err;
}
// After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again.
const credentials = await refreshCredentials(organizerUser.credentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
if (originalRescheduledBooking?.uid) {
// Use EventManager to conditionally use all needed integrations.
const updateManager = await eventManager.reschedule(
evt,
originalRescheduledBooking.uid,
booking?.id,
reqBody.rescheduleReason
);
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
results = updateManager.results;
referencesToCreate = updateManager.referencesToCreate;
if (results.length > 0 && results.some((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
log.error(`Booking ${organizerUser.name} failed`, error, results);
} else {
const metadata: AdditionalInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
? results[0].updatedEvent
: [results[0].updatedEvent];
if (updatedEvent) {
metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints;
}
}
if (noEmail !== true) {
await sendRescheduledEmails({
...evt,
additionalInformation: metadata,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: reqBody.rescheduleReason,
});
}
}
// If it's not a reschedule, doesn't require confirmation and there's no price,
// Create a booking
} else if (!eventType.requiresConfirmation && !eventType.price) {
// Use EventManager to conditionally use all needed integrations.
const createManager = await eventManager.create(evt);
// This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
results = createManager.results;
referencesToCreate = createManager.referencesToCreate;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
message: "Booking failed",
};
log.error(`Booking ${organizerUser.username} failed`, error, results);
} else {
const metadata: AdditionalInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
}
if (noEmail !== true) {
await sendScheduledEmails({
...evt,
additionalInformation: metadata,
additionalNotes,
customInputs,
});
}
}
}
if (eventType.requiresConfirmation && !rescheduleUid && noEmail !== true) {
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
}
if (
!Number.isNaN(eventType.price) &&
eventType.price > 0 &&
!originalRescheduledBooking?.paid &&
!!booking
) {
const [firstStripeCredential] = organizerUser.credentials.filter((cred) => cred.type == "stripe_payment");
if (!firstStripeCredential)
throw new HttpError({ statusCode: 400, message: "Missing payment credentials" });
if (!booking.user) booking.user = organizerUser;
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);
req.statusCode = 201;
return { ...booking, message: "Payment required", paymentUid: payment.uid };
}
log.debug(`Booking ${organizerUser.username} completed`);
const eventTrigger: WebhookTriggerEvents = rescheduleUid
? WebhookTriggerEvents.BOOKING_RESCHEDULED
: WebhookTriggerEvents.BOOKING_CREATED;
const subscriberOptions = {
userId: organizerUser.id,
eventTypeId,
triggerEvent: eventTrigger,
};
const subscriberOptionsMeetingEnded = {
userId: organizerUser.id,
eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
};
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
subscribersMeetingEnded.forEach((subscriber) => {
if (rescheduleUid && originalRescheduledBooking) {
cancelScheduledJobs(originalRescheduledBooking);
}
if (booking && booking.status === BookingStatus.ACCEPTED) {
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber);
}
});
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
const subscribers = await getWebhooks(subscriberOptions);
console.log("evt:", {
...evt,
metadata: reqBody.metadata,
});
const bookingId = booking?.id;
const eventTypeInfo: EventTypeInfo = {
eventTitle: eventType.title,
eventDescription: eventType.description,
requiresConfirmation: eventType.requiresConfirmation || null,
price: eventType.price,
currency: eventType.currency,
length: eventType.length,
};
const promises = subscribers.map((sub) =>
sendPayload(sub.secret, eventTrigger, new Date().toISOString(), sub, {
...evt,
...eventTypeInfo,
bookingId,
rescheduleUid,
metadata: reqBody.metadata,
eventTypeId,
status: eventType.requiresConfirmation ? "PENDING" : "ACCEPTED",
}).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
})
);
await Promise.all(promises);
// Avoid passing referencesToCreate with id unique constrain values
// refresh hashed link if used
const urlSeed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}`;
const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL));
if (hasHashedBookingLink) {
await prisma.hashedLink.update({
where: {
link: reqBody.hashedLink as string,
},
data: {
link: hashedUid,
},
});
}
if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" });
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
references: {
createMany: {
data: referencesToCreate,
},
},
},
});
await scheduleWorkflowReminders(
eventType.workflows,
reqBody.smsReminderNumber as string | null,
evt,
evt.requiresConfirmation || false,
rescheduleUid ? true : false
);
// booking successful
req.statusCode = 201;
/* To mimic API behavior */
req.userId = session?.user?.id;
const booking = await handleNewBooking(req);
return booking;
}
@@ -18,14 +18,13 @@ import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/direc
import { deleteMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { sendRequestRescheduleEmail } from "@calcom/emails";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import sendPayload from "@lib/webhooks/sendPayload";
import getWebhooks from "@lib/webhooks/subscriptions";
export type RescheduleResponse = Booking & {
attendees: Attendee[];
};
+3 -4
View File
@@ -20,16 +20,15 @@ import { sendCancelledEmails } from "@calcom/emails";
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload, { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getSession } from "@calcom/lib/auth";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { getSession } from "@lib/auth";
import sendPayload, { EventTypeInfo } from "@lib/webhooks/sendPayload";
import getWebhooks from "@lib/webhooks/subscriptions";
import { getTranslation } from "@server/lib/i18n";
const bodySchema = z.object({
+9 -39
View File
@@ -10,11 +10,9 @@ import path from "path";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import prisma from "@calcom/prisma";
import useMediaQuery from "@lib/hooks/useMediaQuery";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import App from "@components/App";
import Slider from "@components/apps/Slider";
import App from "@components/v2/apps/App";
const components = {
a: ({ href = "", ...otherProps }: JSX.IntrinsicElements["a"]) => (
@@ -25,42 +23,11 @@ const components = {
img: ({ src = "", alt = "", placeholder, ...rest }: JSX.IntrinsicElements["img"]) => (
<Image src={src} alt={alt} {...rest} />
),
Slider: ({ items }: { items: string[] }) => {
const isTabletAndUp = useMediaQuery("(min-width: 960px)");
return (
<Slider<string>
items={items}
title="Screenshots"
options={{
perView: 1,
}}
renderItem={(item) =>
isTabletAndUp ? (
<Image
src={item}
alt=""
loading="eager"
layout="fixed"
objectFit="contain"
objectPosition="center center"
width={573}
height={382}
/>
) : (
<Image
src={item}
alt=""
layout="responsive"
objectFit="contain"
objectPosition="center center"
width={573}
height={382}
/>
)
}
/>
);
},
// @TODO: In v2 the slider isn't shown anymore. However, to ensure the v1 pages keep
// working, this component is still rendered in the MDX content. To skip them in the v2
// content we have to render null here. In v2 the gallery is shown by directly
// using the `items` property from the MDX's meta data.
Slider: () => <></>,
};
function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
@@ -68,6 +35,8 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
<App
name={data.name}
isGlobal={data.isGlobal}
slug={data.slug}
variant={data.variant}
type={data.type}
logo={data.logo}
categories={data.categories ?? [data.category]}
@@ -80,6 +49,7 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
email={data.email}
licenseRequired={data.licenseRequired}
isProOnly={data.isProOnly}
images={source?.scope?.items as string[] | undefined}
// tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy"
body={<MDXRemote {...source} components={components} />}
+3 -8
View File
@@ -2,9 +2,8 @@ import { GetStaticPaths, InferGetStaticPropsType } from "next";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
import { getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
import Loader from "@calcom/ui/Loader";
import { AppSetupPage } from "@calcom/app-store/_pages/v2/setup";
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
const router = useRouter();
@@ -12,11 +11,7 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
const { status } = useSession();
if (status === "loading") {
return (
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-200">
<Loader />
</div>
);
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
}
if (status === "unauthenticated") {
@@ -28,7 +23,7 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
});
}
return <AppSetupPage slug={slug} {...props} />;
return <AppSetupPage slug={`${slug}-V2`} {...props} />;
}
export const getStaticPaths: GetStaticPaths = async () => {
+22 -13
View File
@@ -7,31 +7,40 @@ import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import AppCard from "@components/apps/AppCard";
import { SkeletonText } from "@calcom/ui/v2";
import Shell from "@calcom/ui/v2/core/Shell";
import AppCard from "@calcom/ui/v2/core/apps/AppCard";
export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) {
const { t } = useLocale();
const { t, isLocaleReady } = useLocale();
const router = useRouter();
const { category } = router.query;
return (
<>
<Shell isPublic large>
<div className="-mx-4 md:-mx-8">
<div className="mb-10 bg-gray-50 px-4 pb-2">
<Shell
isPublic
backPath="/apps"
heading={
<>
<Link href="/apps">
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
<Icon.FiChevronLeft className="h-5 w-5" /> {t("browse_apps")}
<a className="inline-flex items-center justify-start gap-1 rounded-sm py-2 text-gray-900">
{isLocaleReady ? t("app_store") : <SkeletonText className="h-4 w-24" />}{" "}
</a>
</Link>
</div>
</div>
{category && (
<span className="gap-1 text-gray-600">
<span>&nbsp;/&nbsp;</span>
{t("category_apps", { category: category[0].toUpperCase() + category?.slice(1) })}
</span>
)}
</>
}
large>
<div className="mb-16">
<h2 className="mb-2 text-lg font-semibold text-gray-900">All {router.query.category} apps</h2>
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
{apps.map((app) => {
return <AppCard key={app.name} app={app} />;
return <AppCard key={app.slug} app={app} />;
})}
</div>
</div>
+26 -12
View File
@@ -5,25 +5,39 @@ import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import AppStoreCategories from "@components/apps/Categories";
import { SkeletonText } from "@calcom/ui/v2";
export default function Apps({ categories }: InferGetStaticPropsType<typeof getStaticProps>) {
const { t } = useLocale();
const { t, isLocaleReady } = useLocale();
return (
<Shell isPublic large>
<div className="-mx-4 md:-mx-8">
<div className="mb-10 bg-gray-50 px-4 pb-2">
<Link href="/apps">
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
<Icon.FiChevronLeft className="h-5 w-5" /> {t("browse_apps")}
</a>
</Link>
</div>
<div className="text-md flex items-center gap-1 px-4 pb-3 pt-3 font-normal md:px-8 lg:px-0 lg:pt-0">
<Link href="/apps">
<a className="inline-flex items-center justify-start gap-1 rounded-sm py-2 text-gray-900">
<Icon.FiArrowLeft className="h-4 w-4" />
{isLocaleReady ? t("app_store") : <SkeletonText className="h-6 w-24" />}{" "}
</a>
</Link>
</div>
<div className="mb-16">
<AppStoreCategories categories={categories} />
<div className="grid h-auto w-full grid-cols-5 gap-3">
{categories.map((category) => (
<Link key={category.name} href={"/apps/categories/" + category.name}>
<a
data-testid={`app-store-category-${category.name}`}
className="relative flex rounded-sm bg-gray-100 px-6 py-4 sm:block">
<div className="self-center">
<h3 className="font-medium capitalize">{category.name}</h3>
<p className="text-sm text-gray-500">
{t("number_apps", { count: category.count })}{" "}
<Icon.FiArrowRight className="inline-block h-4 w-4" />
</p>
</div>
</a>
</Link>
))}
</div>
</div>
</Shell>
);
+34 -25
View File
@@ -1,37 +1,39 @@
import { InferGetStaticPropsType } from "next";
import { InferGetStaticPropsType, NextPageContext } from "next";
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getSession } from "@calcom/lib/auth";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import Shell from "@calcom/ui/Shell";
import type { AppCategories } from "@calcom/prisma/client";
import AllApps from "@calcom/ui/v2/core/apps/AllApps";
import AppStoreCategories from "@calcom/ui/v2/core/apps/Categories";
import TrendingAppsSlider from "@calcom/ui/v2/core/apps/TrendingAppsSlider";
import AppsLayout from "@calcom/ui/v2/core/layouts/AppsLayout";
import AppsShell from "@components/AppsShell";
import AllApps from "@components/apps/AllApps";
import AppStoreCategories from "@components/apps/Categories";
import TrendingAppsSlider from "@components/apps/TrendingAppsSlider";
export default function Apps({ appStore, categories }: InferGetStaticPropsType<typeof getStaticProps>) {
export default function Apps({ appStore, categories }: InferGetStaticPropsType<typeof getServerSideProps>) {
const { t } = useLocale();
return (
<Shell heading={t("app_store")} subtitle={t("app_store_description")} large isPublic>
<AppsShell>
<AppStoreCategories categories={categories} />
<TrendingAppsSlider items={appStore} />
<AllApps apps={appStore} />
</AppsShell>
</Shell>
<AppsLayout isPublic heading={t("app_store")} subtitle={t("app_store_description")}>
<AppStoreCategories categories={categories} />
<TrendingAppsSlider items={appStore} />
<AllApps apps={appStore} />
</AppsLayout>
);
}
export const getStaticProps = async () => {
const appStore = await getAppRegistry();
export const getServerSideProps = async (context: NextPageContext) => {
const session = await getSession(context);
const categoryQuery = await prisma.app.findMany({
select: {
categories: true,
},
});
let appStore;
if (session?.user?.id) {
appStore = await getAppRegistryWithCredentials(session.user.id);
} else {
appStore = await getAppRegistry();
}
const categoryQuery = appStore.map(({ categories }) => ({
categories: categories || [],
}));
const categories = categoryQuery.reduce((c, app) => {
for (const category of app.categories) {
c[category] = c[category] ? c[category] + 1 : 1;
@@ -40,7 +42,14 @@ export const getStaticProps = async () => {
}, {} as Record<string, number>);
return {
props: {
categories: Object.entries(categories).map(([name, count]) => ({ name, count })),
categories: Object.entries(categories)
.map(([name, count]): { name: AppCategories; count: number } => ({
name: name as AppCategories,
count,
}))
.sort(function (a, b) {
return b.count - a.count;
}),
appStore,
},
};
-200
View File
@@ -1,200 +0,0 @@
import React from "react";
import { InstallAppButton } from "@calcom/app-store/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { App } from "@calcom/types/App";
import { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProps";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import EmptyScreen from "@calcom/ui/EmptyScreen";
import { Icon } from "@calcom/ui/Icon";
import { List } from "@calcom/ui/List";
import Shell, { ShellSubHeading } from "@calcom/ui/Shell";
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
import { QueryCell } from "@lib/QueryCell";
import AppsShell from "@components/AppsShell";
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
import IntegrationListItem from "@components/integrations/IntegrationListItem";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
function ConnectOrDisconnectIntegrationButton(props: {
credentialIds: number[];
type: App["type"];
isGlobal?: boolean;
installed?: boolean;
}) {
const { t } = useLocale();
const [credentialId] = props.credentialIds;
const type = props.type;
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.invalidateQueries(["viewer.integrations"]);
};
if (credentialId) {
if (type === "stripe_payment") {
return (
<DisconnectIntegration
id={credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn" data-testid={type + "-integration-disconnect-button"}>
{t("disconnect")}
</Button>
)}
onOpenChange={handleOpenChange}
/>
);
}
return (
<DisconnectIntegration
id={credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn" data-testid={type + "-integration-disconnect-button"}>
{t("disconnect")}
</Button>
)}
onOpenChange={handleOpenChange}
/>
);
}
if (!props.installed) {
return (
<div className="flex items-center truncate">
<Alert severity="warning" title={t("not_installed")} />
</div>
);
}
/** We don't need to "Connect", just show that it's installed */
if (props.isGlobal) {
return (
<div className="truncate px-3 py-2">
<h3 className="text-sm font-medium text-gray-700">{t("installed")}</h3>
</div>
);
}
return (
<InstallAppButton
type={props.type}
render={(buttonProps) => (
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
{t("connect")}
</Button>
)}
onChanged={handleOpenChange}
/>
);
}
interface IntegrationsContainerProps {
variant: App["variant"];
className?: string;
}
const IntegrationsContainer = ({ variant, className = "" }: IntegrationsContainerProps): JSX.Element => {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.integrations", { variant, onlyInstalled: true }]);
return (
<QueryCell
query={query}
customLoader={<SkeletonLoader className={className} />}
success={({ data }) => {
return (
<>
{data.items.length > 0 && (
<div className={className}>
<ShellSubHeading
title={
<SubHeadingTitleWithConnections title={t(variant)} numConnections={data.items.length} />
}
/>
<List>
{data.items.map((item) => (
<IntegrationListItem
name={item.name}
slug={item.slug}
key={item.title}
title={item.title}
logo={item.logo}
description={item.description}
actions={
<ConnectOrDisconnectIntegrationButton
credentialIds={item.credentialIds}
type={item.type}
isGlobal={item.isGlobal}
installed
/>
}
/>
))}
</List>
</div>
)}
</>
);
}}
/>
);
};
// Server side rendering
export async function getServerSideProps(ctx: AppGetServerSidePropsContext) {
// get return-to cookie and redirect if needed
const { cookies } = ctx.req;
if (cookies && cookies["return-to"]) {
const returnTo = cookies["return-to"];
if (returnTo) {
ctx.res.setHeader("Set-Cookie", "return-to=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
return {
redirect: {
destination: `${returnTo}`,
permanent: false,
},
};
}
}
return {
props: {},
};
}
export default function IntegrationsPage() {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.integrations", { onlyInstalled: true }]);
return (
<Shell heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")} large>
<AppsShell>
<QueryCell
query={query}
success={({ data }) => {
return data.items.length > 0 ? (
<>
<IntegrationsContainer variant="conferencing" />
<CalendarListContainer />
<IntegrationsContainer variant="payment" className="mt-8" />
<IntegrationsContainer variant="other" className="mt-8" />
<IntegrationsContainer variant="web3" className="mt-8" />
</>
) : (
<EmptyScreen
Icon={Icon.FiGrid}
headline={t("empty_installed_apps_headline")}
description={
<>
<span className="mb-6 block">{t("empty_installed_apps_description")}</span>
<Button href="/apps" EndIcon={Icon.FiArrowRight}>
{t("empty_installed_apps_button")}
</Button>
</>
}
/>
);
}}
/>
</AppsShell>
</Shell>
);
}
@@ -1,6 +1,7 @@
import { InferGetServerSidePropsType } from "next";
import z from "zod";
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
import { InstallAppButton } from "@calcom/app-store/components";
import { InstalledAppVariants } from "@calcom/app-store/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -85,13 +86,18 @@ function ConnectOrDisconnectIntegrationButton(props: {
}
interface IntegrationsContainerProps {
variant?: "calendar" | "conferencing" | "payment";
exclude?: App["variant"][];
variant?: keyof typeof InstalledAppVariants;
exclude?: (keyof typeof InstalledAppVariants)[];
}
const IntegrationsList = ({ data }: { data: inferQueryOutput<"viewer.integrations"> }) => {
interface IntegrationsListProps {
variant?: IntegrationsContainerProps["variant"];
data: inferQueryOutput<"viewer.integrations">;
}
const IntegrationsList = ({ data }: IntegrationsListProps) => {
return (
<List>
<List className="flex flex-col gap-6" noBorderTreatment>
{data.items.map((item) => (
<IntegrationListItem
name={item.name}
@@ -100,8 +106,9 @@ const IntegrationsList = ({ data }: { data: inferQueryOutput<"viewer.integration
title={item.title}
logo={item.logo}
description={item.description}
separate={true}
actions={
<div className="flex w-32 justify-end">
<div className="flex w-16 justify-end">
<ConnectOrDisconnectIntegrationButton
credentialIds={item.credentialIds}
type={item.type}
@@ -109,8 +116,9 @@ const IntegrationsList = ({ data }: { data: inferQueryOutput<"viewer.integration
installed
/>
</div>
}
/>
}>
<AppSettings slug={item.slug} />
</IntegrationListItem>
))}
</List>
);
@@ -122,6 +130,8 @@ const IntegrationsContainer = ({ variant, exclude }: IntegrationsContainerProps)
const emptyIcon = {
calendar: Icon.FiCalendar,
conferencing: Icon.FiVideo,
automation: Icon.FiShare2,
analytics: Icon.FiBarChart,
payment: Icon.FiCreditCard,
other: Icon.FiGrid,
};
@@ -151,7 +161,7 @@ const IntegrationsContainer = ({ variant, exclude }: IntegrationsContainerProps)
</Button>
}
/>
<IntegrationsList data={data} />
<IntegrationsList data={data} variant={variant} />
</div>
) : (
<EmptyScreen
@@ -186,16 +196,20 @@ export default function InstalledApps({ category }: InferGetServerSidePropsType<
{(category === InstalledAppVariants.payment || category === InstalledAppVariants.conferencing) && (
<IntegrationsContainer variant={category} />
)}
{category === InstalledAppVariants.other && (
<IntegrationsContainer
exclude={
Object.keys(InstalledAppVariants).filter(
(variant) => variant !== InstalledAppVariants.other
) as App["variant"][]
}
/>
{(category === InstalledAppVariants.automation || category === InstalledAppVariants.analytics) && (
<IntegrationsContainer variant={category} />
)}
{category === InstalledAppVariants.calendar && <CalendarListContainer />}
{category === InstalledAppVariants.other && (
<IntegrationsContainer
exclude={[
InstalledAppVariants.conferencing,
InstalledAppVariants.calendar,
InstalledAppVariants.analytics,
InstalledAppVariants.automation,
]}
/>
)}
</InstalledAppsLayout>
);
}
+53 -57
View File
@@ -5,15 +5,16 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { FaGoogle } from "react-icons/fa";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
import { Icon } from "@calcom/ui";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon";
import { EmailField, Form, PasswordField } from "@calcom/ui/form/fields";
import { Button, EmailField, Form, PasswordField } from "@calcom/ui/v2";
import SAMLLogin from "@calcom/ui/v2/modules/auth/SAMLLogin";
import { ErrorCode, getSession } from "@lib/auth";
import { WEBAPP_URL, WEBSITE_URL } from "@lib/config/constants";
@@ -21,9 +22,8 @@ import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@lib
import { inferSSRProps } from "@lib/types/inferSSRProps";
import AddToHomescreen from "@components/AddToHomescreen";
import SAMLLogin from "@components/auth/SAMLLogin";
import TwoFactor from "@components/auth/TwoFactor";
import AuthContainer from "@components/ui/AuthContainer";
import AuthContainer from "@components/v2/ui/AuthContainer";
import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
import { ssrInit } from "@server/lib/ssr";
@@ -53,8 +53,7 @@ export default function Login({
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const errorMessages: { [key: string]: string } = {
[ErrorCode.RateLimitExceeded]: t("rate_limit_exceeded"),
[ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
// [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
[ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`,
[ErrorCode.UserNotFound]: t("no_account_exists"),
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
@@ -78,12 +77,9 @@ export default function Login({
callbackUrl = safeCallbackUrl || "";
const LoginFooter = (
<span>
{t("dont_have_an_account")}{" "}
<a href={`${WEBSITE_URL}/signup`} className="font-medium text-neutral-900">
{t("create_an_account")}
</a>
</span>
<a href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
{t("dont_have_an_account")}
</a>
);
const TwoFactorFooter = (
@@ -104,11 +100,10 @@ export default function Login({
title={t("login")}
description={t("login")}
showLogo
heading={twoFactorRequired ? t("2fa_code") : t("sign_in_account")}
heading={twoFactorRequired ? t("2fa_code") : t("welcome_back")}
footerText={twoFactorRequired ? TwoFactorFooter : LoginFooter}>
<Form
form={form}
className="space-y-6"
handleSubmit={async (values) => {
setErrorMessage(null);
telemetry.event(telemetryEventTypes.login, collectPageParameters());
@@ -134,51 +129,52 @@ export default function Login({
{...form.register("csrfToken")}
/>
</div>
<div className={classNames("space-y-6", { hidden: twoFactorRequired })}>
<EmailField
id="email"
label={t("email_address")}
defaultValue={router.query.email as string}
placeholder="john.doe@example.com"
required
{...form.register("email")}
/>
<div className="relative">
<div className="absolute right-0 -top-[2px]">
<Link href="/auth/forgot-password">
<a tabIndex={-1} className="text-primary-600 text-sm font-medium">
{t("forgot")}
</a>
</Link>
</div>
<PasswordField
id="password"
type="password"
autoComplete="current-password"
<div className="space-y-6">
<div className={classNames("space-y-6", { hidden: twoFactorRequired })}>
<EmailField
id="email"
label={t("email_address")}
defaultValue={router.query.email as string}
placeholder="john.doe@example.com"
required
{...form.register("password")}
{...form.register("email")}
/>
<div className="relative">
<div className="absolute right-0 -top-[6px] z-10">
<Link href="/auth/forgot-password">
<a tabIndex={-1} className="text-sm font-medium text-gray-600">
{t("forgot")}
</a>
</Link>
</div>
<PasswordField
id="password"
autoComplete="current-password"
required
className="mb-0"
{...form.register("password")}
/>
</div>
</div>
</div>
{twoFactorRequired && <TwoFactor center />}
{twoFactorRequired && <TwoFactor center />}
{errorMessage && <Alert severity="error" title={errorMessage} />}
<div className="flex space-y-2">
<Button className="flex w-full justify-center" type="submit" disabled={isSubmitting}>
{errorMessage && <Alert severity="error" title={errorMessage} />}
<Button type="submit" color="primary" disabled={isSubmitting} className="w-full justify-center">
{twoFactorRequired ? t("submit") : t("sign_in")}
</Button>
</div>
</Form>
{!twoFactorRequired && (
<>
{isGoogleLoginEnabled && (
<div className="mt-5">
{(isGoogleLoginEnabled || isSAMLLoginEnabled) && <hr className="my-8" />}
<div className="space-y-3">
{isGoogleLoginEnabled && (
<Button
color="secondary"
className="flex w-full justify-center"
className="w-full justify-center"
data-testid="google"
StartIcon={FaGoogle}
onClick={async (e) => {
e.preventDefault();
// track Google logins. Without personal data/payload
@@ -187,17 +183,17 @@ export default function Login({
}}>
{t("signin_with_google")}
</Button>
</div>
)}
{isSAMLLoginEnabled && (
<SAMLLogin
email={form.getValues("email")}
samlTenantID={samlTenantID}
samlProductID={samlProductID}
hostedCal={hostedCal}
setErrorMessage={setErrorMessage}
/>
)}
)}
{isSAMLLoginEnabled && (
<SAMLLogin
email={form.getValues("email")}
samlTenantID={samlTenantID}
samlProductID={samlProductID}
hostedCal={hostedCal}
setErrorMessage={setErrorMessage}
/>
)}
</div>
</>
)}
</AuthContainer>
+6 -7
View File
@@ -1,17 +1,16 @@
import { GetServerSidePropsContext } from "next";
import { signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon";
import Button from "@calcom/ui/v2/core/Button";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import AuthContainer from "@components/ui/AuthContainer";
import AuthContainer from "@components/v2/ui/AuthContainer";
import { ssrInit } from "@server/lib/ssr";
@@ -30,7 +29,7 @@ export default function Logout(props: Props) {
const { t } = useLocale();
return (
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")}>
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo>
<div className="mb-4">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<Icon.FiCheck className="h-6 w-6 text-green-600" />
@@ -44,9 +43,9 @@ export default function Logout(props: Props) {
</div>
</div>
</div>
<Link href="/auth/login" passHref>
<Button className="flex w-full justify-center"> {t("go_back_login")}</Button>
</Link>
<Button href="/auth/login" passHref className="flex w-full justify-center">
{t("go_back_login")}
</Button>
</AuthContainer>
);
}
+34 -17
View File
@@ -1,5 +1,6 @@
import { CheckIcon } from "@heroicons/react/solid";
import { zodResolver } from "@hookform/resolvers/zod";
import classNames from "classnames";
import { useRouter } from "next/router";
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
@@ -9,7 +10,7 @@ import { isPasswordValid } from "@calcom/lib/auth";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { TextField, EmailField, PasswordField } from "@calcom/ui/v2";
import { TextField, EmailField, PasswordField, Label } from "@calcom/ui/v2";
import WizardForm from "@calcom/ui/v2/core/WizardForm";
const StepDone = () => {
@@ -85,6 +86,8 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
}
}, onError);
const longWebsiteUrl = process.env.NEXT_PUBLIC_WEBSITE_URL.length > 30;
return (
<FormProvider {...formMethods}>
<form id="setup-step-1" name="setup-step-1" className="space-y-4" onSubmit={onSubmit}>
@@ -93,22 +96,36 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
name="username"
control={formMethods.control}
render={({ field: { onBlur, onChange, value } }) => (
<TextField
addOnLeading={
<span className="items-centerpx-3 inline-flex rounded-none text-sm text-gray-500">
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
</span>
}
value={value || ""}
className="my-0"
onBlur={onBlur}
name="username"
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("username", e.target.value);
await formMethods.trigger("username");
}}
/>
<>
<Label htmlFor="username" className={classNames(longWebsiteUrl && "mb-0")}>
<span className="block">{t("username")}</span>
{longWebsiteUrl && (
<small className="items-centerpx-3 mt-2 inline-flex rounded-t-md border border-b-0 border-gray-300 bg-gray-100 py-1 px-3 text-gray-500">
{process.env.NEXT_PUBLIC_WEBSITE_URL}
</small>
)}
</Label>
<TextField
addOnLeading={
!longWebsiteUrl && (
<span className="items-centerpx-3 inline-flex rounded-none text-sm text-gray-500">
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
</span>
)
}
id="username"
labelSrOnly={true}
value={value || ""}
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
onBlur={onBlur}
name="username"
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("username", e.target.value);
await formMethods.trigger("username");
}}
/>
</>
)}
/>
</div>
-201
View File
@@ -1,201 +0,0 @@
import { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import { useRouter } from "next/router";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import prisma from "@calcom/prisma";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { EmailField, PasswordField, TextField } from "@calcom/ui/form/fields";
import { asStringOrNull } from "@lib/asStringOrNull";
import { NEXT_PUBLIC_BASE_URL } from "@lib/config/constants";
import { useLocale } from "@lib/hooks/useLocale";
import { isSAMLLoginEnabled } from "@lib/saml";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { HeadSeo } from "@components/seo/head-seo";
import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
import { ssrInit } from "@server/lib/ssr";
type Props = inferSSRProps<typeof getServerSideProps>;
type FormValues = {
username: string;
email: string;
password: string;
passwordcheck: string;
apiError: string;
};
export default function Signup({ email }: Props) {
const { t } = useLocale();
const router = useRouter();
const methods = useForm<FormValues>();
const {
register,
formState: { errors, isSubmitting },
} = methods;
methods.setValue("email", email);
const handleErrors = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
};
const signUp: SubmitHandler<FormValues> = async (data) => {
await fetch("/api/auth/signup", {
body: JSON.stringify({
...data,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
})
.then(handleErrors)
.then(
async () =>
await signIn("Cal.com", {
callbackUrl: (`${NEXT_PUBLIC_BASE_URL}/${router.query.callbackUrl}` || "") as string,
})
)
.catch((err) => {
methods.setError("apiError", { message: err.message });
});
};
return (
<div
className="flex min-h-screen flex-col justify-center bg-gray-50 py-12 sm:px-6 lg:px-8"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="font-cal text-center text-3xl font-extrabold text-gray-900">
{t("create_your_account")}
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="mx-2 bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
{/* TODO: Refactor as soon as /availability is live */}
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(signUp)} className="space-y-6 bg-white">
{errors.apiError && <Alert severity="error" message={errors.apiError?.message} />}
<div className="space-y-2">
<TextField
addOnLeading={
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
</span>
}
labelProps={{ className: "block text-sm font-medium text-gray-700" }}
className="block w-full min-w-0 flex-grow rounded-none rounded-r-sm border-gray-300 text-sm lowercase focus:border-black focus:ring-black"
{...register("username")}
required
/>
<EmailField
{...register("email")}
className="mt-1 block w-full rounded-md border border-gray-300 bg-gray-100 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
/>
<PasswordField
labelProps={{
className: "block text-sm font-medium text-gray-700",
}}
{...register("password")}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
/>
<PasswordField
label={t("confirm_password")}
labelProps={{
className: "block text-sm font-medium text-gray-700",
}}
{...register("passwordcheck", {
validate: (value) =>
value === methods.watch("password") || (t("error_password_mismatch") as string),
})}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
/>
</div>
<div className="flex space-x-2 rtl:space-x-reverse">
<Button loading={isSubmitting} className="w-7/12 justify-center">
{t("create_account")}
</Button>
<Button
color="secondary"
className="w-5/12 justify-center"
onClick={() =>
signIn("Cal.com", {
callbackUrl: (`${NEXT_PUBLIC_BASE_URL}/${router.query.callbackUrl}` || "") as string,
})
}>
{t("login_instead")}
</Button>
</div>
</form>
</FormProvider>
</div>
</div>
</div>
);
}
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const ssr = await ssrInit(ctx);
const token = asStringOrNull(ctx.query.token);
if (!token) {
return {
notFound: true,
};
}
const verificationToken = await prisma.verificationToken.findUnique({
where: {
token,
},
});
// for now, disable if no verificationToken given or token expired
if (!verificationToken || verificationToken.expires < new Date()) {
return {
notFound: true,
};
}
const existingUser = await prisma.user.findFirst({
where: {
AND: [
{
email: verificationToken.identifier,
},
{
emailVerified: {
not: null,
},
},
],
},
});
if (existingUser) {
return {
redirect: {
permanent: false,
destination: "/auth/login?callbackUrl=" + `${NEXT_PUBLIC_BASE_URL}/${ctx.query.callbackUrl}`,
},
};
}
return {
props: {
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
isSAMLLoginEnabled,
email: verificationToken.identifier,
trpcState: ssr.dehydrate(),
},
};
};
+156 -126
View File
@@ -1,51 +1,68 @@
/**
* @deprecated modifications to this file should be v2 only
* Use `/apps/web/pages/v2/availability/[schedule].tsx` instead
*/
import { GetStaticPaths, GetStaticProps } from "next";
import { useRouter } from "next/router";
import { useState } from "react";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { DEFAULT_SCHEDULE, availabilityAsString } from "@calcom/lib/availability";
import Schedule from "@calcom/features/schedules/components/Schedule";
import { availabilityAsString } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import { BadgeCheckIcon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import Switch from "@calcom/ui/Switch";
import TimezoneSelect from "@calcom/ui/form/TimezoneSelect";
import { Form } from "@calcom/ui/form/fields";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { Schedule as ScheduleType } from "@calcom/types/schedule";
import { Icon } from "@calcom/ui";
import Button from "@calcom/ui/v2/core/Button";
import Shell from "@calcom/ui/v2/core/Shell";
import Switch from "@calcom/ui/v2/core/Switch";
import TimezoneSelect from "@calcom/ui/v2/core/TimezoneSelect";
import VerticalDivider from "@calcom/ui/v2/core/VerticalDivider";
import { Form, Label } from "@calcom/ui/v2/core/form/fields";
import showToast from "@calcom/ui/v2/core/notifications";
import { Skeleton, SkeletonText } from "@calcom/ui/v2/core/skeleton";
import { QueryCell } from "@lib/QueryCell";
import { HttpError } from "@lib/core/http/error";
import Schedule from "@components/availability/Schedule";
import EditableHeading from "@components/ui/EditableHeading";
import { SelectSkeletonLoader } from "@components/v2/availability/SkeletonLoader";
/**
* @deprecated modifications to this file should be v2 only
* Use `/apps/web/pages/v2/availability/[schedule].tsx` instead
*/
export function AvailabilityForm(props: inferQueryOutput<"viewer.availability.schedule">) {
const { t } = useLocale();
const querySchema = z.object({
schedule: stringOrNumber,
});
type AvailabilityFormValues = {
name: string;
schedule: ScheduleType;
timeZone: string;
isDefault: boolean;
};
export default function Availability({ schedule }: { schedule: number }) {
const { t, i18n } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const me = useMeQuery();
const form = useForm({
defaultValues: {
schedule: props.availability || DEFAULT_SCHEDULE,
isDefault: !!props.isDefault,
timeZone: props.timeZone,
},
});
const { data, isLoading } = trpc.useQuery(["viewer.availability.schedule", { scheduleId: schedule }]);
const form = useForm<AvailabilityFormValues>();
const { control, reset } = form;
useEffect(() => {
if (!isLoading && data) {
reset({
name: data?.schedule?.name,
schedule: data.availability,
timeZone: data.timeZone,
isDefault: data.isDefault,
});
}
}, [data, isLoading, reset]);
const updateMutation = trpc.useMutation("viewer.availability.schedule.update", {
onSuccess: async ({ schedule }) => {
await utils.invalidateQueries(["viewer.availability.schedule"]);
await utils.refetchQueries(["viewer.availability.schedule"]);
await router.push("/availability");
showToast(
t("availability_updated_successfully", {
@@ -63,110 +80,123 @@ export function AvailabilityForm(props: inferQueryOutput<"viewer.availability.sc
});
return (
<Form
form={form}
handleSubmit={async (values) => {
updateMutation.mutate({
scheduleId: parseInt(router.query.schedule as string, 10),
name: props.schedule.name,
...values,
});
}}
className="grid grid-cols-3 gap-2">
<div className="col-span-3 space-y-2 lg:col-span-2">
<div className="divide-y rounded-sm border border-gray-200 bg-white px-4 py-5 sm:p-6">
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">{t("change_start_end")}</h3>
<Schedule name="schedule" />
</div>
<div className="space-x-2 text-right">
<Button color="secondary" href="/availability" tabIndex={-1}>
{t("cancel")}
</Button>
<Button>{t("save")}</Button>
</div>
</div>
<div className="min-w-40 col-span-3 ml-2 space-y-2 lg:col-span-1">
{props.isDefault ? (
<div className="inline-block cursor-default rounded border border-gray-300 bg-gray-200 px-2 py-0.5 pl-1.5 text-sm font-medium text-neutral-800">
<span className="flex items-center">
<BadgeCheckIcon className="mr-1 h-4 w-4" /> {t("default")}
<Shell
backPath="/availability"
title={data?.schedule.name && data.schedule.name + " | " + t("availability")}
heading={
<Controller
control={form.control}
name="name"
render={({ field }) => <EditableHeading isReady={!isLoading} {...field} />}
/>
}
subtitle={
data ? (
data.schedule.availability.map((availability) => (
<span key={availability.id}>
{availabilityAsString(availability, { locale: i18n.language })}
<br />
</span>
</div>
))
) : (
<Controller
name="isDefault"
render={({ field: { onChange, value } }) => (
<Switch label={t("set_to_default")} onCheckedChange={onChange} checked={value} />
)}
/>
)}
<div>
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
{t("timezone")}
</label>
<div className="mt-1">
<Controller
name="timeZone"
render={({ field: { onChange, value } }) => (
<TimezoneSelect
value={value}
className="focus:border-brand mt-1 block w-full rounded-md border-gray-300 text-sm"
onChange={(timezone) => onChange(timezone.value)}
/>
)}
<SkeletonText className="h-4 w-48" />
)
}
CTA={
<div className="flex items-center justify-end">
<div className="flex items-center rounded-md px-2 sm:hover:bg-gray-100">
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
className="mt-2 hidden cursor-pointer self-center pr-2 sm:inline">
{t("set_to_default")}
</Skeleton>
<Switch
id="hiddenSwitch"
disabled={isLoading}
checked={form.watch("isDefault")}
onCheckedChange={(e) => {
form.setValue("isDefault", e);
}}
/>
</div>
<VerticalDivider />
<div className="border-l-2 border-gray-300" />
<Button className="ml-4 lg:ml-0" type="submit" form="availability-form">
{t("save")}
</Button>
</div>
<div className="mt-2 rounded-sm border border-gray-200 px-4 py-5 sm:p-6 ">
<h3 className="text-base font-medium leading-6 text-gray-900">
{t("something_doesnt_look_right")}
</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>{t("troubleshoot_availability")}</p>
</div>
<div className="mt-5">
<Button href="/availability/troubleshoot" color="secondary">
{t("launch_troubleshooter")}
</Button>
</div>
}>
<div className="flex items-baseline sm:mt-0">
{/* TODO: Find a better way to guarantee alignment, but for now this'll do. */}
<Icon.FiArrowLeft className=" mr-3 text-transparent hover:cursor-pointer" />
<div className="w-full">
<Form
form={form}
id="availability-form"
handleSubmit={async (values) => {
updateMutation.mutate({
scheduleId: schedule,
...values,
});
}}
className="-mx-4 flex flex-col pb-16 sm:mx-0 xl:flex-row xl:space-x-6">
<div className="flex-1">
<div className="rounded-md border-gray-200 bg-white py-5 pr-4 sm:border sm:p-6">
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">
{t("change_start_end")}
</h3>
{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="min-w-40 col-span-3 space-y-2 lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:p-0">
<div>
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
{t("timezone")}
</label>
<Controller
name="timeZone"
render={({ field: { onChange, value } }) =>
value ? (
<TimezoneSelect
value={value}
className="focus:border-brand mt-1 block w-72 rounded-md border-gray-300 text-sm"
onChange={(timezone) => onChange(timezone.value)}
/>
) : (
<SelectSkeletonLoader className="w-72" />
)
}
/>
</div>
<hr className="my-8" />
<div className="rounded-md">
<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">
{t("launch_troubleshooter")}
</Button>
</div>
</div>
</div>
</div>
</Form>
</div>
</div>
</Form>
);
}
const querySchema = z.object({
schedule: stringOrNumber,
});
export default function Availability() {
const router = useRouter();
const { i18n } = useLocale();
const { schedule: scheduleId } = router.isReady ? querySchema.parse(router.query) : { schedule: -1 };
const query = trpc.useQuery(["viewer.availability.schedule", { scheduleId }], { enabled: router.isReady });
const [name, setName] = useState<string>();
return (
<div>
<QueryCell
query={query}
success={({ data }) => {
return (
<Shell
heading={<EditableHeading title={name || data.schedule.name} onChange={setName} />}
subtitle={data.schedule.availability.map((availability) => (
<span key={availability.id}>
{availabilityAsString(availability, { locale: i18n.language })}
<br />
</span>
))}>
<AvailabilityForm
{...{ ...data, schedule: { ...data.schedule, name: name || data.schedule.name } }}
/>
</Shell>
);
}}
/>
</div>
</Shell>
);
}
+50 -24
View File
@@ -1,53 +1,79 @@
/**
* @deprecated modifications to this file should be v2 only
* Use `/apps/web/pages/v2/availability/index.tsx` instead
*/
import { ScheduleListItem } from "@calcom/features/schedules/components/ScheduleListItem";
import autoAnimate from "@formkit/auto-animate";
import { useRef, useEffect } from "react";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import EmptyScreen from "@calcom/ui/EmptyScreen";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import { EmptyScreen, showToast } from "@calcom/ui/v2";
import { withQuery } from "@lib/QueryCell";
import { HttpError } from "@lib/core/http/error";
import { NewScheduleButton } from "@components/availability/NewScheduleButton";
import SkeletonLoader from "@components/availability/SkeletonLoader";
import SkeletonLoader from "@components/v2/availability/SkeletonLoader";
/**
* @deprecated modifications to this file should be v2 only
* Use `/apps/web/pages/v2/availability/index.tsx` instead
*/
export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availability.list">) {
const { t } = useLocale();
const utils = trpc.useContext();
const animationParentRef = useRef(null);
const meQuery = trpc.useQuery(["viewer.me"]);
const deleteMutation = trpc.useMutation("viewer.availability.schedule.delete", {
onSuccess: async () => {
await utils.invalidateQueries(["viewer.availability.list"]);
showToast(t("schedule_deleted_successfully"), "success");
onMutate: async ({ scheduleId }) => {
await utils.cancelQuery(["viewer.availability.list"]);
const previousValue = utils.getQueryData(["viewer.availability.list"]);
if (previousValue) {
const filteredValue = previousValue.schedules.filter(({ id }) => id !== scheduleId);
utils.setQueryData(["viewer.availability.list"], { ...previousValue, schedules: filteredValue });
}
return { previousValue };
},
onError: (err) => {
onError: (err, variables, context) => {
if (context?.previousValue) {
utils.setQueryData(["viewer.availability.list"], context.previousValue);
}
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
onSettled: () => {
utils.invalidateQueries(["viewer.availability.list"]);
},
onSuccess: () => {
showToast(t("schedule_deleted_successfully"), "success");
},
});
// Adds smooth delete button - item fades and old item slides into place
useEffect(() => {
animationParentRef.current && autoAnimate(animationParentRef.current);
}, [animationParentRef]);
return (
<>
{schedules.length === 0 ? (
<EmptyScreen
Icon={Icon.FiClock}
headline={t("new_schedule_heading")}
description={t("new_schedule_description")}
/>
<div className="flex justify-center">
<EmptyScreen
Icon={Icon.FiClock}
headline={t("new_schedule_heading")}
description={t("new_schedule_description")}
buttonRaw={<NewScheduleButton />}
/>
</div>
) : (
<div className="-mx-4 mb-16 overflow-hidden rounded-sm border border-gray-200 bg-white sm:mx-0">
<ul className="divide-y divide-neutral-200" data-testid="schedules">
<div className="mb-16 overflow-hidden rounded-md border border-gray-200 bg-white">
<ul className="divide-y divide-neutral-200" data-testid="schedules" ref={animationParentRef}>
{schedules.map((schedule) => (
<ScheduleListItem
displayOptions={{
hour12: meQuery.data?.timeFormat ? meQuery.data.timeFormat === 12 : undefined,
timeZone: meQuery.data?.timeZone,
}}
key={schedule.id}
schedule={schedule}
deleteFunction={deleteMutation.mutate}
+22 -24
View File
@@ -1,25 +1,20 @@
/**
* @deprecated modifications to this file should be v2 only
* Use `/apps/web/pages/v2/availability/troubleshoot.tsx` instead
*/
import type { IBusySlot } from "pages/v2/availability/troubleshoot";
import { useState } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import Shell from "@calcom/ui/Shell";
import { QueryCell } from "@lib/QueryCell";
import Loader from "@components/Loader";
import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
type User = inferQueryOutput<"viewer.me">;
/**
* @deprecated modifications to this file should be v2 only
* Use `/apps/web/pages/v2/availability/troubleshoot.tsx` instead
*/
export interface IBusySlot {
start: string | Date;
end: string | Date;
title?: string;
source?: string | null;
}
const AvailabilityView = ({ user }: { user: User }) => {
const { t } = useLocale();
const [selectedDate, setSelectedDate] = useState(dayjs());
@@ -40,7 +35,7 @@ const AvailabilityView = ({ user }: { user: User }) => {
);
return (
<div className="max-w-xl overflow-hidden rounded-sm bg-white shadow">
<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
@@ -53,27 +48,30 @@ const AvailabilityView = ({ user }: { user: User }) => {
/>
<small className="block text-neutral-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-sm">
<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>
{isLoading ? (
<Loader />
<>
<SkeletonText className="block h-16 w-full" />
<SkeletonText className="block h-16 w-full" />
</>
) : data && data.busy.length > 0 ? (
data.busy
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
.map((slot: IBusySlot) => (
<div
key={`${slot.start}-${slot.title ?? "untitled"}`}
className="overflow-hidden rounded-sm bg-neutral-100">
key={dayjs(slot.start).format("HH:mm")}
className="overflow-hidden rounded-md bg-neutral-100">
<div className="px-4 py-5 text-black sm:p-6">
{t("calendar_shows_busy_between")}{" "}
<span className="font-medium text-neutral-800" title={slot.start}>
<span className="font-medium text-neutral-800" title={dayjs(slot.start).format("HH:mm")}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{t("and")}{" "}
<span className="font-medium text-neutral-800" title={slot.end}>
<span className="font-medium text-neutral-800" title={dayjs(slot.end).format("HH:mm")}>
{dayjs(slot.end).format("HH:mm")}
</span>{" "}
{t("on")} {dayjs(slot.start).format("D")}{" "}
@@ -84,12 +82,12 @@ const AvailabilityView = ({ user }: { user: User }) => {
</div>
))
) : (
<div className="overflow-hidden rounded-sm bg-neutral-100">
<div className="overflow-hidden rounded-md bg-neutral-100">
<div className="px-4 py-5 text-black sm:p-6">{t("calendar_no_busy_slots")}</div>
</div>
)}
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-sm">
<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>
@@ -101,12 +99,12 @@ const AvailabilityView = ({ user }: { user: User }) => {
};
export default function Troubleshoot() {
const query = trpc.useQuery(["viewer.me"]);
const { data, isLoading } = trpc.useQuery(["viewer.me"]);
const { t } = useLocale();
return (
<div>
<Shell heading={t("troubleshoot")} subtitle={t("troubleshoot_description")}>
<QueryCell query={query} success={({ data }) => <AvailabilityView user={data} />} />
{!isLoading && data && <AvailabilityView user={data} />}
</Shell>
</div>
);
+68 -73
View File
@@ -1,27 +1,26 @@
import autoAnimate from "@formkit/auto-animate";
import { GetStaticPaths, GetStaticProps } from "next";
import { useRouter } from "next/router";
import { Fragment } from "react";
import { Fragment, useEffect, useRef } from "react";
import { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryInput, inferQueryOutput, trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import EmptyScreen from "@calcom/ui/EmptyScreen";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import { Button, EmptyScreen } from "@calcom/ui/v2";
import BookingLayout from "@calcom/ui/v2/core/layouts/BookingLayout";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import BookingsShell from "@components/BookingsShell";
import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
import SkeletonLoader from "@components/v2/bookings/SkeletonLoader";
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
type BookingOutput = inferQueryOutput<"viewer.bookings">["bookings"][0];
const validStatuses = ["upcoming", "recurring", "past", "cancelled"] as const;
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
const descriptionByStatus: Record<BookingListingStatus, string> = {
upcoming: "upcoming_bookings",
@@ -46,6 +45,8 @@ export default function Bookings() {
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// Animate page (tab) tranistions to look smoothing
const animationParentRef = useRef(null);
const buttonInView = useInViewObserver(() => {
if (!query.isFetching && query.hasNextPage && query.status === "success") {
query.fetchNextPage();
@@ -54,20 +55,9 @@ export default function Bookings() {
const isEmpty = !query.data?.pages[0]?.bookings.length;
// Get all recurring events of the series with the same recurringEventId
const defineRecurrentBookings = (
booking: BookingOutput,
groupedBookings: Record<string, BookingOutput[]>
) => {
let recurringBookings = undefined;
if (booking.recurringEventId !== null) {
recurringBookings = groupedBookings[booking.recurringEventId];
}
return { recurringBookings };
};
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
if (status === "recurring" || status === "cancelled") {
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
if (!booking.recurringEventId) {
return true;
}
@@ -82,63 +72,68 @@ export default function Bookings() {
}
return true;
};
useEffect(() => {
animationParentRef.current && autoAnimate(animationParentRef.current);
}, [animationParentRef]);
return (
<Shell heading={t("bookings")} subtitle={t("bookings_description")}>
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
<BookingsShell>
<div className="-mx-4 flex flex-col sm:mx-auto">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
{query.status === "error" && (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
)}
{(query.status === "loading" || query.status === "idle") && <SkeletonLoader />}
{query.status === "success" && !isEmpty && (
<>
<div className="mt-6 overflow-hidden rounded-sm border border-b border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
{query.data.pages.map((page, index) => (
<Fragment key={index}>
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => (
<BookingListItem
key={booking.id}
listingStatus={status}
{...defineRecurrentBookings(booking, shownBookings)}
{...booking}
/>
))}
</Fragment>
))}
</tbody>
</table>
</div>
<div className="p-4 text-center" ref={buttonInView.ref}>
<Button
color="minimal"
loading={query.isFetchingNextPage}
disabled={!query.hasNextPage}
onClick={() => query.fetchNextPage()}>
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</>
)}
{query.status === "success" && isEmpty && (
<EmptyScreen
Icon={Icon.FiCalendar}
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
description={t("no_status_bookings_yet_description", {
status: t(status).toLowerCase(),
description: t(descriptionByStatus[status]),
})}
/>
)}
<BookingLayout heading={t("bookings")} subtitle={t("bookings_description")}>
<div className="flex w-full flex-col" ref={animationParentRef}>
{query.status === "error" && (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
)}
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
{query.status === "success" && !isEmpty && (
<div className="pt-2 xl:pt-0">
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
{/* TODO: add today only for the current day
<p className="pb-3 text-xs font-medium leading-4 text-gray-500 uppercase">{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" data-testid="bookings">
{query.data.pages.map((page, index) => (
<Fragment key={index}>
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => (
<BookingListItem
key={booking.id}
listingStatus={status}
recurringBookings={page.recurringInfo}
{...booking}
/>
))}
</Fragment>
))}
</tbody>
</table>
</div>
<div className="p-4 text-center" ref={buttonInView.ref}>
<Button
color="minimal"
loading={query.isFetchingNextPage}
disabled={!query.hasNextPage}
onClick={() => query.fetchNextPage()}>
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
</div>
</BookingsShell>
</Shell>
)}
{query.status === "success" && isEmpty && (
<div className="flex items-center justify-center pt-2 xl:mx-6 xl:pt-0">
<EmptyScreen
Icon={Icon.FiCalendar}
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
description={t("no_status_bookings_yet_description", {
status: t(status).toLowerCase(),
description: t(descriptionByStatus[status]),
})}
/>
</div>
)}
</div>
</BookingLayout>
);
}
+2 -2
View File
@@ -1,11 +1,11 @@
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { z } from "zod";
import { privacyFilteredLocations, LocationObject } from "@calcom/core/location";
import { parseRecurringEvent } from "@calcom/lib";
import { availiblityPageEventTypeSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { getWorkingHours } from "@lib/availability";
import { GetBookingType } from "@lib/getBooking";
@@ -100,7 +100,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
: [];
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
metadata: {} as JSONObject,
metadata: EventTypeMetaDataSchema.parse(hashedLink.eventType.metadata || {}),
recurringEvent: parseRecurringEvent(hashedLink.eventType.recurringEvent),
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
File diff suppressed because it is too large Load Diff
@@ -5,18 +5,19 @@ import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { JSONObject } from "superjson/dist/types";
import { z } from "zod";
import { StripeData } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import getApps, { getEventTypeAppData, getLocationOptions } from "@calcom/app-store/utils";
import { LocationObject, EventLocationType } from "@calcom/core/location";
import { parseRecurringEvent } from "@calcom/lib";
import { parseRecurringEvent, parseBookingLimit, validateBookingLimitOrder } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import type { RecurringEvent } from "@calcom/types/Calendar";
import type { BookingLimit, RecurringEvent } from "@calcom/types/Calendar";
import { Form } from "@calcom/ui/form/fields";
import { showToast } from "@calcom/ui/v2";
@@ -49,8 +50,6 @@ export type FormValues = {
requiresConfirmation: boolean;
recurringEvent: RecurringEvent | null;
schedulingType: SchedulingType | null;
price: number;
currency: string;
hidden: boolean;
hideCalendarNotes: boolean;
hashedLink: string | undefined;
@@ -70,18 +69,18 @@ export type FormValues = {
periodCountCalendarDays: "1" | "0";
periodDates: { startDate: Date; endDate: Date };
seatsPerTimeSlot: number | null;
seatsPerTimeSlotEnabled: boolean;
minimumBookingNotice: number;
beforeBufferTime: number;
afterBufferTime: number;
slotInterval: number | null;
metadata: z.infer<typeof EventTypeMetaDataSchema>;
destinationCalendar: {
integration: string;
externalId: string;
};
successRedirectUrl: string;
giphyThankYouPage: string;
blockchainId: number;
smartContractAddress: string;
bookingLimits?: BookingLimit;
};
const querySchema = z.object({
@@ -95,8 +94,17 @@ export type EventTypeSetupInfered = inferSSRProps<typeof getServerSideProps>;
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const { t } = useLocale();
const { data: eventTypeApps } = trpc.useQuery([
"viewer.apps",
{
extendsFeature: "EventType",
},
]);
const { eventType, locationOptions, team, teamMembers } = props;
const { eventType: dbEventType, locationOptions, team, teamMembers } = props;
// TODO: It isn't a good idea to maintain state using setEventType. If we want to connect the SSR'd data to tRPC, we should useQuery(["viewer.eventTypes.get"]) with initialData
// Due to this change, when Form is saved, there is no way to propagate that info to eventType (e.g. disabling stripe app doesn't allow recurring tab to be enabled without refresh).
const [eventType, setEventType] = useState(dbEventType);
const animationParentRef = useRef(null);
const router = useRouter();
const { tabName } = querySchema.parse(router.query);
@@ -106,7 +114,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
}, [animationParentRef]);
const updateMutation = trpc.useMutation("viewer.eventTypes.update", {
onSuccess: async ({ eventType }) => {
onSuccess: async ({ eventType: newEventType }) => {
setEventType({ ...eventType, slug: newEventType.slug });
showToast(
t("event_type_updated_successfully", {
eventTypeTitle: eventType.title,
@@ -147,6 +156,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
recurringEvent: eventType.recurringEvent || null,
description: eventType.description ?? undefined,
schedule: eventType.schedule || undefined,
bookingLimits: eventType.bookingLimits || undefined,
hidden: eventType.hidden,
periodDates: {
startDate: periodDates.startDate,
@@ -154,9 +164,24 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
},
schedulingType: eventType.schedulingType,
minimumBookingNotice: eventType.minimumBookingNotice,
metadata: eventType.metadata,
},
});
const appsMetadata = formMethods.getValues("metadata")?.apps;
const numberOfInstalledApps = eventTypeApps?.filter((app) => app.isInstalled).length || 0;
let numberOfActiveApps = 0;
if (appsMetadata) {
numberOfActiveApps = Object.entries(appsMetadata).filter(
([appId, appData]) => eventTypeApps?.find((app) => app.slug === appId)?.isInstalled && appData.enabled
).length;
}
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
eventType.slug
}`;
const tabMap = {
setup: (
<EventSetupTab
@@ -177,18 +202,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
),
limits: <EventLimitsTab eventType={eventType} />,
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
recurring: (
<EventRecurringTab eventType={eventType} hasPaymentIntegration={props.hasPaymentIntegration} />
),
apps: (
<EventAppsTab
currency={props.currency}
eventType={eventType}
hasPaymentIntegration={props.hasPaymentIntegration}
hasGiphyIntegration={props.hasGiphyIntegration}
hasRainbowIntegration={props.hasRainbowIntegration}
/>
),
recurring: <EventRecurringTab eventType={eventType} />,
apps: <EventAppsTab eventType={{ ...eventType, URL: permalink }} />,
workflows: (
<EventWorkflowsTab
eventType={eventType}
@@ -199,7 +214,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
return (
<EventTypeSingleLayout
enabledAppsNumber={[props.hasGiphyIntegration, props.hasPaymentIntegration].filter(Boolean).length}
enabledAppsNumber={numberOfActiveApps}
installedAppsNumber={numberOfInstalledApps}
enabledWorkflowsNumber={eventType.workflows.length}
eventType={eventType}
team={team}
@@ -213,17 +229,24 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const {
periodDates,
periodCountCalendarDays,
giphyThankYouPage,
beforeBufferTime,
afterBufferTime,
seatsPerTimeSlot,
bookingLimits,
recurringEvent,
locations,
blockchainId,
smartContractAddress,
metadata,
// We don't need to send it to the backend
// eslint-disable-next-line @typescript-eslint/no-unused-vars
seatsPerTimeSlotEnabled,
...input
} = values;
if (bookingLimits) {
const isValid = validateBookingLimitOrder(bookingLimits);
if (!isValid) throw new Error("Booking limits must be in accending order. [day,week,month,year]");
}
updateMutation.mutate({
...input,
locations,
@@ -234,12 +257,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
id: eventType.id,
beforeEventBuffer: beforeBufferTime,
afterEventBuffer: afterBufferTime,
bookingLimits,
seatsPerTimeSlot,
metadata: {
...(giphyThankYouPage ? { giphyThankYouPage } : {}),
...(smartContractAddress ? { smartContractAddress } : {}),
...(blockchainId ? { blockchainId } : { blockchainId: 1 }),
},
metadata,
});
}}>
<div ref={animationParentRef} className="space-y-6">
@@ -338,7 +358,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
afterEventBuffer: true,
slotInterval: true,
hashedLink: true,
bookingLimits: true,
successRedirectUrl: true,
currency: true,
team: {
select: {
id: true,
@@ -367,7 +389,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
userId: true,
price: true,
currency: true,
destinationCalendar: true,
seatsPerTimeSlot: true,
workflows: {
@@ -412,18 +433,38 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
});
const { locations, metadata, ...restEventType } = rawEventType;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newMetadata = EventTypeMetaDataSchema.parse(metadata || {})!;
const apps = newMetadata.apps || {};
const eventTypeWithParsedMetadata = { ...rawEventType, metadata: newMetadata };
newMetadata.apps = {
...apps,
stripe: {
...getStripeAppData(eventTypeWithParsedMetadata, true),
currency:
(
credentials.find((integration) => integration.type === "stripe_payment")
?.key as unknown as StripeData
)?.default_currency || "usd",
},
giphy: getEventTypeAppData(eventTypeWithParsedMetadata, "giphy", true),
rainbow: getEventTypeAppData(eventTypeWithParsedMetadata, "rainbow", true),
};
// TODO: How to extract metadata schema from _EventTypeModel to be able to parse it?
// const parsedMetaData = _EventTypeModel.parse(newMetadata);
const parsedMetaData = newMetadata;
const eventType = {
...restEventType,
schedule: rawEventType.schedule?.id || rawEventType.users[0].defaultScheduleId,
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
bookingLimits: parseBookingLimit(restEventType.bookingLimits),
locations: locations as unknown as LocationObject[],
metadata: (metadata || {}) as JSONObject,
metadata: parsedMetaData,
};
const hasGiphyIntegration = !!credentials.find((credential) => credential.type === "giphy_other");
const hasRainbowIntegration = !!credentials.find((credential) => credential.type === "rainbow_web3");
// backwards compat
if (eventType.users.length === 0 && !eventType.team) {
const fallbackUser = await prisma.user.findUnique({
@@ -439,10 +480,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const t = await getTranslation(currentUser?.locale ?? "en", "common");
const integrations = getApps(credentials);
const locationOptions = getLocationOptions(integrations, t);
const hasPaymentIntegration = !!credentials.find((credential) => credential.type === "stripe_payment");
const currency =
(credentials.find((integration) => integration.type === "stripe_payment")?.key as unknown as StripeData)
?.default_currency || "usd";
const eventTypeObject = Object.assign({}, eventType, {
periodStartDate: eventType.periodStartDate?.toString() ?? null,
@@ -469,10 +506,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
locationOptions,
team: eventTypeObject.team || null,
teamMembers,
hasPaymentIntegration,
hasGiphyIntegration,
hasRainbowIntegration,
currency,
currentUserMembership,
},
};
+236 -253
View File
@@ -1,9 +1,4 @@
/**
* @deprecated file is not used anymore
* Use `/apps/web/pages/v2/event-types/index.tsx` instead
*/
import { UserPlan } from "@prisma/client";
import { Trans } from "next-i18next";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -11,41 +6,34 @@ import React, { Fragment, useEffect, useState } from "react";
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui";
import { Alert } from "@calcom/ui/Alert";
import Badge from "@calcom/ui/Badge";
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
import { Dialog } from "@calcom/ui/Dialog";
import { TRPCClientError } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Badge, Button, ButtonGroup, Dialog, EmptyScreen, showToast, Switch, Tooltip } from "@calcom/ui/v2";
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
import Dropdown, {
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@calcom/ui/Dropdown";
import EmptyScreen from "@calcom/ui/EmptyScreen";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import { Tooltip } from "@calcom/ui/Tooltip";
DropdownMenuPortal,
} from "@calcom/ui/v2/core/Dropdown";
import Shell from "@calcom/ui/v2/core/Shell";
import CreateEventTypeButton from "@calcom/ui/v2/modules/event-types/CreateEventType";
import EventTypeDescription from "@calcom/ui/v2/modules/event-types/EventTypeDescription";
import { withQuery } from "@lib/QueryCell";
import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { EmbedButton, EmbedDialog } from "@components/Embed";
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
import Avatar from "@components/ui/Avatar";
import AvatarGroup from "@components/ui/AvatarGroup";
import { LinkText } from "@components/ui/LinkText";
import NoCalendarConnectedAlert from "@components/ui/NoCalendarConnectedAlert";
import SkeletonLoader from "@components/v2/eventtype/SkeletonLoader";
import { TRPCClientError } from "@trpc/react";
type EventTypeGroups = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"];
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
interface EventTypeListHeadingProps {
profile: EventTypeGroupProfile;
membershipCount: number;
@@ -64,25 +52,19 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
const { t } = useLocale();
return (
<Link href={"/event-types/" + type.id}>
<Link href={`/event-types/${type.id}?tabName=setup`}>
<a
className="flex-grow truncate text-sm"
title={`${type.title} ${type.description ? `– ${type.description}` : ""}`}>
<div>
<span
className="truncate font-medium text-neutral-900 ltr:mr-1 rtl:ml-1"
className="truncate font-semibold text-gray-700 ltr:mr-1 rtl:ml-1"
data-testid={"event-type-title-" + type.id}>
{type.title}
</span>
<small
className="hidden text-neutral-500 sm:inline"
className="hidden font-normal leading-4 text-gray-600 sm:inline"
data-testid={"event-type-slug-" + type.id}>{`/${group.profile.slug}/${type.slug}`}</small>
{type.hidden && (
<span className="rtl:mr-2inline items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800 ltr:ml-2">
{t("hidden") as string}
</span>
)}
{readOnly && (
<span className="rtl:mr-2inline items-center rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 ltr:ml-2">
{t("readonly") as string}
@@ -96,17 +78,26 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
};
const MemoizedItem = React.memo(Item);
/**
* @deprecated
* Use component from `/apps/web/pages/v2/event-types/index.tsx` instead
*/
export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => {
const { t } = useLocale();
const router = useRouter();
const [parent] = useAutoAnimate<HTMLUListElement>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
const utils = trpc.useContext();
const mutation = trpc.useMutation("viewer.eventTypeOrder", {
onError: async (err) => {
console.error(err.message);
await utils.cancelQuery(["viewer.eventTypes"]);
await utils.invalidateQueries(["viewer.eventTypes"]);
},
onSettled: () => {
utils.invalidateQueries(["viewer.eventTypes"]);
},
});
const setHiddenMutation = trpc.useMutation("viewer.eventTypes.update", {
onError: async (err) => {
console.error(err.message);
await utils.cancelQuery(["viewer.eventTypes"]);
@@ -117,7 +108,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
},
});
function moveEventType(index: number, increment: 1 | -1) {
async function moveEventType(index: number, increment: 1 | -1) {
const newList = [...types];
const type = types[index];
@@ -127,24 +118,19 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
newList[index + increment] = type;
}
utils.cancelQuery(["viewer.eventTypes"]);
utils.setQueryData(["viewer.eventTypes"], (data) => {
// tRPC is very strict with the return signature...
if (!data)
return {
eventTypeGroups: [],
profiles: [],
viewer: { canAddEvents: true, plan: UserPlan.PRO },
};
return {
...data,
eventTypesGroups: [
...data.eventTypeGroups.slice(0, groupIndex),
await utils.cancelQuery(["viewer.eventTypes"]);
const previousValue = utils.getQueryData(["viewer.eventTypes"]);
if (previousValue) {
utils.setQueryData(["viewer.eventTypes"], {
...previousValue,
eventTypeGroups: [
...previousValue.eventTypeGroups.slice(0, groupIndex),
{ ...group, eventTypes: newList },
...data.eventTypeGroups.slice(groupIndex + 1),
...previousValue.eventTypeGroups.slice(groupIndex + 1),
],
};
});
});
}
mutation.mutate({
ids: newList.map((type) => type.id),
@@ -207,28 +193,33 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
}
}, []);
const firstItem = types[0];
const lastItem = types[types.length - 1];
return (
<div className="mb-16 overflow-hidden rounded-sm border border-gray-200 bg-white sm:mx-0">
<ul className="divide-y divide-neutral-200" data-testid="event-types">
<div className="mb-16 flex overflow-hidden rounded-md border border-gray-200 bg-white">
<ul ref={parent} className="!static w-full divide-y divide-neutral-200" data-testid="event-types">
{types.map((type, index) => {
const embedLink = `${group.profile.slug}/${type.slug}`;
const calLink = `${CAL_URL}/${embedLink}`;
return (
<li key={type.id}>
<div className="flex items-center justify-between hover:bg-neutral-50">
<div className="group flex w-full items-center justify-between px-4 py-4 pr-0 hover:bg-neutral-50 sm:px-6">
<button
className="invisible absolute left-[3px] -mt-4 mb-4 -ml-4 hidden h-7 w-7 scale-0 items-center justify-center rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[34px]"
onClick={() => moveEventType(index, -1)}>
<Icon.FiArrowUp className="h-5 w-5" />
</button>
<button
className="invisible absolute left-[3px] mt-8 -ml-4 hidden h-7 w-7 scale-0 items-center justify-center rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[34px]"
onClick={() => moveEventType(index, 1)}>
<Icon.FiArrowDown className="h-5 w-5" />
</button>
<div className="group flex w-full items-center justify-between px-4 py-4 pr-0 sm:px-6">
{!(firstItem && firstItem.id === type.id) && (
<button
className="invisible absolute left-[5px] -mt-4 mb-4 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow disabled:hover:border-inherit disabled:hover:text-gray-400 disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
onClick={() => moveEventType(index, -1)}>
<Icon.FiArrowUp className="h-5 w-5" />
</button>
)}
{!(lastItem && lastItem.id === type.id) && (
<button
className="invisible absolute left-[5px] mt-8 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow disabled:hover:border-inherit disabled:hover:text-gray-400 disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
onClick={() => moveEventType(index, 1)}>
<Icon.FiArrowDown className="h-5 w-5" />
</button>
)}
<MemoizedItem type={type} group={group} readOnly={readOnly} />
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 sm:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
@@ -245,197 +236,200 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
}))}
/>
)}
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<Tooltip side="top" content={t("preview") as string}>
<Button
target="_blank"
rel="noreferrer"
type="button"
size="icon"
color="minimal"
className="group-hover:text-black"
StartIcon={Icon.FiExternalLink}
href={calLink}
/>
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
{type.hidden && (
<Badge variant="gray" size="lg">
{t("hidden")}
</Badge>
)}
<Tooltip content={t("show_eventtype_on_profile") as string}>
<div className="self-center rounded-md p-2 hover:bg-gray-200">
<Switch
name="Hidden"
checked={!type.hidden}
onCheckedChange={() => {
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
}}
/>
</div>
</Tooltip>
<Tooltip side="top" content={t("copy_link") as string}>
<Button
type="button"
size="icon"
color="minimal"
className="group-hover:text-black"
StartIcon={Icon.FiLink}
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(calLink);
}}
/>
</Tooltip>
</div>
<Dropdown>
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
<Button
type="button"
size="icon"
color="minimal"
className="group-hover:text-black"
StartIcon={Icon.FiMoreHorizontal}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className="outline-none">
<Link href={"/event-types/" + type.id} passHref={true}>
<ButtonGroup combined>
<Tooltip content={t("preview") as string}>
<Button
color="secondary"
target="_blank"
size="icon"
href={calLink}
StartIcon={Icon.FiExternalLink}
combined
/>
</Tooltip>
<Tooltip content={t("copy_link") as string}>
<Button
color="secondary"
size="icon"
StartIcon={Icon.FiLink}
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(calLink);
}}
combined
/>
</Tooltip>
<Dropdown modal={false}>
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
<Button
type="button"
size="sm"
color="minimal"
StartIcon={Icon.FiEdit2}
className="w-full">
{t("edit") as string}
</Button>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
size="sm"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiCopy}
onClick={() => openModal(group, type)}>
{t("duplicate") as string}
</Button>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<EmbedButton
color="minimal"
size="sm"
type="button"
as={Button}
StartIcon={Icon.FiCode}
className="w-full rounded-none"
embedUrl={encodeURIComponent(embedLink)}>
{t("embed")}
</EmbedButton>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) && (
<DropdownMenuItem>
<Button
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
}}
color="warn"
size="sm"
StartIcon={Icon.FiTrash}
className="w-full rounded-none">
{t("delete") as string}
</Button>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
size="icon"
color="secondary"
combined
StartIcon={Icon.FiMoreHorizontal}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
data-testid={"event-type-edit-" + type.id}
StartIcon={Icon.FiEdit2}
onClick={() => router.push("/event-types/" + type.id)}>
{t("edit") as string}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiCopy}
onClick={() => openModal(group, type)}>
{t("duplicate") as string}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<EmbedButton
as={DropdownItem}
type="button"
StartIcon={Icon.FiCode}
className="w-full rounded-none"
embedUrl={encodeURIComponent(embedLink)}>
{t("embed")}
</EmbedButton>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) && (
<DropdownMenuItem>
<DropdownItem
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
}}
StartIcon={Icon.FiTrash}
className="w-full rounded-none">
{t("delete") as string}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</ButtonGroup>
</div>
</div>
</div>
</div>
<div className="mr-5 flex flex-shrink-0 sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
<Button type="button" size="icon" color="minimal" StartIcon={Icon.FiMoreHorizontal} />
<Button type="button" size="icon" color="secondary" StartIcon={Icon.FiMoreHorizontal} />
</DropdownMenuTrigger>
<DropdownMenuContent portalled>
<DropdownMenuItem className="outline-none">
<Link href={calLink}>
<Button
rel="noreferrer"
target="_blank"
color="minimal"
size="sm"
StartIcon={Icon.FiExternalLink}
className="w-full rounded-none">
{t("preview") as string}
</Button>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
size="sm"
className="w-full rounded-none text-left"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiClipboard}
onClick={() => {
navigator.clipboard.writeText(calLink);
showToast(t("link_copied"), "success");
}}>
{t("copy_link") as string}
</Button>
</DropdownMenuItem>
{isNativeShare ? (
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuItem className="outline-none">
<Link href={calLink}>
<a target="_blank">
<Button
color="minimal"
StartIcon={Icon.FiExternalLink}
className="w-full rounded-none">
{t("preview") as string}
</Button>
</a>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
size="sm"
className="w-full rounded-none"
className="w-full rounded-none text-left"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiUpload}
StartIcon={Icon.FiClipboard}
onClick={() => {
navigator
.share({
title: t("share"),
text: t("share_event"),
url: calLink,
})
.then(() => showToast(t("link_shared"), "success"))
.catch(() => showToast(t("failed"), "error"));
navigator.clipboard.writeText(calLink);
showToast(t("link_copied"), "success");
}}>
{t("share") as string}
{t("copy_link") as string}
</Button>
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="outline-none">
<Button
type="button"
size="sm"
href={"/event-types/" + type.id}
color="minimal"
className="w-full rounded-none"
StartIcon={Icon.FiEdit2}>
{t("edit") as string}
</Button>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
size="sm"
className="w-full rounded-none"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiCopy}
onClick={() => openModal(group, type)}>
{t("duplicate") as string}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
<DropdownMenuItem className="outline-none">
<Button
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
}}
color="warn"
size="sm"
StartIcon={Icon.FiTrash}
className="w-full rounded-none">
{t("delete") as string}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
{isNativeShare ? (
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
className="w-full rounded-none"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiUpload}
onClick={() => {
navigator
.share({
title: t("share"),
text: t("share_event"),
url: calLink,
})
.then(() => showToast(t("link_shared"), "success"))
.catch(() => showToast(t("failed"), "error"));
}}>
{t("share") as string}
</Button>
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="outline-none">
<Button
type="button"
onClick={() => router.push("/event-types/" + type.id)}
color="minimal"
className="w-full rounded-none"
StartIcon={Icon.FiEdit}>
{t("edit") as string}
</Button>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
className="w-full rounded-none"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiCopy}
onClick={() => openModal(group, type)}>
{t("duplicate") as string}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
<DropdownMenuItem className="outline-none">
<Button
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
}}
color="destructive"
StartIcon={Icon.FiTrash}
className="w-full rounded-none">
{t("delete") as string}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
</div>
</div>
@@ -462,7 +456,6 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
};
const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => {
console.log(profile.slug);
return (
<div className="mb-4 flex">
<Link href="/settings/teams">
@@ -502,16 +495,13 @@ const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeading
</div>
);
};
/**
* @deprecated
* Use component from `/apps/web/pages/v2/event-types/index.tsx` instead
*/
const CreateFirstEventTypeView = () => {
const { t } = useLocale();
return (
<EmptyScreen
Icon={Icon.FiCalendar}
Icon={Icon.FiLink}
headline={t("new_event_type_heading")}
description={t("new_event_type_description")}
/>
@@ -527,13 +517,9 @@ const CTA = () => {
};
const WithQuery = withQuery(["viewer.eventTypes"]);
/**
* @deprecated
* Use component from `/apps/web/pages/v2/event-types/index.tsx` instead
*/
const EventTypesPage = () => {
const { t } = useLocale();
return (
<div>
<Head>
@@ -548,8 +534,6 @@ const EventTypesPage = () => {
customLoader={<SkeletonLoader />}
success={({ data }) => (
<>
<NoCalendarConnectedAlert />
{data.eventTypeGroups.map((group, index) => (
<Fragment key={group.profile.slug}>
{/* hide list heading when there is only one (current user) */}
@@ -559,7 +543,6 @@ const EventTypesPage = () => {
membershipCount={group.metadata.membershipCount}
/>
)}
<EventTypeList
types={group.eventTypes}
group={group}
@@ -92,7 +92,7 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="mx-auto px-4 py-24">
<div className="mx-auto px-4 py-6 md:py-24">
<div className="relative">
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
<div className="mx-auto sm:max-w-[520px]">
@@ -129,7 +129,7 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
event.preventDefault();
goToIndex(currentStepIndex + 1);
}}
className="mt-24 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
{headers[currentStepIndex]?.skipText}
</Button>
</div>
+1 -2
View File
@@ -21,6 +21,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
import { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import { Alert } from "@calcom/ui/Alert";
@@ -39,8 +40,6 @@ import Schedule from "@components/availability/Schedule";
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
import { TRPCClientErrorLike } from "@trpc/client";
// Embed isn't applicable to onboarding, so ignore the rule
/* eslint-disable @calcom/eslint/avoid-web-storage */
+1 -1
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import { useQuery } from "react-query";
import { HttpError } from "@lib/core/http/error";
-70
View File
@@ -1,70 +0,0 @@
import { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import { useRef } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { TextField } from "@calcom/ui/form/fields";
import { getSession } from "@lib/auth";
import SettingsShell from "@components/SettingsShell";
function AdminView() {
const { t } = useLocale();
const usernameRef = useRef<HTMLInputElement>(null);
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 lg:pb-8">
<form
className="mb-6 w-full sm:w-1/2"
onSubmit={(e) => {
e.preventDefault();
const enteredUsername = usernameRef.current?.value.toLowerCase();
signIn("impersonation-auth", { username: enteredUsername }).then((res) => {
console.log(res);
});
}}>
<TextField
name="Impersonate User"
addOnLeading={
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
</span>
}
ref={usernameRef}
defaultValue={undefined}
/>
<p className="mt-2 text-sm text-gray-500" id="email-description">
{t("impersonate_user_tip")}
</p>
<div className="flex justify-end py-4">
<Button type="submit">{t("impersonate")}</Button>
</div>
</form>
</div>
<hr className="mt-8" />
</div>
);
}
export default function Admin() {
const { t } = useLocale();
return (
<SettingsShell heading={t("admin")}>
<AdminView />
</SettingsShell>
);
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getSession(context);
if (!session?.user?.id || session.user.role !== "ADMIN") {
return { redirect: { permanent: false, destination: "/settings/profile" } };
}
return { props: {} };
};
-80
View File
@@ -1,80 +0,0 @@
import { useState } from "react";
import { ReactNode } from "react";
import { HelpScout, useChat } from "react-live-chat-loader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon";
import useMeQuery from "@lib/hooks/useMeQuery";
import SettingsShell from "@components/SettingsShell";
type CardProps = { title: string; description: string; className?: string; children: ReactNode };
const Card = ({ title, description, className = "", children }: CardProps): JSX.Element => (
<div className={`border bg-white sm:rounded-sm ${className}`}>
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">{title}</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>{description}</p>
</div>
<div className="mt-5">{children}</div>
</div>
</div>
);
export default function Billing() {
const { t } = useLocale();
const query = useMeQuery();
const { data } = query;
const [active, setActive] = useState(false);
const [, loadChat] = useChat();
const handleClick = () => {
setActive(true);
loadChat({ open: true });
};
return (
<SettingsShell heading={t("billing")} subtitle={t("manage_your_billing_info")}>
<>
<div className="py-6 lg:col-span-9 lg:pb-8">
{data?.plan && ["FREE", "TRIAL"].includes(data.plan) && (
<Card
title={t("plan_description", { plan: data.plan })}
description={t("plan_upgrade_invitation")}
className="mb-4">
<form method="POST" action="/api/upgrade">
<Button type="submit">
{t("upgrade_now")} <Icon.FiExternalLink className="ml-2 h-4 w-4" />
</Button>
</form>
</Card>
)}
<Card title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
<form method="POST" action="/api/integrations/stripepayment/portal">
<Button type="submit" color="secondary">
{t("go_to_billing_portal")} <Icon.FiExternalLink className="ml-2 h-4 w-4" />
</Button>
</form>
</Card>
<div className="mt-4 border bg-gray-50 sm:rounded-sm">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">{t("need_anything_else")}</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>{t("further_billing_help")}</p>
</div>
<div className="mt-5">
<Button onClick={handleClick} color="secondary">
{t("contact_our_support_team")}
</Button>
</div>
</div>
</div>
</div>
{active && <HelpScout color="#292929" icon="message" horizontalPosition="right" zIndex="1" />}
</>
</SettingsShell>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { useState } from "react";
import { HelpScout, useChat } from "react-live-chat-loader";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Button } from "@calcom/ui/v2";
import Meta from "@calcom/ui/v2/core/Meta";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
interface CtaRowProps {
title: string;
description: string;
children: React.ReactNode;
className?: string;
}
const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
return (
<>
<section className={classNames("flex flex-col sm:flex-row", className)}>
<div>
<h2 className="font-medium">{title}</h2>
<p>{description}</p>
</div>
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pt-0 sm:pl-3">{children}</div>
</section>
<hr className="border-neutral-200" />
</>
);
};
const BillingView = () => {
const { t } = useLocale();
const { data: user } = trpc.useQuery(["viewer.me"]);
const isPro = user?.plan === "PRO";
const [, loadChat] = useChat();
const [showChat, setShowChat] = useState(false);
const onContactSupportClick = () => {
setShowChat(true);
loadChat({ open: true });
};
return (
<>
<Meta title={t("billing")} description={t("manage_billing_description")} />
<div className="space-y-6 text-sm sm:space-y-8">
{!isPro && (
<CtaRow title={t("billing_freeplan_title")} description={t("billing_freeplan_description")}>
<form target="_blank" method="POST" action="/api/upgrade">
<Button type="submit" EndIcon={Icon.FiExternalLink}>
{t("billing_freeplan_cta")}
</Button>
</form>
</CtaRow>
)}
<CtaRow
className={classNames(!isPro && "pointer-events-none opacity-30")}
title={t("billing_manage_details_title")}
description={t("billing_manage_details_description")}>
<Button
color={isPro ? "primary" : "secondary"}
href="/api/integrations/stripepayment/portal"
target="_blank"
EndIcon={Icon.FiExternalLink}>
{t("billing_portal")}
</Button>
</CtaRow>
<CtaRow title={t("billing_help_title")} description={t("billing_help_description")}>
<Button color="secondary" onClick={onContactSupportClick}>
{t("billing_help_cta")}
</Button>
</CtaRow>
{showChat && <HelpScout color="#292929" icon="message" horizontalPosition="right" zIndex="1" />}
</div>
</>
);
};
BillingView.getLayout = getLayout;
export default BillingView;
-30
View File
@@ -1,30 +0,0 @@
import ApiKeyListContainer from "@calcom/features/ee/api-keys/components/ApiKeyListContainer";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import SettingsShell from "@components/SettingsShell";
import WebhookListContainer from "@components/webhook/WebhookListContainer";
export default function Settings() {
const { t } = useLocale();
const { data: routingForms } = trpc.useQuery([
"viewer.appById",
{
appId: "routing-forms",
},
]);
return (
<SettingsShell heading={t("developer")} subtitle={t("manage_developer_settings")}>
<WebhookListContainer title="Event Webhooks" subtitle={t("receive_cal_meeting_data")} />
{routingForms && (
<WebhookListContainer
appId="routing-forms"
title="Routing Webhooks"
subtitle="Receive Routing Form responses at a specified URL, in real-time, when a Routing Form is submitted"
/>
)}
<ApiKeyListContainer />
</SettingsShell>
);
}

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