Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
605f9c61f3 | ||
|
|
564862369f | ||
|
|
be6cf4705c | ||
|
|
a2457ab619 | ||
|
|
867966dd75 | ||
|
|
08ad4c07ea | ||
|
|
78da57055e | ||
|
|
d5c3d9d967 | ||
|
|
aa0e9e6a02 | ||
|
|
cad97f1779 | ||
|
|
31ee689672 | ||
|
|
3edb6f5ffd | ||
|
|
a8825badec | ||
|
|
04c634ec4b | ||
|
|
8dd9b8d74b | ||
|
|
0365157638 | ||
|
|
a571d40958 | ||
|
|
69491d5700 | ||
|
|
20022e2cdd | ||
|
|
492858b45b | ||
|
|
b49ad8294b | ||
|
|
aa30ff7d1a | ||
|
|
cfa720a523 | ||
|
|
9ab196c30e |
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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,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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
<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 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 },
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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()}`;
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
const base = require("@calcom/config/tailwind-preset");
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
content: [...base.content, "/**/*.{js,ts,jsx,tsx}"],
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const isKeyInObject = <T extends object>(k: PropertyKey, o: T): k is keyof T => k in o;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'>"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
+28
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user