Compare commits

..
51 Commits
Author SHA1 Message Date
Alex van Andel 5cc530593b Update lockfile
release.yml / release (push) Canceled after 0s
2023-05-23 00:26:22 +02:00
Nafees Nazik 4111f78431 feat: make branding hideable in the new booker (#9033)
* feat: make branding hideable

* fix: typo
2023-05-22 21:54:43 +00:00
Udit Takkar def80ea4cf fix: reject icon color (#9031)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-05-22 21:53:37 +00:00
2fc4676f6d fix: [CAL-1726] UI: seats input focus too wide (#8999)
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-05-22 18:02:49 +00:00
Peer Richelsen 318bafaab8 Update LICENSE 2023-05-22 18:56:17 +01:00
Peer Richelsen fc10c29037 Update LICENSE 2023-05-22 18:54:09 +01:00
Riyan MohammadandUdit Takkar ccb7b1f9ac Optimize conditional rendering (#9009)
* Optimize conditional rendering

Declared a variable called isAdmin. The check session.data?.user.role === "ADMIN" is repeated multiple times. You can store this value in a variable to avoid redundant checks.

* Update AppsLayout.tsx

Removed extra line space to match prettier format

* fix: lint error

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

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-05-22 13:51:22 +00:00
Peer Richelsen c6c6544f50 Update package.json 2023-05-22 14:21:25 +01:00
Hariom Balhara 8a6b93a747 Fix: Embed - Auto theme configured using embed config doesn't work when there is non-system theme configured in appearance. (#9030)
* Differentiate b/w auto theme and unspecified theme

* Fix TS
2023-05-22 11:17:56 +00:00
alannnc ff6c6ccec0 Replacing bookingAttendees for bookingUid (#8997) 2023-05-22 09:30:24 +00:00
c8db177f7d fix: team member search error (#8982)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2023-05-22 09:29:28 +00:00
Nafees Nazikandalannnc 8871e1eb21 fix: wrong locale in attendee email ics (#8945)
* fix: pass attendee translator function

* fix: type

* fix: t

* fix: t

---------

Co-authored-by: alannnc <alannnc@gmail.com>
2023-05-22 06:14:44 +00:00
Shivam KalraandPeer Richelsen 92b2d57871 feat: 8x8 app-store setup (#9015)
* feat: 8x8 app-store setup

* fix: regex remove optional protocol, -imageSrc

* Update packages/app-store/eightxeight/config.json

* Update packages/app-store/eightxeight/config.json

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
2023-05-21 18:21:30 +00:00
Alex van Andel 77623c72c5 Bump lockfile 2023-05-20 12:52:55 +02:00
3239976e18 Fix: Profile update refresh issues (#8570)
* profile update refresh fixes

* updated avatar based on logged in user

* lint & types check fixes

* fixed settings layout to fetch udpated session

* pushing the 8448 changes to separate PR

* removing hook not required

* Removed extra code

---------

Co-authored-by: Keyur Patel <keyur.patel@difx.io>
Co-authored-by: Alan <alannnc@gmail.com>
2023-05-20 00:37:58 +00:00
611939ffe8 remove todo to issue action (#8903)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: alannnc <alannnc@gmail.com>
2023-05-19 22:53:11 +00:00
1b8ecbe449 [CAL-1008] Fix 'All Bookings' filter not getting selected after coming from 'Your Bookings' (#8902)
* [CAL-1008] Fix 'All Bookings' filter not getting selected after coming from 'Your Bookings'

* Remove optional param from removeAllQueryParams function

* Remove setQuery from the extraction

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: alannnc <alannnc@gmail.com>
2023-05-19 22:50:57 +00:00
cd4882429d fix: impersonation switch active state (#8924)
* fix: switch

* fix: checked

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
2023-05-19 21:23:12 +00:00
zomars 4fd2d80637 Linting 2023-05-19 14:07:25 -07:00
a343d934fe Replacement PR for closed PR #8960 (#8980)
* fix focus ring on number of seats

* Adding yarn.lock back

* checkout yarn.lock from main

---------

Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: alannnc <alannnc@gmail.com>
2023-05-19 20:30:30 +00:00
Hariom Balharaandalannnc 933cc739bd Add config support in floatingButton api (#8986)
Co-authored-by: alannnc <alannnc@gmail.com>
2023-05-19 20:10:19 +00:00
Sanu Dutta c7c75ff4d3 enable search in select and adding border bottom color (#8988)
* searchable select

* divider color subtle
2023-05-19 19:43:01 +00:00
Kamalika 41f1920a7a Borders Fixed with border-subtle (#8990) 2023-05-19 16:43:41 +00:00
Carina WollendorferandCarinaWolli e4836ea8f3 make sure to also trigger when cancelled is null (#8992)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-05-19 14:41:44 +00:00
Hariom Balhara bfa30aa504 Implement UpgradeTip in App install flow (#8968) 2023-05-19 09:52:17 +00:00
Carina WollendorferandCarinaWolli b36043b637 Fixes SMS workflow actions for premium users (#8983)
* fix sms actions for users with premium user names

* code clean up

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-05-19 08:10:11 +00:00
ASHOK-SINGH 75026289fd Fix- Calendar override with the wrong dates and time (#8936) 2023-05-19 07:22:26 +00:00
Parth Sharma 80649c9f1a fix height issue (#8973) 2023-05-19 06:46:06 +00:00
Isaiah Hamilton 7dd75afb63 fix button width (#8979) 2023-05-19 06:25:44 +00:00
Crowdin Bot f66e4aeeed New Crowdin translations by Github Action 2023-05-19 00:45:20 +00:00
Rama Krishna Reddyandrkreddy99 8225e61710 fix redirect after adding ping.gg (#8972)
Co-authored-by: rkreddy99 <rreddy@e2clouds.com>
2023-05-19 00:22:07 +00:00
Jeroen Reumkens 175fc4fcf3 CAL-1673: Use dark/light colors from user profile settings in booker (#8905)
* CAL-1673: Use dark/light colors from user profile settings in booker

* Use system theme for dynamic booking, instead of theme set by first user.
2023-05-18 06:16:51 +00:00
shaharyarshamshiandJoe Au-Yeung f20a5df322 Guide to setup enviroment variable added in contributing mardown file (#8911)
* Guide to setup enviroment variable added in contributing mardown file

* linting error

* Update CONTRIBUTING.md

* linting error

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2023-05-17 15:11:28 -07:00
62544931b2 updated language of raycast instructions (#8908)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-05-17 19:10:25 +01:00
Alex van Andel f6ad1d6ed5 Bump lockfile 2023-05-17 16:25:03 +02:00
d22b1072b5 change datepicker styles to fix dark mode issue (#8886)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-05-17 13:12:52 +00:00
4b356dd937 chore: extended slugify function (#8740)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2023-05-17 13:05:14 +00:00
1f51199a7b issue#8601: fix (#8713)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2023-05-17 12:57:12 +00:00
Crowdin Bot b80a631998 New Crowdin translations by Github Action 2023-05-17 13:15:49 +00:00
Crowdin Bot bef4422080 New Crowdin translations by Github Action 2023-05-17 13:14:27 +00:00
cfd1a72c64 [CAL-1545] replace heroicons with lucide.dev (#8599)
* fix linting

* [CAL-1545] replace heroicons with lucide.dev

* [CAL-1545] replace heroicons with lucide.dev

* [CAL-1545] replace heroicons with lucide.dev

---------

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Efraín Rochín <roae.85@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-05-17 12:47:44 +00:00
23b3a6661c Add feature to offset start times for event types (#8506)
* Add offsetStart column to EventType

* Update buildSlots to support offset start times

* Add "Offset Start Time" to edit event types form

* Fix offset events not appearing on availability selector

* Guard against negative offsetStart values

* Lock offsetStart field for managed event types

* EventLimits UI tweaks for "Offset start times"

* EventLimits UI: Fix offsetStart preview not always updating

* Remove unnecessary ctx from getSchedule.test.ts

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-05-17 11:56:55 +00:00
Hariom Balhara b8b6c48d7d Improvements: Prefill Routing Forms and connect prefilling with Booking Form (#8780)
* Support prefilling routing form and prefilling Booking form through routing form

* Use Option Value as is instead of lowercasing

* Fix prefill validation issue

* Add prefill tests

* Fix Routing Form tests

* Small fix
2023-05-17 08:47:48 +00:00
Crowdin Bot 81655f9988 New Crowdin translations by Github Action 2023-05-17 08:47:19 +00:00
Nafees Nazik ff74822c48 Fix: locale and time format in attendee email (#8941)
* feat: add locale prop

* feat: add locale prop

* feat: add locale prop

* fix: time format
2023-05-17 08:17:09 +00:00
Jeroen Reumkens 8ec178debd CAL-1591: Remember form values in new booker form when user navigates back and forth between timeslots and form (#8920) 2023-05-16 20:34:15 +00:00
Hariom BalharaandOmar López f881061d8d Fully handle booking pages with conflicting themes being opened together (#8921)
* Handle booking pages with conflicting themes being opened together

* Add comments

---------

Co-authored-by: Omar López <zomars@me.com>
2023-05-16 19:41:47 +00:00
Crowdin Bot 2525559d6b New Crowdin translations by Github Action 2023-05-16 11:27:38 +00:00
Carina WollendorferandCarinaWolli 38fb7a3f77 fix locked toggle (#8926)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-05-16 11:04:03 +00:00
Alex van Andel 08956e54c5 Fix yarn.lock (missing lockfile commit in #8769) 2023-05-16 00:51:24 +02:00
ea65a2a27e fix: add exp in /meeting-tokens (#8869)
* fix: add exp in /meeting-tokens

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

* hijacking this PR to add a NIT change lol

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-05-15 20:15:05 +00:00
136 changed files with 1379 additions and 553 deletions
-28
View File
@@ -1,28 +0,0 @@
name: TODO to Issue
on:
push:
branches:
- main
workflow_dispatch:
inputs:
MANUAL_COMMIT_REF:
description: "The SHA of the commit to get the diff for"
required: true
MANUAL_BASE_REF:
description: "By default, the commit entered above is compared to the one directly before it; to go back further, enter an earlier SHA here"
required: false
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
todos:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: "Add TODOs to issues"
uses: "alstr/todo-to-issue-action@v4"
env:
MANUAL_COMMIT_REF: ${{ inputs.MANUAL_COMMIT_REF }}
MANUAL_BASE_REF: ${{ inputs.MANUAL_BASE_REF }}
with:
IDENTIFIERS: '[{"name": "TODO", "labels": ["🙋🏻‍♂️help wanted"]}, {"name": "FIXME", "labels": ["🐛 bug"]}]'
+6 -1
View File
@@ -86,7 +86,12 @@ To develop locally:
yarn
```
5. Start developing and watch for code changes:
5. Set up your `.env` file:
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
6. Start developing and watch for code changes:
```sh
yarn dev
+1 -1
View File
@@ -2,7 +2,7 @@ Copyright (c) 2020-present Cal.com, Inc.
Portions of this software are licensed as follows:
* All content that resides under https://github.com/calcom/cal.com/tree/main/packages/features/ee directory of this repository (Enterprise Edition) is licensed under the license defined in "ee/LICENSE".
* All content that resides under https://github.com/calcom/cal.com/tree/main/packages/features/ee directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE".
* All third party components incorporated into the Cal.com Software are licensed under the original license provided by the owner of the applicable component.
* Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
+7 -1
View File
@@ -36,6 +36,7 @@ const Component = ({
tos,
privacy,
isProOnly,
teamsPlanRequired,
descriptionItems,
isTemplate,
dependencies,
@@ -152,6 +153,7 @@ const Component = ({
type={type}
isProOnly={isProOnly}
disableInstall={disableInstall}
teamsPlanRequired={teamsPlanRequired}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
@@ -192,6 +194,7 @@ const Component = ({
type={type}
isProOnly={isProOnly}
disableInstall={disableInstall}
teamsPlanRequired={teamsPlanRequired}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
@@ -241,7 +244,9 @@ const Component = ({
</div>
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
<span className="text-default">
{price === 0 ? (
{teamsPlanRequired ? (
t("teams_plan_required")
) : price === 0 ? (
t("free_to_use_apps")
) : (
<>
@@ -358,6 +363,7 @@ export default function App(props: {
privacy?: string;
licenseRequired: AppType["licenseRequired"];
isProOnly: AppType["isProOnly"];
teamsPlanRequired: AppType["teamsPlanRequired"];
descriptionItems?: Array<string | { iframe: IframeHTMLAttributes<HTMLIFrameElement> }>;
isTemplate?: boolean;
disableInstall?: boolean;
@@ -18,9 +18,9 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
const { t } = useLocale();
const session = useSession();
const router = useRouter();
const isAdmin = session.data?.user.role === "ADMIN";
if (session.status === "loading") return <></>;
return (
<Shell {...rest} actions={actions?.("block")} hideHeadingOnMobile>
<div className="flex flex-col xl:flex-row">
@@ -29,8 +29,8 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
<EmptyScreen
Icon={AlertCircle}
headline={t("no_apps")}
description={session.data?.user.role === "ADMIN" ? "You can enable apps in the settings" : ""}
buttonText={session.data?.user.role === "ADMIN" ? t("apps_settings") : ""}
description={isAdmin ? "You can enable apps in the settings" : ""}
buttonText={isAdmin ? t("apps_settings") : ""}
buttonOnClick={() => router.push("/settings/admin/apps/calendar")}
/>
) : (
@@ -72,7 +72,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
slotUtcStartDate: slot.time,
eventTypeId,
slotUtcEndDate: dayjs(slot.time).utc().add(duration, "minutes").format(),
bookingAttendees: bookingAttendees || undefined,
bookingUid: slot.bookingUid,
});
};
@@ -29,7 +29,7 @@ import {
TableActions,
TextAreaField,
} from "@calcom/ui";
import { Check, Clock, MapPin, RefreshCcw, Send, Slash, X, CreditCard } from "@calcom/ui/components/icon";
import { Check, Clock, MapPin, RefreshCcw, Send, Ban, X, CreditCard } from "@calcom/ui/components/icon";
import useMeQuery from "@lib/hooks/useMeQuery";
@@ -119,7 +119,7 @@ function BookingListItem(booking: BookingItemProps) {
onClick: () => {
setRejectionDialogIsOpen(true);
},
icon: Slash,
icon: Ban,
disabled: mutation.isLoading,
},
// For bookings with payment, only confirm if the booking is paid for
@@ -237,7 +237,7 @@ const BookingPage = ({
eventTypeId: eventType.id,
slotUtcStartDate: dayjs(queryDate).utc().format(),
slotUtcEndDate: dayjs(queryDate).utc().add(parseInt(queryDuration), "minutes").format(),
bookingAttendees: currentSlotBooking ? currentSlotBooking.attendees.length : undefined,
bookingUid: currentSlotBooking?.uid,
});
}
};
@@ -322,7 +322,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
disabled={seatsLocked.disabled}
defaultValue={value || 2}
min={1}
className="w-24"
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
@@ -108,7 +108,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
});
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
const { t } = useLocale();
const { t, i18n } = useLocale();
const formMethods = useFormContext<FormValues>();
const PERIOD_TYPES = [
@@ -149,12 +149,25 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
const periodTypeLocked = shouldLockDisableProps("periodType");
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
const optionsPeriod = [
{ value: 1, label: t("calendar_days") },
{ value: 0, label: t("business_days") },
];
// offsetStart toggle is client-side only, opened by default if offsetStart is set
const offsetStartValue = useWatch({
control: formMethods.control,
name: "offsetStart",
});
const [offsetToggle, setOffsetToggle] = useState(() => offsetStartValue > 0);
// Preview how the offset will affect start times
const offsetOriginalTime = new Date();
offsetOriginalTime.setHours(9, 0, 0, 0);
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + offsetStartValue * 60 * 1000);
return (
<div className="space-y-8">
<div className="space-y-4 lg:space-y-8">
@@ -432,6 +445,32 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
</SettingsToggle>
)}
/>
<hr className="border-subtle" />
<SettingsToggle
title={t("offset_toggle")}
description={t("offset_toggle_description")}
{...offsetStartLockedProps}
checked={offsetToggle}
onCheckedChange={(active) => {
setOffsetToggle(active);
if (!active) {
formMethods.setValue("offsetStart", 0);
}
}}>
<TextField
required
type="number"
{...offsetStartLockedProps}
label={t("offset_start")}
defaultValue={eventType.offsetStart}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}
hint={t("offset_start_description", {
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
})}
/>
</SettingsToggle>
</div>
);
};
@@ -1,9 +1,8 @@
import { ArrowRightIcon } from "@heroicons/react/solid";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { List } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import { AppConnectionItem } from "../components/AppConnectionItem";
import { ConnectedCalendarItem } from "../components/ConnectedCalendarItem";
@@ -85,7 +84,7 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
onClick={() => nextStep()}
disabled={disabledNextButton}>
{firstCalendar ? `${t("continue")}` : `${t("next_step_text")}`}
<ArrowRightIcon className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
<ArrowRight className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
</button>
</>
);
@@ -1,9 +1,8 @@
import { ArrowRightIcon } from "@heroicons/react/solid";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { List } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import { AppConnectionItem } from "../components/AppConnectionItem";
import { StepConnectionLoader } from "../components/StepConnectionLoader";
@@ -59,7 +58,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
disabled={!hasAnyInstalledVideoApps}
onClick={() => nextStep()}>
{t("next_step_text")}
<ArrowRightIcon className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
<ArrowRight className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
</button>
</>
);
@@ -1,4 +1,3 @@
import { ArrowRightIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
@@ -9,6 +8,7 @@ import type { TRPCClientErrorLike } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import { Button, Form } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
interface ISetupAvailabilityProps {
nextStep: () => void;
@@ -81,7 +81,7 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
type="submit"
className="mt-2 w-full justify-center p-2 text-sm sm:mt-8"
disabled={availabilityForm.formState.isSubmitting}>
{t("next_step_text")} <ArrowRightIcon className="ml-2 h-4 w-4 self-center" />
{t("next_step_text")} <ArrowRight className="ml-2 h-4 w-4 self-center" />
</Button>
</div>
</Form>
@@ -1,4 +1,3 @@
import { ArrowRightIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import type { FormEvent } from "react";
import { useRef, useState } from "react";
@@ -11,6 +10,7 @@ import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
import { Avatar } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
@@ -159,7 +159,7 @@ const UserProfile = (props: IUserProfileProps) => {
type="submit"
className="text-inverted mt-8 flex w-full flex-row justify-center rounded-md border border-black bg-black p-2 text-center text-sm">
{t("finish")}
<ArrowRightIcon className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
<ArrowRight className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
</Button>
</form>
);
@@ -1,4 +1,3 @@
import { ArrowRightIcon } from "@heroicons/react/outline";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -10,6 +9,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { Button, TimezoneSelect } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
@@ -116,7 +116,7 @@ const UserSettings = (props: IUserSettingsProps) => {
className="mt-8 flex w-full flex-row justify-center"
disabled={mutation.isLoading}>
{t("next_step_text")}
<ArrowRightIcon className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
<ArrowRight className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
</Button>
</form>
);
@@ -1,4 +1,3 @@
import { StarIcon as StarIconSolid } from "@heroicons/react/solid";
import classNames from "classnames";
import { debounce, noop } from "lodash";
import { useRouter } from "next/router";
@@ -15,6 +14,7 @@ import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import { Button, Dialog, DialogClose, DialogContent, Input, Label } from "@calcom/ui";
import { Star as StarSolid } from "@calcom/ui/components/icon";
import { Check, Edit2, ExternalLink } from "@calcom/ui/components/icon";
export enum UsernameChangeStatusEnum {
@@ -231,11 +231,15 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
<span
className={classNames(
"mx-2 py-2",
isInputUsernamePremium ? "text-orange-400" : "",
isInputUsernamePremium ? "text-transparent" : "",
usernameIsAvailable ? "" : ""
)}>
{isInputUsernamePremium ? <StarIconSolid className="mt-[2px] h-4 w-4" /> : <></>}
{!isInputUsernamePremium && usernameIsAvailable ? <Check className="mt-2 h-4 w-4" /> : <></>}
{isInputUsernamePremium ? <StarSolid className="mt-[2px] h-4 w-4 fill-orange-400" /> : <></>}
{!isInputUsernamePremium && usernameIsAvailable ? (
<Check className="mt-[2px] h-4 w-4" />
) : (
<></>
)}
</span>
</div>
</div>
+105 -44
View File
@@ -26,7 +26,10 @@ const I18nextAdapter = appWithTranslation<NextJsAppProps<SSRConfig> & { children
);
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<NextAppProps<WithNonceProps & Record<string, unknown>>, "Component"> & {
export type AppProps = Omit<
NextAppProps<WithNonceProps & { themeBasis?: string } & Record<string, unknown>>,
"Component"
> & {
Component: NextAppProps["Component"] & {
requiresLicense?: boolean;
isThemeSupported?: boolean;
@@ -72,58 +75,21 @@ const enum ThemeSupport {
Booking = "userConfigured",
}
const CalcomThemeProvider = (
props: PropsWithChildren<
WithNonceProps & {
isBookingPage?: boolean | ((arg: { router: NextRouter }) => boolean);
isThemeSupported?: boolean;
}
>
) => {
// We now support the inverse of how we handled it in the past. Setting this to false will disable theme.
// undefined or true means we use system theme
type CalcomThemeProps = PropsWithChildren<
Pick<AppProps["pageProps"], "nonce" | "themeBasis"> &
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
>;
const CalcomThemeProvider = (props: CalcomThemeProps) => {
const router = useRouter();
const isBookingPage = (() => {
if (typeof props.isBookingPage === "function") {
return props.isBookingPage({ router: router });
}
return props.isBookingPage;
})();
const themeSupport = isBookingPage
? ThemeSupport.Booking
: // if isThemeSupported is explicitly false, we don't use theme there
props.isThemeSupported === false
? ThemeSupport.None
: ThemeSupport.App;
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
// One such example is our Embeds Demo and Testing page at http://localhost:3100
// Having `getEmbedNamespace` defined on window before react initializes the app, ensures that embedNamespace is available on the first mount and can be used as part of storageKey
const embedNamespace = typeof window !== "undefined" ? window.getEmbedNamespace() : null;
const isEmbedMode = typeof embedNamespace === "string";
const storageKey = isEmbedMode
? `embed-theme-${embedNamespace}`
: themeSupport === ThemeSupport.App
? "app-theme"
: themeSupport === ThemeSupport.Booking
? "booking-theme"
: undefined;
return (
<ThemeProvider
nonce={props.nonce}
enableColorScheme={false}
enableSystem={themeSupport !== ThemeSupport.None}
forcedTheme={forcedTheme}
storageKey={storageKey}
// next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes
// This is how login to dashboard soft navigation changes theme from light to dark
key={storageKey}
attribute="class">
<ThemeProvider {...getThemeProviderProps({ props, isEmbedMode, embedNamespace, router })}>
{/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */}
{/* color-scheme makes background:transparent not work in iframe which is required by embed. */}
{typeof window !== "undefined" && !isEmbedMode && (
@@ -140,6 +106,100 @@ const CalcomThemeProvider = (
);
};
/**
* The most important job for this fn is to generate correct storageKey for theme persistenc.
* `storageKey` is important because that key is listened for changes(using [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event) and any pages opened will change it's theme based on that(as part of next-themes implementation).
* Choosing the right storageKey avoids theme flickering caused by another page using different theme
* So, we handle all the cases here namely,
* - Both Booking Pages, /free/30min and /pro/30min but configured with different themes but being operated together.
* - Embeds using different namespace. They can be completely themed different on the same page.
* - Embeds using the same namespace but showing different cal.com links with different themes
* - Embeds using the same namespace and showing same cal.com links with different themes(Different theme is possible for same cal.com link in case of embed because of theme config available in embed)
* - App has different theme then Booking Pages.
*
* All the above cases have one thing in common, which is the origin and thus localStorage is shared and thus `storageKey` is critical to avoid theme flickering.
*
* Some things to note:
* - There is a side effect of so many factors in `storageKey` that many localStorage keys will be created if a user goes through all these scenarios(e.g like booking a lot of different users)
* - Some might recommend disabling localStorage persistence but that doesn't give good UX as then we would default to light theme always for a few seconds before switching to dark theme(if that's the user's preference).
* - We can't disable [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event handling as well because changing theme in one tab won't change the theme without refresh in other tabs. That's again a bad UX
* - Theme flickering becomes infinitely ongoing in case of embeds because of the browser's delay in processing `storage` event within iframes. Consider two embeds simulatenously opened with pages A and B. Note the timeline and keep in mind that it happened
* because 'setItem(A)' and 'Receives storageEvent(A)' allowed executing setItem(B) in b/w because of the delay.
* - t1 -> setItem(A) & Fires storageEvent(A) - On Page A) - Current State(A)
* - t2 -> setItem(B) & Fires storageEvent(B) - On Page B) - Current State(B)
* - t3 -> Receives storageEvent(A) & thus setItem(A) & thus fires storageEvent(A) (On Page B) - Current State(A)
* - t4 -> Receives storageEvent(B) & thus setItem(B) & thus fires storageEvent(B) (On Page A) - Current State(B)
* - ... and so on ...
*/
function getThemeProviderProps({
props,
isEmbedMode,
embedNamespace,
router,
}: {
props: Omit<CalcomThemeProps, "children">;
isEmbedMode: boolean;
embedNamespace: string | null;
router: NextRouter;
}) {
const isBookingPage = (() => {
if (typeof props.isBookingPage === "function") {
return props.isBookingPage({ router: router });
}
return props.isBookingPage;
})();
const themeSupport = isBookingPage
? ThemeSupport.Booking
: // if isThemeSupported is explicitly false, we don't use theme there
props.isThemeSupported === false
? ThemeSupport.None
: ThemeSupport.App;
const isBookingPageThemSupportRequired = themeSupport === ThemeSupport.Booking;
const themeBasis = props.themeBasis;
if ((isBookingPageThemSupportRequired || isEmbedMode) && !themeBasis) {
console.warn(
"`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker."
);
}
const appearanceIdSuffix = themeBasis ? ":" + themeBasis : "";
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
let embedExplicitlySetThemeSuffix = "";
if (typeof window !== "undefined") {
const embedTheme = window.getEmbedTheme();
if (embedTheme) {
embedExplicitlySetThemeSuffix = ":" + embedTheme;
}
}
const storageKey = isEmbedMode
? // Same Namespace, Same Organizer but different themes would still work seamless and not cause theme flicker
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
: themeSupport === ThemeSupport.App
? "app-theme"
: isBookingPageThemSupportRequired
? `booking-theme${appearanceIdSuffix}`
: undefined;
return {
storageKey,
forcedTheme,
themeSupport,
nonce: props.nonce,
enableColorScheme: false,
enableSystem: themeSupport !== ThemeSupport.None,
// next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes
// This is how login to dashboard soft navigation changes theme from light to dark
key: storageKey,
attribute: "class",
};
}
function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
const flags = useFlags();
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
@@ -157,6 +217,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
themeBasis={props.pageProps.themeBasis}
nonce={props.pageProps.nonce}
isThemeSupported={props.Component.isThemeSupported}
isBookingPage={props.Component.isBookingPage}>
-6
View File
@@ -111,12 +111,6 @@ const nextConfig = {
transform: "lucide-react/dist/esm/icons/{{ kebabCase member }}",
preventFullImport: true,
},
"@heroicons/react/solid": {
transform: "@heroicons/react/solid/esm/{{ member }}",
},
"@heroicons/react/outline": {
transform: "@heroicons/react/outline/esm/{{ member }}",
},
"@calcom/features/insights/components": {
transform: "@calcom/features/insights/components/{{member}}",
skipDefaultConversion: true,
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "2.9.0",
"version": "2.9.1",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -41,7 +41,6 @@
"@daily-co/daily-js": "^0.37.0",
"@formkit/auto-animate": "^1.0.0-beta.5",
"@glidejs/glide": "^3.5.2",
"@heroicons/react": "^1.0.6",
"@hookform/error-message": "^2.0.0",
"@hookform/resolvers": "^2.9.7",
"@next-auth/prisma-adapter": "^1.0.4",
+6 -3
View File
@@ -1,4 +1,3 @@
import { BadgeCheckIcon } from "@heroicons/react/solid";
import classNames from "classnames";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -28,7 +27,7 @@ import prisma from "@calcom/prisma";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { Avatar, AvatarGroup, HeadSeo } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import { Verified, ArrowRight } from "@calcom/ui/components/icon";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
@@ -139,7 +138,9 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
<Avatar imageSrc={user.avatar} size="xl" alt={nameOrUsername} />
<h1 className="font-cal text-emphasis mb-1 text-3xl">
{nameOrUsername}
{user.verified && <BadgeCheckIcon className=" mx-1 -mt-1 inline h-6 w-6 text-blue-500" />}
{user.verified && (
<Verified className=" mx-1 -mt-1 inline h-6 w-6 fill-blue-500 text-white dark:text-black" />
)}
</h1>
{!isBioEmpty && (
<>
@@ -356,6 +357,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
users,
safeBio,
profile,
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: isDynamicGroup ? null : user.username,
user: {
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
},
+4
View File
@@ -177,6 +177,8 @@ async function getUserPageProps(context: GetStaticPropsContext) {
slug: `${user.username}/${eventType.slug}`,
image: `${WEBAPP_URL}/${user.username}/avatar.png`,
},
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: user.username,
away: user?.away,
isDynamic: false,
trpcState: ssg.dehydrate(),
@@ -307,6 +309,8 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
props: {
eventType: eventTypeObject,
profile,
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: null,
isDynamic: true,
away: false,
trpcState: ssg.dehydrate(),
+2
View File
@@ -293,6 +293,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
props: {
away: user.away,
profile,
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: isDynamicGroupBooking ? null : user.username,
eventType: eventTypeObject,
booking,
currentSlotBooking: currentSlotBooking,
+1 -1
View File
@@ -46,7 +46,7 @@ class MyDocument extends Document<Props> {
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#000000" />
<meta name="msapplication-TileColor" content="#ff0000" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="white" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9fafb" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1C1C" />
</Head>
+1
View File
@@ -72,6 +72,7 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
website={data.url}
email={data.email}
licenseRequired={data.licenseRequired}
teamsPlanRequired={data.teamsPlanRequired}
isProOnly={data.isProOnly}
descriptionItems={source.data?.items as string[] | undefined}
isTemplate={data.isTemplate}
+4 -4
View File
@@ -1,4 +1,3 @@
import { CheckIcon, MailOpenIcon, ExclamationIcon } from "@heroicons/react/outline";
import { signIn } from "next-auth/react";
import Head from "next/head";
import { useRouter } from "next/router";
@@ -9,6 +8,7 @@ import z from "zod";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { trpc } from "@calcom/trpc/react";
import { Button, showToast } from "@calcom/ui";
import { Check, MailOpen, AlertTriangle } from "@calcom/ui/components/icon";
import Loader from "@components/Loader";
import PageWrapper from "@components/PageWrapper";
@@ -118,11 +118,11 @@ export default function Verify() {
<div className="m-10 flex max-w-2xl flex-col items-start border border-white p-12 text-left">
<div className="rounded-full border border-white p-3">
{hasPaymentFailed ? (
<ExclamationIcon className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
<AlertTriangle className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
) : sessionId ? (
<CheckIcon className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
<Check className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
) : (
<MailOpenIcon className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
<MailOpen className="text-inverted h-12 w-12 flex-shrink-0 p-0.5 font-extralight" />
)}
</div>
<h3 className="font-cal my-6 text-3xl font-normal">
+1
View File
@@ -1092,6 +1092,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username,
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
profile,
eventType,
+1
View File
@@ -160,6 +160,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
away: user.away,
themeBasis: user.username,
isDynamicGroup: false,
profile,
date,
+1
View File
@@ -122,6 +122,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
profile,
themeBasis: user.username,
eventType: eventTypeObject,
booking: null,
currentSlotBooking: null,
@@ -51,6 +51,7 @@ export type FormValues = {
eventName: string;
slug: string;
length: number;
offsetStart: number;
description: string;
disableGuests: boolean;
requiresConfirmation: boolean;
@@ -211,6 +212,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
bookingLimits: eventType.bookingLimits || undefined,
durationLimits: eventType.durationLimits || undefined,
length: eventType.length,
offsetStart: eventType.offsetStart,
hidden: eventType.hidden,
periodDates: {
startDate: periodDates.startDate,
@@ -255,6 +257,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
)
.optional(),
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
bookingFields: eventTypeBookingFields,
})
// TODO: Add schema for other fields later.
+17 -3
View File
@@ -14,11 +14,22 @@ import PageWrapper from "@components/PageWrapper";
type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away }: PageProps) {
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
return (
<main className="flex h-full min-h-[100dvh] items-center justify-center">
<BookerSeo username={user} eventSlug={slug} rescheduleUid={booking?.uid} />
<Booker username={user} eventSlug={slug} rescheduleBooking={booking} isAway={away} />
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
/>
<Booker
username={user}
eventSlug={slug}
rescheduleBooking={booking}
isAway={away}
hideBranding={isBrandingHidden}
/>
</main>
);
}
@@ -64,6 +75,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
slug,
away: false,
trpcState: ssr.dehydrate(),
isBrandingHidden: false,
},
};
}
@@ -80,6 +92,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
},
select: {
away: true,
hideBranding: true,
},
});
@@ -103,6 +116,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
user: username,
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
},
};
}
@@ -13,11 +13,22 @@ import PageWrapper from "@components/PageWrapper";
type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away }: PageProps) {
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
return (
<main className="flex h-full min-h-[100dvh] items-center justify-center">
<BookerSeo username={user} eventSlug={slug} rescheduleUid={booking?.uid} />
<Booker username={user} eventSlug={slug} rescheduleBooking={booking} isAway={away} />
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
/>
<Booker
username={user}
eventSlug={slug}
rescheduleBooking={booking}
isAway={away}
hideBranding={isBrandingHidden}
/>
</main>
);
}
@@ -41,6 +52,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
select: {
id: true,
hideBranding: true,
},
});
@@ -62,6 +74,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
user: teamSlug,
slug: meetingSlug,
trpcState: ssr.dehydrate(),
isBrandingHidden: team?.hideBranding,
},
};
};
@@ -50,6 +50,7 @@ const ProfileImpersonationView = () => {
setValue,
reset,
getValues,
watch,
} = formMethods;
const isDisabled = isSubmitting || !isDirty;
@@ -63,11 +64,11 @@ const ProfileImpersonationView = () => {
}}>
<div className="flex space-x-3">
<Switch
defaultChecked={!user?.disableImpersonation}
onCheckedChange={(e) => {
setValue("disableImpersonation", !e, { shouldDirty: true });
}}
fitToHeight={true}
checked={!watch("disableImpersonation")}
/>
<div className="flex flex-col">
<Skeleton as={Label} className="text-emphasis text-sm font-semibold leading-none">
+1
View File
@@ -207,6 +207,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
team: { ...team, safeBio, members },
themeBasis: team.slug,
trpcState: ssr.dehydrate(),
markdownStrippedBio,
},
+1
View File
@@ -196,6 +196,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
brandColor: team.brandColor,
darkBrandColor: team.darkBrandColor,
},
themeBasis: team.slug,
date: dateParam,
eventType: eventTypeObject,
workingHours,
+1
View File
@@ -153,6 +153,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
image: eventTypeObject.team?.logo || null,
eventName: null,
},
themeBasis: eventTypeObject.team?.slug,
eventType: eventTypeObject,
recurringEventCount,
booking,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 B

After

Width:  |  Height:  |  Size: 743 B

+1 -3
View File
@@ -1,3 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke="white" stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" x2="19.07" y1="4.93" y2="19.07"></line></svg>

Before

Width:  |  Height:  |  Size: 309 B

After

Width:  |  Height:  |  Size: 308 B

@@ -1572,7 +1572,7 @@
"sender_name": "اسم المرسل",
"already_invited": "تمت دعوة هذا الحاضر بالفعل",
"no_recordings_found": "لم يتم العثور على تسجيلات",
"new_workflow_subtitle": "سير عمل جديد من أجل...",
"new_workflow_subtitle": "سير عمل جديد لـ...",
"reporting": "تقديم التقارير",
"reporting_feature": "عرض جميع البيانات الواردة وتنزيلها كملف CSV",
"teams_plan_required": "خطة Teams مطلوبة",
@@ -756,6 +756,10 @@
"new_event_type_to_book_description": "Create a new event type for people to book times with.",
"length": "Length",
"minimum_booking_notice": "Minimum Notice",
"offset_toggle": "Offset start times",
"offset_toggle_description": "Offset timeslots shown to bookers by a specified number of minutes",
"offset_start": "Offset by",
"offset_start_description": "e.g. this will show time slots to your bookers at {{ adjustedTime }} instead of {{ originalTime }}",
"slot_interval": "Time-slot intervals",
"slot_interval_default": "Use event length (default)",
"delete_event_type": "Delete event type?",
@@ -1815,6 +1819,7 @@
"open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.",
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
"book_my_cal": "Book my Cal",
"form_updated_successfully":"Form updated successfully.",
"email_not_cal_member_cta": "Join your team",
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",
"disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.",
@@ -1,4 +1,5 @@
{
"identity_provider": "அடையாள வழங்குபவர்",
"trial_days_left": "உங்கள் மேம்படுத்தப்பட்ட பயன்பாட்டு சோதனையில் $t(day, {\"count\": {{days}} }) மீதமுள்ளது",
"day_one": "{{count}} நாள்",
"day_other": "{{count}} நாட்கள்",
@@ -7,6 +8,7 @@
"upgrade_now": "மேம்படுத்தவும்",
"accept_invitation": "அழைப்பை ஏற்கவும்",
"calcom_explained": "{{appName}} அனைவருக்கும் திட்டமிடல் உள்கட்டமைப்பை வழங்குகிறது.",
"calcom_explained_new_user": "உங்கள் {{appName}} கணக்கை அமைக்கவும்! உங்கள் திட்டமிடல் சிக்கல்கள் அனைத்தையும் தீர்க்க சில படிகள் மட்டுமே உள்ளன.",
"have_any_questions": "கேள்விகள் உள்ளதா? நாங்கள் உதவ தயாராக இருக்கிறோம்.",
"reset_password_subject": "{{appName}}: கடவுச்சொல் மீட்டமைக்கும் வழிமுறைகள்",
"event_declined_subject": "{{title}} {{date}} அன்று நிராகரிக்கப்பட்டது",
@@ -28,8 +30,58 @@
"your_event_has_been_scheduled": "உங்கள் நிகழ்வு திட்டமிடப்பட்டது",
"your_event_has_been_scheduled_recurring": "உங்கள் தொடர் நிகழ்வு திட்டமிடப்பட்டுள்ளது",
"accept_our_license": ".env மாறியை மாற்றுவதன் மூலம் எங்கள் உரிமத்தை ஏற்கவும்\n<1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
"error_message": "பிழை செய்தி: '{{errorMessage}}'",
"refund_failed_subject": "பணத்தைத் திரும்பப் பெற முடியவில்லை: {{name}} - {{date}} - {{eventType}}",
"refund_failed": "{{date}} அன்று {{userName}} உடன் நடந்த {{eventType}} நிகழ்விற்கான பணத்தைத் திரும்பி அனுப்ப இயலவில்லை.",
"check_with_provider_and_user": "உங்கள் கட்டண வழங்குநர் மற்றும் {{user}} உடன் இனைந்து இதை எவ்வாறு கையாள்வது என்பதைச் சரிபார்க்கவும்.",
"a_refund_failed": "பணத்தைத் திரும்பப் பெற முடியவில்லை",
"awaiting_payment_subject": "பணம் செலுத்த காத்திருக்கிறது: {{date}} அன்று {{title}}",
"meeting_awaiting_payment": "உங்கள் மீட்டிங் கட்டணம் செலுத்த காத்திருக்கிறது",
"help": "உதவி",
"price": "விலை",
"paid": "பணம் செலுத்தப்பட்டது",
"refunded": "பணம் திருப்பிக் கொடுக்கப்பட்டது",
"payment": "பணம் செலுத்துதல்",
"missing_card_fields": "கார்டு புலங்கள் இல்லை",
"pay_now": "இப்போது பணம் செலுத்த",
"codebase_has_to_stay_opensource": "கோட்பேஸ் மாற்றப்பட்டாலும் இல்லாவிட்டாலும் திறந்த மூலமாக இருக்க வேண்டும்",
"cannot_repackage_codebase": "நீங்கள் கோட்பேஸை மீண்டும் பேக்கேஜ் செய்யவோ விற்கவோ முடியாது",
"acquire_license": "மின்னஞ்சல்மூலம் இந்த விதிமுறைகளை அகற்ற வணிக உரிமத்தைப் பெறவும்",
"terms_summary": "விதிமுறைகளின் சுருக்கம்",
"open_env": ".env ஐத் திறந்து எங்கள் உரிமத்தை ஏற்கவும்",
"env_changed": "நான் என் .env ஐ மாற்றிவிட்டேன்",
"accept_license": "உரிமத்தை ஏற்கவும்",
"still_waiting_for_approval": "ஒரு நிகழ்வு இன்னும் ஒப்புதலுக்காகக் காத்திருக்கிறது",
"event_is_still_waiting": "நிகழ்வு கோரிக்கை இன்னும் காத்திருக்கிறது: {{attendeeName}} - {{date}} - {{eventType}}",
"no_more_results": "மேலும் முடிவுகள் இல்லை",
"no_results": "முடிவுகள் இல்லை",
"load_more_results": "மேலும் முடிவுகளை ஏற்றவும்",
"integration_meeting_id": "{{integrationName}} சந்திப்பு ஐடி: {{meetingId}}",
"confirmed_event_type_subject": "உறுதிப்படுத்தப்பட்டது: {{date}} அன்று {{name}} உடன் {{eventType}}",
"new_event_request": "புதிய நிகழ்வு கோரிக்கை: {{attendeeName}} - {{date}} - {{eventType}}",
"confirm_or_reject_request": "கோரிக்கையை உறுதிப்படுத்தவும் அல்லது நிராகரிக்கவும்",
"check_bookings_page_to_confirm_or_reject": "முன்பதிவை உறுதிப்படுத்த அல்லது நிராகரிக்க உங்கள் முன்பதிவு பக்கத்தைப் பார்க்கவும்.",
"event_awaiting_approval": "உங்கள் ஒப்புதலுக்காக ஒரு நிகழ்வு காத்திருக்கிறது",
"event_awaiting_approval_recurring": "தொடர்ச்சியான நிகழ்வு உங்கள் ஒப்புதலுக்காகக் காத்திருக்கிறது",
"someone_requested_an_event": "உங்கள் காலெண்டரில் ஒரு நிகழ்வைத் திட்டமிடுமாறு ஒருவர் கோரியுள்ளார்.",
"someone_requested_password_reset": "உங்கள் கடவுச்சொல்லை மாற்ற யாரோ ஒரு இணைப்பைக் கோரியுள்ளனர்.",
"password_reset_instructions": "நீங்கள் இதைக் கோரவில்லை எனில், இந்த மின்னஞ்சலைப் பாதுகாப்பாகப் புறக்கணிக்கலாம், மேலும் உங்கள் கடவுச்சொல் மாற்றப்படாது.",
"event_awaiting_approval_subject": "ஒப்புதலுக்காகக் காத்திருக்கிறது: {{title}} {{date}} இல்",
"event_still_awaiting_approval": "உங்கள் ஒப்புதலுக்காக ஒரு நிகழ்வு இன்னும் காத்திருக்கிறது",
"booking_submitted_subject": "முன்பதிவு சமர்ப்பிக்கப்பட்டது: {{title}} {{date}} அன்று",
"download_your_recording": "உங்கள் பதிவைப் பதிவிறக்கவும்",
"your_meeting_has_been_booked": "உங்கள் சந்திப்பு பதிவு செய்யப்பட்டுள்ளது",
"event_type_has_been_rescheduled_on_time_date": "உங்கள் {{title}}, {{date}} க்கு மாற்றியமைக்கப்பட்டுள்ளது.",
"event_has_been_rescheduled": "புதுப்பிக்கப்பட்டது - உங்கள் நிகழ்வு மீண்டும் திட்டமிடப்பட்டது",
"request_reschedule_title_attendee": "உங்கள் முன்பதிவை மாற்றியமைக்க கோரிக்கை",
"request_reschedule_subtitle": "{{organizer}} முன்பதிவை ரத்துசெய்துவிட்டு, மற்றொரு நேரத்தைத் தேர்ந்தெடுக்கும்படி கோரியுள்ளார்.",
"request_reschedule_title_organizer": "நீங்கள் {{attendee}} ஐ மறுஅட்டவணைக்குக் கோரியுள்ளீர்கள்",
"request_reschedule_subtitle_organizer": "நீங்கள் முன்பதிவை ரத்து செய்துவிட்டீர்கள், {{attendee}} உங்களுடன் புதிய முன்பதிவு நேரத்தைத் தேர்ந்தெடுக்க வேண்டும்.",
"rescheduled_event_type_subject": "மறுஅட்டவணைக்கான கோரிக்கை அனுப்பப்பட்டது: {{eventType}} உடன் {{name}} உடன் {{date}}",
"requested_to_reschedule_subject_attendee": "மறுஅட்டவணை செயல் தேவை: {{eventType}} க்கு {{name}} உடன் புதிய நேரத்தை முன்பதிவு செய்யவும்",
"reschedule_reason": "மறு அட்டவணைக்கான காரணம்",
"hi_user_name": "வணக்கம் {{name}}",
"turn_on": "இயக்கவும்",
"email_no_user_invite_heading": "நீங்கள் {{appName}} குழுவில் சேர அழைக்கப்பட்டுள்ளீர்கள்",
"email_no_user_invite_subheading": "{{invitedBy}} உங்களை {{appName}} குழுவில் சேர அழைத்துள்ளார். {{appName}} என்பது ஈமெயில் இல்லாமலேயே உங்களையும் உங்கள் குழுவையும் கூட்டங்களைத் திட்டமிடுவதற்கு உதவும் திட்டமிடல் பயன்பாடு.",
"email_no_user_invite_steps_intro": "சில சிறிய முயர்ச்சித்தல் மூலம் நாங்கள் உங்களை அழைத்துச் செல்வோம், எந்த நேரத்திலும் உங்கள் குழுவுடன் மன அழுத்தமில்லாத திட்டமிடலை அனுபவிப்பீர்கள்.",
@@ -114,6 +166,7 @@
"select_all": "அனைத்தையும் தேர்வுசெய்",
"default_conferencing_bulk_title": "தற்போதுள்ள நிகழ்வு வகைகளை மொத்தமாகப் புதுப்பிக்கவும்",
"default_conferencing_bulk_description": "தேர்ந்தெடுக்கப்பட்ட நிகழ்வு வகைகளுக்கான இருப்பிடங்களைப் புதுப்பிக்கவும்",
"locked_by_admin": "குழு நிர்வாகியால் பூட்டப்பட்டது",
"looking_for_more_analytics": "மேலும் பகுப்பாய்வுகளைத் தேடுகிறீர்களா?",
"add_filter": "வடிகட்டிச் சேர்க்கவும்",
"select_user": "பயனரைத் தேர்ந்தெடுக்கவும்",
@@ -1191,6 +1191,7 @@
"create_workflow": "创建工作流程",
"do_this": "执行此操作",
"turn_off": "关闭",
"turn_on": "打开",
"settings_updated_successfully": "设置已成功更新",
"error_updating_settings": "更新设置时出错",
"personal_cal_url": "我的个人 {{appName}} URL",
@@ -1726,6 +1727,7 @@
"locked_apps_description": "成员将能够查看活动的应用,但不能编辑任何应用设置",
"locked_webhooks_description": "成员将能够查看活动的 Webhook,但不能编辑任何 Webhook 设置",
"locked_workflows_description": "成员将能够查看活动的工作流程,但不能编辑任何工作流程设置",
"locked_by_admin": "被团队管理员锁定",
"app_not_connected": "您尚未连接 {{appName}} 账户。",
"connect_now": "立即连接",
"managed_event_dialog_confirm_button_one": "替换并通知 {{count}} 个成员",
+68 -4
View File
@@ -151,10 +151,6 @@ const TestData = {
},
};
const ctx = {
prisma: prismaMock,
};
type App = {
slug: string;
dirName: string;
@@ -186,6 +182,7 @@ type InputEventType = {
id: number;
title?: string;
length?: number;
offsetStart?: number;
slotInterval?: number;
minimumBookingNotice?: number;
users?: { id: number }[];
@@ -711,6 +708,72 @@ describe("getSchedule", () => {
);
});
test("Start times are offset (offsetStart)", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 25,
offsetStart: 5,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
},
],
hosts: [],
apps: [TestData.apps.googleCalendar],
};
createBookingScenario(scenarioData);
const schedule = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
expect(schedule).toHaveTimeSlots(
[
`04:05:00.000Z`,
`04:35:00.000Z`,
`05:05:00.000Z`,
`05:35:00.000Z`,
`06:05:00.000Z`,
`06:35:00.000Z`,
`07:05:00.000Z`,
`07:35:00.000Z`,
`08:05:00.000Z`,
`08:35:00.000Z`,
`09:05:00.000Z`,
`09:35:00.000Z`,
`10:05:00.000Z`,
`10:35:00.000Z`,
`11:05:00.000Z`,
`11:35:00.000Z`,
`12:05:00.000Z`,
],
{
dateString: plus2DateString,
}
);
});
test("Check for Date overrides", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
@@ -1132,6 +1195,7 @@ function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
seatsPerTimeSlot: null,
metadata: {},
minimumBookingNotice: 0,
offsetStart: 0,
};
const foundEvents: Record<number, boolean> = {};
const eventTypesWithUsers = eventTypes.map((eventType) => {
+27
View File
@@ -24,6 +24,7 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 60,
offsetStart: 0,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(24);
@@ -46,6 +47,7 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 60,
offsetStart: 0,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(12);
@@ -66,6 +68,7 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 60,
offsetStart: 0,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(0);
@@ -87,6 +90,7 @@ describe("Tests the slot logic", () => {
minimumBookingNotice: 0,
workingHours,
eventLength: 60,
offsetStart: 0,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(0);
@@ -108,6 +112,7 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 60,
offsetStart: 0,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(11);
@@ -129,8 +134,30 @@ describe("Tests the slot logic", () => {
},
],
eventLength: 20,
offsetStart: 0,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(71);
});
it("can fit 48 25 minute slots with a 5 minute offset for an empty day", async () => {
expect(
getSlots({
inviteeDate: dayjs.utc().add(1, "day"),
frequency: 25,
minimumBookingNotice: 0,
workingHours: [
{
userId: 1,
days: Array.from(Array(7).keys()),
startTime: MINUTES_DAY_START,
endTime: MINUTES_DAY_END,
},
],
eventLength: 25,
offsetStart: 5,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(48);
});
});
+1 -1
View File
@@ -97,7 +97,7 @@
"dependencies": {
"city-timezones": "^1.2.1",
"eslint": "^8.34.0",
"lucide-react": "^0.130.0",
"lucide-react": "^0.171.0",
"turbo": "^1.8.3"
},
"resolutions": {
@@ -47,6 +47,7 @@ export default function OmniInstallAppButton({
<InstallAppButton
type={app.type}
isProOnly={app.isProOnly}
teamsPlanRequired={app.teamsPlanRequired}
wrapperClassName={classNames("[@media(max-width:260px)]:w-full", className)}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
@@ -11,6 +11,7 @@ import closecom_config_json from "./closecom/config.json";
import cron_config_json from "./cron/config.json";
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
import discord_config_json from "./discord/config.json";
import eightxeight_config_json from "./eightxeight/config.json";
import { metadata as exchange2013calendar__metadata_ts } from "./exchange2013calendar/_metadata";
import { metadata as exchange2016calendar__metadata_ts } from "./exchange2016calendar/_metadata";
import exchangecalendar_config_json from "./exchangecalendar/config.json";
@@ -75,6 +76,7 @@ export const appStoreMetadata = {
cron: cron_config_json,
dailyvideo: dailyvideo__metadata_ts,
discord: discord_config_json,
eightxeight: eightxeight_config_json,
exchange2013calendar: exchange2013calendar__metadata_ts,
exchange2016calendar: exchange2016calendar__metadata_ts,
exchangecalendar: exchangecalendar_config_json,
@@ -11,6 +11,7 @@ export const apiHandlers = {
closecom: import("./closecom/api"),
cron: import("./cron/api"),
discord: import("./discord/api"),
eightxeight: import("./eightxeight/api"),
exchange2013calendar: import("./exchange2013calendar/api"),
exchange2016calendar: import("./exchange2016calendar/api"),
exchangecalendar: import("./exchangecalendar/api"),
+14 -3
View File
@@ -7,6 +7,7 @@ import classNames from "@calcom/lib/classNames";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { CAL_URL } from "@calcom/lib/constants";
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -49,14 +50,17 @@ export const InstallAppButtonWithoutPlanCheck = (
export const InstallAppButton = (
props: {
isProOnly?: App["isProOnly"];
teamsPlanRequired?: App["teamsPlanRequired"];
type: App["type"];
wrapperClassName?: string;
disableInstall?: boolean;
} & InstallAppButtonProps
) => {
const { isLoading, data: user } = trpc.viewer.me.useQuery();
const { isLoading: isUserLoading, data: user } = trpc.viewer.me.useQuery();
const router = useRouter();
const proProtectionElementRef = useRef<HTMLDivElement | null>(null);
const { isLoading: isTeamPlanStatusLoading, hasTeamPlan } = useHasTeamPlan();
useEffect(() => {
const el = proProtectionElementRef.current;
if (!el) {
@@ -72,12 +76,19 @@ export const InstallAppButton = (
e.stopPropagation();
return;
}
if (props.teamsPlanRequired && !hasTeamPlan) {
// TODO: I think we should show the UpgradeTip in a Dialog here. This would solve the problem of no way to go back to the App page from the UpgradeTip page(except browser's back button)
router.push(props.teamsPlanRequired.upgradeUrl);
e.stopPropagation();
return;
}
},
true
);
}, [isLoading, user, router, props.isProOnly]);
}, [isUserLoading, user, router, props.isProOnly, hasTeamPlan, props.teamsPlanRequired]);
if (isLoading) {
if (isUserLoading || isTeamPlanStatusLoading) {
return null;
}
@@ -93,7 +93,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
const body = await translateEvent(event);
const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse);
const meetingToken = await postToDailyAPI("/meeting-tokens", {
properties: { room_name: dailyEvent.name, is_owner: true },
properties: { room_name: dailyEvent.name, exp: dailyEvent.config.exp, is_owner: true },
}).then(meetingTokenSchema.parse);
return Promise.resolve({
@@ -0,0 +1,12 @@
---
items:
- 1.jpeg
- 2.jpeg
---
{DESCRIPTION}
- High definition video conferencing on any device for up to 500 participants
- Rich content sharing and collaboration
- Enterprise grade reliability and availability for a business of five or 50,000
- 100% secured and encrypted
- Copy your room link and start scheduling calls in 8x8
+16
View File
@@ -0,0 +1,16 @@
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
};
export default handler;
@@ -0,0 +1 @@
export { default as add } from "./add";
@@ -0,0 +1,28 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "8x8",
"slug": "eightxeight",
"type": "eightxeight_video",
"logo": "icon.svg",
"url": "https://cal.com/apps/eightxeight",
"variant": "conferencing",
"categories": [
"video"
],
"publisher": "shivamklr",
"email": "shivamkalra98@gmail.com",
"appData": {
"location": {
"type": "integrations:{SLUG}_video",
"label": "{TITLE}",
"linkType": "static",
"organizerInputPlaceholder": "https://8x8.vc/company",
"urlRegExp": "^(http|https)://(www\\.)?8x8.vc/[a-zA-Z0-9]*"
}
},
"description": "The best video conferencing solution for businesses of any size. Fully secure, reliable, packed with features and ridiculously simple to use.\r\r",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-location-video-static",
"dirName": "eightxeight"
}
+1
View File
@@ -0,0 +1 @@
export * as api from "./api";
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/eightxeight",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "The best video conferencing solution for businesses of any size. Fully secure, reliable, packed with features and ridiculously simple to use.\r\r"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 288 288" style="enable-background:new 0 0 288 288;" xml:space="preserve">
<style type="text/css">
.st0{fill:#CB2233;}
.st1{fill:#FFFFFF;}
</style>
<rect x="-0.2" y="0.5" class="st0" width="288" height="288"/>
<path class="st1" d="M172.7,132.1h-21.9l-5,9.6c-0.5,1.1-1.1,2.2-1.8,3.2h-0.2c0,0-1-1.7-1.8-3.2l-5-9.6h-21.9l17.2,26.5l-17.1,26.5
h21.5l5.8-11.1c0.6-1,1.4-3,1.4-3h0.2c0,0,0.8,2,1.4,3l5.9,11.1h21.5l-17.1-26.5L172.7,132.1L172.7,132.1z"/>
<path class="st1" d="M75.9,171.7c-5.9,0.1-10.7-4.5-10.9-10.4c0,0,0-0.1,0-0.1c0.1-4.1,1.9-8.1,4.8-11c8.8,4,16.9,7.1,16.9,12.2
C86.7,168.4,82.6,171.7,75.9,171.7L75.9,171.7z M76.5,117.3c5.9,0,9.5,3.3,9.5,8.3c-0.1,3.9-1.2,7.7-3.2,11.1
c-7.9-3.4-14.5-6.4-14.5-12.4C68.3,120.4,70.8,117.3,76.5,117.3z M98.7,144.5c0.9-1.1,8.8-10.9,8.8-20.6c0-16.8-13.8-26.1-30.5-26.1
c-21.1,0-30.3,12.9-30.3,26c0,7.7,3.4,13.1,8.2,17.3c-2.8,2.1-12.5,10.4-12.5,22c0,14.4,11.5,28,33.4,28c22.3,0,33.4-13.8,33.4-27.5
C109.3,154.5,104.8,148.7,98.7,144.5L98.7,144.5z"/>
<path class="st1" d="M211.7,171.7c-5.9,0.1-10.7-4.5-10.8-10.4c0,0,0-0.1,0-0.1c0.1-4.1,1.8-8.1,4.8-11c8.8,4,16.9,7.1,16.9,12.2
C222.5,168.4,218.4,171.7,211.7,171.7L211.7,171.7z M212.3,117.3c5.9,0,9.5,3.3,9.5,8.3c-0.1,3.9-1.2,7.7-3.2,11.1
c-7.9-3.4-14.5-6.4-14.5-12.4C204.1,120.4,206.7,117.3,212.3,117.3z M234.5,144.5c0.9-1.1,8.8-10.9,8.8-20.6
c0-16.8-13.8-26.1-30.6-26.1c-21.1,0-30.3,12.9-30.3,26c0,7.7,3.4,13.1,8.2,17.3c-2.8,2.1-12.5,10.4-12.5,22c0,14.4,11.5,28,33.4,28
c22.3,0,33.4-13.8,33.4-27.5C245.1,154.5,240.6,148.7,234.5,144.5L234.5,144.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,10 +1,10 @@
import { LinkIcon, SearchIcon } from "@heroicons/react/outline";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Button, Dialog, DialogClose, DialogContent, DialogFooter, Input } from "@calcom/ui";
import { Link, Search } from "@calcom/ui/components/icon";
interface ISearchDialog {
isOpenDialog: boolean;
@@ -118,8 +118,8 @@ export const SearchDialog = (props: ISearchDialog) => {
</h3>
<p className="text-subtle mb-3 text-sm font-light">{t("find_gif_spice_confirmation")}</p>
<div className="border-emphasis flex items-center border-b border-solid">
{renderTab(SearchIcon, t("search_giphy"), MODE_SEARCH)}
{renderTab(LinkIcon, t("add_link_from_giphy"), MODE_URL)}
{renderTab(Search, t("search_giphy"), MODE_SEARCH)}
{renderTab(Link, t("add_link_from_giphy"), MODE_URL)}
</div>
<form
className="flex w-full justify-center space-x-2 space-y-2 rtl:space-x-reverse"
@@ -1,8 +1,8 @@
import { PencilAltIcon, PlusIcon, XIcon } from "@heroicons/react/solid";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
import { Edit, Plus, X } from "@calcom/ui/components/icon";
import { SearchDialog } from "./SearchDialog";
@@ -24,11 +24,11 @@ export default function SelectGifInput(props: ISelectGifInput) {
)}
<div className="flex">
{selectedGif ? (
<Button color="minimal" type="button" StartIcon={PencilAltIcon} onClick={() => setShowDialog(true)}>
<Button color="minimal" type="button" StartIcon={Edit} onClick={() => setShowDialog(true)}>
Change
</Button>
) : (
<Button color="minimal" type="button" StartIcon={PlusIcon} onClick={() => setShowDialog(true)}>
<Button color="minimal" type="button" StartIcon={Plus} onClick={() => setShowDialog(true)}>
Add from Giphy
</Button>
)}
@@ -37,7 +37,7 @@ export default function SelectGifInput(props: ISelectGifInput) {
<Button
color="destructive"
type="button"
StartIcon={XIcon}
StartIcon={X}
onClick={() => {
setSelectedGif("");
props.onChange("");
+1 -1
View File
@@ -20,7 +20,7 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
key: {},
});
return { url: getInstalledAppPath({ variant: "other", slug: "ping" }) };
return { url: getInstalledAppPath({ variant: appConfig.variant, slug: "ping" }) };
}
export default defaultResponder(getHandler);
+1 -1
View File
@@ -6,4 +6,4 @@ items:
- 4.png
---
Quickly share your Cal.com meeting links with Raycast. Requires Raycast.com to be installed. You can create an API token in your Developer Cal.com Settings.
Quickly share your Cal.com meeting links with Raycast. Requires Raycast.com to be installed and the Cal.com from the Raycast Store needs to be installed: https://www.raycast.com/cal/. You can create an API token in your Developer Cal.com Settings, and then you can add the API Key by opening up the Cal.com App in Raycast.
@@ -276,6 +276,9 @@ export function FormActionsProvider({ appUrl, children }: { appUrl: string; chil
}
return { previousValue };
},
onSuccess: () => {
showToast(t("form_updated_successfully"), "success");
},
onSettled: (routingForm) => {
utils.viewer.appRoutingForms.forms.invalidate();
if (routingForm) {
@@ -463,7 +466,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
const Component = as || Button;
if (!dropdown) {
return (
<Component ref={forwardedRef} {...actionProps}>
<Component data-testid={`form-action-${actionName}`} ref={forwardedRef} {...actionProps}>
{children}
</Component>
);
@@ -1,6 +1,7 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import type { Dispatch, SetStateAction } from "react";
import getFieldIdentifier from "../lib/getFieldIdentifier";
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import type { SerializableForm, Response } from "../types/types";
@@ -52,7 +53,7 @@ export default function FormInputFields(props: Props) {
/* @ts-ignore */
required={!!field.required}
listValues={options}
data-testid="form-field"
data-testid={`form-field-${getFieldIdentifier(field)}`}
setValue={(value) => {
setResponse((response) => {
response = response || {};
@@ -251,7 +251,7 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onSuccess() {
showToast("Form updated successfully.", "success");
showToast(t("form_updated_successfully"), "success");
},
onError(e) {
if (e.message) {
@@ -157,7 +157,7 @@ const MultiSelectWidget = ({
};
});
const defaultValue = selectItems.filter((item) => value?.includes(item.value));
const optionsFromList = selectItems.filter((item) => value?.includes(item.value));
return (
<Select
@@ -165,7 +165,7 @@ const MultiSelectWidget = ({
onChange={(items) => {
setValue(items?.map((item) => item.value));
}}
defaultValue={defaultValue}
value={optionsFromList}
isMulti={true}
isDisabled={remainingProps.readOnly}
options={selectItems}
@@ -184,7 +184,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
value: item.value,
};
});
const defaultValue = selectItems.find((item) => item.value === value);
const optionFromList = selectItems.find((item) => item.value === value);
return (
<Select
@@ -196,7 +196,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
setValue(item.value);
}}
isDisabled={remainingProps.readOnly}
defaultValue={defaultValue}
value={optionFromList}
options={selectItems}
{...remainingProps}
/>
@@ -11,6 +11,9 @@
"publisher": "Cal.com",
"email": "help@cal.com",
"licenseRequired": true,
"teamsPlanRequired": {
"upgradeUrl": "/apps/routing-forms/forms"
},
"description": "It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user",
"__createdUsingCli": true
}
@@ -134,7 +134,7 @@ function Field({
<TextField
disabled={!!router}
label="Identifier"
name="identifier"
name={`${hookFieldNamespace}.identifier`}
required
placeholder={t("identifies_name_field")}
value={identifier}
@@ -175,7 +175,7 @@ const Route = ({
</div>
<Select
isDisabled={disabled}
className="block w-full flex-grow px-2"
className="data-testid-select-routing-action block w-full flex-grow px-2"
required
value={RoutingPages.find((page) => page.value === route.action?.type)}
onChange={(item) => {
@@ -16,10 +16,12 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button, showToast, useCalcomTheme } from "@calcom/ui";
import FormInputFields from "../../components/FormInputFields";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { processRoute } from "../../lib/processRoute";
import type { Response, Route } from "../../types/types";
type Props = inferSSRProps<typeof getServerSideProps>;
const useBrandColors = ({
brandColor,
darkBrandColor,
@@ -34,7 +36,7 @@ const useBrandColors = ({
useCalcomTheme(brandTheme);
};
function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getServerSideProps>) {
function RoutingForm({ form, profile, ...restProps }: Props) {
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
const formFillerIdRef = useRef(uuidv4());
const isEmbed = useIsEmbed(restProps.isEmbed);
@@ -43,12 +45,15 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
brandColor: profile.brandColor,
darkBrandColor: profile.darkBrandColor,
});
const [response, setResponse] = usePrefilledResponse(form);
// TODO: We might want to prevent spam from a single user by having same formFillerId across pageviews
// But technically, a user can fill form multiple times due to any number of reasons and we currently can't differentiate b/w that.
// - like a network error
// - or he abandoned booking flow in between
const formFillerId = formFillerIdRef.current;
const decidedActionRef = useRef<Route["action"]>();
const decidedActionWithFormResponseRef = useRef<{ action: Route["action"]; response: Response }>();
const router = useRouter();
const onSubmit = (response: Response) => {
@@ -65,7 +70,10 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
formFillerId,
response: response,
});
decidedActionRef.current = decidedAction;
decidedActionWithFormResponseRef.current = {
action: decidedAction,
response,
};
};
useEffect(() => {
@@ -75,19 +83,26 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({
onSuccess: () => {
const decidedAction = decidedActionRef.current;
if (!decidedAction) {
const decidedActionWithFormResponse = decidedActionWithFormResponseRef.current;
if (!decidedActionWithFormResponse) {
return;
}
const fields = form.fields;
if (!fields) {
throw new Error("Routing Form fields must exist here");
}
const allURLSearchParams = getUrlSearchParamsToForward(decidedActionWithFormResponse.response, fields);
const decidedAction = decidedActionWithFormResponse.action;
//TODO: Maybe take action after successful mutation
if (decidedAction.type === "customPageMessage") {
setCustomPageMessage(decidedAction.value);
} else if (decidedAction.type === "eventTypeRedirectUrl") {
router.push(`/${decidedAction.value}`);
router.push(`/${decidedAction.value}?${allURLSearchParams}`);
} else if (decidedAction.type === "externalRedirectUrl") {
window.parent.location.href = decidedAction.value;
window.parent.location.href = `${decidedAction.value}?${allURLSearchParams}`;
}
// We don't want to show this message as it doesn't look good in Embed.
// showToast("Form submitted successfully! Redirecting now ...", "success");
},
onError: (e) => {
@@ -97,12 +112,11 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
if (e?.data?.code === "CONFLICT") {
return void showToast("Form already submitted", "error");
}
// We don't want to show this error as it doesn't look good in Embed.
// showToast("Something went wrong", "error");
},
});
const [response, setResponse] = useState<Response>({});
const handleOnSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit(response);
@@ -161,6 +175,53 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
);
}
function getUrlSearchParamsToForward(response: Response, fields: NonNullable<Props["form"]["fields"]>) {
type Params = Record<string, string | string[]>;
const paramsFromResponse: Params = {};
const paramsFromCurrentUrl: Params = {};
// Build query params from response
Object.entries(response).forEach(([key, fieldResponse]) => {
const foundField = fields.find((f) => f.id === key);
if (!foundField) {
// If for some reason, the field isn't there, let's just
return;
}
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
fieldResponse.value;
});
// Build query params from current URL. It excludes route params
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
for (const [name, value] of new URLSearchParams(window.location.search).entries()) {
const target = paramsFromCurrentUrl[name];
if (target instanceof Array) {
target.push(value);
} else {
paramsFromCurrentUrl[name] = [value];
}
}
const allQueryParams: Params = {
...paramsFromCurrentUrl,
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
...paramsFromResponse,
};
const allQueryURLSearchParams = new URLSearchParams();
// Make serializable URLSearchParams instance
Object.entries(allQueryParams).forEach(([param, value]) => {
const valueArray = value instanceof Array ? value : [value];
valueArray.forEach((v) => {
allQueryURLSearchParams.append(param, v);
});
});
return allQueryURLSearchParams;
}
export default function RoutingLink(props: inferSSRProps<typeof getServerSideProps>) {
return <RoutingForm {...props} />;
}
@@ -192,6 +253,7 @@ export const getServerSideProps = async function getServerSideProps(
include: {
user: {
select: {
username: true,
theme: true,
brandColor: true,
darkBrandColor: true,
@@ -209,6 +271,7 @@ export const getServerSideProps = async function getServerSideProps(
return {
props: {
isEmbed,
themeBasis: form.user.username,
profile: {
theme: form.user.theme,
brandColor: form.user.brandColor,
@@ -218,3 +281,19 @@ export const getServerSideProps = async function getServerSideProps(
},
};
};
const usePrefilledResponse = (form: Props["form"]) => {
const router = useRouter();
const prefillResponse: Response = {};
// Prefill the form from query params
form.fields?.forEach((field) => {
prefillResponse[field.id] = {
value: router.query[getFieldIdentifier(field)] || "",
label: field.label,
};
});
const [response, setResponse] = useState<Response>(prefillResponse);
return [response, setResponse] as const;
};
@@ -16,21 +16,19 @@ test.describe("Routing Forms", () => {
const formId = await addForm(page);
await page.click('[href="/apps/routing-forms/forms"]');
// TODO: Workaround for bug in https://github.com/calcom/cal.com/issues/3410
await page.click('[href="/apps/routing-forms/forms"]');
await page.waitForSelector('[data-testid="routing-forms-list"]');
// Ensure that it's visible in forms list
expect(await page.locator('[data-testid="routing-forms-list"] > li').count()).toBe(1);
await gotoRoutingLink(page, formId);
await page.isVisible("text=Test Form Name");
await gotoRoutingLink({ page, formId });
await expect(page.locator("text=Test Form Name")).toBeVisible();
await page.goto(`apps/routing-forms/route-builder/${formId}`);
await page.click('[data-testid="toggle-form"] [value="on"]');
await gotoRoutingLink(page, formId);
await page.isVisible("text=ERROR 404");
await disableForm(page);
await gotoRoutingLink({ page, formId });
await expect(page.locator("text=ERROR 404")).toBeVisible();
});
test("should be able to edit the form", async ({ page }) => {
@@ -41,16 +39,18 @@ test.describe("Routing Forms", () => {
const createdFields: Record<number, { label: string; typeIndex: number }> = {};
const { types } = await addMultipleFieldsAndSaveForm(formId, page, { description, label });
await page.reload();
const { fieldTypesList: types, fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
description,
label,
});
expect(await page.inputValue(`[data-testid="description"]`)).toBe(description);
expect(await page.locator('[data-testid="field"]').count()).toBe(types.length);
types.forEach((item, index) => {
createdFields[index] = { label: `Test Label ${index + 1}`, typeIndex: index };
fields.forEach((item, index) => {
createdFields[index] = { label: item.label, typeIndex: index };
});
await expectCurrentFormToHaveFields(page, createdFields, types);
await page.click('[href*="/apps/routing-forms/route-builder/"]');
@@ -63,9 +63,7 @@ test.describe("Routing Forms", () => {
});
test.describe("F1<-F2 Relationship", () => {
// TODO: Fix this test, it is very flaky
// prettier-ignore
test.fixme("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
test("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
const form1Id = await addForm(page, { name: "F1" });
const form2Id = await addForm(page, { name: "F2" });
@@ -118,6 +116,63 @@ test.describe("Routing Forms", () => {
todo("Create relationship by using duplicate with live connect");
});
test("should be able to submit a prefilled form with all types of fields", async ({ page }) => {
const formId = await addForm(page);
await page.click('[href*="/apps/routing-forms/route-builder/"]');
await selectNewRoute(page);
await selectOption({
selector: {
selector: ".data-testid-select-routing-action",
nth: 0,
},
option: 2,
page,
});
await page.fill("[name=externalRedirectUrl]", "https://www.google.com");
await saveCurrentForm(page);
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
description: "Description",
label: "Test Field",
});
const queryString =
"firstField=456&Test Field Number=456&Test Field Select=456&Test Field MultiSelect=456&Test Field MultiSelect=789&Test Field Phone=456&Test Field Email=456@example.com";
await gotoRoutingLink({ page, queryString });
await page.fill('[data-testid="form-field-Test Field Long Text"]', "manual-fill");
expect(await page.locator(`[data-testid="form-field-firstField"]`).inputValue()).toBe("456");
expect(await page.locator(`[data-testid="form-field-Test Field Number"]`).inputValue()).toBe("456");
// TODO: Verify select and multiselect has prefilled values.
// expect(await page.locator(`[data-testid="form-field-Test Field Select"]`).inputValue()).toBe("456");
// expect(await page.locator(`[data-testid="form-field-Test Field MultiSelect"]`).inputValue()).toBe("456");
expect(await page.locator(`[data-testid="form-field-Test Field Phone"]`).inputValue()).toBe("456");
expect(await page.locator(`[data-testid="form-field-Test Field Email"]`).inputValue()).toBe(
"456@example.com"
);
await page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.hostname.includes("google.com");
});
const url = new URL(page.url());
// Coming from the response filled by booker
expect(url.searchParams.get("firstField")).toBe("456");
// All other params come from prefill URL
expect(url.searchParams.get("Test Field Number")).toBe("456");
expect(url.searchParams.get("Test Field Long Text")).toBe("manual-fill");
expect(url.searchParams.get("Test Field Select")).toBe("456");
expect(url.searchParams.getAll("Test Field MultiSelect")).toMatchObject(["456", "789"]);
expect(url.searchParams.get("Test Field Phone")).toBe("456");
expect(url.searchParams.get("Test Field Email")).toBe("456@example.com");
});
// TODO: How to install the app just once?
test.beforeEach(async ({ page, users }) => {
const user = await users.create(
@@ -266,16 +321,16 @@ test.describe("Routing Forms", () => {
});
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
await page.isVisible("text=Custom Page Result");
await expect(page.locator("text=Custom Page Result")).toBeVisible();
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&multi=Option-2`);
await page.isVisible("text=Multiselect chosen");
await expect(page.locator("text=Multiselect chosen")).toBeVisible();
});
test("Routing Link should validate fields", async ({ page, users }) => {
const user = await createUserAndLoginAndInstallApp({ users, page });
const routingForm = user.routingForms[0];
await gotoRoutingLink(page, routingForm.id);
await gotoRoutingLink({ page, formId: routingForm.id });
page.click('button[type="submit"]');
const firstInputMissingValue = await page.evaluate(() => {
return document.querySelectorAll("input")[0].validity.valueMissing;
@@ -291,40 +346,45 @@ test.describe("Routing Forms", () => {
await page.click('[data-testid="test-preview"]');
// //event redirect
await page.fill('[data-testid="form-field"]', "event-routing");
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
await page.click('[data-testid="test-routing"]');
let routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
let route = await page.locator('[data-testid="test-routing-result"]').innerText();
await expect(routingType).toBe("Event Redirect");
await expect(route).toBe("pro/30min");
expect(routingType).toBe("Event Redirect");
expect(route).toBe("pro/30min");
//custom page
await page.fill('[data-testid="form-field"]', "custom-page");
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
await expect(routingType).toBe("Custom Page");
await expect(route).toBe("Custom Page Result");
expect(routingType).toBe("Custom Page");
expect(route).toBe("Custom Page Result");
//external redirect
await page.fill('[data-testid="form-field"]', "external-redirect");
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
await expect(routingType).toBe("External Redirect");
await expect(route).toBe("https://google.com");
expect(routingType).toBe("External Redirect");
expect(route).toBe("https://google.com");
//fallback route
await page.fill('[data-testid="form-field"]', "fallback");
await page.fill('[data-testid="form-field-Test field"]', "fallback");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
await expect(routingType).toBe("Custom Page");
await expect(route).toBe("Fallback Message");
expect(routingType).toBe("Custom Page");
expect(route).toBe("Fallback Message");
});
});
});
async function disableForm(page: Page) {
await page.click('[data-testid="toggle-form"] [value="on"]');
await page.waitForSelector(".data-testid-toast-success");
}
async function expectCurrentFormToHaveFields(
page: Page,
fields: {
@@ -341,24 +401,24 @@ async function expectCurrentFormToHaveFields(
}
async function fillSeededForm(page: Page, routingFormId: string) {
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="form-field"]', "event-routing");
await gotoRoutingLink({ page, formId: routingFormId });
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.pathname.endsWith("/pro/30min");
});
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="form-field"]', "external-redirect");
await gotoRoutingLink({ page, formId: routingFormId });
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.hostname.includes("google.com");
});
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="form-field"]', "custom-page");
await gotoRoutingLink({ page, formId: routingFormId });
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
await page.click('button[type="submit"]');
await page.isVisible("text=Custom Page Result");
await expect(page.locator("text=Custom Page Result")).toBeVisible();
}
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
@@ -375,7 +435,7 @@ export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
return formId;
}
async function addMultipleFieldsAndSaveForm(
async function addAllTypesOfFieldsAndSaveForm(
formId: string,
page: Page,
form: { description: string; label: string }
@@ -384,33 +444,51 @@ async function addMultipleFieldsAndSaveForm(
await page.click('[data-testid="add-field"]');
await page.fill('[data-testid="description"]', form.description);
const { optionsInUi: types } = await verifySelectOptions(
const { optionsInUi: fieldTypesList } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
page
);
await page.fill(`[name="fields.0.label"]`, `${form.label} 1`);
await page.click('[data-testid="add-field"]');
const fields = [];
for (let index = 0; index < fieldTypesList.length; index++) {
const fieldTypeLabel = fieldTypesList[index];
const nth = index;
const label = `${form.label} ${fieldTypeLabel}`;
let identifier = "";
const withoutFirstValue = [...types].filter((val) => val !== "Short Text");
if (index !== 0) {
identifier = label;
// Click on the field type dropdown.
await page.locator(".data-testid-field-type").nth(nth).click();
// Click on the dropdown option.
await page.locator(`[data-testid="select-option-${fieldTypeLabel}"]`).click();
} else {
// Set the identifier manually for the first field to test out a case when identifier isn't computed from label automatically
// First field type is by default selected. So, no need to choose from dropdown
identifier = "firstField";
}
for (let index = 0; index < withoutFirstValue.length; index++) {
const fieldName = withoutFirstValue[index];
const nth = index + 1;
const label = `${form.label} ${index + 2}`;
if (fieldTypeLabel === "MultiSelect" || fieldTypeLabel === "Select") {
await page.fill(`[name="fields.${nth}.selectText"]`, "123\n456\n789");
}
await page.locator(".data-testid-field-type").nth(nth).click();
await page.locator(`[data-testid="select-option-${fieldName}"]`).click();
await page.fill(`[name="fields.${nth}.label"]`, label);
if (index !== withoutFirstValue.length - 1) {
if (identifier !== label) {
await page.fill(`[name="fields.${nth}.identifier"]`, identifier);
}
if (index !== fieldTypesList.length - 1) {
await page.click('[data-testid="add-field"]');
}
fields.push({ identifier: identifier, label, type: fieldTypeLabel });
}
await saveCurrentForm(page);
return {
types,
fieldTypesList,
fields,
};
}
@@ -461,6 +539,9 @@ async function selectOption({
}: {
page: Page;
selector: { selector: string; nth: number };
/**
* Index of option to select. Starts from 1
*/
option: number;
}) {
const locatorForSelect = page.locator(selector.selector).nth(selector.nth);
@@ -513,8 +594,29 @@ async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
});
}
async function gotoRoutingLink(page: Page, formId: string) {
await page.goto(`/forms/${formId}`);
async function gotoRoutingLink({
page,
formId,
queryString = "",
}: {
page: Page;
formId?: string;
queryString?: string;
}) {
let previewLink = null;
if (!formId) {
// Instead of clicking on the preview link, we are going to the preview link directly because the earlier opens a new tab which is a bit difficult to manage with Playwright
const href = await page.locator('[data-testid="form-action-preview"]').getAttribute("href");
if (!href) {
throw new Error("Preview link not found");
}
previewLink = href;
} else {
previewLink = `/forms/${formId}`;
}
await page.goto(`${previewLink}${queryString ? `?${queryString}` : ""}`);
// HACK: There seems to be some issue with the inputs to the form getting reset if we don't wait.
await new Promise((resolve) => setTimeout(resolve, 500));
}
@@ -1,4 +1,3 @@
import { ClockIcon } from "@heroicons/react/outline";
import { useMutation } from "@tanstack/react-query";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
@@ -8,6 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import logger from "@calcom/lib/logger";
import { trpc } from "@calcom/trpc/react";
import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, showToast } from "@calcom/ui";
import { Clock } from "@calcom/ui/components/icon";
interface IConfirmDialogWipe {
isOpenDialog: boolean;
@@ -82,7 +82,7 @@ export const ConfirmDialog = (props: IConfirmDialogWipe) => {
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="flex flex-row space-x-3">
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<ClockIcon className="m-auto h-6 w-6" />
<Clock className="m-auto h-5 w-5" />
</div>
<div className="pt-1">
<DialogHeader title="Wipe My Calendar" />
+5 -5
View File
@@ -455,9 +455,9 @@ export default class EventManager {
return [];
}
const { uid: bookingRefUid, externalCalendarId: bookingExternalCalendarId } = calendarReference;
if (!bookingExternalCalendarId) {
throw new Error("externalCalendarId");
let calenderExternalId: string | null = null;
if (bookingExternalCalendarId) {
calenderExternalId = bookingExternalCalendarId;
}
let result = [];
@@ -465,13 +465,13 @@ export default class EventManager {
credential = this.calendarCredentials.filter(
(credential) => credential.id === calendarReference?.credentialId
)[0];
result.push(updateEvent(credential, event, bookingRefUid, bookingExternalCalendarId));
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
} else {
const credentials = this.calendarCredentials.filter(
(credential) => credential.type === calendarReference?.type
);
for (const credential of credentials) {
result.push(updateEvent(credential, event, bookingRefUid, bookingExternalCalendarId));
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
}
}
@@ -65,11 +65,10 @@ export const CallToAction = (props: {
marginLeft: 0,
}}
iconName={startIconName}
secondary={secondary}
/>
)}
{label}
{endIconName && <CallToActionIcon iconName={endIconName} secondary={secondary} />}
{endIconName && <CallToActionIcon iconName={endIconName} />}
</a>
</p>
);
@@ -2,15 +2,7 @@ import React from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
export const CallToActionIcon = ({
secondary,
iconName,
style,
}: {
secondary?: boolean;
iconName: string;
style?: React.CSSProperties;
}) => (
export const CallToActionIcon = ({ iconName, style }: { iconName: string; style?: React.CSSProperties }) => (
<img
src={`${WEBAPP_URL}/emails/${iconName}.png`}
srcSet={`${WEBAPP_URL}/emails/${iconName}.svg`}
@@ -19,7 +11,6 @@ export const CallToActionIcon = ({
height: "1rem",
width: "1rem",
marginLeft: "0.5rem",
...(secondary && { filter: "brightness(50%)" }),
...style,
}}
alt=""
+9 -5
View File
@@ -5,7 +5,7 @@ import dayjs from "@calcom/dayjs";
// TODO: Use browser locale, implement Intl in Dayjs maybe?
import "@calcom/dayjs/locales";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { RecurringEvent } from "@calcom/types/Calendar";
@@ -31,10 +31,14 @@ export function getRecurringWhen({
return "";
}
export function WhenInfo(props: { calEvent: CalendarEvent; timeZone: string; t: TFunction }) {
const { timeZone, t, calEvent: { recurringEvent } = {} } = props;
const timeFormat = props.calEvent.organizer.timeFormat || TimeFormat.TWELVE_HOUR;
const locale = props.calEvent.organizer.language.locale;
export function WhenInfo(props: {
calEvent: CalendarEvent;
timeZone: string;
t: TFunction;
locale: string;
timeFormat: TimeFormat;
}) {
const { timeZone, t, calEvent: { recurringEvent } = {}, locale, timeFormat } = props;
function getRecipientStart(format: string) {
return dayjs(props.calEvent.startTime).tz(timeZone).locale(locale).format(format);
@@ -9,6 +9,12 @@ export const AttendeeScheduledEmail = (
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
return (
<BaseScheduledEmail timeZone={props.attendee.timeZone} t={props.attendee.language.translate} {...props} />
<BaseScheduledEmail
locale={props.attendee.language.locale}
timeZone={props.attendee.timeZone}
t={props.attendee.language.translate}
timeFormat={props.attendee?.timeFormat}
{...props}
/>
);
};
@@ -1,6 +1,7 @@
import type { TFunction } from "next-i18next";
import dayjs from "@calcom/dayjs";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import {
@@ -21,9 +22,13 @@ export const BaseScheduledEmail = (
timeZone: string;
includeAppsStatus?: boolean;
t: TFunction;
locale: string;
timeFormat: TimeFormat | undefined;
} & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
const { t, timeZone } = props;
const { t, timeZone, locale, timeFormat: timeFormat_ } = props;
const timeFormat = timeFormat_ ?? TimeFormat.TWELVE_HOUR;
function getRecipientStart(format: string) {
return dayjs(props.calEvent.startTime).tz(timeZone).format(format);
@@ -73,7 +78,7 @@ export const BaseScheduledEmail = (
)}
<Info label={t("rejection_reason")} description={props.calEvent.rejectionReason} withSpacer />
<Info label={t("what")} description={props.calEvent.title} withSpacer />
<WhenInfo calEvent={props.calEvent} t={t} timeZone={timeZone} />
<WhenInfo timeFormat={timeFormat} calEvent={props.calEvent} t={t} timeZone={timeZone} locale={locale} />
<WhoInfo calEvent={props.calEvent} t={t} />
<LocationInfo calEvent={props.calEvent} t={t} />
<Info label={t("description")} description={props.calEvent.description} withSpacer formatted />
@@ -1,4 +1,4 @@
import { TFunction } from "next-i18next";
import type { TFunction } from "next-i18next";
import { Trans } from "react-i18next";
import { AppStoreLocationType } from "@calcom/app-store/locations";
@@ -56,6 +56,8 @@ export const BrokenIntegrationEmail = (
) => {
const { calEvent, type } = props;
const t = calEvent.organizer.language.translate;
const locale = calEvent.organizer.language.locale;
const timeFormat = calEvent.organizer?.timeFormat;
if (type === "video") {
let location = calEvent.location ? getEnumKeyByEnumValue(AppStoreLocationType, calEvent.location) : " ";
@@ -70,6 +72,8 @@ export const BrokenIntegrationEmail = (
<BaseScheduledEmail
timeZone={calEvent.organizer.timeZone}
t={t}
timeFormat={timeFormat}
locale={locale}
subject={t("broken_integration")}
title={t("problem_adding_video_link")}
subtitle={<BrokenVideoIntegration location={location} eventTypeId={calEvent.eventTypeId} t={t} />}
@@ -94,6 +98,8 @@ export const BrokenIntegrationEmail = (
<BaseScheduledEmail
timeZone={calEvent.organizer.timeZone}
t={t}
timeFormat={timeFormat}
locale={locale}
subject={t("broken_integration")}
title={t("problem_updating_calendar")}
subtitle={<BrokenCalendarIntegration calendar={calendar} eventTypeId={calEvent.eventTypeId} t={t} />}
@@ -107,6 +113,8 @@ export const BrokenIntegrationEmail = (
<BaseScheduledEmail
timeZone={calEvent.organizer.timeZone}
t={t}
timeFormat={timeFormat}
locale={locale}
subject={t("broken_integration")}
title={t("problem_updating_calendar")}
headerType="xCircle"
@@ -10,13 +10,17 @@ export const NoShowFeeChargedEmail = (
) => {
const { calEvent } = props;
const t = props.attendee.language.translate;
const locale = props.attendee.language.locale;
const timeFormat = props.attendee?.timeFormat;
if (!calEvent.paymentInfo?.amount) throw new Error("No payment info");
return (
<BaseScheduledEmail
locale={locale}
title={t("no_show_fee_charged_text_body")}
headerType="calendarCircle"
timeFormat={timeFormat}
subtitle={
<>
{t("no_show_fee_charged_subtitle", {
@@ -29,13 +29,18 @@ export const OrganizerScheduledEmail = (
}
const t = props.teamMember?.language.translate || props.calEvent.organizer.language.translate;
const locale = props.teamMember?.language.locale || props.calEvent.organizer.language.locale;
const timeFormat = props.teamMember?.timeFormat || props.calEvent.organizer?.timeFormat;
return (
<BaseScheduledEmail
locale={locale}
timeZone={props.teamMember?.timeZone || props.calEvent.organizer.timeZone}
t={t}
subject={t(subject)}
title={t(title)}
includeAppsStatus
timeFormat={timeFormat}
subtitle={
<>
{props.attendeeCancelled
@@ -112,7 +112,7 @@ ${this.t(
)}
${this.t(subtitle)}
${getRichDescription(this.calEvent)}
${getRichDescription(this.calEvent, this.t)}
`.trim();
}
@@ -91,7 +91,7 @@ ${this.t("request_reschedule_subtitle", {
})},
${this.getWhen()}
${this.t("need_to_reschedule_or_cancel")}
${getManageLink(this.calEvent)}
${getManageLink(this.calEvent, this.t)}
`.replace(/(<([^>]+)>)/gi, "");
}
}
+12 -3
View File
@@ -9,7 +9,7 @@
const randomInt = Math.floor(Math.random() * 16777216);
// Convert the integer to a hex string with 6 digits and add leading zeros if necessary
const hexString = randomInt.toString(16).padStart(6, '0');
const hexString = randomInt.toString(16).padStart(6, "0");
// Return the hex string with a '#' prefix
return `#${hexString}`;
@@ -161,7 +161,7 @@
<h3>
<a href="?only=ns:default">[Dark Theme][Guests(janedoe@example.com and test@example.com)]</a>
</h3>
<button onclick="Cal('ui',{theme:'light'})" >Toggle to Light</button>
<button onclick="Cal('ui',{theme:'light'})">Toggle to Light</button>
<i class="last-action"> You would see last Booking page action in my place </i>
<div>
@@ -187,7 +187,9 @@
<button onclick="(function () {Cal.ns.second('ui', {styles:{body:{background:'red'}}})})()">
Change <code>body</code> bg color[Deprecated]
</button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{align:'left'}})})()">Align left[Deprecated]</button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{align:'left'}})})()">
Align left[Deprecated]
</button>
<button onclick="(function () {Cal.ns.second('ui', {styles:{align:'center'}})})()">
Align Center[Deprecated]
</button>
@@ -234,6 +236,13 @@
<h3><a href="?only=hideEventTypeDetails">Hide EventType Details Test</a></h3>
<div class="place"></div>
</div>
<div class="inline-embed-container" id="cal-booking-place-conflicting-theme">
<h3><a href="?only=conflicting-theme">You would be able to test out conflicting themes for the same namespace here.</a></h3>
<div class="light"></div>
<div class="dark"></div>
<i>Note that one of the embeds would stay in loading state as they are using the same namespace and it is not supported to have more than 1 embeds using same namespace</i>
</div>
</div>
<script src="./playground.ts"></script>
</body>
</html>
+36 -1
View File
@@ -55,6 +55,7 @@ if (only === "all" || only === "ns:second") {
iframeAttrs: {
id: "cal-booking-place-second-iframe",
},
theme: "auto",
},
},
]);
@@ -245,6 +246,28 @@ if (only === "all" || only === "hideEventTypeDetails") {
);
}
if (only === "conflicting-theme") {
Cal("init", "conflictingTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.conflictingTheme("inline", {
elementOrSelector: "#cal-booking-place-conflicting-theme .dark",
calLink: "pro/30min",
config: {
theme: "dark",
},
});
Cal.ns.conflictingTheme("inline", {
elementOrSelector: "#cal-booking-place-conflicting-theme .light",
calLink: "pro/30min",
config: {
theme: "light",
},
});
}
Cal("init", "popupDarkTheme", {
debug: true,
origin: "http://localhost:3000",
@@ -268,10 +291,12 @@ Cal("init", "popupAutoTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupTeamLinkLightTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupTeamLinkDarkTheme", {
debug: true,
origin: "http://localhost:3000",
@@ -309,6 +334,16 @@ Cal("init", "routingFormDark", {
if (only === "all" || only == "ns:floatingButton") {
Cal.ns.floatingButton("floatingButton", {
calLink: "pro",
calLink: "pro/30min",
config: {
iframeAttrs: {
id: "floatingtest",
},
name: "John",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@example.com", "test@example.com"],
theme: "dark",
},
});
}
@@ -4,6 +4,7 @@ type ModalTargetDatasetProps = {
calLink: string;
calNamespace: string;
calOrigin: string;
calConfig: string;
};
type CamelCase<T extends string> = T extends `${infer U}${infer V}` ? `${Uppercase<U>}${V}` : T;
@@ -8,10 +8,10 @@ import type { Message } from "./embed";
import { sdkActionManager } from "./sdk-event";
type Theme = "dark" | "light";
export type EmbedThemeConfig = Theme | "auto";
export type UiConfig = {
hideEventTypeDetails?: boolean;
theme?: Theme | "auto";
theme?: EmbedThemeConfig;
styles?: EmbedStyles & EmbedNonStylesConfig;
//TODO: Extract from tailwind the list of all custom variables and support them in auto-completion as well as runtime validation. Followup with listing all variables in Embed Snippet Generator UI.
cssVarsPerTheme?: Record<Theme, Record<string, string>>;
@@ -31,7 +31,7 @@ const embedStore = {
reactNonStylesStateSetters: {} as Record<keyof EmbedNonStylesConfig, setNonStylesConfig>,
parentInformedAboutContentHeight: false,
windowLoadEventFired: false,
setTheme: undefined as ((arg0: string) => void) | undefined,
setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined,
theme: undefined as UiConfig["theme"],
uiConfig: undefined as Omit<UiConfig, "styles" | "theme"> | undefined,
setUiConfig: undefined as ((arg0: UiConfig) => void) | undefined,
@@ -170,14 +170,14 @@ function isValidNamespace(ns: string | null | undefined) {
export const useEmbedTheme = () => {
const router = useRouter();
const [theme, setTheme] = useState(embedStore.theme || (router.query.theme as string));
const [theme, setTheme] = useState(embedStore.theme || (router.query.theme as typeof embedStore.theme));
useEffect(() => {
router.events.on("routeChangeComplete", () => {
sdkActionManager?.fire("__routeChanged", {});
});
}, [router.events]);
embedStore.setTheme = setTheme;
return theme === "auto" ? null : theme;
return theme;
};
export const useEmbedUiConfig = () => {
@@ -434,7 +434,7 @@ if (isBrowser) {
// Exposes certain global variables/fns that are used by the app to get interface with the embed.
embedInit();
const url = new URL(document.URL);
embedStore.theme = (window?.getEmbedTheme?.() || "auto") as UiConfig["theme"];
embedStore.theme = window?.getEmbedTheme?.() as UiConfig["theme"];
if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) {
log("Initializing embed-iframe");
// HACK
+8 -1
View File
@@ -2,7 +2,7 @@
import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox";
import type { InterfaceWithParent, interfaceWithParent, UiConfig } from "./embed-iframe";
import type { InterfaceWithParent, interfaceWithParent, UiConfig, EmbedThemeConfig } from "./embed-iframe";
import css from "./embed.css";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import { SdkActionManager } from "./sdk-action-manager";
@@ -129,6 +129,7 @@ type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<str
iframeAttrs?: Record<string, string> & {
id?: string;
};
theme?: EmbedThemeConfig;
};
export class Cal {
@@ -435,6 +436,7 @@ class CalApi {
buttonColor = "rgb(0, 0, 0)",
buttonTextColor = "rgb(255, 255, 255)",
calOrigin,
config,
}: {
calLink: string;
buttonText?: string;
@@ -444,6 +446,7 @@ class CalApi {
buttonColor?: string;
buttonTextColor?: string;
calOrigin?: string;
config?: PrefillAndIframeAttrsConfig;
}) {
// validate(arguments[0], {
// required: true,
@@ -466,6 +469,10 @@ class CalApi {
el.dataset.calLink = calLink;
el.dataset.calNamespace = this.cal.namespace;
el.dataset.calOrigin = calOrigin ?? "";
if (config) {
el.dataset.calConfig = JSON.stringify(config);
}
if (attributes?.id) {
el.id = attributes.id;
}
+11 -12
View File
@@ -5,10 +5,9 @@ import StickyBox from "react-sticky-box";
import { shallow } from "zustand/shallow";
import classNames from "@calcom/lib/classNames";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { ToggleGroup, useCalcomTheme } from "@calcom/ui";
import { ToggleGroup } from "@calcom/ui";
import { Calendar, Columns, Grid } from "@calcom/ui/components/icon";
import { AvailableTimeSlots } from "./components/AvailableTimeSlots";
@@ -23,21 +22,20 @@ import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from ".
import { useBookerStore, useInitializeBookerStore } from "./store";
import type { BookerLayout, BookerProps } from "./types";
import { useEvent } from "./utils/event";
import { useBrandColors } from "./utils/use-brand-colors";
const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy"));
const DatePicker = dynamic(() => import("./components/DatePicker").then((mod) => mod.DatePicker), {
ssr: false,
});
const useBrandColors = ({ brandColor, darkBrandColor }: { brandColor?: string; darkBrandColor?: string }) => {
const brandTheme = useGetBrandingColours({
lightVal: brandColor,
darkVal: darkBrandColor,
});
useCalcomTheme(brandTheme);
};
const BookerComponent = ({ username, eventSlug, month, rescheduleBooking }: BookerProps) => {
const BookerComponent = ({
username,
eventSlug,
month,
rescheduleBooking,
hideBranding = false,
}: BookerProps) => {
const { t } = useLocale();
const isMobile = useMediaQuery("(max-width: 768px)");
const isTablet = useMediaQuery("(max-width: 1024px)");
@@ -61,6 +59,7 @@ const BookerComponent = ({ username, eventSlug, month, rescheduleBooking }: Book
useBrandColors({
brandColor: event.data?.profile.brandColor,
darkBrandColor: event.data?.profile.darkBrandColor,
theme: event.data?.profile.theme,
});
useInitializeBookerStore({
@@ -209,7 +208,7 @@ const BookerComponent = ({ username, eventSlug, month, rescheduleBooking }: Book
"mt-auto mb-6 pt-6 [&_img]:h-[15px]",
layout === "small_calendar" ? "block" : "hidden"
)}>
<PoweredBy logoOnly />
{!hideBranding ? <PoweredBy logoOnly /> : null}
</m.span>
</div>
@@ -70,11 +70,15 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
const timeslot = useBookerStore((state) => state.selectedTimeslot);
const recurringEventCount = useBookerStore((state) => state.recurringEventCount);
const username = useBookerStore((state) => state.username);
const formValues = useBookerStore((state) => state.formValues);
const setFormValues = useBookerStore((state) => state.setFormValues);
const isRescheduling = !!rescheduleUid && !!rescheduleBooking;
const event = useEvent();
const eventType = event.data;
const defaultValues = useMemo(() => {
if (Object.keys(formValues).length) return formValues;
if (!eventType?.bookingFields) {
return {};
}
@@ -128,7 +132,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
email: defaultUserValues.email,
};
return defaults;
}, [eventType?.bookingFields, isRescheduling, rescheduleBooking, rescheduleUid]);
}, [eventType?.bookingFields, formValues, isRescheduling, rescheduleBooking, rescheduleUid]);
const bookingFormSchema = z
.object({
@@ -226,6 +230,8 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
);
const bookEvent = (values: BookingFormValues) => {
// Clears form values stored in store, so old values won't stick around.
setFormValues({});
bookingForm.clearErrors();
// It shouldn't be possible that this method is fired without having event data,
@@ -280,7 +286,18 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
return (
<div className="flex h-full flex-col">
<Form className="flex h-full flex-col" form={bookingForm} handleSubmit={bookEvent} noValidate>
<Form
className="flex h-full flex-col"
onChange={() => {
// Form data is saved in store. This way when user navigates back to
// still change the timeslot, and comes back to the form, all their values
// still exist. This gets cleared when the form is submitted.
const values = bookingForm.getValues();
setFormValues(values);
}}
form={bookingForm}
handleSubmit={bookEvent}
noValidate>
<BookingFields
isDynamicGroupBooking={!!(username && username.indexOf("+") > -1)}
fields={eventType.bookingFields}
@@ -79,6 +79,13 @@ type BookerStore = {
* Method called by booker component to set initial data.
*/
initialize: (data: StoreInitializeType) => void;
/**
* Stored form state, used when user navigates back and
* forth between timeslots and form. Get's cleared on submit
* to prevent sticky data.
*/
formValues: Record<string, any>;
setFormValues: (values: Record<string, any>) => void;
};
const validLayouts: BookerLayout[] = ["large_calendar", "large_timeslots", "small_calendar"];
@@ -179,6 +186,10 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
set({ selectedTimeslot });
updateQueryParam("slot", selectedTimeslot ?? "");
},
formValues: {},
setFormValues: (formValues: Record<string, any>) => {
set({ formValues });
},
}));
export const useInitializeBookerStore = ({
@@ -0,0 +1,26 @@
import { useEffect } from "react";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
import useTheme from "@calcom/lib/hooks/useTheme";
import { useCalcomTheme } from "@calcom/ui";
export const useBrandColors = ({
brandColor,
darkBrandColor,
theme,
}: {
brandColor?: string;
darkBrandColor?: string;
theme?: string | null;
}) => {
const brandTheme = useGetBrandingColours({
lightVal: brandColor,
darkVal: darkBrandColor,
});
useCalcomTheme(brandTheme);
const { setTheme } = useTheme(theme);
useEffect(() => {
if (theme) setTheme(theme);
}, [setTheme, theme]);
};
@@ -11,7 +11,7 @@ import { useFilterQuery } from "../lib/useFilterQuery";
export const TeamsMemberFilter = () => {
const { t } = useLocale();
const session = useSession();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeByKey } = useFilterQuery();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeByKey, removeAllQueryParams } = useFilterQuery();
const { data } = trpc.viewer.teams.list.useQuery();
const [dropdownTitle, setDropdownTitle] = useState<string>(t("all_bookings_filter_label"));
@@ -38,7 +38,7 @@ export const TeamsMemberFilter = () => {
checked={!query.teamIds && !query.userIds?.includes(session.data?.user.id || 0)}
onChange={() => {
setDropdownTitle(t("all_bookings_filter_label"));
removeByKey("teamIds"); // Always clear on toggle or not toggle (seems weird but when you know the behviour it works well )
removeAllQueryParams();
}}
className="text-primary-600 focus:ring-primary-500 border-default inline-flex h-4 w-4 place-self-center justify-self-end rounded "
/>
@@ -57,7 +57,7 @@ export const TeamsMemberFilter = () => {
id="yourBookings"
type="checkbox"
disabled={session.status === "loading"}
checked={query.userIds?.includes(session.data?.user.id || 0)}
checked={!!query.userIds?.includes(session.data?.user.id || 0)}
onChange={(e) => {
setDropdownTitle(t("your_bookings_filter_label"));
if (e.target.checked) {
+3 -4
View File
@@ -1,5 +1,3 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { useEmbedStyles } from "@calcom/embed-core/embed-iframe";
@@ -8,6 +6,7 @@ import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { weekdayNames } from "@calcom/lib/weekday";
import { Button, SkeletonText } from "@calcom/ui";
import { ChevronLeft, ChevronRight } from "@calcom/ui/components/icon";
import { ArrowRight } from "@calcom/ui/components/icon";
export type DatePickerProps = {
@@ -223,7 +222,7 @@ const DatePicker = ({
data-testid="decrementMonth"
color="minimal"
variant="icon"
StartIcon={ChevronLeftIcon}
StartIcon={ChevronLeft}
/>
<Button
className="group p-1 opacity-70 hover:opacity-100"
@@ -231,7 +230,7 @@ const DatePicker = ({
data-testid="incrementMonth"
color="minimal"
variant="icon"
StartIcon={ChevronRightIcon}
StartIcon={ChevronRight}
/>
</div>
</div>
+7 -7
View File
@@ -1,4 +1,4 @@
The Cal.com Enterprise Edition (EE) license (the “EE License”)
The Cal.com Commercial License (the “Commercial License”)
Copyright (c) 2020-present Cal.com, Inc
With regard to the Cal.com Software:
@@ -6,15 +6,15 @@ With regard to the Cal.com Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Cal.com Subscription Terms available
at https://cal.com/terms (the “EE Terms”), or other agreements governing
at https://cal.com/terms, or other agreements governing
the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"),
and otherwise have a valid Cal.com Enterprise Edition subscription ("EE Subscription")
for the correct number of hosts as defined in the EE Terms ("Hosts"). Subject to the foregoing sentence,
and otherwise have a valid Cal.com Enterprise Edition subscription ("Commercial Subscription")
for the correct number of hosts as defined in the "Commercial Terms ("Hosts"). Subject to the foregoing sentence,
you are free to modify this Software and publish patches to the Software. You agree
that Cal.com and/or its licensors (as applicable) retain all right, title and interest in
and to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid EE Subscription for the correct number of hosts.
exploited with a valid Commercial Subscription for the correct number of hosts.
Notwithstanding the foregoing, you may copy and modify the Software for development
and testing purposes, without requiring a subscription. You agree that Cal.com and/or
its licensors (as applicable) retain all right, title and interest in and to all such
@@ -22,11 +22,11 @@ modifications. You are not granted any other rights beyond what is expressly sta
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
This EE License applies only to the part of this Software that is not distributed under
This Commercial License applies only to the part of this Software that is not distributed under
the AGPLv3 license. Any part of this Software distributed under the MIT license or which
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
in part, is copyrighted under the AGPLv3 license. The full text of this EE License shall
in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall
be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
@@ -41,6 +41,7 @@ export default function TeamAvailabilityTimes(props: Props) {
inviteeDate: props.selectedDate,
workingHours: data?.workingHours || [],
minimumBookingNotice: 0,
offsetStart: 0,
eventLength: props.frequency,
organizerTimeZone: `${data?.timeZone}`,
})
@@ -12,7 +12,7 @@ import {
DropdownItem,
DropdownMenuTrigger,
} from "@calcom/ui";
import { Slash, Check, MoreHorizontal, X } from "@calcom/ui/components/icon";
import { Ban, Check, MoreHorizontal, X } from "@calcom/ui/components/icon";
interface Props {
team: {
@@ -92,7 +92,7 @@ export default function TeamInviteListItem(props: Props) {
variant="icon"
color="secondary"
onClick={declineInvite}
StartIcon={Slash}
StartIcon={Ban}
/>
<Button
type="button"
@@ -48,7 +48,6 @@ function MembersList(props: MembersListProps) {
autoComplete="false"
onChange={(e) => setQuery(e.target.value)}
value={query}
defaultValue=""
placeholder={`${t("search")}...`}
/>
{membersList?.length && team ? (
@@ -102,10 +102,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
where: {
method: WorkflowMethods.EMAIL,
scheduled: false,
cancelled: false,
scheduledDate: {
lte: dayjs().add(72, "hour").toISOString(),
},
OR: [{ cancelled: false }, { cancelled: null }],
},
include: {
workflowStep: true,
@@ -197,7 +197,7 @@ function EventWorkflowsTab(props: Props) {
const dataWf = data.workflows.find((wf) => wf.id === workflowOnEventType.id);
return {
...workflowOnEventType,
readOnly: isChildrenManagedEventType && dataWf?.teamId ? true : dataWf?.readOnly ?? true,
readOnly: isChildrenManagedEventType && dataWf?.teamId ? true : dataWf?.readOnly ?? false,
} as WorkflowType;
});
const disabledWorkflows = data.workflows.filter(

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