Compare commits

...

86 Commits

Author SHA1 Message Date
Joe Au-Yeung 57b8104630 Merge branch 'teams-stripe-checkout-form' of https://github.com/calcom/cal.com into teams-stripe-checkout-form 2022-11-07 15:17:35 -05:00
Joe Au-Yeung 8606ba9893 WIP 2022-11-07 15:09:06 -05:00
Joe Au-Yeung db48f9fbfb Create a temporary team after first step 2022-11-07 14:18:14 -05:00
Joe Au-Yeung 402f87e7c2
Merge branch 'main' into teams-stripe-checkout-form 2022-11-07 13:41:29 -05:00
Peer Richelsen e59dbb9ed0
Merge branch 'main' into teams-stripe-checkout-form 2022-11-06 15:47:10 +00:00
Omar López 108b7f8a0e
Merge branch 'main' into teams-stripe-checkout-form 2022-11-04 16:07:52 -07:00
zomars d256b0e4e7 Update PurchaseNewTeam.tsx 2022-11-04 14:46:51 -07:00
zomars b47d021c02 Update yarn.lock 2022-11-04 12:56:53 -07:00
zomars acc455a79d Merge branch 'main' into teams-stripe-checkout-form 2022-11-04 12:55:32 -07:00
Joe Au-Yeung 692f6bd3ca More clean up 2022-11-04 11:18:08 -04:00
Joe Au-Yeung a1b196f3f6 Clean some unused parts 2022-11-04 09:56:06 -04:00
Joe Au-Yeung 618968c57c Fix bug, send email 2022-11-03 22:02:23 -04:00
Joe Au-Yeung 6f59210787 Remove option to send email 2022-11-03 21:48:10 -04:00
Joe Au-Yeung 7a4c1fbedf Fix stripe type errors 2022-11-03 20:19:29 -04:00
Joe Au-Yeung a1c9afdf46 Fix type errors 2022-11-03 17:18:25 -04:00
Joe Au-Yeung d9194a9baa Return validation results when ready 2022-11-03 16:19:37 -04:00
Joe Au-Yeung 45b4187d3a Persist team members between screens 2022-11-03 15:45:57 -04:00
Joe Au-Yeung 42a6376cc4 WIP - render added members on navigation 2022-11-03 11:33:48 -04:00
Joe Au-Yeung 7c3c3e69e8 Grab avatar from auth session 2022-11-03 11:33:24 -04:00
Joe Au-Yeung ab53af9c6e Change customer on stripe when name input changed 2022-11-02 15:51:27 -04:00
Joe Au-Yeung 87f795c514 Write stripe ids to DB 2022-11-02 15:16:08 -04:00
Joe Au-Yeung fc062d0271 Fix type errors on new members page 2022-11-02 14:58:25 -04:00
Joe Au-Yeung ad066745e5 Write stored form values to CreateNewTeam form 2022-11-02 14:36:59 -04:00
Joe Au-Yeung 63b4c9fa2d Fix type errors 2022-11-02 14:28:29 -04:00
Joe Au-Yeung 027a036765 Add payment error message 2022-11-02 14:00:07 -04:00
Joe Au-Yeung c60e9bdc32 Create successful payment 2022-11-02 11:26:01 -04:00
Joe Au-Yeung a9e897d99e WIP 2022-11-01 18:28:27 -04:00
Joe Au-Yeung cc46f21d66 Merge branch 'main' into v2/teams-billing 2022-11-01 13:50:43 -04:00
Joe Au-Yeung 50521ce2bb WIP 2022-10-31 17:48:32 -04:00
Joe Au-Yeung 20f1306be2 Send email on paid subscription 2022-10-31 16:30:52 -04:00
Joe Au-Yeung 2a52e7693c Hide pending teams from settings 2022-10-31 14:41:20 -04:00
Joe Au-Yeung 8535570ea4 Add subscription status column 2022-10-31 14:40:49 -04:00
Joe Au-Yeung b0e81e3823 Abstract invite members function 2022-10-31 12:27:51 -04:00
Joe Au-Yeung 6f539dfefd WIP 2022-10-28 16:22:32 -04:00
Joe Au-Yeung 5f1e089014 Fix type errors 2022-10-28 14:36:05 -04:00
Joe Au-Yeung 69db0d6610 Fix type error 2022-10-28 14:21:17 -04:00
Joe Au-Yeung 1dda714804 Clean up 2022-10-28 13:07:47 -04:00
Joe Au-Yeung 2ebd0b1774 Add validation to team slug 2022-10-28 12:46:04 -04:00
Joe Au-Yeung eacdf69659 Add validation for team name 2022-10-28 12:06:31 -04:00
Joe Au-Yeung c1570ff807 WIP 2022-10-27 16:00:01 -04:00
Joe Au-Yeung 7db31ad5ca Add translations 2022-10-27 15:59:22 -04:00
Joe Au-Yeung d34b4ac931 Validate for invited members 2022-10-27 14:52:59 -04:00
Joe Au-Yeung 20488b35c8 Fix some type errors 2022-10-27 12:58:44 -04:00
Joe Au-Yeung 1c3e294dc0 Add new team to form 2022-10-27 11:19:42 -04:00
Joe Au-Yeung 725608d595 WIP 2022-10-26 17:15:18 -04:00
Joe Au-Yeung 6b151e5817 Add high level form to create new team 2022-10-26 14:35:08 -04:00
Joe Au-Yeung e650112a35 Merge branch 'v2/teams-billing' of https://github.com/calcom/cal.com into v2/teams-billing 2022-10-26 12:45:14 -04:00
Joe Au-Yeung a4f435b9fb Add Stripe check for delete team customer 2022-10-26 12:16:17 -04:00
Peer Richelsen 0372e3eb26
Merge branch 'main' into v2/teams-billing 2022-10-26 14:45:55 +01:00
zomars d93033d9d6 Removed billing frequency from team creation 2022-10-25 13:59:00 -06:00
Joe Au-Yeung f1cfbc7347
Merge branch 'main' into v2/teams-billing 2022-10-25 15:21:27 -04:00
zomars 29a1cf9430 WIP 2022-10-25 13:13:49 -06:00
zomars 42dbbf4773 Removes team creation modal 2022-10-25 13:12:56 -06:00
Joe Au-Yeung ad70309258
Merge branch 'main' into v2/teams-billing 2022-10-25 14:50:31 -04:00
Joe Au-Yeung 278db57641 Fix type errors 2022-10-25 13:46:11 -04:00
Joe Au-Yeung bdb6318abb Address feedback 2022-10-25 12:19:03 -04:00
Joe Au-Yeung f6dbd0fff2
Merge branch 'main' into v2/teams-billing 2022-10-25 10:20:49 -04:00
zomars efe8b1df32 Merge branch 'main' into v2/teams-billing 2022-10-24 16:41:47 -06:00
Joe Au-Yeung 773ec9741f Delete old files & type fixes 2022-10-24 16:26:30 -04:00
Joe Au-Yeung 51e127f293 Fix type error 2022-10-24 15:17:59 -04:00
Joe Au-Yeung ec5769abb2 Merge branch 'v2/teams-billing' of https://github.com/calcom/cal.com into v2/teams-billing 2022-10-24 14:44:18 -04:00
Joe Au-Yeung 90e1cbc2cd Fix type errors 2022-10-24 14:43:57 -04:00
Joe Au-Yeung bfdea7c0fc Fix type errors 2022-10-24 14:31:27 -04:00
Joe Au-Yeung 235e419467
Merge branch 'main' into v2/teams-billing 2022-10-24 10:20:53 -04:00
Joe Au-Yeung 96ef940c2d Fix types 2022-10-21 22:11:40 -04:00
Joe Au-Yeung cf7a21a7b3 Link to team's portal page 2022-10-21 22:05:59 -04:00
Joe Au-Yeung 7bfd74cac9 Small clean up 2022-10-21 21:04:18 -04:00
Joe Au-Yeung be17a3a447 Merge branch 'v2/teams-billing' of https://github.com/calcom/cal.com into v2/teams-billing 2022-10-21 17:18:05 -04:00
Joe Au-Yeung 0b50ac3803 Merge branch 'v2/teams-billing' of https://github.com/calcom/cal.com into v2/teams-billing 2022-10-21 17:17:39 -04:00
Joe Au-Yeung 671077d7b0 Some cleanup 2022-10-21 17:17:20 -04:00
Joe Au-Yeung a91e7abd0b
Merge branch 'main' into v2/teams-billing 2022-10-21 16:45:21 -04:00
Joe Au-Yeung c193b93ff6 Move deleting team from Stripe under ee 2022-10-21 16:24:52 -04:00
Joe Au-Yeung 523a2172af Add Stripe migration files 2022-10-21 16:20:00 -04:00
Joe Au-Yeung 4feb65d130 Create webhook to update team with Stripe ids 2022-10-21 16:04:13 -04:00
Joe Au-Yeung 91d3d6b84f Create checkout session when creating team 2022-10-21 14:00:01 -04:00
Joe Au-Yeung a31fd08154 Merge branch 'main' into v2/teams-billing 2022-10-21 12:38:08 -04:00
Joe Au-Yeung 16d7a9ca61 Add string 2022-10-21 10:32:15 -04:00
Joe Au-Yeung 4dbcc62152 Create & delete Stripe customers 2022-10-20 15:16:27 -04:00
Joe Au-Yeung d968b37e4c Add Stripe price ids for team to .env 2022-10-20 15:16:14 -04:00
Joe Au-Yeung 71b9350cc2 Add Stripe ids to team record 2022-10-20 15:16:01 -04:00
Joe Au-Yeung cf4f870f3b Create stripe customer on team creation 2022-10-20 11:38:49 -04:00
Joe Au-Yeung 37dfd420e9 Remove unused imports 2022-10-20 11:25:11 -04:00
Joe Au-Yeung 8c598d2562 Merge branch 'main' into v2/teams-billing 2022-10-20 10:40:37 -04:00
Joe Au-Yeung 33ff213fe0 Create settings page 2022-10-19 17:28:40 -04:00
Joe Au-Yeung 427c5df99c WIP 2022-10-19 16:50:40 -04:00
Joe Au-Yeung ec3816f1e9 WIP teams billing page 2022-10-19 15:32:22 -04:00
32 changed files with 1288 additions and 629 deletions

