feat: add upgrade banners for teams and organizations (#27650)

This commit is contained in:
Eunjae Lee
2026-03-05 18:19:01 +09:00
committed by GitHub
parent c197de147a
commit b7340f7151
74 changed files with 1834 additions and 500 deletions
@@ -1,13 +1,10 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import type { PageProps as ServerPageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { ServerTeamsListing } from "./server-page";
export const generateMetadata = async () =>
@@ -37,10 +34,14 @@ const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
}
const t = await getTranslate();
const { Main, CTA } = await ServerTeamsListing({ searchParams, session });
const { Main, CTA, showHeader } = await ServerTeamsListing({ searchParams, session });
return (
<ShellMainAppDir CTA={CTA} heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
<ShellMainAppDir
CTA={showHeader ? CTA : null}
heading={showHeader ? t("teams") : undefined}
subtitle={showHeader ? t("create_manage_teams_collaborative") : undefined}
flexChildrenContainer={!showHeader}>
{Main}
</ShellMainAppDir>
);
@@ -1,16 +1,13 @@
import type { SearchParams } from "app/_types";
import type { Session } from "next-auth";
import { unstable_cache } from "next/cache";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { ErrorWithCode } from "@calcom/lib/errors";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { SearchParams } from "app/_types";
import { unstable_cache } from "next/cache";
import type { Session } from "next-auth";
import { TeamsListing } from "~/ee/teams/components/TeamsListing";
import { TeamsCTA } from "./CTA";
const getCachedTeams = unstable_cache(
@@ -31,7 +28,11 @@ export const ServerTeamsListing = async ({
}: {
searchParams: SearchParams;
session: Session;
}) => {
}): Promise<{
Main: JSX.Element;
CTA: JSX.Element | null;
showHeader: boolean;
}> => {
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
const autoAccept = Array.isArray(searchParams?.autoAccept)
? searchParams.autoAccept[0]
@@ -60,6 +61,15 @@ export const ServerTeamsListing = async ({
const userProfile = session?.user?.profile;
const orgId = userProfile?.organizationId ?? session?.user.org?.id;
// Filter to get accepted non-organization teams (same logic as TeamsListing)
const acceptedTeams = teams.filter((m) => m.accepted && !m.isOrganization);
// Check if user has a team plan (any accepted team with a slug)
const hasTeamPlan = teams.some((team) => team.accepted === true && team.slug !== null);
// Show header unless we're showing the upgrade banner (no teams and no team plan)
const showHeader = acceptedTeams.length > 0 || hasTeamPlan;
const permissionCheckService = new PermissionCheckService();
const canCreateTeam = orgId
? await permissionCheckService.checkPermission({
@@ -84,5 +94,6 @@ export const ServerTeamsListing = async ({
/>
),
CTA: !orgId || canCreateTeam ? <TeamsCTA /> : null,
showHeader,
};
};
@@ -5,14 +5,12 @@ import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-for
import { FilterResults } from "~/filters/components/FilterResults";
import { TeamsFilter } from "~/filters/components/TeamsFilter";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { ArrowButton } from "@calcom/ui/components/arrow-button";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { List, ListLinkItem } from "@calcom/ui/components/list";
@@ -26,24 +24,15 @@ import {
FormActionsDropdown,
FormActionsProvider,
} from "@calcom/web/components/apps/routing-forms/FormActions";
import {
ChartBarIcon,
CircleCheckIcon,
DownloadIcon,
FileTextIcon,
MailIcon,
ShuffleIcon,
} from "@coss/ui/icons";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import posthog from "posthog-js";
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { useHasPaidPlan } from "~/billing/hooks/useHasPaidPlan";
import { useHasPaidPlan, useHasTeamPlan } from "~/billing/hooks/useHasPaidPlan";
import LicenseRequired from "~/ee/common/components/LicenseRequired";
import { CreateButtonWithTeamsList } from "~/ee/teams/components/createButton/CreateButtonWithTeamsList";
import SkeletonLoaderTeamList from "~/ee/teams/components/SkeletonloaderTeamList";
import { ShellMain } from "~/shell/Shell";
import { UpgradeTip } from "~/shell/UpgradeTip";
function NewFormButton({ setNewFormDialogState }: { setNewFormDialogState: SetNewFormDialogState }) {
const { t } = useLocale();
@@ -66,6 +55,7 @@ function NewFormButton({ setNewFormDialogState }: { setNewFormDialogState: SetNe
export default function RoutingForms({ appUrl }: { appUrl: string }) {
const { t } = useLocale();
const { hasPaidPlan } = useHasPaidPlan();
const { hasTeamPlan, isPending: isPendingTeamPlan } = useHasTeamPlan();
const routerQuery = useRouterQuery();
const hookForm = useFormContext<RoutingFormWithResponseCount>();
const utils = trpc.useUtils();
@@ -95,38 +85,6 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
const [newFormDialogState, setNewFormDialogState] = useState<NewFormDialogState>(null);
const forms = queryRes.data?.filtered;
const features = [
{
icon: <FileTextIcon className="h-5 w-5 text-orange-500" />,
title: t("create_your_first_form"),
description: t("create_your_first_form_description"),
},
{
icon: <ShuffleIcon className="h-5 w-5 text-lime-500" />,
title: t("create_your_first_route"),
description: t("route_to_the_right_person"),
},
{
icon: <ChartBarIcon className="h-5 w-5 text-blue-500" />,
title: t("reporting"),
description: t("reporting_feature"),
},
{
icon: <CircleCheckIcon className="h-5 w-5 text-teal-500" />,
title: t("test_routing_form"),
description: t("test_preview_description"),
},
{
icon: <MailIcon className="h-5 w-5 text-yellow-500" />,
title: t("routing_forms_send_email_owner"),
description: t("routing_forms_send_email_owner_description"),
},
{
icon: <DownloadIcon className="h-5 w-5 text-violet-500" />,
title: t("download_responses"),
description: t("download_responses_description"),
},
];
async function moveRoutingForm(index: number, increment: 1 | -1) {
const types = forms?.map((type) => {
@@ -162,25 +120,8 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
) : null
}
subtitle={t("routing_forms_description")}>
<UpgradeTip
plan="team"
title={t("routing_that_grows_with_you")}
description={t("routing_forms_upgrade_description")}
features={features}
background="/tips/routing-forms"
isParentLoading={<SkeletonLoaderTeamList />}
buttons={
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("get_started")}
</Button>
<Button color="minimal" href="https://cal.com/routing" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{isPendingTeamPlan && <SkeletonLoaderTeamList />}
{!isPendingTeamPlan && hasTeamPlan && (
<FormActionsProvider
appUrl={appUrl}
newFormDialogState={newFormDialogState}
@@ -340,7 +281,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
</FilterResults>
</div>
</FormActionsProvider>
</UpgradeTip>
)}
</ShellMain>
</LicenseRequired>
);
@@ -1,5 +1,9 @@
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { FullscreenUpgradeBannerForRoutingFormPage } from "@calcom/web/modules/billing/upgrade-banners/FullscreenUpgradeBannerForRoutingFormPage";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import Forms from "./Forms";
export const generateMetadata = async ({ params }: { params: Promise<{ pages: string[] }> }) => {
@@ -15,6 +19,14 @@ export const generateMetadata = async ({ params }: { params: Promise<{ pages: st
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const userId = session?.user?.id;
const hasTeamPlan = userId && (await MembershipRepository.hasAnyAcceptedMembershipByUserId(userId));
if (!hasTeamPlan) {
return <FullscreenUpgradeBannerForRoutingFormPage />;
}
return <Forms appUrl="/routing" />;
};
@@ -1,6 +1,8 @@
import { _generateMetadata } from "app/_utils";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { _generateMetadata, getTranslate } from "app/_utils";
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import InsightsCallHistoryPage from "~/insights/views/insights-call-history-view";
import Shell from "~/shell/Shell";
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
@@ -16,5 +18,16 @@ export const generateMetadata = async () =>
export default async function Page() {
await checkInsightsPagePermission();
return <InsightsCallHistoryPage />;
const t = await getTranslate();
return (
<Shell withoutMain={true}>
<ShellMainAppDir
heading={t("insights")}
subtitle={t("insights_subtitle")}
actions={<div className={`flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<InsightsCallHistoryPage />
</ShellMainAppDir>
</Shell>
);
}
@@ -0,0 +1,18 @@
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { FullscreenUpgradeBannerForInsightsPage } from "@calcom/web/modules/billing/upgrade-banners/FullscreenUpgradeBannerForInsightsPage";
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import Shell from "~/shell/Shell";
export async function getInsightsUpgradeBanner(userId: number) {
const hasMembership = await MembershipRepository.hasAnyAcceptedMembershipByUserId(userId);
if (hasMembership) return null;
return (
<Shell withoutMain={true}>
<ShellMainAppDir>
<FullscreenUpgradeBannerForInsightsPage />
</ShellMainAppDir>
</Shell>
);
}
@@ -1,23 +0,0 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import { getTranslate } from "app/_utils";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import Shell from "~/shell/Shell";
import UpgradeTipWrapper from "./UpgradeTipWrapper";
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
const t = await getTranslate();
return (
<Shell withoutMain={true}>
<ShellMainAppDir
heading={t("insights")}
subtitle={t("insights_subtitle")}
actions={<div className={`flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<UpgradeTipWrapper>{children}</UpgradeTipWrapper>
</ShellMainAppDir>
</Shell>
);
}
@@ -1,10 +1,13 @@
import { _generateMetadata } from "app/_utils";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import prisma from "@calcom/prisma";
import { _generateMetadata, getTranslate } from "app/_utils";
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import InsightsPage from "~/insights/views/insights-view";
import Shell from "~/shell/Shell";
import { checkInsightsPagePermission } from "./checkInsightsPagePermission";
import { getInsightsUpgradeBanner } from "./getInsightsUpgradeBanner";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,17 +18,26 @@ export const generateMetadata = async () =>
"/insights"
);
const ServerPage = async () => {
export default async function Page() {
const session = await checkInsightsPagePermission();
const { timeZone } = await prisma.user.findUniqueOrThrow({
where: { id: session?.user.id ?? -1 },
select: {
timeZone: true,
},
});
const banner = await getInsightsUpgradeBanner(session.user.id);
if (banner) return banner;
return <InsightsPage timeZone={timeZone} />;
};
const t = await getTranslate();
export default ServerPage;
const userRepository = new UserRepository(prisma);
const user = await userRepository.getTimeZoneAndDefaultScheduleId({ userId: session.user.id });
const timeZone = user?.timeZone ?? "UTC";
return (
<Shell withoutMain={true}>
<ShellMainAppDir
heading={t("insights")}
subtitle={t("insights_subtitle")}
actions={<div className={`flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<InsightsPage timeZone={timeZone} />
</ShellMainAppDir>
</Shell>
);
}
@@ -1,8 +1,11 @@
import { _generateMetadata } from "app/_utils";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { _generateMetadata, getTranslate } from "app/_utils";
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import InsightsVirtualQueuesPage from "~/insights/views/insights-virtual-queues-view";
import Shell from "~/shell/Shell";
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
import { getInsightsUpgradeBanner } from "../getInsightsUpgradeBanner";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,7 +17,21 @@ export const generateMetadata = async () =>
);
export default async function Page() {
await checkInsightsPagePermission();
const session = await checkInsightsPagePermission();
return <InsightsVirtualQueuesPage />;
const banner = await getInsightsUpgradeBanner(session.user.id);
if (banner) return banner;
const t = await getTranslate();
return (
<Shell withoutMain={true}>
<ShellMainAppDir
heading={t("insights")}
subtitle={t("insights_subtitle")}
actions={<div className={`flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<InsightsVirtualQueuesPage />
</ShellMainAppDir>
</Shell>
);
}
@@ -1,10 +1,13 @@
import { _generateMetadata } from "app/_utils";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
import { _generateMetadata, getTranslate } from "app/_utils";
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import InsightsRoutingPage from "~/insights/views/insights-routing-view";
import Shell from "~/shell/Shell";
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
import { getInsightsUpgradeBanner } from "../getInsightsUpgradeBanner";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -18,12 +21,23 @@ export const generateMetadata = async () =>
export default async function Page() {
const session = await checkInsightsPagePermission();
const { timeZone } = await prisma.user.findUniqueOrThrow({
where: { id: session?.user.id ?? -1 },
select: {
timeZone: true,
},
});
const banner = await getInsightsUpgradeBanner(session.user.id);
if (banner) return banner;
return <InsightsRoutingPage timeZone={timeZone} />;
const t = await getTranslate();
const userRepository = new UserRepository(prisma);
const user = await userRepository.getTimeZoneAndDefaultScheduleId({ userId: session.user.id });
const timeZone = user?.timeZone ?? "UTC";
return (
<Shell withoutMain={true}>
<ShellMainAppDir
heading={t("insights")}
subtitle={t("insights_subtitle")}
actions={<div className={`flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<InsightsRoutingPage timeZone={timeZone} />
</ShellMainAppDir>
</Shell>
);
}
@@ -1,11 +1,13 @@
import { _generateMetadata } from "app/_utils";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import prisma from "@calcom/prisma";
import { _generateMetadata, getTranslate } from "app/_utils";
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import InsightsWrongRoutingPage from "~/insights/views/insights-wrong-routing-view";
import Shell from "~/shell/Shell";
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
import { getInsightsUpgradeBanner } from "../getInsightsUpgradeBanner";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -19,8 +21,22 @@ export const generateMetadata = async () =>
export default async function Page() {
const session = await checkInsightsPagePermission();
const userRepository = new UserRepository(prisma);
const user = await userRepository.getTimeZoneAndDefaultScheduleId({ userId: session?.user.id ?? -1 });
const banner = await getInsightsUpgradeBanner(session.user.id);
if (banner) return banner;
return <InsightsWrongRoutingPage timeZone={user?.timeZone ?? "UTC"} />;
const t = await getTranslate();
const userRepository = new UserRepository(prisma);
const user = await userRepository.getTimeZoneAndDefaultScheduleId({ userId: session.user.id });
return (
<Shell withoutMain={true}>
<ShellMainAppDir
heading={t("insights")}
subtitle={t("insights_subtitle")}
actions={<div className={`flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<InsightsWrongRoutingPage timeZone={user?.timeZone ?? "UTC"} />
</ShellMainAppDir>
</Shell>
);
}
@@ -1,12 +1,11 @@
import { clearSessionCache, getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { CompanyEmailRequired } from "~/onboarding/organization/details/company-email-required-view";
import { OrganizationDetailsView } from "~/onboarding/organization/details/organization-details-view";
export const generateMetadata = async () => {
@@ -19,14 +18,25 @@ export const generateMetadata = async () => {
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const ServerPage = async (props: { searchParams: Promise<{ sessionClear?: string }> }) => {
const searchParams = await props.searchParams;
const req = buildLegacyRequest(await headers(), await cookies());
let session = await getServerSession({ req });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
if (searchParams.sessionClear) {
clearSessionCache();
session = await getServerSession({ req });
}
const userEmail = session?.user?.email || "";
if (!isCompanyEmail(userEmail)) {
return <CompanyEmailRequired userEmail={userEmail} />;
}
return <OrganizationDetailsView userEmail={userEmail} />;
};
@@ -1,5 +1,4 @@
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
@@ -14,15 +13,10 @@ export default async function OrganizationOnboardingLayout({ children }: { child
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
const userId = session.user.id;
const gettingStartedPath = await OnboardingPathService.getGettingStartedPath();
if (!isCompanyEmail(userEmail)) {
return redirect(gettingStartedPath);
}
const userRepository = new UserRepository(prisma);
const { organizations } = await userRepository.findOrganizations({ userId });
@@ -1,16 +1,8 @@
"use client";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import { useSession } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { ComponentProps } from "react";
import React, { useEffect, useState, useMemo } from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import type { OrganizationBranding } from "@calcom/features/ee/organizations/context/provider";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import {
HAS_ORG_OPT_IN_FEATURES,
HAS_TEAM_OPT_IN_FEATURES,
@@ -35,7 +27,13 @@ import { ArrowLeftIcon, ChevronDownIcon, ChevronRightIcon } from "@coss/ui/icons
import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
import { VerticalTabItem } from "@calcom/ui/components/navigation";
import { Skeleton } from "@calcom/ui/components/skeleton";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import type { ComponentProps } from "react";
import React, { useEffect, useMemo, useState } from "react";
import Shell from "~/shell/Shell";
const getTabs = (orgBranding: OrganizationBranding | null) => {
@@ -509,18 +507,34 @@ const TeamRolesNavItem = ({
feature: "pbac",
});
// Only show for sub-teams (teams with parentId) AND when parent has PBAC enabled
if (!team.parentId || !isPbacEnabled) return null;
// For sub-teams with PBAC-enabled parent org: show functional roles page
if (team.parentId && isPbacEnabled) {
return (
<VerticalTabItem
name={t("roles_and_permissions")}
href={`/settings/teams/${team.id}/roles`}
trackingMetadata={{ section: "team", page: "roles_and_permissions", teamId: team.id }}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
);
}
return (
<VerticalTabItem
name={t("roles_and_permissions")}
href={`/settings/teams/${team.id}/roles`}
trackingMetadata={{ section: "team", page: "roles_and_permissions", teamId: team.id }}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
);
// For standalone teams (not in an org): show upgrade banner page
if (!team.parentId) {
return (
<VerticalTabItem
name={t("roles_and_permissions")}
href={`/settings/teams/${team.id}/roles`}
trackingMetadata={{ section: "team", page: "roles_and_permissions", teamId: team.id }}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
);
}
return null;
};
const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, TeamFeatures> }) => {
@@ -647,7 +661,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
<TeamRolesNavItem team={team} teamFeatures={teamFeatures} />
{(checkAdminOrOwner(team.role) ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this exists wtf?
// @ts-expect-error this exists wtf?
(team.isOrgAdmin && team.isOrgAdmin)) && (
<>
{/* TODO */}
@@ -1053,6 +1067,9 @@ type SettingsLayoutProps = {
function SettingsLayoutAppDirClient({ children, teamFeatures, permissions, ...rest }: SettingsLayoutProps) {
const pathname = usePathname();
const isFullWidthPage =
pathname?.includes("/settings/teams/") &&
(pathname?.includes("/attributes") || pathname?.includes("/roles"));
const state = useState(false);
const [sideContainerOpen, setSideContainerOpen] = state;
@@ -1092,7 +1109,11 @@ function SettingsLayoutAppDirClient({ children, teamFeatures, permissions, ...re
}>
<div className="*:flex-1 flex flex-1">
<div
className={classNames("mx-auto max-w-full justify-center lg:max-w-3xl", rest.containerClassName)}>
className={classNames(
"mx-auto justify-center",
!isFullWidthPage && "max-w-full lg:max-w-3xl",
rest.containerClassName
)}>
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</div>
@@ -1,9 +1,3 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getTeamMemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
@@ -11,9 +5,14 @@ import { RoleManagementFactory } from "@calcom/features/pbac/services/role-manag
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { prisma } from "@calcom/prisma";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { WideUpgradeBannerForMembers } from "@calcom/web/modules/billing/upgrade-banners/WideUpgradeBannerForMembers";
import { TeamMembersView } from "@calcom/web/modules/ee/teams/views/team-members-view";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata(
@@ -99,6 +98,11 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
return (
<SettingsHeader title={t("team_members")} description={t("members_team_description")}>
{!team.parentId && (
<div className="mb-4">
<WideUpgradeBannerForMembers />
</div>
)}
<TeamMembersView
team={team}
facetedTeamValues={facetedTeamValues}
@@ -1,21 +1,19 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import type { AppFlags } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
import { Resource, CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import { CrudAction, Resource, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { prisma } from "@calcom/prisma";
import { FullscreenUpgradeBannerForRolesAndPermissions } from "@calcom/web/modules/billing/upgrade-banners/FullscreenUpgradeBannerForRolesAndPermissions";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { CreateRoleCTA } from "../../../organizations/roles/_components/CreateRoleCta";
import { RolesList } from "../../../organizations/roles/_components/RolesList";
import { roleSearchParamsCache } from "../../../organizations/roles/_components/searchParams";
@@ -110,9 +108,9 @@ const Page = async ({
return notFound();
}
// Check if team has a parent (is a sub-team)
// Standalone teams (not in an org): show upgrade banner
if (!team.parent?.id) {
return notFound();
return <FullscreenUpgradeBannerForRolesAndPermissions />;
}
// Check if parent team has PBAC feature enabled
+3 -4
View File
@@ -1,17 +1,16 @@
"use server";
import { revalidateTag, unstable_cache } from "next/cache";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { revalidateTag, unstable_cache } from "next/cache";
const CACHE_TAGS = {
HAS_TEAM_PLAN: "MembershipRepository.findFirstAcceptedMembershipByUserId",
HAS_TEAM_PLAN: "MembershipRepository.hasAnyAcceptedMembershipByUserId",
} as const;
export const getCachedHasTeamPlan = unstable_cache(
async (userId: number) => {
const hasTeamPlan = await MembershipRepository.findFirstAcceptedMembershipByUserId(userId);
const hasTeamPlan = await MembershipRepository.hasAnyAcceptedMembershipByUserId(userId);
return { hasTeamPlan: !!hasTeamPlan };
},
+44 -2
View File
@@ -1,4 +1,5 @@
import dayjs from "@calcom/dayjs";
import { clearSessionCache } from "@calcom/features/auth/lib/getServerSession";
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
@@ -12,6 +13,8 @@ import { z } from "zod";
const verifySchema = z.object({
token: z.string(),
makePrimary: z.string().optional(),
redirectTo: z.string().optional(),
});
const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email";
@@ -41,7 +44,7 @@ export async function moveUserToMatchingOrg({ email }: { email: string }) {
}
export async function handler(req: NextApiRequest, res: NextApiResponse) {
const { token } = verifySchema.parse(req.query);
const { token, makePrimary, redirectTo } = verifySchema.parse(req.query);
const foundToken = await prisma.verificationToken.findFirst({
where: {
@@ -69,9 +72,48 @@ export async function handler(req: NextApiRequest, res: NextApiResponse) {
},
});
if (makePrimary === "true") {
const secondaryEmail = await prisma.secondaryEmail.findUnique({
where: { id: foundToken.secondaryEmailId },
select: { userId: true, email: true },
});
if (secondaryEmail) {
const primaryUser = await prisma.user.findUnique({
where: { id: secondaryEmail.userId },
select: { id: true, email: true, emailVerified: true },
});
if (primaryUser) {
await prisma.$transaction([
prisma.user.update({
where: { id: primaryUser.id },
data: { email: secondaryEmail.email, emailVerified: new Date() },
}),
prisma.secondaryEmail.update({
where: { id: foundToken.secondaryEmailId },
data: {
email: primaryUser.email,
emailVerified: primaryUser.emailVerified,
},
}),
]);
}
}
clearSessionCache();
}
await cleanUpVerificationTokens(foundToken.id);
return res.redirect(`${WEBAPP_URL}/settings/my-account/profile`);
const isSafePath =
redirectTo?.startsWith("/") && !redirectTo.startsWith("//") && !redirectTo.startsWith("/\\");
let redirectPath = isSafePath && redirectTo ? redirectTo : "/settings/my-account/profile";
if (makePrimary === "true") {
const separator = redirectPath.includes("?") ? "&" : "?";
redirectPath = `${redirectPath}${separator}sessionClear=1`;
}
return res.redirect(`${WEBAPP_URL}${redirectPath}`);
}
const user = await prisma.user.findFirst({
@@ -0,0 +1,318 @@
"use client";
import { useFillRemainingHeight } from "@calcom/lib/hooks/useFillRemainingHeight";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { Icon } from "@calcom/ui/components/icon";
import { OrgBadge, TeamBadge } from "@calcom/web/modules/billing/components/PlanBadge";
import { UpgradePlanDialog } from "@calcom/web/modules/billing/components/UpgradePlanDialog";
import { Badge } from "@coss/ui/components/badge";
import { Button } from "@coss/ui/components/button";
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPopup,
DialogTitle,
} from "@coss/ui/components/dialog";
import Image from "next/image";
import Link from "next/link";
import posthog from "posthog-js";
import { useState } from "react";
import type { UpgradeTarget } from "./types";
export type { UpgradeTarget };
export type FullScreenUpgradeBannerProps = {
tracking: string;
name: string;
title: string;
subtitle: string;
features?: string[];
target: UpgradeTarget;
learnMoreButton?: {
text: string;
href?: string;
onClick?: () => void;
};
extraOffset?:
| number
| {
mobile?: number;
tablet?: number;
desktop?: number;
};
image: {
src: string;
width: number;
height: number;
};
youtubeId?: string;
};
function useResponsiveOffset(
extraOffset?:
| number
| {
mobile?: number;
tablet?: number;
desktop?: number;
}
) {
const isMobile = useMediaQuery("(max-width: 639px)");
const isTablet = useMediaQuery("(max-width: 767px)");
if (isMobile) {
return typeof extraOffset === "number" ? extraOffset : (extraOffset?.mobile ?? 74);
} else if (isTablet) {
return typeof extraOffset === "number" ? extraOffset : (extraOffset?.tablet ?? 85);
} else {
return typeof extraOffset === "number" ? extraOffset : (extraOffset?.desktop ?? 24);
}
}
function BannerImage({
image,
name,
youtubeId,
tracking,
target,
onPlayVideo,
}: {
image: { src: string; width: number; height: number };
name: string;
youtubeId?: string;
tracking: string;
target: UpgradeTarget;
onPlayVideo: () => void;
}): JSX.Element {
const { t } = useLocale();
return (
<>
<Image
src={image.src}
alt={name}
width={image.width}
height={image.height}
className="h-full w-full object-cover object-top"
/>
{youtubeId && (
<button
type="button"
className="absolute inset-0 flex items-center justify-center cursor-pointer"
onClick={() => {
posthog.capture("fullscreen_upgrade_banner_video_played", { source: tracking, target });
onPlayVideo();
}}>
<Image src="/play_button.svg" alt={t("play_video")} width={48} height={48} />
</button>
)}
</>
);
}
export function FullScreenUpgradeBanner({
tracking,
name,
title,
subtitle,
features,
target,
learnMoreButton,
extraOffset,
image,
youtubeId,
}: FullScreenUpgradeBannerProps): JSX.Element {
const [videoOpen, setVideoOpen] = useState(false);
const [showFeatures, setShowFeatures] = useState(false);
const deviceSpecificOffset = useResponsiveOffset(extraOffset);
const { t } = useLocale();
const ref = useFillRemainingHeight(deviceSpecificOffset);
const bannerImageProps = {
image,
name,
youtubeId,
tracking,
target,
onPlayVideo: () => setVideoOpen(true),
};
return (
<div ref={ref} className="flex w-full shrink-0 items-center justify-center rounded-xl bg-subtle p-8">
<div className="flex w-full h-full md:h-auto max-w-3xl gap-2 overflow-hidden rounded-3xl bg-default p-5 md:py-8 md:pl-8 md:pr-0 shadow-sm">
{/* Left Content */}
<div className="flex flex-1 flex-col justify-between overflow-hidden">
<div className={`${showFeatures ? "overflow-y-auto" : ""} md:overflow-visible`}>
<div>
<Badge
variant="outline"
className="text-sm text-default font-medium bg-subtle px-2 py-1 h-fit! border-0">
{name}
</Badge>
</div>
<h2 className="mt-3 font-cal font-semibold text-emphasis text-xl leading-none">{title}</h2>
<p className="mt-2 text-subtle text-sm">{subtitle}</p>
{/* Features List */}
{features && (
<>
<ul className="mt-4 space-y-2 hidden md:block">
{features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm text-subtle">
<span className="text-subtle">•</span>
{feature}
</li>
))}
</ul>
<div className="md:hidden mt-4">
{showFeatures ? (
<>
<ul className="space-y-2">
{features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm text-subtle">
<span className="text-subtle">•</span>
{feature}
</li>
))}
</ul>
<button
type="button"
className="mt-2 text-sm text-emphasis font-medium cursor-pointer underline"
onClick={() => setShowFeatures(false)}>
{t("hide")}
</button>
</>
) : (
<button
type="button"
className="text-sm text-emphasis font-medium cursor-pointer underline"
onClick={() => setShowFeatures(true)}>
{t("show_more")}
</button>
)}
</div>
</>
)}
</div>
{/* Image - mobile only, hidden when features are expanded */}
<div className={`${showFeatures ? "hidden" : ""} md:hidden my-4 flex items-center justify-center rounded-xl bg-subtle aspect-[3/4] overflow-hidden relative`}>
<BannerImage {...bannerImageProps} />
</div>
<div>
<div className="hidden md:flex items-center gap-2 mt-4">
<p className="text-sm font-medium text-subtle">{t("available_on")}</p>
{target === "team" && <TeamBadge />}
{(target === "team" || target === "organization") && <OrgBadge />}
</div>
<div className="mt-4 h-px w-full border border-t-subtle border-dashed" />
{/* Buttons */}
<div className="mt-6 flex items-center justify-between md:justify-start gap-2">
<UpgradePlanDialog
tracking={tracking}
info={{
title,
description: subtitle,
}}
target={target}>
<Button
onClick={() =>
posthog.capture("fullscreen_upgrade_banner_cta_clicked", { source: tracking, target })
}>
{t("try_for_free")}
<Icon name="arrow-right" />
</Button>
</UpgradePlanDialog>
{learnMoreButton &&
(learnMoreButton.href ? (
<Button
variant="ghost"
className="text-subtle"
onClick={() =>
posthog.capture("fullscreen_upgrade_banner_learn_more_clicked", {
source: tracking,
target,
})
}
render={<Link href={learnMoreButton.href} target="_blank" rel="noopener noreferrer" />}>
{learnMoreButton.text}
</Button>
) : (
<Button
variant="ghost"
className="text-subtle"
onClick={() => {
posthog.capture("fullscreen_upgrade_banner_learn_more_clicked", {
source: tracking,
target,
});
learnMoreButton.onClick?.();
}}>
{learnMoreButton.text}
</Button>
))}
</div>
</div>
</div>
{/* Right Content - Image */}
<div className="-my-2 hidden md:flex flex-1 items-center justify-center rounded-l-xl bg-subtle aspect-[3/4] overflow-hidden border border-muted border-r-0 relative">
<BannerImage {...bannerImageProps} />
</div>
</div>
{youtubeId && (
<Dialog open={videoOpen} onOpenChange={setVideoOpen}>
<DialogPopup className="max-w-3xl overflow-hidden">
<DialogHeader>
<DialogTitle>{name}</DialogTitle>
<DialogDescription>
{t("available_on_plan", { plan: target === "organization" ? t("organization") : t("team") })}
</DialogDescription>
</DialogHeader>
<div className="aspect-video w-full px-6 pb-6">
<iframe
src={`https://www.youtube.com/embed/${youtubeId}`}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="h-full w-full rounded-lg border border-muted"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
posthog.capture("fullscreen_upgrade_banner_video_dismissed", { source: tracking, target });
setVideoOpen(false);
}}>
{t("dismiss")}
</Button>
<UpgradePlanDialog
tracking={tracking}
info={{
title,
description: subtitle,
}}
target={target}>
<Button
onClick={() =>
posthog.capture("fullscreen_upgrade_banner_video_cta_clicked", {
source: tracking,
target,
})
}>
{t("get_started")}
<Icon name="arrow-right" />
</Button>
</UpgradePlanDialog>
</DialogFooter>
</DialogPopup>
</Dialog>
)}
</div>
);
}
@@ -0,0 +1,16 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@coss/ui/components/badge";
export function TeamBadge() {
const { t } = useLocale();
return <Badge variant="warning" className="bg-orange-200! text-orange-900">{t("teams")}</Badge>;
}
export function OrgBadge() {
const { t } = useLocale();
return (
<Badge variant="warning" className="bg-purple-200! text-purple-900">
{t("orgs")}
</Badge>
);
}
@@ -0,0 +1,303 @@
"use client";
import { BILLING_PLANS, BILLING_PRICING } from "@calcom/features/ee/billing/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/components/icon";
import { Alert, AlertDescription, AlertTitle } from "@coss/ui/components/alert";
import { Badge } from "@coss/ui/components/badge";
import { Button } from "@coss/ui/components/button";
import { Card, CardPanel } from "@coss/ui/components/card";
import {
Dialog,
DialogClose,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
DialogTrigger,
} from "@coss/ui/components/dialog";
import { Toggle, ToggleGroup } from "@coss/ui/components/toggle-group";
import Link from "next/link";
import posthog from "posthog-js";
import { useState } from "react";
type BillingPeriodToggle = "annual" | "monthly";
interface PlanFeature {
text: string;
}
interface PlanColumnProps {
name: string;
badge?: string;
price: string;
priceSubtext: string;
description: string;
features: PlanFeature[];
buttonText: string;
buttonHref: string;
buttonTarget?: string;
primaryButton?: boolean;
onCtaClick?: () => void;
}
function PlanColumn({
name,
badge,
price,
priceSubtext,
description,
features,
buttonText,
buttonHref,
buttonTarget,
primaryButton,
onCtaClick,
}: PlanColumnProps): JSX.Element {
return (
<Card className="flex-1 gap-0 rounded-xl border-subtle p-4 py-0">
<CardPanel className="px-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm text-emphasis">{name}</h3>
{badge && (
<Badge variant="outline" className="rounded-lg py-0.5 px-2 h-fit!">
{badge}
</Badge>
)}
</div>
<p className="font-cal mt-2 leading-none font-semibold text-2xl text-emphasis">{price}</p>
<p className="mt-2 leading-none font-medium text-sm text-subtle h-4">{priceSubtext}</p>
<Button
className="mt-4 w-full"
variant={primaryButton ? "default" : "outline"}
onClick={onCtaClick}
render={
<Link
href={buttonHref}
target={buttonTarget}
rel={buttonTarget === "_blank" ? "noopener noreferrer" : undefined}
/>
}>
<Icon name="circle-arrow-up" />
<span>{buttonText}</span>
</Button>
<p className="mt-4 text-sm text-subtle">{description}</p>
<ul className="mt-3 space-y-2">
{features.map((feature) => (
<li key={feature.text} className="flex items-start gap-2 text-sm">
<Icon name="dot" className="relative top-0.5 h-4 w-4 shrink-0 text-default" />
<span className="text-default">{feature.text}</span>
</li>
))}
</ul>
</CardPanel>
</Card>
);
}
export type UpgradePlanDialogProps = {
tracking: string;
target: "team" | "organization";
info?: {
title: string;
description: string;
};
children: React.ReactNode;
};
export function UpgradePlanDialog({ tracking, target, info, children }: UpgradePlanDialogProps): JSX.Element {
const { t } = useLocale();
const [billingPeriod, setBillingPeriod] = useState<"annual" | "monthly">("annual");
const { data: activeTeamPlan } = trpc.viewer.teams.hasActiveTeamPlan.useQuery();
const formatCents = (cents: number) => {
const dollars = cents / 100;
return `$${cents % 100 === 0 ? dollars : dollars.toFixed(2)}`;
};
const teamPrice = formatCents(BILLING_PRICING[BILLING_PLANS.TEAMS][billingPeriod]);
const orgPrice = formatCents(BILLING_PRICING[BILLING_PLANS.ORGANIZATIONS][billingPeriod]);
const currentPlanPricingKey = activeTeamPlan?.billingPeriod === "ANNUALLY" ? "annual" : "monthly";
const currentTeamPrice = formatCents(BILLING_PRICING[BILLING_PLANS.TEAMS][currentPlanPricingKey]);
const bpParam = billingPeriod === "annual" ? "a" : "m";
const teamHref = `/settings/teams/new?bp=${bpParam}`;
const organizationHref = `/onboarding/organization/details?migrate=true&bp=${bpParam}`;
const teamFeatures: PlanFeature[] = [
{ text: t("upgrade_feature_round_robin") },
{ text: t("upgrade_feature_collective_events") },
{ text: t("routing_forms") },
{ text: t("upgrade_feature_workflows") },
{ text: t("upgrade_feature_insights") },
{ text: t("upgrade_feature_remove_branding") },
];
const orgFeatures: PlanFeature[] = [
{ text: t("upgrade_feature_everything_in_team") },
{ text: t("unlimited_teams") },
{ text: t("upgrade_feature_verified_domain") },
{ text: t("upgrade_feature_directory_sync") },
{ text: t("upgrade_feature_sso") },
{ text: t("upgrade_feature_admin_panel") },
];
const enterpriseFeatures: PlanFeature[] = [
{ text: t("upgrade_feature_everything_in_org") },
{ text: t("upgrade_feature_dedicated_support") },
{ text: t("upgrade_feature_custom_sla") },
{ text: t("upgrade_feature_custom_integrations") },
{ text: t("upgrade_feature_compliance") },
];
return (
<Dialog>
<DialogTrigger render={children as React.ReactElement} />
<DialogPopup className="max-w-3xl" showCloseButton={false} bottomStickOnMobile={false}>
<DialogHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<DialogTitle>{t("upgrade_dialog_title")}</DialogTitle>
<div className="flex items-center gap-1 max-sm:order-last">
<ToggleGroup
value={[billingPeriod]}
onValueChange={(value): void => {
if (value.length > 0) {
const newPeriod = value[0] as BillingPeriodToggle;
setBillingPeriod(newPeriod);
posthog.capture("upgrade_plan_dialog_billing_period_changed", {
source: tracking,
target,
billingPeriod: newPeriod,
});
}
}}
className="rounded-lg bg-muted p-1"
size="sm">
<Toggle
value="annual"
className="gap-1 rounded-md data-pressed:bg-default data-pressed:shadow-sm">
{t("upgrade_billing_annual")}
<Badge variant="info" size="sm">
{t("discount_25")}
</Badge>
</Toggle>
<Toggle
value="monthly"
className="ml-1 rounded-md data-pressed:bg-default data-pressed:shadow-sm">
{t("monthly")}
</Toggle>
</ToggleGroup>
<DialogClose render={<Button size="icon" variant="ghost" />}>
<Icon name="x" />
</DialogClose>
</div>
{info && (
<Alert variant="info" className="w-full">
<AlertTitle>{info.title}</AlertTitle>
<AlertDescription>{info.description}</AlertDescription>
</Alert>
)}
</div>
</DialogHeader>
<DialogPanel>
<div className="mt-3 overflow-x-auto">
<div className="flex gap-2 sm:gap-4 max-sm:grid max-sm:grid-flow-col max-sm:auto-cols-fr max-sm:w-max">
{target === "team" && (
<PlanColumn
name={t("team")}
badge={t("upgrade_badge_free_trial")}
price={teamPrice}
priceSubtext={t("upgrade_price_per_month_user")}
description={t("upgrade_plan_team_tagline")}
features={teamFeatures}
buttonText={t("upgrade_cta_teams")}
buttonHref={teamHref}
primaryButton={target === "team"}
onCtaClick={() =>
posthog.capture("upgrade_plan_dialog_cta_clicked", {
source: tracking,
plan: "team",
target,
billingPeriod,
})
}
/>
)}
<PlanColumn
name={t("organization")}
badge={t("upgrade_badge_free_trial")}
price={orgPrice}
priceSubtext={t("upgrade_price_per_month_user")}
description={t("upgrade_plan_org_tagline")}
features={orgFeatures}
buttonText={t("upgrade_cta_orgs")}
buttonHref={organizationHref}
primaryButton={target === "organization"}
onCtaClick={() =>
posthog.capture("upgrade_plan_dialog_cta_clicked", {
source: tracking,
plan: "organization",
target,
billingPeriod,
})
}
/>
<PlanColumn
name={t("enterprise")}
price={t("custom")}
priceSubtext=""
description={t("upgrade_plan_enterprise_tagline")}
features={enterpriseFeatures}
buttonText={t("upgrade_cta_enterprise")}
buttonHref="https://cal.com/sales"
buttonTarget="_blank"
onCtaClick={() =>
posthog.capture("upgrade_plan_dialog_cta_clicked", {
source: tracking,
plan: "enterprise",
target,
billingPeriod,
})
}
/>
</div>
</div>
<Card className="bg-muted mt-4 py-3 px-4 flex-row justify-between items-center">
{target === "team" && (
<div>
<p className="font-medium text-sm text-emphasis">{t("individual")}</p>
<p className="font-cal mt-1 font-semibold text-emphasis text-2xl leading-none">{t("free")}</p>
</div>
)}
{target === "organization" && (
<div>
<p className="font-medium text-sm text-emphasis">{t("team")}</p>
<p className="mt-1">
<span className="font-cal font-semibold text-emphasis text-2xl leading-none">
{currentTeamPrice}
</span>
<span className="ml-1 leading-none font-medium text-sm text-subtle">
{t("upgrade_price_per_month_user")}
</span>
</p>
</div>
)}
<Badge variant="outline" size="lg" className="rounded-lg py-1.5 px-2.5 h-fit!">
<span className="text-emphasis opacity-50 leading-none">{t("upgrade_badge_current_plan")}</span>
</Badge>
</Card>
</DialogPanel>
</DialogPopup>
</Dialog>
);
}
@@ -0,0 +1,149 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localStorage } from "@calcom/lib/webstorage";
import { Icon } from "@calcom/ui/components/icon";
import { OrgBadge, TeamBadge } from "@calcom/web/modules/billing/components/PlanBadge";
import { Button } from "@coss/ui/components/button";
import Image from "next/image";
import Link from "next/link";
import posthog from "posthog-js";
import { useClientOnly } from "@calcom/lib/hooks/useClientOnly";
import { useState } from "react";
import type { UpgradeTarget } from "./types";
const STORAGE_KEY = "dismissed-large-upgrade-banners";
function isDismissed(tracking: string): boolean {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return false;
try {
const dismissed = JSON.parse(raw) as Record<string, boolean>;
return dismissed[tracking] === true;
} catch {
return false;
}
}
function dismiss(tracking: string): void {
const raw = localStorage.getItem(STORAGE_KEY);
let dismissed: Record<string, boolean> = {};
if (raw) {
try {
dismissed = JSON.parse(raw) as Record<string, boolean>;
} catch {
// corrupted data, start fresh
}
}
dismissed[tracking] = true;
localStorage.setItem(STORAGE_KEY, JSON.stringify(dismissed));
}
export type WideUpgradeBannerProps = {
tracking: string;
title: string;
subtitle: string;
target: UpgradeTarget;
size?: "md" | "sm";
image: {
src: string;
width: number;
height: number;
};
learnMoreButton?: {
text: string;
href?: string;
onClick?: () => void;
};
children: React.ReactNode;
};
export function WideUpgradeBanner({
tracking,
title,
subtitle,
target,
size = "md",
image,
learnMoreButton,
children,
}: WideUpgradeBannerProps) {
const { t } = useLocale();
const [visible, setVisible] = useState(false);
useClientOnly(() => {
setVisible(!isDismissed(tracking));
});
if (!visible) return null;
return (
<div className="relative flex w-full overflow-hidden rounded-xl bg-muted border-muted border">
<Button
variant="ghost"
className="absolute right-2 top-2 z-10"
onClick={() => {
dismiss(tracking);
setVisible(false);
posthog.capture("large_upgrade_banner_dismissed", { source: tracking, target });
}}>
{/* This button goes on top of the image, so it's better to force this color */}
<Icon name="x" className="h-4 w-4 text-gray-700" />
</Button>
{/* Left Content */}
<div className="flex flex-1 flex-col p-6">
{size === "sm" ? (
<div className="flex items-start gap-1.5">
<h2 className="font-cal text-base font-semibold leading-none text-default">{title}</h2>
<div className="relative -top-1">
{target === "team" ? <TeamBadge /> : <OrgBadge />}
</div>
</div>
) : (
<div>
{target === "team" ? <TeamBadge /> : <OrgBadge />}
<h2 className="mt-1 font-cal text-lg font-semibold leading-none text-default">{title}</h2>
</div>
)}
<p className="mt-2 text-sm font-normal text-subtle">{subtitle}</p>
{/* Buttons */}
<div className={`${size === "sm" ? "mt-4" : "mt-9"} flex items-center gap-2`}>
{children}
{learnMoreButton &&
(learnMoreButton.href ? (
<Button
variant="ghost"
className="text-subtle"
onClick={() =>
posthog.capture("large_upgrade_banner_learn_more_clicked", { source: tracking, target })
}
render={<Link href={learnMoreButton.href} target="_blank" rel="noopener noreferrer" />}>
{learnMoreButton.text}
</Button>
) : (
<Button
variant="ghost"
className="text-subtle"
onClick={() => {
posthog.capture("large_upgrade_banner_learn_more_clicked", { source: tracking, target });
learnMoreButton.onClick?.();
}}>
{learnMoreButton.text}
</Button>
))}
</div>
</div>
{/* Right Content - Image */}
<div
className={`relative hidden w-1/2 overflow-hidden md:block${size === "sm" ? " max-w-64" : " max-w-[520px]"}`}>
<Image
src={image.src}
alt={title}
fill
className="object-cover object-left"
/>
</div>
</div>
);
}
@@ -0,0 +1 @@
export type UpgradeTarget = "team" | "organization";
@@ -0,0 +1,34 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { FullScreenUpgradeBanner } from "@calcom/web/modules/billing/components/FullScreenUpgradeBanner";
export function FullscreenUpgradeBannerForInsightsPage() {
const { t } = useLocale();
const features = [
t("upgrade_banner_insights_feature1"),
t("upgrade_banner_insights_feature2"),
t("upgrade_banner_insights_feature3"),
];
return (
<FullScreenUpgradeBanner
tracking="fullscreen-upgrade-banner-for-insights-page"
name={t("upgrade_banner_insights_name")}
title={t("upgrade_banner_insights_title")}
subtitle={t("upgrade_banner_insights_subtitle")}
features={features}
target="team"
image={{
src: "/upgrade/full_insights.png",
width: 572,
height: 744,
}}
learnMoreButton={{
text: t("learn_more"),
href: "https://cal.com/blog/insight-2-0-calcom-scheduling",
}}
/>
);
}
@@ -0,0 +1,29 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { FullScreenUpgradeBanner } from "@calcom/web/modules/billing/components/FullScreenUpgradeBanner";
export function FullscreenUpgradeBannerForRolesAndPermissions() {
const { t } = useLocale();
return (
<FullScreenUpgradeBanner
tracking="fullscreen-upgrade-banner-for-roles-and-permissions"
name={t("upgrade_banner_roles_name")}
title={t("upgrade_banner_roles_title")}
subtitle={t("upgrade_banner_roles_subtitle")}
target="organization"
extraOffset={20}
image={{
src: "/upgrade/full_roles.png",
width: 572,
height: 744,
}}
youtubeId="J8HsK-8W39U"
learnMoreButton={{
text: t("learn_more"),
href: "https://cal.com/blog/role-based-access-control",
}}
/>
);
}
@@ -0,0 +1,28 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { FullScreenUpgradeBanner } from "@calcom/web/modules/billing/components/FullScreenUpgradeBanner";
export function FullscreenUpgradeBannerForRoutingFormPage() {
const { t } = useLocale();
return (
<FullScreenUpgradeBanner
tracking="fullscreen-upgrade-banner-for-routing-form-page"
name={t("upgrade_banner_routing_form_name")}
title={t("upgrade_banner_routing_form_title")}
subtitle={t("upgrade_banner_routing_form_subtitle")}
target="team"
image={{
src: "/upgrade/full_insights_routing.png",
width: 572,
height: 744,
}}
youtubeId="omM89sE7Jpg"
learnMoreButton={{
text: t("learn_more"),
href: "https://cal.com/routing",
}}
/>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { FullScreenUpgradeBanner } from "@calcom/web/modules/billing/components/FullScreenUpgradeBanner";
export function FullscreenUpgradeBannerForTeamsPage() {
const { t } = useLocale();
const features = [
t("upgrade_banner_teams_feature1"),
t("upgrade_banner_teams_feature2"),
t("upgrade_banner_teams_feature3"),
t("upgrade_banner_teams_feature4"),
];
return (
<FullScreenUpgradeBanner
tracking="fullscreen-upgrade-banner-for-teams-page"
name={t("upgrade_banner_teams_name")}
title={t("upgrade_banner_teams_title")}
subtitle={t("upgrade_banner_teams_subtitle")}
features={features}
target="team"
image={{
src: "/upgrade/full_teams.png",
width: 572,
height: 744,
}}
youtubeId="hp-l5oRVD-U"
learnMoreButton={{
text: t("learn_more"),
href: "https://cal.com/teams",
}}
/>
);
}
@@ -0,0 +1,35 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui/components/icon";
import { UpgradePlanDialog } from "@calcom/web/modules/billing/components/UpgradePlanDialog";
import { WideUpgradeBanner } from "@calcom/web/modules/billing/components/WideUpgradeBanner";
import { Button } from "@coss/ui/components/button";
export function WideUpgradeBannerForMembers() {
const { t } = useLocale();
return (
<WideUpgradeBanner
tracking="large-upgrade-banner-for-members"
title={t("upgrade_banner_team_attributes_title")}
subtitle={t("upgrade_banner_team_attributes_subtitle")}
target="organization"
image={{
src: "/upgrade/large_members.png",
width: 1010,
height: 340,
}}
learnMoreButton={{
text: t("learn_more"),
href: "https://cal.com/help/routing/routing-with-attributes",
}}>
<UpgradePlanDialog tracking="members" target="organization">
<Button variant="outline">
{t("try_for_free")}
<Icon name="arrow-right" />
</Button>
</UpgradePlanDialog>
</WideUpgradeBanner>
);
}
@@ -1,56 +1,36 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import {
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import classNames from "classnames";
import { useSession } from "next-auth/react";
import { signIn } from "next-auth/react";
import { usePathname } from "next/navigation";
import { useQueryState, parseAsBoolean } from "nuqs";
import posthog from "posthog-js";
import { useMemo, useReducer, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import process from "node:process";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { convertFacetedValuesToMap } from "@calcom/features/data-table";
import { DataTableProvider } from "~/data-table/DataTableProvider";
import { useDataTable } from "~/data-table/hooks/useDataTable";
import { useFetchMoreOnBottomReached } from "~/data-table/hooks/useFetchMoreOnBottomReached";
import { useColumnFilters } from "~/data-table/hooks/useColumnFilters";
import {
DataTableToolbar,
DataTableFilters,
DataTableWrapper,
DataTableSelectionBar,
} from "~/data-table/components";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useFillRemainingHeight } from "@calcom/lib/hooks/useFillRemainingHeight";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import {
ConfirmationDialogContent,
DialogClose,
DialogContent,
DialogFooter,
DialogClose,
ConfirmationDialogContent,
} from "@calcom/ui/components/dialog";
import {
Dropdown,
DropdownItem,
DropdownMenuPortal,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
@@ -59,8 +39,27 @@ import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import TeamAvailabilityModal from "@calcom/web/modules/ee/teams/components/TeamAvailabilityModal";
import { DynamicLink } from "@calcom/web/modules/users/components/UserTable/BulkActions/DynamicLink";
import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
import { keepPreviousData } from "@tanstack/react-query";
import {
type ColumnDef,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import classNames from "classnames";
import { usePathname } from "next/navigation";
import { signIn, useSession } from "next-auth/react";
import { parseAsBoolean, useQueryState } from "nuqs";
import posthog from "posthog-js";
import type { Dispatch, SetStateAction } from "react";
import { useMemo, useReducer, useRef, useState } from "react";
import {
DataTableFilters,
DataTableSelectionBar,
DataTableToolbar,
DataTableWrapper,
} from "~/data-table/components";
import DeleteBulkTeamMembers from "./DeleteBulkTeamMembers";
import { EditMemberSheet } from "./EditMemberSheet";
import { EventTypesList } from "./EventTypesList";
@@ -690,13 +689,6 @@ function MemberListContent(props: Props) {
getRowId: (row) => `${row.id}`,
});
useFetchMoreOnBottomReached({
tableContainerRef,
hasNextPage,
fetchNextPage,
isFetching,
});
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
return (
@@ -85,7 +85,7 @@ export default function TeamList(props: Props) {
: t("consider_consolidating_multi_team_org")
}
actionButton={{
href: `/settings/organizations/new`,
href: `/onboarding/organization/details?migrate=true`,
child: t("set_up_your_organization"),
"data-testid": "setup_your_org_action_button",
}}
@@ -1,29 +1,18 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Label } from "@calcom/ui/components/form";
import {
EyeOffIcon,
InfoIcon,
MailIcon,
RefreshCcwIcon,
UserPlusIcon,
UsersIcon,
VideoIcon,
} from "@coss/ui/icons";
import { InfoIcon } from "@coss/ui/icons";
import { showToast } from "@calcom/ui/components/toast";
import { useHasTeamPlan } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
import { FullscreenUpgradeBannerForTeamsPage } from "@calcom/web/modules/billing/upgrade-banners/FullscreenUpgradeBannerForTeamsPage";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
import SkeletonLoaderTeamList from "~/ee/teams/components/SkeletonloaderTeamList";
import { UpgradeTip } from "~/shell/UpgradeTip";
import TeamList from "./TeamList";
type TeamsListingProps = {
@@ -66,38 +55,7 @@ export function TeamsListing({
const isCreateTeamButtonDisabled = !!(orgId && !permissions.canCreateTeam);
const features = [
{
icon: <UsersIcon className="h-5 w-5 text-red-500" />,
title: t("collective_scheduling"),
description: t("make_it_easy_to_book"),
},
{
icon: <RefreshCcwIcon className="h-5 w-5 text-blue-500" />,
title: t("round_robin"),
description: t("find_the_best_person"),
},
{
icon: <UserPlusIcon className="h-5 w-5 text-green-500" />,
title: t("fixed_round_robin"),
description: t("add_one_fixed_attendee"),
},
{
icon: <MailIcon className="h-5 w-5 text-orange-500" />,
title: t("sms_attendee_action"),
description: t("send_reminder_sms"),
},
{
icon: <VideoIcon className="h-5 w-5 text-purple-500" />,
title: `Cal Video ${t("recordings_title")}`,
description: t("upgrade_to_access_recordings_description"),
},
{
icon: <EyeOffIcon className="h-5 w-5 text-indigo-500" />,
title: t("disable_cal_branding", { appName: APP_NAME }),
description: t("disable_cal_branding_description", { appName: APP_NAME }),
},
];
const { isPending: isPendingTeamPlan } = useHasTeamPlan();
useEffect(() => {
if (!token) {
@@ -118,73 +76,36 @@ export function TeamsListing({
showToast(t("team_invite_received", { teamName: teamNameFromInvite }), "success");
return;
}
}, []);
}, [errorMsgFromInvite, invitationAccepted, t, teamNameFromInvite, token]);
return (
<>
{organizationInvites.length > 0 && (
<div className="bg-subtle mb-6 rounded-md p-5">
<Label className="text-emphasis pb-2 font-semibold">{t("pending_organization_invites")}</Label>
<div className="mb-6 rounded-md bg-subtle p-5">
<Label className="pb-2 font-semibold text-emphasis">{t("pending_organization_invites")}</Label>
<TeamList orgId={orgId} teams={organizationInvites} pending />
</div>
)}
{teamInvites.length > 0 && (
<div className="bg-subtle mb-6 rounded-md p-5">
<Label className="text-emphasis pb-2 font-semibold">{t("pending_invites")}</Label>
<div className="mb-6 rounded-md bg-subtle p-5">
<Label className="pb-2 font-semibold text-emphasis">{t("pending_invites")}</Label>
<TeamList orgId={orgId} teams={teamInvites} pending />
</div>
)}
{teams.length > 0 && <TeamList orgId={orgId} teams={teams} />}
{teams.length === 0 && (
<UpgradeTip
plan="team"
title={t("calcom_is_better_with_team", { appName: APP_NAME })}
description={t("add_your_team_members")}
features={features}
background="/tips/teams"
buttons={
!orgId || permissions.canCreateTeam ? (
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://cal.com/teams" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
) : (
<p>{t("org_admins_can_create_new_teams")}</p>
)
}
isParentLoading={<SkeletonLoaderTeamList />}>
<EmptyScreen
Icon="users"
headline={t("create_team_to_get_started")}
description={t("create_first_team_and_invite_others")}
buttonRaw={
<Button
color="secondary"
data-testid="create-team-btn"
disabled={!!isCreateTeamButtonDisabled}
tooltip={
isCreateTeamButtonDisabled ? t("org_admins_can_create_new_teams") : t("create_new_team")
}
onClick={() => router.push(`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`)}>
{t(`create_new_team`)}
</Button>
}
/>
</UpgradeTip>
)}
{teams.length === 0 && isPendingTeamPlan && <SkeletonLoaderTeamList />}
<p className="text-subtle mb-8 mt-4 flex w-full items-center gap-1 text-sm md:justify-center md:text-center">
<InfoIcon className="hidden sm:block" /> {t("tip_username_plus")}
</p>
{teams.length === 0 && !isPendingTeamPlan && <FullscreenUpgradeBannerForTeamsPage />}
{/* Only show tip when not showing the upgrade banner */}
{teams.length > 0 && (
<p className="mt-4 mb-8 flex w-full items-center gap-1 text-sm text-subtle md:justify-center md:text-center">
<InfoIcon className="hidden sm:block" /> {t("tip_username_plus")}
</p>
)}
</>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useSession } from "next-auth/react";
import { createContext, useState } from "react";
import { createContext, useEffect, useRef, useState } from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
@@ -26,6 +26,21 @@ export function InsightsOrgTeamsProvider({ children }: { children: React.ReactNo
);
const [selectedTeamId, setSelectedTeamId] = useState<number | undefined>();
// Track whether the session has been loaded and initial state synced.
// useState's initial value is only used on the first render, so if the session
// isn't available yet (e.g. during SSR/hydration in production), orgTeamsType
// defaults to "yours". This effect corrects it once the session loads.
const isInitializedRef = useRef(false);
useEffect(() => {
if (!isInitializedRef.current && session.status === "authenticated") {
isInitializedRef.current = true;
const shouldBeOrg = isAdminOrOwner && currentOrgId;
if (shouldBeOrg) {
setOrgTeamsType("org");
}
}
}, [session.status, isAdminOrOwner, currentOrgId]);
return (
<InsightsOrgTeamsContext.Provider
value={{
@@ -10,6 +10,7 @@ type UseCreateTeamOptions = {
redirectBasePath?: string;
skipRedirectAfterInvite?: boolean;
isOnboarding?: boolean;
billingPeriod?: "MONTHLY" | "ANNUALLY";
};
export function useCreateTeam(options: UseCreateTeamOptions = {}) {
@@ -17,6 +18,7 @@ export function useCreateTeam(options: UseCreateTeamOptions = {}) {
redirectBasePath = "/onboarding/teams",
skipRedirectAfterInvite = false,
isOnboarding = true,
billingPeriod,
} = options;
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -48,6 +50,7 @@ export function useCreateTeam(options: UseCreateTeamOptions = {}) {
bio: teamDetails.bio,
logo: teamBrand.logo,
isOnboarding,
...(billingPeriod && { billingPeriod }),
});
// If there's a checkout URL, redirect to Stripe payment
@@ -0,0 +1,20 @@
"use client";
import { useSearchParams } from "next/navigation";
export const useOnboardingQueryParams = () => {
const searchParams = useSearchParams();
const bpParam = searchParams?.get("bp");
const billingPeriod = bpParam === "a" ? ("ANNUALLY" as const) : ("MONTHLY" as const);
const getQueryString = () => {
const migrateParam = searchParams?.get("migrate");
const queryParams = new URLSearchParams();
if (migrateParam) queryParams.set("migrate", migrateParam);
if (bpParam) queryParams.set("bp", bpParam);
return queryParams.toString() ? `?${queryParams.toString()}` : "";
};
return { billingPeriod, getQueryString };
};
@@ -18,7 +18,8 @@ export const useSubmitOnboarding = () => {
const submitOnboarding = async (
store: OnboardingState,
userEmail: string,
invitesToSubmit: OnboardingState["invites"]
invitesToSubmit: OnboardingState["invites"],
options?: { billingPeriod?: "MONTHLY" | "ANNUALLY" }
) => {
setIsSubmitting(true);
setError(null);
@@ -96,6 +97,7 @@ export const useSubmitOnboarding = () => {
creationSource: CreationSource.WEBAPP,
teams: teamsData,
invitedMembers: allInvitedMembers,
...(options?.billingPeriod && { billingPeriod: options.billingPeriod }),
});
// If there's a checkout URL, redirect to Stripe (billing enabled flow)
@@ -1,6 +1,6 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useEffect, useState } from "react";
@@ -13,6 +13,7 @@ import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
import { useOnboardingQueryParams } from "../../hooks/useOnboardingQueryParams";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationBrandViewProps = {
@@ -21,8 +22,8 @@ type OrganizationBrandViewProps = {
export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const { getQueryString } = useOnboardingQueryParams();
const { organizationDetails, organizationBrand, setOrganizationBrand } = useOnboardingStore();
const { isMigrationFlow, hasTeams } = useMigrationFlow();
@@ -52,8 +53,7 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
};
const getNextStep = () => {
const migrateParam = searchParams?.get("migrate");
const queryString = migrateParam ? `?migrate=${migrateParam}` : "";
const queryString = getQueryString();
// If migration flow and has teams, go to migrate-teams, otherwise go to teams
if (isMigrationFlow && hasTeams) {
@@ -80,7 +80,7 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
const handleSkip = () => {
posthog.capture("onboarding_organization_brand_skip_clicked");
// Skip brand customization and go to teams
router.push("/onboarding/organization/teams");
router.push(`/onboarding/organization/teams${getQueryString()}`);
};
const totalSteps = isMigrationFlow && hasTeams ? 6 : 4;
@@ -0,0 +1,118 @@
"use client";
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
import { emailSchema } from "@calcom/lib/emailSchema";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { Form, InputError, TextField } from "@calcom/ui/components/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
type FormValues = {
email: string;
};
export const CompanyEmailRequired = ({ userEmail }: { userEmail: string }) => {
const { t } = useLocale();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState("");
const formMethods = useForm<FormValues>({
resolver: zodResolver(
z.object({
email: emailSchema.refine((val) => isCompanyEmail(val), {
message: t("use_company_email_to_create_an_organization"),
}),
})
),
});
useEffect(() => {
const subscription = formMethods.watch(() => setErrorMessage(""));
return () => subscription.unsubscribe();
}, [formMethods.watch]);
const addSecondaryEmailMutation = trpc.viewer.loggedInViewerRouter.addSecondaryEmail.useMutation({
onSuccess: (_res, variables) => {
setSubmittedEmail(variables.email);
},
onError: (error) => {
setErrorMessage(error?.message || "");
},
});
const handleSubmit = (values: FormValues) => {
posthog.capture("onboarding_company_email_verify_clicked");
setErrorMessage("");
const qs = searchParams?.toString() ?? "";
const redirectTo = qs ? `${pathname ?? ""}?${qs}` : pathname ?? undefined;
addSecondaryEmailMutation.mutate({
email: values.email,
makePrimary: true,
redirectTo,
});
};
return (
<OnboardingLayout userEmail={userEmail}>
<OnboardingCard
title={t("set_up_your_organization")}
subtitle={t("onboarding_org_details_subtitle")}
footer={
!submittedEmail ? (
<div className="flex w-full items-center justify-between gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={() => {
posthog.capture("onboarding_company_email_back_clicked");
router.back();
}}>
{t("go_back")}
</Button>
<Button
color="primary"
className="rounded-[10px]"
type="submit"
form="company-email-form"
disabled={addSecondaryEmailMutation.isPending}>
{t("verify_email")}
</Button>
</div>
) : undefined
}>
{submittedEmail ? (
<Alert
severity="info"
title={t("check_your_email")}
message={t("org_company_email_verification_sent", { email: submittedEmail })}
/>
) : (
<Form id="company-email-form" form={formMethods} handleSubmit={handleSubmit}>
<Alert severity="warning" title={t("org_requires_company_email")} className="mb-4" />
<TextField
label={t("company_email")}
placeholder={t("company_email_placeholder")}
{...formMethods.register("email")}
/>
{errorMessage && <InputError message={errorMessage} />}
</Form>
)}
</OnboardingCard>
<OnboardingOrganizationBrowserView avatar={null} name="" bio="" slug="" bannerUrl={null} />
</OnboardingLayout>
);
};
@@ -1,6 +1,6 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useEffect, useState } from "react";
@@ -12,6 +12,7 @@ import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
import { useOnboardingQueryParams } from "../../hooks/useOnboardingQueryParams";
import { useOnboardingStore } from "../../store/onboarding-store";
import { ValidatedOrganizationSlug } from "./validated-organization-slug";
@@ -31,8 +32,8 @@ const slugify = (text: string): string => {
export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const { getQueryString } = useOnboardingQueryParams();
const { organizationDetails, setOrganizationDetails, selectedPlan, setSelectedPlan } = useOnboardingStore();
const { isMigrationFlow, hasTeams } = useMigrationFlow();
@@ -88,9 +89,7 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
link: organizationLink,
bio: organizationBio,
});
const migrateParam = searchParams?.get("migrate");
const nextUrl = `/onboarding/organization/brand${migrateParam ? `?migrate=${migrateParam}` : ""}`;
router.push(nextUrl);
router.push(`/onboarding/organization/brand${getQueryString()}`);
};
const totalSteps = isMigrationFlow && hasTeams ? 6 : 4;
@@ -17,6 +17,7 @@ import { OnboardingCard } from "../../../components/OnboardingCard";
import { OnboardingLayout } from "../../../components/OnboardingLayout";
import { RoleSelector } from "../../../components/RoleSelector";
import { OnboardingInviteBrowserView } from "../../../components/onboarding-invite-browser-view";
import { useOnboardingQueryParams } from "../../../hooks/useOnboardingQueryParams";
import { useSubmitOnboarding } from "../../../hooks/useSubmitOnboarding";
import { useOnboardingStore, type InviteRole } from "../../../store/onboarding-store";
import { OrganizationCSVUploadModal } from "../csv-upload-modal";
@@ -41,6 +42,7 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
const { teams } = useOnboardingStore();
const migratedTeams = teams.filter((team) => team.isBeingMigrated);
const isMigrationFlow = searchParams?.get("migrate") === "true";
const { billingPeriod } = useOnboardingQueryParams();
const store = useOnboardingStore();
const usersEmailDomain = userEmail.split("@")[1];
@@ -102,7 +104,7 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
const handleContinue = async (data: FormValues) => {
setInvites(data.invites);
await submitOnboarding(store, userEmail, data.invites);
await submitOnboarding(store, userEmail, data.invites, { billingPeriod });
};
const handleBack = () => {
@@ -111,7 +113,7 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
const handleSkip = async () => {
setInvites([]);
await submitOnboarding(store, userEmail, []);
await submitOnboarding(store, userEmail, [], { billingPeriod });
};
const handleGoogleWorkspaceConnect = () => {
@@ -12,6 +12,7 @@ import { InviteOptions } from "../../components/InviteOptions";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
import { useOnboardingQueryParams } from "../../hooks/useOnboardingQueryParams";
import { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding";
import { useOnboardingStore } from "../../store/onboarding-store";
import { OrganizationCSVUploadModal } from "./csv-upload-modal";
@@ -24,6 +25,7 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
const router = useRouter();
const { t } = useLocale();
const flags = useFlags();
const { billingPeriod, getQueryString } = useOnboardingQueryParams();
const store = useOnboardingStore();
const { setInvites, organizationDetails, organizationBrand } = store;
@@ -37,7 +39,7 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
};
const handleInviteViaEmail = () => {
router.push("/onboarding/organization/invite/email");
router.push(`/onboarding/organization/invite/email${getQueryString()}`);
};
const handleUploadCSV = () => {
@@ -51,11 +53,11 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
const handleSkip = async () => {
posthog.capture("onboarding_organization_invite_skip_clicked");
setInvites([]);
await submitOnboarding(store, userEmail, []);
await submitOnboarding(store, userEmail, [], { billingPeriod });
};
const handleInvite = async () => {
await submitOnboarding(store, userEmail, []);
await submitOnboarding(store, userEmail, [], { billingPeriod });
};
return (
@@ -71,7 +73,7 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
className="rounded-[10px]"
onClick={() => {
posthog.capture("onboarding_organization_invite_back_clicked");
router.push("/onboarding/organization/teams");
router.push(`/onboarding/organization/teams${getQueryString()}`);
}}
disabled={isSubmitting}>
{t("back")}
@@ -1,6 +1,6 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -15,6 +15,7 @@ import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingMigrateMembersBrowserView } from "../../components/onboarding-migrate-members-browser-view";
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
import { useOnboardingQueryParams } from "../../hooks/useOnboardingQueryParams";
import { useOnboardingStore } from "../../store/onboarding-store";
type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
@@ -25,8 +26,8 @@ type OrganizationMigrateMembersViewProps = {
export const OrganizationMigrateMembersView = ({ userEmail }: OrganizationMigrateMembersViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const { getQueryString } = useOnboardingQueryParams();
const { isLoading: isMigrationFlowLoading } = useMigrationFlow();
const { teams, organizationBrand, organizationDetails, setMigratedMembers } = useOnboardingStore();
@@ -56,9 +57,7 @@ export const OrganizationMigrateMembersView = ({ userEmail }: OrganizationMigrat
setMigratedMembers(migratedMembersData);
const migrateParam = searchParams?.get("migrate");
const nextUrl = `/onboarding/organization/invite/email${migrateParam ? `?migrate=${migrateParam}` : ""}`;
router.push(nextUrl);
router.push(`/onboarding/organization/invite/email${getQueryString()}`);
};
if (isLoading) {
@@ -1,6 +1,6 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
@@ -16,6 +16,7 @@ import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingMigrateTeamsBrowserView } from "../../components/onboarding-migrate-teams-browser-view";
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
import { useOnboardingQueryParams } from "../../hooks/useOnboardingQueryParams";
import { useOnboardingStore } from "../../store/onboarding-store";
import { getSuggestedSlug } from "./utils";
@@ -37,8 +38,8 @@ type FormValues = z.infer<typeof schema>;
export const OrganizationMigrateTeamsView = ({ userEmail }: OrganizationMigrateTeamsViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const { getQueryString } = useOnboardingQueryParams();
const { isMigrationFlow, hasTeams, teams, isLoading } = useMigrationFlow();
const { organizationDetails, organizationBrand, teams: teamsFromStore, setTeams } = useOnboardingStore();
const orgSlug = organizationDetails.link;
@@ -115,15 +116,11 @@ export const OrganizationMigrateTeamsView = ({ userEmail }: OrganizationMigrateT
setTeams([...teamsBeingMoved, ...existingNewTeams]);
const migrateParam = searchParams?.get("migrate");
const nextUrl = `/onboarding/organization/teams${migrateParam ? `?migrate=${migrateParam}` : ""}`;
router.push(nextUrl);
router.push(`/onboarding/organization/teams${getQueryString()}`);
};
const handleSkip = () => {
const migrateParam = searchParams?.get("migrate");
const nextUrl = `/onboarding/organization/teams${migrateParam ? `?migrate=${migrateParam}` : ""}`;
router.push(nextUrl);
router.push(`/onboarding/organization/teams${getQueryString()}`);
};
if (isLoading) {
@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
@@ -17,6 +17,7 @@ import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingTeamsBrowserView } from "../../components/onboarding-teams-browser-view";
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
import { useOnboardingQueryParams } from "../../hooks/useOnboardingQueryParams";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationTeamsViewProps = {
@@ -29,8 +30,8 @@ type FormValues = {
export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const { getQueryString } = useOnboardingQueryParams();
const { teams: storedTeams, setTeams, organizationBrand, organizationDetails } = useOnboardingStore();
const { isMigrationFlow } = useMigrationFlow();
@@ -59,8 +60,7 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
});
const getNextStep = () => {
const migrateParam = searchParams?.get("migrate");
const queryString = migrateParam ? `?migrate=${migrateParam}` : "";
const queryString = getQueryString();
// If migration flow and teams were migrated, go to migrate-members
if (isMigrationFlow && migratedTeams.length > 0) {
@@ -1,20 +1,10 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { revalidateSettingsProfile } from "app/cache/path/settings/my-account";
// eslint-disable-next-line no-restricted-imports
import { get, pick } from "lodash";
import { signOut, useSession } from "next-auth/react";
import type { BaseSyntheticEvent } from "react";
import React, { useRef, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { emailSchema } from "@calcom/lib/emailSchema";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
@@ -28,25 +18,29 @@ import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
import { Alert } from "@calcom/ui/components/alert";
import { UserAvatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogTrigger, DialogClose } from "@calcom/ui/components/dialog";
import { DialogClose, DialogContent, DialogFooter, DialogTrigger } from "@calcom/ui/components/dialog";
import { Editor } from "@calcom/ui/components/editor";
import { Form } from "@calcom/ui/components/form";
import { PasswordField } from "@calcom/ui/components/form";
import { Label } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { Form, Label, PasswordField, TextField } from "@calcom/ui/components/form";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { InfoIcon } from "@coss/ui/icons";
import { showToast } from "@calcom/ui/components/toast";
import { DisplayInfo } from "@calcom/web/modules/users/components/UserTable/EditSheet/DisplayInfo";
import TwoFactor from "@components/auth/TwoFactor";
import CustomEmailTextField from "@components/settings/CustomEmailTextField";
import SecondaryEmailConfirmModal from "@components/settings/SecondaryEmailConfirmModal";
import SecondaryEmailModal from "@components/settings/SecondaryEmailModal";
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
import { InfoIcon } from "@coss/ui/icons";
import { zodResolver } from "@hookform/resolvers/zod";
import type { TRPCClientErrorLike } from "@trpc/client";
import { revalidateSettingsProfile } from "app/cache/path/settings/my-account";
// eslint-disable-next-line no-restricted-imports
import { get, pick } from "lodash";
import { signOut, useSession } from "next-auth/react";
import type React from "react";
import type { BaseSyntheticEvent } from "react";
import { useRef, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import { CompanyEmailOrganizationBanner } from "./components/CompanyEmailOrganizationBanner";
interface DeleteAccountValues {
@@ -77,6 +71,7 @@ const ProfileView = ({ user }: Props) => {
const utils = trpc.useUtils();
const session = useSession();
const { update } = session;
const [showSecondaryEmailModalOpen, setShowSecondaryEmailModalOpen] = useState(false);
const updateProfileMutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
@@ -135,7 +130,6 @@ const ProfileView = ({ user }: Props) => {
const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] = useState("");
const [showCreateAccountPasswordDialog, setShowCreateAccountPasswordDialog] = useState(false);
const [showAccountDisconnectWarning, setShowAccountDisconnectWarning] = useState(false);
const [showSecondaryEmailModalOpen, setShowSecondaryEmailModalOpen] = useState(false);
const [secondaryEmailAddErrorMessage, setSecondaryEmailAddErrorMessage] = useState("");
const [newlyAddedSecondaryEmail, setNewlyAddedSecondaryEmail] = useState<undefined | string>(undefined);
@@ -1,6 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import React, { useEffect, useRef, useState, type FormEvent } from "react";
@@ -25,12 +25,16 @@ type CreateNewTeamViewProps = {
export const CreateNewTeamView = ({ userEmail }: CreateNewTeamViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const store = useOnboardingStore();
const { teamDetails, teamBrand, setTeamDetails, setTeamBrand, resetOnboardingPreservingPlan } = store;
const bpParam = searchParams?.get("bp");
const billingPeriod = bpParam === "a" ? ("ANNUALLY" as const) : bpParam === "m" ? ("MONTHLY" as const) : undefined;
const { createTeam, isSubmitting } = useCreateTeam({
redirectBasePath: "/settings/teams/new",
isOnboarding: false,
billingPeriod,
});
const logoRef = useRef<HTMLInputElement>(null);
+2 -2
View File
@@ -206,10 +206,10 @@ function MainContainer({
...props
}: LayoutProps) {
return (
<main className="bg-default relative z-0 flex-1 pb-8 focus:outline-none">
<main className="bg-default relative z-0 flex-1 focus:outline-none">
{/* show top navigation for md and smaller (tablet and phones) */}
{TopNavContainerProp}
<div className="max-w-full p-2 sm:py-4 lg:px-6">
<div className="max-w-full p-2 sm:p-4 lg:p-6">
<ErrorBoundary>
{!props.withoutMain ? <ShellMain {...props}>{props.children}</ShellMain> : props.children}
</ErrorBoundary>
@@ -20,8 +20,7 @@ import { NavigationItem, MobileNavigationItem, MobileNavigationMoreItem } from "
export const MORE_SEPARATOR_NAME = "more";
const getNavigationItems = (
orgBranding: OrganizationBranding,
hasAllInsightsAccess: boolean
orgBranding: OrganizationBranding
): NavigationItemType[] => [
{
name: "event_types_page_title",
@@ -112,8 +111,7 @@ const getNavigationItems = (
icon: "chart-bar",
isCurrent: ({ pathname: path, item }) => path?.startsWith(item.href) ?? false,
moreOnMobile: true,
child: hasAllInsightsAccess
? [
child: [
{
name: "bookings",
href: "/insights",
@@ -141,14 +139,6 @@ const getNavigationItems = (
isCurrent: ({ pathname: path }) => path?.startsWith("/insights/wrong-routing") ?? false,
},
]
: [
{
name: "call_history",
href: "/insights/call-history",
// icon: "phone",
isCurrent: ({ pathname: path }) => path?.startsWith("/insights/call-history") ?? false,
},
],
},
];
@@ -206,9 +196,8 @@ const useNavigationItems = (isPlatformNavigation = false) => {
const orgBranding = useOrgBranding();
const { hasPaidPlan, isPending } = useHasPaidPlan();
return useMemo(() => {
const hasAllInsightsAccess = !isPending && !!hasPaidPlan;
const items = !isPlatformNavigation
? getNavigationItems(orgBranding, hasAllInsightsAccess)
? getNavigationItems(orgBranding)
: platformNavigationItems;
const desktopNavigationItems = items.filter((item) => item.name !== MORE_SEPARATOR_NAME);
+3 -3
View File
@@ -59,7 +59,7 @@ test.describe("Locale-specific pages must not 404", () => {
});
test(`/${locale}/teams page shouldn't 404`, async ({ page, users }) => {
const user = await users.create();
const user = await users.create(undefined, { hasTeam: true });
await user.apiLogin();
const response = await page.goto(`/${locale}/teams`);
expect(response?.status()).not.toBe(404);
@@ -67,7 +67,7 @@ test.describe("Locale-specific pages must not 404", () => {
});
test(`/${locale}/routing page shouldn't 404`, async ({ page, users }) => {
const user = await users.create();
const user = await users.create(undefined, { hasTeam: true });
await user.apiLogin();
const response = await page.goto(`/${locale}/routing`);
expect(response?.status()).not.toBe(404);
@@ -75,7 +75,7 @@ test.describe("Locale-specific pages must not 404", () => {
});
test(`/${locale}/insights page shouldn't 404`, async ({ page, users }) => {
const user = await users.create();
const user = await users.create(undefined, { hasTeam: true });
await user.apiLogin();
const response = await page.goto(`/${locale}/insights`);
expect(response?.status()).not.toBe(404);
@@ -17,17 +17,20 @@ test.describe("Teams", () => {
const org = await orgs.create({
name: "TestOrg",
});
const user = await users.create({
organizationId: org.id,
roleInOrganization: MembershipRole.ADMIN,
});
const user = await users.create(
{
organizationId: org.id,
roleInOrganization: MembershipRole.ADMIN,
},
{ hasTeam: true }
);
const inviteeEmail = `${user.username}+invitee@example.com`;
await user.apiLogin();
await page.goto("/teams");
await test.step("Can create team", async () => {
// Click text=Create Team
await page.locator("text=Create a new Team").click();
// Click the new team button
await page.locator("[data-testid=new-team-btn]").click();
await page.waitForLoadState("networkidle");
// Fill team name input (new onboarding-v3 style flow)
await page.locator('[data-testid="team-name-input"]').fill(`${user.username}'s Team`);
+20 -5
View File
@@ -123,6 +123,7 @@ test.describe("Teams - NonOrg", () => {
const owner = await users.create(undefined, {
hasTeam: true,
isOrg: true,
hasSubteam: true,
teammates: [{ name: teamMateName }],
});
@@ -131,11 +132,26 @@ test.describe("Teams - NonOrg", () => {
// eslint-disable-next-line playwright/no-conditional-in-test
if (memberUser) {
// Add teammate to the sub-team so they have a team membership and see teams list
const org = await owner.getOrgMembership();
const subTeam = await prisma.team.findFirst({
where: { parentId: org.teamId },
select: { id: true },
});
if (!subTeam) throw new Error("Expected a sub-team under the org, but none was found");
await prisma.membership.create({
data: {
teamId: subTeam.id,
userId: memberUser.id,
role: "MEMBER",
accepted: true,
},
});
await memberUser.apiLogin();
await page.goto("/teams");
await expect(page.locator("[data-testid=new-team-btn]")).toBeHidden();
await expect(page.locator("[data-testid=create-team-btn]")).toHaveAttribute("disabled", "");
const uniqueName = "test-unique-team-name";
@@ -147,21 +163,20 @@ test.describe("Teams - NonOrg", () => {
await page.click("[type=submit]");
// cleanup
const org = await owner.getOrgMembership();
await prisma.team.delete({ where: { id: org.teamId } });
}
});
test("Can create team with same name as user", async ({ page, users }) => {
const user = await users.create();
const user = await users.create(undefined, { hasTeam: true });
// Name to be used for both user and team
const uniqueName = user.username!;
await user.apiLogin();
await page.goto("/teams");
await test.step("Can create team with same name", async () => {
// Click text=Create Team
await page.locator("text=Create Team").click();
// Click the new team button
await page.locator("[data-testid=new-team-btn]").click();
await page.waitForLoadState("networkidle");
// Fill team name input (new onboarding-v3 style flow)
await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
+41
View File
@@ -0,0 +1,41 @@
<svg width="56" height="61" viewBox="0 0 56 61" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_ddddd_389_35705)">
<rect x="4" y="0.666504" width="48" height="48" rx="24" fill="white"/>
<path d="M25.9805 16.7778C24.3149 15.7491 22.1667 16.9472 22.1667 18.9048V30.4281C22.1667 32.3858 24.3149 33.5838 25.9805 32.5551L35.3089 26.7935C36.8905 25.8166 36.8906 23.5164 35.3089 22.5395L25.9805 16.7778Z" fill="#3C3F43"/>
</g>
<defs>
<filter id="filter0_ddddd_389_35705" x="0" y="-0.00016278" width="56" height="60.6667" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="0.666667" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_389_35705"/>
<feOffset/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_389_35705"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="0.333333" operator="erode" in="SourceAlpha" result="effect2_dropShadow_389_35705"/>
<feOffset dy="0.666667"/>
<feGaussianBlur stdDeviation="0.333333"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_389_35705" result="effect2_dropShadow_389_35705"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="1" operator="erode" in="SourceAlpha" result="effect3_dropShadow_389_35705"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_389_35705" result="effect3_dropShadow_389_35705"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect4_dropShadow_389_35705"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="effect3_dropShadow_389_35705" result="effect4_dropShadow_389_35705"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect5_dropShadow_389_35705"/>
<feOffset dy="8"/>
<feGaussianBlur stdDeviation="4"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="effect4_dropShadow_389_35705" result="effect5_dropShadow_389_35705"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect5_dropShadow_389_35705" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@@ -1,8 +1,3 @@
import { LRUCache } from "lru-cache";
import type { GetServerSidePropsContext, NextApiRequest } from "next";
import type { AuthOptions, Session } from "next-auth";
import { getToken } from "next-auth/jwt";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { DeploymentRepository } from "@calcom/features/ee/deployment/repositories/DeploymentRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
@@ -10,6 +5,10 @@ import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import { LRUCache } from "lru-cache";
import type { GetServerSidePropsContext, NextApiRequest } from "next";
import type { AuthOptions, Session } from "next-auth";
import { getToken } from "next-auth/jwt";
const log = logger.getSubLogger({ prefix: ["getServerSession"] });
/**
@@ -142,3 +141,7 @@ export async function getServerSession(options: {
log.debug("Returned session", safeStringify(session));
return session;
}
export function clearSessionCache() {
CACHE.clear();
}
+8 -5
View File
@@ -1,10 +1,9 @@
import { randomBytes, createHash } from "node:crypto";
import { totp } from "otplib";
import { createHash, randomBytes } from "node:crypto";
import process from "node:process";
import {
sendChangeOfEmailVerificationLink,
sendEmailVerificationCode,
sendEmailVerificationLink,
sendChangeOfEmailVerificationLink,
} from "@calcom/emails/auth-email-service";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
@@ -12,9 +11,10 @@ import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/wat
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { hashEmail } from "@calcom/lib/server/PiiHasher";
import { getTranslation } from "@calcom/i18n/server";
import { hashEmail } from "@calcom/lib/server/PiiHasher";
import { prisma } from "@calcom/prisma";
import { totp } from "otplib";
const log = logger.getSubLogger({ prefix: [`[[Auth] `] });
@@ -25,6 +25,7 @@ interface VerifyEmailType {
secondaryEmailId?: number;
isVerifyingEmail?: boolean;
isPlatform?: boolean;
extraParams?: Record<string, string>;
hideBranding?: boolean;
}
@@ -34,6 +35,7 @@ export const sendEmailVerification = async ({
username,
secondaryEmailId,
isPlatform = false,
extraParams,
}: VerifyEmailType) => {
const token = randomBytes(32).toString("hex");
const translation = await getTranslation(language ?? "en", "common");
@@ -71,6 +73,7 @@ export const sendEmailVerification = async ({
const params = new URLSearchParams({
token,
...extraParams,
});
await sendEmailVerificationLink({
@@ -33,3 +33,8 @@ export const PLATFORM_PLANS_MAP: Record<string, BillingPlan> = {
export const PLATFORM_ENTERPRISE_SLUGS = process.env.PLATFORM_ENTERPRISE_SLUGS?.split(",") ?? [];
export const ENTERPRISE_SLUGS = process.env.ENTERPRISE_SLUGS?.split(",") ?? [];
export const BILLING_PRICING = {
[BILLING_PLANS.TEAMS]: { monthly: 1600, annual: 1200 },
[BILLING_PLANS.ORGANIZATIONS]: { monthly: 3700, annual: 2800 },
} as const;
@@ -0,0 +1,12 @@
import { createContainer } from "@calcom/features/di/di";
import type { BillingPeriodRepository } from "@calcom/features/ee/billing/repository/billingPeriod/BillingPeriodRepository";
import { billingPeriodRepositoryModuleLoader } from "../modules/BillingPeriodRepository";
import { DI_TOKENS } from "../tokens";
const container = createContainer();
export function getBillingPeriodRepository(): BillingPeriodRepository {
billingPeriodRepositoryModuleLoader.loadModule(container);
return container.get<BillingPeriodRepository>(DI_TOKENS.BILLING_PERIOD_REPOSITORY);
}
@@ -87,6 +87,14 @@ export class BillingPeriodRepository {
});
}
async findBillingPeriodByTeamId(teamId: number): Promise<BillingPeriod | null> {
const billing = await this.prisma.teamBilling.findFirst({
where: { teamId },
select: { billingPeriod: true },
});
return billing?.billingPeriod ?? null;
}
async getTeamForBillingUpdate(teamId: number) {
return await this.prisma.team.findUnique({
where: { id: teamId },
@@ -1,17 +1,18 @@
import process from "node:process";
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
import type { StripeBillingService } from "@calcom/features/ee/billing/service/billingProvider/StripeBillingService";
import { getOrgPriceId } from "@calcom/features/ee/teams/lib/payments";
import { OrganizationOnboardingRepository } from "@calcom/features/organizations/repositories/OrganizationOnboardingRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { ORGANIZATION_SELF_SERVE_PRICE, WEBAPP_URL, ORG_TRIAL_DAYS } from "@calcom/lib/constants";
import { ORG_TRIAL_DAYS, ORGANIZATION_SELF_SERVE_PRICE, WEBAPP_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { prisma } from "@calcom/prisma";
import type { OrganizationOnboarding } from "@calcom/prisma/client";
import { UserPermissionRole, type BillingPeriod } from "@calcom/prisma/enums";
import { type BillingPeriod, UserPermissionRole } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { OrganizationPermissionService } from "./OrganizationPermissionService";
import type { OnboardingUser } from "./service/onboarding/types";
@@ -223,16 +224,7 @@ export class OrganizationPaymentService {
throw new Error("STRIPE_ORG_PRODUCT_ID is not set");
}
const fixedPriceId =
config.billingPeriod === "ANNUALLY"
? process.env.STRIPE_ORG_ANNUAL_PRICE_ID
: process.env.STRIPE_ORG_MONTHLY_PRICE_ID;
if (!fixedPriceId) {
const envVar =
config.billingPeriod === "ANNUALLY" ? "STRIPE_ORG_ANNUAL_PRICE_ID" : "STRIPE_ORG_MONTHLY_PRICE_ID";
throw new Error(`${envVar} is not set`);
}
const fixedPriceId = getOrgPriceId(config.billingPeriod);
if (!shouldCreateCustomPrice) {
return {
@@ -58,19 +58,13 @@ export class OrganizationPermissionService {
}
hasModifiedDefaultPayment(data: SeatsPrice & { billingPeriod?: string }): boolean {
const isBillingPeriodModified =
data.billingPeriod !== undefined && data.billingPeriod !== null && data.billingPeriod !== "MONTHLY";
const isPricePerSeatModified =
data.pricePerSeat !== undefined &&
data.pricePerSeat !== null &&
data.pricePerSeat !== ORGANIZATION_SELF_SERVE_PRICE;
log.debug(
"hasModifiedDefaultPayment",
safeStringify({ isBillingPeriodModified, isPricePerSeatModified })
);
return isBillingPeriodModified || isPricePerSeatModified;
log.debug("hasModifiedDefaultPayment", safeStringify({ isPricePerSeatModified }));
return isPricePerSeatModified;
}
async hasPermissionToMigrateTeams(teamIds: number[]): Promise<boolean> {
+34 -27
View File
@@ -1,22 +1,21 @@
import type Stripe from "stripe";
import { z } from "zod";
import process from "node:process";
import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
import { getDubCustomer } from "@calcom/features/auth/lib/dub";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { CHECKOUT_SESSION_TYPES } from "@calcom/features/ee/billing/constants";
import stripe from "@calcom/features/ee/payments/server/stripe";
import {
IS_PRODUCTION,
ORG_TRIAL_DAYS,
ORGANIZATION_SELF_SERVE_PRICE,
WEBAPP_URL,
ORG_TRIAL_DAYS,
} from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { TrackingData } from "@calcom/lib/tracking";
import prisma from "@calcom/prisma";
import { BillingPeriod } from "@calcom/prisma/zod-utils";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TrackingData } from "@calcom/lib/tracking";
import { BillingPeriod, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type Stripe from "stripe";
import { z } from "zod";
const log = logger.getSubLogger({ prefix: ["teams/lib/payments"] });
const teamPaymentMetadataSchema = z.object({
@@ -27,6 +26,21 @@ const teamPaymentMetadataSchema = z.object({
orgSeats: teamMetadataSchema.unwrap().shape.orgSeats,
});
export function getOrgPriceId(billingPeriod: "MONTHLY" | "ANNUALLY"): string {
const priceId =
billingPeriod === "ANNUALLY"
? process.env.STRIPE_ORG_ANNUAL_PRICE_ID
: process.env.STRIPE_ORG_MONTHLY_PRICE_ID;
if (!priceId) {
const envVar =
billingPeriod === "ANNUALLY" ? "STRIPE_ORG_ANNUAL_PRICE_ID" : "STRIPE_ORG_MONTHLY_PRICE_ID";
throw new Error(`${envVar} is not set`);
}
return priceId;
}
/** Used to prevent double charges for the same team */
export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => {
const team = await prisma.team.findUniqueOrThrow({
@@ -288,27 +302,20 @@ export const purchaseTeamOrOrgSubscription = async (input: {
* If the organization has a custom price per seat, it will create a new price in stripe and return its ID.
*/
async function getFixedPrice() {
const isAnnual = billingPeriod === "ANNUALLY";
const fixedPriceId = isOrg
? isAnnual
? process.env.STRIPE_ORG_ANNUAL_PRICE_ID
: process.env.STRIPE_ORG_MONTHLY_PRICE_ID
: isAnnual
? process.env.STRIPE_TEAM_ANNUAL_PRICE_ID
: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID;
if (!fixedPriceId) {
const envVar = isOrg
? isAnnual
? "STRIPE_ORG_ANNUAL_PRICE_ID"
: "STRIPE_ORG_MONTHLY_PRICE_ID"
: isAnnual
? "STRIPE_TEAM_ANNUAL_PRICE_ID"
: "STRIPE_TEAM_MONTHLY_PRICE_ID";
throw new Error(`Missing env var: ${envVar}`);
if (isOrg) {
const fixedPriceId = getOrgPriceId(billingPeriod);
log.debug(
"Getting price ID",
safeStringify({ fixedPriceId, isOrg, teamId, pricePerSeat, billingPeriod })
);
return fixedPriceId;
}
log.debug("Getting price ID", safeStringify({ fixedPriceId, isOrg, teamId, pricePerSeat, billingPeriod }));
const fixedPriceId = getTeamPriceId(billingPeriod);
log.debug(
"Getting price ID",
safeStringify({ fixedPriceId, isOrg, teamId, pricePerSeat, billingPeriod })
);
return fixedPriceId;
}
@@ -2,7 +2,6 @@ import { Memoize, Unmemoize } from "@calcom/features/cache";
import type { FeatureId } from "@calcom/features/flags/config";
import type { FeatureDto } from "@calcom/lib/dto/FeatureDto";
import { FeatureDtoArraySchema, FeatureDtoSchema } from "@calcom/lib/dto/FeatureDto";
import type { IFeatureRepository } from "./PrismaFeatureRepository";
const CACHE_PREFIX = "features:global";
@@ -47,4 +46,20 @@ export class CachedFeatureRepository implements IFeatureRepository {
const feature = await this.findBySlug(slug);
return Boolean(feature && feature.enabled);
}
/**
* Gets a map of all feature flags and their enabled status.
* Uses caching to avoid hitting the database on every request.
* @returns Promise<AppFlags> - A map of feature flags to their enabled status
*/
public async getFeatureFlagMap() {
const flags = await this.findAll();
return flags.reduce(
(acc, flag) => {
acc[flag.slug as FeatureId] = flag.enabled;
return acc;
},
{} as Record<string, boolean>
);
}
}
@@ -7,6 +7,7 @@ export interface IFeatureRepository {
findBySlug(slug: string): Promise<FeatureDto | null>;
update(input: { featureId: FeatureId; enabled: boolean; updatedBy?: number }): Promise<FeatureDto>;
checkIfFeatureIsEnabledGlobally(slug: string): Promise<boolean>;
getFeatureFlagMap(): Promise<Record<string, boolean>>;
}
export class PrismaFeatureRepository implements IFeatureRepository {
@@ -76,4 +77,15 @@ export class PrismaFeatureRepository implements IFeatureRepository {
});
return Boolean(feature?.enabled);
}
async getFeatureFlagMap(): Promise<Record<string, boolean>> {
const flags = await this.findAll();
return flags.reduce(
(acc, flag) => {
acc[flag.slug as FeatureId] = flag.enabled;
return acc;
},
{} as Record<string, boolean>
);
}
}
@@ -131,8 +131,8 @@ export class MembershipRepository {
});
}
static async findFirstAcceptedMembershipByUserId(userId: number) {
return await prisma.membership.findFirst({
static async hasAnyAcceptedMembershipByUserId(userId: number) {
const membership = await prisma.membership.findFirst({
where: {
accepted: true,
userId,
@@ -142,7 +142,9 @@ export class MembershipRepository {
},
},
},
select: { id: true },
});
return Boolean(membership);
}
static async findAcceptedMembershipsByUserIdsInTeam({
+60
View File
@@ -1173,6 +1173,11 @@
"create_first_team_and_invite_others": "Create your first team and invite other users to work together.",
"create_team_to_get_started": "Create a team to get started",
"teams": "Teams",
"upgrade_feature_round_robin": "Round-robin, fixed round-robin",
"upgrade_feature_collective_events": "Collective events",
"upgrade_feature_workflows": "Teams workflows",
"upgrade_feature_insights": "Insights - analyze your booking data",
"upgrade_feature_remove_branding": "Remove branding",
"team": "Team",
"organization": "Organization",
"org": "Org",
@@ -3341,6 +3346,7 @@
"skip_contact_creation": "Skip creating contacts if they do not exist in {{appName}} ",
"skip_writing_to_calendar_note": "If your ICS link is read-only (e.g., Proton Calendar), check the box above to avoid errors. You'll also need to manually update your calendar for changes.",
"attributes": "Attributes",
"attributes_description": "Manage attributes for your team members",
"new_attribute": "New attribute",
"add_attributes": "Add attributes",
"add_attributes_description": "Add attributes to your team members",
@@ -3875,6 +3881,10 @@
"reschedule_cta_short": "Reschedule here.",
"hide_organizer_email": "Hide organizer's email",
"platform_customer_cant_create_organization": "You are already part of a platform organization which is why you can't create a new organization. Each user can only belong to one organization. Please, create a new cal.com account with a different email and use that account to subscribe to the organizations plan.",
"org_requires_company_email": "Creating an organization requires a company email. Add your company email below to continue.",
"org_company_email_verification_sent": "We sent a verification email to {{email}}. Click the link in the email to verify and continue setting up your organization.",
"company_email": "Company email",
"company_email_placeholder": "you@company.com",
"organization_customer_cant_create_platform": "You are already part of an organization which is why you can't create a platform organization. Each user can only belong to one organization. Please, create a new cal.com account with a different email and use that account to subscribe to the platform plan.",
"hide_organizer_email_description": "Hide organizer's email address from the booking screen, email notifications, and calendar events. <0>Learn more</0>",
"you_and_conjunction": "You &",
@@ -4660,6 +4670,55 @@
"last_6_months": "Last 6 months",
"last_12_months": "Last 12 months",
"calendar_events_disabled_video_limitation": "When calendar events are disabled, meeting links cannot be generated for apps whose link generation is tied to calendar event creation, for example, Google Meet and Microsoft Teams. Other video apps like Cal Video will continue to work.",
"upgrade_dialog_title_short": "Upgrade",
"upgrade_dialog_title": "Upgrade plan",
"upgrade_price_per_month_user": "per month/user",
"upgrade_feature_everything_in_team": "Everything in Team",
"upgrade_feature_verified_domain": "Verified domain",
"upgrade_feature_directory_sync": "Directory sync (SCIM)",
"upgrade_feature_sso": "SAML SSO",
"upgrade_feature_admin_panel": "Admin panel",
"upgrade_feature_everything_in_org": "Everything in Organization",
"upgrade_feature_dedicated_support": "Dedicated support",
"upgrade_feature_custom_sla": "Custom SLA",
"upgrade_feature_custom_integrations": "Custom integrations",
"upgrade_feature_compliance": "SOC2 & HIPAA compliance",
"upgrade_badge_current_plan": "Current plan",
"upgrade_billing_annual": "Annual",
"upgrade_badge_free_trial": "14 day free trial",
"upgrade_plan_team_tagline": "For growing teams",
"upgrade_plan_org_tagline": "For scaling organizations",
"upgrade_plan_enterprise_tagline": "For large enterprises",
"upgrade_cta_teams": "Upgrade to Teams",
"upgrade_cta_orgs": "Upgrade to Orgs",
"upgrade_cta_enterprise": "Get in touch",
"upgrade_info_team_insights_description": "Upgrade to surface all activity across your bookings pages",
"orgs": "Orgs",
"available_on": "Available on",
"available_on_plan": "Available on {{plan}} plan",
"upgrade_banner_roles_name": "Roles and Permissions",
"upgrade_banner_roles_title": "Manage team access with roles and permissions",
"upgrade_banner_roles_subtitle": "Give teammates the right level of access to events, settings, and scheduling.",
"upgrade_banner_teams_name": "Teams",
"upgrade_banner_teams_title": "Automatically route meetings to your team",
"upgrade_banner_teams_subtitle": "Turn individual scheduling into a system that assigns, distributes, and manages meetings for our team.",
"upgrade_banner_teams_feature1": "Route inbound requests to the right person",
"upgrade_banner_teams_feature2": "Distribute meetings fairly with round-robin",
"upgrade_banner_teams_feature3": "See what's getting booked (and what's not)",
"upgrade_banner_teams_feature4": "Remove Cal.com branding",
"upgrade_banner_insights_name": "Insights",
"upgrade_banner_insights_title": "See what's getting booked, and what's not",
"upgrade_banner_insights_subtitle": "Turn booking data into clarity for you and your team so you can spot gaps, balance workload, and make better scheduling decisions.",
"upgrade_banner_insights_feature1": "Understand booking volume and cancellations",
"upgrade_banner_insights_feature2": "See how meetings are distributed across team members",
"upgrade_banner_insights_feature3": "Spot trends that help you improve availability and routing",
"upgrade_banner_routing_form_name": "Routing",
"upgrade_banner_routing_form_title": "Route bookings to the right team member automatically",
"upgrade_banner_routing_form_subtitle": "Use routing rules to qualify bookers and assign meetings to the best team member.",
"upgrade_banner_team_attributes_title": "Route meetings based on team member attributes",
"upgrade_banner_team_attributes_subtitle": "Define attributes like role, region, or specialty and use them to route bookings to the right people automatically.",
"play_video": "Play Video",
"connected_accounts": "Connected accounts",
"paypal": "PayPal",
"paypal_getting_started": "Getting started with Paypal APP",
"important_requirements": "Important requirements:",
@@ -4681,5 +4740,6 @@
"paypal_step_7": "You should be all set up after this.",
"dashboard": "dashboard",
"paypal_webhook_reminder": "Our integration creates a specific webhook on your PayPal account that we use to report back transactions to our system. If you delete this webhook, we will not be able to report back and you should uninstall and install the app again for this to work properly. Uninstalling the app won't delete your current event type price/currency configuration but you will not be able to receive bookings.",
"discount_25": "-25%",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+10
View File
@@ -0,0 +1,10 @@
import { useEffect } from "react";
/**
* Runs a callback once after hydration, on the client only.
* Use this to defer browser-only logic (e.g. reading localStorage)
* and avoid SSR hydration mismatches.
*/
export function useClientOnly(callback: () => void) {
useEffect(callback, []);
}
@@ -0,0 +1,27 @@
import { useLayoutEffect, useRef } from "react";
export function useFillRemainingHeight<T extends HTMLElement = HTMLDivElement>(offset = 0) {
const ref = useRef<T>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => {
const top = Math.round(el.getBoundingClientRect().top + offset);
const newHeight = `calc(100dvh - ${top}px)`;
if (el.style.height !== newHeight) {
el.style.height = newHeight;
}
};
update();
const observer = new ResizeObserver(update);
observer.observe(document.body);
return () => observer.disconnect();
}, [offset]);
return ref;
}
+3 -4
View File
@@ -1,12 +1,11 @@
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import prisma from "@calcom/prisma";
import { getFeatureRepository } from "@calcom/features/di/containers/FeatureRepository";
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
/**
* TRPC procedure that returns a map of all feature flags and their enabled status.
* Uses the FeaturesRepository to handle caching and database access.
* Uses the CachedFeatureRepository to handle caching and database access.
*/
export const map = publicProcedure.query(async () => {
const featuresRepository = new FeaturesRepository(prisma);
const featuresRepository = getFeatureRepository();
return featuresRepository.getFeatureFlagMap();
});
@@ -1,12 +1,9 @@
import type { GetServerSidePropsContext, NextApiResponse } from "next";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { GetServerSidePropsContext, NextApiResponse } from "next";
import type { TAddSecondaryEmailInputSchema } from "./addSecondaryEmail.schema";
type AddSecondaryEmailOptions = {
@@ -46,14 +43,25 @@ export const addSecondaryEmailHandler = async ({ ctx, input }: AddSecondaryEmail
}
const updatedData = await prisma.secondaryEmail.create({
data: { ...input, userId: user.id },
data: { email: input.email, userId: user.id },
});
const extraParams: Record<string, string> = {};
if (input.makePrimary) {
extraParams.makePrimary = "true";
}
if (input.redirectTo) {
extraParams.redirectTo = input.redirectTo;
}
const hasExtraParams = Object.keys(extraParams).length > 0;
await sendEmailVerification({
email: updatedData.email,
username: user?.username ?? undefined,
language: user.locale,
secondaryEmailId: updatedData.id,
...(hasExtraParams && { extraParams }),
});
return {
@@ -2,8 +2,12 @@ import { z } from "zod";
export type TAddSecondaryEmailInputSchema = {
email: string;
makePrimary?: boolean;
redirectTo?: string;
};
export const ZAddSecondaryEmailInputSchema: z.ZodType<TAddSecondaryEmailInputSchema> = z.object({
email: z.string(),
makePrimary: z.boolean().optional(),
redirectTo: z.string().optional(),
});
@@ -1,5 +1,6 @@
import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing";
import { SubscriptionStatus } from "@calcom/ee/billing/repository/billing/IBillingRepository";
import { getBillingPeriodRepository } from "@calcom/features/ee/billing/di/containers/BillingPeriodRepository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
@@ -16,7 +17,8 @@ type HasActiveTeamPlanOptions = {
};
export const hasActiveTeamPlanHandler = async ({ ctx, input }: HasActiveTeamPlanOptions) => {
if (IS_SELF_HOSTED) return { isActive: true, isTrial: false };
const billingPeriodRepository = getBillingPeriodRepository();
if (IS_SELF_HOSTED) return { isActive: true, isTrial: false, billingPeriod: null };
const whereClause: Prisma.MembershipWhereInput = { userId: ctx.user.id, accepted: true };
@@ -26,15 +28,16 @@ export const hasActiveTeamPlanHandler = async ({ ctx, input }: HasActiveTeamPlan
const teams = await MembershipRepository.findAllAcceptedTeamMemberships(ctx.user.id, whereClause);
if (!teams.length) return { isActive: false, isTrial: false };
if (!teams.length) return { isActive: false, isTrial: false, billingPeriod: null };
let isTrial = false;
// check if user has at least on membership with an active plan
let relevantTeamId: number | null = null;
// check if user has at least one membership with an active plan
for (const team of teams) {
if (team.isPlatform && team.isOrganization) {
const platformBilling = await prisma.platformBilling.findUnique({ where: { id: team.id } });
if (platformBilling && platformBilling.plan !== "none" && platformBilling.plan !== "FREE") {
return { isActive: true, isTrial: false };
return { isActive: true, isTrial: false, billingPeriod: await billingPeriodRepository.findBillingPeriodByTeamId(team.id) };
}
}
@@ -47,14 +50,19 @@ export const hasActiveTeamPlanHandler = async ({ ctx, input }: HasActiveTeamPlan
subscriptionStatus === SubscriptionStatus.ACTIVE ||
subscriptionStatus === SubscriptionStatus.PAST_DUE
) {
return { isActive: true, isTrial: false };
return { isActive: true, isTrial: false, billingPeriod: await billingPeriodRepository.findBillingPeriodByTeamId(team.id) };
}
if (subscriptionStatus === SubscriptionStatus.TRIALING) {
isTrial = true;
relevantTeamId = team.id;
}
}
return { isActive: false, isTrial };
const billingPeriod = relevantTeamId
? await billingPeriodRepository.findBillingPeriodByTeamId(relevantTeamId)
: null;
return { isActive: false, isTrial, billingPeriod };
};
export default hasActiveTeamPlanHandler;