chore: app router - /team, /org, /[user] booking pages (excl. embeds) (#18186)

* update env vars

* update middleware

* remove pages router and move pages to /app

* move to /team

* update imports

* fix

* remove pages router and move org pages to /app

* wip

* fix orgSlug/user pages

* fix orgSlug/user/type pages

* remove generateMetadata from embed pages

* fix

* remove pages router for /user pages

* generateMetadata is not needed in embed pages

* remove future/org

* no layout in [user] page

* simplify

* fix

* fix OG image for [user]

* fix OG images for org/user and team/slug

* fix OG images for booking page

* fix all metadata

* use isBrandingHidden

* remove BookerSeo and its usages

* rename excludeAppNameFromTitle -> hideBranding

* remove logic for meeting type in HeadSeo

* remove BookerSeo instances from team-view and users-public-view

* create generateMeetingMetadata util and use it to reduce duplicate code

* remove BookerSeo imports

* fix spacing

* remove constructMeetingImage mock from head-seo.test

* fetch avatarUrl using user id for user page metadata

* fix test

* remove unused test cases

* index and follow must be true by default

* invert noindex/nofollow flags

* remove HeadSeo for already migrated pages and refactor prepareMetadata

* fix organization-settings.e2e.ts

* fix order

* enable parallel test execution for dynamic-booking e2e test

* fix

* + could be %2B in app router

* refactor handling logic for embeds in app router

* fix isEmbed

* fix embed-core

* remove dead code

* move embed pages back to /future

* add back embed pages in pages router

* revert some changes

* fix import type checks

* simplify

* fix

* feat: Implement generateBookingPageMetadata function for improved SEO and metadata handling across user and event pages (#18440)

- Added a new utility function `generateBookingPageMetadata` to streamline the generation of metadata for booking and user profile pages.
- Updated multiple page components to utilize the new function, enhancing SEO indexing and metadata consistency.
- Removed redundant code related to previous metadata generation methods, improving code clarity and maintainability.

* fix dirs

* Pr-review-fixes-app-router-team-pages (#18450)

* Remove unnecessary getPublicEvent call

* Remove unused variable

* Fix 404 for team page (#18451)

* Remove console log

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
Benny Joo
2025-01-03 22:26:30 +00:00
committed by GitHub
co-authored by Hariom Balhara
parent 39173900b1
commit 2f9c8cb441
60 changed files with 651 additions and 825 deletions
-1
View File
@@ -365,7 +365,6 @@ APP_ROUTER_APPS_CATEGORIES_ENABLED=0
# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
APP_ROUTER_APPS_ENABLED=0
APP_ROUTER_TEAM_ENABLED=0
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
APP_ROUTER_AUTH_LOGIN_ENABLED=0
APP_ROUTER_AUTH_LOGOUT_ENABLED=0
-1
View File
@@ -20,7 +20,6 @@ const ROUTES: [URLPattern, boolean][] = [
["/auth/error", process.env.APP_ROUTER_AUTH_ERROR_ENABLED === "1"] as const,
["/auth/platform/:path*", process.env.APP_ROUTER_AUTH_PLATFORM_ENABLED === "1"] as const,
["/auth/oauth2/:path*", process.env.APP_ROUTER_AUTH_OAUTH2_ENABLED === "1"] as const,
["/team", process.env.APP_ROUTER_TEAM_ENABLED === "1"] as const,
].map(([pathname, enabled]) => [
new URLPattern({
pathname,
@@ -1,6 +1,6 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { generateEventBookingPageMetadata } from "app/generateBookingPageMetadata";
import { WithLayout } from "app/layoutHOC";
import { headers, cookies } from "next/headers";
@@ -18,7 +18,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx);
const { booking, user: username, slug: eventSlug } = props;
const { booking, user: username, slug: eventSlug, isSEOIndexable, eventData, isBrandingHidden } = props;
const rescheduleUid = booking?.uid;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(legacyCtx.req, legacyCtx.params?.orgSlug);
@@ -31,12 +31,21 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
});
const profileName = event?.profile?.name ?? "";
const profileImage = event?.profile.image;
const title = event?.title ?? "";
return await _generateMetadata(
(t) => `${rescheduleUid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`
);
const metadata = await generateEventBookingPageMetadata({
event: {
hidden: event?.hidden ?? false,
title,
users: event?.users ?? [],
},
hideBranding: isBrandingHidden,
orgSlug: eventData?.entity.orgSlug ?? null,
isSEOIndexable: !!isSEOIndexable,
profile: { name: profileName, image: profileImage ?? "" },
isReschedule: !!rescheduleUid,
});
return metadata;
};
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
+39
View File
@@ -0,0 +1,39 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateUserProfilePageMetadata } from "app/generateBookingPageMetadata";
import { WithLayout } from "app/layoutHOC";
import { headers, cookies } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@server/lib/[user]/getServerSideProps";
import type { PageProps as LegacyPageProps } from "~/users/views/users-public-view";
import LegacyPage from "~/users/views/users-public-view";
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams));
const { profile, markdownStrippedBio, isOrgSEOIndexable, entity } = props;
const isOrg = !!profile?.organization;
const allowSEOIndexing = !!(
(!isOrg && profile.allowSEOIndexing) ||
(isOrg && isOrgSEOIndexable && profile.allowSEOIndexing)
);
return await generateUserProfilePageMetadata({
profile: {
name: profile.name,
image: profile.image ?? "",
username: profile.username ?? "",
markdownStrippedBio: markdownStrippedBio,
},
event: null,
hideBranding: false,
orgSlug: entity.orgSlug ?? null,
isSEOIndexable: allowSEOIndexing,
});
};
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
export default WithLayout({ getData, Page: LegacyPage })<"P">;
+20 -1
View File
@@ -3,7 +3,8 @@ import i18next from "i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { headers } from "next/headers";
import { constructGenericImage } from "@calcom/lib/OgImages";
import type { MeetingImageProps } from "@calcom/lib/OgImages";
import { constructGenericImage, constructMeetingImage } from "@calcom/lib/OgImages";
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG, CAL_URL } from "@calcom/lib/constants";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import { truncateOnWord } from "@calcom/lib/text";
@@ -103,3 +104,21 @@ export const _generateMetadata = async (
},
};
};
export const generateMeetingMetadata = async (
meeting: MeetingImageProps,
getTitle: (t: TFunction<string, undefined>) => string,
getDescription: (t: TFunction<string, undefined>) => string,
hideBranding?: boolean,
origin?: string
) => {
const metadata = await _generateMetadataWithoutImage(getTitle, getDescription, hideBranding, origin);
const image = SEO_IMG_OGIMG + constructMeetingImage(meeting);
return {
...metadata,
openGraph: {
...metadata.openGraph,
images: [image],
},
};
};
+3 -2
View File
@@ -17,7 +17,7 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const pageProps = await getData(legacyCtx);
const { booking, user: username, slug: eventSlug, isTeamEvent } = pageProps;
const { booking, user: username, slug: eventSlug, isTeamEvent, isBrandingHidden } = pageProps;
const rescheduleUid = booking?.uid;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(legacyCtx.req);
const org = isValidOrgDomain ? currentOrgDomain : null;
@@ -34,7 +34,8 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
const title = event?.title ?? "";
return await _generateMetadata(
(t) => `${rescheduleUid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`
(t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`,
isBrandingHidden
);
};
-27
View File
@@ -1,27 +0,0 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { headers, cookies } from "next/headers";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@server/lib/[user]/getServerSideProps";
import type { PageProps as LegacyPageProps } from "~/users/views/users-public-view";
import LegacyPage from "~/users/views/users-public-view";
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams));
const { profile, markdownStrippedBio } = props;
return await _generateMetadata(
() => profile.name,
() => markdownStrippedBio
);
};
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
export default WithLayout({ getLayout, getData, Page: LegacyPage })<"P">;
@@ -1,10 +1,9 @@
import { type PageProps } from "@pages/org/[orgSlug]/[user]/[type]";
import Page from "@pages/org/[orgSlug]/[user]/[type]/embed";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";
import { Page, type OrgTypePageProps } from "app/org/[orgSlug]/[user]/[type]/page";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
const getEmbedData = withEmbedSsrAppDir<PageProps>(getServerSideProps);
const getEmbedData = withEmbedSsrAppDir<OrgTypePageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, getData: getEmbedData, isBookingPage: true, Page });
export default WithLayout({ getLayout: null, getData: getEmbedData, isBookingPage: true, ServerPage: Page });
@@ -1,9 +0,0 @@
import Page, { type PageProps } from "@pages/org/[orgSlug]/[user]/[type]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, getData, Page });
@@ -1,8 +1,9 @@
import { getServerSideProps, type PageProps } from "@pages/org/[orgSlug]/[user]";
import Page from "@pages/org/[orgSlug]/[user]/embed";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";
import { Page, type OrgPageProps } from "app/org/[orgSlug]/[user]/page";
const getEmbedData = withEmbedSsrAppDir<PageProps>(getServerSideProps);
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/getServerSideProps";
export default WithLayout({ getLayout: null, getData: getEmbedData, isBookingPage: true, Page });
const getEmbedData = withEmbedSsrAppDir<OrgPageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, getData: getEmbedData, isBookingPage: true, ServerPage: Page });
@@ -1,7 +0,0 @@
import Page, { getServerSideProps, type PageProps } from "@pages/org/[orgSlug]/[user]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { WithLayout } from "app/layoutHOC";
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, getData, Page });
@@ -1,29 +0,0 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/getServerSideProps";
import type { PageProps } from "~/team/team-view";
import TeamPage from "~/team/team-view";
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams));
return await _generateMetadata(
(t) => props.team.name || t("nameless_team"),
(t) => props.team.name || t("nameless_team")
);
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({
Page: TeamPage,
getData,
getLayout: null,
isBookingPage: true,
})<"P">;
@@ -1,45 +0,0 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventRepository } from "@calcom/lib/server/repository/event";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
import type { PageProps } from "~/team/type-view";
import TypePage from "~/team/type-view";
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx);
const { user: username, slug: eventSlug, booking } = props;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(legacyCtx.req, legacyCtx.params?.orgSlug);
const event = await EventRepository.getPublicEvent({
username,
eventSlug,
isTeamEvent: true,
org: isValidOrgDomain ? currentOrgDomain : null,
fromRedirectOfNonOrgLink: legacyCtx.query.orgRedirection === "true",
});
const profileName = event?.profile?.name ?? "";
const title = event?.title ?? "";
return await _generateMetadata(
(t) => `${booking?.uid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${booking?.uid ? t("reschedule") : ""} ${title}`
);
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({
Page: TypePage,
getData,
getLayout: null,
isBookingPage: true,
})<"P">;
@@ -1,29 +0,0 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/getServerSideProps";
import type { PageProps } from "~/team/team-view";
import TeamPage from "~/team/team-view";
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams));
return await _generateMetadata(
(t) => props.team.name || t("nameless_team"),
(t) => props.team.name || t("nameless_team")
);
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({
Page: TeamPage,
getData,
getLayout: null,
isBookingPage: true,
})<"P">;
+151
View File
@@ -0,0 +1,151 @@
import type { TFunction } from "next-i18next";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { generateMeetingMetadata } from "./_utils";
type MetadataPayload = {
title: (t: TFunction) => string;
description: (t: TFunction) => string;
meeting: {
title: string;
profile: { name: string; image: string };
users?: { name: string; username: string }[];
};
robots: {
index: boolean;
follow: boolean;
};
};
type UserProfilePageProfile = {
name: string | null;
username: string | null;
image: string | null;
markdownStrippedBio: string | null | undefined;
};
type TeamProfilePageProfile = {
teamName: string | null;
image: string | null;
markdownStrippedBio: string | null | undefined;
};
type EventBookingPageProfile = { name: string; image: string };
type BaseMetadataArgs = {
hideBranding: boolean;
orgSlug: string | null;
isSEOIndexable: boolean;
isReschedule?: boolean;
};
type TeamProfilePageArgs = BaseMetadataArgs & {
profile: TeamProfilePageProfile;
event: null;
};
type UserProfilePageArgs = BaseMetadataArgs & {
profile: UserProfilePageProfile;
event: null;
};
type EventBookingPageArgs = BaseMetadataArgs & {
profile: EventBookingPageProfile;
event: {
title: string;
hidden: boolean;
users: { name: string | null; username: string | null }[];
};
};
export const generateEventBookingPageMetadata = async (props: EventBookingPageArgs) => {
const { profile, isReschedule, hideBranding, orgSlug, isSEOIndexable, event } = props;
const fullOrigin = getOrgFullOrigin(orgSlug);
const payload: MetadataPayload = {
title: (t) => `${isReschedule ? t("reschedule") : ""} ${event.title} | ${profile.name}`,
description: (t) => `${isReschedule ? t("reschedule") : ""} ${event.title}`,
meeting: {
title: event.title,
profile: { name: profile.name, image: profile.image },
users: event.users.map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
},
robots: {
index: !(event.hidden || !isSEOIndexable),
follow: !(event.hidden || !isSEOIndexable),
},
};
return {
...(await generateMeetingMetadata(
payload.meeting,
payload.title,
payload.description,
hideBranding,
fullOrigin
)),
robots: payload.robots,
};
};
export const generateUserProfilePageMetadata = async (props: UserProfilePageArgs) => {
const { profile, hideBranding, orgSlug, isSEOIndexable } = props;
const fullOrigin = getOrgFullOrigin(orgSlug);
const payload: MetadataPayload = {
title: () => profile.name ?? "",
description: () => profile.markdownStrippedBio ?? "",
meeting: {
title: profile.markdownStrippedBio ?? "",
profile: { name: profile.name ?? "", image: profile.image ?? "" },
users: [{ name: profile.name ?? "", username: profile.username ?? "" }],
},
robots: {
index: isSEOIndexable,
follow: isSEOIndexable,
},
};
return {
...(await generateMeetingMetadata(
payload.meeting,
payload.title,
payload.description,
hideBranding,
fullOrigin
)),
robots: payload.robots,
};
};
export const generateTeamProfilePageMetadata = async (props: TeamProfilePageArgs) => {
const { profile, hideBranding, orgSlug, isSEOIndexable } = props;
const fullOrigin = getOrgFullOrigin(orgSlug);
const payload: MetadataPayload = {
title: (t) => profile.teamName || t("nameless_team"),
description: () => profile.markdownStrippedBio ?? "",
meeting: {
title: profile.markdownStrippedBio ?? "",
profile: { name: `${profile.teamName}`, image: profile.image ?? "" },
},
robots: {
index: isSEOIndexable,
follow: isSEOIndexable,
},
};
return {
...(await generateMeetingMetadata(
payload.meeting,
payload.title,
payload.description,
hideBranding,
fullOrigin
)),
robots: payload.robots,
};
};
+4 -1
View File
@@ -26,7 +26,10 @@ export const generateMetadata = () => prepareRootMetadata();
const getInitialProps = async (url: string) => {
const { pathname, searchParams } = new URL(url);
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
const isEmbedSnippetGeneratorPath = pathname.startsWith("/event-types");
const isEmbed =
(pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null) &&
!isEmbedSnippetGeneratorPath;
const embedColorScheme = searchParams?.get("ui.color-scheme");
const req = { headers: headers(), cookies: cookies() };
@@ -0,0 +1,54 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateEventBookingPageMetadata } from "app/generateBookingPageMetadata";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
import type { PageProps as TeamTypePageProps } from "~/team/type-view";
import TeamTypePage from "~/team/type-view";
import UserTypePage from "~/users/views/users-type-public-view";
import type { PageProps as UserTypePageProps } from "~/users/views/users-type-public-view";
export type OrgTypePageProps = UserTypePageProps | TeamTypePageProps;
const getData = withAppDirSsr<OrgTypePageProps>(getServerSideProps);
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx);
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
const rescheduleUid = booking?.uid;
const profileName = eventData?.profile?.name ?? "";
const profileImage = eventData?.profile.image ?? "";
return await generateEventBookingPageMetadata({
profile: { name: profileName, image: profileImage },
event: {
title: eventData?.title ?? "",
hidden: eventData?.hidden ?? false,
users: [
...(eventData?.users || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
},
hideBranding: isBrandingHidden,
orgSlug: eventData?.entity.orgSlug ?? null,
isSEOIndexable: !!isSEOIndexable,
isReschedule: !!rescheduleUid,
});
};
export const Page = async (props: OrgTypePageProps) => {
if ((props as TeamTypePageProps)?.teamId) {
return <TeamTypePage {...(props as TeamTypePageProps)} />;
}
return <UserTypePage {...(props as UserTypePageProps)} />;
};
export default WithLayout({ getLayout: null, getData, ServerPage: Page, isBookingPage: true });
@@ -0,0 +1,64 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import {
generateTeamProfilePageMetadata,
generateUserProfilePageMetadata,
} from "app/generateBookingPageMetadata";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { getOrgOrTeamAvatar } from "@calcom/lib/defaultAvatarImage";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/getServerSideProps";
import type { PageProps as TeamPageProps } from "~/team/team-view";
import TeamPage from "~/team/team-view";
import UserPage from "~/users/views/users-public-view";
import type { PageProps as UserPageProps } from "~/users/views/users-public-view";
export type OrgPageProps = UserPageProps | TeamPageProps;
const getData = withAppDirSsr<OrgPageProps>(getServerSideProps);
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx);
if ((props as TeamPageProps)?.team) {
const { team, markdownStrippedBio, isSEOIndexable, currentOrgDomain } = props as TeamPageProps;
return await generateTeamProfilePageMetadata({
profile: {
teamName: team.name,
image: getOrgOrTeamAvatar(team),
markdownStrippedBio: markdownStrippedBio,
},
event: null,
hideBranding: false,
orgSlug: currentOrgDomain ?? null,
isSEOIndexable: !!isSEOIndexable,
});
} else {
const { profile, markdownStrippedBio, isOrgSEOIndexable, entity } = props as UserPageProps;
const isOrg = !!profile?.organization;
const allowSEOIndexing =
(!isOrg && profile.allowSEOIndexing) || (isOrg && isOrgSEOIndexable && profile.allowSEOIndexing);
return await generateUserProfilePageMetadata({
profile: { name: profile.name, username: profile.username, image: profile.image, markdownStrippedBio },
hideBranding: false,
orgSlug: entity.orgSlug ?? null,
isSEOIndexable: !!allowSEOIndexing,
event: null,
});
}
};
export const Page = async (props: OrgPageProps) => {
if ((props as TeamPageProps)?.team) {
return <TeamPage {...(props as TeamPageProps)} />;
}
return <UserPage {...(props as UserPageProps)} />;
};
export default WithLayout({ getLayout: null, getData, ServerPage: Page, isBookingPage: true });
@@ -4,7 +4,6 @@ import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { headers, cookies } from "next/headers";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventRepository } from "@calcom/lib/server/repository/event";
@@ -16,7 +15,7 @@ import Page from "~/org/[orgSlug]/instant-meeting/team/[slug]/[type]/instant-mee
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const context = buildLegacyCtx(headers(), cookies(), params, searchParams);
const { slug: eventSlug, user: username } = await getData(context);
const { slug: eventSlug, user: username, isBrandingHidden } = await getData(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const org = isValidOrgDomain ? currentOrgDomain : null;
@@ -34,9 +33,10 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
return await _generateMetadata(
() => `${title} | ${profileName}`,
() => `${title}`
() => `${title}`,
isBrandingHidden
);
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({ getLayout, getData, Page })<"P">;
export default WithLayout({ getData, Page, isBookingPage: true })<"P">;
+1
View File
@@ -0,0 +1 @@
export { default, generateMetadata } from "app/team/[slug]/page";
@@ -0,0 +1 @@
export { default, generateMetadata } from "app/team/[slug]/[type]/page";
@@ -0,0 +1 @@
export { default, generateMetadata } from "app/team/[slug]/page";
@@ -1,6 +1,6 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { generateEventBookingPageMetadata } from "app/generateBookingPageMetadata";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
@@ -15,7 +15,7 @@ import LegacyPage, { type PageProps as LegacyPageProps } from "~/team/type-view"
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx);
const { user: username, slug: eventSlug, booking } = props;
const { user: username, slug: eventSlug, booking, isSEOIndexable, eventData, isBrandingHidden } = props;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(legacyCtx.req, legacyCtx.params?.orgSlug);
const event = await EventRepository.getPublicEvent({
@@ -26,13 +26,26 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
fromRedirectOfNonOrgLink: legacyCtx.query.orgRedirection === "true",
});
const profileName = event?.profile?.name ?? "";
const title = event?.title ?? "";
return await _generateMetadata(
(t) => `${booking?.uid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${booking?.uid ? t("reschedule") : ""} ${title}`
);
return await generateEventBookingPageMetadata({
profile: {
name: event?.profile?.name ?? "",
image: event?.profile.image ?? "",
},
event: {
title: event?.title ?? "",
hidden: event?.hidden ?? false,
users: [
...(event?.users || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
},
hideBranding: isBrandingHidden,
orgSlug: eventData?.entity.orgSlug ?? null,
isSEOIndexable,
isReschedule: !!booking,
});
};
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
@@ -1,9 +1,11 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { generateTeamProfilePageMetadata } from "app/generateBookingPageMetadata";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { getOrgOrTeamAvatar } from "@calcom/lib/defaultAvatarImage";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/getServerSideProps";
@@ -11,12 +13,21 @@ import type { PageProps } from "~/team/team-view";
import LegacyPage from "~/team/team-view";
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams));
return await _generateMetadata(
(t) => props.team.name || t("nameless_team"),
(t) => props.team.name || t("nameless_team")
const { team, markdownStrippedBio, isSEOIndexable, currentOrgDomain } = await getData(
buildLegacyCtx(headers(), cookies(), params, searchParams)
);
return await generateTeamProfilePageMetadata({
profile: {
teamName: team.name,
image: getOrgOrTeamAvatar(team),
markdownStrippedBio: markdownStrippedBio,
},
event: null,
hideBranding: false,
orgSlug: currentOrgDomain ?? null,
isSEOIndexable: !!isSEOIndexable,
});
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
@@ -25,4 +36,5 @@ export default WithLayout({
Page: LegacyPage,
getData,
getLayout: null,
isBookingPage: true,
})<"P">;
@@ -1,4 +1,3 @@
import { getServerSideProps as GSSUserPage } from "@pages/[user]";
import type { GetServerSidePropsContext } from "next";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
@@ -6,6 +5,8 @@ import prisma from "@calcom/prisma";
import { getServerSideProps as GSSTeamPage } from "@lib/team/[slug]/getServerSideProps";
import { getServerSideProps as GSSUserPage } from "@server/lib/[user]/getServerSideProps";
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const team = await prisma.team.findFirst({
where: {
@@ -55,7 +55,6 @@ const getTheLastArrayElement = (value: ReadonlyArray<string> | string | undefine
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const slug = getTheLastArrayElement(context.query.slug) ?? getTheLastArrayElement(context.query.orgSlug);
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(
context.req,
context.params?.orgSlug ?? context.query?.orgSlug
+4 -1
View File
@@ -195,7 +195,10 @@ export const config = {
"/settings/:path*",
"/reschedule/:path*",
"/availability/:path*",
"/booking/:path*",
"/org/:path*",
"/team/:path*",
"/:user/:type/",
"/:user/",
],
};
@@ -2,7 +2,6 @@
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 "@lib/d/[link]/[slug]/getServerSideProps";
@@ -20,13 +19,6 @@ export default function Type({
}: PageProps) {
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
entity={entity}
/>
<Booker
username={user}
eventSlug={slug}
@@ -2,7 +2,6 @@
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 { getServerSideProps } from "@lib/org/[orgSlug]/instant-meeting/team/[slug]/[type]/getServerSideProps";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -13,15 +12,6 @@ export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
function Type({ slug, user, booking, isEmbed, isBrandingHidden, entity, eventTypeId, duration }: PageProps) {
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={undefined}
hideBranding={isBrandingHidden}
isTeamEvent
entity={entity}
bookingData={booking}
/>
<Booker
username={user}
eventSlug={slug}
+78 -97
View File
@@ -12,7 +12,6 @@ import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { getOrgOrTeamAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -20,7 +19,7 @@ import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { Avatar, Button, HeadSeo, UnpublishedEntity, UserAvatarGroup } from "@calcom/ui";
import { Avatar, Button, UnpublishedEntity, UserAvatarGroup } from "@calcom/ui";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import type { getServerSideProps } from "@lib/team/[slug]/getServerSideProps";
@@ -164,105 +163,87 @@ function TeamPage({
const profileImageSrc = getOrgOrTeamAvatar(team);
return (
<>
<HeadSeo
origin={getOrgFullOrigin(currentOrgDomain)}
title={teamName}
description={teamName}
meeting={{
title: markdownStrippedBio,
profile: {
name: `${team.name}`,
image: profileImageSrc,
},
}}
nextSeoProps={{
nofollow: !isSEOIndexable,
noindex: !isSEOIndexable,
}}
/>
<main className="dark:bg-darkgray-50 bg-subtle mx-auto max-w-3xl rounded-md px-4 pb-12 pt-12">
<div className="mx-auto mb-8 max-w-3xl text-center">
<div className="relative">
<Avatar alt={teamName} imageSrc={profileImageSrc} size="lg" />
</div>
<p className="font-cal text-emphasis mb-2 text-2xl tracking-wider" data-testid="team-name">
{team.parent && `${team.parent.name} `}
{teamName}
</p>
{!isBioEmpty && (
<>
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: team.safeBio }}
/>
</>
)}
<main className="dark:bg-darkgray-50 bg-subtle mx-auto max-w-3xl rounded-md px-4 pb-12 pt-12">
<div className="mx-auto mb-8 max-w-3xl text-center">
<div className="relative">
<Avatar alt={teamName} imageSrc={profileImageSrc} size="lg" />
</div>
{team.isOrganization ? (
!teamOrOrgIsPrivate ? (
<SubTeams />
) : (
<div className="w-full text-center">
<h2 className="text-emphasis font-semibold">{t("you_cannot_see_teams_of_org")}</h2>
</div>
)
) : (
<p className="font-cal text-emphasis mb-2 text-2xl tracking-wider" data-testid="team-name">
{team.parent && `${team.parent.name} `}
{teamName}
</p>
{!isBioEmpty && (
<>
{(showMembers.isOn || !team.eventTypes?.length) &&
(teamOrOrgIsPrivate ? (
<div className="w-full text-center">
<h2 data-testid="you-cannot-see-team-members" className="text-emphasis font-semibold">
{t("you_cannot_see_team_members")}
</h2>
</div>
) : (
<Team members={team.members} teamName={team.name} />
))}
{!showMembers.isOn && team.eventTypes && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl ">
<EventTypes eventTypes={team.eventTypes} />
{/* Hide "Book a team member button when team is private or hideBookATeamMember is true" */}
{!team.hideBookATeamMember && !teamOrOrgIsPrivate && (
<div>
<div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="border-subtle w-full border-t" />
</div>
<div className="relative flex justify-center">
<span className="dark:bg-darkgray-50 bg-subtle text-subtle px-2 text-sm">
{t("or")}
</span>
</div>
</div>
<aside className="dark:text-inverted mt-8 flex justify-center text-center">
<Button
color="minimal"
EndIcon="arrow-right"
data-testid="book-a-team-member-btn"
className="dark:hover:bg-darkgray-200"
href={{
pathname: `${isValidOrgDomain ? "" : "/team"}/${team.slug}`,
query: {
...queryParamsToForward,
members: "1",
},
}}
shallow={true}>
{t("book_a_team_member")}
</Button>
</aside>
</div>
)}
</div>
)}
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: team.safeBio }}
/>
</>
)}
</main>
</>
</div>
{team.isOrganization ? (
!teamOrOrgIsPrivate ? (
<SubTeams />
) : (
<div className="w-full text-center">
<h2 className="text-emphasis font-semibold">{t("you_cannot_see_teams_of_org")}</h2>
</div>
)
) : (
<>
{(showMembers.isOn || !team.eventTypes?.length) &&
(teamOrOrgIsPrivate ? (
<div className="w-full text-center">
<h2 data-testid="you-cannot-see-team-members" className="text-emphasis font-semibold">
{t("you_cannot_see_team_members")}
</h2>
</div>
) : (
<Team members={team.members} teamName={team.name} />
))}
{!showMembers.isOn && team.eventTypes && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl ">
<EventTypes eventTypes={team.eventTypes} />
{/* Hide "Book a team member button when team is private or hideBookATeamMember is true" */}
{!team.hideBookATeamMember && !teamOrOrgIsPrivate && (
<div>
<div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="border-subtle w-full border-t" />
</div>
<div className="relative flex justify-center">
<span className="dark:bg-darkgray-50 bg-subtle text-subtle px-2 text-sm">
{t("or")}
</span>
</div>
</div>
<aside className="dark:text-inverted mt-8 flex justify-center text-center">
<Button
color="minimal"
EndIcon="arrow-right"
data-testid="book-a-team-member-btn"
className="dark:hover:bg-darkgray-200"
href={{
pathname: `${isValidOrgDomain ? "" : "/team"}/${team.slug}`,
query: {
...queryParamsToForward,
members: "1",
},
}}
shallow={true}>
{t("book_a_team_member")}
</Button>
</aside>
</div>
)}
</div>
)}
</>
)}
</main>
);
}
+1 -24
View File
@@ -1,14 +1,13 @@
"use client";
import type { EmbedProps } from "app/WithEmbedSSR";
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 { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "app/WithEmbedSSR";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
@@ -34,33 +33,11 @@ function Type({
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
isSEOIndexable,
}: PageProps) {
const searchParams = useSearchParams();
const { profile, users, hidden, title } = eventData;
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
isTeamEvent
eventData={
profile && users && title && hidden !== undefined
? {
profile,
users,
title,
hidden,
}
: undefined
}
entity={eventData.entity}
bookingData={booking}
isSEOIndexable={isSEOIndexable}
/>
<Booker
username={user}
eventSlug={slug}
@@ -1,9 +1,8 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { describe, 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";
@@ -71,33 +70,5 @@ describe("UserPage Component", () => {
});
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,
},
},
{}
);
});
});
@@ -11,12 +11,11 @@ import {
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 { Icon, UnpublishedEntity, UserAvatar } from "@calcom/ui";
import type { getServerSideProps } from "@server/lib/[user]/getServerSideProps";
@@ -60,112 +59,89 @@ export function UserPage(props: PageProps) {
const isEventListEmpty = eventTypes.length === 0;
const isOrg = !!user?.profile?.organization;
const allowSEOIndexing = isOrg
? isOrgSEOIndexable
? profile.allowSEOIndexing
: false
: profile.allowSEOIndexing;
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: !allowSEOIndexing,
nofollow: !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"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: props.safeBio }}
/>
</>
<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"
/>
)}
</div>
{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"
// eslint-disable-next-line react/no-danger
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
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>
</Link>
))}
</div>
<EventTypeDescription eventType={type} isPublic={true} shortenDescription />
</div>
</Link>
))}
</div>
{isEventListEmpty && <EmptyPage name={profile.name || "User"} />}
</main>
<Toaster position="bottom-right" />
</div>
</>
{isEventListEmpty && <EmptyPage name={profile.name || "User"} />}
</main>
<Toaster position="bottom-right" />
</div>
);
}
UserPage.isBookingPage = true;
@@ -1,13 +1,12 @@
"use client";
import type { EmbedProps } from "app/WithEmbedSSR";
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 { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "app/WithEmbedSSR";
import type { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps";
@@ -23,40 +22,11 @@ export const getMultipleDurationValue = (
return defaultValue;
};
function Type({
slug,
user,
isEmbed,
booking,
isBrandingHidden,
isSEOIndexable,
rescheduleUid,
eventData,
orgBannerUrl,
}: PageProps) {
function Type({ slug, user, isEmbed, booking, isBrandingHidden, eventData, orgBannerUrl }: PageProps) {
const searchParams = useSearchParams();
const { profile, users, hidden, title } = eventData;
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={rescheduleUid ?? undefined}
hideBranding={isBrandingHidden}
isSEOIndexable={isSEOIndexable ?? true}
eventData={
profile && users && title && hidden !== undefined
? {
profile,
users,
title,
hidden,
}
: undefined
}
entity={eventData.entity}
bookingData={booking}
/>
<Booker
username={user}
eventSlug={slug}
+10 -1
View File
@@ -1,7 +1,16 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
import { getServerSideProps as _getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps";
import type { PageProps } from "~/users/views/users-type-public-view";
import TypePage from "~/users/views/users-type-public-view";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
export { default } from "./index";
const Type = (props: PageProps) => <TypePage {...props} />;
Type.PageWrapper = PageWrapper;
export default Type;
-12
View File
@@ -1,12 +0,0 @@
import PageWrapper from "@components/PageWrapper";
import type { PageProps } from "~/users/views/users-type-public-view";
import TypePage from "~/users/views/users-type-public-view";
export { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps";
const Type = (props: PageProps) => <TypePage {...props} />;
Type.PageWrapper = PageWrapper;
export default Type;
+17 -4
View File
@@ -1,6 +1,19 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
import { getServerSideProps as _getServerSideProps } from "@server/lib/[user]/getServerSideProps";
import type { PageProps as TeamPageProps } from "~/team/team-view";
import TeamPage from "~/team/team-view";
import UserPage from "~/users/views/users-public-view";
import type { PageProps as UserPageProps } from "~/users/views/users-public-view";
export { default } from "./index";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
export { getServerSideProps } from "@lib/org/[orgSlug]/[user]/getServerSideProps";
export type PageProps = UserPageProps | TeamPageProps;
function Page(props: PageProps) {
if ((props as TeamPageProps)?.team) return <TeamPage {...(props as TeamPageProps)} />;
return <UserPage {...(props as UserPageProps)} />;
}
Page.PageWrapper = PageWrapper;
export default Page;
-11
View File
@@ -1,11 +0,0 @@
import PageWrapper from "@components/PageWrapper";
import User, { type PageProps } from "~/users/views/users-public-view";
export { getServerSideProps } from "@server/lib/[user]/getServerSideProps";
const UserPage = (props: PageProps) => <User {...props} />;
UserPage.PageWrapper = PageWrapper;
export default UserPage;
@@ -1,7 +1,20 @@
import { getServerSideProps as _getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from ".";
import PageWrapper from "@components/PageWrapper";
export { default, type PageProps } from ".";
import type { PageProps as TeamTypePageProps } from "~/team/type-view";
import TeamTypePage from "~/team/type-view";
import UserTypePage from "~/users/views/users-type-public-view";
import type { PageProps as UserTypePageProps } from "~/users/views/users-type-public-view";
export type PageProps = UserTypePageProps | TeamTypePageProps;
export default function Page(props: PageProps) {
if ((props as TeamTypePageProps)?.teamId) return <TeamTypePage {...(props as TeamTypePageProps)} />;
return <UserTypePage {...(props as UserTypePageProps)} />;
}
Page.PageWrapper = PageWrapper;
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
@@ -1,17 +0,0 @@
import PageWrapper from "@components/PageWrapper";
import type { PageProps as TeamTypePageProps } from "~/team/type-view";
import TeamTypePage from "~/team/type-view";
import UserTypePage from "~/users/views/users-type-public-view";
import type { PageProps as UserTypePageProps } from "~/users/views/users-type-public-view";
export { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
export type PageProps = UserTypePageProps | TeamTypePageProps;
export default function Page(props: PageProps) {
if ((props as TeamTypePageProps)?.teamId) return <TeamTypePage {...(props as TeamTypePageProps)} />;
return <UserTypePage {...(props as UserTypePageProps)} />;
}
Page.PageWrapper = PageWrapper;
@@ -1,9 +1 @@
"use client";
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[user]";
export { default } from "../[user]";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
export { default, getServerSideProps } from "@pages/[user]/embed";
@@ -1,21 +0,0 @@
"use client";
import PageWrapper from "@components/PageWrapper";
import type { PageProps as TeamPageProps } from "~/team/team-view";
import TeamPage from "~/team/team-view";
import UserPage from "~/users/views/users-public-view";
import type { PageProps as UserPageProps } from "~/users/views/users-public-view";
export { getServerSideProps } from "@lib/org/[orgSlug]/[user]/getServerSideProps";
export type PageProps = UserPageProps | TeamPageProps;
function Page(props: PageProps) {
if ((props as TeamPageProps)?.team) return <TeamPage {...(props as TeamPageProps)} />;
return <UserPage {...(props as UserPageProps)} />;
}
Page.PageWrapper = PageWrapper;
export default Page;
+9 -2
View File
@@ -1,7 +1,14 @@
import { getServerSideProps as _getServerSideProps } from "@lib/team/[slug]/getServerSideProps";
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "./index";
import PageWrapper from "@components/PageWrapper";
export { default } from "./index";
import TeamPage, { type PageProps } from "~/team/team-view";
const Page = (props: PageProps) => <TeamPage {...props} />;
Page.PageWrapper = PageWrapper;
export default Page;
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
-1
View File
@@ -1 +0,0 @@
export { getServerSideProps, default } from "@pages/team/[slug]";
@@ -1,10 +0,0 @@
import PageWrapper from "@components/PageWrapper";
import type { PageProps } from "~/org/[orgSlug]/instant-meeting/team/[slug]/[type]/instant-meeting-view";
import Type from "~/org/[orgSlug]/instant-meeting/team/[slug]/[type]/instant-meeting-view";
const Page = (props: PageProps) => <Type {...props} />;
Page.PageWrapper = PageWrapper;
export { getServerSideProps } from "@lib/org/[orgSlug]/instant-meeting/team/[slug]/[type]/getServerSideProps";
export default Page;
@@ -1 +0,0 @@
export { getServerSideProps, default } from "@pages/team/[slug]/[type]";
@@ -1 +0,0 @@
export { getServerSideProps, default } from "@pages/team/[slug]";
-10
View File
@@ -1,10 +0,0 @@
import PageWrapper from "@components/PageWrapper";
import TeamPage, { type PageProps } from "~/team/team-view";
const Page = (props: PageProps) => <TeamPage {...props} />;
Page.PageWrapper = PageWrapper;
export default Page;
export { getServerSideProps } from "@lib/team/[slug]/getServerSideProps";
-10
View File
@@ -1,10 +0,0 @@
import PageWrapper from "@components/PageWrapper";
import TypePage, { type PageProps } from "~/team/type-view";
const Page = (props: PageProps) => <TypePage {...props} />;
Page.PageWrapper = PageWrapper;
export default Page;
export { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
@@ -11,6 +11,8 @@ import {
selectSecondAvailableTimeSlotNextMonth,
} from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test("dynamic booking", async ({ page, users }) => {
@@ -66,7 +66,7 @@ async function verifyRobotsMetaTag({ page, orgSlug, urls, expectedContent }: Ver
await doOnOrgDomain({ orgSlug, page }, async ({ page, goToUrlWithErrorHandling }) => {
for (const relativeUrl of urls) {
const { url } = await goToUrlWithErrorHandling(relativeUrl);
const metaTag = await page.locator('head > meta[name="robots"]');
const metaTag = await page.locator('head > meta[name="robots"]').first();
const metaTagValue = await metaTag.getAttribute("content");
expect(metaTagValue).toEqual(expectedContent);
}
@@ -103,7 +103,7 @@ test.describe("Organization Settings", () => {
`/${orgMember.username}`,
`/${orgMember.username}/${userEvent.slug}`,
],
expectedContent: "noindex,nofollow",
expectedContent: "noindex, nofollow",
});
});
});
@@ -130,7 +130,7 @@ test.describe("Organization Settings", () => {
`/${orgMember.username}`,
`/${orgMember.username}/${userEvent.slug}`,
],
expectedContent: "index,follow",
expectedContent: "index, follow",
});
});
});
@@ -148,7 +148,7 @@ test.describe("Organization Settings", () => {
page,
orgSlug: org.slug,
urls: [`/${orgMember.username}/${userEvent.slug}`],
expectedContent: "noindex,nofollow",
expectedContent: "noindex, nofollow",
});
});
});
@@ -20,14 +20,12 @@ checkRoute "$APP_ROUTER_AUTH_SAML_ENABLED" app/future/auth/saml-idp
checkRoute "$APP_ROUTER_AUTH_ERROR_ENABLED" app/future/auth/error
checkRoute "$APP_ROUTER_AUTH_PLATFORM_ENABLED" app/future/auth/platform
checkRoute "$APP_ROUTER_AUTH_OAUTH2_ENABLED" app/future/auth/oauth2
checkRoute "$APP_ROUTER_TEAM_ENABLED" app/future/team
# These are routes that don't have and environment variable to enable or disable them
# Will stop removing gradually as we test and confirm that they are working
rm -rf \
app/future/d\
app/future/enterprise\
app/future/org\
app/future/payment\
app/future/reschedule\
app/future/routing-forms\
@@ -25,7 +25,7 @@ type Props = {
"profile" | "users"
> & {
profile: {
image: string | undefined;
image: string | null;
name: string | null;
username: string | null;
};
@@ -158,7 +158,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
multipleDuration: [15, 30, 45, 60, 90],
},
profile: {
image: eventData.profile.image,
image: eventData.profile.image ?? null,
name: eventData.profile.name ?? null,
username: eventData.profile.username ?? null,
},
@@ -254,7 +254,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
length: eventData.length,
metadata: eventData.metadata,
profile: {
image: eventData.profile.image,
image: eventData.profile.image ?? null,
name: eventData.profile.name ?? null,
username: eventData.profile.username ?? null,
},
@@ -1,94 +0,0 @@
import { render } from "@testing-library/react";
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import { trpc } from "@calcom/trpc/react";
import { HeadSeo } from "@calcom/ui";
import { BookerSeo } from "./BookerSeo";
// Mocking necessary modules and hooks
vi.mock("@calcom/trpc/react", () => ({
trpc: {
viewer: {
public: {
event: {
useQuery: vi.fn(),
},
},
},
},
}));
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: () => ({ t: (key: string) => key }),
}));
vi.mock("@calcom/ee/organizations/lib/orgDomains", () => ({
getOrgFullOrigin: vi.fn(),
}));
vi.mock("@calcom/ui", () => ({
HeadSeo: vi.fn(),
}));
describe("BookerSeo Component", () => {
it("renders HeadSeo with correct props", () => {
const mockData = {
event: {
slug: "event-slug",
profile: { name: "John Doe", image: "image-url", username: "john" },
title: "30min",
hidden: false,
users: [{ name: "Jane Doe", username: "jane" }],
},
entity: { fromRedirectOfNonOrgLink: false, orgSlug: "org1" },
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
trpc.viewer.public.event.useQuery.mockReturnValueOnce({
data: mockData.event,
});
vi.mocked(getOrgFullOrigin).mockImplementation((text: string | null) => `${text}.cal.local`);
render(
<BookerSeo
username={mockData.event.profile.username}
eventSlug={mockData.event.slug}
rescheduleUid={undefined}
entity={mockData.entity}
/>
);
expect(HeadSeo).toHaveBeenCalledWith(
{
origin: `${mockData.entity.orgSlug}.cal.local`,
isBrandingHidden: undefined,
// Don't know why we are adding space in the beginning
title: ` ${mockData.event.title} | ${mockData.event.profile.name}`,
description: ` ${mockData.event.title}`,
meeting: {
profile: {
name: mockData.event.profile.name,
image: mockData.event.profile.image,
},
title: mockData.event.title,
users: [
{
name: mockData.event.users[0].name,
username: mockData.event.users[0].username,
},
],
},
nextSeoProps: {
nofollow: true,
noindex: true,
},
},
{}
);
});
});
@@ -1,88 +0,0 @@
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { HeadSeo } from "@calcom/ui";
interface BookerSeoProps {
username: string;
eventSlug: string;
rescheduleUid: string | undefined;
hideBranding?: boolean;
isSEOIndexable?: boolean;
isTeamEvent?: boolean;
eventData?: Omit<
Pick<NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>, "profile" | "title" | "users" | "hidden">,
"profile" | "users"
> & {
profile: {
image: string | undefined;
name: string | null;
username: string | null;
};
users: {
username: string;
name: string;
}[];
};
entity: {
fromRedirectOfNonOrgLink: boolean;
orgSlug?: string | null;
teamSlug?: string | null;
name?: string | null;
};
bookingData?: GetBookingType | null;
}
export const BookerSeo = (props: BookerSeoProps) => {
const {
eventSlug,
username,
rescheduleUid,
hideBranding,
isTeamEvent,
entity,
isSEOIndexable,
bookingData,
eventData,
} = props;
const { t } = useLocale();
const { data: _event } = trpc.viewer.public.event.useQuery(
{
username,
eventSlug,
isTeamEvent,
org: entity.orgSlug ?? null,
fromRedirectOfNonOrgLink: entity.fromRedirectOfNonOrgLink,
},
{ refetchOnWindowFocus: false, enabled: !eventData }
);
const event = eventData ?? _event;
const profileName = event?.profile.name ?? "";
const profileImage = event?.profile.image;
const title = event?.title ?? "";
return (
<HeadSeo
origin={getOrgFullOrigin(entity.orgSlug ?? null)}
title={`${rescheduleUid && !!bookingData ? t("reschedule") : ""} ${title} | ${profileName}`}
description={`${rescheduleUid ? t("reschedule") : ""} ${title}`}
meeting={{
title: title,
profile: { name: profileName, image: profileImage },
users: [
...(event?.users || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
}}
nextSeoProps={{
nofollow: event?.hidden || !isSEOIndexable,
noindex: event?.hidden || !isSEOIndexable,
}}
isBrandingHidden={hideBranding}
/>
);
};
+1 -1
View File
@@ -178,7 +178,7 @@ export const getUsernameList = (users: string | string[] | undefined): string[]
// So, even though this code handles even if individual user is dynamic link, that isn't a possibility right now.
users = arrayCast(users);
const allUsers = users.map((user) => user.replace(/( |%20|%2b)/g, "+").split("+")).flat();
const allUsers = users.map((user) => user.replace(/( |%20|%2b)/gi, "+").split("+")).flat();
return Array.prototype.concat(...allUsers.map((userSlug) => slugify(userSlug)));
};
+13
View File
@@ -800,6 +800,19 @@ export class UserRepository {
return withSelectedCalendars(user);
}
static async getAvatarUrl(id: number) {
const user = await prisma.user.findUnique({
where: { id },
select: { avatarUrl: true },
});
if (!user?.avatarUrl) {
return null;
}
return user.avatarUrl;
}
static async findForAvailabilityCheck({ where }: { where: Prisma.UserWhereInput }) {
const user = await prisma.user.findFirst({
where,
+2 -15
View File
@@ -2,8 +2,8 @@ import type { NextSeoProps } from "next-seo";
import { NextSeo } from "next-seo";
import { usePathname } from "next/navigation";
import type { AppImageProps, MeetingImageProps } from "@calcom/lib/OgImages";
import { constructAppImage, constructGenericImage, constructMeetingImage } from "@calcom/lib/OgImages";
import type { AppImageProps } from "@calcom/lib/OgImages";
import { constructAppImage, constructGenericImage } from "@calcom/lib/OgImages";
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
import { buildCanonical, getSeoImage, seoConfig } from "@calcom/lib/next-seo.config";
import { truncateOnWord } from "@calcom/lib/text";
@@ -16,7 +16,6 @@ export type HeadSeoProps = {
canonical?: string;
nextSeoProps?: NextSeoProps;
app?: AppImageProps;
meeting?: MeetingImageProps;
isBrandingHidden?: boolean;
origin?: string;
};
@@ -83,7 +82,6 @@ export const HeadSeo = (props: HeadSeoProps): JSX.Element => {
canonical = defaultUrl,
nextSeoProps = {},
app,
meeting,
isBrandingHidden,
} = props;
@@ -98,17 +96,6 @@ export const HeadSeo = (props: HeadSeoProps): JSX.Element => {
siteName,
});
if (meeting) {
const pageImage = getSeoImage("ogImage") + constructMeetingImage(meeting);
seoObject = buildSeoMeta({
title: pageTitle,
description: truncatedDescription,
image: pageImage,
canonical,
siteName,
});
}
if (app) {
const pageImage =
getSeoImage("ogImage") + constructAppImage({ ...app, description: truncatedDescription });
@@ -45,9 +45,6 @@ vi.mock("@calcom/lib/OgImages", async () => {
constructGenericImage() {
return "constructGenericImage";
},
constructMeetingImage() {
return "constructMeetingImage";
},
};
});
@@ -149,12 +146,4 @@ describe("Tests for HeadSeo component", () => {
expect(mockedNextSeoEl?.getAttribute("image")).toContain("constructAppImage");
});
});
test("Should render with meeting props", async () => {
const { container } = render(<HeadSeo {...basicProps} meeting={{} as HeadSeoProps["meeting"]} />);
await waitFor(async () => {
const mockedNextSeoEl = container.querySelector("#mocked-next-seo");
expect(mockedNextSeoEl?.getAttribute("image")).toContain("constructMeetingImage");
});
});
});
-1
View File
@@ -252,7 +252,6 @@
"APP_ROUTER_AUTH_ERROR_ENABLED",
"APP_ROUTER_AUTH_PLATFORM_ENABLED",
"APP_ROUTER_AUTH_OAUTH2_ENABLED",
"APP_ROUTER_TEAM_ENABLED",
"APP_USER_NAME",
"BASECAMP3_CLIENT_ID",
"BASECAMP3_CLIENT_SECRET",