View File

@ -92,15 +92,11 @@ TWILIO_MESSAGING_SID=
NEXT_PUBLIC_IS_E2E=
# Used for internal billing system
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_TEAM_YEARLY_PRICE_ID=
STRIPE_PRIVATE_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRO_PLAN_PRODUCT_ID=
STRIPE_PREMIUM_PLAN_PRODUCT_ID=
STRIPE_FREE_PLAN_PRODUCT_ID=
STRIPE_CLIENT_ID=
# Use for internal Public API Keys and optional
API_KEY_PREFIX=cal_

View File

@ -0,0 +1,73 @@
import { PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { useState } from "react";
import { NewTeamData } from "@calcom/features/ee/teams/lib/types";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui";
const PurchaseNewTeam = ({
total,
newTeamData,
}: {
total: number;
newTeamData: NewTeamData & { customerId: string; subscriptionId: string };
}) => {
const { t } = useLocale();
const [errorMessage, setErrorMessage] = useState("");
const [paymentProcessing, setPaymentProcessing] = useState(false);
const stripe = useStripe();
const elements = useElements();
const router = useRouter();
const createTeamMutation = trpc.useMutation(["viewer.teams.createTeam"], {
onSuccess: (data) => {
router.push(`${CAL_URL}/settings/teams/${data.id}/profile`);
},
});
const handleSubmit = async () => {
if (!stripe || !elements) return;
setPaymentProcessing(true);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${CAL_URL}/settings/profile`,
},
redirect: "if_required",
});
if (error) {
setPaymentProcessing(false);
setErrorMessage(error.message || t("error_processing_payment"));
} else {
createTeamMutation.mutate(newTeamData);
}
};
return (
<>
<PaymentElement />
<Button
className="mt-4 w-full justify-center"
loading={paymentProcessing}
onClick={() => handleSubmit()}>
{t("subscribe")} ${total} / {t(newTeamData.billingFrequency)}
</Button>
{errorMessage && (
<p className="mt-2 text-red-900">
{t("error_processing_payment")}: {errorMessage}
</p>
)}
</>
);
};
export default dynamic(() => Promise.resolve(PurchaseNewTeam), {
ssr: false,
});

View File

@ -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 | string>(null);
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
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 (
<>
<Dialog open={props.isOpen} onOpenChange={props.onClose}>
<DialogContent type="creation" actionText={t("create_new_team")} actionOnClick={createTeam}>
<div className="mb-4 sm:flex sm:items-start">
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<Icon.FiUsers className="text-brandcontrast h-6 w-6" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("create_new_team")}
</h3>
<div>
<p className="text-sm text-gray-400">{t("create_new_team_description")}</p>
</div>
</div>
</div>
<form>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
{t("name")}
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder="Acme Inc."
required
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm"
/>
</div>
{errorMessage && <Alert severity="error" title={errorMessage} />}
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -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<string | null>(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 (
<Dialog
onOpenChange={() => {
setErrorMessage(null);
}}>
<DialogTrigger asChild>
<a className="cursor-pointer underline">Upgrade Now</a>
</DialogTrigger>
<DialogContent>
<DialogHeader title={t("Purchase missing seats")} />
<p className="-mt-4 text-sm text-gray-600">{t("changed_team_billing_info")}</p>
{data && (
<p className="mt-2 text-sm italic text-gray-700">
{t("team_upgrade_seats_details", {
memberCount: data.totalMembers,
unpaidCount: data.missingSeats,
seatPrice: 12,
totalCost: (data.totalMembers - data.freeSeats) * 12 + 12,
})}
</p>
)}
{errorMessage && (
<Alert severity="error" title={errorMessage} message={t("further_billing_help")} className="my-4" />
)}
<DialogFooter>
<DialogClose>
<Button color="secondary">{t("close")}</Button>
</DialogClose>
<Button
disabled={mutation.isLoading}
onClick={() => {
setErrorMessage(null);
mutation.mutate({ teamId: props.teamId });
}}>
{t("upgrade_to_per_seat")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -57,8 +57,8 @@
"@radix-ui/react-toggle-group": "^1.0.0",
"@radix-ui/react-tooltip": "^1.0.0",
"@sentry/nextjs": "^7.17.3",
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
"@stripe/react-stripe-js": "^1.14.1",
"@stripe/stripe-js": "^1.42.1",
"@tanstack/react-query": "^4.3.9",
"@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.0.19",
@ -116,7 +116,7 @@
"react-window": "^1.8.7",
"rrule": "^2.7.1",
"short-uuid": "^4.2.0",
"stripe": "^9.16.0",
"stripe": "^10.15.0",
"superjson": "1.9.1",
"tailwindcss-radix": "^2.6.0",
"uuid": "^8.3.2",

View File

@ -231,6 +231,8 @@ export default NextAuth({
email: existingUser.email,
role: existingUser.role,
impersonatedByUID: token?.impersonatedByUID as number,
avatar: existingUser.avatar,
locale: existingUser.locale,
};
};
if (!user) {
@ -296,6 +298,8 @@ export default NextAuth({
username: token.username as string,
role: token.role as UserPermissionRole,
impersonatedByUID: token.impersonatedByUID as number,
avatar: token.avatar as string,
locale: token.locale as string,
},
};
return calendsoSession;

View File

@ -71,6 +71,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
// If user has been invitedTo a team, we accept the membership
// TODO check memberships instead of single invitedTo
if (user.invitedTo) {
const team = await prisma.team.findFirst({
where: { id: user.invitedTo },

View File

@ -0,0 +1 @@
export { default } from "@calcom/features/ee/teams/pages/team-billing-view";

View File

@ -1,19 +1,39 @@
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { useSession } from "next-auth/react";
import Head from "next/head";
import { useRouter } from "next/router";
import { useState } from "react";
import { useEffect } from "react";
import { Toaster } from "react-hot-toast";
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 {
NewTeamFormValues,
PendingMember,
NewTeamData,
TeamPrices,
} from "@calcom/features/ee/teams/lib/types";
import { CAL_URL } from "@calcom/lib/constants";
import { STRIPE_PUBLISHABLE_KEY } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localStorage } from "@calcom/lib/webstorage";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/v2";
import { StepCard } from "@components/getting-started/components/StepCard";
import { Steps } from "@components/getting-started/components/Steps";
import PurchaseNewTeam from "../../../../components/team/PurchaseNewTeam";
const stripe = STRIPE_PUBLISHABLE_KEY ? loadStripe(STRIPE_PUBLISHABLE_KEY) : null;
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 steps = ["create-a-new-team", "add-team-members", "purchase-new-team"] as const;
const stepTransform = (step: typeof steps[number]) => {
const stepIndex = steps.indexOf(step);
@ -29,13 +49,31 @@ const stepRouteSchema = z.object({
const CreateNewTeamPage = () => {
const router = useRouter();
const [newTeamData, setNewTeamData] = useState<NewTeamData>({
name: "",
temporarySlug: "",
logo: "",
members: [],
billingFrequency: "monthly",
});
const [clientSecret, setClientSecret] = useState("");
const [teamPrices, setTeamPrices] = useState({
monthly: 0,
yearly: 0,
});
const { t } = useLocale();
const [teamId, setTeamId] = useState<number>();
const session = useSession();
const result = stepRouteSchema.safeParse(router.query);
const currentStep = result.success ? result.data.step[0] : INITIAL_STEP;
const createTemporaryTeamMutation = trpc.useMutation(["viewer.teams.createTemporaryTeam"], {
onSuccess: (data) => {
localStorage.setItem("temporaryTeamSlug", data.metadata.temporarySlug);
goToIndex(1);
},
});
const headers = [
{
title: `${t("create_new_team")}`,
@ -49,6 +87,10 @@ const CreateNewTeamPage = () => {
title: `${t("add_team_members")}`,
subtitle: [`${t("add_team_members_description")}`],
},
{
title: `${t("purchase_team_subscription")}`,
subtitle: [`${t("purchase_team_subscription_description")}`],
},
];
const goToIndex = (index: number) => {
@ -63,6 +105,34 @@ const CreateNewTeamPage = () => {
const currentStepIndex = steps.indexOf(currentStep);
const addNewTeamMember = (newMember: PendingMember) => {
setNewTeamData({ ...newTeamData, members: [...newTeamData.members, newMember] });
};
const deleteNewTeamMember = (email: string) => {
const newMembersArray = newTeamData.members.filter((member) => member.email !== email);
setNewTeamData({ ...newTeamData, members: newMembersArray });
};
const createPaymentIntentMutation = trpc.useMutation(["viewer.teams.createPaymentIntent"], {
onSuccess: (data) => {
if (data) {
setClientSecret(data.clientSecret);
setNewTeamData({ ...newTeamData, customerId: data.customerId, subscriptionId: data.subscriptionId });
goToIndex(2);
}
},
onError: (error) => {
showToast(error.message, "error");
},
});
const getTeamPricesQuery = trpc.useQuery(["viewer.teams.getTeamPrices"], {
onSuccess: (data: TeamPrices) => {
setTeamPrices(data);
},
});
return (
<div
className="dark:bg-brand dark:text-brand-contrast min-h-screen text-black"
@ -72,6 +142,9 @@ const CreateNewTeamPage = () => {
<title>Create a new Team</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div>
<Toaster position="bottom-right" />
</div>
<div className="mx-auto px-4 py-24">
<div className="relative">
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
@ -90,10 +163,14 @@ const CreateNewTeamPage = () => {
<StepCard>
{currentStep === "create-a-new-team" && (
<CreateNewTeam
nextStep={() => {
goToIndex(1);
nextStep={(values) => {
createTemporaryTeamMutation.mutate(values);
}}
setTeamId={(teamId: number) => setTeamId(teamId)}
// newTeamData={newTeamData}
// nextStep={(values: NewTeamFormValues) => {
// setNewTeamData({ ...newTeamData, ...values });
// goToIndex(1);
// }}
/>
)}
@ -101,7 +178,35 @@ const CreateNewTeamPage = () => {
<TeamGeneralSettings teamId={teamId} nextStep={() => goToIndex(2)} />
)} */}
{currentStep === "add-team-members" && teamId && <AddNewTeamMembers teamId={teamId} />}
{currentStep === "add-team-members" && (
<AddNewTeamMembers
newTeamData={newTeamData}
teamPrices={teamPrices}
addNewTeamMember={addNewTeamMember}
deleteNewTeamMember={deleteNewTeamMember}
nextStep={(values: { billingFrequency: "monthly" | "yearly" }) => {
createPaymentIntentMutation.mutate({
teamName: newTeamData.name,
billingFrequency: values.billingFrequency,
seats: newTeamData.members.length,
...(newTeamData.customerId && { customerId: newTeamData.customerId }),
...(newTeamData.subscriptionId && { subscriptionId: newTeamData.subscriptionId }),
});
}}
/>
)}
{currentStep === "purchase-new-team" && (
<Elements stripe={stripe} options={{ clientSecret }}>
<PurchaseNewTeam
total={
newTeamData.members.length *
teamPrices[newTeamData.billingFrequency as keyof typeof teamPrices]
}
newTeamData={newTeamData as NewTeamData & { customerId: string; subscriptionId: string }}
/>
</Elements>
)}
</StepCard>
</div>
</div>

View File

@ -1,5 +1,6 @@
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";
@ -9,12 +10,10 @@ 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"], {
@ -25,22 +24,22 @@ function Teams() {
const teams = data?.filter((m) => m.accepted) || [];
const invites = data?.filter((m) => !m.accepted) || [];
const handleNewTeam = () => {
// Hey
};
return (
<Shell
heading={t("teams")}
subtitle={t("create_manage_teams_collaborative")}
CTA={
<Button type="button" onClick={() => setShowCreateTeamModal(true)}>
<Button type="button" href={`${WEBAPP_URL}/settings/teams/new`}>
<Icon.FiPlus className="inline-block h-3.5 w-3.5 text-white group-hover:text-black ltr:mr-2 rtl:ml-2" />
{t("new")}
</Button>
}>
<>
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
{showCreateTeamModal && (
<TeamCreateModal isOpen={showCreateTeamModal} onClose={() => setShowCreateTeamModal(false)} />
)}
{invites.length > 0 && (
<div className="mb-4">
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
@ -54,11 +53,10 @@ function Teams() {
headline={t("no_teams")}
description={t("no_teams_description")}
buttonRaw={
<Button color="secondary" onClick={() => setShowCreateTeamModal(true)}>
<Button color="secondary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
}
buttonOnClick={() => setShowCreateTeamModal(true)}
/>
)}
{teams.length > 0 && <TeamList teams={teams} />}

View File

@ -1338,10 +1338,29 @@
"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",
"number_sms_notifications": "Phone number (SMS\u00a0notifications)",
"attendee_email_workflow": "Attendee email",
"attendee_email_info": "The person booking's email",
"invalid_credential": "Oh no! Looks like permission expired or was revoked. Please reinstall again.",
"purchase_team_subscription": "Purchase team subscription",
"purchase_team_subscription_description": "All payment details are handled by Stripe",
"error_processing_payment": "Error processing payment",
"switch_to_yearly": "Switch to yearly and save ${{total}}",
"total": "Total",
"choose_common_schedule_team_event": "Choose a common schedule",
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule."
}

View File

@ -1,23 +1,50 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getStripeIdsForTeam } from "@calcom/features/ee/teams/lib/payments";
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);
const referer = req.headers.referer;
if (!customerId) {
res.status(500).json({ message: "Missing customer id" });
if (!referer) {
res.status(500).json({ message: "Missing referer" });
return;
}
const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`;
const stripeSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url,
});
// If accessing a user's portal
if (referer.includes("/settings/billing")) {
const customerId = await getStripeCustomerIdFromUserId(req.session!.user.id);
if (!customerId) {
res.status(500).json({ message: "Missing customer id" });
return;
}
res.redirect(302, stripeSession.url);
const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`;
const stripeSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url,
});
res.redirect(302, stripeSession.url);
}
// If accessing a team's portal if referer has /settings/team/[:teamId]/billing
if (/settings\/teams\/\d+\/billing/g.test(referer)) {
// Grab the teamId by just matching /settings/teams/[:teamId]/billing and getting third item in array after split
const teamId = referer.match(/\/(settings.+)/g) || "";
const team = await getStripeIdsForTeam(parseInt(teamId[0].split("/")[3]));
if (!team?.stripeCustomerId) {
res.status(500).json({ message: "Missing customer id" });
return;
}
const stripeSession = await stripe.billingPortal.sessions.create({
customer: team.stripeCustomerId as string,
return_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/teams/${teamId}/billing`,
});
res.redirect(302, stripeSession.url);
}
}
}

