diff --git a/.env.example b/.env.example
index 5b34bcf263..308d5a5b42 100644
--- a/.env.example
+++ b/.env.example
@@ -96,11 +96,10 @@ NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
-NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
+STRIPE_TEAM_MONTHLY_PRICE_ID=
STRIPE_WEBHOOK_SECRET=
-STRIPE_PRO_PLAN_PRODUCT_ID=
-STRIPE_PREMIUM_PLAN_PRODUCT_ID=
-STRIPE_FREE_PLAN_PRODUCT_ID=
+STRIPE_PRIVATE_KEY=
+STRIPE_CLIENT_ID=
# Use for internal Public API Keys and optional
API_KEY_PREFIX=cal_
diff --git a/apps/web/components/team/DisableTeamImpersonation.tsx b/apps/web/components/team/DisableTeamImpersonation.tsx
deleted file mode 100644
index 0208c47845..0000000000
--- a/apps/web/components/team/DisableTeamImpersonation.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useLocale } from "@calcom/lib/hooks/useLocale";
-import { trpc } from "@calcom/trpc/react";
-import Button from "@calcom/ui/Button";
-import { Badge } from "@calcom/ui/components/badge";
-import showToast from "@calcom/ui/v2/core/notifications";
-
-/** @deprecated Use `packages/features/ee/teams/components/DisableTeamImpersonation.tsx` */
-const DisableTeamImpersonation = ({ teamId, memberId }: { teamId: number; memberId: number }) => {
- const { t } = useLocale();
-
- const utils = trpc.useContext();
-
- const query = trpc.useQuery(["viewer.teams.getMembershipbyUser", { teamId, memberId }]);
-
- const mutation = trpc.useMutation("viewer.teams.updateMembership", {
- onSuccess: async () => {
- showToast(t("your_user_profile_updated_successfully"), "success");
- await utils.invalidateQueries(["viewer.teams.getMembershipbyUser"]);
- },
- async onSettled() {
- await utils.invalidateQueries(["viewer.public.i18n"]);
- },
- });
- if (query.isLoading) return <>>;
-
- return (
- <>
-
{t("settings")}
-
-
-
-
-
- {t("user_impersonation_heading")}
-
-
- {!query.data?.disableImpersonation ? t("enabled") : t("disabled")}
-
-
-
{t("team_impersonation_description")}
-
-
-
- !query.data?.disableImpersonation
- ? mutation.mutate({ teamId, memberId, disableImpersonation: true })
- : mutation.mutate({ teamId, memberId, disableImpersonation: false })
- }>
- {!query.data?.disableImpersonation ? t("disable") : t("enable")}
-
-
-
-
- >
- );
-};
-
-export default DisableTeamImpersonation;
diff --git a/apps/web/components/team/MemberChangeRoleModal.tsx b/apps/web/components/team/MemberChangeRoleModal.tsx
deleted file mode 100644
index fe7dda2776..0000000000
--- a/apps/web/components/team/MemberChangeRoleModal.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import { MembershipRole } from "@prisma/client";
-import { SyntheticEvent, useMemo, useState } from "react";
-
-import { useLocale } from "@calcom/lib/hooks/useLocale";
-import { trpc } from "@calcom/trpc/react";
-import Button from "@calcom/ui/Button";
-
-import ModalContainer from "@components/ui/ModalContainer";
-import Select from "@components/ui/form/Select";
-
-type MembershipRoleOption = {
- label: string;
- value: MembershipRole;
-};
-
-/** @deprecated Use `packages/features/ee/teams/components/MemberChangeRoleModal.tsx` */
-export default function MemberChangeRoleModal(props: {
- isOpen: boolean;
- currentMember: MembershipRole;
- memberId: number;
- teamId: number;
- initialRole: MembershipRole;
- onExit: () => void;
-}) {
- const { t } = useLocale();
-
- const options = useMemo(() => {
- return [
- {
- label: t("member"),
- value: MembershipRole.MEMBER,
- },
- {
- label: t("admin"),
- value: MembershipRole.ADMIN,
- },
- {
- label: t("owner"),
- value: MembershipRole.OWNER,
- },
- ].filter(({ value }) => value !== MembershipRole.OWNER || props.currentMember === MembershipRole.OWNER);
- }, [t, props.currentMember]);
-
- const [role, setRole] = useState(
- options.find((option) => option.value === props.initialRole) || {
- label: t("member"),
- value: MembershipRole.MEMBER,
- }
- );
- const [errorMessage, setErrorMessage] = useState("");
- const utils = trpc.useContext();
-
- const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
- async onSuccess() {
- await utils.invalidateQueries(["viewer.teams.get"]);
- props.onExit();
- },
- async onError(err) {
- setErrorMessage(err.message);
- },
- });
-
- function changeRole(e: SyntheticEvent) {
- e.preventDefault();
-
- changeRoleMutation.mutate({
- teamId: props.teamId,
- memberId: props.memberId,
- role: role.value,
- });
- }
- return (
-
- <>
-
-
-
- {t("change_member_role")}
-
-
-
-
- >
-
- );
-}
diff --git a/apps/web/components/team/MemberInvitationModal.tsx b/apps/web/components/team/MemberInvitationModal.tsx
deleted file mode 100644
index 0836c84fda..0000000000
--- a/apps/web/components/team/MemberInvitationModal.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import { MembershipRole } from "@prisma/client";
-import React, { useState, SyntheticEvent, useMemo } from "react";
-
-import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
-import { trpc } from "@calcom/trpc/react";
-import Button from "@calcom/ui/Button";
-import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/Dialog";
-import { Icon } from "@calcom/ui/Icon";
-import { TextField } from "@calcom/ui/form/fields";
-
-import { useLocale } from "@lib/hooks/useLocale";
-
-import Select from "@components/ui/form/Select";
-
-type MemberInvitationModalProps = {
- isOpen: boolean;
- team: TeamWithMembers | null;
- currentMember: MembershipRole;
- onExit: () => void;
-};
-
-type MembershipRoleOption = {
- value: MembershipRole;
- label?: string;
-};
-
-const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
-
-/** @deprecated Use `packages/features/ee/teams/components/MemberInvitationModal.tsx` */
-export default function MemberInvitationModal(props: MemberInvitationModalProps) {
- const [errorMessage, setErrorMessage] = useState("");
- const { t, i18n } = useLocale();
- const utils = trpc.useContext();
-
- const options = useMemo(() => {
- _options.forEach((option, i) => {
- _options[i].label = t(option.value.toLowerCase());
- });
- return _options;
- }, [t]);
-
- const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
- async onSuccess() {
- await utils.invalidateQueries(["viewer.teams.get"]);
- props.onExit();
- },
- async onError(err) {
- setErrorMessage(err.message);
- },
- });
-
- function inviteMember(e: SyntheticEvent) {
- e.preventDefault();
- if (!props.team) return;
-
- const target = e.target as typeof e.target & {
- elements: {
- role: { value: MembershipRole };
- inviteUser: { value: string };
- sendInviteEmail: { checked: boolean };
- };
- };
-
- inviteMemberMutation.mutate({
- teamId: props.team.id,
- language: i18n.language,
- role: target.elements["role"].value,
- usernameOrEmail: target.elements["inviteUser"].value,
- sendEmailInvitation: target.elements["sendInviteEmail"].checked,
- });
- }
-
- return (
-
-
-
-
-
-
-
-
- {t("invite_new_member")}
-
-
-
{t("invite_new_team_member")}
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/components/team/MemberList.tsx b/apps/web/components/team/MemberList.tsx
deleted file mode 100644
index 24cf37ebe7..0000000000
--- a/apps/web/components/team/MemberList.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { inferQueryOutput } from "@calcom/trpc/react";
-
-import MemberListItem from "./MemberListItem";
-
-interface Props {
- team: inferQueryOutput<"viewer.teams.get">;
- members: inferQueryOutput<"viewer.teams.get">["members"];
-}
-
-export default function MemberList(props: Props) {
- if (!props.members.length) return <>>;
-
- return (
-
-
- {props.members?.map((member) => (
-
- ))}
-
-
- );
-}
diff --git a/apps/web/components/team/MemberListItem.tsx b/apps/web/components/team/MemberListItem.tsx
deleted file mode 100644
index c729803637..0000000000
--- a/apps/web/components/team/MemberListItem.tsx
+++ /dev/null
@@ -1,260 +0,0 @@
-import { MembershipRole } from "@prisma/client";
-import { signIn } from "next-auth/react";
-import Link from "next/link";
-import { useState } from "react";
-
-import TeamAvailabilityModal from "@calcom/features/ee/teams/components/TeamAvailabilityModal";
-import { WEBAPP_URL } from "@calcom/lib/constants";
-import { useLocale } from "@calcom/lib/hooks/useLocale";
-import { inferQueryOutput, trpc } from "@calcom/trpc/react";
-import Button from "@calcom/ui/Button";
-import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
-import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
-import Dropdown, {
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@calcom/ui/Dropdown";
-import { Icon } from "@calcom/ui/Icon";
-import { Tooltip } from "@calcom/ui/Tooltip";
-import showToast from "@calcom/ui/v2/core/notifications";
-
-import useCurrentUserId from "@lib/hooks/useCurrentUserId";
-
-import Avatar from "@components/ui/Avatar";
-import ModalContainer from "@components/ui/ModalContainer";
-
-import MemberChangeRoleModal from "./MemberChangeRoleModal";
-import TeamPill, { TeamRole } from "./TeamPill";
-
-interface Props {
- team: inferQueryOutput<"viewer.teams.get">;
- member: inferQueryOutput<"viewer.teams.get">["members"][number];
-}
-
-/** @deprecated Use `packages/features/ee/teams/components/MemberListItem.tsx` */
-export default function MemberListItem(props: Props) {
- const { t } = useLocale();
-
- const utils = trpc.useContext();
- const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
- const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
- const [showImpersonateModal, setShowImpersonateModal] = useState(false);
-
- const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
- async onSuccess() {
- await utils.invalidateQueries(["viewer.teams.get"]);
- showToast(t("success"), "success");
- },
- async onError(err) {
- showToast(err.message, "error");
- },
- });
-
- const ownersInTeam = () => {
- const { members } = props.team;
- const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]);
- return owners.length;
- };
-
- const currentUserId = useCurrentUserId();
-
- const name =
- props.member.name ||
- (() => {
- const emailName = props.member.email.split("@")[0];
- return emailName.charAt(0).toUpperCase() + emailName.slice(1);
- })();
-
- const removeMember = () =>
- removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
-
- return (
-
-
-
-
-
-
- {name}
-
- {props.member.email}
-
-
-
-
- {/* Tooltip doesn't show... WHY????? */}
- {props.member.isMissingSeat && (
-
-
-
- )}
- {!props.member.accepted && }
- {props.member.role && }
-
-
-
-
- (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
- color="minimal"
- size="icon">
-
-
-
-
-
-
-
-
-
-
-
-
- {t("view_public_page")}
-
-
-
-
-
- {((props.team.membership.role === MembershipRole.OWNER &&
- (props.member.role !== MembershipRole.OWNER ||
- ownersInTeam() > 1 ||
- props.member.id !== currentUserId)) ||
- (props.team.membership.role === MembershipRole.ADMIN &&
- props.member.role !== MembershipRole.OWNER)) && (
- <>
-
- setShowChangeMemberRoleModal(true)}
- color="minimal"
- StartIcon={Icon.FiEdit2}
- className="w-full flex-shrink-0 font-normal">
- {t("edit_role")}
-
-
-
- {/* Only show impersonate box if - The user has impersonation enabled,
- They have accepted the team invite, and it is enabled for this instance */}
- {!props.member.disableImpersonation &&
- props.member.accepted &&
- process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true" && (
- <>
-
- setShowImpersonateModal(true)}
- color="minimal"
- StartIcon={Icon.FiLock}
- className="w-full flex-shrink-0 font-normal">
- {t("impersonate")}
-
-
-
- >
- )}
-
-
-
- {
- e.stopPropagation();
- }}
- color="warn"
- StartIcon={Icon.FiUserMinus}
- className="w-full font-normal">
- {t("remove_member")}
-
-
-
- {t("remove_member_confirmation_message")}
-
-
-
- >
- )}
-
-
-
-
- {showChangeMemberRoleModal && (
- setShowChangeMemberRoleModal(false)}
- />
- )}
- {showImpersonateModal && props.member.username && (
- setShowImpersonateModal(false)}>
- <>
-
-
-
- {t("impersonate")}
-
-
-
-
- >
-
- )}
- {showTeamAvailabilityModal && (
- setShowTeamAvailabilityModal(false)}>
-
-
- setShowTeamAvailabilityModal(false)}>{t("done")}
- {props.team.membership.role !== MembershipRole.MEMBER && (
-
- {t("Open Team Availability")}
-
- )}
-
-
- )}
-
- );
-}
diff --git a/apps/web/components/team/TeamCreateModal.tsx b/apps/web/components/team/TeamCreateModal.tsx
deleted file mode 100644
index a1dc2e9b3a..0000000000
--- a/apps/web/components/team/TeamCreateModal.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { useRef, useState } from "react";
-
-import { useLocale } from "@calcom/lib/hooks/useLocale";
-import { trpc } from "@calcom/trpc/react";
-import { Button } from "@calcom/ui";
-import { Icon } from "@calcom/ui/Icon";
-import { Alert } from "@calcom/ui/v2/core/Alert";
-import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/v2/core/Dialog";
-
-interface Props {
- isOpen: boolean;
- onClose: () => void;
-}
-
-export default function TeamCreate(props: Props) {
- const { t } = useLocale();
- const utils = trpc.useContext();
- const [errorMessage, setErrorMessage] = useState(null);
- const nameRef = useRef() as React.MutableRefObject;
-
- const createTeamMutation = trpc.useMutation("viewer.teams.create", {
- onSuccess: () => {
- utils.invalidateQueries(["viewer.teams.list"]);
- props.onClose();
- },
- onError: (e) => {
- setErrorMessage(e?.message || t("something_went_wrong"));
- },
- });
-
- const createTeam = () => {
- createTeamMutation.mutate({ name: nameRef?.current?.value });
- };
-
- return (
- <>
-
-
-
-
-
-
-
-
- {t("create_new_team")}
-
-
-
{t("create_new_team_description")}
-
-
-
-
-
-
- >
- );
-}
diff --git a/apps/web/components/team/TeamPill.tsx b/apps/web/components/team/TeamPill.tsx
deleted file mode 100644
index b7abb99077..0000000000
--- a/apps/web/components/team/TeamPill.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { MembershipRole } from "@prisma/client";
-import classNames from "classnames";
-
-import { useLocale } from "@lib/hooks/useLocale";
-
-type PillColor = "blue" | "green" | "red" | "yellow";
-
-interface Props {
- text: string;
- color?: PillColor;
-}
-
-/** @deprecated Use `packages/features/ee/teams/components/TeamPill.tsx` */
-export default function TeamPill(props: Props) {
- return (
-
- {props.text}
-
- );
-}
-
-export function TeamRole(props: { role: MembershipRole }) {
- const { t } = useLocale();
- const keys: Record = {
- [MembershipRole.OWNER]: undefined,
- [MembershipRole.ADMIN]: "red",
- [MembershipRole.MEMBER]: "blue",
- };
- return ;
-}
diff --git a/apps/web/components/team/UpgradeToFlexibleProModal.tsx b/apps/web/components/team/UpgradeToFlexibleProModal.tsx
deleted file mode 100644
index 10450f7c1c..0000000000
--- a/apps/web/components/team/UpgradeToFlexibleProModal.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import { useState } from "react";
-
-import { trpc } from "@calcom/trpc/react";
-import { Alert } from "@calcom/ui/Alert";
-import Button from "@calcom/ui/Button";
-import {
- Dialog,
- DialogTrigger,
- DialogContent,
- DialogClose,
- DialogFooter,
- DialogHeader,
-} from "@calcom/ui/Dialog";
-import showToast from "@calcom/ui/v2/core/notifications";
-
-import { useLocale } from "@lib/hooks/useLocale";
-
-interface Props {
- teamId: number;
-}
-
-/** @deprecated Use `packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx` */
-export function UpgradeToFlexibleProModal(props: Props) {
- const { t } = useLocale();
- const [errorMessage, setErrorMessage] = useState(null);
- const utils = trpc.useContext();
- const { data } = trpc.useQuery(["viewer.teams.getTeamSeats", { teamId: props.teamId }], {
- onError: (err) => {
- setErrorMessage(err.message);
- },
- });
- const mutation = trpc.useMutation(["viewer.teams.upgradeTeam"], {
- onSuccess: (data) => {
- // if the user does not already have a Stripe subscription, this wi
- if (data?.url) {
- window.location.href = data.url;
- }
- if (data?.success) {
- utils.invalidateQueries(["viewer.teams.get"]);
- showToast(t("team_upgraded_successfully"), "success");
- }
- },
- onError: (err) => {
- setErrorMessage(err.message);
- },
- });
-
- return (
- {
- setErrorMessage(null);
- }}>
-
- Upgrade Now
-
-
-
-
- {t("changed_team_billing_info")}
- {data && (
-
- {t("team_upgrade_seats_details", {
- memberCount: data.totalMembers,
- unpaidCount: data.missingSeats,
- seatPrice: 12,
- totalCost: (data.totalMembers - data.freeSeats) * 12 + 12,
- })}
-
- )}
-
- {errorMessage && (
-
- )}
-
-
- {t("close")}
-
-
- {
- setErrorMessage(null);
- mutation.mutate({ teamId: props.teamId });
- }}>
- {t("upgrade_to_per_seat")}
-
-
-
-
- );
-}
diff --git a/apps/web/pages/getting-started/[[...step]].tsx b/apps/web/pages/getting-started/[[...step]].tsx
index 6e6540d111..e088354175 100644
--- a/apps/web/pages/getting-started/[[...step]].tsx
+++ b/apps/web/pages/getting-started/[[...step]].tsx
@@ -8,11 +8,11 @@ import { getSession } from "@calcom/lib/auth";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { User } from "@calcom/prisma/client";
import { Button } from "@calcom/ui/components/button";
+import { StepCard } from "@calcom/ui/v2/core/StepCard";
+import { Steps } from "@calcom/ui/v2/core/Steps";
import prisma from "@lib/prisma";
-import { StepCard } from "@components/getting-started/components/StepCard";
-import { Steps } from "@components/getting-started/components/Steps";
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
import { SetupAvailability } from "@components/getting-started/steps-views/SetupAvailability";
import UserProfile from "@components/getting-started/steps-views/UserProfile";
diff --git a/apps/web/pages/settings/billing/index.tsx b/apps/web/pages/settings/billing/index.tsx
index 11a1072fbb..cb638a0f04 100644
--- a/apps/web/pages/settings/billing/index.tsx
+++ b/apps/web/pages/settings/billing/index.tsx
@@ -1,7 +1,9 @@
+import { useRouter } from "next/router";
import { useState } from "react";
import { HelpScout, useChat } from "react-live-chat-loader";
import { classNames } from "@calcom/lib";
+import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
@@ -37,6 +39,9 @@ const BillingView = () => {
const isPro = user?.plan === "PRO";
const [, loadChat] = useChat();
const [showChat, setShowChat] = useState(false);
+ const router = useRouter();
+ const returnTo = router.asPath;
+ const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
const onContactSupportClick = () => {
setShowChat(true);
@@ -63,7 +68,7 @@ const BillingView = () => {
description={t("billing_manage_details_description")}>
{t("billing_portal")}
diff --git a/apps/web/pages/settings/teams/[id]/billing.tsx b/apps/web/pages/settings/teams/[id]/billing.tsx
new file mode 100644
index 0000000000..f127a20d53
--- /dev/null
+++ b/apps/web/pages/settings/teams/[id]/billing.tsx
@@ -0,0 +1 @@
+export { default } from "@calcom/features/ee/teams/pages/team-billing-view";
diff --git a/apps/web/pages/settings/teams/[id]/onboard-members.tsx b/apps/web/pages/settings/teams/[id]/onboard-members.tsx
new file mode 100644
index 0000000000..3d8bb6bd1e
--- /dev/null
+++ b/apps/web/pages/settings/teams/[id]/onboard-members.tsx
@@ -0,0 +1,26 @@
+import Head from "next/head";
+
+import AddNewTeamMembers from "@calcom/features/ee/teams/components/AddNewTeamMembers";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import WizardLayout from "@calcom/ui/v2/core/layouts/WizardLayout";
+
+const OnboardTeamMembersPage = () => {
+ const { t } = useLocale();
+ return (
+ <>
+
+ {t("add_team_members")}
+
+
+
+ >
+ );
+};
+
+OnboardTeamMembersPage.getLayout = (page: React.ReactElement) => (
+
+ {page}
+
+);
+
+export default OnboardTeamMembersPage;
diff --git a/apps/web/pages/settings/teams/index.ts b/apps/web/pages/settings/teams/index.ts
new file mode 100644
index 0000000000..1f80faa1c1
--- /dev/null
+++ b/apps/web/pages/settings/teams/index.ts
@@ -0,0 +1 @@
+export { default } from "@calcom/features/ee/teams/pages/team-listing-view";
diff --git a/apps/web/pages/settings/teams/new/[[...step]].tsx b/apps/web/pages/settings/teams/new/[[...step]].tsx
deleted file mode 100644
index cf06837231..0000000000
--- a/apps/web/pages/settings/teams/new/[[...step]].tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import Head from "next/head";
-import { useRouter } from "next/router";
-import { useState } from "react";
-import { z } from "zod";
-
-// import TeamGeneralSettings from "@calcom/features/teams/createNewTeam/TeamGeneralSettings";
-import AddNewTeamMembers from "@calcom/features/ee/teams/components/v2/AddNewTeamMembers";
-import CreateNewTeam from "@calcom/features/ee/teams/components/v2/CreateNewTeam";
-import { useLocale } from "@calcom/lib/hooks/useLocale";
-
-import { StepCard } from "@components/getting-started/components/StepCard";
-import { Steps } from "@components/getting-started/components/Steps";
-
-const INITIAL_STEP = "create-a-new-team";
-// TODO: Add teams general settings "general-settings"
-const steps = ["create-a-new-team", "add-team-members"] as const;
-
-const stepTransform = (step: typeof steps[number]) => {
- const stepIndex = steps.indexOf(step);
- if (stepIndex > -1) {
- return steps[stepIndex];
- }
- return INITIAL_STEP;
-};
-
-const stepRouteSchema = z.object({
- step: z.array(z.enum(steps)).default([INITIAL_STEP]),
-});
-
-const CreateNewTeamPage = () => {
- const router = useRouter();
-
- const { t } = useLocale();
- const [teamId, setTeamId] = useState();
-
- const result = stepRouteSchema.safeParse(router.query);
- const currentStep = result.success ? result.data.step[0] : INITIAL_STEP;
-
- const headers = [
- {
- title: `${t("create_new_team")}`,
- subtitle: [`${t("create_new_team_description")}`],
- },
- // {
- // title: `${t("general_settings")}`,
- // subtitle: [`${t("general_settings_description")}`],
- // },
- {
- title: `${t("add_team_members")}`,
- subtitle: [`${t("add_team_members_description")}`],
- },
- ];
-
- const goToIndex = (index: number) => {
- const newStep = steps[index];
- router.push(
- {
- pathname: `/settings/teams/new/${stepTransform(newStep)}`,
- },
- undefined
- );
- };
-
- const currentStepIndex = steps.indexOf(currentStep);
-
- return (
-
-
-
Create a new Team
-
-
-
-
-
-
-
-
- {headers[currentStepIndex]?.title || "Undefined title"}
-
-
-
- {headers[currentStepIndex]?.subtitle}
-
-
-
-
-
- {currentStep === "create-a-new-team" && (
- {
- goToIndex(1);
- }}
- setTeamId={(teamId: number) => setTeamId(teamId)}
- />
- )}
-
- {/* {currentStep === "general-settings" && (
- goToIndex(2)} />
- )} */}
-
- {currentStep === "add-team-members" && teamId && }
-
-
-
-
-
- );
-};
-
-export default CreateNewTeamPage;
diff --git a/apps/web/pages/settings/teams/new/index.tsx b/apps/web/pages/settings/teams/new/index.tsx
new file mode 100644
index 0000000000..fd0fa1e9be
--- /dev/null
+++ b/apps/web/pages/settings/teams/new/index.tsx
@@ -0,0 +1,20 @@
+import Head from "next/head";
+
+import { CreateANewTeamForm } from "@calcom/features/ee/teams/components";
+import { getLayout } from "@calcom/ui/v2/core/layouts/WizardLayout";
+
+const CreateNewTeamPage = () => {
+ return (
+ <>
+
+ Create a new Team
+
+
+
+ >
+ );
+};
+
+CreateNewTeamPage.getLayout = getLayout;
+
+export default CreateNewTeamPage;
diff --git a/apps/web/pages/teams/index.tsx b/apps/web/pages/teams/index.tsx
index 2e973e25e3..8497063478 100644
--- a/apps/web/pages/teams/index.tsx
+++ b/apps/web/pages/teams/index.tsx
@@ -1,68 +1,23 @@
-import { useState } from "react";
-
+import { TeamsListing } from "@calcom/features/ee/teams/components";
+import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
-import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import { Button } from "@calcom/ui/components/button";
import { Shell } from "@calcom/ui/v2";
-import { Alert } from "@calcom/ui/v2/core/Alert";
-import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
-
-import SkeletonLoaderTeamList from "@components/team/SkeletonloaderTeamList";
-import TeamCreateModal from "@components/team/TeamCreateModal";
-import TeamList from "@components/team/TeamList";
function Teams() {
const { t } = useLocale();
- const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
- const [errorMessage, setErrorMessage] = useState("");
-
- const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
- onError: (e) => {
- setErrorMessage(e.message);
- },
- });
-
- const teams = data?.filter((m) => m.accepted) || [];
- const invites = data?.filter((m) => !m.accepted) || [];
-
return (
setShowCreateTeamModal(true)}>
+
{t("new")}
}>
- <>
- {!!errorMessage && }
- {showCreateTeamModal && (
- setShowCreateTeamModal(false)} />
- )}
- {invites.length > 0 && (
-
-
{t("open_invitations")}
-
-
- )}
- {isLoading && }
- {!teams.length && !isLoading && (
- setShowCreateTeamModal(true)}>
- {t("create_team")}
-
- }
- buttonOnClick={() => setShowCreateTeamModal(true)}
- />
- )}
- {teams.length > 0 && }
- >
+
);
}
diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts
new file mode 100644
index 0000000000..f649608fb2
--- /dev/null
+++ b/apps/web/playwright/teams.e2e.ts
@@ -0,0 +1,64 @@
+import { expect } from "@playwright/test";
+
+import { test } from "./lib/fixtures";
+
+test.describe.configure({ mode: "parallel" });
+
+test.describe("Teams", () => {
+ test.afterEach(async ({ users }) => {
+ await users.deleteAll();
+ });
+
+ test("Can create teams via Wizard", async ({ page, users, prisma }) => {
+ const user = await users.create();
+ const inviteeEmail = `${user.username}+invitee@example.com`;
+ await user.login();
+ await page.goto("/teams");
+
+ // Expect teams to be empty
+ await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
+
+ await test.step("Can create team", async () => {
+ // Click text=Create Team
+ await page.locator("text=Create Team").click();
+ await page.waitForURL("/settings/teams/new");
+ // Fill input[name="name"]
+ await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
+ // Click text=Continue
+ await page.locator("text=Continue").click();
+ await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
+ await page.waitForSelector('[data-testid="pending-member-list"]');
+ expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
+ });
+
+ await test.step("Can add members", async () => {
+ // Click [data-testid="new-member-button"]
+ await page.locator('[data-testid="new-member-button"]').click();
+ // Fill [placeholder="email\@example\.com"]
+ await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
+ // Click [data-testid="invite-new-member-button"]
+ await page.locator('[data-testid="invite-new-member-button"]').click();
+ await expect(page.locator(`li:has-text("${inviteeEmail}PendingMemberNot on Cal.com")`)).toBeVisible();
+ expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
+ });
+
+ await test.step("Can remove members", async () => {
+ const removeMemberButton = page.locator('[data-testid="remove-member-button"]');
+ await removeMemberButton.click();
+ await removeMemberButton.waitFor({ state: "hidden" });
+ expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
+ });
+
+ await test.step("Can publish team", async () => {
+ await page.locator("text=Publish team").click();
+ await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
+ });
+
+ await test.step("Can disband team", async () => {
+ await page.locator("text=Delete Team").click();
+ await page.locator("text=Yes, disband team").click();
+ await page.waitForURL("/teams");
+ await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
+ });
+ });
+});
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index c2839309eb..22706a4b0f 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -502,6 +502,7 @@
"add_team_members_description": "Invite others to join your team",
"add_team_member": "Add team member",
"invite_new_member": "Invite a new team member",
+ "invite_new_member_description": "Note: This will <1>cost an extra seat ($15/m)1> on your subscription.",
"invite_new_team_member": "Invite someone to your team.",
"change_member_role": "Change team member role",
"disable_cal_branding": "Disable Cal branding",
@@ -1339,6 +1340,21 @@
"limit_future_bookings_description": "Limit how far in the future this event can be booked",
"no_event_types": "No event types setup",
"no_event_types_description": "{{name}} has not setup any event types for you to book.",
+ "billing_frequency": "Billing Frequency",
+ "monthly": "Monthly",
+ "yearly": "Yearly",
+ "checkout": "Checkout",
+ "your_team_disbanded_successfully": "Your team has been disbanded successfully",
+ "error_creating_team": "Error creating team",
+ "you": "You",
+ "send_email": "Send email",
+ "member_already_invited": "Member has already been invited",
+ "enter_email_or_username": "Enter an email or username",
+ "team_name_taken": "This name is already taken",
+ "must_enter_team_name": "Must enter a team name",
+ "team_url_required": "Must enter a team URL",
+ "team_url_taken": "This URL is already taken",
+ "team_publish": "Publish team",
"number_sms_notifications": "Phone number (SMS\u00a0notifications)",
"attendee_email_workflow": "Attendee email",
"attendee_email_info": "The person booking's email",
diff --git a/packages/app-store/stripepayment/api/portal.ts b/packages/app-store/stripepayment/api/portal.ts
index f04d3b4be8..1aaedd3d85 100644
--- a/packages/app-store/stripepayment/api/portal.ts
+++ b/packages/app-store/stripepayment/api/portal.ts
@@ -1,23 +1,35 @@
import type { NextApiRequest, NextApiResponse } from "next";
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
+
import { getStripeCustomerIdFromUserId } from "../lib/customer";
import stripe from "../lib/server";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- if (req.method === "POST" || req.method === "GET") {
- const customerId = await getStripeCustomerIdFromUserId(req.session!.user.id);
+ if (req.method !== "POST" && req.method !== "GET")
+ return res.status(405).json({ message: "Method not allowed" });
+ const { referer } = req.headers;
- if (!customerId) {
- res.status(500).json({ message: "Missing customer id" });
- return;
- }
+ if (!referer) return res.status(400).json({ message: "Missing referrer" });
- const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`;
- const stripeSession = await stripe.billingPortal.sessions.create({
- customer: customerId,
- return_url,
- });
+ if (!req.session?.user?.id) return res.status(401).json({ message: "Not authenticated" });
- res.redirect(302, stripeSession.url);
+ // If accessing a user's portal
+ const customerId = await getStripeCustomerIdFromUserId(req.session.user.id);
+ if (!customerId) return res.status(400).json({ message: "CustomerId not found in stripe" });
+
+ let return_url = `${WEBAPP_URL}/settings/billing`;
+
+ if (typeof req.query.returnTo === "string") {
+ const safeRedirectUrl = getSafeRedirectUrl(req.query.returnTo);
+ if (safeRedirectUrl) return_url = safeRedirectUrl;
}
+
+ const stripeSession = await stripe.billingPortal.sessions.create({
+ customer: customerId,
+ return_url,
+ });
+
+ res.redirect(302, stripeSession.url);
}
diff --git a/packages/app-store/stripepayment/lib/team-billing.ts b/packages/app-store/stripepayment/lib/team-billing.ts
index e9868fead6..3cd49e0405 100644
--- a/packages/app-store/stripepayment/lib/team-billing.ts
+++ b/packages/app-store/stripepayment/lib/team-billing.ts
@@ -1,7 +1,7 @@
import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
+import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import Stripe from "stripe";
-import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
@@ -42,21 +42,6 @@ async function getMembersMissingSeats(teamId: number) {
};
}
-// a helper for the upgrade dialog
-export async function getTeamSeatStats(teamId: number) {
- const { membersMissingSeats, members, ownerIsMissingSeat } = await getMembersMissingSeats(teamId);
- return {
- totalMembers: members.length,
- // members we need not pay for
- freeSeats: members.length - membersMissingSeats.length,
- // members we need to pay for (if not hosted cal, team billing is disabled)
- missingSeats: HOSTED_CAL_FEATURES ? membersMissingSeats.length : 0,
- // members who have been hidden from view
- hiddenMembers: members.filter((m) => m.user.plan === UserPlan.FREE).length,
- ownerIsMissingSeat: HOSTED_CAL_FEATURES ? ownerIsMissingSeat : false,
- };
-}
-
async function updatePerSeatQuantity(subscription: Stripe.Subscription, quantity: number) {
const perSeatProPlan = subscription.items.data.find((item) => item.plan.id === getPerSeatProPlanPrice());
// if their subscription does not contain Per Seat Pro, add it—otherwise, update the existing one
@@ -249,16 +234,17 @@ async function createCheckoutSession(
return await stripe.checkout.sessions.create(params);
}
-// verifies that the subscription's quantity is correct for the number of members the team has
-// this is a function is a dev util, but could be utilized as a sync technique in the future
-export async function ensureSubscriptionQuantityCorrectness(userId: number, teamId: number) {
- const subscription = await getProPlanSubscription(userId);
- const stripeQuantity =
- subscription?.items.data.find((item) => item.plan.id === getPerSeatProPlanPrice())?.quantity ?? 0;
-
- const { membersMissingSeats } = await getMembersMissingSeats(teamId);
- // correct the quantity if missing seats is out of sync with subscription quantity
- if (subscription && membersMissingSeats.length !== stripeQuantity) {
- await updatePerSeatQuantity(subscription, membersMissingSeats.length);
+export function getRequestedSlugError(error: unknown, requestedSlug: string) {
+ let message = `Unknown error`;
+ let statusCode = 500;
+ // This covers the edge case if an unpublished team takes too long to publish
+ // and another team gets the requestedSlug first.
+ // https://www.prisma.io/docs/reference/api-reference/error-reference#p2002
+ if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
+ statusCode = 400;
+ message = `It seems like the requestedSlug: '${requestedSlug}' is already taken. Please contact support at help@cal.com so we can resolve this issue.`;
+ } else if (error instanceof Error) {
+ message = error.message;
}
+ return { message, statusCode };
}
diff --git a/packages/features/ee/payments/api/webhook.ts b/packages/features/ee/payments/api/webhook.ts
index 775ea0cade..7dc18d2225 100644
--- a/packages/features/ee/payments/api/webhook.ts
+++ b/packages/features/ee/payments/api/webhook.ts
@@ -7,15 +7,13 @@ import stripe from "@calcom/app-store/stripepayment/lib/server";
import EventManager from "@calcom/core/EventManager";
import { sendScheduledEmails } from "@calcom/emails";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
+import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
+import { HttpError as HttpCode } from "@calcom/lib/http-error";
+import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
-import { IS_PRODUCTION } from "@lib/config/constants";
-import { HttpError as HttpCode } from "@lib/core/http/error";
-
-import { getTranslation } from "@server/lib/i18n";
-
export const config = {
api: {
bodyParser: false,
diff --git a/packages/features/ee/teams/api/upgrade.ts b/packages/features/ee/teams/api/upgrade.ts
index 09b968fedb..1b9f23d128 100644
--- a/packages/features/ee/teams/api/upgrade.ts
+++ b/packages/features/ee/teams/api/upgrade.ts
@@ -1,20 +1,71 @@
import type { NextApiRequest, NextApiResponse } from "next";
+import Stripe from "stripe";
+import { z } from "zod";
-import { upgradeTeam } from "@calcom/app-store/stripepayment/lib/team-billing";
-import { getSession } from "@calcom/lib/auth";
+import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing";
+import stripe from "@calcom/features/ee/payments/server/stripe";
+import { ensureSession } from "@calcom/lib/auth";
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import { HttpError } from "@calcom/lib/http-error";
+import { defaultHandler, defaultResponder } from "@calcom/lib/server";
+import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
+import prisma from "@calcom/prisma";
+import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
-export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- if (req.method === "GET") {
- const session = await getSession({ req });
+const querySchema = z.object({
+ team: z.string().transform((val) => parseInt(val)),
+ session_id: z.string().min(1),
+});
- if (!session) {
- res.status(401).json({ message: "You must be logged in to do this" });
- return;
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ await ensureSession({ req });
+
+ const { team: id, session_id } = querySchema.parse(req.query);
+
+ const checkoutSession = await stripe.checkout.sessions.retrieve(session_id, {
+ expand: ["subscription"],
+ });
+ if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
+
+ const subscription = checkoutSession.subscription as Stripe.Subscription;
+ if (checkoutSession.payment_status !== "paid")
+ throw new HttpError({ statusCode: 402, message: "Payment required" });
+
+ /* Check if a team was already upgraded with this payment intent */
+ let team = await prisma.team.findFirst({
+ where: { metadata: { path: ["paymentId"], equals: checkoutSession.id } },
+ });
+
+ 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" });
+
+ try {
+ team = await prisma.team.update({
+ where: { id },
+ data: {
+ slug: metadata.requestedSlug,
+ metadata: {
+ paymentId: checkoutSession.id,
+ subscriptionId: subscription.id || null,
+ subscriptionItemId: subscription.items.data[0].id || null,
+ },
+ },
+ });
+ } catch (error) {
+ const { message, statusCode } = getRequestedSlugError(error, metadata.requestedSlug);
+ return res.status(statusCode).json({ message });
}
- await upgradeTeam(session.user.id, Number(req.query.team));
-
- // redirect to team screen
- res.redirect(302, `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/teams/${req.query.team}?upgraded=true`);
+ // Sync Services: Close.com
+ closeComUpdateTeam(prevTeam, team);
}
+
+ // redirect to team screen
+ res.redirect(302, `${WEBAPP_URL}/settings/teams/${team.id}/profile?upgraded=true`);
}
+
+export default defaultHandler({
+ GET: Promise.resolve({ default: defaultResponder(handler) }),
+});
diff --git a/packages/features/ee/teams/components/AddNewTeamMembers.tsx b/packages/features/ee/teams/components/AddNewTeamMembers.tsx
new file mode 100644
index 0000000000..70f49b2446
--- /dev/null
+++ b/packages/features/ee/teams/components/AddNewTeamMembers.tsx
@@ -0,0 +1,180 @@
+import { useSession } from "next-auth/react";
+import { useRouter } from "next/router";
+import { useState } from "react";
+import { z } from "zod";
+
+import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
+import { classNames } from "@calcom/lib";
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { inferQueryOutput, trpc } from "@calcom/trpc/react";
+import { Icon } from "@calcom/ui";
+import { Avatar, Badge, Button } from "@calcom/ui/components";
+import { showToast } from "@calcom/ui/v2/core";
+import { SkeletonContainer, SkeletonText } from "@calcom/ui/v2/core/skeleton";
+
+const querySchema = z.object({
+ id: z.string().transform((val) => parseInt(val)),
+});
+
+type TeamMember = inferQueryOutput<"viewer.teams.get">["members"][number];
+
+type FormValues = {
+ members: TeamMember[];
+};
+
+const AddNewTeamMembers = () => {
+ const session = useSession();
+ const router = useRouter();
+ const { id: teamId } = router.isReady ? querySchema.parse(router.query) : { id: -1 };
+ const teamQuery = trpc.useQuery(["viewer.teams.get", { teamId }], { enabled: router.isReady });
+ if (session.status === "loading" || !teamQuery.data) return ;
+
+ return ;
+};
+
+const AddNewTeamMembersForm = ({ defaultValues, teamId }: { defaultValues: FormValues; teamId: number }) => {
+ const { t, i18n } = useLocale();
+ const [memberInviteModal, setMemberInviteModal] = useState(false);
+ const utils = trpc.useContext();
+ const router = useRouter();
+ const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
+ async onSuccess() {
+ await utils.invalidateQueries(["viewer.teams.get"]);
+ setMemberInviteModal(false);
+ },
+ onError: (error) => {
+ showToast(error.message, "error");
+ },
+ });
+ const publishTeamMutation = trpc.useMutation("viewer.teams.publish", {
+ onSuccess(data) {
+ router.push(data.url);
+ },
+ onError: (error) => {
+ showToast(error.message, "error");
+ },
+ });
+
+ return (
+ <>
+
+
+ {defaultValues.members.map((member, index) => (
+
+ ))}
+
+
setMemberInviteModal(true)}
+ className="mt-6 w-full justify-center">
+ {t("add_team_member")}
+
+
+ setMemberInviteModal(false)}
+ onSubmit={(values) => {
+ inviteMemberMutation.mutate({
+ teamId,
+ language: i18n.language,
+ role: values.role.value,
+ usernameOrEmail: values.emailOrUsername,
+ sendEmailInvitation: values.sendInviteEmail,
+ });
+ }}
+ members={defaultValues.members}
+ />
+
+ {
+ publishTeamMutation.mutate({ teamId });
+ }}>
+ {t("team_publish")}
+
+ >
+ );
+};
+
+export default AddNewTeamMembers;
+
+const AddNewTeamMemberSkeleton = () => {
+ return (
+
+
+
+ );
+};
+
+const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: number }) => {
+ const { member, index, teamId } = props;
+ const { t } = useLocale();
+ const utils = trpc.useContext();
+
+ const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
+ async onSuccess() {
+ await utils.invalidateQueries(["viewer.teams.get"]);
+ showToast("Member removed", "success");
+ },
+ async onError(err) {
+ showToast(err.message, "error");
+ },
+ });
+
+ return (
+
+
+
+
+
+
{member.name || member.email || t("team_member")}
+ {/* Assume that the first member of the team is the creator */}
+ {index === 0 &&
{t("you")} }
+ {!member.accepted &&
{t("pending")} }
+ {member.role === "MEMBER" &&
{t("member")} }
+ {member.role === "ADMIN" &&
{t("admin")} }
+
+ {member.username ? (
+
{`${WEBAPP_URL}/${member.username}`}
+ ) : (
+
{t("not_on_cal")}
+ )}
+
+
+ {member.role !== "OWNER" && (
+ {
+ removeMemberMutation.mutate({ teamId, memberId: member.id });
+ }}
+ />
+ )}
+
+ );
+};
diff --git a/packages/features/ee/teams/components/CreateANewTeamForm.tsx b/packages/features/ee/teams/components/CreateANewTeamForm.tsx
new file mode 100644
index 0000000000..f786264ca2
--- /dev/null
+++ b/packages/features/ee/teams/components/CreateANewTeamForm.tsx
@@ -0,0 +1,129 @@
+import { useRouter } from "next/router";
+import { Controller, useForm } from "react-hook-form";
+
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import slugify from "@calcom/lib/slugify";
+import { trpc } from "@calcom/trpc/react";
+import { Icon } from "@calcom/ui";
+import { Avatar, Button } from "@calcom/ui/components";
+import { Form, TextField } from "@calcom/ui/components/form";
+import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
+
+import { NewTeamFormValues } from "../lib/types";
+
+export const CreateANewTeamForm = () => {
+ const { t } = useLocale();
+ const router = useRouter();
+ const newTeamFormMethods = useForm();
+
+ const createTeamMutation = trpc.useMutation(["viewer.teams.create"], {
+ onSuccess: (data) => {
+ router.push(`/settings/teams/${data.id}/onboard-members`);
+ },
+ });
+
+ const validateTeamSlugQuery = trpc.useQuery(
+ ["viewer.teams.validateTeamSlug", { slug: newTeamFormMethods.watch("slug") }],
+ {
+ enabled: false,
+ refetchOnWindowFocus: false,
+ }
+ );
+
+ const validateTeamSlug = async () => {
+ await validateTeamSlugQuery.refetch();
+ if (validateTeamSlugQuery.isFetched) return validateTeamSlugQuery.data || t("team_url_taken");
+ };
+
+ return (
+ <>
+
+ >
+ );
+};
diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx
index 8a01e6b3eb..76c2017009 100644
--- a/packages/features/ee/teams/components/MemberInvitationModal.tsx
+++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx
@@ -1,127 +1,145 @@
import { MembershipRole } from "@prisma/client";
-import React, { useState, SyntheticEvent, useMemo } from "react";
+import { Trans } from "next-i18next";
+import { useMemo } from "react";
+import { Controller, useForm } from "react-hook-form";
+import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
-import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
-import { trpc } from "@calcom/trpc/react";
import { Button, TextField } from "@calcom/ui/components";
+import CheckboxField from "@calcom/ui/components/form/checkbox/Checkbox";
+import { Form } from "@calcom/ui/form/fields";
import { Dialog, DialogContent, DialogFooter, Select } from "@calcom/ui/v2";
+import { PendingMember } from "../lib/types";
+
type MemberInvitationModalProps = {
isOpen: boolean;
- team: TeamWithMembers | null;
- currentMember: MembershipRole;
onExit: () => void;
+ onSubmit: (values: NewMemberForm) => void;
+ members: PendingMember[];
};
type MembershipRoleOption = {
value: MembershipRole;
- label?: string;
+ label: string;
};
-const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
+export interface NewMemberForm {
+ emailOrUsername: string;
+ role: MembershipRoleOption;
+ sendInviteEmail: boolean;
+}
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
- const [errorMessage, setErrorMessage] = useState("");
- const { t, i18n } = useLocale();
- const utils = trpc.useContext();
+ const { t } = useLocale();
- const options = useMemo(() => {
- _options.forEach((option, i) => {
- _options[i].label = t(option.value.toLowerCase());
- });
- return _options;
+ const options: MembershipRoleOption[] = useMemo(() => {
+ return [
+ { value: "MEMBER", label: t("member") },
+ { value: "ADMIN", label: t("admin") },
+ { value: "OWNER", label: t("owner") },
+ ];
}, [t]);
- const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
- async onSuccess() {
- await utils.invalidateQueries(["viewer.teams.get"]);
- props.onExit();
- },
- async onError(err) {
- setErrorMessage(err.message);
- },
- });
+ const newMemberFormMethods = useForm();
- function inviteMember(e: SyntheticEvent) {
- e.preventDefault();
- if (!props.team) return;
-
- const target = e.target as typeof e.target & {
- elements: {
- role: { value: MembershipRole };
- inviteUser: { value: string };
- sendInviteEmail: { checked: boolean };
- };
- };
-
- inviteMemberMutation.mutate({
- teamId: props.team.id,
- language: i18n.language,
- role: target.elements["role"].value,
- usernameOrEmail: target.elements["inviteUser"].value,
- sendEmailInvitation: target.elements["sendInviteEmail"].checked,
- });
- }
+ const validateUniqueInvite = (value: string) => {
+ return !(
+ props.members.some((member) => member?.username === value) ||
+ props.members.some((member) => member?.email === value)
+ );
+ };
return (
-
+ {
+ props.onExit();
+ newMemberFormMethods.reset();
+ }}>
- Note: This will cost an extra seat ($12/m) on
- your subscription if this invitee does not have a TEAM account.
-
+ IS_TEAM_BILLING_ENABLED ? (
+
+
+ Note: This will cost an extra seat ($15/m) {" "}
+ on your subscription.
+
+
+ ) : (
+ ""
+ )
}>
-
+
);
diff --git a/apps/web/components/team/SkeletonloaderTeamList.tsx b/packages/features/ee/teams/components/SkeletonloaderTeamList.tsx
similarity index 100%
rename from apps/web/components/team/SkeletonloaderTeamList.tsx
rename to packages/features/ee/teams/components/SkeletonloaderTeamList.tsx
diff --git a/apps/web/components/team/TeamList.tsx b/packages/features/ee/teams/components/TeamList.tsx
similarity index 100%
rename from apps/web/components/team/TeamList.tsx
rename to packages/features/ee/teams/components/TeamList.tsx
diff --git a/apps/web/components/team/TeamListItem.tsx b/packages/features/ee/teams/components/TeamListItem.tsx
similarity index 80%
rename from apps/web/components/team/TeamListItem.tsx
rename to packages/features/ee/teams/components/TeamListItem.tsx
index 214daf0763..a29bf8fc08 100644
--- a/apps/web/components/team/TeamListItem.tsx
+++ b/packages/features/ee/teams/components/TeamListItem.tsx
@@ -1,5 +1,6 @@
import { MembershipRole } from "@prisma/client";
import Link from "next/link";
+import { useRouter } from "next/router";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
@@ -11,11 +12,11 @@ import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
import { Dialog, DialogTrigger } from "@calcom/ui/v2/core/Dialog";
import Dropdown, {
+ DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
- DropdownItem,
} from "@calcom/ui/v2/core/Dropdown";
import { Tooltip } from "@calcom/ui/v2/core/Tooltip";
import showToast from "@calcom/ui/v2/core/notifications";
@@ -72,7 +73,7 @@ export default function TeamListItem(props: Props) {
{team.name}
- {process.env.NEXT_PUBLIC_WEBSITE_URL}/team/{team.slug}
+ {team.slug ? `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}` : "Unpublished team"}
@@ -95,7 +96,7 @@ export default function TeamListItem(props: Props) {
teamInfo
)}
- {isInvitee && (
+ {isInvitee ? (
<>
@@ -133,25 +134,26 @@ export default function TeamListItem(props: Props) {
>
- )}
- {!isInvitee && (
+ ) : (
-
- {
- navigator.clipboard.writeText(
- process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug
- );
- showToast(t("link_copied"), "success");
- }}
- size="icon"
- StartIcon={Icon.FiLink}
- combined
- />
-
+ {team.slug && (
+
+ {
+ navigator.clipboard.writeText(
+ process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug
+ );
+ showToast(t("link_copied"), "success");
+ }}
+ size="icon"
+ StartIcon={Icon.FiLink}
+ combined
+ />
+
+ )}
@@ -167,15 +169,18 @@ export default function TeamListItem(props: Props) {
)}
-
-
- {t("preview_team") as string}
-
-
+ {!team.slug && }
+ {team.slug && (
+
+
+ {t("preview_team") as string}
+
+
+ )}
{isOwner && (
@@ -241,3 +246,29 @@ export default function TeamListItem(props: Props) {
);
}
+
+const TeamPublishButton = ({ teamId }: { teamId: number }) => {
+ const { t } = useLocale();
+ const router = useRouter();
+ const publishTeamMutation = trpc.useMutation("viewer.teams.publish", {
+ onSuccess(data) {
+ router.push(data.url);
+ },
+ onError: (error) => {
+ showToast(error.message, "error");
+ },
+ });
+
+ return (
+
+ {
+ publishTeamMutation.mutate({ teamId });
+ }}
+ StartIcon={Icon.FiGlobe}>
+ {t("team_publish")}
+
+
+ );
+};
diff --git a/packages/features/ee/teams/components/TeamsListing.tsx b/packages/features/ee/teams/components/TeamsListing.tsx
new file mode 100644
index 0000000000..007fbc30a1
--- /dev/null
+++ b/packages/features/ee/teams/components/TeamsListing.tsx
@@ -0,0 +1,52 @@
+import { useState } from "react";
+
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc/react";
+import { Icon } from "@calcom/ui/Icon";
+import { Button } from "@calcom/ui/components/button";
+import { Alert } from "@calcom/ui/v2/core/Alert";
+import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
+
+import SkeletonLoaderTeamList from "./SkeletonloaderTeamList";
+import TeamList from "./TeamList";
+
+export function TeamsListing() {
+ const { t } = useLocale();
+ const [errorMessage, setErrorMessage] = useState("");
+
+ const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
+ onError: (e) => {
+ setErrorMessage(e.message);
+ },
+ });
+
+ const teams = data?.filter((m) => m.accepted) || [];
+ const invites = data?.filter((m) => !m.accepted) || [];
+
+ return (
+ <>
+ {!!errorMessage && }
+ {invites.length > 0 && (
+
+
{t("open_invitations")}
+
+
+ )}
+ {isLoading && }
+ {!teams.length && !isLoading && (
+
+ {t("create_team")}
+
+ }
+ />
+ )}
+ {teams.length > 0 && }
+ >
+ );
+}
diff --git a/packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx b/packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx
deleted file mode 100644
index f289e94166..0000000000
--- a/packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { useState } from "react";
-
-import { useLocale } from "@calcom/lib/hooks/useLocale";
-import { trpc } from "@calcom/trpc/react";
-import { Alert, Dialog, DialogContent, DialogTrigger, showToast } from "@calcom/ui/v2/core";
-
-interface Props {
- teamId: number;
-}
-
-export function UpgradeToFlexibleProModal(props: Props) {
- const { t } = useLocale();
- const [errorMessage, setErrorMessage] = useState(null);
- const utils = trpc.useContext();
- const { data } = trpc.useQuery(["viewer.teams.getTeamSeats", { teamId: props.teamId }], {
- onError: (err) => {
- setErrorMessage(err.message);
- },
- });
- const mutation = trpc.useMutation(["viewer.teams.upgradeTeam"], {
- onSuccess: (data) => {
- // if the user does not already have a Stripe subscription, this wi
- if (data?.url) {
- window.location.href = data.url;
- }
- if (data?.success) {
- utils.invalidateQueries(["viewer.teams.get"]);
- showToast(t("team_upgraded_successfully"), "success");
- }
- },
- onError: (err) => {
- setErrorMessage(err.message);
- },
- });
-
- function upgrade() {
- setErrorMessage(null);
- mutation.mutate({ teamId: props.teamId });
- }
-
- return (
-
-
- Upgrade Now
-
- upgrade()}>
- {t("changed_team_billing_info")}test
- {data && (
-
- {t("team_upgrade_seats_details", {
- memberCount: data.totalMembers,
- unpaidCount: data.missingSeats,
- seatPrice: 12,
- totalCost: (data.totalMembers - data.freeSeats) * 12 + 12,
- })}
-
- )}
- {errorMessage && (
-
- )}
-
-
- );
-}
diff --git a/packages/features/ee/teams/components/index.ts b/packages/features/ee/teams/components/index.ts
new file mode 100644
index 0000000000..cf5f5dcf68
--- /dev/null
+++ b/packages/features/ee/teams/components/index.ts
@@ -0,0 +1,2 @@
+export { CreateANewTeamForm } from "./CreateANewTeamForm";
+export { TeamsListing } from "./TeamsListing";
diff --git a/packages/features/ee/teams/components/v2/AddNewTeamMembers.tsx b/packages/features/ee/teams/components/v2/AddNewTeamMembers.tsx
deleted file mode 100644
index ae4387137e..0000000000
--- a/packages/features/ee/teams/components/v2/AddNewTeamMembers.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import { Suspense, useState } from "react";
-
-import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
-import { classNames } from "@calcom/lib";
-import { WEBAPP_URL } from "@calcom/lib/constants";
-import { useLocale } from "@calcom/lib/hooks/useLocale";
-import { trpc } from "@calcom/trpc/react";
-import { Icon } from "@calcom/ui";
-import { Avatar } from "@calcom/ui/components/avatar";
-import { Badge } from "@calcom/ui/components/badge";
-import { Button } from "@calcom/ui/components/button";
-import { SkeletonContainer, SkeletonText } from "@calcom/ui/v2/core/skeleton";
-
-const AddNewTeamMemberSkeleton = () => {
- return (
-
-
-
- );
-};
-
-const AddNewTeamMembers = (props: { teamId: number }) => {
- const { t } = useLocale();
- const utils = trpc.useContext();
-
- const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: props.teamId }]);
- const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
- onSuccess() {
- utils.invalidateQueries(["viewer.teams.get", { teamId: props.teamId }]);
- utils.invalidateQueries(["viewer.teams.list"]);
- },
- });
-
- const [memberInviteModal, setMemberInviteModal] = useState(false);
-
- if (isLoading) return ;
-
- return (
- }>
- <>
- <>
-
- {team?.members.map((member, index) => (
-
-
-
-
-
-
{member?.name || t("team_member")}
- {/* Assume that the first member of the team is the creator */}
- {index === 0 &&
{t("you")} }
- {!member.accepted &&
{t("pending")} }
- {member.role === "MEMBER" &&
{t("member")} }
- {member.role === "ADMIN" &&
{t("admin")} }
-
- {member.username ? (
-
{`${WEBAPP_URL}/${member?.username}`}
- ) : (
-
{t("not_on_cal")}
- )}
-
-
- {member.role !== "OWNER" && (
- removeMemberMutation.mutate({ teamId: props.teamId, memberId: member.id })}
- />
- )}
-
- ))}
-
-
- setMemberInviteModal(true)}
- className="mt-6 w-full justify-center">
- {t("add_team_member")}
-
- >
-
- {team && (
- setMemberInviteModal(false)}
- team={team}
- currentMember={team?.membership.role}
- />
- )}
-
-
-
-
- {t("finish")}
-
- >
-
- );
-};
-
-export default AddNewTeamMembers;
diff --git a/packages/features/ee/teams/components/v2/CreateNewTeam.tsx b/packages/features/ee/teams/components/v2/CreateNewTeam.tsx
deleted file mode 100644
index c3f2c781d2..0000000000
--- a/packages/features/ee/teams/components/v2/CreateNewTeam.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { useForm, Controller } from "react-hook-form";
-
-import { WEBAPP_URL } from "@calcom/lib/constants";
-import { useLocale } from "@calcom/lib/hooks/useLocale";
-import slugify from "@calcom/lib/slugify";
-import { trpc } from "@calcom/trpc/react";
-import { Icon } from "@calcom/ui";
-import { Button, Avatar } from "@calcom/ui/components";
-import { Form, TextField } from "@calcom/ui/components/form";
-import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
-
-const CreateANewTeamForm = (props: { nextStep: () => void; setTeamId: (teamId: number) => void }) => {
- const { t } = useLocale();
- const utils = trpc.useContext();
-
- const createTeamMutation = trpc.useMutation("viewer.teams.create", {
- onSuccess(data) {
- utils.invalidateQueries(["viewer.teams.list"]);
- props.setTeamId(data.id);
- props.nextStep();
- },
- });
-
- const formMethods = useForm();
-
- return (
-
- );
-};
-
-export default CreateANewTeamForm;
diff --git a/packages/features/ee/teams/lib/payments.ts b/packages/features/ee/teams/lib/payments.ts
new file mode 100644
index 0000000000..0d915a35ba
--- /dev/null
+++ b/packages/features/ee/teams/lib/payments.ts
@@ -0,0 +1,52 @@
+import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
+import stripe from "@calcom/app-store/stripepayment/lib/server";
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import prisma from "@calcom/prisma";
+import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
+
+export const purchaseTeamSubscription = async (input: { teamId: number; seats: number; userId: number }) => {
+ const { teamId, seats, userId } = input;
+ const customer = await getStripeCustomerIdFromUserId(userId);
+ return await stripe.checkout.sessions.create({
+ customer,
+ mode: "subscription",
+ success_url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id={CHECKOUT_SESSION_ID}`,
+ cancel_url: `${WEBAPP_URL}/settings/profile`,
+ locale: "en",
+ 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,
+ quantity: seats,
+ },
+ ],
+ metadata: {
+ teamId,
+ },
+ payment_method_types: ["card"],
+ subscription_data: {
+ metadata: {
+ teamId,
+ },
+ },
+ });
+};
+
+export const cancelTeamSubscriptionFromStripe = async (teamId: number) => {
+ try {
+ const team = await prisma.team.findUniqueOrThrow({
+ where: { id: teamId },
+ select: { metadata: true },
+ });
+ const metadata = teamMetadataSchema.parse(team.metadata);
+ if (!metadata?.subscriptionId)
+ throw Error(
+ `Couldn't cancelTeamSubscriptionFromStripe, Team id: ${teamId} didn't have a subscriptionId`
+ );
+ return await stripe.subscriptions.cancel(metadata.subscriptionId);
+ } catch (error) {
+ let message = "Unknown error on cancelTeamSubscriptionFromStripe";
+ if (error instanceof Error) message = error.message;
+ console.error(message);
+ }
+};
diff --git a/packages/features/ee/teams/lib/types.ts b/packages/features/ee/teams/lib/types.ts
new file mode 100644
index 0000000000..0f87289108
--- /dev/null
+++ b/packages/features/ee/teams/lib/types.ts
@@ -0,0 +1,18 @@
+import { MembershipRole } from "@prisma/client";
+
+export interface NewTeamFormValues {
+ name: string;
+ slug: string;
+ temporarySlug: string;
+ logo: string;
+}
+
+export interface PendingMember {
+ name: string | null;
+ email: string;
+ id?: number;
+ username: string | null;
+ role: MembershipRole;
+ avatar: string | null;
+ sendInviteEmail?: boolean;
+}
diff --git a/packages/features/ee/teams/pages/team-billing-view.tsx b/packages/features/ee/teams/pages/team-billing-view.tsx
new file mode 100644
index 0000000000..49d0f0daf9
--- /dev/null
+++ b/packages/features/ee/teams/pages/team-billing-view.tsx
@@ -0,0 +1,35 @@
+import { useRouter } from "next/router";
+
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Button, Icon } from "@calcom/ui";
+import Meta from "@calcom/ui/v2/core/Meta";
+import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
+
+const BillingView = () => {
+ const { t } = useLocale();
+ const router = useRouter();
+ const returnTo = router.asPath;
+ const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
+
+ return (
+ <>
+
+
+
+
{t("billing_manage_details_title")}
+
{t("billing_manage_details_description")}
+
+
+
+ {t("billing_portal")}
+
+
+
+ >
+ );
+};
+
+BillingView.getLayout = getLayout;
+
+export default BillingView;
diff --git a/packages/features/ee/teams/pages/team-listing-view.tsx b/packages/features/ee/teams/pages/team-listing-view.tsx
new file mode 100644
index 0000000000..69ce551f96
--- /dev/null
+++ b/packages/features/ee/teams/pages/team-listing-view.tsx
@@ -0,0 +1,19 @@
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import Meta from "@calcom/ui/v2/core/Meta";
+import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
+
+import { TeamsListing } from "../components";
+
+const BillingView = () => {
+ const { t } = useLocale();
+ return (
+ <>
+
+
+ >
+ );
+};
+
+BillingView.getLayout = getLayout;
+
+export default BillingView;
diff --git a/packages/features/ee/teams/pages/team-members-view.tsx b/packages/features/ee/teams/pages/team-members-view.tsx
index 4d49cb0c42..fa5bac292a 100644
--- a/packages/features/ee/teams/pages/team-members-view.tsx
+++ b/packages/features/ee/teams/pages/team-members-view.tsx
@@ -7,7 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import { Button } from "@calcom/ui/components";
-import { Alert } from "@calcom/ui/v2/core";
+import { showToast } from "@calcom/ui/v2/core";
import Meta from "@calcom/ui/v2/core/Meta";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
@@ -15,20 +15,30 @@ import DisableTeamImpersonation from "../components/DisableTeamImpersonation";
import MemberInvitationModal from "../components/MemberInvitationModal";
import MemberListItem from "../components/MemberListItem";
import TeamInviteList from "../components/TeamInviteList";
-import { UpgradeToFlexibleProModal } from "../components/UpgradeToFlexibleProModal";
const MembersView = () => {
- const { t } = useLocale();
+ const { t, i18n } = useLocale();
const router = useRouter();
const session = useSession();
+ const utils = trpc.useContext();
+ const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
+ const teamId = Number(router.query.id);
- const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
+ const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId }], {
onError: () => {
router.push("/settings");
},
});
- const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
+ const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
+ async onSuccess() {
+ await utils.invalidateQueries(["viewer.teams.get"]);
+ setShowMemberInvitationModal(false);
+ },
+ onError: (error) => {
+ showToast(error.message, "error");
+ },
+ });
const isInviteOpen = !team?.membership.accepted;
@@ -57,44 +67,6 @@ const MembersView = () => {
]}
/>
)}
- {team.membership.role === MembershipRole.OWNER &&
- team.membership.isMissingSeat &&
- team.requiresUpgrade ? (
-
- {t("hidden_team_owner_message")}
- >
- }
- className="mb-4 "
- />
- ) : (
- <>
- {team.membership.isMissingSeat && (
-
- )}
- {team.membership.role === MembershipRole.OWNER && team.requiresUpgrade && (
-
- {t("upgrade_to_flexible_pro_message")}
-
-
- }
- className="mb-4"
- />
- )}
- >
- )}
>
)}
{isAdmin && (
@@ -131,9 +103,17 @@ const MembersView = () => {
{showMemberInvitationModal && team && (
setShowMemberInvitationModal(false)}
+ onSubmit={(values) => {
+ inviteMemberMutation.mutate({
+ teamId,
+ language: i18n.language,
+ role: values.role.value,
+ usernameOrEmail: values.emailOrUsername,
+ sendEmailInvitation: values.sendInviteEmail,
+ });
+ }}
/>
)}
>
diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx
index 9592cc6d44..b412bcc167 100644
--- a/packages/features/ee/teams/pages/team-profile-view.tsx
+++ b/packages/features/ee/teams/pages/team-profile-view.tsx
@@ -10,12 +10,14 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import objectKeys from "@calcom/lib/objectKeys";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
-import { Avatar, Button, Label, TextArea, Form, TextField } from "@calcom/ui/components";
-import { Dialog, DialogTrigger, LinkIconButton, showToast } from "@calcom/ui/v2/core";
+import { Avatar, Button, Form, Label, TextArea, TextField } from "@calcom/ui/components";
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
+import { Dialog, DialogTrigger } from "@calcom/ui/v2/core/Dialog";
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
+import LinkIconButton from "@calcom/ui/v2/core/LinkIconButton";
import Meta from "@calcom/ui/v2/core/Meta";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
+import showToast from "@calcom/ui/v2/core/notifications";
interface TeamProfileValues {
name: string;
@@ -65,8 +67,8 @@ const ProfileView = () => {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
await utils.invalidateQueries(["viewer.teams.list"]);
- router.push(`/settings`);
- showToast(t("your_team_updated_successfully"), "success");
+ showToast(t("your_team_disbanded_successfully"), "success");
+ router.push(`${WEBAPP_URL}/teams`);
},
});
diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts
index dee3306f43..911a6be2e1 100644
--- a/packages/lib/constants.ts
+++ b/packages/lib/constants.ts
@@ -36,3 +36,10 @@ export const DEVELOPER_DOCS = "https://developer.cal.com";
export const SEO_IMG_DEFAULT = `${WEBSITE_URL}/og-image.png`;
export const SEO_IMG_OGIMG = `${CAL_URL}/api/social/og/image`;
export const SEO_IMG_OGIMG_VIDEO = `${WEBSITE_URL}/video-og-image.png`;
+export const IS_STRIPE_ENABLED = !!(
+ process.env.STRIPE_CLIENT_ID &&
+ process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
+ process.env.STRIPE_PRIVATE_KEY
+);
+/** Self hosted shouldn't checkout when creating teams */
+export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && !IS_SELF_HOSTED;
diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts
index 12f8dcf41c..1206dad55b 100644
--- a/packages/lib/server/queries/teams/index.ts
+++ b/packages/lib/server/queries/teams/index.ts
@@ -3,8 +3,10 @@ import { Prisma, UserPlan } from "@prisma/client";
import prisma, { baseEventTypeSelect } from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
+import { WEBAPP_URL } from "../../../constants";
+
export type TeamWithMembers = Awaited>;
-export async function getTeamWithMembers(id?: number, slug?: string) {
+export async function getTeamWithMembers(id?: number, slug?: string, userId?: number) {
const userSelect = Prisma.validator()({
username: true,
email: true,
@@ -22,6 +24,9 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
hideBranding: true,
members: {
select: {
+ accepted: true,
+ role: true,
+ disableImpersonation: true,
user: {
select: userSelect,
},
@@ -41,26 +46,26 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
},
});
- const team = await prisma.team.findUnique({
- where: id ? { id } : { slug },
+ const where: Prisma.TeamFindFirstArgs["where"] = {};
+
+ if (userId) where.members = { some: { userId } };
+ if (id) where.id = id;
+ if (slug) where.slug = slug;
+
+ const team = await prisma.team.findFirst({
+ where,
select: teamSelect,
});
if (!team) return null;
- const memberships = await prisma.membership.findMany({
- where: {
- teamId: team.id,
- },
- });
-
const members = team.members.map((obj) => {
- const membership = memberships.find((membership) => obj.user.id === membership.userId);
return {
...obj.user,
isMissingSeat: obj.user.plan === UserPlan.FREE,
- role: membership?.role,
- accepted: membership?.accepted,
- disableImpersonation: membership?.disableImpersonation,
+ role: obj.role,
+ accepted: obj.accepted,
+ disableImpersonation: obj.disableImpersonation,
+ avatar: `${WEBAPP_URL}/${obj.user.username}/avatar.png`,
};
});
diff --git a/packages/prisma/migrations/20221107201132_add_team_subscription_cols/migration.sql b/packages/prisma/migrations/20221107201132_add_team_subscription_cols/migration.sql
new file mode 100644
index 0000000000..ada911fd8c
--- /dev/null
+++ b/packages/prisma/migrations/20221107201132_add_team_subscription_cols/migration.sql
@@ -0,0 +1,4 @@
+-- AlterTable
+ALTER TABLE "Team" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ADD COLUMN "metadata" JSONB,
+ALTER COLUMN "slug" DROP NOT NULL;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 8c96334d96..9dda13a96b 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -202,12 +202,15 @@ model Team {
/// @zod.min(1)
name String
/// @zod.min(1)
- slug String @unique
+ slug String? @unique
logo String?
bio String?
hideBranding Boolean @default(false)
members Membership[]
eventTypes EventType[]
+ createdAt DateTime @default(now())
+ /// @zod.custom(imports.teamMetadataSchema)
+ metadata Json?
}
enum MembershipRole {
diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts
index 59ec94a1f7..3db8dda2c2 100644
--- a/packages/prisma/seed.ts
+++ b/packages/prisma/seed.ts
@@ -574,6 +574,7 @@ async function main() {
],
},
},
+ createdAt: new Date(),
},
[
{
diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts
index a041f5b051..c5bf4f005a 100644
--- a/packages/prisma/zod-utils.ts
+++ b/packages/prisma/zod-utils.ts
@@ -199,6 +199,16 @@ export const userMetadata = z
})
.nullable();
+export const teamMetadataSchema = z
+ .object({
+ requestedSlug: z.string(),
+ paymentId: z.string(),
+ subscriptionId: z.string().nullable(),
+ subscriptionItemId: z.string().nullable(),
+ })
+ .partial()
+ .nullable();
+
/**
* Ensures that it is a valid HTTP URL
* It automatically avoids
diff --git a/packages/trpc/server/routers/viewer/teams.tsx b/packages/trpc/server/routers/viewer/teams.tsx
index bf0104c675..ce343452f9 100644
--- a/packages/trpc/server/routers/viewer/teams.tsx
+++ b/packages/trpc/server/routers/viewer/teams.tsx
@@ -2,19 +2,16 @@ import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
import { randomBytes } from "crypto";
import { z } from "zod";
-import {
- addSeat,
- downgradeTeamMembers,
- ensureSubscriptionQuantityCorrectness,
- getTeamSeatStats,
- removeSeat,
- upgradeTeam,
-} from "@calcom/app-store/stripepayment/lib/team-billing";
+import { addSeat, getRequestedSlugError, removeSeat } from "@calcom/app-store/stripepayment/lib/team-billing";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import { sendTeamInviteEmail } from "@calcom/emails";
-import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants";
+import {
+ cancelTeamSubscriptionFromStripe,
+ purchaseTeamSubscription,
+} from "@calcom/features/ee/teams/lib/payments";
+import { HOSTED_CAL_FEATURES, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
-import { getTeamWithMembers, isTeamAdmin, isTeamOwner, isTeamMember } from "@calcom/lib/server/queries/teams";
+import { getTeamWithMembers, isTeamAdmin, isTeamMember, isTeamOwner } from "@calcom/lib/server/queries/teams";
import slugify from "@calcom/lib/slugify";
import {
closeComDeleteTeam,
@@ -23,6 +20,7 @@ import {
closeComUpsertTeamUser,
} from "@calcom/lib/sync/SyncServiceManager";
import { availabilityUserSelect } from "@calcom/prisma";
+import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@@ -35,9 +33,9 @@ export const viewerTeamsRouter = createProtectedRouter()
teamId: z.number(),
}),
async resolve({ ctx, input }) {
- const team = await getTeamWithMembers(input.teamId);
- if (!team?.members.find((m) => m.id === ctx.user.id)) {
- throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a member of this team." });
+ const team = await getTeamWithMembers(input.teamId, undefined, ctx.user.id);
+ if (!team) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
}
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
@@ -59,59 +57,52 @@ export const viewerTeamsRouter = createProtectedRouter()
where: {
userId: ctx.user.id,
},
+ include: {
+ team: true,
+ },
orderBy: { role: "desc" },
});
- const teams = await ctx.prisma.team.findMany({
- where: {
- id: {
- in: memberships.map((membership) => membership.teamId),
- },
- },
- });
- return memberships.map((membership) => ({
+ return memberships.map(({ team, ...membership }) => ({
role: membership.role,
accepted: membership.accepted,
- ...teams.find((team) => team.id === membership.teamId),
+ ...team,
}));
},
})
.mutation("create", {
input: z.object({
name: z.string(),
- slug: z.string().optional().nullable(),
- logo: z.string().optional().nullable(),
+ slug: z.string().transform((val) => slugify(val.trim())),
+ logo: z
+ .string()
+ .optional()
+ .nullable()
+ .transform((v) => v || null),
}),
async resolve({ ctx, input }) {
- if (ctx.user.plan === "FREE") {
- throw new TRPCError({ code: "UNAUTHORIZED", message: "You need a team plan." });
- }
+ const { slug, name, logo } = input;
- const slug = input.slug || slugify(input.name);
-
- const nameCollisions = await ctx.prisma.team.count({
- where: {
- OR: [{ name: input.name }, { slug: slug }],
- },
+ const nameCollisions = await ctx.prisma.team.findFirst({
+ where: { OR: [{ name }, { slug }] },
});
- if (nameCollisions > 0)
- throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." });
+ if (nameCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." });
const createTeam = await ctx.prisma.team.create({
data: {
- name: input.name,
- slug: slug,
- logo: input.logo || null,
- },
- });
-
- await ctx.prisma.membership.create({
- data: {
- teamId: createTeam.id,
- userId: ctx.user.id,
- role: MembershipRole.OWNER,
- accepted: true,
+ name,
+ logo,
+ members: {
+ create: {
+ userId: ctx.user.id,
+ role: MembershipRole.OWNER,
+ accepted: true,
+ },
+ },
+ metadata: {
+ requestedSlug: slug,
+ },
},
});
@@ -149,17 +140,31 @@ export const viewerTeamsRouter = createProtectedRouter()
},
});
+ if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
+
+ const data: Prisma.TeamUpdateArgs["data"] = {
+ name: input.name,
+ logo: input.logo,
+ bio: input.bio,
+ hideBranding: input.hideBranding,
+ };
+
+ if (
+ input.slug &&
+ IS_TEAM_BILLING_ENABLED &&
+ /** If the team doesn't have a slug we can assume that it hasn't been published yet. */ !prevTeam.slug
+ ) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You cannot change the slug until you publish your team",
+ });
+ } else {
+ data.slug = input.slug;
+ }
+
const updatedTeam = await ctx.prisma.team.update({
- where: {
- id: input.id,
- },
- data: {
- name: input.name,
- slug: input.slug,
- logo: input.logo,
- bio: input.bio,
- hideBranding: input.hideBranding,
- },
+ where: { id: input.id },
+ data,
});
// Sync Services: Close.com
@@ -173,9 +178,7 @@ export const viewerTeamsRouter = createProtectedRouter()
async resolve({ ctx, input }) {
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
- if (process.env.STRIPE_PRIVATE_KEY) {
- await downgradeTeamMembers(input.teamId);
- }
+ if (IS_TEAM_BILLING_ENABLED) await cancelTeamSubscriptionFromStripe(input.teamId);
// delete all memberships
await ctx.prisma.membership.deleteMany({
@@ -487,32 +490,6 @@ export const viewerTeamsRouter = createProtectedRouter()
);
},
})
- .mutation("upgradeTeam", {
- input: z.object({
- teamId: z.number(),
- }),
- async resolve({ ctx, input }) {
- if (!HOSTED_CAL_FEATURES)
- throw new TRPCError({ code: "FORBIDDEN", message: "Team billing is not enabled" });
- return await upgradeTeam(ctx.user.id, input.teamId);
- },
- })
- .query("getTeamSeats", {
- input: z.object({
- teamId: z.number(),
- }),
- async resolve({ input }) {
- return await getTeamSeatStats(input.teamId);
- },
- })
- .mutation("ensureSubscriptionQuantityCorrectness", {
- input: z.object({
- teamId: z.number(),
- }),
- async resolve({ ctx, input }) {
- return await ensureSubscriptionQuantityCorrectness(ctx.user.id, input.teamId);
- },
- })
.query("getMembershipbyUser", {
input: z.object({
teamId: z.number(),
@@ -562,4 +539,72 @@ export const viewerTeamsRouter = createProtectedRouter()
},
});
},
+ })
+ .query("validateTeamSlug", {
+ input: z.object({
+ slug: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const team = await ctx.prisma.team.findFirst({
+ where: {
+ slug: input.slug,
+ },
+ });
+
+ return !team;
+ },
+ })
+ .mutation("publish", {
+ input: z.object({
+ teamId: z.number(),
+ }),
+ async resolve({ ctx, input }) {
+ if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
+ const { teamId: id } = input;
+
+ const prevTeam = await ctx.prisma.team.findFirst({ where: { id }, include: { members: true } });
+
+ if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
+
+ 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 payment needed, responed with checkout url
+ if (IS_TEAM_BILLING_ENABLED) {
+ const checkoutSession = await purchaseTeamSubscription({
+ teamId: prevTeam.id,
+ seats: prevTeam.members.length,
+ userId: ctx.user.id,
+ });
+ if (!checkoutSession.url)
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed retrieving a checkout session URL.",
+ });
+ return { url: checkoutSession.url };
+ }
+
+ const { requestedSlug, ...newMetadata } = metadata.data;
+ let updatedTeam: Awaited>;
+
+ try {
+ updatedTeam = await ctx.prisma.team.update({
+ where: { id },
+ data: {
+ slug: requestedSlug,
+ metadata: { ...newMetadata },
+ },
+ });
+ } catch (error) {
+ const { message } = getRequestedSlugError(error, requestedSlug);
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message });
+ }
+
+ // Sync Services: Close.com
+ closeComUpdateTeam(prevTeam, updatedTeam);
+
+ return { url: `${WEBAPP_URL}/settings/teams/${updatedTeam.id}/profile` };
+ },
});
diff --git a/packages/ui/v2/core/Dialog.tsx b/packages/ui/v2/core/Dialog.tsx
index 5015cdf121..37ba6329fa 100644
--- a/packages/ui/v2/core/Dialog.tsx
+++ b/packages/ui/v2/core/Dialog.tsx
@@ -80,7 +80,7 @@ type DialogContentProps = React.ComponentProps(
- ({ children, title, Icon, actionProps, ...props }, forwardedRef) => {
+ ({ children, title, Icon, actionProps, useOwnActionButtons, ...props }, forwardedRef) => {
const { t } = useLocale();
return (
@@ -122,7 +122,7 @@ export const DialogContent = React.forwardRef
)}
- {!props.useOwnActionButtons && (
+ {!useOwnActionButtons && (
diff --git a/apps/web/components/getting-started/components/StepCard.tsx b/packages/ui/v2/core/StepCard.tsx
similarity index 100%
rename from apps/web/components/getting-started/components/StepCard.tsx
rename to packages/ui/v2/core/StepCard.tsx
diff --git a/apps/web/components/getting-started/components/Steps.tsx b/packages/ui/v2/core/Steps.tsx
similarity index 100%
rename from apps/web/components/getting-started/components/Steps.tsx
rename to packages/ui/v2/core/Steps.tsx
diff --git a/packages/ui/v2/core/layouts/SettingsLayout.tsx b/packages/ui/v2/core/layouts/SettingsLayout.tsx
index a0fe60a024..0a35c1bed6 100644
--- a/packages/ui/v2/core/layouts/SettingsLayout.tsx
+++ b/packages/ui/v2/core/layouts/SettingsLayout.tsx
@@ -1,6 +1,7 @@
import { MembershipRole, UserPermissionRole } from "@prisma/client";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import { useSession } from "next-auth/react";
+import Link from "next/link";
import { useRouter } from "next/router";
import React, { ComponentProps, useEffect, useState } from "react";
@@ -158,12 +159,16 @@ const SettingsSidebarContainer = ({ className = "" }) => {
) : (
-
- {tab && tab.icon && (
-
- )}
-
{t(tab.name)}
-
+
+
+
+ {tab && tab.icon && (
+
+ )}
+
{t(tab.name)}
+
+
+
{teams &&
teamMenuState &&
teams.map((team, index: number) => {
@@ -243,6 +248,12 @@ const SettingsSidebarContainer = ({ className = "" }) => {
textClassNames="px-3 text-gray-900 font-medium text-sm"
disableChevron
/>
+
{HOSTED_CAL_FEATURES && (
{
+ setMeta({
+ title: window.document.title,
+ subtitle: window.document.querySelector('meta[name="description"]')?.getAttribute("content") || "",
+ });
+ }, [router.asPath]);
+
+ return (
+
+ );
+}
+
+export const getLayout = (page: React.ReactElement) => {page} ;
diff --git a/turbo.json b/turbo.json
index 87631ad877..67b087399d 100644
--- a/turbo.json
+++ b/turbo.json
@@ -39,6 +39,7 @@
"$STRIPE_PRO_PLAN_PRODUCT_ID",
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"$STRIPE_FREE_PLAN_PRODUCT_ID",
+ "$STRIPE_TEAM_MONTHLY_PRICE_ID",
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
"$NEXT_PUBLIC_WEBAPP_URL",
"$NEXT_PUBLIC_WEBSITE_URL"