From b7340f71518ef794737c8fdf6847f8afb04ab51e Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Thu, 5 Mar 2026 10:19:01 +0100 Subject: [PATCH] feat: add upgrade banners for teams and organizations (#27650) --- .../(main-nav)/teams/page.tsx | 17 +- .../(main-nav)/teams/server-page.tsx | 25 +- .../forms/[[...pages]]/Forms.tsx | 69 +--- .../routing-forms/forms/[[...pages]]/page.tsx | 14 +- .../insights/call-history/page.tsx | 19 +- .../insights/getInsightsUpgradeBanner.tsx | 18 + .../(use-page-wrapper)/insights/layout.tsx | 23 -- .../app/(use-page-wrapper)/insights/page.tsx | 38 ++- .../insights/router-position/page.tsx | 25 +- .../insights/routing/page.tsx | 34 +- .../insights/wrong-routing/page.tsx | 28 +- .../onboarding/organization/details/page.tsx | 28 +- .../onboarding/organization/layout.tsx | 6 - .../SettingsLayoutAppDirClient.tsx | 67 ++-- .../teams/[id]/members/page.tsx | 18 +- .../teams/[id]/roles/page.tsx | 18 +- apps/web/app/cache/membership.ts | 7 +- apps/web/lib/pages/auth/verify-email.ts | 46 ++- .../components/FullScreenUpgradeBanner.tsx | 318 ++++++++++++++++++ .../modules/billing/components/PlanBadge.tsx | 16 + .../billing/components/UpgradePlanDialog.tsx | 303 +++++++++++++++++ .../billing/components/WideUpgradeBanner.tsx | 149 ++++++++ apps/web/modules/billing/components/types.ts | 1 + ...FullscreenUpgradeBannerForInsightsPage.tsx | 34 ++ ...eenUpgradeBannerForRolesAndPermissions.tsx | 29 ++ ...lscreenUpgradeBannerForRoutingFormPage.tsx | 28 ++ .../FullscreenUpgradeBannerForTeamsPage.tsx | 36 ++ .../WideUpgradeBannerForMembers.tsx | 35 ++ .../ee/teams/components/MemberList.tsx | 64 ++-- .../modules/ee/teams/components/TeamList.tsx | 2 +- .../ee/teams/components/TeamsListing.tsx | 121 ++----- .../context/InsightsOrgTeamsProvider.tsx | 17 +- .../modules/onboarding/hooks/useCreateTeam.ts | 3 + .../hooks/useOnboardingQueryParams.ts | 20 ++ .../onboarding/hooks/useSubmitOnboarding.ts | 4 +- .../brand/organization-brand-view.tsx | 10 +- .../details/company-email-required-view.tsx | 118 +++++++ .../details/organization-details-view.tsx | 9 +- .../email/organization-invite-email-view.tsx | 6 +- .../invite/organization-invite-view.tsx | 10 +- .../organization-migrate-members-view.tsx | 9 +- .../organization-migrate-teams-view.tsx | 13 +- .../teams/organization-teams-view.tsx | 8 +- .../settings/my-account/profile-view.tsx | 36 +- .../teams/new/create-new-team-view.tsx | 6 +- apps/web/modules/shell/Shell.tsx | 4 +- .../modules/shell/navigation/Navigation.tsx | 17 +- apps/web/playwright/i18n-routing.e2e.ts | 6 +- .../organization/team-management.e2e.ts | 15 +- apps/web/playwright/teams.e2e.ts | 25 +- apps/web/public/play_button.svg | 41 +++ apps/web/public/upgrade/full_insights.png | Bin 0 -> 85362 bytes .../public/upgrade/full_insights_routing.png | Bin 0 -> 86678 bytes apps/web/public/upgrade/full_roles.png | Bin 0 -> 35383 bytes apps/web/public/upgrade/full_teams.png | Bin 0 -> 93738 bytes apps/web/public/upgrade/large_members.png | Bin 0 -> 34447 bytes .../features/auth/lib/getServerSession.ts | 13 +- packages/features/auth/lib/verifyEmail.ts | 13 +- packages/features/ee/billing/constants.ts | 5 + .../di/containers/BillingPeriodRepository.ts | 12 + .../billingPeriod/BillingPeriodRepository.ts | 8 + .../lib/OrganizationPaymentService.ts | 18 +- .../lib/OrganizationPermissionService.ts | 10 +- packages/features/ee/teams/lib/payments.ts | 61 ++-- .../repositories/CachedFeatureRepository.ts | 17 +- .../repositories/PrismaFeatureRepository.ts | 12 + .../repositories/MembershipRepository.ts | 6 +- packages/i18n/locales/en/common.json | 60 ++++ packages/lib/hooks/useClientOnly.ts | 10 + packages/lib/hooks/useFillRemainingHeight.ts | 27 ++ packages/trpc/server/routers/features/map.ts | 7 +- .../addSecondaryEmail.handler.ts | 18 +- .../addSecondaryEmail.schema.ts | 4 + .../viewer/teams/hasActiveTeamPlan.handler.ts | 20 +- 74 files changed, 1834 insertions(+), 500 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/insights/getInsightsUpgradeBanner.tsx delete mode 100644 apps/web/app/(use-page-wrapper)/insights/layout.tsx create mode 100644 apps/web/modules/billing/components/FullScreenUpgradeBanner.tsx create mode 100644 apps/web/modules/billing/components/PlanBadge.tsx create mode 100644 apps/web/modules/billing/components/UpgradePlanDialog.tsx create mode 100644 apps/web/modules/billing/components/WideUpgradeBanner.tsx create mode 100644 apps/web/modules/billing/components/types.ts create mode 100644 apps/web/modules/billing/upgrade-banners/FullscreenUpgradeBannerForInsightsPage.tsx create mode 100644 apps/web/modules/billing/upgrade-banners/FullscreenUpgradeBannerForRolesAndPermissions.tsx create mode 100644 apps/web/modules/billing/upgrade-banners/FullscreenUpgradeBannerForRoutingFormPage.tsx create mode 100644 apps/web/modules/billing/upgrade-banners/FullscreenUpgradeBannerForTeamsPage.tsx create mode 100644 apps/web/modules/billing/upgrade-banners/WideUpgradeBannerForMembers.tsx create mode 100644 apps/web/modules/onboarding/hooks/useOnboardingQueryParams.ts create mode 100644 apps/web/modules/onboarding/organization/details/company-email-required-view.tsx create mode 100644 apps/web/public/play_button.svg create mode 100644 apps/web/public/upgrade/full_insights.png create mode 100644 apps/web/public/upgrade/full_insights_routing.png create mode 100644 apps/web/public/upgrade/full_roles.png create mode 100644 apps/web/public/upgrade/full_teams.png create mode 100644 apps/web/public/upgrade/large_members.png create mode 100644 packages/features/ee/billing/di/containers/BillingPeriodRepository.ts create mode 100644 packages/lib/hooks/useClientOnly.ts create mode 100644 packages/lib/hooks/useFillRemainingHeight.ts diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx index 287a278b42..85fdf0db7b 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx @@ -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 ( - + {Main} ); diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx index f37447c20b..c385509478 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx @@ -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 ? : null, + showHeader, }; }; diff --git a/apps/web/app/(use-page-wrapper)/apps/routing-forms/forms/[[...pages]]/Forms.tsx b/apps/web/app/(use-page-wrapper)/apps/routing-forms/forms/[[...pages]]/Forms.tsx index 8b6b8d344b..0021565e8f 100644 --- a/apps/web/app/(use-page-wrapper)/apps/routing-forms/forms/[[...pages]]/Forms.tsx +++ b/apps/web/app/(use-page-wrapper)/apps/routing-forms/forms/[[...pages]]/Forms.tsx @@ -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(); const utils = trpc.useUtils(); @@ -95,38 +85,6 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) { const [newFormDialogState, setNewFormDialogState] = useState(null); const forms = queryRes.data?.filtered; - const features = [ - { - icon: , - title: t("create_your_first_form"), - description: t("create_your_first_form_description"), - }, - { - icon: , - title: t("create_your_first_route"), - description: t("route_to_the_right_person"), - }, - { - icon: , - title: t("reporting"), - description: t("reporting_feature"), - }, - { - icon: , - title: t("test_routing_form"), - description: t("test_preview_description"), - }, - { - icon: , - title: t("routing_forms_send_email_owner"), - description: t("routing_forms_send_email_owner_description"), - }, - { - icon: , - 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")}> - } - buttons={ -
- - - - -
- }> + {isPendingTeamPlan && } + {!isPendingTeamPlan && hasTeamPlan && ( -
+ )} ); diff --git a/apps/web/app/(use-page-wrapper)/apps/routing-forms/forms/[[...pages]]/page.tsx b/apps/web/app/(use-page-wrapper)/apps/routing-forms/forms/[[...pages]]/page.tsx index 10c3d36ba2..38d6645213 100644 --- a/apps/web/app/(use-page-wrapper)/apps/routing-forms/forms/[[...pages]]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/apps/routing-forms/forms/[[...pages]]/page.tsx @@ -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 ; + } + return ; }; diff --git a/apps/web/app/(use-page-wrapper)/insights/call-history/page.tsx b/apps/web/app/(use-page-wrapper)/insights/call-history/page.tsx index 97f6cf0753..0ff3892c1e 100644 --- a/apps/web/app/(use-page-wrapper)/insights/call-history/page.tsx +++ b/apps/web/app/(use-page-wrapper)/insights/call-history/page.tsx @@ -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 ; + const t = await getTranslate(); + + return ( + + }> + + + + ); } diff --git a/apps/web/app/(use-page-wrapper)/insights/getInsightsUpgradeBanner.tsx b/apps/web/app/(use-page-wrapper)/insights/getInsightsUpgradeBanner.tsx new file mode 100644 index 0000000000..0a3e8f9ebf --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/insights/getInsightsUpgradeBanner.tsx @@ -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 ( + + + + + + ); +} diff --git a/apps/web/app/(use-page-wrapper)/insights/layout.tsx b/apps/web/app/(use-page-wrapper)/insights/layout.tsx deleted file mode 100644 index 174cf18396..0000000000 --- a/apps/web/app/(use-page-wrapper)/insights/layout.tsx +++ /dev/null @@ -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 ( - - }> - {children} - - - ); -} diff --git a/apps/web/app/(use-page-wrapper)/insights/page.tsx b/apps/web/app/(use-page-wrapper)/insights/page.tsx index 8cd8fd36af..47fbf3820d 100644 --- a/apps/web/app/(use-page-wrapper)/insights/page.tsx +++ b/apps/web/app/(use-page-wrapper)/insights/page.tsx @@ -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 ; -}; + 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 ( + + }> + + + + ); +} diff --git a/apps/web/app/(use-page-wrapper)/insights/router-position/page.tsx b/apps/web/app/(use-page-wrapper)/insights/router-position/page.tsx index 634362705c..81cfdfc100 100644 --- a/apps/web/app/(use-page-wrapper)/insights/router-position/page.tsx +++ b/apps/web/app/(use-page-wrapper)/insights/router-position/page.tsx @@ -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 ; + const banner = await getInsightsUpgradeBanner(session.user.id); + if (banner) return banner; + + const t = await getTranslate(); + + return ( + + }> + + + + ); } diff --git a/apps/web/app/(use-page-wrapper)/insights/routing/page.tsx b/apps/web/app/(use-page-wrapper)/insights/routing/page.tsx index 886c8bffd0..65e25397f4 100644 --- a/apps/web/app/(use-page-wrapper)/insights/routing/page.tsx +++ b/apps/web/app/(use-page-wrapper)/insights/routing/page.tsx @@ -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 ; + const t = await getTranslate(); + + const userRepository = new UserRepository(prisma); + const user = await userRepository.getTimeZoneAndDefaultScheduleId({ userId: session.user.id }); + const timeZone = user?.timeZone ?? "UTC"; + + return ( + + }> + + + + ); } diff --git a/apps/web/app/(use-page-wrapper)/insights/wrong-routing/page.tsx b/apps/web/app/(use-page-wrapper)/insights/wrong-routing/page.tsx index e06188e41e..e3f6a5acbd 100644 --- a/apps/web/app/(use-page-wrapper)/insights/wrong-routing/page.tsx +++ b/apps/web/app/(use-page-wrapper)/insights/wrong-routing/page.tsx @@ -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 ; + const t = await getTranslate(); + + const userRepository = new UserRepository(prisma); + const user = await userRepository.getTimeZoneAndDefaultScheduleId({ userId: session.user.id }); + + return ( + + }> + + + + ); } diff --git a/apps/web/app/(use-page-wrapper)/onboarding/organization/details/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/details/page.tsx index fd48f95f86..328781696b 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/organization/details/page.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/details/page.tsx @@ -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 ; + } return ; }; diff --git a/apps/web/app/(use-page-wrapper)/onboarding/organization/layout.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/layout.tsx index 242b300023..77c598f17c 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/organization/layout.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/layout.tsx @@ -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 }); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index 9006b6b94f..5381ccbed6 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -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 ( + + ); + } - return ( - - ); + // For standalone teams (not in an org): show upgrade banner page + if (!team.parentId) { + return ( + + ); + } + + return null; }; const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record }) => { @@ -647,7 +661,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record {(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 }>
+ className={classNames( + "mx-auto justify-center", + !isFullWidthPage && "max-w-full lg:max-w-3xl", + rest.containerClassName + )}> {children}
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx index 61763e299a..827480b809 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx @@ -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 ( + {!team.parentId && ( +
+ +
+ )} ; } // Check if parent team has PBAC feature enabled diff --git a/apps/web/app/cache/membership.ts b/apps/web/app/cache/membership.ts index 2ec297b8eb..d36787ee15 100644 --- a/apps/web/app/cache/membership.ts +++ b/apps/web/app/cache/membership.ts @@ -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 }; }, diff --git a/apps/web/lib/pages/auth/verify-email.ts b/apps/web/lib/pages/auth/verify-email.ts index 07246877ed..327ae4f7da 100644 --- a/apps/web/lib/pages/auth/verify-email.ts +++ b/apps/web/lib/pages/auth/verify-email.ts @@ -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({ diff --git a/apps/web/modules/billing/components/FullScreenUpgradeBanner.tsx b/apps/web/modules/billing/components/FullScreenUpgradeBanner.tsx new file mode 100644 index 0000000000..e849c187a1 --- /dev/null +++ b/apps/web/modules/billing/components/FullScreenUpgradeBanner.tsx @@ -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 ( + <> + {name} + {youtubeId && ( + + )} + + ); +} + +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 ( +
+
+ {/* Left Content */} +
+
+
+ + {name} + +
+

{title}

+

{subtitle}

+ + {/* Features List */} + {features && ( + <> +
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ {showFeatures ? ( + <> +
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + + ) : ( + + )} +
+ + )} +
+ + {/* Image - mobile only, hidden when features are expanded */} +
+ +
+ +
+
+

{t("available_on")}

+ {target === "team" && } + {(target === "team" || target === "organization") && } +
+
+ {/* Buttons */} +
+ + + + {learnMoreButton && + (learnMoreButton.href ? ( + + ) : ( + + ))} +
+
+
+ + {/* Right Content - Image */} +
+ +
+
+ + {youtubeId && ( + + + + {name} + + {t("available_on_plan", { plan: target === "organization" ? t("organization") : t("team") })} + + +
+