View File

@ -1,71 +1,58 @@
import { MembershipRole } from "@prisma/client";
import React, { useState, SyntheticEvent, useMemo } from "react";
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
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 { 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;
}
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<NewMemberForm>();
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 (
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
<Dialog
open={props.isOpen}
onOpenChange={() => {
props.onExit();
newMemberFormMethods.reset();
}}>
<DialogContent
type="creation"
useOwnActionButtons
@ -73,55 +60,63 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
description={
<span className=" text-sm leading-tight text-gray-500">
Note: This will <span className="font-medium text-gray-900">cost an extra seat ($12/m)</span> on
your subscription if this invitee does not have a TEAM account.
your subscription once this member accepts your invite.
</span>
}>
<form onSubmit={inviteMember}>
<Form form={newMemberFormMethods} handleSubmit={(values) => props.onSubmit(values)}>
<div className="space-y-4">
<TextField
label={t("email_or_username")}
id="inviteUser"
name="inviteUser"
placeholder="email@example.com"
required
<Controller
name="emailOrUsername"
control={newMemberFormMethods.control}
rules={{
required: t("enter_email_or_username"),
validate: (value) => validateUniqueInvite(value) || t("member_already_invited"),
}}
render={({ field: { onChange }, fieldState: { error } }) => (
<>
<TextField
label={t("email_or_username")}
id="inviteUser"
name="inviteUser"
placeholder="email@example.com"
required
onChange={onChange}
/>
{error && <span className="text-sm text-red-800">{error.message}</span>}
</>
)}
/>
<Controller
name="role"
control={newMemberFormMethods.control}
defaultValue={options[0]}
render={({ field: { onChange } }) => (
<div>
<label
className="mb-1 block text-sm font-medium tracking-wide text-gray-700"
htmlFor="role">
{t("role")}
</label>
<Select
defaultValue={options[0]}
options={options.slice(0, 2)}
id="role"
name="role"
className="mt-1 block w-full rounded-sm border-gray-300 text-sm"
onChange={onChange}
/>
</div>
)}
/>
<div>
<label className="mb-1 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
{t("role")}
</label>
<Select
defaultValue={options[0]}
options={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
id="role"
name="role"
className="mt-1 block w-full rounded-sm border-gray-300 text-sm"
/>
</div>
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
type="checkbox"
name="sendInviteEmail"
defaultChecked
id="sendInviteEmail"
className="rounded-sm border-gray-300 text-sm text-black"
/>
</div>
<div className="text-sm ltr:ml-2 rtl:mr-2">
<label htmlFor="sendInviteEmail" className="font-medium text-gray-700">
{t("send_invite_email")}
</label>
</div>
</div>
</div>
{errorMessage && (
<p className="text-sm text-red-700">
<span className="font-bold">Error: </span>
{errorMessage}
</p>
)}
<DialogFooter>
<Button type="button" color="secondary" onClick={props.onExit}>
<Button
type="button"
color="secondary"
onClick={() => {
props.onExit();
newMemberFormMethods.reset();
}}>
{t("cancel")}
</Button>
<Button
@ -132,7 +127,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
{t("invite")}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@ -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<string | null>(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 (
<Dialog>
<DialogTrigger asChild>
<a className="cursor-pointer underline">Upgrade Now</a>
</DialogTrigger>
<DialogContent
type="creation"
title={t("purchase_missing_seats")}
actionText={t("upgrade_to_per_seat")}
actionOnClick={() => upgrade()}>
<p className="mt-6 text-sm text-gray-600">{t("changed_team_billing_info")}test</p>
{data && (
<p className="mt-2 text-sm italic text-gray-700">
{t("team_upgrade_seats_details", {
memberCount: data.totalMembers,
unpaidCount: data.missingSeats,
seatPrice: 12,
totalCost: (data.totalMembers - data.freeSeats) * 12 + 12,
})}
</p>
)}
{errorMessage && (
<Alert severity="error" title={errorMessage} message={t("further_billing_help")} className="my-4" />
)}
</DialogContent>
</Dialog>
);
}

View File

@ -1,127 +1,243 @@
import { Suspense, useState } from "react";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
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 { localStorage } from "@calcom/lib/webstorage";
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";
import { Form } from "@calcom/ui/form/fields";
import { showToast, Switch } from "@calcom/ui/v2/core";
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui/v2/core/skeleton";
const AddNewTeamMemberSkeleton = () => {
return (
<SkeletonContainer className="rounded-md border">
<div className="flex w-full justify-between p-4">
<div>
<p className="text-sm font-medium text-gray-900">
<SkeletonText className="h-4 w-56" />
</p>
<div className="mt-2.5 w-max">
<SkeletonText className="h-5 w-28" />
</div>
</div>
</div>
</SkeletonContainer>
);
};
import { NewTeamData, PendingMember, TeamPrices } from "../../lib/types";
import { NewMemberForm } from "../MemberInvitationModal";
const AddNewTeamMembers = (props: { teamId: number }) => {
const AddNewTeamMembers = ({
nextStep,
teamPrices,
newTeamData,
addNewTeamMember,
deleteNewTeamMember,
}: {
nextStep: (values: { billingFrequency: "monthly" | "yearly" }) => void;
teamPrices: TeamPrices;
newTeamData: NewTeamData;
addNewTeamMember: (newMember: PendingMember) => void;
deleteNewTeamMember: (email: string) => void;
}) => {
const { t } = useLocale();
const utils = trpc.useContext();
const session = useSession();
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);
const [inviteMemberInput, setInviteMemberInput] = useState<NewMemberForm>({
emailOrUsername: "",
role: { value: "MEMBER", label: "Member" },
sendInviteEmail: false,
});
const [skeletonMember, setSkeletonMember] = useState(false);
const [billingFrequency, setBillingFrequency] = useState("monthly");
const [team, setTeam] = useState();
const numberOfMembers = newTeamData.members.length;
const formMethods = useForm({
defaultValues: {
members: newTeamData.members,
},
});
const [memberInviteModal, setMemberInviteModal] = useState(false);
// Set current user as team owner
// useEffect(() => {
// if (!session.data) router.push(`${CAL_URL}/settings/profile`);
// if (session.status !== "loading" && !team.length) {
// setNewTeamData({
// ...newTeamData,
// members: [
// {
// name: session?.data?.user.name || "",
// email: session?.data?.user.email || "",
// username: session?.data?.user.username || "",
// id: session?.data?.user.id,
// avatar: session?.data?.user.avatar || "",
// role: "OWNER",
// locale: session?.data?.user.locale || "en",
// },
// ],
// });
// }
// /* eslint-disable */
// }, [session, team]);
if (isLoading) return <AddNewTeamMemberSkeleton />;
const retrieveTemporaryTeam = trpc.useQuery(
["viewer.teams.retrieveTemporaryTeam", { temporarySlug: localStorage.getItem("temporaryTeamSlug") }],
{
onSuccess: (data) => {
if (data) setTeam(data);
},
}
);
const findUser = trpc.useQuery(["viewer.teams.findUser", inviteMemberInput], {
refetchOnWindowFocus: false,
enabled: false,
onSuccess: (newMember) => {
addNewTeamMember(newMember);
setSkeletonMember(false);
},
onError: (error) => {
showToast(error.message, "error");
setSkeletonMember(false);
},
});
useEffect(() => {
if (inviteMemberInput.emailOrUsername) {
findUser.refetch();
}
// eslint-disable-next-line
}, [inviteMemberInput]);
const handleInviteTeamMember = (values: NewMemberForm) => {
setInviteMemberInput(values);
setMemberInviteModal(false);
setSkeletonMember(true);
};
return (
<Suspense fallback={<AddNewTeamMemberSkeleton />}>
<>
<>
<Form form={formMethods} handleSubmit={(values) => nextStep(values)}>
<>
<ul className="rounded-md border">
{team?.members.map((member, index) => (
<li
key={member.id}
className={classNames(
"flex items-center justify-between p-6 text-sm",
index !== 0 && "border-t"
)}>
<div className="flex space-x-2">
<Avatar
gravatarFallbackMd5="teamMember"
size="mdLg"
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
alt="owner-avatar"
/>
<div>
<div className="flex space-x-1">
<p>{member?.name || t("team_member")}</p>
{/* Assume that the first member of the team is the creator */}
{index === 0 && <Badge variant="green">{t("you")}</Badge>}
{!member.accepted && <Badge variant="orange">{t("pending")}</Badge>}
{member.role === "MEMBER" && <Badge variant="gray">{t("member")}</Badge>}
{member.role === "ADMIN" && <Badge variant="default">{t("admin")}</Badge>}
<div>
<ul className="rounded-md border">
{newTeamData.members &&
team.members.map((member: PendingMember, index: number) => (
<li
key={member.email}
className={classNames(
"flex items-center justify-between p-6 text-sm",
index !== 0 && "border-t"
)}>
<div className="flex space-x-2">
<Avatar
gravatarFallbackMd5="teamMember"
size="mdLg"
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
alt="owner-avatar"
/>
<div>
<div className="flex space-x-1">
<p>{member?.name || member?.email || t("team_member")}</p>
{/* Assume that the first member of the team is the creator */}
{index === 0 && <Badge variant="green">{t("you")}</Badge>}
{member.role !== "OWNER" && <Badge variant="orange">{t("pending")}</Badge>}
{member.role === "MEMBER" && <Badge variant="gray">{t("member")}</Badge>}
{member.role === "ADMIN" && <Badge variant="default">{t("admin")}</Badge>}
</div>
{member.username ? (
<p className="text-gray-600">{`${WEBAPP_URL}/${member?.username}`}</p>
) : (
<p className="text-gray-600">{t("not_on_cal")}</p>
)}
</div>
</div>
{member.username ? (
<p className="text-gray-600">{`${WEBAPP_URL}/${member?.username}`}</p>
) : (
<p className="text-gray-600">{t("not_on_cal")}</p>
{member.role !== "OWNER" && (
<Button
StartIcon={Icon.FiTrash2}
size="icon"
color="secondary"
className="h-[36px] w-[36px]"
onClick={() => deleteNewTeamMember(member.email)}
/>
)}
</div>
</div>
{member.role !== "OWNER" && (
<Button
StartIcon={Icon.FiTrash2}
size="icon"
color="secondary"
className="h-[36px] w-[36px]"
onClick={() => removeMemberMutation.mutate({ teamId: props.teamId, memberId: member.id })}
/>
)}
</li>
))}
</ul>
</li>
))}
{skeletonMember && <SkeletonMember />}
</ul>
<Button
color="secondary"
data-testid="new-member-button"
StartIcon={Icon.FiPlus}
onClick={() => setMemberInviteModal(true)}
className="mt-6 w-full justify-center">
{t("add_team_member")}
</Button>
</>
{team && (
<Button
color="secondary"
data-testid="new-member-button"
StartIcon={Icon.FiPlus}
onClick={() => setMemberInviteModal(true)}
className="mt-6 w-full justify-center">
{t("add_team_member")}
</Button>
</div>
<MemberInvitationModal
isOpen={memberInviteModal}
onExit={() => setMemberInviteModal(false)}
team={team}
currentMember={team?.membership.role}
onSubmit={handleInviteTeamMember}
members={newTeamData.members}
/>
)}
<hr className="my-6 border-neutral-200" />
<hr className="mb-4 mt-6" />
<Button
EndIcon={Icon.FiArrowRight}
className="mt-6 w-full justify-center"
href={`${WEBAPP_URL}/settings/teams/${props.teamId}/profile`}>
{t("finish")}
</Button>
</>
</Suspense>
<Controller
control={formMethods.control}
name="billingFrequency"
defaultValue="monthly"
render={() => (
<div className="flex space-x-2">
<Switch
onCheckedChange={(e) => {
formMethods.setValue("billingFrequency", e ? "yearly" : "monthly");
setBillingFrequency(e ? "yearly" : "monthly");
}}
/>
<p>
{t("switch_to_yearly", {
total: numberOfMembers * (teamPrices.monthly * 12 - teamPrices.yearly),
})}
</p>
</div>
)}
/>
<div className="mt-6 flex justify-between">
<p>{t("total")}</p>
<div>
<p>
{numberOfMembers} {t("members").toLowerCase()} × $
{teamPrices[billingFrequency as keyof typeof teamPrices]} / {billingFrequency} = $
{numberOfMembers * teamPrices[billingFrequency as keyof typeof teamPrices]}
</p>
</div>
</div>
<Button EndIcon={Icon.FiArrowRight} className="mt-6 w-full justify-center" type="submit">
{t("checkout")}
</Button>
</>
</Form>
</>
);
};
export default AddNewTeamMembers;
const SkeletonMember = () => {
return (
<SkeletonContainer className="rounded-md border-t text-sm">
<div className="flex items-center justify-between p-5">
<div className="flex">
<SkeletonAvatar className="h-10 w-10" />
<div>
<p>
<SkeletonText className="h-4 w-56" />
</p>
<p>
<SkeletonText className="h-4 w-56" />
</p>
</div>
</div>
<SkeletonText className="h-7 w-7" />
</div>
</SkeletonContainer>
);
};

View File

@ -9,104 +9,125 @@ 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();
import { NewTeamData, NewTeamFormValues } from "../../lib/types";
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
onSuccess(data) {
utils.invalidateQueries(["viewer.teams.list"]);
props.setTeamId(data.id);
props.nextStep();
const CreateANewTeamForm = ({
nextStep,
newTeamData,
}: {
nextStep: (values: NewTeamFormValues) => void;
newTeamData: NewTeamData;
}) => {
const { t } = useLocale();
const newTeamFormMethods = useForm<NewTeamFormValues>({
defaultValues: {
name: newTeamData?.name || "",
temporarySlug: newTeamData?.temporarySlug || "",
logo: newTeamData?.logo || "",
},
});
const formMethods = useForm();
const validateTeamSlugQuery = trpc.useQuery(
["viewer.teams.validateTeamSlug", { temporarySlug: newTeamFormMethods.watch("temporarySlug") }],
{
enabled: false,
refetchOnWindowFocus: false,
}
);
const validateTeamSlug = async () => {
await validateTeamSlugQuery.refetch();
if (validateTeamSlugQuery.isFetched) return validateTeamSlugQuery.data || t("team_url_taken");
};
return (
<Form
form={formMethods}
handleSubmit={(values) => {
createTeamMutation.mutate({
name: values.name,
slug: values.slug || null,
logo: values.logo || null,
});
}}>
<div className="mb-8">
<Controller
name="name"
control={formMethods.control}
rules={{ required: { value: true, message: t("team_name_required") } }}
render={({ field: { value } }) => (
<TextField
className="mt-2"
name="name"
label={t("team_name")}
value={value}
onChange={(e) => {
formMethods.setValue("name", e?.target.value);
if (formMethods.formState.touchedFields["slug"] === undefined) {
formMethods.setValue("slug", slugify(e?.target.value));
}
}}
autoComplete="off"
/>
)}
/>
</div>
<div className="mb-8">
<Controller
name="slug"
control={formMethods.control}
render={({ field: { value } }) => (
<TextField
className="mt-2"
name="slug"
label={t("team_url")}
addOnLeading={`${WEBAPP_URL}/team/`}
value={value}
onChange={(e) => {
formMethods.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
}}
/>
)}
/>
</div>
<div className="mb-8">
<Controller
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => (
<div className="flex items-center">
<Avatar alt="" imageSrc={value || null} gravatarFallbackMd5="newTeam" size="lg" />
<div className="ml-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("update")}
handleAvatarChange={(newAvatar: string) => {
formMethods.setValue("avatar", newAvatar);
<>
<Form
form={newTeamFormMethods}
handleSubmit={async (values) => {
nextStep(values);
}}>
<div className="mb-8">
<Controller
name="name"
control={newTeamFormMethods.control}
defaultValue=""
rules={{
required: t("must_enter_team_name"),
}}
render={({ field: { value } }) => (
<>
<TextField
className="mt-2"
name="name"
label={t("team_name")}
value={value}
onChange={(e) => {
newTeamFormMethods.setValue("name", e?.target.value);
if (newTeamFormMethods.formState.touchedFields["temporarySlug"] === undefined) {
newTeamFormMethods.setValue("temporarySlug", slugify(e?.target.value));
}
}}
imageSrc={value}
autoComplete="off"
/>
</div>
</div>
)}
/>
</div>
<div className="flex space-x-2">
<Button color="secondary" href="/settings" className="w-full justify-center">
{t("cancel")}
</Button>
<Button color="primary" type="submit" EndIcon={Icon.FiArrowRight} className="w-full justify-center">
{t("continue")}
</Button>
</div>
</>
)}
/>
</div>
{createTeamMutation.isError && <p className="mt-4 text-red-700">{createTeamMutation.error.message}</p>}
</Form>
<div className="mb-8">
<Controller
name="temporarySlug"
control={newTeamFormMethods.control}
rules={{ required: t("team_url_required"), validate: async () => await validateTeamSlug() }}
render={({ field: { value } }) => (
<TextField
className="mt-2"
name="temporarySlug"
label={t("team_url")}
addOnLeading={`${WEBAPP_URL}/team/`}
value={value}
onChange={(e) => {
newTeamFormMethods.setValue("temporarySlug", slugify(e?.target.value), {
shouldTouch: true,
});
}}
/>
)}
/>
</div>
<div className="mb-8">
<Controller
control={newTeamFormMethods.control}
name="logo"
render={({ field: { value } }) => (
<div className="flex items-center">
<Avatar alt="" imageSrc={value || null} gravatarFallbackMd5="newTeam" size="lg" />
<div className="ml-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("update")}
handleAvatarChange={(newAvatar: string) => {
newTeamFormMethods.setValue("logo", newAvatar);
}}
imageSrc={value}
/>
</div>
</div>
)}
/>
</div>
<div className="flex space-x-2">
<Button color="secondary" href="/settings" className="w-full justify-center">
{t("cancel")}
</Button>
<Button color="primary" type="submit" EndIcon={Icon.FiArrowRight} className="w-full justify-center">
{t("continue")}
</Button>
</div>
</Form>
</>
);
};

View File

@ -0,0 +1,77 @@
import { MembershipRole } from "@prisma/client";
import { randomBytes } from "crypto";
import { sendTeamInviteEmail } from "@calcom/emails";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { PendingMember } from "./types";
export const createMember = async ({
teamId,
teamName,
inviter,
pendingMember,
teamOwnerLocale,
}: {
teamId: number;
teamName: string;
inviter: string;
pendingMember: PendingMember;
teamOwnerLocale: string;
teamSubscriptionActive?: boolean;
}) => {
const translation = await getTranslation(pendingMember.locale || teamOwnerLocale || "en", "common");
if (pendingMember.username && pendingMember.id) {
const user = await prisma.membership.create({
data: {
teamId,
userId: pendingMember.id,
role: pendingMember.role as MembershipRole,
},
});
const sendEmail = await sendTeamInviteEmail({
language: translation,
from: inviter,
to: pendingMember.email,
teamName,
joinLink: WEBAPP_URL + `/settings/teams/${teamId}/members`,
});
// If user's are not on Cal.com
} else {
// If user is not in DB
const createdMember = await prisma.user.create({
data: {
email: pendingMember.email,
invitedTo: teamId,
teams: {
create: {
teamId: teamId,
role: pendingMember.role as MembershipRole,
},
},
},
});
const token: string = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
identifier: pendingMember.email,
token,
expires: new Date(new Date().setHours(168)), // +1 week
},
});
await sendTeamInviteEmail({
language: translation,
from: inviter,
to: pendingMember.email,
teamName: teamName,
joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/settings/teams`,
});
}
};

View File

@ -0,0 +1,134 @@
import { Stripe } from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { CAL_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
export type BillingFrequency = "monthly" | "yearly";
export const getTeamPricing = async () => {
if (!process.env.STRIPE_TEAM_MONTHLY_PRICE_ID || !process.env.STRIPE_TEAM_YEARLY_PRICE_ID)
throw new Error("Missing Stripe price ids");
const monthlyPriceQuery = await stripe.prices.retrieve(process.env.STRIPE_TEAM_MONTHLY_PRICE_ID);
const yearlyPriceQuery = await stripe.prices.retrieve(process.env.STRIPE_TEAM_YEARLY_PRICE_ID);
if (!monthlyPriceQuery.unit_amount || !yearlyPriceQuery.unit_amount) return null;
return {
monthly: monthlyPriceQuery.unit_amount / 100,
yearly: yearlyPriceQuery.unit_amount / 100,
};
};
export const searchForTeamCustomer = async (teamName: string, ownerEmail: string) => {
// Search to see if the customer is already in Stripe
const customer = await stripe.customers.search({
query: `name: \'${teamName}\' AND email: \'${ownerEmail}\'`,
});
return customer.data[0];
};
export const createTeamCustomer = async (teamName: string, ownerEmail: string) => {
return await stripe.customers.create({
name: teamName,
email: ownerEmail,
});
};
export const retrieveTeamCustomer = async (customerId: string) => {
const customer = await stripe.customers.retrieve(customerId);
if (customer.deleted) throw new Error("Customer has been deleted off Stripe");
return customer as Stripe.Customer;
};
export const updateTeamCustomerName = async (customerId: string, teamName: string) => {
return await stripe.customers.update(customerId, { name: teamName });
};
export const createTeamSubscription = async (customerId: string, billingFrequency: string, seats: number) => {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [
{
price:
billingFrequency === "monthly"
? process.env.STRIPE_TEAM_MONTHLY_PRICE_ID
: process.env.STRIPE_TEAM_YEARLY_PRICE_ID,
quantity: seats,
},
],
payment_behavior: "default_incomplete",
expand: ["latest_invoice.payment_intent"],
});
return subscription as unknown as Stripe.Subscription & {
quantity: number;
latest_invoice: Stripe.Invoice & { payment_intent: Stripe.PaymentIntent };
};
};
export const retrieveTeamSubscription = async ({
subscriptionId,
customerId,
}: {
subscriptionId?: string;
customerId?: string;
}) => {
if (!subscriptionId && !customerId) throw new Error("No Stripe subscriptions found");
if (subscriptionId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ["latest_invoice.payment_intent"],
});
return subscription as unknown as Stripe.Subscription & {
quantity: number;
latest_invoice: Stripe.Invoice & { payment_intent: Stripe.PaymentIntent };
};
}
if (customerId) {
const subscription = await stripe.subscriptions.list({
customer: customerId,
status: "incomplete",
limit: 1,
expand: ["data.latest_invoice.payment_intent"],
});
return subscription.data[0] as Stripe.Subscription & {
quantity: number;
latest_invoice: Stripe.Invoice & { payment_intent: Stripe.PaymentIntent };
};
}
};
export const deleteTeamSubscriptionQuantity = async (subscriptionId: string) => {
return await stripe.subscriptions.del(subscriptionId);
};
export const getStripeIdsForTeam = async (teamId: number) => {
const teamQuery = await prisma.team.findFirst({
where: {
id: teamId,
},
select: {
metadata: true,
},
});
const teamStripeIds = { ...teamQuery.metadata };
return teamStripeIds;
};
export const deleteTeamFromStripe = async (teamId: number) => {
const stripeCustomerId = await prisma.team.findFirst({
where: {
id: teamId,
},
select: { metadata: true },
});
if (stripeCustomerId?.metadata?.stripeCustomerId) {
await stripe.customers.del(stripeCustomerId.metadata.stripeCustomerId);
return;
} else {
console.error(`Couldn't deleteTeamFromStripe, Team id: ${teamId} didn't have a stripeCustomerId`);
}
};

