feat: dynamic group links + flexible schedule (#9931)
* redirect user to /[user]/[type] for dynamic booking in /[user] * feat: dynamic group links * fix: type errors * fix: more type errors * Booker components cannot rely on useRouter * Rename 15min/30/60 to dynamic * Fixed a blocking test, unclear what it was waiting for --------- Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
co-authored by
Alex van Andel
parent
829a103f6b
commit
94b4507901
+32
-116
@@ -13,12 +13,7 @@ import {
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
|
||||
import defaultEvents, {
|
||||
getDynamicEventDescription,
|
||||
getGroupName,
|
||||
getUsernameList,
|
||||
getUsernameSlugLink,
|
||||
} from "@calcom/lib/defaultEvents";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
@@ -26,7 +21,7 @@ import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Avatar, AvatarGroup, HeadSeo } from "@calcom/ui";
|
||||
import { Avatar, HeadSeo } from "@calcom/ui";
|
||||
import { Verified, ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -38,16 +33,7 @@ import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type UserPageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
export function UserPage(props: UserPageProps) {
|
||||
const {
|
||||
users,
|
||||
profile,
|
||||
eventTypes,
|
||||
isDynamicGroup,
|
||||
dynamicNames,
|
||||
dynamicUsernames,
|
||||
isSingleUser,
|
||||
markdownStrippedBio,
|
||||
} = props;
|
||||
const { users, profile, eventTypes, isSingleUser, markdownStrippedBio } = props;
|
||||
const [user] = users; //To be used when we only have a single user, not dynamic group
|
||||
useTheme(user.theme);
|
||||
const { t } = useLocale();
|
||||
@@ -55,47 +41,6 @@ export function UserPage(props: UserPageProps) {
|
||||
|
||||
const isBioEmpty = !user.bio || !user.bio.replace("<p><br></p>", "").length;
|
||||
|
||||
const groupEventTypes = props.users.some((user) => !user.allowDynamicBooking) ? (
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border ">
|
||||
<div className="text-muted p-8 text-center">
|
||||
<h2 className="font-cal text-default mb-2 text-3xl">{" " + t("unavailable")}</h2>
|
||||
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled") as string}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{eventTypes.map((type, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className=" border-subtle bg-default dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b first:rounded-t-md last:rounded-b-md last:border-b-0">
|
||||
<ArrowRight className="text-emphasis absolute right-3 top-3 h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
<Link
|
||||
href={getUsernameSlugLink({ users: props.users, slug: type.slug })}
|
||||
className="flex justify-between px-6 py-4"
|
||||
data-testid="event-type-link">
|
||||
<div className="flex-shrink">
|
||||
<p className=" text-emphasis text-sm font-semibold">{type.title}</p>
|
||||
<EventTypeDescription className="text-sm" eventType={type} />
|
||||
</div>
|
||||
<div className="mt-1 self-center">
|
||||
<AvatarGroup
|
||||
truncateAfter={4}
|
||||
className="flex flex-shrink-0"
|
||||
size="sm"
|
||||
items={props.users.map((user) => ({
|
||||
alt: user.name || "",
|
||||
image: user.avatar,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
const isEmbed = useIsEmbed(props.isEmbed);
|
||||
const eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem");
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
@@ -117,14 +62,12 @@ export function UserPage(props: UserPageProps) {
|
||||
return (
|
||||
<>
|
||||
<HeadSeo
|
||||
title={isDynamicGroup ? dynamicNames.join(", ") : nameOrUsername}
|
||||
description={isDynamicGroup ? `Book events with ${dynamicUsernames.join(", ")}` : markdownStrippedBio}
|
||||
title={nameOrUsername}
|
||||
description={markdownStrippedBio}
|
||||
meeting={{
|
||||
title: isDynamicGroup ? "" : markdownStrippedBio,
|
||||
title: markdownStrippedBio,
|
||||
profile: { name: `${profile.name}`, image: null },
|
||||
users: isDynamicGroup
|
||||
? dynamicUsernames.map((username, index) => ({ username, name: dynamicNames[index] }))
|
||||
: [{ username: `${user.username}`, name: `${user.name}` }],
|
||||
users: [{ username: `${user.username}`, name: `${user.name}` }],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -165,8 +108,6 @@ export function UserPage(props: UserPageProps) {
|
||||
<p className="mx-auto max-w-md">{t("user_away_description") as string}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isDynamicGroup ? ( //When we deal with dynamic group (users > 1)
|
||||
groupEventTypes
|
||||
) : (
|
||||
eventTypes.map((type) => (
|
||||
<div
|
||||
@@ -288,6 +229,21 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
},
|
||||
});
|
||||
|
||||
const isDynamicGroup = usersWithoutAvatar.length > 1;
|
||||
if (isDynamicGroup) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/${usernameList.join("+")}/dynamic`,
|
||||
},
|
||||
} as {
|
||||
redirect: {
|
||||
permanent: false;
|
||||
destination: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const users = usersWithoutAvatar.map((user) => ({
|
||||
...user,
|
||||
avatar: `/${user.username}/avatar.png`,
|
||||
@@ -300,45 +256,18 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
notFound: true;
|
||||
};
|
||||
}
|
||||
const isDynamicGroup = users.length > 1;
|
||||
|
||||
if (isDynamicGroup) {
|
||||
// sort and be in the same order as usernameList so first user is the first user in the list
|
||||
users.sort((a, b) => {
|
||||
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
|
||||
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
const dynamicNames = isDynamicGroup
|
||||
? users.map((user) => {
|
||||
return user.name || "";
|
||||
})
|
||||
: [];
|
||||
const [user] = users; //to be used when dealing with single user, not dynamic group
|
||||
|
||||
const profile = isDynamicGroup
|
||||
? {
|
||||
name: getGroupName(dynamicNames),
|
||||
image: null,
|
||||
theme: null,
|
||||
weekStart: "Sunday",
|
||||
brandColor: "",
|
||||
darkBrandColor: "",
|
||||
allowDynamicBooking: !users.some((user) => {
|
||||
return !user.allowDynamicBooking;
|
||||
}),
|
||||
}
|
||||
: {
|
||||
name: user.name || user.username,
|
||||
image: user.avatar,
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
};
|
||||
const profile = {
|
||||
name: user.name || user.username,
|
||||
image: user.avatar,
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
};
|
||||
|
||||
const eventTypesWithHidden = isDynamicGroup ? [] : await getEventTypesWithHiddenFromDB(user.id);
|
||||
const eventTypesWithHidden = await getEventTypesWithHiddenFromDB(user.id);
|
||||
const dataFetchEnd = Date.now();
|
||||
if (context.query.log === "1") {
|
||||
context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`);
|
||||
@@ -352,11 +281,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
}));
|
||||
|
||||
const isSingleUser = users.length === 1;
|
||||
const dynamicUsernames = isDynamicGroup
|
||||
? users.map((user) => {
|
||||
return user.username || "";
|
||||
})
|
||||
: [];
|
||||
|
||||
const safeBio = markdownToSafeHTML(user.bio) || "";
|
||||
|
||||
@@ -368,20 +292,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
safeBio,
|
||||
profile,
|
||||
// Dynamic group has no theme preference right now. It uses system theme.
|
||||
themeBasis: isDynamicGroup ? null : user.username,
|
||||
themeBasis: user.username,
|
||||
user: {
|
||||
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
|
||||
},
|
||||
eventTypes: isDynamicGroup
|
||||
? defaultEvents.map((event) => {
|
||||
event.description = getDynamicEventDescription(dynamicUsernames, event.slug);
|
||||
return event;
|
||||
})
|
||||
: eventTypes,
|
||||
eventTypes,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isDynamicGroup,
|
||||
dynamicNames,
|
||||
dynamicUsernames,
|
||||
isSingleUser,
|
||||
markdownStrippedBio,
|
||||
},
|
||||
|
||||
@@ -236,10 +236,7 @@ test.describe("Routing Forms", () => {
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto(`/apps/routing-forms/reporting/${routingForm.id}`);
|
||||
// Can't keep waiting forever. So, added a timeout of 5000ms
|
||||
await page.waitForResponse((response) => response.url().includes("appRoutingForms/report"), {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const headerEls = page.locator("[data-testid='reporting-header'] th");
|
||||
// Once the response is there, React would soon render it, so 500ms is enough
|
||||
// FIXME: Sometimes it takes more than 500ms, so added a timeout of 1000ms for now. There might be something wrong with rendering.
|
||||
|
||||
@@ -14,17 +14,23 @@ export const EventDuration = ({ event }: { event: PublicEvent }) => {
|
||||
state.setSelectedDuration,
|
||||
]);
|
||||
|
||||
const isDynamicEvent = "isDynamic" in event && event.isDynamic;
|
||||
|
||||
// Sets initial value of selected duration to the default duration.
|
||||
useEffect(() => {
|
||||
// Only store event duration in url if event has multiple durations.
|
||||
if (!selectedDuration && event.metadata?.multipleDuration) setSelectedDuration(event.length);
|
||||
}, [selectedDuration, setSelectedDuration, event.length, event.metadata?.multipleDuration]);
|
||||
if (!selectedDuration && (event.metadata?.multipleDuration || isDynamicEvent))
|
||||
setSelectedDuration(event.length);
|
||||
}, [selectedDuration, setSelectedDuration, event.metadata?.multipleDuration, event.length, isDynamicEvent]);
|
||||
|
||||
if (!event?.metadata?.multipleDuration) return <>{t("multiple_duration_mins", { count: event.length })}</>;
|
||||
if (!event?.metadata?.multipleDuration && !isDynamicEvent)
|
||||
return <>{t("multiple_duration_mins", { count: event.length })}</>;
|
||||
|
||||
const durations = event?.metadata?.multipleDuration || [15, 30, 60];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.metadata.multipleDuration.map((duration) => (
|
||||
{durations.map((duration) => (
|
||||
<Badge
|
||||
variant="gray"
|
||||
className={classNames(selectedDuration === duration && "bg-brand-default text-brand")}
|
||||
|
||||
@@ -106,38 +106,18 @@ const commons = {
|
||||
}),
|
||||
};
|
||||
|
||||
const min15Event = {
|
||||
length: 15,
|
||||
slug: "15",
|
||||
title: "15min",
|
||||
eventName: "Dynamic Collective 15min Event",
|
||||
description: "Dynamic Collective 15min Event",
|
||||
descriptionAsSafeHTML: "Dynamic Collective 15min Event",
|
||||
const dynamicEvent = {
|
||||
length: 30,
|
||||
slug: "dynamic",
|
||||
title: "Dynamic",
|
||||
eventName: "Dynamic Event",
|
||||
description: "",
|
||||
descriptionAsSafeHTML: "",
|
||||
position: 0,
|
||||
...commons,
|
||||
};
|
||||
const min30Event = {
|
||||
length: 30,
|
||||
slug: "30",
|
||||
title: "30min",
|
||||
eventName: "Dynamic Collective 30min Event",
|
||||
description: "Dynamic Collective 30min Event",
|
||||
descriptionAsSafeHTML: "Dynamic Collective 30min Event",
|
||||
position: 1,
|
||||
...commons,
|
||||
};
|
||||
const min60Event = {
|
||||
length: 60,
|
||||
slug: "60",
|
||||
title: "60min",
|
||||
eventName: "Dynamic Collective 60min Event",
|
||||
description: "Dynamic Collective 60min Event",
|
||||
descriptionAsSafeHTML: "Dynamic Collective 60min Event",
|
||||
position: 2,
|
||||
...commons,
|
||||
};
|
||||
|
||||
const defaultEvents = [min15Event, min30Event, min60Event];
|
||||
const defaultEvents = [dynamicEvent];
|
||||
|
||||
export const getDynamicEventDescription = (dynamicUsernames: string[], slug: string): string => {
|
||||
return `Book a ${slug} min event with ${dynamicUsernames.join(", ")}`;
|
||||
@@ -152,7 +132,7 @@ export const getDefaultEvent = (slug: string) => {
|
||||
const event = defaultEvents.find((obj) => {
|
||||
return obj.slug === slug;
|
||||
});
|
||||
return event || min15Event;
|
||||
return event || dynamicEvent;
|
||||
};
|
||||
|
||||
export const getGroupName = (usernameList: string[]): string => {
|
||||
|
||||
Reference in New Issue
Block a user