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 <alannnc@gmail.com>

Co-authored-by: alannnc <alannnc@gmail.com>
This commit is contained in:
Omar López
2022-11-16 21:07:20 +00:00
committed by GitHub
co-authored by alannnc
parent 112efbbda2
commit ee620c7fff
9 changed files with 97 additions and 16 deletions
@@ -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"
}
+8 -5
View File
@@ -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 });
}
@@ -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 = (
<div className="flex px-5 py-5">
<Avatar
size={9}
size="sm"
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
alt="Team Logo"
className="min-h-9 min-w-9 h-9 w-9 rounded-full"
@@ -0,0 +1,38 @@
import { useRouter } from "next/router";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { TopBanner } from "@calcom/ui/components";
import { showToast } from "@calcom/ui/v2/core";
export function TeamsUpgradeBanner() {
const { t } = useLocale();
const router = useRouter();
const { data } = trpc.viewer.teams.getUpgradeable.useQuery();
const publishTeamMutation = trpc.viewer.teams.publish.useMutation({
onSuccess(data) {
router.push(data.url);
},
onError: (error) => {
showToast(error.message, "error");
},
});
if (!data) return null;
const [membership] = data;
if (!membership) return null;
return (
<TopBanner
text={t("team_upgrade_banner_description", { teamName: membership.team.name })}
variant="warning"
actions={
<button
className="border-b border-b-black"
onClick={() => {
publishTeamMutation.mutate({ teamId: membership.team.id });
}}>
{t("team_upgrade_banner_action")}
</button>
}
/>
);
}
@@ -1,2 +1,3 @@
export { CreateANewTeamForm } from "./CreateANewTeamForm";
export { TeamsListing } from "./TeamsListing";
export { TeamsUpgradeBanner } from "./TeamsUpgradeBanner";
+20 -1
View File
@@ -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) => {
+20 -2
View File
@@ -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<ReturnType<typeof ctx.prisma.team.update>>;
@@ -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;
}),
});
@@ -23,10 +23,10 @@ export function TopBanner(props: TopBannerProps) {
<div
data-testid="banner"
className={classNames(
" z-50 flex h-[40px] w-full items-start justify-between gap-8 bg-gray-50 px-4 text-center sm:items-center",
" z-50 flex min-h-[40px] w-full items-start justify-between gap-8 bg-gray-50 px-4 text-center sm:items-center",
variantClassName[variant]
)}>
<div className="flex flex-1 items-center justify-center gap-2">
<div className="flex flex-1 items-center justify-center gap-2 p-1">
<p className="flex items-center justify-center gap-2 font-sans text-sm font-medium leading-4 text-gray-900">
{["warning", "error"].includes(variant) && (
<ExclamationIcon className="h-5 w-5 text-black" aria-hidden="true" />
+4 -2
View File
@@ -11,9 +11,10 @@ import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookingBadge";
import ImpersonatingBanner from "@calcom/features/ee/impersonation/components/ImpersonatingBanner";
import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem";
import { TeamsUpgradeBanner } from "@calcom/features/ee/teams/components";
import CustomBranding from "@calcom/lib/CustomBranding";
import classNames from "@calcom/lib/classNames";
import { JOIN_SLACK, ROADMAP, DESKTOP_APP_LINK, WEBAPP_URL } from "@calcom/lib/constants";
import { DESKTOP_APP_LINK, JOIN_SLACK, ROADMAP, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import isCalcom from "@calcom/lib/isCalcom";
@@ -23,9 +24,9 @@ import { SVGComponent } from "@calcom/types/SVGComponent";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@calcom/ui/Dropdown";
import { Icon } from "@calcom/ui/Icon";
import TimezoneChangeDialog from "@calcom/ui/TimezoneChangeDialog";
@@ -147,6 +148,7 @@ const Layout = (props: LayoutProps) => {
<div className="flex h-screen overflow-hidden" data-testid="dashboard-shell">
{props.SidebarContainer || <SideBarContainer />}
<div className="flex w-0 flex-1 flex-col overflow-hidden">
<TeamsUpgradeBanner />
<ImpersonatingBanner />
<MainContainer {...props} />
</div>