View File

@ -0,0 +1,33 @@
import { MembershipRole } from "@prisma/client";
export interface NewTeamMembersFieldArray {
members: PendingMember[] | [];
}
export interface NewTeamFormValues {
name: string;
temporarySlug: string;
logo: string;
}
export interface PendingMember {
name: string | null;
email: string;
id?: number;
username: string | null;
role: MembershipRole;
avatar: string | null;
locale: string;
}
export type NewTeamData = NewTeamFormValues &
NewTeamMembersFieldArray & {
billingFrequency: "monthly" | "yearly";
customerId?: string;
subscriptionId?: string;
};
export interface TeamPrices {
monthly: number;
yearly: number;
}

View File

@ -0,0 +1,35 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui";
import { Button } from "@calcom/ui/v2/core";
import Meta from "@calcom/ui/v2/core/Meta";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
const BillingView = () => {
const { t } = useLocale();
return (
<>
<Meta title="Team Billing" description="Manage billing for your team" />
<div className="flex flex-col text-sm sm:flex-row">
<div>
<h2 className="font-medium">{t("billing_manage_details_title")}</h2>
<p>{t("billing_manage_details_description")}</p>
</div>
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pt-0 sm:pl-3">
<Button
color="primary"
href="/api/integrations/stripepayment/portal"
target="_blank"
EndIcon={Icon.FiExternalLink}>
{t("billing_portal")}
</Button>
</div>
</div>
</>
);
};
BillingView.getLayout = getLayout;
export default BillingView;

