refactor: Team Creation Flow [CAL-2751] (#12501)

* Create new endpoint for creating a team

* Generate a team checkout session

* Create team navigate to checkout

* Clean up

* UI changes

* Add comments

* Fix

* Type fix

* Type fix

* Type fix

* Type fixes

* Set telemetry

* Import fix

* Type fix

* Update tests

* Type fix

* fix: e2e

* fix: e2e

* fix: e2e

* fix: e2e

* Update teams.e2e.ts

* fix: e2e

---------

Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Joe Au-Yeung
2023-11-29 09:39:21 -07:00
committed by GitHub
co-authored by Omar López
parent bae3bd76e5
commit 877cd4cdff
13 changed files with 353 additions and 169 deletions
@@ -1,6 +1,6 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import InviteLinkSettingsModal from "@calcom/features/ee/teams/components/InviteLinkSettingsModal";
@@ -10,6 +10,7 @@ import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTelemetry, telemetryEventTypes } from "@calcom/lib/telemetry";
import { MembershipRole } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
@@ -33,11 +34,21 @@ type FormValues = {
const AddNewTeamMembers = () => {
const searchParams = useCompatSearchParams();
const session = useSession();
const telemetry = useTelemetry();
const teamId = searchParams?.get("id") ? Number(searchParams.get("id")) : -1;
const teamQuery = trpc.viewer.teams.get.useQuery(
{ teamId },
{ enabled: session.status === "authenticated" }
);
useEffect(() => {
const event = searchParams?.get("event");
if (event === "team_created") {
telemetry.event(telemetryEventTypes.team_created);
}
}, []);
if (session.status === "loading" || !teamQuery.data) return <AddNewTeamMemberSkeleton />;
return <AddNewTeamMembersForm defaultValues={{ members: teamQuery.data.members }} teamId={teamId} />;
@@ -170,18 +181,15 @@ export const AddNewTeamMembersForm = ({
)}
<hr className="border-subtle my-6" />
<Button
data-testid="publish-button"
EndIcon={!orgBranding ? ArrowRight : undefined}
color="primary"
className="w-full justify-center"
disabled={publishTeamMutation.isLoading}
onClick={() => {
if (orgBranding) {
router.push("/settings/teams");
} else {
publishTeamMutation.mutate({ teamId });
}
router.push(`/settings/teams/${teamId}/profile`);
}}>
{t(orgBranding ? "finish" : "team_publish")}
{t("finish")}
</Button>
</>
);
@@ -4,14 +4,15 @@ import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import slugify from "@calcom/lib/slugify";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { Avatar, Button, Form, ImageUploader, TextField, Alert, Label } from "@calcom/ui";
import { ArrowRight, Plus } from "@calcom/ui/components/icon";
import { Alert, Button, Form, TextField } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import { useOrgBranding } from "../../organizations/context/provider";
import type { NewTeamFormValues } from "../lib/types";
@@ -21,8 +22,19 @@ const querySchema = z.object({
slug: z.string().optional(),
});
const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES;
const flag = isTeamBillingEnabledClient
? {
telemetryEvent: telemetryEventTypes.team_checkout_session_created,
submitLabel: "checkout",
}
: {
telemetryEvent: telemetryEventTypes.team_created,
submitLabel: "continue",
};
export const CreateANewTeamForm = () => {
const { t } = useLocale();
const { t, isLocaleReady } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
const params = useParamsWithFallback();
@@ -42,8 +54,8 @@ export const CreateANewTeamForm = () => {
const createTeamMutation = trpc.viewer.teams.create.useMutation({
onSuccess: (data) => {
telemetry.event(telemetryEventTypes.team_created);
router.push(`/settings/teams/${data.id}/onboard-members`);
telemetry.event(flag.telemetryEvent);
router.push(data.url);
},
onError: (err) => {
if (err.message === "team_url_taken") {
@@ -81,6 +93,10 @@ export const CreateANewTeamForm = () => {
render={({ field: { value } }) => (
<>
<TextField
disabled={
/* E2e is too fast and it tries to fill this way before the form is ready */
!isLocaleReady || createTeamMutation.isLoading
}
className="mt-2"
placeholder="Acme Inc."
name="name"
@@ -128,38 +144,6 @@ export const CreateANewTeamForm = () => {
/>
</div>
<div className="mb-8">
<Controller
control={newTeamFormMethods.control}
name="logo"
render={({ field: { value } }) => (
<>
<Label>{t("team_logo")}</Label>
<div className="flex items-center">
<Avatar
alt=""
imageSrc={value}
fallback={<Plus className="text-subtle h-6 w-6" />}
size="lg"
/>
<div className="ms-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("update")}
handleAvatarChange={(newAvatar: string) => {
newTeamFormMethods.setValue("logo", newAvatar);
createTeamMutation.reset();
}}
imageSrc={value}
/>
</div>
</div>
</>
)}
/>
</div>
<div className="flex space-x-2 rtl:space-x-reverse">
<Button
disabled={createTeamMutation.isLoading}
@@ -174,7 +158,7 @@ export const CreateANewTeamForm = () => {
EndIcon={ArrowRight}
type="submit"
className="w-full justify-center">
{t("continue")}
{t(flag.submitLabel)}
</Button>
</div>
</Form>
@@ -29,6 +29,51 @@ export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => {
return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` };
};
/**
* Used to generate a checkout session when trying to create a team
*/
export const generateTeamCheckoutSession = async ({
teamName,
teamSlug,
userId,
}: {
teamName: string;
teamSlug: string;
userId: number;
}) => {
const customer = await getStripeCustomerIdFromUserId(userId);
const session = await stripe.checkout.sessions.create({
customer,
mode: "subscription",
allow_promotion_codes: true,
success_url: `${WEBAPP_URL}/api/teams/create?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${WEBAPP_URL}/settings/my-account/profile`,
line_items: [
{
/** We only need to set the base price and we can upsell it directly on Stripe's checkout */
price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID,
/**Initially it will be just the team owner */
quantity: 1,
},
],
customer_update: {
address: "auto",
},
automatic_tax: {
enabled: true,
},
metadata: {
teamName,
teamSlug,
userId,
},
});
return session;
};
/**
* Used to generate a checkout session when creating a new org (parent team) or backwards compatibility for old teams
*/
export const purchaseTeamSubscription = async (input: {
teamId: number;
seats: number;
@@ -194,7 +194,11 @@ const ProfileView = () => {
<Dialog>
<SectionBottomActions align="end">
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={Trash2}>
<Button
color="destructive"
className="border"
StartIcon={Trash2}
data-testid="disband-team-button">
{t("disband_team")}
</Button>
</DialogTrigger>
+1 -1
View File
@@ -88,7 +88,7 @@ export const IS_STRIPE_ENABLED = !!(
process.env.STRIPE_PRIVATE_KEY
);
/** Self hosted shouldn't checkout when creating teams unless required */
export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && (!IS_SELF_HOSTED || HOSTED_CAL_FEATURES);
export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && HOSTED_CAL_FEATURES;
export const FULL_NAME_LENGTH_MAX_LIMIT = 50;
export const MINUTES_TO_BOOK = process.env.NEXT_PUBLIC_MINUTES_TO_BOOK || "5";
+1
View File
@@ -18,6 +18,7 @@ export const telemetryEventTypes = {
onboardingFinished: "onboarding_finished",
onboardingStarted: "onboarding_started",
signup: "signup",
team_checkout_session_created: "team_checkout_session_created",
team_created: "team_created",
slugReplacementAction: "slug_replacement_action",
org_created: "org_created",
@@ -1,3 +1,5 @@
import { generateTeamCheckoutSession } from "@calcom/features/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -14,6 +16,34 @@ type CreateOptions = {
input: TCreateInputSchema;
};
const generateCheckoutSession = async ({
teamSlug,
teamName,
userId,
}: {
teamSlug: string;
teamName: string;
userId: number;
}) => {
if (!IS_TEAM_BILLING_ENABLED) {
console.info("Team billing is disabled, not generating a checkout session.");
return;
}
const checkoutSession = await generateTeamCheckoutSession({
teamSlug,
teamName,
userId,
});
if (!checkoutSession.url)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed retrieving a checkout session URL.",
});
return { url: checkoutSession.url, message: "Payment required to publish team" };
};
export const createHandler = async ({ ctx, input }: CreateOptions) => {
const { user } = ctx;
const { slug, name, logo } = input;
@@ -45,28 +75,26 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
if (nameCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "team_slug_exists_as_user" });
}
// Ensure that the user is not duplicating a requested team
const duplicatedRequest = await prisma.team.findFirst({
where: {
members: {
some: {
userId: ctx.user.id,
},
},
metadata: {
path: ["requestedSlug"],
equals: slug,
},
},
});
// If the user is not a part of an org, then make them pay before creating the team
if (!isOrgChildTeam) {
const checkoutSession = await generateCheckoutSession({
teamSlug: slug,
teamName: name,
userId: user.id,
});
if (duplicatedRequest) {
return duplicatedRequest;
// If there is a checkout session, return it. Otherwise, it means it's disabled.
if (checkoutSession)
return {
url: checkoutSession.url,
message: checkoutSession.message,
team: null,
};
}
const createTeam = await prisma.team.create({
const createdTeam = await prisma.team.create({
data: {
...(isOrgChildTeam ? { slug } : {}),
slug,
name,
logo,
members: {
@@ -76,17 +104,16 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
accepted: true,
},
},
metadata: !isOrgChildTeam
? {
requestedSlug: slug,
}
: undefined,
...(isOrgChildTeam && { parentId: user.organizationId }),
},
});
// Sync Services: Close.com
closeComUpsertTeamUser(createTeam, ctx.user, MembershipRole.OWNER);
closeComUpsertTeamUser(createdTeam, ctx.user, MembershipRole.OWNER);
return createTeam;
return {
url: `${WEBAPP_URL}/settings/teams/${createdTeam.id}/onboard-members`,
message: "Team billing is disabled, not generating a checkout session.",
team: createdTeam,
};
};
@@ -69,7 +69,11 @@ export function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationD
{confirmBtn ? (
confirmBtn
) : (
<DialogClose color="primary" loading={isLoading} onClick={(e) => onConfirm && onConfirm(e)}>
<DialogClose
color="primary"
loading={isLoading}
onClick={(e) => onConfirm && onConfirm(e)}
data-testid="dialog-confirmation">
{isLoading ? loadingText : confirmBtnText}
</DialogClose>
)}