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=
|
NEXT_PUBLIC_IS_E2E=
|
||||||
|
|
||||||
# Used for internal billing system
|
# Used for internal billing system
|
||||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
|
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
STRIPE_TEAM_YEARLY_PRICE_ID=
|
||||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
STRIPE_PRIVATE_KEY=
|
||||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
|
||||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
|
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
STRIPE_PRO_PLAN_PRODUCT_ID=
|
STRIPE_CLIENT_ID=
|
||||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID=
|
|
||||||
STRIPE_FREE_PLAN_PRODUCT_ID=
|
|
||||||
|
|
||||||
# Use for internal Public API Keys and optional
|
# Use for internal Public API Keys and optional
|
||||||
API_KEY_PREFIX=cal_
|
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-toggle-group": "^1.0.0",
|
||||||
"@radix-ui/react-tooltip": "^1.0.0",
|
"@radix-ui/react-tooltip": "^1.0.0",
|
||||||
"@sentry/nextjs": "^7.17.3",
|
"@sentry/nextjs": "^7.17.3",
|
||||||
"@stripe/react-stripe-js": "^1.10.0",
|
"@stripe/react-stripe-js": "^1.14.1",
|
||||||
"@stripe/stripe-js": "^1.35.0",
|
"@stripe/stripe-js": "^1.42.1",
|
||||||
"@tanstack/react-query": "^4.3.9",
|
"@tanstack/react-query": "^4.3.9",
|
||||||
"@vercel/edge-functions-ui": "^0.2.1",
|
"@vercel/edge-functions-ui": "^0.2.1",
|
||||||
"@vercel/og": "^0.0.19",
|
"@vercel/og": "^0.0.19",
|
||||||
|
@ -116,7 +116,7 @@
|
||||||
"react-window": "^1.8.7",
|
"react-window": "^1.8.7",
|
||||||
"rrule": "^2.7.1",
|
"rrule": "^2.7.1",
|
||||||
"short-uuid": "^4.2.0",
|
"short-uuid": "^4.2.0",
|
||||||
"stripe": "^9.16.0",
|
"stripe": "^10.15.0",
|
||||||
"superjson": "1.9.1",
|
"superjson": "1.9.1",
|
||||||
"tailwindcss-radix": "^2.6.0",
|
"tailwindcss-radix": "^2.6.0",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
|
|
|
@ -231,6 +231,8 @@ export default NextAuth({
|
||||||
email: existingUser.email,
|
email: existingUser.email,
|
||||||
role: existingUser.role,
|
role: existingUser.role,
|
||||||
impersonatedByUID: token?.impersonatedByUID as number,
|
impersonatedByUID: token?.impersonatedByUID as number,
|
||||||
|
avatar: existingUser.avatar,
|
||||||
|
locale: existingUser.locale,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
@ -296,6 +298,8 @@ export default NextAuth({
|
||||||
username: token.username as string,
|
username: token.username as string,
|
||||||
role: token.role as UserPermissionRole,
|
role: token.role as UserPermissionRole,
|
||||||
impersonatedByUID: token.impersonatedByUID as number,
|
impersonatedByUID: token.impersonatedByUID as number,
|
||||||
|
avatar: token.avatar as string,
|
||||||
|
locale: token.locale as string,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return calendsoSession;
|
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
|
// If user has been invitedTo a team, we accept the membership
|
||||||
|
// TODO check memberships instead of single invitedTo
|
||||||
if (user.invitedTo) {
|
if (user.invitedTo) {
|
||||||
const team = await prisma.team.findFirst({
|
const team = await prisma.team.findFirst({
|
||||||
where: { id: user.invitedTo },
|
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 Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// import TeamGeneralSettings from "@calcom/features/teams/createNewTeam/TeamGeneralSettings";
|
// import TeamGeneralSettings from "@calcom/features/teams/createNewTeam/TeamGeneralSettings";
|
||||||
import AddNewTeamMembers from "@calcom/features/ee/teams/components/v2/AddNewTeamMembers";
|
import AddNewTeamMembers from "@calcom/features/ee/teams/components/v2/AddNewTeamMembers";
|
||||||
import CreateNewTeam from "@calcom/features/ee/teams/components/v2/CreateNewTeam";
|
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 { 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 { StepCard } from "@components/getting-started/components/StepCard";
|
||||||
import { Steps } from "@components/getting-started/components/Steps";
|
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";
|
const INITIAL_STEP = "create-a-new-team";
|
||||||
// TODO: Add teams general settings "general-settings"
|
// 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 stepTransform = (step: typeof steps[number]) => {
|
||||||
const stepIndex = steps.indexOf(step);
|
const stepIndex = steps.indexOf(step);
|
||||||
|
@ -29,13 +49,31 @@ const stepRouteSchema = z.object({
|
||||||
|
|
||||||
const CreateNewTeamPage = () => {
|
const CreateNewTeamPage = () => {
|
||||||
const router = useRouter();
|
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 { t } = useLocale();
|
||||||
const [teamId, setTeamId] = useState<number>();
|
const session = useSession();
|
||||||
|
|
||||||
const result = stepRouteSchema.safeParse(router.query);
|
const result = stepRouteSchema.safeParse(router.query);
|
||||||
const currentStep = result.success ? result.data.step[0] : INITIAL_STEP;
|
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 = [
|
const headers = [
|
||||||
{
|
{
|
||||||
title: `${t("create_new_team")}`,
|
title: `${t("create_new_team")}`,
|
||||||
|
@ -49,6 +87,10 @@ const CreateNewTeamPage = () => {
|
||||||
title: `${t("add_team_members")}`,
|
title: `${t("add_team_members")}`,
|
||||||
subtitle: [`${t("add_team_members_description")}`],
|
subtitle: [`${t("add_team_members_description")}`],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: `${t("purchase_team_subscription")}`,
|
||||||
|
subtitle: [`${t("purchase_team_subscription_description")}`],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const goToIndex = (index: number) => {
|
const goToIndex = (index: number) => {
|
||||||
|
@ -63,6 +105,34 @@ const CreateNewTeamPage = () => {
|
||||||
|
|
||||||
const currentStepIndex = steps.indexOf(currentStep);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="dark:bg-brand dark:text-brand-contrast min-h-screen text-black"
|
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>
|
<title>Create a new Team</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
|
<div>
|
||||||
|
<Toaster position="bottom-right" />
|
||||||
|
</div>
|
||||||
<div className="mx-auto px-4 py-24">
|
<div className="mx-auto px-4 py-24">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
|
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
|
||||||
|
@ -90,10 +163,14 @@ const CreateNewTeamPage = () => {
|
||||||
<StepCard>
|
<StepCard>
|
||||||
{currentStep === "create-a-new-team" && (
|
{currentStep === "create-a-new-team" && (
|
||||||
<CreateNewTeam
|
<CreateNewTeam
|
||||||
nextStep={() => {
|
nextStep={(values) => {
|
||||||
goToIndex(1);
|
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)} />
|
<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>
|
</StepCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
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 EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
|
||||||
|
|
||||||
import SkeletonLoaderTeamList from "@components/team/SkeletonloaderTeamList";
|
import SkeletonLoaderTeamList from "@components/team/SkeletonloaderTeamList";
|
||||||
import TeamCreateModal from "@components/team/TeamCreateModal";
|
|
||||||
import TeamList from "@components/team/TeamList";
|
import TeamList from "@components/team/TeamList";
|
||||||
|
|
||||||
function Teams() {
|
function Teams() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
|
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
|
||||||
|
@ -25,22 +24,22 @@ function Teams() {
|
||||||
|
|
||||||
const teams = data?.filter((m) => m.accepted) || [];
|
const teams = data?.filter((m) => m.accepted) || [];
|
||||||
const invites = data?.filter((m) => !m.accepted) || [];
|
const invites = data?.filter((m) => !m.accepted) || [];
|
||||||
|
const handleNewTeam = () => {
|
||||||
|
// Hey
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
heading={t("teams")}
|
heading={t("teams")}
|
||||||
subtitle={t("create_manage_teams_collaborative")}
|
subtitle={t("create_manage_teams_collaborative")}
|
||||||
CTA={
|
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" />
|
<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")}
|
{t("new")}
|
||||||
</Button>
|
</Button>
|
||||||
}>
|
}>
|
||||||
<>
|
<>
|
||||||
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
|
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||||
{showCreateTeamModal && (
|
|
||||||
<TeamCreateModal isOpen={showCreateTeamModal} onClose={() => setShowCreateTeamModal(false)} />
|
|
||||||
)}
|
|
||||||
{invites.length > 0 && (
|
{invites.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
|
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
|
||||||
|
@ -54,11 +53,10 @@ function Teams() {
|
||||||
headline={t("no_teams")}
|
headline={t("no_teams")}
|
||||||
description={t("no_teams_description")}
|
description={t("no_teams_description")}
|
||||||
buttonRaw={
|
buttonRaw={
|
||||||
<Button color="secondary" onClick={() => setShowCreateTeamModal(true)}>
|
<Button color="secondary" href={`${WEBAPP_URL}/settings/teams/new`}>
|
||||||
{t("create_team")}
|
{t("create_team")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
buttonOnClick={() => setShowCreateTeamModal(true)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{teams.length > 0 && <TeamList teams={teams} />}
|
{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",
|
"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": "No event types setup",
|
||||||
"no_event_types_description": "{{name}} has not setup any event types for you to book.",
|
"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)",
|
"number_sms_notifications": "Phone number (SMS\u00a0notifications)",
|
||||||
"attendee_email_workflow": "Attendee email",
|
"attendee_email_workflow": "Attendee email",
|
||||||
"attendee_email_info": "The person booking's email",
|
"attendee_email_info": "The person booking's email",
|
||||||
"invalid_credential": "Oh no! Looks like permission expired or was revoked. Please reinstall again.",
|
"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": "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."
|
"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,23 +1,50 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { getStripeIdsForTeam } from "@calcom/features/ee/teams/lib/payments";
|
||||||
|
|
||||||
import { getStripeCustomerIdFromUserId } from "../lib/customer";
|
import { getStripeCustomerIdFromUserId } from "../lib/customer";
|
||||||
import stripe from "../lib/server";
|
import stripe from "../lib/server";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "POST" || req.method === "GET") {
|
if (req.method === "POST" || req.method === "GET") {
|
||||||
const customerId = await getStripeCustomerIdFromUserId(req.session!.user.id);
|
const referer = req.headers.referer;
|
||||||
|
|
||||||
if (!customerId) {
|
if (!referer) {
|
||||||
res.status(500).json({ message: "Missing customer id" });
|
res.status(500).json({ message: "Missing referer" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`;
|
// If accessing a user's portal
|
||||||
const stripeSession = await stripe.billingPortal.sessions.create({
|
if (referer.includes("/settings/billing")) {
|
||||||
customer: customerId,
|
const customerId = await getStripeCustomerIdFromUserId(req.session!.user.id);
|
||||||
return_url,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,71 +1,58 @@
|
||||||
import { MembershipRole } from "@prisma/client";
|
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 { 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 { Button, TextField } from "@calcom/ui/components";
|
||||||
|
import { Form } from "@calcom/ui/form/fields";
|
||||||
import { Dialog, DialogContent, DialogFooter, Select } from "@calcom/ui/v2";
|
import { Dialog, DialogContent, DialogFooter, Select } from "@calcom/ui/v2";
|
||||||
|
|
||||||
|
import { PendingMember } from "../lib/types";
|
||||||
|
|
||||||
type MemberInvitationModalProps = {
|
type MemberInvitationModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
team: TeamWithMembers | null;
|
|
||||||
currentMember: MembershipRole;
|
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
|
onSubmit: (values: NewMemberForm) => void;
|
||||||
|
members: PendingMember[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type MembershipRoleOption = {
|
type MembershipRoleOption = {
|
||||||
value: MembershipRole;
|
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) {
|
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const { t } = useLocale();
|
||||||
const { t, i18n } = useLocale();
|
|
||||||
const utils = trpc.useContext();
|
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options: MembershipRoleOption[] = useMemo(() => {
|
||||||
_options.forEach((option, i) => {
|
return [
|
||||||
_options[i].label = t(option.value.toLowerCase());
|
{ value: "MEMBER", label: t("member") },
|
||||||
});
|
{ value: "ADMIN", label: t("admin") },
|
||||||
return _options;
|
{ value: "OWNER", label: t("owner") },
|
||||||
|
];
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
|
const newMemberFormMethods = useForm<NewMemberForm>();
|
||||||
async onSuccess() {
|
|
||||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
|
||||||
props.onExit();
|
|
||||||
},
|
|
||||||
async onError(err) {
|
|
||||||
setErrorMessage(err.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function inviteMember(e: SyntheticEvent) {
|
const validateUniqueInvite = (value: string) => {
|
||||||
e.preventDefault();
|
return !(
|
||||||
if (!props.team) return;
|
props.members.some((member) => member?.username === value) ||
|
||||||
|
props.members.some((member) => member?.email === value)
|
||||||
const target = e.target as typeof e.target & {
|
);
|
||||||
elements: {
|
};
|
||||||
role: { value: MembershipRole };
|
|
||||||
inviteUser: { value: string };
|
|
||||||
sendInviteEmail: { checked: boolean };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
inviteMemberMutation.mutate({
|
|
||||||
teamId: props.team.id,
|
|
||||||
language: i18n.language,
|
|
||||||
role: target.elements["role"].value,
|
|
||||||
usernameOrEmail: target.elements["inviteUser"].value,
|
|
||||||
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
|
<Dialog
|
||||||
|
open={props.isOpen}
|
||||||
|
onOpenChange={() => {
|
||||||
|
props.onExit();
|
||||||
|
newMemberFormMethods.reset();
|
||||||
|
}}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
type="creation"
|
type="creation"
|
||||||
useOwnActionButtons
|
useOwnActionButtons
|
||||||
|
@ -73,55 +60,63 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||||
description={
|
description={
|
||||||
<span className=" text-sm leading-tight text-gray-500">
|
<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
|
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>
|
</span>
|
||||||
}>
|
}>
|
||||||
<form onSubmit={inviteMember}>
|
<Form form={newMemberFormMethods} handleSubmit={(values) => props.onSubmit(values)}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<TextField
|
<Controller
|
||||||
label={t("email_or_username")}
|
name="emailOrUsername"
|
||||||
id="inviteUser"
|
control={newMemberFormMethods.control}
|
||||||
name="inviteUser"
|
rules={{
|
||||||
placeholder="email@example.com"
|
required: t("enter_email_or_username"),
|
||||||
required
|
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>
|
</div>
|
||||||
{errorMessage && (
|
|
||||||
<p className="text-sm text-red-700">
|
|
||||||
<span className="font-bold">Error: </span>
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
props.onExit();
|
||||||
|
newMemberFormMethods.reset();
|
||||||
|
}}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
@ -132,7 +127,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||||
{t("invite")}
|
{t("invite")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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,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 MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { localStorage } from "@calcom/lib/webstorage";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Icon } from "@calcom/ui";
|
import { Icon } from "@calcom/ui";
|
||||||
import { Avatar } from "@calcom/ui/components/avatar";
|
import { Avatar } from "@calcom/ui/components/avatar";
|
||||||
import { Badge } from "@calcom/ui/components/badge";
|
import { Badge } from "@calcom/ui/components/badge";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
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 = () => {
|
import { NewTeamData, PendingMember, TeamPrices } from "../../lib/types";
|
||||||
return (
|
import { NewMemberForm } from "../MemberInvitationModal";
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const session = useSession();
|
||||||
|
|
||||||
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: props.teamId }]);
|
const [memberInviteModal, setMemberInviteModal] = useState(false);
|
||||||
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
|
const [inviteMemberInput, setInviteMemberInput] = useState<NewMemberForm>({
|
||||||
onSuccess() {
|
emailOrUsername: "",
|
||||||
utils.invalidateQueries(["viewer.teams.get", { teamId: props.teamId }]);
|
role: { value: "MEMBER", label: "Member" },
|
||||||
utils.invalidateQueries(["viewer.teams.list"]);
|
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 (
|
return (
|
||||||
<Suspense fallback={<AddNewTeamMemberSkeleton />}>
|
<>
|
||||||
<>
|
<Form form={formMethods} handleSubmit={(values) => nextStep(values)}>
|
||||||
<>
|
<>
|
||||||
<ul className="rounded-md border">
|
<div>
|
||||||
{team?.members.map((member, index) => (
|
<ul className="rounded-md border">
|
||||||
<li
|
{newTeamData.members &&
|
||||||
key={member.id}
|
team.members.map((member: PendingMember, index: number) => (
|
||||||
className={classNames(
|
<li
|
||||||
"flex items-center justify-between p-6 text-sm",
|
key={member.email}
|
||||||
index !== 0 && "border-t"
|
className={classNames(
|
||||||
)}>
|
"flex items-center justify-between p-6 text-sm",
|
||||||
<div className="flex space-x-2">
|
index !== 0 && "border-t"
|
||||||
<Avatar
|
)}>
|
||||||
gravatarFallbackMd5="teamMember"
|
<div className="flex space-x-2">
|
||||||
size="mdLg"
|
<Avatar
|
||||||
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
|
gravatarFallbackMd5="teamMember"
|
||||||
alt="owner-avatar"
|
size="mdLg"
|
||||||
/>
|
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
|
||||||
<div>
|
alt="owner-avatar"
|
||||||
<div className="flex space-x-1">
|
/>
|
||||||
<p>{member?.name || t("team_member")}</p>
|
<div>
|
||||||
{/* Assume that the first member of the team is the creator */}
|
<div className="flex space-x-1">
|
||||||
{index === 0 && <Badge variant="green">{t("you")}</Badge>}
|
<p>{member?.name || member?.email || t("team_member")}</p>
|
||||||
{!member.accepted && <Badge variant="orange">{t("pending")}</Badge>}
|
{/* Assume that the first member of the team is the creator */}
|
||||||
{member.role === "MEMBER" && <Badge variant="gray">{t("member")}</Badge>}
|
{index === 0 && <Badge variant="green">{t("you")}</Badge>}
|
||||||
{member.role === "ADMIN" && <Badge variant="default">{t("admin")}</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>
|
</div>
|
||||||
{member.username ? (
|
{member.role !== "OWNER" && (
|
||||||
<p className="text-gray-600">{`${WEBAPP_URL}/${member?.username}`}</p>
|
<Button
|
||||||
) : (
|
StartIcon={Icon.FiTrash2}
|
||||||
<p className="text-gray-600">{t("not_on_cal")}</p>
|
size="icon"
|
||||||
|
color="secondary"
|
||||||
|
className="h-[36px] w-[36px]"
|
||||||
|
onClick={() => deleteNewTeamMember(member.email)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</li>
|
||||||
</div>
|
))}
|
||||||
{member.role !== "OWNER" && (
|
{skeletonMember && <SkeletonMember />}
|
||||||
<Button
|
</ul>
|
||||||
StartIcon={Icon.FiTrash2}
|
|
||||||
size="icon"
|
|
||||||
color="secondary"
|
|
||||||
className="h-[36px] w-[36px]"
|
|
||||||
onClick={() => removeMemberMutation.mutate({ teamId: props.teamId, memberId: member.id })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
data-testid="new-member-button"
|
data-testid="new-member-button"
|
||||||
StartIcon={Icon.FiPlus}
|
StartIcon={Icon.FiPlus}
|
||||||
onClick={() => setMemberInviteModal(true)}
|
onClick={() => setMemberInviteModal(true)}
|
||||||
className="mt-6 w-full justify-center">
|
className="mt-6 w-full justify-center">
|
||||||
{t("add_team_member")}
|
{t("add_team_member")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
|
|
||||||
{team && (
|
|
||||||
<MemberInvitationModal
|
<MemberInvitationModal
|
||||||
isOpen={memberInviteModal}
|
isOpen={memberInviteModal}
|
||||||
onExit={() => setMemberInviteModal(false)}
|
onExit={() => setMemberInviteModal(false)}
|
||||||
team={team}
|
onSubmit={handleInviteTeamMember}
|
||||||
currentMember={team?.membership.role}
|
members={newTeamData.members}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<hr className="my-6 border-neutral-200" />
|
<hr className="mb-4 mt-6" />
|
||||||
|
|
||||||
<Button
|
<Controller
|
||||||
EndIcon={Icon.FiArrowRight}
|
control={formMethods.control}
|
||||||
className="mt-6 w-full justify-center"
|
name="billingFrequency"
|
||||||
href={`${WEBAPP_URL}/settings/teams/${props.teamId}/profile`}>
|
defaultValue="monthly"
|
||||||
{t("finish")}
|
render={() => (
|
||||||
</Button>
|
<div className="flex space-x-2">
|
||||||
</>
|
<Switch
|
||||||
</Suspense>
|
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;
|
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,104 +9,125 @@ import { Button, Avatar } from "@calcom/ui/components";
|
||||||
import { Form, TextField } from "@calcom/ui/components/form";
|
import { Form, TextField } from "@calcom/ui/components/form";
|
||||||
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
|
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
|
||||||
|
|
||||||
const CreateANewTeamForm = (props: { nextStep: () => void; setTeamId: (teamId: number) => void }) => {
|
import { NewTeamData, NewTeamFormValues } from "../../lib/types";
|
||||||
const { t } = useLocale();
|
|
||||||
const utils = trpc.useContext();
|
|
||||||
|
|
||||||
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
|
const CreateANewTeamForm = ({
|
||||||
onSuccess(data) {
|
nextStep,
|
||||||
utils.invalidateQueries(["viewer.teams.list"]);
|
newTeamData,
|
||||||
props.setTeamId(data.id);
|
}: {
|
||||||
props.nextStep();
|
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 (
|
return (
|
||||||
<Form
|
<>
|
||||||
form={formMethods}
|
<Form
|
||||||
handleSubmit={(values) => {
|
form={newTeamFormMethods}
|
||||||
createTeamMutation.mutate({
|
handleSubmit={async (values) => {
|
||||||
name: values.name,
|
nextStep(values);
|
||||||
slug: values.slug || null,
|
}}>
|
||||||
logo: values.logo || null,
|
<div className="mb-8">
|
||||||
});
|
<Controller
|
||||||
}}>
|
name="name"
|
||||||
<div className="mb-8">
|
control={newTeamFormMethods.control}
|
||||||
<Controller
|
defaultValue=""
|
||||||
name="name"
|
rules={{
|
||||||
control={formMethods.control}
|
required: t("must_enter_team_name"),
|
||||||
rules={{ required: { value: true, message: t("team_name_required") } }}
|
}}
|
||||||
render={({ field: { value } }) => (
|
render={({ field: { value } }) => (
|
||||||
<TextField
|
<>
|
||||||
className="mt-2"
|
<TextField
|
||||||
name="name"
|
className="mt-2"
|
||||||
label={t("team_name")}
|
name="name"
|
||||||
value={value}
|
label={t("team_name")}
|
||||||
onChange={(e) => {
|
value={value}
|
||||||
formMethods.setValue("name", e?.target.value);
|
onChange={(e) => {
|
||||||
if (formMethods.formState.touchedFields["slug"] === undefined) {
|
newTeamFormMethods.setValue("name", e?.target.value);
|
||||||
formMethods.setValue("slug", slugify(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}
|
|
||||||
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);
|
|
||||||
}}
|
}}
|
||||||
imageSrc={value}
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
</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>
|
|
||||||
|
|
||||||
{createTeamMutation.isError && <p className="mt-4 text-red-700">{createTeamMutation.error.message}</p>}
|
<div className="mb-8">
|
||||||
</Form>
|
<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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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 MemberInvitationModal from "../components/MemberInvitationModal";
|
||||||
import MemberListItem from "../components/MemberListItem";
|
import MemberListItem from "../components/MemberListItem";
|
||||||
import TeamInviteList from "../components/TeamInviteList";
|
import TeamInviteList from "../components/TeamInviteList";
|
||||||
import { UpgradeToFlexibleProModal } from "../components/UpgradeToFlexibleProModal";
|
|
||||||
|
|
||||||
const MembersView = () => {
|
const MembersView = () => {
|
||||||
const { t } = useLocale();
|
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 && (
|
{isAdmin && (
|
||||||
|
|
|
@ -66,7 +66,7 @@ const ProfileView = () => {
|
||||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||||
await utils.invalidateQueries(["viewer.teams.list"]);
|
await utils.invalidateQueries(["viewer.teams.list"]);
|
||||||
router.push(`/settings`);
|
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_DEFAULT = `${WEBSITE_URL}/og-image.png`;
|
||||||
export const SEO_IMG_OGIMG = `${CAL_URL}/api/social/og/image`;
|
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 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;
|
|
@ -198,16 +198,24 @@ model User {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Team {
|
model Team {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
/// @zod.min(1)
|
/// @zod.min(1)
|
||||||
name String
|
name String
|
||||||
/// @zod.min(1)
|
/// @zod.min(1)
|
||||||
slug String @unique
|
slug String? @unique
|
||||||
logo String?
|
logo String?
|
||||||
bio String?
|
bio String?
|
||||||
hideBranding Boolean @default(false)
|
hideBranding Boolean @default(false)
|
||||||
members Membership[]
|
members Membership[]
|
||||||
eventTypes EventType[]
|
eventTypes EventType[]
|
||||||
|
subscriptionStatus SubscriptionStatus
|
||||||
|
createdDate DateTime @default(now())
|
||||||
|
metadata Json?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionStatus {
|
||||||
|
PENDING
|
||||||
|
ACTIVE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MembershipRole {
|
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 { MembershipRole, Prisma, UserPlan } from "@prisma/client";
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { z } from "zod";
|
import { undefined, z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addSeat,
|
addSeat,
|
||||||
downgradeTeamMembers,
|
|
||||||
ensureSubscriptionQuantityCorrectness,
|
ensureSubscriptionQuantityCorrectness,
|
||||||
getTeamSeatStats,
|
getTeamSeatStats,
|
||||||
removeSeat,
|
removeSeat,
|
||||||
upgradeTeam,
|
|
||||||
} from "@calcom/app-store/stripepayment/lib/team-billing";
|
} from "@calcom/app-store/stripepayment/lib/team-billing";
|
||||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||||
import { sendTeamInviteEmail } from "@calcom/emails";
|
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 { 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 slugify from "@calcom/lib/slugify";
|
||||||
import {
|
import {
|
||||||
closeComDeleteTeam,
|
closeComDeleteTeam,
|
||||||
|
@ -79,39 +89,34 @@ export const viewerTeamsRouter = createProtectedRouter()
|
||||||
.mutation("create", {
|
.mutation("create", {
|
||||||
input: z.object({
|
input: z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
slug: z.string().optional().nullable(),
|
slug: z.string().transform((val) => slugify(val.trim())),
|
||||||
logo: z.string().optional().nullable(),
|
logo: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.transform((v) => v || null),
|
||||||
}),
|
}),
|
||||||
async resolve({ ctx, input }) {
|
async resolve({ ctx, input }) {
|
||||||
if (ctx.user.plan === "FREE") {
|
const { slug, name, logo } = input;
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "You need a team plan." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = input.slug || slugify(input.name);
|
const nameCollisions = await ctx.prisma.team.findFirst({
|
||||||
|
where: { OR: [{ name }, { slug }] },
|
||||||
const nameCollisions = await ctx.prisma.team.count({
|
|
||||||
where: {
|
|
||||||
OR: [{ name: input.name }, { slug: slug }],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nameCollisions > 0)
|
if (nameCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." });
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." });
|
|
||||||
|
|
||||||
const createTeam = await ctx.prisma.team.create({
|
const createTeam = await ctx.prisma.team.create({
|
||||||
data: {
|
data: {
|
||||||
name: input.name,
|
name,
|
||||||
slug: slug,
|
slug,
|
||||||
logo: input.logo || null,
|
logo,
|
||||||
},
|
members: {
|
||||||
});
|
create: {
|
||||||
|
userId: ctx.user.id,
|
||||||
await ctx.prisma.membership.create({
|
role: MembershipRole.OWNER,
|
||||||
data: {
|
accepted: true,
|
||||||
teamId: createTeam.id,
|
},
|
||||||
userId: ctx.user.id,
|
},
|
||||||
role: MembershipRole.OWNER,
|
|
||||||
accepted: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -173,9 +178,7 @@ export const viewerTeamsRouter = createProtectedRouter()
|
||||||
async resolve({ ctx, input }) {
|
async resolve({ ctx, input }) {
|
||||||
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
|
||||||
if (process.env.STRIPE_PRIVATE_KEY) {
|
if (IS_STRIPE_ENABLED) await deleteTeamFromStripe(input.teamId);
|
||||||
await downgradeTeamMembers(input.teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete all memberships
|
// delete all memberships
|
||||||
await ctx.prisma.membership.deleteMany({
|
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", {
|
.query("getTeamSeats", {
|
||||||
input: z.object({
|
input: z.object({
|
||||||
teamId: z.number(),
|
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;
|
username: string;
|
||||||
impersonatedByUID?: number;
|
impersonatedByUID?: number;
|
||||||
role: UserPermissionRole;
|
role: UserPermissionRole;
|
||||||
|
avatar: string | null;
|
||||||
|
locale: string;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Returned by `useSession`, `getSession` and received as a prop on the `Provider` React Context
|
* 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"
|
textClassNames="px-3 text-gray-900 font-medium text-sm"
|
||||||
disableChevron
|
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 && (
|
{HOSTED_CAL_FEATURES && (
|
||||||
<VerticalTabItem
|
<VerticalTabItem
|
||||||
name={t("saml_config")}
|
name={t("saml_config")}
|
||||||
|
|
|
@ -39,6 +39,9 @@
|
||||||
"$STRIPE_PRO_PLAN_PRODUCT_ID",
|
"$STRIPE_PRO_PLAN_PRODUCT_ID",
|
||||||
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
|
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
|
||||||
"$STRIPE_FREE_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_STRIPE_PUBLIC_KEY",
|
||||||
"$NEXT_PUBLIC_WEBAPP_URL",
|
"$NEXT_PUBLIC_WEBAPP_URL",
|
||||||
"$NEXT_PUBLIC_WEBSITE_URL"
|
"$NEXT_PUBLIC_WEBSITE_URL"
|
||||||
|
@ -67,6 +70,7 @@
|
||||||
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
|
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
|
||||||
"$STRIPE_FREE_PLAN_PRODUCT_ID",
|
"$STRIPE_FREE_PLAN_PRODUCT_ID",
|
||||||
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
|
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
|
||||||
|
"$STRIPE_PUBLISHABLE_KEY",
|
||||||
"$NEXT_PUBLIC_WEBAPP_URL",
|
"$NEXT_PUBLIC_WEBAPP_URL",
|
||||||
"$NEXT_PUBLIC_WEBSITE_URL"
|
"$NEXT_PUBLIC_WEBSITE_URL"
|
||||||
],
|
],
|
||||||
|
@ -234,6 +238,7 @@
|
||||||
"$SLACK_CLIENT_ID",
|
"$SLACK_CLIENT_ID",
|
||||||
"$SLACK_CLIENT_SECRET",
|
"$SLACK_CLIENT_SECRET",
|
||||||
"$SLACK_SIGNING_SECRET",
|
"$SLACK_SIGNING_SECRET",
|
||||||
|
"$STRIPE_PUBLISHABLE_KEY",
|
||||||
"$STRIPE_CLIENT_ID",
|
"$STRIPE_CLIENT_ID",
|
||||||
"$STRIPE_PRIVATE_KEY",
|
"$STRIPE_PRIVATE_KEY",
|
||||||
"$STRIPE_WEBHOOK_SECRET",
|
"$STRIPE_WEBHOOK_SECRET",
|
||||||
|
|
22
yarn.lock
22
yarn.lock
|
@ -6376,11 +6376,23 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
prop-types "^15.7.2"
|
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":
|
"@stripe/stripe-js@^1.35.0":
|
||||||
version "1.35.0"
|
version "1.35.0"
|
||||||
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.35.0.tgz#f809e2e5e0a00f01aa12e8aed0b89d27728c05c0"
|
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.35.0.tgz#f809e2e5e0a00f01aa12e8aed0b89d27728c05c0"
|
||||||
integrity sha512-UIuzpbJqgXCTvJhY/aZYvBtaKdMfQgnIv6kkLlfRJ9smZcC4zoPvq3j7k9wobYI+idHAWP4BRiPnqA8lvzJCtg==
|
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":
|
"@swc/helpers@0.4.11":
|
||||||
version "0.4.11"
|
version "0.4.11"
|
||||||
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.11.tgz#db23a376761b3d31c26502122f349a21b592c8de"
|
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"
|
resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.7.tgz#4610846871485e1e048f44ae3b94033f0e675afe"
|
||||||
integrity sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==
|
integrity sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==
|
||||||
|
|
||||||
qs@^6.10.0:
|
qs@^6.10.0, qs@^6.11.0:
|
||||||
version "6.11.0"
|
version "6.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
|
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
|
||||||
integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
|
integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
|
||||||
|
@ -23216,6 +23228,14 @@ stripe@*:
|
||||||
"@types/node" ">=8.1.0"
|
"@types/node" ">=8.1.0"
|
||||||
qs "^6.10.3"
|
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:
|
stripe@^9.16.0:
|
||||||
version "9.16.0"
|
version "9.16.0"
|
||||||
resolved "https://registry.yarnpkg.com/stripe/-/stripe-9.16.0.tgz#94c24549c91fced457b9e3259e8a1a1bdb6dbd0e"
|
resolved "https://registry.yarnpkg.com/stripe/-/stripe-9.16.0.tgz#94c24549c91fced457b9e3259e8a1a1bdb6dbd0e"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user