View File

@ -15,7 +15,6 @@ 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();
@ -57,44 +56,6 @@ const MembersView = () => {
]}
/>
)}
{team.membership.role === MembershipRole.OWNER &&
team.membership.isMissingSeat &&
team.requiresUpgrade ? (
<Alert
severity="warning"
title={t("hidden_team_member_title")}
message={
<>
{t("hidden_team_owner_message")} <UpgradeToFlexibleProModal teamId={team.id} />
</>
}
className="mb-4 "
/>
) : (
<>
{team.membership.isMissingSeat && (
<Alert
severity="warning"
title={t("hidden_team_member_title")}
message={t("hidden_team_member_message")}
className="mb-4 "
/>
)}
{team.membership.role === MembershipRole.OWNER && team.requiresUpgrade && (
<Alert
severity="warning"
title={t("upgrade_to_flexible_pro_title")}
message={
<span>
{t("upgrade_to_flexible_pro_message")} <br />
<UpgradeToFlexibleProModal teamId={team.id} />
</span>
}
className="mb-4"
/>
)}
</>
)}
</>
)}
{isAdmin && (

View File

@ -66,7 +66,7 @@ const ProfileView = () => {
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");
},
});

View File

@ -36,3 +36,9 @@ 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
);
export const STRIPE_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY;

