2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
import type { DehydratedState } from "@tanstack/react-query";
import type { GetServerSideProps } from "next";
import { encode } from "querystring";
import type { z } from "zod";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { getEventTypesPublic } from "@calcom/lib/event-types/getEventTypesPublic";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { UserProfile } from "@calcom/types/UserProfile";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import type { EmbedProps } from "@lib/withEmbedSsr";
import { ssrInit } from "@server/lib/ssr";
const log = logger.getSubLogger({ prefix: ["[[pages/[user]]]"] });
export type UserPageProps = {
trpcState: DehydratedState;
profile: {
name: string;
image: string;
theme: string | null;
brandColor: string;
darkBrandColor: string;
organization: {
requestedSlug: string | null;
slug: string | null;
id: number | null;
} | null;
allowSEOIndexing: boolean;
username: string | null;
};
users: (Pick<User, "name" | "username" | "bio" | "verified" | "avatarUrl"> & {
profile: UserProfile;
})[];
themeBasis: string | null;
markdownStrippedBio: string;
safeBio: string;
entity: {
logoUrl?: string | null;
considerUnpublished: boolean;
orgSlug?: string | null;
name?: string | null;
teamSlug?: string | null;
};
eventTypes: ({
descriptionAsSafeHTML: string;
metadata: z.infer<typeof EventTypeMetaDataSchema>;
} & Pick<
EventType,
| "id"
| "title"
| "slug"
| "length"
| "hidden"
| "lockTimeZoneToggleOnBookingPage"
| "requiresConfirmation"
| "requiresBookerEmailVerification"
| "price"
| "currency"
| "recurringEvent"
>)[];
} & EmbedProps;
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const usernameList = getUsernameList(context.query.user as string);
const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
const isOrgContext = isValidOrgDomain && !!currentOrgDomain;
const dataFetchStart = Date.now();
if (!isOrgContext) {
// If there is no org context, see if some redirect is setup due to org migration
const redirect = await getTemporaryOrgRedirect({
slugs: usernameList,
redirectType: RedirectType.User,
eventTypeSlug: null,
currentQuery: context.query,
});
if (redirect) {
return redirect;
}
}
const usersInOrgContext = await UserRepository.findUsersByUsername({
usernameList,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
const isDynamicGroup = usersInOrgContext.length > 1;
log.debug(safeStringify({ usersInOrgContext, isValidOrgDomain, currentOrgDomain, isDynamicGroup }));
if (isDynamicGroup) {
const destinationUrl = `/${usernameList.join("+")}/dynamic`;
const originalQueryString = new URLSearchParams(context.query as Record<string, string>).toString();
const destinationWithQuery = `${destinationUrl}?${originalQueryString}`;
log.debug(`Dynamic group detected, redirecting to ${destinationUrl}`);
return {
redirect: {
permanent: false,
destination: destinationWithQuery,
},
} as const;
}
const isNonOrgUser = (user: { profile: UserProfile }) => {
return !user.profile?.organization;
};
const isThereAnyNonOrgUser = usersInOrgContext.some(isNonOrgUser);
if (!usersInOrgContext.length || (!isValidOrgDomain && !isThereAnyNonOrgUser)) {
return {
notFound: true,
} as const;
}
const [user] = usersInOrgContext; //to be used when dealing with single user, not dynamic group
const profile = {
name: user.name || user.username || "",
image: getUserAvatarUrl({
avatarUrl: user.avatarUrl,
}),
theme: user.theme,
brandColor: user.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
avatarUrl: user.avatarUrl,
darkBrandColor: user.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
allowSEOIndexing: user.allowSEOIndexing ?? true,
username: user.username,
organization: user.profile.organization,
};
const dataFetchEnd = Date.now();
if (context.query.log === "1") {
context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`);
}
const eventTypes = await getEventTypesPublic(user.id);
// if profile only has one public event-type, redirect to it
if (eventTypes.length === 1 && context.query.redirect !== "false") {
// Redirect but don't change the URL
const urlDestination = `/${user.profile.username}/${eventTypes[0].slug}`;
const { query } = context;
const urlQuery = new URLSearchParams(encode(query));
return {
redirect: {
permanent: false,
destination: `${urlDestination}?${urlQuery}`,
},
};
}
const safeBio = markdownToSafeHTML(user.bio) || "";
const markdownStrippedBio = stripMarkdown(user?.bio || "");
const org = usersInOrgContext[0].profile.organization;
return {
props: {
users: usersInOrgContext.map((user) => ({
name: user.name,
username: user.username,
bio: user.bio,
avatarUrl: user.avatarUrl,
verified: user.verified,
profile: user.profile,
})),
entity: {
...(org?.logoUrl ? { logoUrl: org?.logoUrl } : {}),
considerUnpublished: !isARedirectFromNonOrgLink && org?.slug === null,
orgSlug: currentOrgDomain,
name: org?.name ?? null,
},
eventTypes,
safeBio,
profile,
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: user.username,
trpcState: ssr.dehydrate(),
markdownStrippedBio,
},
};
};

View File

@@ -0,0 +1,102 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { HeadSeo } from "@calcom/ui";
import UserPage from "./users-public-view";
function mockedUserPageComponentProps(props: Partial<React.ComponentProps<typeof UserPage>>) {
return {
trpcState: {
mutations: [],
queries: [],
},
themeBasis: "dark",
safeBio: "My Bio",
profile: {
name: "John Doe",
image: "john-profile-url",
theme: "dark",
brandColor: "red",
darkBrandColor: "black",
organization: { requestedSlug: "slug", slug: "slug", id: 1 },
allowSEOIndexing: true,
username: "john",
},
users: [
{
name: "John Doe",
username: "john",
avatarUrl: "john-user-url",
bio: "",
verified: false,
profile: {
upId: "1",
id: 1,
username: "john",
organizationId: null,
organization: null,
},
},
],
markdownStrippedBio: "My Bio",
entity: {
considerUnpublished: false,
...(props.entity ?? null),
},
eventTypes: [],
} satisfies React.ComponentProps<typeof UserPage>;
}
describe("UserPage Component", () => {
it("should render HeadSeo with correct props", () => {
const mockData = {
props: mockedUserPageComponentProps({
entity: {
considerUnpublished: false,
orgSlug: "org1",
},
}),
};
vi.mocked(getOrgFullOrigin).mockImplementation((orgSlug: string | null) => {
return `${orgSlug}.cal.local`;
});
vi.mocked(useRouterQuery).mockReturnValue({
uid: "uid",
});
render(<UserPage {...mockData.props} />);
const expectedDescription = mockData.props.markdownStrippedBio;
const expectedTitle = expectedDescription;
expect(HeadSeo).toHaveBeenCalledWith(
{
origin: `${mockData.props.entity.orgSlug}.cal.local`,
title: `${mockData.props.profile.name}`,
description: expectedDescription,
meeting: {
profile: {
name: mockData.props.profile.name,
image: mockData.props.users[0].avatarUrl,
},
title: expectedTitle,
users: [
{
name: mockData.props.users[0].name,
username: mockData.props.users[0].username,
},
],
},
nextSeoProps: {
nofollow: !mockData.props.profile.allowSEOIndexing,
noindex: !mockData.props.profile.allowSEOIndexing,
},
},
{}
);
});
});

View File

@@ -0,0 +1,163 @@
"use client";
import classNames from "classnames";
import type { InferGetServerSidePropsType } from "next";
import Link from "next/link";
import { Toaster } from "react-hot-toast";
import {
sdkActionManager,
useEmbedNonStylesConfig,
useEmbedStyles,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { getOrgFullOrigin } 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 { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
import { HeadSeo, Icon, UnpublishedEntity, UserAvatar } from "@calcom/ui";
import { type getServerSideProps } from "./users-public-view.getServerSideProps";
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
const [user] = users; //To be used when we only have a single user, not dynamic group
useTheme(profile.theme);
const isBioEmpty = !user.bio || !user.bio.replace("<p><br></p>", "").length;
const isEmbed = useIsEmbed(props.isEmbed);
const eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem");
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
const {
// So it doesn't display in the Link (and make tests fail)
user: _user,
orgSlug: _orgSlug,
redirect: _redirect,
...query
} = useRouterQuery();
/*
const telemetry = useTelemetry();
useEffect(() => {
if (top !== window) {
//page_view will be collected automatically by _middleware.ts
telemetry.event(telemetryEventTypes.embedView, collectPageParameters("/[user]"));
}
}, [telemetry, router.asPath]); */
if (entity.considerUnpublished) {
return (
<div className="flex h-full min-h-[calc(100dvh)] items-center justify-center">
<UnpublishedEntity {...entity} />
</div>
);
}
const isEventListEmpty = eventTypes.length === 0;
const isOrg = !!user?.profile?.organization;
return (
<>
<HeadSeo
origin={getOrgFullOrigin(entity.orgSlug ?? null)}
title={profile.name}
description={markdownStrippedBio}
meeting={{
title: markdownStrippedBio,
profile: { name: `${profile.name}`, image: user.avatarUrl || null },
users: [{ username: `${user.username}`, name: `${user.name}` }],
}}
nextSeoProps={{
noindex: !profile.allowSEOIndexing,
nofollow: !profile.allowSEOIndexing,
}}
/>
<div className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "max-w-3xl" : "")}>
<main
className={classNames(
shouldAlignCentrally ? "mx-auto" : "",
isEmbed ? "border-booker border-booker-width bg-default rounded-md" : "",
"max-w-3xl px-4 py-24"
)}>
<div className="mb-8 text-center">
<UserAvatar
size="xl"
user={{
avatarUrl: user.avatarUrl,
profile: user.profile,
name: profile.name,
username: profile.username,
}}
/>
<h1 className="font-cal text-emphasis my-1 text-3xl" data-testid="name-title">
{profile.name}
{!isOrg && user.verified && (
<Icon
name="badge-check"
className="mx-1 -mt-1 inline h-6 w-6 fill-blue-500 text-white dark:text-black"
/>
)}
{isOrg && (
<Icon
name="badge-check"
className="mx-1 -mt-1 inline h-6 w-6 fill-yellow-500 text-white dark:text-black"
/>
)}
</h1>
{!isBioEmpty && (
<>
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: props.safeBio }}
/>
</>
)}
</div>
<div
className={classNames("rounded-md ", !isEventListEmpty && "border-subtle border")}
data-testid="event-types">
{eventTypes.map((type) => (
<Link
key={type.id}
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
prefetch={false}
href={{
pathname: `/${user.profile.username}/${type.slug}`,
query,
}}
passHref
onClick={async () => {
sdkActionManager?.fire("eventTypeSelected", {
eventType: type,
});
}}
className="bg-default border-subtle dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0"
data-testid="event-type-link">
<Icon
name="arrow-right"
className="text-emphasis absolute right-4 top-4 h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */}
<div className="block w-full p-5">
<div className="flex flex-wrap items-center">
<h2 className="text-default pr-2 text-sm font-semibold">{type.title}</h2>
</div>
<EventTypeDescription eventType={type} isPublic={true} shortenDescription />
</div>
</Link>
))}
</div>
{isEventListEmpty && <EmptyPage name={profile.name || "User"} />}
</main>
<Toaster position="bottom-right" />
</div>
</>
);
}
export default UserPage;

View File

@@ -0,0 +1,270 @@
import type { DehydratedState } from "@tanstack/react-query";
import { type GetServerSidePropsContext } from "next";
import type { Session } from "next-auth";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
import { type EmbedProps } from "@lib/withEmbedSsr";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
type Props = {
eventData: Pick<
NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>,
"id" | "length" | "metadata" | "entity"
>;
booking?: GetBookingType;
rescheduleUid: string | null;
bookingUid: string | null;
user: string;
slug: string;
trpcState: DehydratedState;
isBrandingHidden: boolean;
isSEOIndexable: boolean | null;
themeBasis: null | string;
orgBannerUrl: null;
};
async function processReschedule({
props,
rescheduleUid,
session,
}: {
props: Props;
session: Session | null;
rescheduleUid: string | string[] | undefined;
}) {
if (!rescheduleUid) return;
const booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
// if no booking found, no eventTypeId (dynamic) or it matches this eventData - return void (success).
if (booking === null || !booking.eventTypeId || booking?.eventTypeId === props.eventData?.id) {
props.booking = booking;
props.rescheduleUid = Array.isArray(rescheduleUid) ? rescheduleUid[0] : rescheduleUid;
return;
}
// handle redirect response
const redirectEventTypeTarget = await prisma.eventType.findUnique({
where: {
id: booking.eventTypeId,
},
select: {
slug: true,
},
});
if (!redirectEventTypeTarget) {
return {
notFound: true,
} as const;
}
return {
redirect: {
permanent: false,
destination: redirectEventTypeTarget.slug,
},
};
}
async function processSeatedEvent({
props,
bookingUid,
}: {
props: Props;
bookingUid: string | string[] | undefined;
}) {
if (!bookingUid) return;
props.booking = await getBookingForSeatedEvent(`${bookingUid}`);
props.bookingUid = Array.isArray(bookingUid) ? bookingUid[0] : bookingUid;
}
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context);
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const { rescheduleUid, bookingUid } = context.query;
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const org = isValidOrgDomain ? currentOrgDomain : null;
if (!org) {
const redirect = await getTemporaryOrgRedirect({
slugs: usernames,
redirectType: RedirectType.User,
eventTypeSlug: slug,
currentQuery: context.query,
});
if (redirect) {
return redirect;
}
}
const usersInOrgContext = await UserRepository.findUsersByUsername({
usernameList: usernames,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
const users = usersInOrgContext;
if (!users.length) {
return {
notFound: true,
} as const;
}
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({
username: usernames.join("+"),
eventSlug: slug,
org,
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
});
if (!eventData) {
return {
notFound: true,
} as const;
}
const props: Props = {
eventData: {
id: eventData.id,
entity: eventData.entity,
length: eventData.length,
metadata: {
...eventData.metadata,
multipleDuration: [15, 30, 60],
},
},
user: usernames.join("+"),
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: false,
isSEOIndexable: true,
themeBasis: null,
bookingUid: bookingUid ? `${bookingUid}` : null,
rescheduleUid: null,
orgBannerUrl: null,
};
if (rescheduleUid) {
const processRescheduleResult = await processReschedule({ props, rescheduleUid, session });
if (processRescheduleResult) {
return processRescheduleResult;
}
} else if (bookingUid) {
await processSeatedEvent({ props, bookingUid });
}
return {
props,
};
}
async function getUserPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context);
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const username = usernames[0];
const { rescheduleUid, bookingUid } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slugs: usernames,
redirectType: RedirectType.User,
eventTypeSlug: slug,
currentQuery: context.query,
});
if (redirect) {
return redirect;
}
}
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const [user] = await UserRepository.findUsersByUsername({
usernameList: [username],
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
if (!user) {
return {
notFound: true,
} as const;
}
const org = isValidOrgDomain ? currentOrgDomain : null;
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we can show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({
username,
eventSlug: slug,
org,
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
});
if (!eventData) {
return {
notFound: true,
} as const;
}
const props: Props = {
eventData: {
id: eventData.id,
entity: eventData.entity,
length: eventData.length,
metadata: eventData.metadata,
},
user: username,
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
isSEOIndexable: user?.allowSEOIndexing,
themeBasis: username,
bookingUid: bookingUid ? `${bookingUid}` : null,
rescheduleUid: null,
orgBannerUrl: eventData?.owner?.profile?.organization?.bannerUrl ?? null,
};
if (rescheduleUid) {
const processRescheduleResult = await processReschedule({ props, rescheduleUid, session });
if (processRescheduleResult) {
return processRescheduleResult;
}
} else if (bookingUid) {
await processSeatedEvent({ props, bookingUid });
}
return {
props,
};
}
const paramsSchema = z.object({
type: z.string().transform((s) => slugify(s)),
user: z.string().transform((s) => getUsernameList(s)),
});
// Booker page fetches a tiny bit of data server side, to determine early
// whether the page should show an away state or dynamic booking not allowed.
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { user } = paramsSchema.parse(context.params);
const isDynamicGroup = user.length > 1;
return isDynamicGroup ? await getDynamicGroupPageProps(context) : await getUserPageProps(context);
};

View File

@@ -0,0 +1,64 @@
"use client";
import { useSearchParams } from "next/navigation";
import { Booker } from "@calcom/atoms/monorepo";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { type PageProps } from "./users-type-public-view.getServerSideProps";
export const getMultipleDurationValue = (
multipleDurationConfig: number[] | undefined,
queryDuration: string | string[] | null | undefined,
defaultValue: number
) => {
if (!multipleDurationConfig) return null;
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
return defaultValue;
};
export default function Type({
slug,
user,
isEmbed,
booking,
isBrandingHidden,
isSEOIndexable,
rescheduleUid,
eventData,
orgBannerUrl,
}: PageProps) {
const searchParams = useSearchParams();
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={rescheduleUid ?? undefined}
hideBranding={isBrandingHidden}
isSEOIndexable={isSEOIndexable ?? true}
entity={eventData.entity}
bookingData={booking}
/>
<Booker
username={user}
eventSlug={slug}
bookingData={booking}
hideBranding={isBrandingHidden}
entity={eventData.entity}
durationConfig={eventData.metadata?.multipleDuration}
orgBannerUrl={orgBannerUrl}
/* TODO: Currently unused, evaluate it is needed-
* Possible alternative approach is to have onDurationChange.
*/
duration={getMultipleDurationValue(
eventData.metadata?.multipleDuration,
searchParams?.get("duration"),
eventData.length
)}
/>
</main>
);
}