Compare commits
86 Commits
main
...
teams-stri
Author | SHA1 | Date | |
---|---|---|---|
|
57b8104630 | ||
|
8606ba9893 | ||
|
db48f9fbfb | ||
|
402f87e7c2 | ||
|
e59dbb9ed0 | ||
|
108b7f8a0e | ||
|
d256b0e4e7 | ||
|
b47d021c02 | ||
|
acc455a79d | ||
|
692f6bd3ca | ||
|
a1b196f3f6 | ||
|
618968c57c | ||
|
6f59210787 | ||
|
7a4c1fbedf | ||
|
a1c9afdf46 | ||
|
d9194a9baa | ||
|
45b4187d3a | ||
|
42a6376cc4 | ||
|
7c3c3e69e8 | ||
|
ab53af9c6e | ||
|
87f795c514 | ||
|
fc062d0271 | ||
|
ad066745e5 | ||
|
63b4c9fa2d | ||
|
027a036765 | ||
|
c60e9bdc32 | ||
|
a9e897d99e | ||
|
cc46f21d66 | ||
|
50521ce2bb | ||
|
20f1306be2 | ||
|
2a52e7693c | ||
|
8535570ea4 | ||
|
b0e81e3823 | ||
|
6f539dfefd | ||
|
5f1e089014 | ||
|
69db0d6610 | ||
|
1dda714804 | ||
|
2ebd0b1774 | ||
|
eacdf69659 | ||
|
c1570ff807 | ||
|
7db31ad5ca | ||
|
d34b4ac931 | ||
|
20488b35c8 | ||
|
1c3e294dc0 | ||
|
725608d595 | ||
|
6b151e5817 | ||
|
e650112a35 | ||
|
a4f435b9fb | ||
|
0372e3eb26 | ||
|
d93033d9d6 | ||
|
f1cfbc7347 | ||
|
29a1cf9430 | ||
|
42dbbf4773 | ||
|
ad70309258 | ||
|
278db57641 | ||
|
bdb6318abb | ||
|
f6dbd0fff2 | ||
|
efe8b1df32 | ||
|
773ec9741f | ||
|
51e127f293 | ||
|
ec5769abb2 | ||
|
90e1cbc2cd | ||
|
bfdea7c0fc | ||
|
235e419467 | ||
|
96ef940c2d | ||
|
cf7a21a7b3 | ||
|
7bfd74cac9 | ||
|
be17a3a447 | ||
|
0b50ac3803 | ||
|
671077d7b0 | ||
|
a91e7abd0b | ||
|
c193b93ff6 | ||
|
523a2172af | ||
|
4feb65d130 | ||
|
91d3d6b84f | ||
|
a31fd08154 | ||
|
16d7a9ca61 | ||
|
4dbcc62152 | ||
|
d968b37e4c | ||
|
71b9350cc2 | ||
|
cf4f870f3b | ||
|
37dfd420e9 | ||
|
8c598d2562 | ||
|
33ff213fe0 | ||
|
427c5df99c | ||
|
ec3816f1e9 |
12
.env.example
12
.env.example
|
@ -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_
|
||||
|
|
|
@ -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,
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { default } from "@calcom/features/ee/teams/pages/team-billing-view";
|
|
@ -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>
|
||||
|
|
|
@ -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} />}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -1,12 +1,22 @@
|
|||
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 (!referer) {
|
||||
res.status(500).json({ message: "Missing referer" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
@ -17,7 +27,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 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,
|
||||
});
|
||||
export interface NewMemberForm {
|
||||
emailOrUsername: string;
|
||||
role: MembershipRoleOption;
|
||||
}
|
||||
|
||||
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const options: MembershipRoleOption[] = useMemo(() => {
|
||||
return [
|
||||
{ value: "MEMBER", label: t("member") },
|
||||
{ value: "ADMIN", label: t("admin") },
|
||||
{ value: "OWNER", label: t("owner") },
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
const newMemberFormMethods = useForm<NewMemberForm>();
|
||||
|
||||
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">
|
||||
<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">
|
||||
<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}
|
||||
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 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>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,57 +1,124 @@
|
|||
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)}>
|
||||
<>
|
||||
<div>
|
||||
<ul className="rounded-md border">
|
||||
{team?.members.map((member, index) => (
|
||||
{newTeamData.members &&
|
||||
team.members.map((member: PendingMember, index: number) => (
|
||||
<li
|
||||
key={member.id}
|
||||
key={member.email}
|
||||
className={classNames(
|
||||
"flex items-center justify-between p-6 text-sm",
|
||||
index !== 0 && "border-t"
|
||||
|
@ -65,10 +132,10 @@ const AddNewTeamMembers = (props: { teamId: number }) => {
|
|||
/>
|
||||
<div>
|
||||
<div className="flex space-x-1">
|
||||
<p>{member?.name || t("team_member")}</p>
|
||||
<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.accepted && <Badge variant="orange">{t("pending")}</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>
|
||||
|
@ -85,11 +152,12 @@ const AddNewTeamMembers = (props: { teamId: number }) => {
|
|||
size="icon"
|
||||
color="secondary"
|
||||
className="h-[36px] w-[36px]"
|
||||
onClick={() => removeMemberMutation.mutate({ teamId: props.teamId, memberId: member.id })}
|
||||
onClick={() => deleteNewTeamMember(member.email)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{skeletonMember && <SkeletonMember />}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
|
@ -100,28 +168,76 @@ const AddNewTeamMembers = (props: { teamId: number }) => {
|
|||
className="mt-6 w-full justify-center">
|
||||
{t("add_team_member")}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
{team && (
|
||||
</div>
|
||||
<MemberInvitationModal
|
||||
isOpen={memberInviteModal}
|
||||
onExit={() => setMemberInviteModal(false)}
|
||||
team={team}
|
||||
currentMember={team?.membership.role}
|
||||
onSubmit={handleInviteTeamMember}
|
||||
members={newTeamData.members}
|
||||
/>
|
||||
|
||||
<hr className="mb-4 mt-6" />
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<hr className="my-6 border-neutral-200" />
|
||||
<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"
|
||||
href={`${WEBAPP_URL}/settings/teams/${props.teamId}/profile`}>
|
||||
{t("finish")}
|
||||
<Button EndIcon={Icon.FiArrowRight} className="mt-6 w-full justify-center" type="submit">
|
||||
{t("checkout")}
|
||||
</Button>
|
||||
</>
|
||||
</Suspense>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,66 +9,88 @@ 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,
|
||||
});
|
||||
form={newTeamFormMethods}
|
||||
handleSubmit={async (values) => {
|
||||
nextStep(values);
|
||||
}}>
|
||||
<div className="mb-8">
|
||||
<Controller
|
||||
name="name"
|
||||
control={formMethods.control}
|
||||
rules={{ required: { value: true, message: t("team_name_required") } }}
|
||||
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) => {
|
||||
formMethods.setValue("name", e?.target.value);
|
||||
if (formMethods.formState.touchedFields["slug"] === undefined) {
|
||||
formMethods.setValue("slug", slugify(e?.target.value));
|
||||
newTeamFormMethods.setValue("name", e?.target.value);
|
||||
if (newTeamFormMethods.formState.touchedFields["temporarySlug"] === undefined) {
|
||||
newTeamFormMethods.setValue("temporarySlug", slugify(e?.target.value));
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Controller
|
||||
name="slug"
|
||||
control={formMethods.control}
|
||||
name="temporarySlug"
|
||||
control={newTeamFormMethods.control}
|
||||
rules={{ required: t("team_url_required"), validate: async () => await validateTeamSlug() }}
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
className="mt-2"
|
||||
name="slug"
|
||||
name="temporarySlug"
|
||||
label={t("team_url")}
|
||||
addOnLeading={`${WEBAPP_URL}/team/`}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
|
||||
newTeamFormMethods.setValue("temporarySlug", slugify(e?.target.value), {
|
||||
shouldTouch: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -76,8 +98,8 @@ const CreateANewTeamForm = (props: { nextStep: () => void; setTeamId: (teamId: n
|
|||
</div>
|
||||
<div className="mb-8">
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="avatar"
|
||||
control={newTeamFormMethods.control}
|
||||
name="logo"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="flex items-center">
|
||||
<Avatar alt="" imageSrc={value || null} gravatarFallbackMd5="newTeam" size="lg" />
|
||||
|
@ -87,7 +109,7 @@ const CreateANewTeamForm = (props: { nextStep: () => void; setTeamId: (teamId: n
|
|||
id="avatar-upload"
|
||||
buttonMsg={t("update")}
|
||||
handleAvatarChange={(newAvatar: string) => {
|
||||
formMethods.setValue("avatar", newAvatar);
|
||||
newTeamFormMethods.setValue("logo", newAvatar);
|
||||
}}
|
||||
imageSrc={value}
|
||||
/>
|
||||
|
@ -104,9 +126,8 @@ const CreateANewTeamForm = (props: { nextStep: () => void; setTeamId: (teamId: n
|
|||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{createTeamMutation.isError && <p className="mt-4 text-red-700">{createTeamMutation.error.message}</p>}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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`,
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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`);
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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 && (
|
||||
|
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Team" ALTER COLUMN "createdDate" SET DEFAULT CURRENT_TIMESTAMP;
|
|
@ -202,12 +202,20 @@ 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[]
|
||||
subscriptionStatus SubscriptionStatus
|
||||
createdDate DateTime @default(now())
|
||||
metadata Json?
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
PENDING
|
||||
ACTIVE
|
||||
}
|
||||
|
||||
enum MembershipRole {
|
||||
|
|
|
@ -574,6 +574,8 @@ async function main() {
|
|||
],
|
||||
},
|
||||
},
|
||||
subscriptionStatus: "ACTIVE",
|
||||
createdDate: new Date(),
|
||||
},
|
||||
[
|
||||
{
|
||||
|
|
|
@ -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,40 +89,35 @@ 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,
|
||||
name,
|
||||
slug,
|
||||
logo,
|
||||
members: {
|
||||
create: {
|
||||
userId: ctx.user.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 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_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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")}
|
||||
|
|
|
@ -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",
|
||||
|
|
22
yarn.lock
22
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue
Block a user