From ee620c7fff97c9bf9c2bdd8694b43d4c9c10ebe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Wed, 16 Nov 2022 14:07:20 -0700 Subject: [PATCH] Adds team upgrade banner (#5541) * Adds team upgrade banner * Update TeamListItem.tsx * Update payments.ts * Copy updates and mobile style fixes * Update packages/trpc/server/routers/viewer/teams.tsx Co-authored-by: alannnc Co-authored-by: alannnc --- apps/web/public/static/locales/en/common.json | 3 +- packages/features/ee/teams/api/upgrade.ts | 13 ++++--- .../ee/teams/components/TeamListItem.tsx | 5 +-- .../teams/components/TeamsUpgradeBanner.tsx | 38 +++++++++++++++++++ .../features/ee/teams/components/index.ts | 1 + packages/features/ee/teams/lib/payments.ts | 21 +++++++++- packages/trpc/server/routers/viewer/teams.tsx | 22 ++++++++++- .../ui/components/top-banner/TopBanner.tsx | 4 +- packages/ui/v2/core/Shell.tsx | 6 ++- 9 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 packages/features/ee/teams/components/TeamsUpgradeBanner.tsx diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index b956608523..95cbc704fc 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -108,6 +108,8 @@ "link_expires": "p.s. It expires in {{expiresIn}} hours.", "upgrade_to_per_seat": "Upgrade to Per-Seat", "team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/month per seat the estimated total cost of your membership is ${{totalCost}}/month.", + "team_upgrade_banner_description": "Thank you for trialing our new team plan. We noticed your team \"{{teamName}}\" needs to be upgraded.", + "team_upgrade_banner_action": "Upgrade here", "team_upgraded_successfully": "Your team was upgraded successfully!", "use_link_to_reset_password": "Use the link below to reset your password", "hey_there": "Hey there,", @@ -1371,4 +1373,3 @@ "test_preview_description": "Test your routing form without submitting any data", "test_routing": "Test Routing" } - diff --git a/packages/features/ee/teams/api/upgrade.ts b/packages/features/ee/teams/api/upgrade.ts index 1b9f23d128..6e3b77c13d 100644 --- a/packages/features/ee/teams/api/upgrade.ts +++ b/packages/features/ee/teams/api/upgrade.ts @@ -39,13 +39,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (!team) { const prevTeam = await prisma.team.findFirstOrThrow({ where: { id } }); const metadata = teamMetadataSchema.parse(prevTeam.metadata); - if (!metadata?.requestedSlug) throw new HttpError({ statusCode: 400, message: "Missing requestedSlug" }); - + /** Legacy teams already have a slug, this will allow them to upgrade as well */ + const slug = prevTeam.slug || metadata?.requestedSlug; + if (!slug) throw new HttpError({ statusCode: 400, message: "Missing team slug" }); try { - team = await prisma.team.update({ + /** We save the metadata first to prevent duplicate payments */ + await prisma.team.update({ where: { id }, data: { - slug: metadata.requestedSlug, metadata: { paymentId: checkoutSession.id, subscriptionId: subscription.id || null, @@ -53,8 +54,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }, }, }); + /** Then we try to upgrade the slug, which may fail if a conflict came up since team creation */ + team = await prisma.team.update({ where: { id }, data: { slug } }); } catch (error) { - const { message, statusCode } = getRequestedSlugError(error, metadata.requestedSlug); + const { message, statusCode } = getRequestedSlugError(error, slug); return res.status(statusCode).json({ message }); } diff --git a/packages/features/ee/teams/components/TeamListItem.tsx b/packages/features/ee/teams/components/TeamListItem.tsx index 9f6b2d3829..f026ced0f9 100644 --- a/packages/features/ee/teams/components/TeamListItem.tsx +++ b/packages/features/ee/teams/components/TeamListItem.tsx @@ -7,6 +7,7 @@ import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { RouterOutputs, trpc } from "@calcom/trpc/react"; import { Icon } from "@calcom/ui/Icon"; +import { Avatar } from "@calcom/ui/components"; import { Button } from "@calcom/ui/components/button"; import { ButtonGroup } from "@calcom/ui/components/buttonGroup"; import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent"; @@ -21,8 +22,6 @@ import Dropdown, { import { Tooltip } from "@calcom/ui/v2/core/Tooltip"; import showToast from "@calcom/ui/v2/core/notifications"; -import Avatar from "@components/ui/Avatar"; - import { TeamRole } from "./TeamPill"; interface Props { @@ -65,7 +64,7 @@ export default function TeamListItem(props: Props) { const teamInfo = (
{ + showToast(error.message, "error"); + }, + }); + if (!data) return null; + const [membership] = data; + if (!membership) return null; + return ( + { + publishTeamMutation.mutate({ teamId: membership.team.id }); + }}> + {t("team_upgrade_banner_action")} + + } + /> + ); +} diff --git a/packages/features/ee/teams/components/index.ts b/packages/features/ee/teams/components/index.ts index cf5f5dcf68..9a2c857f44 100644 --- a/packages/features/ee/teams/components/index.ts +++ b/packages/features/ee/teams/components/index.ts @@ -1,2 +1,3 @@ export { CreateANewTeamForm } from "./CreateANewTeamForm"; export { TeamsListing } from "./TeamsListing"; +export { TeamsUpgradeBanner } from "./TeamsUpgradeBanner"; diff --git a/packages/features/ee/teams/lib/payments.ts b/packages/features/ee/teams/lib/payments.ts index 0d915a35ba..bcd5461e3d 100644 --- a/packages/features/ee/teams/lib/payments.ts +++ b/packages/features/ee/teams/lib/payments.ts @@ -4,10 +4,28 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import prisma from "@calcom/prisma"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; +/** Used to prevent double charges for the same team */ +export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => { + const team = await prisma.team.findUniqueOrThrow({ + where: { id: teamId }, + select: { metadata: true }, + }); + const metadata = teamMetadataSchema.parse(team.metadata); + /** If there's no paymentId, we need to pay this team */ + if (!metadata?.paymentId) return { url: null }; + const checkoutSession = await stripe.checkout.sessions.retrieve(metadata.paymentId); + /** If there's a pending session but it isn't paid, we need to pay this team */ + if (checkoutSession.payment_status === "paid") return { url: null }; + /** If the session is already paid we return the upgrade URL so team is updated. */ + return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` }; +}; + export const purchaseTeamSubscription = async (input: { teamId: number; seats: number; userId: number }) => { const { teamId, seats, userId } = input; + const { url } = await checkIfTeamPaymentRequired({ teamId }); + if (url) return { url }; const customer = await getStripeCustomerIdFromUserId(userId); - return await stripe.checkout.sessions.create({ + const session = await stripe.checkout.sessions.create({ customer, mode: "subscription", success_url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id={CHECKOUT_SESSION_ID}`, @@ -30,6 +48,7 @@ export const purchaseTeamSubscription = async (input: { teamId: number; seats: n }, }, }); + return { url: session.url }; }; export const cancelTeamSubscriptionFromStripe = async (teamId: number) => { diff --git a/packages/trpc/server/routers/viewer/teams.tsx b/packages/trpc/server/routers/viewer/teams.tsx index f5c840660c..1df5f7ccda 100644 --- a/packages/trpc/server/routers/viewer/teams.tsx +++ b/packages/trpc/server/routers/viewer/teams.tsx @@ -589,8 +589,7 @@ export const viewerTeamsRouter = router({ const metadata = teamMetadataSchema.safeParse(prevTeam.metadata); - if (!metadata.success || !metadata.data?.requestedSlug) - throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" }); + if (!metadata.success) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" }); // if payment needed, respond with checkout url if (IS_TEAM_BILLING_ENABLED) { @@ -607,6 +606,10 @@ export const viewerTeamsRouter = router({ return { url: checkoutSession.url }; } + if (!metadata.data?.requestedSlug) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" }); + } + const { requestedSlug, ...newMetadata } = metadata.data; let updatedTeam: Awaited>; @@ -628,4 +631,19 @@ export const viewerTeamsRouter = router({ return { url: `${WEBAPP_URL}/settings/teams/${updatedTeam.id}/profile` }; }), + /** This is a temporal endpoint so we can progressively upgrade teams to the new billing system. */ + getUpgradeable: authedProcedure.query(async ({ ctx }) => { + if (!IS_TEAM_BILLING_ENABLED) return []; + let { teams } = await ctx.prisma.user.findUniqueOrThrow({ + where: { id: ctx.user.id }, + include: { teams: { where: { role: MembershipRole.OWNER }, include: { team: true } } }, + }); + /** We only need to return teams that don't have a `subscriptionId` on their metadata */ + teams = teams.filter((m) => { + const metadata = teamMetadataSchema.safeParse(m.team.metadata); + if (metadata.success && metadata.data?.subscriptionId) return false; + return true; + }); + return teams; + }), }); diff --git a/packages/ui/components/top-banner/TopBanner.tsx b/packages/ui/components/top-banner/TopBanner.tsx index 06b2916c46..fc82687758 100644 --- a/packages/ui/components/top-banner/TopBanner.tsx +++ b/packages/ui/components/top-banner/TopBanner.tsx @@ -23,10 +23,10 @@ export function TopBanner(props: TopBannerProps) {
-
+

{["warning", "error"].includes(variant) && (

{props.SidebarContainer || }
+