View File

@ -0,0 +1,15 @@
/*
Warnings:
- Added the required column `createdDate` to the `Team` table without a default value. This is not possible if the table is not empty.
- Added the required column `subscriptionStatus` to the `Team` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('PENDING', 'ACTIVE');
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "createdDate" TIMESTAMP(3) NOT NULL,
ADD COLUMN "metadata" JSONB,
ADD COLUMN "subscriptionStatus" "SubscriptionStatus" NOT NULL,
ALTER COLUMN "slug" DROP NOT NULL;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Team" ALTER COLUMN "createdDate" SET DEFAULT CURRENT_TIMESTAMP;

View File

@ -198,16 +198,24 @@ model User {
}
model Team {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
/// @zod.min(1)
name String
name String
/// @zod.min(1)
slug String @unique
logo String?
bio String?
hideBranding Boolean @default(false)
members Membership[]
eventTypes EventType[]
slug String? @unique
logo String?
bio String?
hideBranding Boolean @default(false)
members Membership[]
eventTypes EventType[]
subscriptionStatus SubscriptionStatus
createdDate DateTime @default(now())
metadata Json?
}
enum SubscriptionStatus {
PENDING
ACTIVE
}
enum MembershipRole {

View File

@ -574,6 +574,8 @@ async function main() {
],
},
},
subscriptionStatus: "ACTIVE",
createdDate: new Date(),
},
[
{

View File

@ -1,20 +1,30 @@
import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
import { randomBytes } from "crypto";
import { z } from "zod";
import { undefined, z } from "zod";
import {
addSeat,
downgradeTeamMembers,
ensureSubscriptionQuantityCorrectness,
getTeamSeatStats,
removeSeat,
upgradeTeam,
} 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 { createMember } from "@calcom/features/ee/teams/lib/inviteMember";
import {
deleteTeamFromStripe,
createTeamCustomer,
createTeamSubscription,
getTeamPricing,
retrieveTeamCustomer,
updateTeamCustomerName,
retrieveTeamSubscription,
deleteTeamSubscriptionQuantity,
searchForTeamCustomer,
} from "@calcom/features/ee/teams/lib/payments";
import { HOSTED_CAL_FEATURES, IS_STRIPE_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,
@ -79,39 +89,34 @@ export const viewerTeamsRouter = createProtectedRouter()
.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,
slug,
logo,
members: {
create: {
userId: ctx.user.id,
role: MembershipRole.OWNER,
accepted: true,
},
},
},
});
@ -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_STRIPE_ENABLED) await deleteTeamFromStripe(input.teamId);
// delete all memberships
await ctx.prisma.membership.deleteMany({
@ -487,16 +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(),
@ -562,4 +555,240 @@ export const viewerTeamsRouter = createProtectedRouter()
},
});
},
})
.query("validateTeamSlug", {
input: z.object({
temporarySlug: z.string(),
}),
async resolve({ ctx, input }) {
const team = await ctx.prisma.team.findFirst({
where: {
slug: input.temporarySlug,
},
});
return !team;
},
})
.query("findUser", {
input: z.object({
emailOrUsername: z.string(),
role: z.object({
value: z.union([z.literal("OWNER"), z.literal("ADMIN"), z.literal("MEMBER")]),
label: z.string(),
}),
}),
async resolve({ ctx, input }) {
const { emailOrUsername } = input;
const user = await ctx.prisma.user.findFirst({
where: {
OR: [{ username: emailOrUsername }, { email: emailOrUsername }],
},
select: {
id: true,
name: true,
username: true,
avatar: true,
email: true,
locale: true,
},
});
if (!user && !/\b[a-z0-9-_.]+@[a-z0-9-_.]+(\.[a-z0-9]+)+/.test(emailOrUsername)) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Could not find user",
});
}
return {
id: user?.id || undefined,
name: user?.name || "",
email: user?.email || emailOrUsername,
username: user?.username || "",
avatar: user?.avatar || "",
role: input.role.value,
locale: user?.locale || "en",
};
},
})
.query("getTeamPrices", {
async resolve() {
const teamPrices = getTeamPricing();
return teamPrices;
},
})
.mutation("createPaymentIntent", {
input: z.object({
teamName: z.string(),
billingFrequency: z.string(),
seats: z.number(),
customerId: z.string().optional(),
subscriptionId: z.string().optional(),
}),
async resolve({ ctx, input }) {
const { teamName, billingFrequency, seats, customerId, subscriptionId } = input;
// Check to see if customer already exists in DB from another session
let customer = await searchForTeamCustomer(teamName, ctx.user.email);
// Check to see if team name has changed if within same session
if (customerId) {
customer = await retrieveTeamCustomer(customerId);
if (teamName !== customer?.name) {
await updateTeamCustomerName(customerId, teamName);
}
}
// Check to that subscription quantity is the same
if (subscriptionId || customer) {
// Grab current "incomplete" subscription
const subscriptionQuery = await retrieveTeamSubscription({
subscriptionId: subscriptionId,
customerId: customer.id,
});
if (subscriptionQuery && seats !== subscriptionQuery?.quantity) {
/* If the number of seats changed we need to cancel the current
incomplete subscription and create a new one */
await deleteTeamSubscriptionQuantity(subscriptionId || subscriptionQuery.id);
const subscription = await createTeamSubscription(
customerId || customer.id,
input.billingFrequency,
input.seats
);
return {
clientSecret: subscription?.latest_invoice?.payment_intent?.client_secret,
customerId: customer.id,
subscriptionId: subscription.id,
};
}
// If customer exists and no changes were made to the subscription
if (subscriptionQuery)
return {
clientSecret: subscriptionQuery?.latest_invoice?.payment_intent?.client_secret,
customerId: customer.id,
subscriptionId: subscriptionQuery.id,
};
}
// If no changes then do not create a new customer & subscription, just return
if (!customerId && !subscriptionId) {
// First create the customer if it does not exist
customer = customer || (await createTeamCustomer(teamName, ctx.user.email));
// Create the subscription for the team
const subscription = await createTeamSubscription(customer.id, billingFrequency, seats);
// We just need the client secret for the payment intent
return {
clientSecret: subscription?.latest_invoice?.payment_intent?.client_secret,
customerId: customer.id,
subscriptionId: subscription.id,
};
}
},
})
.mutation("createTeam", {
input: z.object({
name: z.string(),
slug: z.string(),
logo: z.string().optional(),
members: z.array(
z.object({
name: z.union([z.string(), z.null()]),
email: z.string(),
id: z.number().optional(),
username: z.union([z.string(), z.null()]),
role: z.union([z.literal("OWNER"), z.literal("ADMIN"), z.literal("MEMBER")]),
avatar: z.union([z.string(), z.null()]),
locale: z.string(),
})
),
customerId: z.string(),
subscriptionId: z.string(),
}),
async resolve({ ctx, input }) {
const { name, slug, logo, members, customerId, subscriptionId } = input;
if (!customerId && !subscriptionId)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Stripe Ids not found",
});
const createTeam = await ctx.prisma.team.create({
data: {
name,
slug,
logo,
subscriptionStatus: "ACTIVE",
members: {
create: {
userId: ctx.user.id,
role: MembershipRole.OWNER,
accepted: true,
},
},
metadata: {
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
},
},
});
for (const member of members) {
if (member.id !== ctx.user.id) {
await createMember({
teamId: createTeam.id,
teamName: name,
inviter: ctx.user.name || "Owner",
pendingMember: member,
teamOwnerLocale: ctx.user.locale,
});
}
}
return createTeam;
},
})
.mutation("createTemporaryTeam", {
input: z.object({
name: z.string(),
temporarySlug: z.string(),
logo: z.string().optional(),
}),
async resolve({ ctx, input }) {
const { name, temporarySlug, logo } = input;
const createTempraryTeam = await ctx.prisma.team.create({
data: {
name,
logo,
members: {
create: {
userId: ctx.user.id,
role: MembershipRole.OWNER,
accepted: true,
},
},
subscriptionStatus: "PENDING",
metadata: {
temporarySlug,
},
},
});
return createTempraryTeam;
},
})
.query("retrieveTemporaryTeam", {
input: z.object({
temporarySlug: z.union[(z.string(), z.null())],
}),
async resolve({ ctx, input }) {
if (!input.temporarySlug) {
return;
}
},
});

