Compare commits

...
24 Commits
Author SHA1 Message Date
Alex van Andel 605f9c61f3 bookingSeat could absolutely be unset at this point, resulting in Internal Error. (#7964)
release.yml / release (push) Canceled after 0s
2023-03-27 18:16:07 -07:00
Omar López 564862369f v2.7.5 2023-03-27 17:30:40 -07:00
Alex van Andel be6cf4705c An empty array is truthy, [0] potentially isn't (#7961) 2023-03-28 00:16:52 +02:00
Joe Au-YeungandPeer Richelsen a2457ab619 Fix/availability not loading (#7789)
* Set default schedule if there is not one

* Removing last schedule sets user.defaultScheduleId to null

* Create new schedule from event type

* First schedule created is the default

* Go back if new schedule was created

* Moved update logic out of getter

* Remove flashing of empty screen

* Add border prop to EmptyScreen

* User requires a schedule

* Change back schema

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-27 19:53:35 +00:00
Alex van Andel 867966dd75 Fixup whereby config.json, invalid JSON 2023-03-27 21:12:57 +02:00
Peer RichelsenandAlex van Andel 08ad4c07ea added better regex for whereby allowing subdomains (#7957)
* added better regex for whereby allowing subdomains

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

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

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-03-27 21:01:34 +02:00
Joe 78da57055e fix: updating password ui bug #7775 (#7815)
* fix: updating password ui bug #7775

* fix: type error
2023-03-27 20:58:46 +02:00
Udit TakkarandPeer Richelsen d5c3d9d967 fix: create team form (#7896)
* fix: create team form

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

* fix: change hover border

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

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-03-27 16:35:46 +00:00
aa0e9e6a02 Prevent GCal from sending update (#7920)
* Do not send updates

* Clean up

* Clean up

* Update packages/app-store/googlecalendar/lib/CalendarService.ts

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

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-03-27 14:22:27 +00:00
Alex van Andel cad97f1779 Remove 3rd param from getBooking 2023-03-27 13:32:35 +02:00
GitStart-Cal.comandgitstart-calcom 31ee689672 CALCOM-7848 - Confirm button also in the branding color (#7932)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-03-27 12:31:17 +02:00
3edb6f5ffd Delete rescheduled booking (#7910)
* deleted orignal booking and fix open seats number on booking page

* fix type error

* Adjust tests

* fix type error

* include fix in e2e test

* fix e2e test

* fixes e2e test

* fix type error in e2e test

* increase timeout time

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2023-03-27 11:34:41 +02:00
Hariom Balhara a8825badec Fix: Send responses in confirm booking flow (#7830) 2023-03-27 13:57:10 +05:30
sean-brydonandEfraín Rochín 04c634ec4b Fix timezone select - create two variants (#7875)
* Fix timezone select - create two variants

* Remove menu is open

* Timezone fixes

---------

Co-authored-by: Efraín Rochín <roae.85@gmail.com>
2023-03-26 21:19:13 +02:00
Joe Au-Yeung 8dd9b8d74b Fix Zod errors in Outlook auth function (#7931)
* Add quick fixes

* Add error message

* Wooops
2023-03-25 21:19:33 +00:00
Efraín Rochín 0365157638 Revalidate booking pages on updateProfile (#7941) 2023-03-25 22:06:43 +01:00
zomars a571d40958 Update yarn.lock 2023-03-24 18:00:50 -07:00
69491d5700 Adds basic global feature flags (#7459)
* Adds basic feature flag model

* Create router.ts

* WIP

* WIP

* WIP

* WIP

* Emails kill switch

* Adds missing migrations

* Type fix

* Cleanup

* Revert

* Cleanup

* Fixes migration

* Update packages/features/flags/server/utils.ts

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

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-03-24 17:59:04 -07:00
Joe Au-Yeung 20022e2cdd Fix minimum booking notice set to 0 (#7750)
* Only use the current day

* Set min of booking notice to 0

* Set min minimumBookingNotice to 0
2023-03-24 17:37:46 -07:00
Syed Ali Shahbaz 492858b45b Fix for Apple Calendar failing entire availability check because of Shared Calendars (#7934) 2023-03-24 23:55:30 +01:00
Bailey Pumfleet b49ad8294b new yarn.lock 2023-03-24 16:43:30 +00:00
Peer Richelsen aa30ff7d1a insights improvements 2023-03-24 00:17:12 +01:00
cfa720a523 /insights (#6511)
* init page

* init insights frontend

* nit

* nit

* nit

* merge

* fixed icons

* i18n, needs features

* Init insights trpc

* Using trpc on client

* Added events timeline

* Seed analytics script

* connect ui with trpc

* Added and fixed event time lines

* WIP popular days and avg time duration event type

* added new metric graphs

* improved upgrade tip

* always show upgrade screen

* upgrade tremor.so and select inputs for page

* Remove log

* Move everything to components and add context

* Fix select types using calcom ui one

* Adding translations

* Add missing translations

* Add more translations

* min fix

* Fixes for date select

* Prefer early return and mobile design fixes

* Fix style for mobile

* Fix data with userId filter from popular events

* add user id to average time duration

* Removed submodules

* Delete website

* Update yarn.lock

* Code organization and type fixes

* trpc fixes

* Builds are now passing

* Relocates server code

* Cleanup

* Medium size screen fixes

* Added TODO

---------

Co-authored-by: Alan <alannnc@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2023-03-23 22:10:01 +00:00
Omar López 9ab196c30e Revert "Fixes formatted description in email " (#7917) 2023-03-23 21:20:35 +00:00
115 changed files with 3645 additions and 1064 deletions
@@ -77,7 +77,7 @@ export default function CancelBooking(props: Props) {
{t("nevermind")}
</Button>
<Button
className="flex justify-center"
className="dark:bg-darkmodebrand dark:hover:border-darkmodebrand dark:text-darkmodebrandcontrast bg-brand hover:border-brand text-brandcontrast flex justify-center"
data-testid="cancel"
onClick={async () => {
setLoading(true);
+1 -4
View File
@@ -133,10 +133,7 @@ export const SlotPicker = ({
eventTypeId: eventType.id,
eventTypeSlug: eventType.slug,
usernameList: users,
startTime:
browsingDate === undefined || browsingDate.get("month") === dayjs.tz(undefined, timeZone).get("month")
? dayjs.tz(undefined, timeZone).subtract(2, "days").startOf("day")
: browsingDate?.startOf("month"),
startTime: dayjs().startOf("day"),
endTime: browsingDate?.endOf("month"),
timeZone,
duration,
+5 -53
View File
@@ -1,12 +1,9 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
import useTheme from "@calcom/lib/hooks/useTheme";
import type { ITimezoneOption } from "@calcom/ui";
import { TimezoneSelect } from "@calcom/ui";
import useMeQuery from "@lib/hooks/useMeQuery";
import { timeZone } from "../../lib/clock";
type Props = {
@@ -15,8 +12,6 @@ type Props = {
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const query = useMeQuery();
const userTheme = useTheme(query?.data?.theme).resolvedTheme;
useEffect(() => {
setSelectedTimeZone(timeZone());
@@ -28,59 +23,16 @@ const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
}
}, [selectedTimeZone, onSelectTimeZone]);
const customStyles = {
option: (_provided: object, state: { isSelected: boolean; isFocused: boolean }) => ({
padding: "10px 12px !important",
color: state.isSelected || state.isFocused ? "#101010" : "#374151",
backgroundColor: state.isSelected ? "#E5E7EB !important" : "white",
...(userTheme === "dark" && {
color: state.isSelected || state.isFocused ? "white" : "#80868B",
}),
}),
control: () => ({
display: "flex",
cursor: "pointer",
backgroundColor: "transparent !important",
minWidth: "5rem",
height: "24px",
minHeight: "24px !important",
boxShadow: "none !important",
}),
singleValue: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
...provided,
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
...(userTheme === "dark" && {
color: "#a5a5a5 !important",
}),
}),
menu: (provided: object) => ({
...provided,
minWidth: "20rem",
margin: "8px 0 0 -20px",
}),
valueContainer: (provided: object) => ({
...provided,
padding: "0 0 0 6px",
}),
dropdownIndicator: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
...provided,
transform: state.selectProps.menuIsOpen ? "rotate(180deg)" : "",
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
marginLeft: 4,
padding: 0,
...(userTheme === "dark" && {
color: "#80868B",
}),
}),
};
return !!selectedTimeZone ? (
<TimezoneSelect
id="timeZone"
classNames={{
singleValue: () => "dark:text-darkgray-600 text-gray-600",
menu: () => "!w-64 max-w-[90vw]",
}}
variant="minimal"
value={selectedTimeZone}
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
className="flex h-6 text-sm font-medium"
styles={customStyles}
/>
) : null;
};
@@ -17,8 +17,8 @@ export function TimezoneDropdown({
return (
<>
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit max-w-[20rem] items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-[2px] rtl:ml-[2px]" />
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 !mt-3 flex w-full max-w-[20rem] items-center rounded-[4px] px-1 text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-2 rtl:ml-2" />
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
</div>
</>
@@ -205,6 +205,7 @@ const routerQuerySchema = z
const BookingPage = ({
eventType,
booking,
currentSlotBooking,
profile,
isDynamicGroupBooking,
recurringEventCount,
@@ -584,23 +585,27 @@ const BookingPage = ({
<div className="text-bookinghighlight flex items-start text-sm">
<FiUser
className={`ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] ${
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
? "text-rose-600"
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
: currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
? "text-yellow-500"
: "text-bookinghighlight"
}`}
/>
<p
className={`${
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
? "text-rose-600"
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
: currentSlotBooking &&
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
? "text-yellow-500"
: "text-bookinghighlight"
} mb-2 font-medium`}>
{booking
? eventType.seatsPerTimeSlot - booking.attendees.length
{currentSlotBooking
? eventType.seatsPerTimeSlot - currentSlotBooking.attendees.length
: eventType.seatsPerTimeSlot}{" "}
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
</p>
@@ -632,6 +637,7 @@ const BookingPage = ({
</Button>
<Button
type="submit"
className="dark:bg-darkmodebrand dark:hover:border-darkmodebrand dark:text-darkmodebrandcontrast bg-brand hover:border-brand text-brandcontrast"
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
loading={mutation.isLoading || recurringMutation.isLoading}>
{rescheduleUid ? t("reschedule") : t("confirm")}
@@ -4,13 +4,15 @@ import type { OptionProps, SingleValueProps } from "react-select";
import { components } from "react-select";
import dayjs from "@calcom/dayjs";
import { NewScheduleButton } from "@calcom/features/schedules";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { weekdayNames } from "@calcom/lib/weekday";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Badge, Button, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
import { FiExternalLink, FiGlobe } from "@calcom/ui/components/icon";
import { Badge, Button, Select, SettingsToggle, SkeletonText, EmptyScreen } from "@calcom/ui";
import { FiExternalLink, FiGlobe, FiClock } from "@calcom/ui/components/icon";
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
@@ -52,23 +54,24 @@ const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
const AvailabilitySelect = ({
className = "",
isLoading,
schedules,
...props
}: {
className?: string;
name: string;
value: number;
isLoading: boolean;
schedules: RouterOutputs["viewer"]["availability"]["list"]["schedules"] | [];
onBlur: () => void;
onChange: (value: AvailabilityOption | null) => void;
}) => {
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
const { t } = useLocale();
if (isLoading) {
return <SelectSkeletonLoader />;
}
const schedules = data?.schedules || [];
const options = schedules.map((schedule) => ({
value: schedule.id,
label: schedule.name,
@@ -107,6 +110,7 @@ const EventTypeScheduleDetails = () => {
const { watch } = useFormContext<FormValues>();
const scheduleId = watch("schedule");
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined },
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId }
@@ -174,6 +178,19 @@ const EventTypeScheduleDetails = () => {
const EventTypeSchedule = () => {
const { t } = useLocale();
const { data: schedules, isLoading } = trpc.viewer.availability.list.useQuery();
if (!schedules?.schedules.length && !isLoading)
return (
<EmptyScreen
Icon={FiClock}
headline={t("new_schedule_heading")}
description={t("new_schedule_description")}
buttonRaw={<NewScheduleButton fromEventType />}
border={false}
/>
);
return (
<div className="space-y-4">
<div>
@@ -183,14 +200,18 @@ const EventTypeSchedule = () => {
<Controller
name="schedule"
render={({ field }) => (
<AvailabilitySelect
value={field.value}
onBlur={field.onBlur}
name={field.name}
onChange={(selected) => {
field.onChange(selected?.value || null);
}}
/>
<>
<AvailabilitySelect
value={field.value}
onBlur={field.onBlur}
name={field.name}
schedules={schedules?.schedules || []}
isLoading={isLoading}
onChange={(selected) => {
field.onChange(selected?.value || null);
}}
/>
</>
)}
/>
</div>
@@ -88,6 +88,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
label={t("minimum_booking_notice")}
type="number"
placeholder="0"
min={0}
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
/>
<input type="hidden" ref={ref} {...passThroughProps} />
@@ -1,11 +1,11 @@
export type EventTypeDescriptionSafeProps = {
eventType: { description: string | null; descriptionAsSafeHTML: string | null };
eventType: { description: string | null };
};
export const EventTypeDescriptionSafeHTML = ({ eventType }: EventTypeDescriptionSafeProps) => {
const props: JSX.IntrinsicElements["div"] = { suppressHydrationWarning: true };
if (eventType.description)
props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML || "" };
// @ts-expect-error: @see packages/prisma/middleware/eventTypeDescriptionParseAndSanitize.ts
if (eventType.description) props.dangerouslySetInnerHTML = { __html: eventType.descriptionAsSafeHTML };
return <div {...props} />;
};
@@ -247,7 +247,7 @@ function EventTypeSingleLayout({
tooltip={t("embed")}
/>
<Button
color="secondary"
color="destructive"
variant="icon"
StartIcon={FiTrash}
tooltip={t("delete")}
+5 -6
View File
@@ -2,14 +2,13 @@ import Link from "next/link";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import { Avatar } from "@calcom/ui";
type TeamType = NonNullable<TeamWithMembers>;
type MembersType = TeamType["members"];
type MemberType = MembersType[number] & { safeBio: string | null };
type TeamTypeWithSafeHtml = Omit<TeamType, "members"> & { members: MemberType[] };
type MemberType = MembersType[number];
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
const { t } = useLocale();
@@ -31,7 +30,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
<>
<div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: member.safeBio || "" }}
dangerouslySetInnerHTML={{ __html: md.render(member.bio || "") }}
/>
</>
) : (
@@ -44,7 +43,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
);
};
const Members = ({ members, teamName }: { members: MemberType[]; teamName: string | null }) => {
const Members = ({ members, teamName }: { members: MembersType; teamName: string | null }) => {
if (!members || members.length === 0) {
return null;
}
@@ -58,7 +57,7 @@ const Members = ({ members, teamName }: { members: MemberType[]; teamName: strin
);
};
const Team = ({ team }: { team: TeamTypeWithSafeHtml }) => {
const Team = ({ team }: { team: TeamType }) => {
return (
<div>
<Members members={team.members} teamName={team.name} />
+10 -1
View File
@@ -10,6 +10,8 @@ import type { ComponentProps, ReactNode } from "react";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
import { FeatureProvider } from "@calcom/features/flags/context/provider";
import { useFlags } from "@calcom/features/flags/hooks";
import { trpc } from "@calcom/trpc/react";
import { MetaProvider } from "@calcom/ui";
@@ -58,6 +60,11 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
return <I18nextAdapter {...passedProps} />;
};
function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
const flags = useFlags();
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
}
const AppProviders = (props: AppPropsWithChildren) => {
const session = trpc.viewer.public.session.useQuery().data;
// No need to have intercom on public pages - Good for Page Performance
@@ -85,7 +92,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
storageKey={storageKey}
forcedTheme={forcedTheme}
attribute="class">
<MetaProvider>{props.children}</MetaProvider>
<FeatureFlagsProvider>
<MetaProvider>{props.children}</MetaProvider>
</FeatureFlagsProvider>
</ThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
+6 -22
View File
@@ -1,9 +1,8 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import type { z } from "zod";
import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import slugify from "@calcom/lib/slugify";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
type BookingSelect = {
description: true;
@@ -45,11 +44,7 @@ function getResponsesFromOldBooking(
};
}
async function getBooking(
prisma: PrismaClient,
uid: string,
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">
) {
async function getBooking(prisma: PrismaClient, uid: string) {
const rawBooking = await prisma.booking.findFirst({
where: {
uid,
@@ -82,9 +77,7 @@ async function getBooking(
return rawBooking;
}
const booking = getBookingWithResponses(rawBooking, {
bookingFields,
});
const booking = getBookingWithResponses(rawBooking);
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
@@ -104,20 +97,11 @@ export const getBookingWithResponses = <
};
}>
>(
booking: T,
eventType: {
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">;
}
booking: T
) => {
return {
...booking,
responses: getBookingResponsesPartialSchema({
eventType: {
bookingFields: eventType.bookingFields,
},
// An existing booking can have data from any number of views, so the schema should consider ALL_VIEWS
view: "ALL_VIEWS",
}).parse(booking.responses || getResponsesFromOldBooking(booking)),
};
responses: bookingResponsesDbSchema.parse(booking.responses || getResponsesFromOldBooking(booking)),
} as Omit<T, "responses"> & { responses: z.infer<typeof bookingResponsesDbSchema> };
};
export default getBooking;
+5
View File
@@ -99,6 +99,11 @@ const nextConfig = {
skipDefaultConversion: true,
preventFullImport: true,
},
"@calcom/features/insights/components": {
transform: "@calcom/features/insights/components/{{member}}",
skipDefaultConversion: true,
preventFullImport: true,
},
lodash: {
transform: "lodash/{{member}}",
},
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "2.7.4",
"version": "2.7.5",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -60,6 +60,7 @@
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^4.3.9",
"@tremor/react": "^2.0.0",
"@types/turndown": "^5.0.1",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
@@ -67,7 +68,6 @@
"accept-language-parser": "^1.5.0",
"async": "^3.2.4",
"bcryptjs": "^2.4.3",
"canvas": "^2.11.0",
"classnames": "^2.3.1",
"dotenv-cli": "^6.0.0",
"entities": "^4.4.0",
@@ -78,7 +78,6 @@
"ical.js": "^1.4.0",
"ics": "^2.37.0",
"jose": "^4.13.1",
"jsdom": "^21.1.1",
"kbar": "^0.1.0-beta.36",
"libphonenumber-js": "^1.10.12",
"lodash": "^4.17.21",
+2 -6
View File
@@ -24,7 +24,7 @@ import defaultEvents, {
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { markdownAndSanitize } from "@calcom/lib/markdownAndSanitize";
import { md } from "@calcom/lib/markdownIt";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
@@ -147,7 +147,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
<>
<div
className=" dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: props.safeBio }}
dangerouslySetInnerHTML={{ __html: md.render(user.bio || "") }}
/>
</>
)}
@@ -343,7 +343,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const eventTypes = eventTypesRaw.map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
descriptionAsSafeHTML: markdownAndSanitize(eventType.description),
}));
const isSingleUser = users.length === 1;
@@ -353,12 +352,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
})
: [];
const safeBio = markdownAndSanitize(user.bio) || "";
return {
props: {
users,
safeBio,
profile,
user: {
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
+5 -2
View File
@@ -5,7 +5,7 @@ import type { LocationObject } from "@calcom/app-store/locations";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownAndSanitize } from "@calcom/lib/markdownAndSanitize";
import { addListFormatting } from "@calcom/lib/markdownIt";
import type { User } from "@calcom/prisma/client";
import { isBrandingHidden } from "@lib/isBrandingHidden";
@@ -59,6 +59,7 @@ Type.isThemeSupported = true;
const paramsSchema = z.object({ type: z.string(), user: z.string() });
async function getUserPageProps(context: GetStaticPropsContext) {
// load server side dependencies
const MarkdownIt = await import("markdown-it").then((mod) => mod.default);
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const { privacyFilteredLocations } = await import("@calcom/app-store/locations");
const { parseRecurringEvent } = await import("@calcom/lib/isRecurringEvent");
@@ -124,6 +125,8 @@ async function getUserPageProps(context: GetStaticPropsContext) {
},
});
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
if (!user || !user.eventTypes.length) return { notFound: true };
const [eventType]: ((typeof user.eventTypes)[number] & {
@@ -150,7 +153,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: privacyFilteredLocations(locations),
descriptionAsSafeHTML: markdownAndSanitize(eventType.description),
descriptionAsSafeHTML: eventType.description ? addListFormatting(md.render(eventType.description)) : null,
});
// Check if the user you are logging into has any active teams or premium user name
const hasActiveTeam =
+7 -8
View File
@@ -14,7 +14,6 @@ import {
getUsernameList,
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownAndSanitize } from "@calcom/lib/markdownAndSanitize";
import prisma, { bookEventTypeSelect } from "@calcom/prisma";
import {
customInputSchema,
@@ -190,7 +189,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
slug: u.username,
theme: u.theme,
})),
descriptionAsSafeHTML: markdownAndSanitize(eventType.description),
};
})[0];
@@ -219,7 +217,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
rescheduleUid = bookingSeat.booking.uid;
attendeeEmail = bookingSeat.attendee.email;
}
} else if (eventTypeRaw.seatsPerTimeSlot && query.duration && query.date) {
}
if (query.duration) {
// If it's not reschedule but event Type has seats we should obtain
// the bookingUid regardless and use it to get the booking
const currentSeats = await prisma.booking.findFirst({
@@ -239,11 +239,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
let booking: GetBookingType | null = null;
if (rescheduleUid || query.bookingUid || bookingUidWithSeats) {
booking = await getBooking(
prisma,
rescheduleUid || query.bookingUid || bookingUidWithSeats || "",
eventTypeObject.bookingFields
);
booking = await getBooking(prisma, rescheduleUid || query.bookingUid || bookingUidWithSeats || "");
}
if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) {
@@ -287,12 +283,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
: eventType.recurringEvent.count)) ||
null;
const currentSlotBooking = await getBooking(prisma, bookingUidWithSeats || "");
return {
props: {
away: user.away,
profile,
eventType: eventTypeObject,
booking,
currentSlotBooking: currentSlotBooking,
recurringEventCount,
trpcState: ssr.dehydrate(),
isDynamicGroupBooking,
+10 -2
View File
@@ -94,6 +94,7 @@ export default function Availability() {
data: { schedule: scheduleId },
} = useTypedQuery(querySchema);
const { fromEventType } = router.query;
const { timeFormat } = me.data || { timeFormat: null };
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId },
@@ -153,7 +154,7 @@ export default function Availability() {
return (
<Shell
backPath="/availability"
backPath={fromEventType ? true : "/availability"}
title={schedule?.name ? schedule.name + " | " + t("availability") : t("availability")}
heading={
<Controller
@@ -200,7 +201,14 @@ export default function Availability() {
<VerticalDivider />
<Dialog>
<DialogTrigger asChild>
<Button StartIcon={FiTrash} variant="icon" color="destructive" aria-label={t("delete")} />
<Button
StartIcon={FiTrash}
variant="icon"
color="destructive"
aria-label={t("delete")}
disabled={schedule?.isLastSchedule}
tooltip={t("requires_at_least_one_schedule")}
/>
</DialogTrigger>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
+7 -1
View File
@@ -93,7 +93,13 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
key={schedule.id}
schedule={schedule}
updateDefault={updateMutation.mutate}
deleteFunction={deleteMutation.mutate}
deleteFunction={() => {
if (schedules.length === 1) {
showToast(t("requires_at_least_one_schedule"), "error");
} else {
deleteMutation.mutate;
}
}}
/>
))}
</ul>
+1 -2
View File
@@ -1091,8 +1091,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const bookingInfo = getBookingWithResponses(bookingInfoRaw, eventTypeRaw);
const bookingInfo = getBookingWithResponses(bookingInfoRaw);
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
-2
View File
@@ -5,7 +5,6 @@ import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location";
import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import { markdownAndSanitize } from "@calcom/lib/markdownAndSanitize";
import { availiblityPageEventTypeSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -120,7 +119,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding: u.hideBranding,
timeZone: u.timeZone,
})),
descriptionAsSafeHTML: markdownAndSanitize(hashedLink.eventType.description),
});
const [user] = users;
+1 -2
View File
@@ -1,7 +1,6 @@
import type { GetServerSidePropsContext } from "next";
import { parseRecurringEvent } from "@calcom/lib";
import { markdownAndSanitize } from "@calcom/lib/markdownAndSanitize";
import prisma from "@calcom/prisma";
import { bookEventTypeSelect } from "@calcom/prisma/selects";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -94,7 +93,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
brandColor: u.brandColor,
darkBrandColor: u.darkBrandColor,
})),
descriptionAsSafeHTML: markdownAndSanitize(eventType.description),
};
})[0];
@@ -122,6 +120,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
profile,
eventType: eventTypeObject,
booking: null,
currentSlotBooking: null,
trpcState: ssr.dehydrate(),
recurringEventCount,
isDynamicGroupBooking: false,
+1 -2
View File
@@ -13,7 +13,6 @@ import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { markdownAndSanitize } from "@calcom/lib/markdownAndSanitizeClientSide";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc, TRPCClientError } from "@calcom/trpc/react";
import {
@@ -142,7 +141,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
</div>
<EventTypeDescription
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
eventType={{ ...type, descriptionAsSafeHTML: markdownAndSanitize(type.description) }}
eventType={type}
shortenDescription
/>
</Link>
+116
View File
@@ -0,0 +1,116 @@
import {
AverageEventDurationChart,
BookingKPICards,
BookingStatusLineChart,
LeastBookedTeamMembersTable,
MostBookedTeamMembersTable,
PopularEventsTable,
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { Filters } from "@calcom/features/insights/filters";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, ButtonGroup } from "@calcom/ui";
import { FiRefreshCcw, FiUserPlus, FiUsers } from "@calcom/ui/components/icon";
const Heading = () => {
const { t } = useLocale();
const {
filter: { selectedTeamName },
} = useFilterContext();
return (
<div className="min-w-52">
<h3 className="font-cal max-w-28 sm:max-w-72 md:max-w-80 hidden truncate text-xl font-semibold tracking-wide text-black md:block xl:max-w-full">
{t("analytics_for_organisation", {
organisationName: selectedTeamName,
})}
</h3>
<p className="hidden text-sm text-gray-500 md:block">{t("subtitle_analytics")}</p>
</div>
);
};
export default function InsightsPage() {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <FiUsers className="h-5 w-5 text-red-500" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <FiRefreshCcw className="h-5 w-5 text-blue-500" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <FiUserPlus className="h-5 w-5 text-green-500" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
return (
<div>
<Shell>
<UpgradeTip
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/banners/insights.jpg"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="secondary" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!user ? (
<></>
) : (
<FiltersProvider>
<div className="mb-4 mt-0 ml-auto flex w-full flex-wrap justify-between md:-mt-8">
<Heading />
<Filters />
</div>
<div className="mb-4 space-y-6">
<BookingKPICards />
<BookingStatusLineChart />
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<PopularEventsTable />
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<small className="block text-center text-gray-600">
{t("looking_for_more_analytics")}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
)}
</UpgradeTip>
</Shell>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
import { getLayout } from "@components/auth/layouts/AdminLayout";
const FlagsPage = () => <FlagListingView />;
FlagsPage.getLayout = getLayout;
export default FlagsPage;
+3 -10
View File
@@ -10,7 +10,7 @@ import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { markdownAndSanitize } from "@calcom/lib/markdownAndSanitize";
import { md } from "@calcom/lib/markdownIt";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import prisma from "@calcom/prisma";
@@ -113,7 +113,7 @@ function TeamPage({ team, isUnpublished }: TeamPageProps) {
<>
<div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: team.safeBio }}
dangerouslySetInnerHTML={{ __html: md.render(team.bio || "") }}
/>
</>
)}
@@ -187,18 +187,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
...user,
avatar: CAL_URL + "/" + user.username + "/avatar.png",
})),
descriptionAsSafeHTML: markdownAndSanitize(type.description),
}));
const safeBio = markdownAndSanitize(team.bio) || "";
const members = team.members.map((member) => {
return { ...member, safeBio: markdownAndSanitize(member.bio || "") };
});
return {
props: {
team: { ...team, safeBio, members },
team,
trpcState: ssr.dehydrate(),
},
} as const;
+1 -4
View File
@@ -2,10 +2,8 @@ import type { GetServerSidePropsContext } from "next";
import type { LocationObject } from "@calcom/core/location";
import { privacyFilteredLocations } from "@calcom/core/location";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import { markdownAndSanitize } from "@calcom/lib/markdownAndSanitize";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -172,14 +170,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding,
timeZone,
})),
descriptionAsSafeHTML: markdownAndSanitize(eventType.description),
});
eventTypeObject.availability = [];
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBooking(prisma, rescheduleUid, getBookingFieldsWithSystemFields(eventTypeObject));
booking = await getBooking(prisma, rescheduleUid);
}
const weekStart = eventType.team?.members?.[0]?.user?.weekStart;
+2 -3
View File
@@ -5,7 +5,6 @@ import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import { markdownAndSanitize } from "@calcom/lib/markdownAndSanitize";
import prisma from "@calcom/prisma";
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -122,14 +121,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
image: u.avatar,
slug: u.username,
})),
descriptionAsSafeHTML: markdownAndSanitize(eventType.description),
};
})[0];
let booking: GetBookingType | null = null;
const { rescheduleUid, bookingUid } = querySchema.parse(context.query);
if (rescheduleUid || bookingUid) {
booking = await getBooking(prisma, rescheduleUid || bookingUid || "", eventTypeObject.bookingFields);
booking = await getBooking(prisma, rescheduleUid || bookingUid || "");
}
// Checking if number of recurring event ocurrances is valid against event type configuration
@@ -154,6 +152,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventType: eventTypeObject,
recurringEventCount,
booking,
currentSlotBooking: null,
isDynamicGroupBooking: false,
hasHashedBookingLink: false,
hashedLink: null,
+1 -1
View File
@@ -119,7 +119,7 @@ test.describe("Event Types tests", () => {
await page.locator("#location-select").click();
await page.locator("text=In Person (Organizer Address)").click();
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(250);
await page.waitForTimeout(1000);
await page.locator('input[name="locationAddress"]').fill(inputText);
await page.locator("[data-testid=display-location]").check();
await page.locator("[data-testid=update-location]").click();
+5 -1
View File
@@ -26,7 +26,11 @@ test.describe("Onboarding", () => {
await page.locator("input[name=username]").fill("new user onboarding");
await page.locator("input[name=name]").fill("new user 2");
await page.locator("input[role=combobox]").click();
await page.locator("text=Europe/London GMT +0:00").click();
await page
.locator("*")
.filter({ hasText: /^Europe\/London/ })
.first()
.click();
await page.locator("button[type=submit]").click();
+4 -6
View File
@@ -97,11 +97,12 @@ test.describe("Reschedule Tests", async () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// NOTE: remove if old booking should not be deleted
expect(await (await booking.self()).status).toBe(BookingStatus.CANCELLED);
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking.uid } });
const rescheduledBooking = await prisma.booking.findFirst({ where: { uid: booking.uid } });
expect(newBooking).not.toBeNull();
expect(rescheduledBooking).toBeNull();
await prisma.booking.delete({ where: { id: newBooking?.id } });
});
@@ -148,7 +149,6 @@ test.describe("Reschedule Tests", async () => {
});
await expect(page).toHaveURL(/.*payment/);
await payment.delete();
});
test("Paid rescheduling should go to success page", async ({ page, users, bookings, payments }) => {
@@ -172,8 +172,6 @@ test.describe("Reschedule Tests", async () => {
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*booking/);
await payment.delete();
});
test("Opt in event should be PENDING when rescheduled by USER", async ({ page, users, bookings }) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

+37 -9
View File
@@ -111,7 +111,7 @@
"hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.",
"link_expires": "p.s. It expires in {{expiresIn}} hours.",
"upgrade_to_per_seat": "Upgrade to Per-Seat",
"seat_options_doesnt_support_confirmation":"Seats option doesn't support confirmation requirement",
"seat_options_doesnt_support_confirmation": "Seats option doesn't support confirmation requirement",
"team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/month per seat the estimated total cost of your membership is ${{totalCost}}/month.",
"team_upgrade_banner_description": "Thank you for trialing our new team plan. We noticed your team \"{{teamName}}\" needs to be upgraded.",
"team_upgrade_banner_action": "Upgrade here",
@@ -433,7 +433,7 @@
"password_hint_min": "Minimum 7 characters long",
"password_hint_admin_min": "Minimum 15 characters long",
"password_hint_num": "Contain at least 1 number",
"max_limit_allowed_hint":"Must be {{limit}} or fewer characters long",
"max_limit_allowed_hint": "Must be {{limit}} or fewer characters long",
"invalid_password_hint": "The password must be a minimum of {{passwordLength}} characters long containing at least one number and have a mixture of uppercase and lowercase letters",
"incorrect_password": "Password is incorrect.",
"incorrect_username_password": "Username or password is incorrect.",
@@ -863,7 +863,7 @@
"add_new_calendar": "Add a new calendar",
"set_calendar": "Set where to add new events to when you're booked.",
"delete_schedule": "Delete schedule",
"delete_schedule_description":"Deleting a schedule will remove it from all event types. This action cannot be undone.",
"delete_schedule_description": "Deleting a schedule will remove it from all event types. This action cannot be undone.",
"schedule_created_successfully": "{{scheduleName}} schedule created successfully",
"availability_updated_successfully": "{{scheduleName}} schedule updated successfully",
"schedule_deleted_successfully": "Schedule deleted successfully",
@@ -1631,22 +1631,30 @@
"a_routing_form": "A Routing Form",
"form_description_placeholder": "Form Description",
"keep_me_connected_with_form": "Keep me connected with the form",
"fields_in_form_duplicated":"Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate.",
"fields_in_form_duplicated": "Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate.",
"form_deleted": "Form deleted",
"delete_form": "Are you sure you want to delete this form?",
"delete_form_action": "Yes, delete Form",
"delete_form_confirmation":"Anyone who you've shared the link with will no longer be able to access it.",
"delete_form_confirmation_2":"All associated responses will be deleted.",
"delete_form_confirmation": "Anyone who you've shared the link with will no longer be able to access it.",
"delete_form_confirmation_2": "All associated responses will be deleted.",
"typeform_redirect_url_copied": "Typeform Redirect URL copied! You can go and set the URL in Typeform form.",
"modifications_in_fields_warning": "Modifications in fields and routes of following forms will be reflected in this form.",
"connected_forms": "Connected Forms",
"form_modifications_warning": "Following forms would be affected when you modify fields or routes here.",
"responses_collection_waiting_description": "Wait for some time for responses to be collected. You can go and submit the form yourself as well.",
"this_is_what_your_users_would_see":"This is what your users would see",
"this_is_what_your_users_would_see": "This is what your users would see",
"identifies_name_field": "Identifies field by this name.",
"add_1_option_per_line": "Add 1 option per line",
"select_a_router": "Select a router",
"add_a_new_route": "Add a new Route",
"make_informed_decisions": "Make informed decisions with Insights",
"make_informed_decisions_description": "Our Insights dashboard surfaces all activity across your team and shows you trends that enable better team scheduling and decision making.",
"view_bookings_across": "View bookings across all members",
"view_bookings_across_description": "See who’s receiving the most bookings and ensure the best distribution across your team",
"identify_booking_trends": "Identify booking trends",
"identify_booking_trends_description": "See what times of the week and what times during the day are popular for your bookers",
"spot_popular_event_types": "Spot popular event types",
"spot_popular_event_types_description": "See which of your event types are receiving the most clicks and bookings",
"no_responses_yet": "No responses yet",
"this_will_be_the_placeholder": "This will be the placeholder",
"this_meeting_has_not_started_yet": "This meeting has not started yet",
@@ -1657,8 +1665,28 @@
"verification_code": "Verification code",
"can_you_try_again": "Can you try again with a different time?",
"verify": "Verify",
"invalid_event_name_variables":"There is an invalid variable in your event name",
"invalid_event_name_variables": "There is an invalid variable in your event name",
"select_all": "Select All",
"default_conferencing_bulk_title": "Bulk update existing event types",
"default_conferencing_bulk_description": "Update the locations for the selected event types"
"requires_at_least_one_schedule": "You are required to have at least one schedule",
"default_conferencing_bulk_description": "Update the locations for the selected event types",
"looking_for_more_analytics": "Looking for more analytics?",
"add_filter": "Add filter",
"select_user": "Select User",
"select_event_type": "Select Event Type",
"select_date_range": "Select Date Range",
"popular_events": "Popular Events",
"no_event_types_found": "No event types found",
"average_event_duration": "Average Event Duration",
"most_booked_members": "Most Booked Members",
"least_booked_members": "Least Booked Members",
"events_created": "Events Created",
"events_completed": "Events Completed",
"events_cancelled": "Events Cancelled",
"events_rescheduled": "Events Rescheduled",
"from_last_period": "from last period",
"from_to_date_period": "From: {{startDate}} To: {{endDate}}",
"analytics_for_organisation": "Analytics for {{organisationName}}",
"subtitle_analytics": "This is a organisation analytics",
"event_trends": "Event Trends"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

+1 -1
View File
@@ -2,5 +2,5 @@ const base = require("@calcom/config/tailwind-preset");
/** @type {import('tailwindcss').Config} */
module.exports = {
...base,
content: [...base.content],
content: [...base.content, "../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}"],
};
@@ -133,6 +133,7 @@ export default class GoogleCalendarService implements Calendar {
calendarId: selectedCalendar,
requestBody: payload,
conferenceDataVersion: 1,
sendUpdates: "none",
},
function (error, event) {
if (error || !event?.data) {
@@ -234,14 +235,13 @@ export default class GoogleCalendarService implements Calendar {
calendarId: selectedCalendar,
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
sendUpdates: "none",
requestBody: payload,
conferenceDataVersion: 1,
},
function (err, evt) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
@@ -293,7 +293,7 @@ export default class GoogleCalendarService implements Calendar {
calendarId: calendarId ? calendarId : defaultCalendarId,
eventId: uid,
sendNotifications: false,
sendUpdates: "all",
sendUpdates: "none",
},
function (err: GoogleCalError | null, event) {
if (err) {
@@ -219,7 +219,7 @@ export default class Office365CalendarService implements Calendar {
};
const o365AuthCredentials = credential.key as O365AuthCredentials;
const refreshAccessToken = async (refreshToken: string) => {
const refreshAccessToken = async (o365AuthCredentials: O365AuthCredentials) => {
const { client_id, client_secret } = await getOfficeAppKeys();
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
@@ -227,12 +227,22 @@ export default class Office365CalendarService implements Calendar {
body: new URLSearchParams({
scope: "User.Read Calendars.Read Calendars.ReadWrite",
client_id,
refresh_token: refreshToken,
refresh_token: o365AuthCredentials.refresh_token,
grant_type: "refresh_token",
client_secret,
}),
});
const o365AuthCredentials = refreshTokenResponseSchema.parse(await handleErrorsJson(response));
const responseJson = await handleErrorsJson(response);
const tokenResponse = refreshTokenResponseSchema.safeParse(responseJson);
o365AuthCredentials = { ...o365AuthCredentials, ...(tokenResponse.success && tokenResponse.data) };
if (!tokenResponse.success) {
console.error(
"Outlook error grabbing new tokens ~ zodError:",
tokenResponse.error,
"MS response:",
responseJson
);
}
await prisma.credential.update({
where: {
id: credential.id,
@@ -246,9 +256,10 @@ export default class Office365CalendarService implements Calendar {
return {
getToken: () =>
refreshTokenResponseSchema.safeParse(o365AuthCredentials).success &&
!isExpired(o365AuthCredentials.expires_in)
? Promise.resolve(o365AuthCredentials.access_token)
: refreshAccessToken(o365AuthCredentials.refresh_token),
: refreshAccessToken(o365AuthCredentials),
};
};
@@ -99,23 +99,18 @@ export default function RoutingForms({
CTA={hasPaidPlan && <NewFormButton />}
subtitle={t("routing_forms_description")}>
<UpgradeTip
dark
title={t("teams_plan_required")}
description={t("routing_forms_are_a_great_way")}
features={features}
background="/routing-form-banner-background.jpg"
background="/banners/routing-forms.jpg"
isParentLoading={isLoading && <SkeletonLoaderTeamList />}
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="secondary" href={`${WEBAPP_URL}/settings/teams/new`}>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("upgrade")}
</Button>
<Button
color="minimal"
className="!bg-transparent text-white opacity-50 hover:opacity-100"
href="https://go.cal.com/teams-video"
target="_blank">
<Button color="minimal" href="https://go.cal.com/teams-video" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
+1 -1
View File
@@ -19,7 +19,7 @@
"type": "integrations:whereby_video",
"label": "Whereby Video",
"organizerInputPlaceholder": "https://www.whereby.com/cal",
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?(team.)?whereby.com\\/[a-zA-Z0-9]*"
"urlRegExp": "^(?:https?://)?(?:(?!.*-\\.)(?:\\w+(-\\w+)*\\.))*whereby\\.com(/[\\w\\-._~:?#\\[\\]@!$&'()*+,;%=]+)*$"
}
}
}
+1 -20
View File
@@ -1,5 +1,3 @@
import { markdownAndSanitize } from "@calcom/lib/markdownAndSanitize";
const Spacer = () => <p style={{ height: 6 }} />;
export const Info = (props: {
@@ -8,14 +6,8 @@ export const Info = (props: {
extraInfo?: React.ReactNode;
withSpacer?: boolean;
lineThrough?: boolean;
formatted?: boolean;
}) => {
if (!props.description || props.description === "") return null;
const descriptionCSS = "color: '#101010'; font-weight: 400; line-height: 24px; margin: 0;";
const safeDescription = markdownAndSanitize(props.description.toString()) || "";
return (
<>
{props.withSpacer && <Spacer />}
@@ -29,18 +21,7 @@ export const Info = (props: {
whiteSpace: "pre-wrap",
textDecoration: props.lineThrough ? "line-through" : undefined,
}}>
{props.formatted ? (
<p
className="dark:text-darkgray-600 mt-2 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{
__html: safeDescription
.replaceAll("<p>", `<p style="${descriptionCSS}">`)
.replaceAll("<li>", `<li style="${descriptionCSS}">`),
}}
/>
) : (
props.description
)}
{props.description}
</p>
{props.extraInfo}
</div>
@@ -76,7 +76,7 @@ export const BaseScheduledEmail = (
<WhenInfo calEvent={props.calEvent} t={t} timeZone={timeZone} />
<WhoInfo calEvent={props.calEvent} t={t} />
<LocationInfo calEvent={props.calEvent} t={t} />
<Info label={t("description")} description={props.calEvent.description} withSpacer formatted />
<Info label={t("description")} description={props.calEvent.description} withSpacer />
<Info label={t("additional_notes")} description={props.calEvent.additionalNotes} withSpacer />
{props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />}
<UserFieldsResponses calEvent={props.calEvent} />
+9 -1
View File
@@ -4,8 +4,10 @@ import { z } from "zod";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig";
import prisma from "@calcom/prisma";
declare let global: {
E2E_EMAILS?: Record<string, unknown>[];
@@ -29,7 +31,13 @@ export default class BaseEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
return {};
}
public sendEmail() {
public async sendEmail() {
const featureFlags = await getFeatureFlagMap(prisma);
/** If email kill switch exists and is active, we prevent emails being sent. */
if (featureFlags.emails) {
console.warn("Skipped Sending Email due to active Kill Switch");
return new Promise((r) => r("Skipped Sending Email due to active Kill Switch"));
}
if (process.env.NEXT_PUBLIC_IS_E2E) {
global.E2E_EMAILS = global.E2E_EMAILS || [];
global.E2E_EMAILS.push(this.getNodeMailerPayload());
@@ -344,15 +344,13 @@ function keepParentInformedAboutDimensionChanges() {
// Use, .height as that gives more accurate value in floating point. Also, do a ceil on the total sum so that whatever happens there is enough iframe size to avoid scroll.
const contentHeight = Math.ceil(
parseFloat(mainElementStyles.height) +
parseFloat(mainElementStyles.marginTop) +
parseFloat(mainElementStyles.marginBottom)
);
parseFloat(mainElementStyles.marginTop) +
parseFloat(mainElementStyles.marginBottom));
const contentWidth = Math.ceil(
parseFloat(mainElementStyles.width) +
parseFloat(mainElementStyles.marginLeft) +
parseFloat(mainElementStyles.marginRight)
);
parseFloat(mainElementStyles.marginLeft) +
parseFloat(mainElementStyles.marginRight));
// During first render let iframe tell parent that how much is the expected height to avoid scroll.
// Parent would set the same value as the height of iframe which would prevent scroll.
// On subsequent renders, consider html height as the height of the iframe. If we don't do this, then if iframe get's bigger in height, it would never shrink
@@ -9,6 +9,20 @@ type EventType = Parameters<typeof preprocess>[0]["eventType"];
// eslint-disable-next-line @typescript-eslint/ban-types
type View = ALL_VIEWS | (string & {});
export const bookingResponse = z.union([
z.string(),
z.boolean(),
z.string().array(),
z.object({
optionValue: z.string(),
value: z.string(),
}),
]);
export const bookingResponsesDbSchema = z.record(bookingResponse);
const catchAllSchema = bookingResponsesDbSchema;
export const getBookingResponsesPartialSchema = ({
eventType,
view,
@@ -16,7 +30,7 @@ export const getBookingResponsesPartialSchema = ({
eventType: EventType;
view: View;
}) => {
const schema = bookingResponses.unwrap().partial().and(z.record(z.any()));
const schema = bookingResponses.unwrap().partial().and(catchAllSchema);
return preprocess({ schema, eventType, isPartialSchema: true, view });
};
@@ -38,9 +52,12 @@ function preprocess<T extends z.ZodType>({
view: currentView,
}: {
schema: T;
// It is useful when we want to prefill the responses with the partial values. Partial can be in 2 ways
// - Not all required fields are need to be provided for prefill.
// - Even a field response itself can be partial so the content isn't validated e.g. a field with type="phone" can be given a partial phone number(e.g. Specifying the country code like +91)
isPartialSchema: boolean;
eventType: {
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">;
bookingFields: (z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">) | null;
};
view: View;
}): z.ZodType<z.infer<T>, z.infer<T>, z.infer<T>> {
@@ -48,6 +65,8 @@ function preprocess<T extends z.ZodType>({
(responses) => {
const parsedResponses = z.record(z.any()).nullable().parse(responses) || {};
const newResponses = {} as typeof parsedResponses;
// if eventType has been deleted, we won't have bookingFields and thus we can't preprocess or validate them.
if (!eventType.bookingFields) return parsedResponses;
eventType.bookingFields.forEach((field) => {
const value = parsedResponses[field.name];
if (value === undefined) {
@@ -86,6 +105,10 @@ function preprocess<T extends z.ZodType>({
return newResponses;
},
schema.superRefine((responses, ctx) => {
if (!eventType.bookingFields) {
// if eventType has been deleted, we won't have bookingFields and thus we can't validate the responses.
return;
}
eventType.bookingFields.forEach((bookingField) => {
const value = responses[bookingField.name];
const stringSchema = z.string();
@@ -0,0 +1,59 @@
import type z from "zod";
import { SystemField } from "@calcom/features/bookings/lib/getBookingFields";
import type { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
export const getCalEventResponses = ({
bookingFields,
responses,
}: {
// If the eventType has been deleted and a booking is Accepted later on, then bookingFields will be null and we can't know the label of fields. So, we should store the label as well in the DB
// Also, it is no longer straightforward to identify if a field is system field or not
bookingFields: z.infer<typeof eventTypeBookingFields> | null;
responses: z.infer<typeof bookingResponsesDbSchema>;
}) => {
const calEventUserFieldsResponses = {} as NonNullable<CalendarEvent["userFieldsResponses"]>;
const calEventResponses = {} as NonNullable<CalendarEvent["responses"]>;
if (bookingFields) {
bookingFields.forEach((field) => {
const label = field.label || field.defaultLabel;
if (!label) {
throw new Error('Missing label for booking field "' + field.name + '"');
}
if (field.editable === "user" || field.editable === "user-readonly") {
calEventUserFieldsResponses[field.name] = {
label,
value: responses[field.name],
};
}
calEventResponses[field.name] = {
label,
value: responses[field.name],
};
});
} else {
// Alternative way to generate for a booking of whose eventType has been deleted
for (const [name, value] of Object.entries(responses)) {
const isSystemField = SystemField.safeParse(name);
// Use name for Label because we don't have access to the label. This will not be needed once we start storing the label along with the response
const label = name;
if (!isSystemField.success) {
calEventUserFieldsResponses[name] = {
label,
value,
};
}
calEventResponses[name] = {
label,
value,
};
}
}
return { calEventUserFieldsResponses, calEventResponses };
};
@@ -494,7 +494,7 @@ async function handler(req: CustomRequest) {
bookingToDelete.user.credentials
.filter((credential) => credential.type.endsWith("_video"))
.forEach((credential) => {
const uidToDelete = bookingToDelete?.references?.[0].uid ?? bookingToDelete.uid;
const uidToDelete = bookingToDelete?.references?.[0]?.uid ?? bookingToDelete.uid;
apiDeletes.push(deleteMeeting(credential, uidToDelete));
});
}
@@ -32,6 +32,7 @@ import {
sendScheduledSeatsEmails,
} from "@calcom/emails";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
@@ -403,23 +404,9 @@ function getBookingData({
const reqBody = bookingDataSchema.parse(req.body);
if ("responses" in reqBody) {
const responses = reqBody.responses;
const calEventResponses = {} as NonNullable<CalendarEvent["responses"]>;
const calEventUserFieldsResponses = {} as NonNullable<CalendarEvent["userFieldsResponses"]>;
eventType.bookingFields.forEach((field) => {
const label = field.label || field.defaultLabel;
if (!label) {
throw new Error('Missing label for booking field "' + field.name + '"');
}
if (field.editable === "user" || field.editable === "user-readonly") {
calEventUserFieldsResponses[field.name] = {
label,
value: responses[field.name],
};
}
calEventResponses[field.name] = {
label,
value: responses[field.name],
};
const { calEventUserFieldsResponses, calEventResponses } = getCalEventResponses({
bookingFields: eventType.bookingFields,
responses,
});
return {
...reqBody,
@@ -940,14 +927,6 @@ async function handler(
// There are two paths here, reschedule a booking with seats and booking seats without reschedule
if (rescheduleUid) {
const seatAttendee: Partial<Person> | null = bookingSeat?.attendee || null;
// Required for Typescript, these should always be set.
if (!seatAttendee || !bookingSeat || !rescheduleUid) {
throw new Error("Internal Error.");
}
seatAttendee.language = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" };
// See if the new date has a booking already
const newTimeSlotBooking = await prisma.booking.findFirst({
where: {
@@ -1011,9 +990,16 @@ async function handler(
}),
};
// If owner reschedules the event we want to update the entire booking
// Also if owner is rescheduling there should be no bookingSeat
if (booking.user?.id === req.userId && !bookingSeat) {
if (!bookingSeat) {
// if no bookingSeat is given and the userId != owner, 401.
// TODO: Next step; Evaluate ownership, what about teams?
if (booking.user?.id !== req.userId) {
throw new HttpError({ statusCode: 401 });
}
// If owner reschedules the event we want to update the entire booking
// Also if owner is rescheduling there should be no bookingSeat
// If there is no booking during the new time slot then update the current booking to the new date
if (!newTimeSlotBooking) {
const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({
@@ -1192,14 +1178,13 @@ async function handler(
return { ...resultBooking };
}
// seatAttendee is null when the organizer is rescheduling.
const seatAttendee: Partial<Person> | null = bookingSeat?.attendee || null;
seatAttendee.language = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" };
// If there is no booking then remove the attendee from the old booking and create a new one
if (!newTimeSlotBooking) {
await prisma.bookingSeat.delete({
where: {
id: bookingSeat.id,
},
});
await prisma.attendee.delete({
where: {
id: seatAttendee.id,
@@ -1676,6 +1661,16 @@ async function handler(
// Use EventManager to conditionally use all needed integrations.
addVideoCallDataToEvt(originalRescheduledBooking.references);
const updateManager = await eventManager.reschedule(evt, originalRescheduledBooking.uid);
//delete original rescheduled booking (no seats event)
if (!eventType.seatsPerTimeSlot) {
await prisma.booking.delete({
where: {
id: originalRescheduledBooking.id,
},
});
}
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
@@ -75,6 +75,7 @@ export const CreateANewTeamForm = () => {
if (newTeamFormMethods.formState.touchedFields["slug"] === undefined) {
newTeamFormMethods.setValue("slug", slugify(e?.target.value));
}
createTeamMutation.reset();
}}
autoComplete="off"
/>
@@ -102,6 +103,7 @@ export const CreateANewTeamForm = () => {
newTeamFormMethods.setValue("slug", slugify(e?.target.value), {
shouldTouch: true,
});
createTeamMutation.reset();
}}
/>
)}
@@ -122,6 +124,7 @@ export const CreateANewTeamForm = () => {
buttonMsg={t("update")}
handleAvatarChange={(newAvatar: string) => {
newTeamFormMethods.setValue("logo", newAvatar);
createTeamMutation.reset();
}}
imageSrc={value}
/>
@@ -71,14 +71,14 @@ export function TeamsListing() {
emptyTitle="no_teams"
emptyDescription="no_teams_description"
features={features}
background="/team-banner-background.jpg"
background="/banners/teams.jpg"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="secondary" href="https://go.cal.com/teams-video" target="_blank">
<Button color="minimal" href="https://go.cal.com/teams-video" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
@@ -10,7 +10,6 @@ import { z } from "zod";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownAndSanitize } from "@calcom/lib/markdownAndSanitizeClientSide";
import { md } from "@calcom/lib/markdownIt";
import objectKeys from "@calcom/lib/objectKeys";
import turndown from "@calcom/lib/turndownService";
@@ -257,7 +256,7 @@ const ProfileView = () => {
<Label className="mt-5 text-black">{t("about")}</Label>
<div
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: markdownAndSanitize(team.bio) }}
dangerouslySetInnerHTML={{ __html: md.render(team.bio || "") }}
/>
</>
)}
@@ -7,6 +7,7 @@ import type { z } from "zod";
import { classNames, parseRecurringEvent } from "@calcom/lib";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { addListFormatting, md } from "@calcom/lib/markdownIt";
import type { baseEventTypeSelect } from "@calcom/prisma";
import type { EventTypeModel } from "@calcom/prisma/zod";
import { Badge } from "@calcom/ui";
@@ -25,9 +26,8 @@ export type EventTypeDescriptionProps = {
z.infer<typeof EventTypeModel>,
Exclude<keyof typeof baseEventTypeSelect, "recurringEvent"> | "metadata"
> & {
descriptionAsSafeHTML?: string | null;
recurringEvent: Prisma.JsonValue;
seatsPerTimeSlot?: number | null;
seatsPerTimeSlot?: number;
};
className?: string;
shortenDescription?: boolean;
@@ -57,7 +57,7 @@ export const EventTypeDescription = ({
shortenDescription ? "line-clamp-4" : ""
)}
dangerouslySetInnerHTML={{
__html: eventType.descriptionAsSafeHTML || "",
__html: addListFormatting(md.render(eventType.description)),
}}
/>
)}
@@ -0,0 +1,49 @@
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge, List, ListItem, ListItemText, ListItemTitle, Switch } from "@calcom/ui";
export const FlagAdminList = () => {
const [data] = trpc.viewer.features.list.useSuspenseQuery();
return (
<List roundContainer noBorderTreatment>
{data.map((flag) => (
<ListItem key={flag.slug} rounded={false}>
<div className="flex flex-1 flex-col">
<ListItemTitle component="h3">
{flag.slug}
&nbsp;&nbsp;
<Badge variant="green">{flag.type}</Badge>
</ListItemTitle>
<ListItemText component="p">{flag.description}</ListItemText>
</div>
<div className="flex py-2">
<FlagToggle flag={flag} />
</div>
</ListItem>
))}
</List>
);
};
type Flag = RouterOutputs["viewer"]["features"]["list"][number];
const FlagToggle = (props: { flag: Flag }) => {
const {
flag: { slug, enabled },
} = props;
const utils = trpc.useContext();
const mutation = trpc.viewer.features.toggle.useMutation({
onSuccess: () => {
utils.viewer.features.list.invalidate();
utils.viewer.features.map.invalidate();
},
});
return (
<Switch
defaultChecked={enabled}
onCheckedChange={(checked) => {
mutation.mutate({ slug, enabled: checked });
}}
/>
);
};
+11
View File
@@ -0,0 +1,11 @@
/**
* Right now we only support boolean flags.
* Maybe later on we can add string variants or numeric ones
**/
export type AppFlags = {
emails: boolean;
teams: boolean;
webhooks: boolean;
workflows: boolean;
"booking-page-v2": boolean;
};
@@ -0,0 +1,56 @@
import * as React from "react";
import type { AppFlags } from "../config";
/**
* Generic Feature Flags
*
* Entries consist of the feature flag name as the key and the resolved variant's value as the value.
*/
export type Flags = AppFlags;
/**
* Allows you to access the flags from context
*/
const FeatureContext = React.createContext<Flags | null>(null);
/**
* Accesses the evaluated flags from context.
*
* You need to render a <FeatureProvider /> further up to be able to use
* this component.
*/
export function useFlagMap() {
const flagMapContext = React.useContext(FeatureContext);
if (flagMapContext === null) throw new Error("Error: useFlagMap was used outside of FeatureProvider.");
return flagMapContext as Flags;
}
/**
* If you want to be able to access the flags from context using `useFlagMap()`,
* you can render the FeatureProvider at the top of your Next.js pages, like so:
*
* ```ts
* import { useFlags } from "@calcom/features/flags/hooks/useFlag"
* import { FeatureProvider, useFlagMap } from @calcom/features/flags/context/provider"
*
* export default function YourPage () {
* const flags = useFlags()
*
* return (
* <FeatureProvider value={flags}>
* <YourOwnComponent />
* </FeatureProvider>
* )
* }
* ```
*
* You can then call `useFlagMap()` to access your `flagMap` from within
* `YourOwnComponent` or further down.
*
* _Note that it's generally better to explicitly pass your flags down as props,
* so you might not need this at all._
*/
export function FeatureProvider<F extends Flags>(props: { value: F; children: React.ReactNode }) {
return React.createElement(FeatureContext.Provider, { value: props.value }, props.children);
}
+6
View File
@@ -0,0 +1,6 @@
import { trpc } from "@calcom/trpc/react";
export function useFlags() {
const query = trpc.viewer.features.map.useQuery(undefined, { initialData: {} });
return query.data;
}
@@ -0,0 +1,17 @@
import { Suspense } from "react";
import { Meta } from "@calcom/ui";
import { FiLoader } from "@calcom/ui/components/icon";
import { FlagAdminList } from "../components/FlagAdminList";
export const FlagListingView = () => {
return (
<>
<Meta title="Feature Flags" description="Here you can toggle your Cal.com instance features." />
<Suspense fallback={<FiLoader />}>
<FlagAdminList />
</Suspense>
</>
);
};
+28
View File
@@ -0,0 +1,28 @@
import { z } from "zod";
import { authedAdminProcedure, publicProcedure, router } from "@calcom/trpc/server/trpc";
import { getFeatureFlagMap } from "./utils";
export const featureFlagRouter = router({
list: publicProcedure.query(async ({ ctx }) => {
const { prisma } = ctx;
return prisma.feature.findMany({
orderBy: { slug: "asc" },
});
}),
map: publicProcedure.query(async ({ ctx }) => {
const { prisma } = ctx;
return getFeatureFlagMap(prisma);
}),
toggle: authedAdminProcedure
.input(z.object({ slug: z.string(), enabled: z.boolean() }))
.mutation(({ ctx, input }) => {
const { prisma, user } = ctx;
const { slug, enabled } = input;
return prisma.feature.update({
where: { slug },
data: { enabled, updatedBy: user.id },
});
}),
});
+13
View File
@@ -0,0 +1,13 @@
import type { PrismaClient } from "@prisma/client";
import type { AppFlags } from "../config";
export async function getFeatureFlagMap(prisma: PrismaClient) {
const flags = await prisma.feature.findMany({
orderBy: { slug: "asc" },
});
return flags.reduce<AppFlags>((acc, flag) => {
acc[flag.slug as keyof AppFlags] = flag.enabled;
return acc;
}, {} as AppFlags);
}
@@ -0,0 +1,38 @@
import { Card, LineChart, Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { valueFormatter } from "../lib/valueFormatter";
export const AverageEventDurationChart = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedUserId } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess } = trpc.viewer.insights.averageEventDuration.useQuery({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
userId: selectedUserId ?? undefined,
});
if (!isSuccess || data?.length == 0 || !startDate || !endDate || !teamId) return null;
return (
<Card>
<Title>{t("average_event_duration")}</Title>
<LineChart
className="mt-4 h-80"
data={data}
index="Date"
categories={["Average"]}
colors={["blue"]}
valueFormatter={valueFormatter}
/>
</Card>
);
};
@@ -0,0 +1,62 @@
import { Grid } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { KPICard } from "./KPICard";
export const BookingKPICards = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedEventTypeId, selectedUserId } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess } = trpc.viewer.insights.eventsByStatus.useQuery({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
userId: selectedUserId ?? undefined,
});
const categories: {
title: string;
index: "created" | "completed" | "rescheduled" | "cancelled";
}[] = [
{
title: t("events_created"),
index: "created",
},
{
title: t("events_completed"),
index: "completed",
},
{
title: t("events_rescheduled"),
index: "rescheduled",
},
{
title: t("events_cancelled"),
index: "cancelled",
},
];
if (!isSuccess || !startDate || !endDate || !teamId || data?.empty) return null;
return (
<Grid numColsSm={2} numColsLg={4} className="gap-x-6 gap-y-6">
{categories.map((item) => (
<KPICard
key={item.title}
title={item.title}
value={data[item.index].count}
previousMetricData={data[item.index]}
previousDateRange={data.previousRange}
/>
))}
</Grid>
);
};
@@ -0,0 +1,40 @@
import { Card, LineChart, Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { valueFormatter } from "../lib/valueFormatter";
export const BookingStatusLineChart = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { selectedTeamId, selectedTimeView = "week", dateRange, selectedEventTypeId } = filter;
const [startDate, endDate] = dateRange;
if (!startDate || !endDate) return null;
const { data: eventsTimeLine, isSuccess } = trpc.viewer.insights.eventsTimeline.useQuery({
timeView: selectedTimeView,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId: selectedTeamId || -1,
eventTypeId: selectedEventTypeId ?? undefined,
});
if (!isSuccess) return null;
return (
<Card>
<Title>{t("event_trends")}</Title>
<LineChart
className="mt-4 h-80"
data={eventsTimeLine ?? []}
categories={["Created", "Completed", "Rescheduled", "Cancelled"]}
index="Month"
colors={["gray", "green", "blue", "red"]}
valueFormatter={valueFormatter}
/>
</Card>
);
};
@@ -0,0 +1,49 @@
import { Card, Flex, Text, Metric, BadgeDelta } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Tooltip } from "@calcom/ui";
import { calculateDeltaType, colors, valueFormatter } from "../lib";
export const KPICard = ({
title,
previousMetricData,
previousDateRange,
}: {
title: string;
value: number;
previousMetricData: {
count: number;
deltaPrevious: number;
};
previousDateRange: { startDate: string; endDate: string };
}) => {
const { t } = useLocale();
return (
<Card key={title}>
<Text>{title}</Text>
<Flex className="items-baseline justify-start space-x-3 truncate">
<Metric>{valueFormatter(previousMetricData.count)}</Metric>
</Flex>
<Flex className="mt-4 justify-start space-x-2">
<BadgeDelta
deltaType={calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count)}
/>
<Flex className="justify-start space-x-1 truncate">
<Text
color={colors[calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count)]}>
{Number(previousMetricData.deltaPrevious).toFixed(0)}%
</Text>
<Tooltip
content={t("from_to_date_period", {
startDate: previousDateRange.startDate,
endDate: previousDateRange.endDate,
})}>
<small className="relative top-px text-xs text-gray-600">{t("from_last_period")}</small>
</Tooltip>
</Flex>
</Flex>
</Card>
);
};
@@ -0,0 +1,30 @@
import { Card, Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
export const LeastBookedTeamMembersTable = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess } = trpc.viewer.insights.membersWithLeastBookings.useQuery({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
});
if (!isSuccess || !startDate || !endDate || !teamId) return null;
return (
<Card>
<Title>{t("least_booked_members")}</Title>
<TotalBookingUsersTable data={data} />
</Card>
);
};
@@ -0,0 +1,31 @@
import { Card, Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
export const MostBookedTeamMembersTable = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedEventTypeId } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess } = trpc.viewer.insights.membersWithMostBookings.useQuery({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
});
if (!isSuccess || !startDate || !endDate || !teamId) return null;
return (
<Card>
<Title>{t("most_booked_members")}</Title>
<TotalBookingUsersTable data={data} />
</Card>
);
};
@@ -0,0 +1,52 @@
import { Card, Title, Table, TableBody, TableCell, TableRow, Text } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
export const PopularEventsTable = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedUserId } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess } = trpc.viewer.insights.popularEventTypes.useQuery({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
userId: selectedUserId ?? undefined,
});
if (!startDate || !endDate || !teamId) return null;
return (
<Card>
<Title>{t("popular_events")}</Title>
<Table className="mt-5">
<TableBody>
{isSuccess ? (
data?.map((item) => (
<TableRow key={item.eventTypeId}>
<TableCell>{item.eventTypeName}</TableCell>
<TableCell>
<Text>
<strong>{item.count}</strong>
</Text>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell>{t("no_event_types_found")}</TableCell>
<TableCell>
<strong>0</strong>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
);
};
@@ -0,0 +1,49 @@
import { Table, TableBody, TableCell, TableRow, Text } from "@tremor/react";
import type { User } from "@calcom/prisma/client";
import { Avatar } from "@calcom/ui";
export const TotalBookingUsersTable = ({
data,
}: {
data:
| { userId: number | null; user: User; emailMd5?: string; count: number; Username?: string }[]
| undefined;
}) => {
return (
<Table>
<TableBody>
<>
{data && data?.length > 0 ? (
data?.map((item) => (
<TableRow key={item.userId}>
<TableCell className="flex flex-row">
<Avatar
alt={item.user.name || ""}
size="sm"
imageSrc={item.user.avatar}
title={item.user.name || ""}
className="m-2"
gravatarFallbackMd5={item.emailMd5}
/>
<p className="my-auto mx-0">
<strong>{item.user.name}</strong>
</p>
</TableCell>
<TableCell>
<Text>
<strong>{item.count}</strong>
</Text>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell>No members found</TableCell>
</TableRow>
)}
</>
</TableBody>
</Table>
);
};
@@ -0,0 +1,6 @@
export { AverageEventDurationChart } from "./AverageEventDurationChart";
export { BookingKPICards } from "./BookingKPICards";
export { BookingStatusLineChart } from "./BookingStatusLineChart";
export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable";
export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable";
export { PopularEventsTable } from "./PopularEventsTable";
@@ -0,0 +1,47 @@
import { useState } from "react";
import dayjs from "@calcom/dayjs";
import type { FilterContextType } from "./provider";
import { FilterProvider } from "./provider";
export function FiltersProvider({ children }: { children: React.ReactNode }) {
// TODO: Sync insight filters with URL parameters
const [selectedTimeView, setSelectedTimeView] =
useState<FilterContextType["filter"]["selectedTimeView"]>("week");
const [selectedUserId, setSelectedUserId] = useState<FilterContextType["filter"]["selectedUserId"]>(null);
const [selectedTeamId, setSelectedTeamId] = useState<FilterContextType["filter"]["selectedTeamId"]>(null);
const [selectedEventTypeId, setSelectedEventTypeId] =
useState<FilterContextType["filter"]["selectedEventTypeId"]>(null);
const [selectedFilter, setSelectedFilter] = useState<FilterContextType["filter"]["selectedFilter"]>(null);
const [selectedTeamName, setSelectedTeamName] =
useState<FilterContextType["filter"]["selectedTeamName"]>(null);
const [dateRange, setDateRange] = useState<FilterContextType["filter"]["dateRange"]>([
dayjs().subtract(1, "month"),
dayjs(),
"t",
]);
return (
<FilterProvider
value={{
filter: {
dateRange,
selectedTimeView,
selectedUserId,
selectedTeamId,
selectedTeamName,
selectedEventTypeId,
selectedFilter,
},
setSelectedFilter: (filter) => setSelectedFilter(filter),
setDateRange: (dateRange) => setDateRange(dateRange),
setSelectedTimeView: (selectedTimeView) => setSelectedTimeView(selectedTimeView),
setSelectedUserId: (selectedUserId) => setSelectedUserId(selectedUserId),
setSelectedTeamId: (selectedTeamId) => setSelectedTeamId(selectedTeamId),
setSelectedTeamName: (selectedTeamName) => setSelectedTeamName(selectedTeamName),
setSelectedEventTypeId: (selectedEventTypeId) => setSelectedEventTypeId(selectedEventTypeId),
}}>
{children}
</FilterProvider>
);
}
@@ -0,0 +1,38 @@
import * as React from "react";
import type { Dayjs } from "@calcom/dayjs";
export type FilterContextType = {
filter: {
dateRange: [Dayjs, Dayjs, null | string];
selectedTimeView: "year" | "week" | "month";
selectedFilter: Array<"user" | "event-type"> | null;
selectedTeamId: number | null;
selectedTeamName: string | null;
selectedUserId: number | null;
selectedEventTypeId: number | null;
};
setDateRange: ([start, end, range]: [Dayjs, Dayjs, null | string]) => void;
setSelectedFilter: (filter: Array<"user" | "event-type"> | null) => void;
setSelectedTeamId: (teamId: number | null) => void;
setSelectedTeamName: (teamName: string | null) => void;
setSelectedUserId: (userId: number | null) => void;
setSelectedEventTypeId: (eventTypeId: number | null) => void;
setSelectedTimeView: (timeView: "year" | "week" | "month") => void;
};
export const FilterContext = React.createContext<FilterContextType | null>(null);
export function useFilterContext() {
const context = React.useContext(FilterContext);
if (!context) {
throw new Error("useFilterContext must be used within a FilterProvider");
}
return context;
}
export function FilterProvider<F extends FilterContextType>(props: { value: F; children: React.ReactNode }) {
return React.createElement(FilterContext.Provider, { value: props.value }, props.children);
}
@@ -0,0 +1,58 @@
import { DateRangePicker } from "@tremor/react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useFilterContext } from "../context/provider";
type RangeType = "tdy" | "w" | "t" | "m" | "y" | undefined | null;
export const DateSelect = () => {
const { t } = useLocale();
const { filter, setDateRange } = useFilterContext();
const currentDate = dayjs();
const [startDate, endDate, range] = filter.dateRange;
const startValue = startDate?.toDate() || null;
const endValue = endDate?.toDate() || null;
return (
<DateRangePicker
value={[startValue, endValue, range]}
defaultValue={[startValue, endValue, range]}
onValueChange={(datesArray) => {
const [selected, ...rest] = datesArray;
const [start, end, range] = datesArray;
// If range has value and it's of type RangeType
if (range && (range === "tdy" || range === "w" || range === "t" || range === "m" || range === "y")) {
setDateRange([dayjs(start), dayjs(end), range]);
return;
} else if (start && !end) {
// If only start time has value that means selected date should push to dateRange with last value null
const currentDates = filter.dateRange;
// remove last position of array
currentDates.pop();
// push new value to array
currentDates.push(dayjs(selected));
// if lenght > 2 then remove first value
if (currentDates.length > 2) {
currentDates.shift();
}
setDateRange([currentDates[0], currentDates[1], null]);
return;
}
// If range has value and it's of type RangeType
}}
options={undefined}
enableDropdown={true}
placeholder={t("select_date_range")}
enableYearPagination={true}
minDate={currentDate.subtract(2, "year").toDate()}
maxDate={currentDate.toDate()}
color="blue"
className="h-[42px] max-w-sm"
/>
);
};
@@ -0,0 +1,57 @@
import { isArray } from "lodash";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Select } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type EventType = RouterOutputs["viewer"]["insights"]["eventTypeList"][number];
type Option = { value: string; label: string };
const mapEventTypeToOption = (eventType: EventType): Option => ({
value: eventType.slug,
label: eventType.title,
});
export const EventTypeListInTeam = () => {
const { t } = useLocale();
const { filter, setSelectedEventTypeId } = useFilterContext();
const { selectedTeamId, selectedEventTypeId } = filter;
const { selectedFilter } = filter;
const { data, isSuccess } = trpc.viewer.insights.eventTypeList.useQuery({
teamId: selectedTeamId,
});
if (!selectedFilter?.includes("event-type")) return null;
if (!selectedTeamId) return null;
const filterOptions =
data?.map(mapEventTypeToOption) ?? ([{ label: "No event types found", value: "" }] as Option[]);
const selectedEventType = data?.find((item) => item.id === selectedEventTypeId);
const eventTypeValue = selectedEventType ? mapEventTypeToOption(selectedEventType) : null;
if (!isSuccess || !data || !isArray(data)) return null;
return (
<Select<Option>
isSearchable={false}
isMulti={false}
options={filterOptions}
onChange={(input) => {
if (input) {
const selectedEventTypeId = data.find((item) => item.slug === input.value)?.id;
!!selectedEventTypeId && setSelectedEventTypeId(selectedEventTypeId);
}
}}
defaultValue={eventTypeValue}
className="w-52 min-w-[180px]"
placeholder={
<div className="flex flex-row">
<p>{t("select_event_type")}</p>
</div>
}
/>
);
};
@@ -0,0 +1,49 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Select } from "@calcom/ui";
import { FiFilter } from "@calcom/ui/components/icon";
import { useFilterContext } from "../context/provider";
type Option = { value: "event-type" | "user"; label: string };
export const FilterType = () => {
const { t } = useLocale();
const { setSelectedFilter, setSelectedUserId, setSelectedEventTypeId } = useFilterContext();
const filterOptions: Option[] = [
{
label: t("event_type"),
value: "event-type",
},
{
label: t("user"),
value: "user",
},
];
return (
<Select<Option>
isMulti={false}
isSearchable={false}
options={filterOptions}
onChange={(input) => {
if (input) {
// This can multiple values, but for now we only want to have one filter active at a time
setSelectedFilter([input.value]);
if (input.value === "event-type") {
setSelectedUserId(null);
} else if (input.value === "user") {
setSelectedEventTypeId(null);
}
}
}}
className="w-32 min-w-[130px]"
placeholder={
<div className="flex flex-row text-gray-900">
<FiFilter className="m-auto text-gray-900" />
{t("add_filter")}
</div>
}
/>
);
};
@@ -0,0 +1,53 @@
import { useEffect } from "react";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Select } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type Team = RouterOutputs["viewer"]["insights"]["teamListForUser"][number];
type Option = { value: number; label: string };
const mapTeamToOption = (team: Team): Option => ({
value: team.id,
label: team.name ?? "",
});
export const TeamList = () => {
const { filter, setSelectedTeamId, setSelectedTeamName } = useFilterContext();
const { selectedTeamId } = filter;
const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery();
useEffect(() => {
if (data && data?.length > 0) {
setSelectedTeamId(data[0].id);
setSelectedTeamName(data[0].name);
}
}, [data]);
const UserListOptions = data?.map(mapTeamToOption) || ([{ label: "Empty", value: -1 }] as Option[]);
const selectedTeam = data?.find((item) => item.id === selectedTeamId);
const teamValue = selectedTeam ? mapTeamToOption(selectedTeam) : null;
if (!isSuccess || !selectedTeamId || data?.length === 0) return null;
return (
<>
<Select<Option>
isSearchable={false}
isMulti={false}
value={teamValue}
defaultValue={selectedTeamId ? { value: data[0].id, label: data[0].name } : null}
className="h-[38px] w-[90vw] capitalize md:min-w-[100px] md:max-w-[100px]"
options={UserListOptions}
onChange={(input) => {
if (input) {
setSelectedTeamId(input.value);
setSelectedTeamName(input.label);
}
}}
/>
</>
);
};
@@ -0,0 +1,51 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Select } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type User = RouterOutputs["viewer"]["insights"]["userList"][number];
type Option = { value: number; label: string };
const mapUserToOption = (user: User): Option => ({
value: user.id,
label: user.name ?? "",
});
export const UserListInTeam = () => {
const { t } = useLocale();
const { filter, setSelectedUserId } = useFilterContext();
const { selectedFilter, selectedTeamId, selectedUserId } = filter;
const { data, isSuccess } = trpc.viewer.insights.userList.useQuery({
teamId: selectedTeamId,
});
if (!selectedFilter?.includes("user")) return null;
if (!selectedTeamId) return null;
const userListOptions = data?.map(mapUserToOption) ?? ([] as { value: number; label: string }[]);
const selectedUser = data?.find((item) => item.id === selectedUserId);
const userValue = selectedUser ? mapUserToOption(selectedUser) : null;
if (!isSuccess || data?.length === 0) return null;
return (
<Select<Option>
isSearchable={false}
className="mb-0 ml-2 h-[38px] w-40 min-w-[140px] capitalize md:min-w-[150px] md:max-w-[200px]"
defaultValue={userValue}
options={userListOptions}
onChange={(input) => {
if (input) {
setSelectedUserId(input.value);
}
}}
placeholder={
<div className="flex flex-row">
<p>{t("select_user")}</p>
</div>
}
/>
);
};
@@ -0,0 +1,45 @@
import { DateSelect } from "./DateSelect";
import { EventTypeListInTeam } from "./EventTypeListInTeam";
import { FilterType } from "./FilterType";
import { TeamList } from "./TeamList";
import { UserListInTeam } from "./UsersListInTeam";
export const Filters = () => {
return (
<div className="mt-2 flex flex-col flex-wrap gap-2 md:flex-row md:flex-nowrap">
<TeamList />
<FilterType />
<UserListInTeam />
<EventTypeListInTeam />
<DateSelect />
{/* @NOTE: To be released in next iteration */}
{/* <ButtonGroup combined containerProps={{ className: "hidden lg:flex mr-2" }}>
<Tooltip content={t("settings")}>
<Button
variant="icon"
color="secondary"
target="_blank"
rel="noreferrer"
StartIcon={FiSettings}
className="h-[38px]"
/>
</Tooltip>
<Tooltip content={t("download_csv")>
<Button
variant="icon"
color="secondary"
target="_blank"
rel="noreferrer"
StartIcon={FiDownload}
className="h-[38px]"
/>
</Tooltip>
</ButtonGroup> */}
</div>
);
};
@@ -0,0 +1,9 @@
export const calculateDeltaType = (delta: number) => {
if (delta > 0) {
return delta > 10 ? "increase" : "moderateIncrease";
} else if (delta < 0) {
return delta < -10 ? "decrease" : "moderateDecrease";
} else {
return "unchanged";
}
};
+9
View File
@@ -0,0 +1,9 @@
import type { Color } from "@tremor/react";
export const colors: { [key: string]: Color } = {
increase: "emerald",
moderateIncrease: "emerald",
unchanged: "orange",
moderateDecrease: "rose",
decrease: "rose",
};
+3
View File
@@ -0,0 +1,3 @@
export { calculateDeltaType } from "./calculateDeltaType";
export { colors } from "./colors";
export { valueFormatter } from "./valueFormatter";
@@ -0,0 +1 @@
export const valueFormatter = (number: number) => `${Intl.NumberFormat().format(number).toString()}`;
+179
View File
@@ -0,0 +1,179 @@
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
interface ITimeRange {
start: Dayjs;
end: Dayjs;
}
type TimeViewType = "week" | "month" | "year" | "day";
class EventsInsights {
static getBookingsInTimeRange = async (timeRange: ITimeRange, where: Prisma.BookingWhereInput) => {
const { start, end } = timeRange;
const events = await prisma.booking.count({
where: {
...where,
createdAt: {
gte: start.toISOString(),
lte: end.toISOString(),
},
},
});
return events;
};
static getCreatedEventsInTimeRange = async (timeRange: ITimeRange, where: Prisma.BookingWhereInput) => {
const result = await this.getBookingsInTimeRange(timeRange, where);
return result;
};
static getCancelledEventsInTimeRange = async (timeRange: ITimeRange, where: Prisma.BookingWhereInput) => {
const result = await this.getBookingsInTimeRange(timeRange, {
...where,
status: "CANCELLED",
});
return result;
};
static getCompletedEventsInTimeRange = async (timeRange: ITimeRange, where: Prisma.BookingWhereInput) => {
const result = await this.getBookingsInTimeRange(timeRange, {
...where,
status: "ACCEPTED",
endTime: {
lte: dayjs().toISOString(),
},
});
return result;
};
static getRescheduledEventsInTimeRange = async (timeRange: ITimeRange, where: Prisma.BookingWhereInput) => {
const result = await this.getBookingsInTimeRange(timeRange, {
...where,
rescheduled: true,
});
return result;
};
static getBaseBookingForEventStatus = async (where: Prisma.BookingWhereInput) => {
const baseBookings = await prisma.booking.findMany({
where,
select: {
id: true,
eventType: true,
},
});
return baseBookings;
};
static getTotalRescheduledEvents = async (bookingIds: number[]) => {
return await prisma.booking.count({
where: {
id: {
in: bookingIds,
},
rescheduled: true,
},
});
};
static getTotalCancelledEvents = async (bookingIds: number[]) => {
return await prisma.booking.count({
where: {
id: {
in: bookingIds,
},
status: "CANCELLED",
},
});
};
static getTimeLine = async (timeView: TimeViewType, startDate: Dayjs, endDate: Dayjs) => {
let resultTimeLine: string[] = [];
if (timeView) {
switch (timeView) {
case "week":
resultTimeLine = this.getWeekTimeline(startDate, endDate);
break;
case "month":
resultTimeLine = this.getMonthTimeline(startDate, endDate);
break;
case "year":
resultTimeLine = this.getYearTimeline(startDate, endDate);
break;
default:
resultTimeLine = this.getWeekTimeline(startDate, endDate);
break;
}
}
return resultTimeLine;
};
static getTimeView = (timeView: TimeViewType, startDate: Dayjs, endDate: Dayjs) => {
let resultTimeView = timeView;
if (startDate.diff(endDate, "day") > 90) {
resultTimeView = "month";
} else if (startDate.diff(endDate, "day") > 365) {
resultTimeView = "year";
}
return resultTimeView;
};
static getWeekTimeline(startDate: Dayjs, endDate: Dayjs) {
let pivotDate = dayjs(startDate);
const dates = [];
while (pivotDate.isBefore(endDate)) {
pivotDate = pivotDate.add(7, "day");
dates.push(pivotDate.format("YYYY-MM-DD"));
}
return dates;
}
static getMonthTimeline(startDate: Dayjs, endDate: Dayjs) {
let pivotDate = dayjs(startDate);
const dates = [];
while (pivotDate.isBefore(endDate)) {
pivotDate = pivotDate.set("month", pivotDate.get("month") + 1);
dates.push(pivotDate.format("YYYY-MM-DD"));
}
return dates;
}
static getYearTimeline(startDate: Dayjs, endDate: Dayjs) {
const pivotDate = dayjs(startDate);
const dates = [];
while (pivotDate.isBefore(endDate)) {
pivotDate.set("year", pivotDate.get("year") + 1);
dates.push(pivotDate.format("YYYY-MM-DD"));
}
return dates;
}
static getPercentage = (actualMetric: number, previousMetric: number) => {
const differenceActualVsPrevious = actualMetric - previousMetric;
if (differenceActualVsPrevious === 0) {
return 0;
}
const result = (differenceActualVsPrevious * 100) / previousMetric;
if (isNaN(result) || !isFinite(result)) {
return 0;
}
return result;
};
}
export { EventsInsights };
@@ -0,0 +1,717 @@
import type { Prisma } from "@prisma/client";
import crypto from "crypto";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { authedProcedure, isAuthed, router } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import { EventsInsights } from "./events";
const UserBelongsToTeamInput = z.object({
teamId: z.coerce.number().optional(),
});
const userBelongsToTeamMiddleware = isAuthed.unstable_pipe(async ({ ctx, next, rawInput }) => {
const parse = UserBelongsToTeamInput.safeParse(rawInput);
if (!parse.success) {
throw new TRPCError({ code: "BAD_REQUEST" });
}
const team = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: parse.data.teamId,
},
});
if (!team) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next();
});
const userBelongsToTeamProcedure = authedProcedure.use(userBelongsToTeamMiddleware);
const UserSelect = {
id: true,
name: true,
email: true,
avatar: true,
};
const emptyResponseEventsByStatus = {
empty: true,
created: {
count: 0,
deltaPrevious: 0,
},
completed: {
count: 0,
deltaPrevious: 0,
},
rescheduled: {
count: 0,
deltaPrevious: 0,
},
cancelled: {
count: 0,
deltaPrevious: 0,
},
previousRange: {
startDate: dayjs().toISOString(),
endDate: dayjs().toISOString(),
},
};
export const insightsRouter = router({
eventsByStatus: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().optional().nullable(),
startDate: z.string(),
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
userId: z.coerce.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, userId } = input;
if (!input.teamId) {
return emptyResponseEventsByStatus;
}
const whereConditional: Prisma.BookingWhereInput = {
eventType: {
teamId: teamId,
},
};
if (eventTypeId) {
whereConditional["eventTypeId"] = eventTypeId;
} else if (userId) {
whereConditional["userId"] = userId;
}
// Migrate to use prisma views
const baseBookings = await EventsInsights.getBaseBookingForEventStatus({
...whereConditional,
createdAt: {
gte: new Date(startDate),
lte: new Date(endDate),
},
eventType: {
teamId: teamId,
},
});
const startTimeEndTimeDiff = dayjs(endDate).diff(dayjs(startDate), "day");
const baseBookingIds = baseBookings.map((b) => b.id);
const totalRescheduled = await EventsInsights.getTotalRescheduledEvents(baseBookingIds);
const totalCancelled = await EventsInsights.getTotalCancelledEvents(baseBookingIds);
const lastPeriodStartDate = dayjs(startDate).subtract(startTimeEndTimeDiff, "day");
const lastPeriodEndDate = dayjs(endDate).subtract(startTimeEndTimeDiff, "day");
const lastPeriodBaseBookings = await EventsInsights.getBaseBookingForEventStatus({
...whereConditional,
createdAt: {
gte: lastPeriodStartDate.toDate(),
lte: lastPeriodEndDate.toDate(),
},
eventType: {
teamId: teamId,
},
});
const lastPeriodBaseBookingIds = lastPeriodBaseBookings.map((b) => b.id);
const lastPeriodTotalRescheduled = await EventsInsights.getTotalRescheduledEvents(
lastPeriodBaseBookingIds
);
const lastPeriodTotalCancelled = await EventsInsights.getTotalCancelledEvents(lastPeriodBaseBookingIds);
return {
empty: false,
created: {
count: baseBookings.length,
deltaPrevious: EventsInsights.getPercentage(baseBookings.length, lastPeriodBaseBookings.length),
},
completed: {
count: baseBookings.length - totalCancelled - totalRescheduled,
deltaPrevious: EventsInsights.getPercentage(
baseBookings.length - totalCancelled - totalRescheduled,
lastPeriodBaseBookings.length - lastPeriodTotalCancelled - lastPeriodTotalRescheduled
),
},
rescheduled: {
count: totalRescheduled,
deltaPrevious: EventsInsights.getPercentage(totalRescheduled, lastPeriodTotalRescheduled),
},
cancelled: {
count: totalCancelled,
deltaPrevious: EventsInsights.getPercentage(totalCancelled, lastPeriodTotalCancelled),
},
previousRange: {
startDate: lastPeriodStartDate.format("YYYY-MM-DD"),
endDate: lastPeriodEndDate.format("YYYY-MM-DD"),
},
};
}),
eventsTimeline: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().optional(),
startDate: z.string(),
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
userId: z.coerce.number().optional(),
timeView: z.enum(["week", "month", "year"]),
})
)
.query(async ({ ctx, input }) => {
const {
teamId,
startDate: startDateString,
endDate: endDateString,
eventTypeId,
userId,
timeView: inputTimeView,
} = input;
const startDate = dayjs(startDateString);
const endDate = dayjs(endDateString);
const user = ctx.user;
const timeView = inputTimeView;
let whereConditional: Prisma.BookingWhereInput = {
eventType: {
teamId: teamId,
},
};
if (userId) {
delete whereConditional.eventType;
whereConditional = {
...whereConditional,
userId,
};
}
if (eventTypeId && !!whereConditional) {
delete whereConditional.eventType;
delete whereConditional.userId;
whereConditional = {
...whereConditional,
eventTypeId: eventTypeId,
};
}
// Get timeline data
const timeline = await EventsInsights.getTimeLine(timeView, dayjs(startDate), dayjs(endDate));
// iterate timeline and fetch data
if (!timeline) {
return [];
}
const dateFormat: string = timeView === "year" ? "YYYY" : timeView === "month" ? "MMM YYYY" : "ll";
const result = [];
for (const date of timeline) {
const EventData = {
Month: dayjs(date).format(dateFormat),
Created: 0,
Completed: 0,
Rescheduled: 0,
Cancelled: 0,
};
const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
const startDate = dayjs(date).startOf(startOfEndOf);
const endDate = dayjs(date).endOf(startOfEndOf);
const promisesResult = await Promise.all([
EventsInsights.getCreatedEventsInTimeRange(
{
start: startDate,
end: endDate,
},
whereConditional
),
EventsInsights.getCompletedEventsInTimeRange(
{
start: startDate,
end: endDate,
},
whereConditional
),
EventsInsights.getRescheduledEventsInTimeRange(
{
start: startDate,
end: endDate,
},
whereConditional
),
EventsInsights.getCancelledEventsInTimeRange(
{
start: startDate,
end: endDate,
},
whereConditional
),
]);
EventData["Created"] = promisesResult[0];
EventData["Completed"] = promisesResult[1];
EventData["Rescheduled"] = promisesResult[2];
EventData["Cancelled"] = promisesResult[3];
result.push(EventData);
}
return result;
}),
popularEventTypes: userBelongsToTeamProcedure
.input(
z.object({
userId: z.coerce.number().optional(),
teamId: z.coerce.number().optional().nullable(),
startDate: z.string(),
endDate: z.string(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, userId } = input;
const user = ctx.user;
if (!input.teamId) {
return [];
}
const eventTypeWhere: Prisma.EventTypeWhereInput = {
teamId: teamId,
};
const bookingWhere: Prisma.BookingWhereInput = {
eventType: eventTypeWhere,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
};
if (userId) {
bookingWhere.userId = userId;
}
const bookingsFromTeam = await ctx.prisma.booking.groupBy({
by: ["eventTypeId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
take: 10,
});
const eventTypeIds = bookingsFromTeam
.filter((booking) => typeof booking.eventTypeId === "number")
.map((booking) => booking.eventTypeId);
const eventTypesFromTeam = await ctx.prisma.eventType.findMany({
where: {
teamId: teamId,
id: {
in: eventTypeIds as number[],
},
},
});
const eventTypeHashMap = new Map();
eventTypesFromTeam.forEach((eventType) => {
eventTypeHashMap.set(eventType.id, eventType.title);
});
const result = bookingsFromTeam.map((booking) => {
return {
eventTypeId: booking.eventTypeId,
eventTypeName: eventTypeHashMap.get(booking.eventTypeId),
count: booking._count.id,
};
});
return result;
}),
averageEventDuration: userBelongsToTeamProcedure
.input(
z.object({
userId: z.coerce.number().optional(),
teamId: z.coerce.number().optional().nullable(),
startDate: z.string(),
endDate: z.string(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate: startDateString, endDate: endDateString, userId } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const startDate = dayjs(startDateString);
const endDate = dayjs(endDateString);
const whereConditional: Prisma.BookingWhereInput = {
eventType: {
teamId: teamId,
},
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
};
if (userId) {
delete whereConditional.eventType;
whereConditional["userId"] = userId;
}
const timeView = EventsInsights.getTimeView("week", startDate, endDate);
const timeLine = await EventsInsights.getTimeLine("week", startDate, endDate);
if (!timeLine) {
return [];
}
const dateFormat = "ll";
const result = [];
for (const date of timeLine) {
const EventData = {
Date: dayjs(date).format(dateFormat),
Average: 0,
};
const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
const startDate = dayjs(date).startOf(startOfEndOf);
const endDate = dayjs(date).endOf(startOfEndOf);
const bookingsInTimeRange = await ctx.prisma.booking.findMany({
where: {
...whereConditional,
createdAt: {
gte: startDate.toDate(),
lte: endDate.toDate(),
},
},
include: {
eventType: true,
},
});
const avgDuration =
bookingsInTimeRange.reduce((acc, booking) => {
const duration = booking.eventType?.length || 0;
return acc + duration;
}, 0) / bookingsInTimeRange.length;
EventData["Average"] = Number(avgDuration) || 0;
result.push(EventData);
}
return result;
}),
membersWithMostBookings: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().nullable(),
startDate: z.string(),
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const eventTypeWhere: Prisma.EventTypeWhereInput = {
teamId: teamId,
};
if (eventTypeId) {
eventTypeWhere["id"] = eventTypeId;
}
const bookingsFromTeam = await ctx.prisma.booking.groupBy({
by: ["userId"],
where: {
eventType: eventTypeWhere,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
},
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
take: 10,
});
const userIds = bookingsFromTeam
.filter((booking) => typeof booking.userId === "number")
.map((booking) => booking.userId);
const usersFromTeam = await ctx.prisma.user.findMany({
where: {
id: {
in: userIds as number[],
},
},
});
const userHashMap = new Map();
usersFromTeam.forEach((user) => {
userHashMap.set(user.id, user);
});
const result = bookingsFromTeam.map((booking) => {
return {
userId: booking.userId,
user: userHashMap.get(booking.userId),
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
count: booking._count.id,
};
});
return result;
}),
membersWithLeastBookings: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().nullable(),
startDate: z.string(),
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId } = input;
if (!teamId) {
return [];
}
const eventTypeWhere: Prisma.EventTypeWhereInput = {
teamId: teamId,
};
if (eventTypeId) {
eventTypeWhere["id"] = eventTypeId;
}
const bookingsFromTeam = await ctx.prisma.booking.groupBy({
by: ["userId"],
where: {
eventType: eventTypeWhere,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
},
_count: {
id: true,
},
orderBy: {
_count: {
id: "asc",
},
},
take: 10,
});
// Users are obtained from bookings so if a user has 0 they won't be in the list
const userIds = bookingsFromTeam
.filter((booking) => typeof booking.userId === "number")
.map((booking) => booking.userId);
const usersWithNoBookings = await ctx.prisma.user.findMany({
select: UserSelect,
where: {
id: {
notIn: userIds as number[],
},
teams: {
some: {
teamId: teamId,
},
},
},
});
let usersFromTeam: Prisma.UserGetPayload<{
select: typeof UserSelect;
}>[] = [];
if (usersWithNoBookings.length < 10) {
usersFromTeam = await ctx.prisma.user.findMany({
where: {
id: {
in: userIds as number[],
},
},
select: UserSelect,
take: 10 - usersWithNoBookings.length,
});
}
const userHashMap = new Map();
[...usersWithNoBookings, ...usersFromTeam].forEach((user) => {
userHashMap.set(user.id, user);
});
const result = bookingsFromTeam.map((booking) => {
const user = userHashMap.get(booking.userId);
return {
userId: booking.userId,
user,
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
count: booking._count.id,
Username: user.name || "No Username found",
};
});
return result;
}),
teamListForUser: authedProcedure.query(async ({ ctx }) => {
const user = ctx.user;
// Look if user it's admin in multiple teams
const belongsToTeams = await ctx.prisma.membership.findMany({
where: {
userId: user.id,
team: {
slug: { not: null },
},
OR: [
{
role: "ADMIN",
},
{
role: "OWNER",
},
],
},
include: {
team: {
select: {
id: true,
name: true,
logo: true,
slug: true,
},
},
},
});
const result = belongsToTeams.map((membership) => membership.team);
return result;
}),
userList: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().nullable(),
})
)
.query(async ({ ctx, input }) => {
const user = ctx.user;
if (!input.teamId) {
return [];
}
const membership = await ctx.prisma.membership.findFirst({
where: {
userId: user.id,
teamId: input.teamId,
},
include: {
user: {
select: UserSelect,
},
},
});
// If user is not admin, return himself only
if (membership && membership.role === "MEMBER") {
return [membership.user];
}
const usersInTeam = await ctx.prisma.membership.findMany({
where: {
teamId: input.teamId,
},
include: {
user: {
select: UserSelect,
},
},
});
return usersInTeam.map((membership) => membership.user);
}),
eventTypeList: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().nullable(),
})
)
.query(async ({ ctx, input }) => {
const user = ctx.user;
if (!input.teamId) {
return [];
}
const membership = await ctx.prisma.membership.findFirst({
where: {
userId: user.id,
teamId: input.teamId,
},
});
if (membership && membership.role === "MEMBER") {
const eventTypes = await ctx.prisma.eventType.findMany({
where: {
teamId: input.teamId,
// user its listed as direct user or as part of a group
OR: [
{
userId: user.id,
},
{
users: {
some: {
id: user.id,
},
},
},
],
},
});
return eventTypes;
}
const eventTypes = await ctx.prisma.eventType.findMany({
where: {
teamId: input.teamId,
},
});
return eventTypes;
}),
});
+1
View File
@@ -8,6 +8,7 @@
"dependencies": {
"@calcom/dayjs": "*",
"@calcom/lib": "*",
"@calcom/trpc": "*",
"@calcom/ui": "*",
"@lexical/react": "^0.5.0",
"dompurify": "^2.4.1",
@@ -17,7 +17,13 @@ import {
} from "@calcom/ui";
import { FiPlus } from "@calcom/ui/components/icon";
export function NewScheduleButton({ name = "new-schedule" }: { name?: string }) {
export function NewScheduleButton({
name = "new-schedule",
fromEventType,
}: {
name?: string;
fromEventType?: boolean;
}) {
const router = useRouter();
const { t } = useLocale();
@@ -29,7 +35,7 @@ export function NewScheduleButton({ name = "new-schedule" }: { name?: string })
const createMutation = trpc.viewer.availability.schedule.create.useMutation({
onSuccess: async ({ schedule }) => {
await router.push("/availability/" + schedule.id);
await router.push(`/availability/${schedule.id}${fromEventType ? "?fromEventType=true" : ""}`);
showToast(t("schedule_created_successfully", { scheduleName: schedule.name }), "success");
utils.viewer.availability.list.setData(undefined, (data) => {
const newSchedule = { ...schedule, isDefault: false, availability: [] };
@@ -83,6 +83,7 @@ const tabs: VerticalTabItemProps[] = [
icon: FiLock,
children: [
//
{ name: "features", href: "/settings/admin/flags" },
{ name: "license", href: "/auth/setup?step=1" },
{ name: "impersonation", href: "/settings/admin/impersonation" },
{ name: "apps", href: "/settings/admin/apps/calendar" },
+28 -18
View File
@@ -13,6 +13,7 @@ import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookin
import ImpersonatingBanner from "@calcom/features/ee/impersonation/components/ImpersonatingBanner";
import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem";
import { TeamsUpgradeBanner } from "@calcom/features/ee/teams/components";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { KBarContent, KBarRoot, KBarTrigger } from "@calcom/features/kbar/Kbar";
import TimezoneChangeDialog from "@calcom/features/settings/TimezoneChangeDialog";
import { Tips } from "@calcom/features/tips";
@@ -22,6 +23,7 @@ import classNames from "@calcom/lib/classNames";
import { APP_NAME, DESKTOP_APP_LINK, JOIN_SLACK, ROADMAP, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { SVGComponent } from "@calcom/types/SVGComponent";
@@ -29,38 +31,39 @@ import {
Button,
Credits,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuTrigger,
ErrorBoundary,
Logo,
HeadSeo,
showToast,
Logo,
SkeletonText,
showToast,
} from "@calcom/ui";
import {
FiMoreVertical,
FiMoon,
FiExternalLink,
FiLink,
FiSlack,
FiMap,
FiHelpCircle,
FiDownload,
FiLogOut,
FiArrowLeft,
FiArrowRight,
FiBarChart,
FiCalendar,
FiClock,
FiUsers,
FiGrid,
FiMoreHorizontal,
FiDownload,
FiExternalLink,
FiFileText,
FiZap,
FiGrid,
FiHelpCircle,
FiLink,
FiLogOut,
FiMap,
FiMoon,
FiMoreHorizontal,
FiMoreVertical,
FiSettings,
FiArrowRight,
FiArrowLeft,
FiSlack,
FiUsers,
FiZap,
} from "@calcom/ui/components/icon";
import FreshChatProvider from "../ee/support/lib/freshchat/FreshChatProvider";
@@ -532,6 +535,11 @@ const navigation: NavigationItemType[] = [
href: "/workflows",
icon: FiZap,
},
{
name: "Insights",
href: "/insights",
icon: FiBarChart,
},
{
name: "settings",
href: "/settings/my-account/profile",
@@ -578,6 +586,8 @@ function useShouldDisplayNavigationItem(item: NavigationItemType) {
trpc: {},
}
);
const flags = useFlagMap();
if (isKeyInObject(item.name, flags)) return flags[item.name];
return !requiredCredentialNavigationItems.includes(item.name) || routingForms?.isInstalled;
}
+6
View File
@@ -0,0 +1,6 @@
const base = require("@calcom/config/tailwind-preset");
module.exports = {
...base,
content: [...base.content, "/**/*.{js,ts,jsx,tsx}"],
};
+25 -37
View File
@@ -1,11 +1,9 @@
import Image from "next/image";
import type { ReactNode } from "react";
import { classNames } from "@calcom/lib";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen } from "@calcom/ui";
import { FiUsers } from "@calcom/ui/components/icon";
export function UpgradeTip({
dark,
@@ -39,44 +37,34 @@ export function UpgradeTip({
if (isParentLoading || isLoading) return <>{isParentLoading}</>;
if (IS_SELF_HOSTED)
return (
<EmptyScreen
Icon={FiUsers}
headline={emptyTitle ? t(emptyTitle) : t(title)}
description={emptyDescription ? t(emptyDescription) : t(description)}
buttonRaw={buttons}
/>
);
return (
<>
<div className="-mt-10 rtl:ml-4 sm:mt-0 md:rtl:ml-0 lg:-mt-6">
<div
className="flex w-full justify-between overflow-hidden rounded-lg pt-4 pb-10 md:min-h-[295px] md:pt-10"
style={{
background: `url(${background})`,
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
}}>
<div className="mt-3 px-8 sm:px-14">
<h1 className={classNames("font-cal text-3xl", dark && "text-white")}>{t(title)}</h1>
<p className={classNames("my-4 max-w-sm", dark ? "text-white" : "text-gray-600")}>
{t(description)}
</p>
{buttons}
</div>
<div className="relative flex min-h-[295px] w-full items-center justify-between overflow-hidden rounded-lg pb-10">
<Image
alt={title}
src={background}
className="absolute min-h-[295px] w-full rounded-lg object-cover"
height={295}
width={1118}
quality={100}
/>
<div className="relative mt-4 px-8 sm:px-14">
<h1 className={classNames("font-cal text-3xl", dark && "text-white")}>{t(title)}</h1>
<p className={classNames("mt-4 mb-8 max-w-sm", dark ? "text-white" : "text-gray-700")}>
{t(description)}
</p>
{buttons}
</div>
</div>
<div className="mt-4 grid-cols-3 md:grid md:gap-4">
{features.map((feature) => (
<div key={feature.title} className="mb-4 min-h-[180px] w-full rounded-md bg-gray-50 p-8 md:mb-0">
{feature.icon}
<h2 className="font-cal mt-4 text-lg">{feature.title}</h2>
<p className="text-gray-700">{feature.description}</p>
</div>
))}
</div>
<div className="mt-4 grid-cols-3 md:grid md:gap-4">
{features.map((feature) => (
<div key={feature.title} className="mb-4 min-h-[180px] w-full rounded-md bg-gray-50 p-8 md:mb-0">
{feature.icon}
<h2 className="font-cal mt-4 text-lg">{feature.title}</h2>
<p className="text-gray-700">{feature.description}</p>
</div>
))}
</div>
</>
);
@@ -1,11 +1,17 @@
import type z from "zod";
import type { bookingResponse } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import type { CalendarEvent } from "@calcom/types/Calendar";
export default function getLabelValueMapFromResponses(calEvent: CalendarEvent) {
const { customInputs, userFieldsResponses } = calEvent;
let labelValueMap: Record<string, string | string[]> = {};
let labelValueMap: Record<string, z.infer<typeof bookingResponse>> = {};
if (userFieldsResponses) {
for (const [, value] of Object.entries(userFieldsResponses)) {
if (!value.label) {
continue;
}
labelValueMap[value.label] = value.value;
}
} else {
+1
View File
@@ -0,0 +1 @@
export const isKeyInObject = <T extends object>(k: PropertyKey, o: T): k is keyof T => k in o;
-25
View File
@@ -1,25 +0,0 @@
import DOMPurify from "dompurify";
import { JSDOM } from "jsdom";
import { md } from "@calcom/lib/markdownIt";
export function markdownAndSanitize(markdown: string | null) {
if (!markdown) return null;
const window = new JSDOM("").window;
// @ts-expect-error as suggested here: https://github.com/cure53/DOMPurify/issues/437#issuecomment-632021941
const purify = DOMPurify(window);
const html = md
.render(markdown)
.replaceAll(
"<ul>",
"<ul style='list-style-type: disc; list-style-position: inside; margin-left: 12px; margin-bottom: 4px'>"
)
.replaceAll(
"<ol>",
"<ol style='list-style-type: decimal; list-style-position: inside; margin-left: 12px; margin-bottom: 4px'>"
);
const safeHtml = purify.sanitize(html);
return safeHtml;
}
@@ -1,21 +0,0 @@
import DOMPurify from "dompurify";
import { md } from "./markdownIt";
export function markdownAndSanitize(markdown: string | null) {
if (!markdown) return "";
const html = md
.render(markdown)
.replaceAll(
"<ul>",
"<ul style='list-style-type: disc; list-style-position: inside; margin-left: 12px; margin-bottom: 4px'>"
)
.replaceAll(
"<ol>",
"<ol style='list-style-type: decimal; list-style-position: inside; margin-left: 12px; margin-bottom: 4px'>"
);
const safeHtml = DOMPurify.sanitize(html);
return safeHtml;
}
+9
View File
@@ -1,3 +1,12 @@
import MarkdownIt from "markdown-it";
export const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
export function addListFormatting(html: string) {
return html
.replaceAll("<ul>", "<ul style='list-style-type: disc; list-style-position: inside; margin-left: 12px'>")
.replaceAll(
"<ol>",
"<ol style='list-style-type: decimal; list-style-position: inside; margin-left: 12px'>"
);
}
-1
View File
@@ -4,7 +4,6 @@ export const sanitizeCalendarObject = (obj: DAVObject) => {
return obj.data
.replaceAll("\r\n", "\r")
.replaceAll("\r", "\r\n")
.replaceAll(/(: \r\n|:\r\n|\r\n:|\r\n :)/gm, ":")
.replaceAll(/(; \r\n|;\r\n|\r\n;|\r\n ;)/gm, ";")
.replaceAll(/(= \r\n|=\r\n|\r\n=|\r\n =)/gm, "=");
};
@@ -0,0 +1,20 @@
-- CreateEnum
CREATE TYPE "FeatureType" AS ENUM ('RELEASE', 'EXPERIMENT', 'OPERATIONAL', 'KILL_SWITCH', 'PERMISSION');
-- CreateTable
CREATE TABLE "Feature" (
"slug" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"description" TEXT,
"type" "FeatureType" DEFAULT 'RELEASE',
"stale" BOOLEAN DEFAULT false,
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
"updatedBy" INTEGER,
CONSTRAINT "Feature_pkey" PRIMARY KEY ("slug")
);
-- CreateIndex
CREATE UNIQUE INDEX "Feature_slug_key" ON "Feature"("slug");
@@ -0,0 +1,28 @@
-- Insert initial feature flags with their default values
INSERT INTO
"Feature" (slug, enabled, description, "type")
VALUES
(
'emails',
false,
'Enable to prevent any emails being send',
'KILL_SWITCH'
),
(
'workflows',
true,
'Enable workflows for this instance',
'OPERATIONAL'
),
(
'teams',
true,
'Enable teams for this instance',
'OPERATIONAL'
),
(
'webhooks',
true,
'Enable webhooks for this instance',
'OPERATIONAL'
) ON CONFLICT (slug) DO NOTHING;
+2 -1
View File
@@ -16,7 +16,8 @@
"generate-schemas": "prisma generate && prisma format",
"post-install": "yarn generate-schemas",
"seed-app-store": "ts-node --transpile-only ./seed-app-store.ts",
"delete-app": "ts-node --transpile-only ./delete-app.ts"
"delete-app": "ts-node --transpile-only ./delete-app.ts",
"seed-analytics": "ts-node --transpile-only ./seed-analytics.ts"
},
"devDependencies": {
"npm-run-all": "^4.1.5"
+26
View File
@@ -77,6 +77,7 @@ model EventType {
recurringEvent Json?
disableGuests Boolean @default(false)
hideCalendarNotes Boolean @default(false)
/// @zod.min(0)
minimumBookingNotice Int @default(120)
beforeEventBuffer Int @default(0)
afterEventBuffer Int @default(0)
@@ -685,3 +686,28 @@ model VerifiedNumber {
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
phoneNumber String
}
model Feature {
// The feature slug, ex: 'v2-workflows'
slug String @id @unique
// If the feature is currently enabled
enabled Boolean @default(false)
// A short description of the feature
description String?
// The type of feature flag
type FeatureType? @default(RELEASE)
// If the flag is considered stale
stale Boolean? @default(false)
lastUsedAt DateTime?
createdAt DateTime? @default(now())
updatedAt DateTime? @default(now()) @updatedAt
updatedBy Int?
}
enum FeatureType {
RELEASE
EXPERIMENT
OPERATIONAL
KILL_SWITCH
PERMISSION
}

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