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:
co-authored by
Omar López
parent
bae3bd76e5
commit
877cd4cdff
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user