View File

@ -8,6 +8,8 @@ declare module "next-auth" {
username: string;
impersonatedByUID?: number;
role: UserPermissionRole;
avatar: string | null;
locale: string;
};
/**
* Returned by `useSession`, `getSession` and received as a prop on the `Provider` React Context

View File

@ -243,6 +243,12 @@ const SettingsSidebarContainer = ({ className = "" }) => {
textClassNames="px-3 text-gray-900 font-medium text-sm"
disableChevron
/>
<VerticalTabItem
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-gray-900 font-medium text-sm"
disableChevron
/>
{HOSTED_CAL_FEATURES && (
<VerticalTabItem
name={t("saml_config")}

View File

@ -39,6 +39,9 @@
"$STRIPE_PRO_PLAN_PRODUCT_ID",
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"$STRIPE_FREE_PLAN_PRODUCT_ID",
"$STRIPE_TEAM_MONTHLY_PRICE_ID",
"$STRIPE_TEAM_YEARLY_PRICE_ID",
"$STRIPE_PUBLISHABLE_KEY",
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
"$NEXT_PUBLIC_WEBAPP_URL",
"$NEXT_PUBLIC_WEBSITE_URL"
@ -67,6 +70,7 @@
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"$STRIPE_FREE_PLAN_PRODUCT_ID",
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
"$STRIPE_PUBLISHABLE_KEY",
"$NEXT_PUBLIC_WEBAPP_URL",
"$NEXT_PUBLIC_WEBSITE_URL"
],
@ -234,6 +238,7 @@
"$SLACK_CLIENT_ID",
"$SLACK_CLIENT_SECRET",
"$SLACK_SIGNING_SECRET",
"$STRIPE_PUBLISHABLE_KEY",
"$STRIPE_CLIENT_ID",
"$STRIPE_PRIVATE_KEY",
"$STRIPE_WEBHOOK_SECRET",

View File

@ -6376,11 +6376,23 @@
dependencies:
prop-types "^15.7.2"
"@stripe/react-stripe-js@^1.14.1":
version "1.14.1"
resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.14.1.tgz#7b748fa039beb79b549b88e9389fd6148ac8c54f"
integrity sha512-PpXsi/NkiDGKILS6bWcM8QwBdC39U7QAT9KFopQ2CdO+kcbXv9l/e8M1saXAQC5AwdgQTTk6Zl0+//qp1faRiw==
dependencies:
prop-types "^15.7.2"
"@stripe/stripe-js@^1.35.0":
version "1.35.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.35.0.tgz#f809e2e5e0a00f01aa12e8aed0b89d27728c05c0"
integrity sha512-UIuzpbJqgXCTvJhY/aZYvBtaKdMfQgnIv6kkLlfRJ9smZcC4zoPvq3j7k9wobYI+idHAWP4BRiPnqA8lvzJCtg==
"@stripe/stripe-js@^1.42.1":
version "1.42.1"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.42.1.tgz#09f303fe298f81e5dc618f0df88bf929d758fa0f"
integrity sha512-5H7P7rVNDMdiJCUKsKXRUyr6IjZ4/9f8FB62NCa5O4rDftRfs4u2dPjjHs/tIs4OLeBB6oSzJIkE7cPr4ykPHg==
"@swc/helpers@0.4.11":
version "0.4.11"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.11.tgz#db23a376761b3d31c26502122f349a21b592c8de"
@ -20600,7 +20612,7 @@ qs@6.9.7:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.7.tgz#4610846871485e1e048f44ae3b94033f0e675afe"
integrity sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==
qs@^6.10.0:
qs@^6.10.0, qs@^6.11.0:
version "6.11.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
@ -23216,6 +23228,14 @@ stripe@*:
"@types/node" ">=8.1.0"
qs "^6.10.3"
stripe@^10.15.0:
version "10.15.0"
resolved "https://registry.yarnpkg.com/stripe/-/stripe-10.15.0.tgz#1f4cde7f90959673964edb07e5a49c918b124ca5"
integrity sha512-KXw7dozfIEQCxsshjKUwsNwlD55CkhcvVgwwxFtQNikVlRpNUPy6Ljz2mN9OXjinbbmLIcn26MAiPxfNp8Adtg==
dependencies:
"@types/node" ">=8.1.0"
qs "^6.11.0"
stripe@^9.16.0:
version "9.16.0"
resolved "https://registry.yarnpkg.com/stripe/-/stripe-9.16.0.tgz#94c24549c91fced457b9e3259e8a1a1bdb6dbd0e"