Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc530593b | ||
|
|
4111f78431 | ||
|
|
def80ea4cf | ||
|
|
2fc4676f6d | ||
|
|
318bafaab8 | ||
|
|
fc10c29037 | ||
|
|
ccb7b1f9ac | ||
|
|
c6c6544f50 | ||
|
|
8a6b93a747 | ||
|
|
ff6c6ccec0 | ||
|
|
c8db177f7d | ||
|
|
8871e1eb21 | ||
|
|
92b2d57871 | ||
|
|
77623c72c5 | ||
|
|
3239976e18 | ||
|
|
611939ffe8 | ||
|
|
1b8ecbe449 | ||
|
|
cd4882429d | ||
|
|
4fd2d80637 | ||
|
|
a343d934fe | ||
|
|
933cc739bd | ||
|
|
c7c75ff4d3 | ||
|
|
41f1920a7a | ||
|
|
e4836ea8f3 | ||
|
|
bfa30aa504 | ||
|
|
b36043b637 | ||
|
|
75026289fd | ||
|
|
80649c9f1a | ||
|
|
7dd75afb63 | ||
|
|
f66e4aeeed | ||
|
|
8225e61710 | ||
|
|
175fc4fcf3 | ||
|
|
f20a5df322 | ||
|
|
62544931b2 | ||
|
|
f6ad1d6ed5 | ||
|
|
d22b1072b5 | ||
|
|
4b356dd937 | ||
|
|
1f51199a7b | ||
|
|
b80a631998 | ||
|
|
bef4422080 | ||
|
|
cfd1a72c64 | ||
|
|
23b3a6661c | ||
|
|
b8b6c48d7d | ||
|
|
81655f9988 | ||
|
|
ff74822c48 | ||
|
|
8ec178debd | ||
|
|
f881061d8d | ||
|
|
2525559d6b | ||
|
|
38fb7a3f77 | ||
|
|
08956e54c5 | ||
|
|
ea65a2a27e |
@@ -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"]}]'
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -160,6 +160,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
return {
|
||||
props: {
|
||||
away: user.away,
|
||||
themeBasis: user.username,
|
||||
isDynamicGroup: false,
|
||||
profile,
|
||||
date,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -207,6 +207,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
return {
|
||||
props: {
|
||||
team: { ...team, safeBio, members },
|
||||
themeBasis: team.slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
markdownStrippedBio,
|
||||
},
|
||||
|
||||
@@ -196,6 +196,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
brandColor: team.brandColor,
|
||||
darkBrandColor: team.darkBrandColor,
|
||||
},
|
||||
themeBasis: team.slug,
|
||||
date: dateParam,
|
||||
eventType: eventTypeObject,
|
||||
workingHours,
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 485 B After Width: | Height: | Size: 743 B |
@@ -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}} 个成员",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 194 KiB |
|
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("");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||