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:
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user