Team billing (#5453)
* WIP teams billing page * WIP * Create settings page * Remove unused imports * Create stripe customer on team creation * Add Stripe ids to team record * Add Stripe price ids for team to .env * Create & delete Stripe customers * Add string * Merge branch 'main' into v2/teams-billing * Create checkout session when creating team * Create webhook to update team with Stripe ids * Add Stripe migration files * Move deleting team from Stripe under ee * Some cleanup * Merge branch 'v2/teams-billing' of https://github.com/calcom/cal.com into v2/teams-billing * Small clean up * Link to team's portal page * Fix types * Fix type errors * Fix type errors * Fix type error * Delete old files & type fixes * Address feedback * Fix type errors * Removes team creation modal * WIP * Removed billing frequency from team creation * Add Stripe check for delete team customer * Merge branch 'v2/teams-billing' of https://github.com/calcom/cal.com into v2/teams-billing * Add high level form to create new team * WIP * Add new team to form * Validate for invited members * Add translations * WIP * Add validation for team name * Add validation to team slug * Clean up * Fix type error * Fix type errors * WIP * Abstract invite members function * Add subscription status column * Hide pending teams from settings * Send email on paid subscription * WIP * Sync packages * Add team subscription cols to schema * WIP * Matches locks vite version to <3 * Removed subscriptionStatus * WIP * Fix warning * Query optimizations * WIP * Cleanup * Wip * WIP * Runtime error fixes * Cancellation fixes * Delete team fixes * Cleanup * Type fixes * Allows to check memebership in getTeamWithMembers * Adds team creation tests * Cleanup * Cleanup * Restored change * Updated copy * Moved component * Cleanup * Fix team members view * Cleanup * Adds failsafe for skipping publishing on update * Cleanup * Feedback * More feedback * Cleanup * Cleanup * Feedback * Feedback * Feedback * Adds edge-case for slug conflicts * Feedback * e2e fixes Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com>pull/5198/merge
parent
557b57a261
commit
6d67808627
|
@ -96,11 +96,10 @@ NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
|
|||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRO_PLAN_PRODUCT_ID=
|
||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID=
|
||||
STRIPE_FREE_PLAN_PRODUCT_ID=
|
||||
STRIPE_PRIVATE_KEY=
|
||||
STRIPE_CLIENT_ID=
|
||||
|
||||
# Use for internal Public API Keys and optional
|
||||
API_KEY_PREFIX=cal_
|
||||
|
|
|
@ -1,62 +0,0 @@
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/DisableTeamImpersonation.tsx` */
|
||||
const DisableTeamImpersonation = ({ teamId, memberId }: { teamId: number; memberId: number }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const query = trpc.useQuery(["viewer.teams.getMembershipbyUser", { teamId, memberId }]);
|
||||
|
||||
const mutation = trpc.useMutation("viewer.teams.updateMembership", {
|
||||
onSuccess: async () => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
await utils.invalidateQueries(["viewer.teams.getMembershipbyUser"]);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
},
|
||||
});
|
||||
if (query.isLoading) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="font-cal mt-7 pb-4 text-xl leading-6 text-gray-900">{t("settings")}</h3>
|
||||
<div className="-mx-0 rounded-sm border border-neutral-200 bg-white px-4 pb-4 sm:px-6">
|
||||
<div className="flex flex-col justify-between pt-4 sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal font-bold leading-6 text-gray-900">
|
||||
{t("user_impersonation_heading")}
|
||||
</h2>
|
||||
<Badge
|
||||
className="ml-2 text-xs"
|
||||
variant={!query.data?.disableImpersonation ? "success" : "gray"}>
|
||||
{!query.data?.disableImpersonation ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700">{t("team_impersonation_description")}</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 sm:self-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
!query.data?.disableImpersonation
|
||||
? mutation.mutate({ teamId, memberId, disableImpersonation: true })
|
||||
: mutation.mutate({ teamId, memberId, disableImpersonation: false })
|
||||
}>
|
||||
{!query.data?.disableImpersonation ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisableTeamImpersonation;
|
|
@ -1,115 +0,0 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import { SyntheticEvent, useMemo, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type MembershipRoleOption = {
|
||||
label: string;
|
||||
value: MembershipRole;
|
||||
};
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/MemberChangeRoleModal.tsx` */
|
||||
export default function MemberChangeRoleModal(props: {
|
||||
isOpen: boolean;
|
||||
currentMember: MembershipRole;
|
||||
memberId: number;
|
||||
teamId: number;
|
||||
initialRole: MembershipRole;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t("member"),
|
||||
value: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
label: t("admin"),
|
||||
value: MembershipRole.ADMIN,
|
||||
},
|
||||
{
|
||||
label: t("owner"),
|
||||
value: MembershipRole.OWNER,
|
||||
},
|
||||
].filter(({ value }) => value !== MembershipRole.OWNER || props.currentMember === MembershipRole.OWNER);
|
||||
}, [t, props.currentMember]);
|
||||
|
||||
const [role, setRole] = useState<MembershipRoleOption>(
|
||||
options.find((option) => option.value === props.initialRole) || {
|
||||
label: t("member"),
|
||||
value: MembershipRole.MEMBER,
|
||||
}
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function changeRole(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
changeRoleMutation.mutate({
|
||||
teamId: props.teamId,
|
||||
memberId: props.memberId,
|
||||
role: role.value,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<ModalContainer isOpen={props.isOpen} onExit={props.onExit}>
|
||||
<>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("change_member_role")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={changeRole}>
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
{/*<option value="OWNER">{t("owner")}</option> - needs dialog to confirm change of ownership */}
|
||||
<Select
|
||||
isSearchable={false}
|
||||
options={options}
|
||||
value={role}
|
||||
onChange={(option) => option && setRole(option)}
|
||||
id="role"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-700">
|
||||
<span className="font-bold">Error: </span>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" color="primary" className="ltr:ml-2 rtl:mr-2">
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
</ModalContainer>
|
||||
);
|
||||
}
|
|
@ -1,159 +0,0 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import React, { useState, SyntheticEvent, useMemo } from "react";
|
||||
|
||||
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type MemberInvitationModalProps = {
|
||||
isOpen: boolean;
|
||||
team: TeamWithMembers | null;
|
||||
currentMember: MembershipRole;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
type MembershipRoleOption = {
|
||||
value: MembershipRole;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/MemberInvitationModal.tsx` */
|
||||
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t, i18n } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const options = useMemo(() => {
|
||||
_options.forEach((option, i) => {
|
||||
_options[i].label = t(option.value.toLowerCase());
|
||||
});
|
||||
return _options;
|
||||
}, [t]);
|
||||
|
||||
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function inviteMember(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!props.team) return;
|
||||
|
||||
const target = e.target as typeof e.target & {
|
||||
elements: {
|
||||
role: { value: MembershipRole };
|
||||
inviteUser: { value: string };
|
||||
sendInviteEmail: { checked: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
inviteMemberMutation.mutate({
|
||||
teamId: props.team.id,
|
||||
language: i18n.language,
|
||||
role: target.elements["role"].value,
|
||||
usernameOrEmail: target.elements["inviteUser"].value,
|
||||
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
|
||||
<DialogContent>
|
||||
<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.FiUser 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("invite_new_member")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{t("invite_new_team_member")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={inviteMember}>
|
||||
<div className="space-y-4">
|
||||
<TextField
|
||||
label={t("email_or_username")}
|
||||
id="inviteUser"
|
||||
name="inviteUser"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
<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 className="flex flex-row rounded-sm bg-gray-50 px-3 py-2">
|
||||
<Icon.FiInfo className="h-5 w-5 flex-shrink-0 fill-gray-400" aria-hidden="true" />
|
||||
<span className="ml-2 text-sm leading-tight text-gray-500">
|
||||
Note: This will cost an extra seat ($12/m) on your subscription if this invitee does not have
|
||||
a pro account.{" "}
|
||||
<a href="#" className="underline">
|
||||
Learn More
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-700">
|
||||
<span className="font-bold">Error: </span>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
data-testid="invite-new-member-button">
|
||||
{t("invite")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
import { inferQueryOutput } from "@calcom/trpc/react";
|
||||
|
||||
import MemberListItem from "./MemberListItem";
|
||||
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
members: inferQueryOutput<"viewer.teams.get">["members"];
|
||||
}
|
||||
|
||||
export default function MemberList(props: Props) {
|
||||
if (!props.members.length) return <></>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="-mx-4 mb-2 divide-y divide-gray-200 rounded border bg-white px-4 sm:mx-0 sm:px-4">
|
||||
{props.members?.map((member) => (
|
||||
<MemberListItem key={member.id} member={member} team={props.team} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,260 +0,0 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import TeamAvailabilityModal from "@calcom/features/ee/teams/components/TeamAvailabilityModal";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
|
||||
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
||||
import TeamPill, { TeamRole } from "./TeamPill";
|
||||
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
member: inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
}
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/MemberListItem.tsx` */
|
||||
export default function MemberListItem(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
|
||||
const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
|
||||
const [showImpersonateModal, setShowImpersonateModal] = useState(false);
|
||||
|
||||
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("success"), "success");
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const ownersInTeam = () => {
|
||||
const { members } = props.team;
|
||||
const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]);
|
||||
return owners.length;
|
||||
};
|
||||
|
||||
const currentUserId = useCurrentUserId();
|
||||
|
||||
const name =
|
||||
props.member.name ||
|
||||
(() => {
|
||||
const emailName = props.member.email.split("@")[0];
|
||||
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
|
||||
})();
|
||||
|
||||
const removeMember = () =>
|
||||
removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
|
||||
|
||||
return (
|
||||
<li className="divide-y">
|
||||
<div className="my-4 flex justify-between">
|
||||
<div className="flex w-full flex-col justify-between sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={WEBAPP_URL + "/" + props.member.username + "/avatar.png"}
|
||||
alt={name || ""}
|
||||
className="h-9 w-9 rounded-full"
|
||||
/>
|
||||
<div className="ml-3 inline-block">
|
||||
<span className="text-sm font-bold text-neutral-700">{name}</span>
|
||||
<span
|
||||
className="-mt-1 block text-xs text-gray-400"
|
||||
data-testid="member-email"
|
||||
data-email={props.member.email}>
|
||||
{props.member.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex ltr:mr-2 rtl:ml-2 sm:mt-0 sm:justify-center">
|
||||
{/* Tooltip doesn't show... WHY????? */}
|
||||
{props.member.isMissingSeat && (
|
||||
<Tooltip side="top" content={t("hidden_team_member_message")}>
|
||||
<TeamPill color="red" text={t("hidden")} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{!props.member.accepted && <TeamPill color="yellow" text={t("invitee")} />}
|
||||
{props.member.role && <TeamRole role={props.member.role} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Tooltip side="top" content={t("team_view_user_availability")}>
|
||||
<Button
|
||||
// Disabled buttons don't trigger Tooltips
|
||||
title={
|
||||
props.member.accepted
|
||||
? t("team_view_user_availability")
|
||||
: t("team_view_user_availability_disabled")
|
||||
}
|
||||
disabled={!props.member.accepted}
|
||||
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
|
||||
color="minimal"
|
||||
size="icon">
|
||||
<Icon.FiClock className="h-5 w-5 group-hover:text-gray-800" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" color="minimal" size="icon" StartIcon={Icon.FiMoreHorizontal} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Link href={"/" + props.member.username}>
|
||||
<a target="_blank">
|
||||
<Button color="minimal" StartIcon={Icon.FiExternalLink} className="w-full font-normal">
|
||||
{t("view_public_page")}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{((props.team.membership.role === MembershipRole.OWNER &&
|
||||
(props.member.role !== MembershipRole.OWNER ||
|
||||
ownersInTeam() > 1 ||
|
||||
props.member.id !== currentUserId)) ||
|
||||
(props.team.membership.role === MembershipRole.ADMIN &&
|
||||
props.member.role !== MembershipRole.OWNER)) && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
onClick={() => setShowChangeMemberRoleModal(true)}
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiEdit2}
|
||||
className="w-full flex-shrink-0 font-normal">
|
||||
{t("edit_role")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{/* Only show impersonate box if - The user has impersonation enabled,
|
||||
They have accepted the team invite, and it is enabled for this instance */}
|
||||
{!props.member.disableImpersonation &&
|
||||
props.member.accepted &&
|
||||
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
onClick={() => setShowImpersonateModal(true)}
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiLock}
|
||||
className="w-full flex-shrink-0 font-normal">
|
||||
{t("impersonate")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={Icon.FiUserMinus}
|
||||
className="w-full font-normal">
|
||||
{t("remove_member")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_member")}
|
||||
confirmBtnText={t("confirm_remove_member")}
|
||||
onConfirm={removeMember}>
|
||||
{t("remove_member_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{showChangeMemberRoleModal && (
|
||||
<MemberChangeRoleModal
|
||||
isOpen={showChangeMemberRoleModal}
|
||||
currentMember={props.team.membership.role}
|
||||
teamId={props.team?.id}
|
||||
memberId={props.member.id}
|
||||
initialRole={props.member.role as MembershipRole}
|
||||
onExit={() => setShowChangeMemberRoleModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showImpersonateModal && props.member.username && (
|
||||
<ModalContainer isOpen={showImpersonateModal} onExit={() => setShowImpersonateModal(false)}>
|
||||
<>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("impersonate")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await signIn("impersonation-auth", {
|
||||
username: props.member.username,
|
||||
teamId: props.team.id,
|
||||
});
|
||||
}}>
|
||||
<p className="mt-2 text-sm text-gray-500" id="email-description">
|
||||
{t("impersonate_user_tip")}
|
||||
</p>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" color="primary" className="ltr:ml-2 rtl:mr-2">
|
||||
{t("impersonate")}
|
||||
</Button>
|
||||
<Button type="button" color="secondary" onClick={() => setShowImpersonateModal(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
</ModalContainer>
|
||||
)}
|
||||
{showTeamAvailabilityModal && (
|
||||
<ModalContainer
|
||||
wide
|
||||
noPadding
|
||||
isOpen={showTeamAvailabilityModal}
|
||||
onExit={() => setShowTeamAvailabilityModal(false)}>
|
||||
<TeamAvailabilityModal team={props.team} member={props.member} />
|
||||
<div className="space-x-2 border-t py-5 rtl:space-x-reverse">
|
||||
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
|
||||
{props.team.membership.role !== MembershipRole.MEMBER && (
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`} passHref>
|
||||
<Button color="secondary">{t("Open Team Availability")}</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</ModalContainer>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
|
@ -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,37 +0,0 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
type PillColor = "blue" | "green" | "red" | "yellow";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
color?: PillColor;
|
||||
}
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/TeamPill.tsx` */
|
||||
export default function TeamPill(props: Props) {
|
||||
return (
|
||||
<div
|
||||
className={classNames("self-center rounded-md border px-3 py-1 text-xs capitalize ltr:mr-2 rtl:ml-2", {
|
||||
"border-gray-200 bg-gray-50 text-gray-700": !props.color,
|
||||
"border-blue-200 bg-blue-50 text-blue-700": props.color === "blue",
|
||||
"border-red-200 bg-red-50 text-red-700": props.color === "red",
|
||||
"border-yellow-200 bg-yellow-50 text-yellow-700": props.color === "yellow",
|
||||
"border-green-200 bg-green-50 text-green-600": props.color === "green",
|
||||
})}>
|
||||
{props.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamRole(props: { role: MembershipRole }) {
|
||||
const { t } = useLocale();
|
||||
const keys: Record<MembershipRole, PillColor | undefined> = {
|
||||
[MembershipRole.OWNER]: undefined,
|
||||
[MembershipRole.ADMIN]: "red",
|
||||
[MembershipRole.MEMBER]: "blue",
|
||||
};
|
||||
return <TeamPill text={t(props.role.toLowerCase())} color={keys[props.role]} />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -8,11 +8,11 @@ import { getSession } from "@calcom/lib/auth";
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { StepCard } from "@calcom/ui/v2/core/StepCard";
|
||||
import { Steps } from "@calcom/ui/v2/core/Steps";
|
||||
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
import { StepCard } from "@components/getting-started/components/StepCard";
|
||||
import { Steps } from "@components/getting-started/components/Steps";
|
||||
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
|
||||
import { SetupAvailability } from "@components/getting-started/steps-views/SetupAvailability";
|
||||
import UserProfile from "@components/getting-started/steps-views/UserProfile";
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { HelpScout, useChat } from "react-live-chat-loader";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
@ -37,6 +39,9 @@ const BillingView = () => {
|
|||
const isPro = user?.plan === "PRO";
|
||||
const [, loadChat] = useChat();
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
const router = useRouter();
|
||||
const returnTo = router.asPath;
|
||||
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
|
||||
|
||||
const onContactSupportClick = () => {
|
||||
setShowChat(true);
|
||||
|
@ -63,7 +68,7 @@ const BillingView = () => {
|
|||
description={t("billing_manage_details_description")}>
|
||||
<Button
|
||||
color={isPro ? "primary" : "secondary"}
|
||||
href="/api/integrations/stripepayment/portal"
|
||||
href={billingHref}
|
||||
target="_blank"
|
||||
EndIcon={Icon.FiExternalLink}>
|
||||
{t("billing_portal")}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { default } from "@calcom/features/ee/teams/pages/team-billing-view";
|
|
@ -0,0 +1,26 @@
|
|||
import Head from "next/head";
|
||||
|
||||
import AddNewTeamMembers from "@calcom/features/ee/teams/components/AddNewTeamMembers";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import WizardLayout from "@calcom/ui/v2/core/layouts/WizardLayout";
|
||||
|
||||
const OnboardTeamMembersPage = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{t("add_team_members")}</title>
|
||||
<meta name="description" content={t("add_team_members_description")} />
|
||||
</Head>
|
||||
<AddNewTeamMembers />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
OnboardTeamMembersPage.getLayout = (page: React.ReactElement) => (
|
||||
<WizardLayout currentStep={1} maxSteps={2}>
|
||||
{page}
|
||||
</WizardLayout>
|
||||
);
|
||||
|
||||
export default OnboardTeamMembersPage;
|
|
@ -0,0 +1 @@
|
|||
export { default } from "@calcom/features/ee/teams/pages/team-listing-view";
|
|
@ -1,113 +0,0 @@
|
|||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
// import TeamGeneralSettings from "@calcom/features/teams/createNewTeam/TeamGeneralSettings";
|
||||
import AddNewTeamMembers from "@calcom/features/ee/teams/components/v2/AddNewTeamMembers";
|
||||
import CreateNewTeam from "@calcom/features/ee/teams/components/v2/CreateNewTeam";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { StepCard } from "@components/getting-started/components/StepCard";
|
||||
import { Steps } from "@components/getting-started/components/Steps";
|
||||
|
||||
const INITIAL_STEP = "create-a-new-team";
|
||||
// TODO: Add teams general settings "general-settings"
|
||||
const steps = ["create-a-new-team", "add-team-members"] as const;
|
||||
|
||||
const stepTransform = (step: typeof steps[number]) => {
|
||||
const stepIndex = steps.indexOf(step);
|
||||
if (stepIndex > -1) {
|
||||
return steps[stepIndex];
|
||||
}
|
||||
return INITIAL_STEP;
|
||||
};
|
||||
|
||||
const stepRouteSchema = z.object({
|
||||
step: z.array(z.enum(steps)).default([INITIAL_STEP]),
|
||||
});
|
||||
|
||||
const CreateNewTeamPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useLocale();
|
||||
const [teamId, setTeamId] = useState<number>();
|
||||
|
||||
const result = stepRouteSchema.safeParse(router.query);
|
||||
const currentStep = result.success ? result.data.step[0] : INITIAL_STEP;
|
||||
|
||||
const headers = [
|
||||
{
|
||||
title: `${t("create_new_team")}`,
|
||||
subtitle: [`${t("create_new_team_description")}`],
|
||||
},
|
||||
// {
|
||||
// title: `${t("general_settings")}`,
|
||||
// subtitle: [`${t("general_settings_description")}`],
|
||||
// },
|
||||
{
|
||||
title: `${t("add_team_members")}`,
|
||||
subtitle: [`${t("add_team_members_description")}`],
|
||||
},
|
||||
];
|
||||
|
||||
const goToIndex = (index: number) => {
|
||||
const newStep = steps[index];
|
||||
router.push(
|
||||
{
|
||||
pathname: `/settings/teams/new/${stepTransform(newStep)}`,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
};
|
||||
|
||||
const currentStepIndex = steps.indexOf(currentStep);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dark:bg-brand dark:text-brand-contrast min-h-screen text-black"
|
||||
data-testid="onboarding"
|
||||
key={router.asPath}>
|
||||
<Head>
|
||||
<title>Create a new Team</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div className="mx-auto px-4 py-24">
|
||||
<div className="relative">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
|
||||
<div className="mx-auto sm:max-w-[520px]">
|
||||
<header>
|
||||
<p className="font-cal mb-3 text-[28px] font-medium leading-7">
|
||||
{headers[currentStepIndex]?.title || "Undefined title"}
|
||||
</p>
|
||||
|
||||
<p className="font-sans text-sm font-normal text-gray-500">
|
||||
{headers[currentStepIndex]?.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
<Steps maxSteps={steps.length} currentStep={currentStepIndex} navigateToStep={goToIndex} />
|
||||
</div>
|
||||
<StepCard>
|
||||
{currentStep === "create-a-new-team" && (
|
||||
<CreateNewTeam
|
||||
nextStep={() => {
|
||||
goToIndex(1);
|
||||
}}
|
||||
setTeamId={(teamId: number) => setTeamId(teamId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* {currentStep === "general-settings" && (
|
||||
<TeamGeneralSettings teamId={teamId} nextStep={() => goToIndex(2)} />
|
||||
)} */}
|
||||
|
||||
{currentStep === "add-team-members" && teamId && <AddNewTeamMembers teamId={teamId} />}
|
||||
</StepCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateNewTeamPage;
|
|
@ -0,0 +1,20 @@
|
|||
import Head from "next/head";
|
||||
|
||||
import { CreateANewTeamForm } from "@calcom/features/ee/teams/components";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/WizardLayout";
|
||||
|
||||
const CreateNewTeamPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create a new Team</title>
|
||||
<meta name="description" content="Create a new team to ease your organisational booking" />
|
||||
</Head>
|
||||
<CreateANewTeamForm />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CreateNewTeamPage.getLayout = getLayout;
|
||||
|
||||
export default CreateNewTeamPage;
|
|
@ -1,68 +1,23 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Shell } from "@calcom/ui/v2";
|
||||
import { Alert } from "@calcom/ui/v2/core/Alert";
|
||||
import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
|
||||
|
||||
import SkeletonLoaderTeamList from "@components/team/SkeletonloaderTeamList";
|
||||
import TeamCreateModal from "@components/team/TeamCreateModal";
|
||||
import TeamList from "@components/team/TeamList";
|
||||
|
||||
function Teams() {
|
||||
const { t } = useLocale();
|
||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
|
||||
onError: (e) => {
|
||||
setErrorMessage(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
const teams = data?.filter((m) => m.accepted) || [];
|
||||
const invites = data?.filter((m) => !m.accepted) || [];
|
||||
|
||||
return (
|
||||
<Shell
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
CTA={
|
||||
<Button type="button" onClick={() => setShowCreateTeamModal(true)}>
|
||||
<Button type="button" href={`${WEBAPP_URL}/settings/teams/new`}>
|
||||
<Icon.FiPlus className="inline-block h-3.5 w-3.5 text-white group-hover:text-black ltr:mr-2 rtl:ml-2" />
|
||||
{t("new")}
|
||||
</Button>
|
||||
}>
|
||||
<>
|
||||
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
{showCreateTeamModal && (
|
||||
<TeamCreateModal isOpen={showCreateTeamModal} onClose={() => setShowCreateTeamModal(false)} />
|
||||
)}
|
||||
{invites.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
|
||||
<TeamList teams={invites} />
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <SkeletonLoaderTeamList />}
|
||||
{!teams.length && !isLoading && (
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiUsers}
|
||||
headline={t("no_teams")}
|
||||
description={t("no_teams_description")}
|
||||
buttonRaw={
|
||||
<Button color="secondary" onClick={() => setShowCreateTeamModal(true)}>
|
||||
{t("create_team")}
|
||||
</Button>
|
||||
}
|
||||
buttonOnClick={() => setShowCreateTeamModal(true)}
|
||||
/>
|
||||
)}
|
||||
{teams.length > 0 && <TeamList teams={teams} />}
|
||||
</>
|
||||
<TeamsListing />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import { expect } from "@playwright/test";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Teams", () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
test("Can create teams via Wizard", async ({ page, users, prisma }) => {
|
||||
const user = await users.create();
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
await user.login();
|
||||
await page.goto("/teams");
|
||||
|
||||
// Expect teams to be empty
|
||||
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
|
||||
|
||||
await test.step("Can create team", async () => {
|
||||
// Click text=Create Team
|
||||
await page.locator("text=Create Team").click();
|
||||
await page.waitForURL("/settings/teams/new");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
|
||||
// Click text=Continue
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
});
|
||||
|
||||
await test.step("Can add members", async () => {
|
||||
// Click [data-testid="new-member-button"]
|
||||
await page.locator('[data-testid="new-member-button"]').click();
|
||||
// Fill [placeholder="email\@example\.com"]
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
|
||||
// Click [data-testid="invite-new-member-button"]
|
||||
await page.locator('[data-testid="invite-new-member-button"]').click();
|
||||
await expect(page.locator(`li:has-text("${inviteeEmail}PendingMemberNot on Cal.com")`)).toBeVisible();
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
|
||||
});
|
||||
|
||||
await test.step("Can remove members", async () => {
|
||||
const removeMemberButton = page.locator('[data-testid="remove-member-button"]');
|
||||
await removeMemberButton.click();
|
||||
await removeMemberButton.waitFor({ state: "hidden" });
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
});
|
||||
|
||||
await test.step("Can publish team", async () => {
|
||||
await page.locator("text=Publish team").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
});
|
||||
|
||||
await test.step("Can disband team", async () => {
|
||||
await page.locator("text=Delete Team").click();
|
||||
await page.locator("text=Yes, disband team").click();
|
||||
await page.waitForURL("/teams");
|
||||
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -502,6 +502,7 @@
|
|||
"add_team_members_description": "Invite others to join your team",
|
||||
"add_team_member": "Add team member",
|
||||
"invite_new_member": "Invite a new team member",
|
||||
"invite_new_member_description": "Note: This will <1>cost an extra seat ($15/m)</1> on your subscription.",
|
||||
"invite_new_team_member": "Invite someone to your team.",
|
||||
"change_member_role": "Change team member role",
|
||||
"disable_cal_branding": "Disable Cal branding",
|
||||
|
@ -1339,6 +1340,21 @@
|
|||
"limit_future_bookings_description": "Limit how far in the future this event can be booked",
|
||||
"no_event_types": "No event types setup",
|
||||
"no_event_types_description": "{{name}} has not setup any event types for you to book.",
|
||||
"billing_frequency": "Billing Frequency",
|
||||
"monthly": "Monthly",
|
||||
"yearly": "Yearly",
|
||||
"checkout": "Checkout",
|
||||
"your_team_disbanded_successfully": "Your team has been disbanded successfully",
|
||||
"error_creating_team": "Error creating team",
|
||||
"you": "You",
|
||||
"send_email": "Send email",
|
||||
"member_already_invited": "Member has already been invited",
|
||||
"enter_email_or_username": "Enter an email or username",
|
||||
"team_name_taken": "This name is already taken",
|
||||
"must_enter_team_name": "Must enter a team name",
|
||||
"team_url_required": "Must enter a team URL",
|
||||
"team_url_taken": "This URL is already taken",
|
||||
"team_publish": "Publish team",
|
||||
"number_sms_notifications": "Phone number (SMS\u00a0notifications)",
|
||||
"attendee_email_workflow": "Attendee email",
|
||||
"attendee_email_info": "The person booking's email",
|
||||
|
|
|
@ -1,23 +1,35 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
|
||||
import { getStripeCustomerIdFromUserId } from "../lib/customer";
|
||||
import stripe from "../lib/server";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST" || req.method === "GET") {
|
||||
const customerId = await getStripeCustomerIdFromUserId(req.session!.user.id);
|
||||
if (req.method !== "POST" && req.method !== "GET")
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
const { referer } = req.headers;
|
||||
|
||||
if (!customerId) {
|
||||
res.status(500).json({ message: "Missing customer id" });
|
||||
return;
|
||||
}
|
||||
if (!referer) return res.status(400).json({ message: "Missing referrer" });
|
||||
|
||||
const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`;
|
||||
const stripeSession = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url,
|
||||
});
|
||||
if (!req.session?.user?.id) return res.status(401).json({ message: "Not authenticated" });
|
||||
|
||||
res.redirect(302, stripeSession.url);
|
||||
// If accessing a user's portal
|
||||
const customerId = await getStripeCustomerIdFromUserId(req.session.user.id);
|
||||
if (!customerId) return res.status(400).json({ message: "CustomerId not found in stripe" });
|
||||
|
||||
let return_url = `${WEBAPP_URL}/settings/billing`;
|
||||
|
||||
if (typeof req.query.returnTo === "string") {
|
||||
const safeRedirectUrl = getSafeRedirectUrl(req.query.returnTo);
|
||||
if (safeRedirectUrl) return_url = safeRedirectUrl;
|
||||
}
|
||||
|
||||
const stripeSession = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url,
|
||||
});
|
||||
|
||||
res.redirect(302, stripeSession.url);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
|
@ -42,21 +42,6 @@ async function getMembersMissingSeats(teamId: number) {
|
|||
};
|
||||
}
|
||||
|
||||
// a helper for the upgrade dialog
|
||||
export async function getTeamSeatStats(teamId: number) {
|
||||
const { membersMissingSeats, members, ownerIsMissingSeat } = await getMembersMissingSeats(teamId);
|
||||
return {
|
||||
totalMembers: members.length,
|
||||
// members we need not pay for
|
||||
freeSeats: members.length - membersMissingSeats.length,
|
||||
// members we need to pay for (if not hosted cal, team billing is disabled)
|
||||
missingSeats: HOSTED_CAL_FEATURES ? membersMissingSeats.length : 0,
|
||||
// members who have been hidden from view
|
||||
hiddenMembers: members.filter((m) => m.user.plan === UserPlan.FREE).length,
|
||||
ownerIsMissingSeat: HOSTED_CAL_FEATURES ? ownerIsMissingSeat : false,
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePerSeatQuantity(subscription: Stripe.Subscription, quantity: number) {
|
||||
const perSeatProPlan = subscription.items.data.find((item) => item.plan.id === getPerSeatProPlanPrice());
|
||||
// if their subscription does not contain Per Seat Pro, add it—otherwise, update the existing one
|
||||
|
@ -249,16 +234,17 @@ async function createCheckoutSession(
|
|||
return await stripe.checkout.sessions.create(params);
|
||||
}
|
||||
|
||||
// verifies that the subscription's quantity is correct for the number of members the team has
|
||||
// this is a function is a dev util, but could be utilized as a sync technique in the future
|
||||
export async function ensureSubscriptionQuantityCorrectness(userId: number, teamId: number) {
|
||||
const subscription = await getProPlanSubscription(userId);
|
||||
const stripeQuantity =
|
||||
subscription?.items.data.find((item) => item.plan.id === getPerSeatProPlanPrice())?.quantity ?? 0;
|
||||
|
||||
const { membersMissingSeats } = await getMembersMissingSeats(teamId);
|
||||
// correct the quantity if missing seats is out of sync with subscription quantity
|
||||
if (subscription && membersMissingSeats.length !== stripeQuantity) {
|
||||
await updatePerSeatQuantity(subscription, membersMissingSeats.length);
|
||||
export function getRequestedSlugError(error: unknown, requestedSlug: string) {
|
||||
let message = `Unknown error`;
|
||||
let statusCode = 500;
|
||||
// This covers the edge case if an unpublished team takes too long to publish
|
||||
// and another team gets the requestedSlug first.
|
||||
// https://www.prisma.io/docs/reference/api-reference/error-reference#p2002
|
||||
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
statusCode = 400;
|
||||
message = `It seems like the requestedSlug: '${requestedSlug}' is already taken. Please contact support at help@cal.com so we can resolve this issue.`;
|
||||
} else if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
return { message, statusCode };
|
||||
}
|
||||
|
|
|
@ -7,15 +7,13 @@ import stripe from "@calcom/app-store/stripepayment/lib/server";
|
|||
import EventManager from "@calcom/core/EventManager";
|
||||
import { sendScheduledEmails } from "@calcom/emails";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { IS_PRODUCTION } from "@lib/config/constants";
|
||||
import { HttpError as HttpCode } from "@lib/core/http/error";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
|
|
|
@ -1,20 +1,71 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import { upgradeTeam } from "@calcom/app-store/stripepayment/lib/team-billing";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { ensureSession } from "@calcom/lib/auth";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") {
|
||||
const session = await getSession({ req });
|
||||
const querySchema = z.object({
|
||||
team: z.string().transform((val) => parseInt(val)),
|
||||
session_id: z.string().min(1),
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
res.status(401).json({ message: "You must be logged in to do this" });
|
||||
return;
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
await ensureSession({ req });
|
||||
|
||||
const { team: id, session_id } = querySchema.parse(req.query);
|
||||
|
||||
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id, {
|
||||
expand: ["subscription"],
|
||||
});
|
||||
if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
|
||||
|
||||
const subscription = checkoutSession.subscription as Stripe.Subscription;
|
||||
if (checkoutSession.payment_status !== "paid")
|
||||
throw new HttpError({ statusCode: 402, message: "Payment required" });
|
||||
|
||||
/* Check if a team was already upgraded with this payment intent */
|
||||
let team = await prisma.team.findFirst({
|
||||
where: { metadata: { path: ["paymentId"], equals: checkoutSession.id } },
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
const prevTeam = await prisma.team.findFirstOrThrow({ where: { id } });
|
||||
const metadata = teamMetadataSchema.parse(prevTeam.metadata);
|
||||
if (!metadata?.requestedSlug) throw new HttpError({ statusCode: 400, message: "Missing requestedSlug" });
|
||||
|
||||
try {
|
||||
team = await prisma.team.update({
|
||||
where: { id },
|
||||
data: {
|
||||
slug: metadata.requestedSlug,
|
||||
metadata: {
|
||||
paymentId: checkoutSession.id,
|
||||
subscriptionId: subscription.id || null,
|
||||
subscriptionItemId: subscription.items.data[0].id || null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const { message, statusCode } = getRequestedSlugError(error, metadata.requestedSlug);
|
||||
return res.status(statusCode).json({ message });
|
||||
}
|
||||
|
||||
await upgradeTeam(session.user.id, Number(req.query.team));
|
||||
|
||||
// redirect to team screen
|
||||
res.redirect(302, `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/teams/${req.query.team}?upgraded=true`);
|
||||
// Sync Services: Close.com
|
||||
closeComUpdateTeam(prevTeam, team);
|
||||
}
|
||||
|
||||
// redirect to team screen
|
||||
res.redirect(302, `${WEBAPP_URL}/settings/teams/${team.id}/profile?upgraded=true`);
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,180 @@
|
|||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Avatar, Badge, Button } from "@calcom/ui/components";
|
||||
import { showToast } from "@calcom/ui/v2/core";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||
|
||||
const querySchema = z.object({
|
||||
id: z.string().transform((val) => parseInt(val)),
|
||||
});
|
||||
|
||||
type TeamMember = inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
|
||||
type FormValues = {
|
||||
members: TeamMember[];
|
||||
};
|
||||
|
||||
const AddNewTeamMembers = () => {
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const { id: teamId } = router.isReady ? querySchema.parse(router.query) : { id: -1 };
|
||||
const teamQuery = trpc.useQuery(["viewer.teams.get", { teamId }], { enabled: router.isReady });
|
||||
if (session.status === "loading" || !teamQuery.data) return <AddNewTeamMemberSkeleton />;
|
||||
|
||||
return <AddNewTeamMembersForm defaultValues={{ members: teamQuery.data.members }} teamId={teamId} />;
|
||||
};
|
||||
|
||||
const AddNewTeamMembersForm = ({ defaultValues, teamId }: { defaultValues: FormValues; teamId: number }) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const [memberInviteModal, setMemberInviteModal] = useState(false);
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
setMemberInviteModal(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
const publishTeamMutation = trpc.useMutation("viewer.teams.publish", {
|
||||
onSuccess(data) {
|
||||
router.push(data.url);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<ul className="rounded-md border" data-testid="pending-member-list">
|
||||
{defaultValues.members.map((member, index) => (
|
||||
<PendingMemberItem key={member.email} member={member} index={index} teamId={teamId} />
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="new-member-button"
|
||||
StartIcon={Icon.FiPlus}
|
||||
onClick={() => setMemberInviteModal(true)}
|
||||
className="mt-6 w-full justify-center">
|
||||
{t("add_team_member")}
|
||||
</Button>
|
||||
</div>
|
||||
<MemberInvitationModal
|
||||
isOpen={memberInviteModal}
|
||||
onExit={() => setMemberInviteModal(false)}
|
||||
onSubmit={(values) => {
|
||||
inviteMemberMutation.mutate({
|
||||
teamId,
|
||||
language: i18n.language,
|
||||
role: values.role.value,
|
||||
usernameOrEmail: values.emailOrUsername,
|
||||
sendEmailInvitation: values.sendInviteEmail,
|
||||
});
|
||||
}}
|
||||
members={defaultValues.members}
|
||||
/>
|
||||
<hr className="my-6 border-neutral-200" />
|
||||
<Button
|
||||
EndIcon={Icon.FiArrowRight}
|
||||
className="mt-6 w-full justify-center"
|
||||
disabled={publishTeamMutation.isLoading}
|
||||
onClick={() => {
|
||||
publishTeamMutation.mutate({ teamId });
|
||||
}}>
|
||||
{t("team_publish")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNewTeamMembers;
|
||||
|
||||
const AddNewTeamMemberSkeleton = () => {
|
||||
return (
|
||||
<SkeletonContainer className="rounded-md border">
|
||||
<div className="flex w-full justify-between p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
<SkeletonText className="h-4 w-56" />
|
||||
</p>
|
||||
<div className="mt-2.5 w-max">
|
||||
<SkeletonText className="h-5 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: number }) => {
|
||||
const { member, index, teamId } = props;
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast("Member removed", "success");
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<li
|
||||
key={member.email}
|
||||
className={classNames("flex items-center justify-between p-6 text-sm", index !== 0 && "border-t")}
|
||||
data-testid="pending-member-item">
|
||||
<div className="flex space-x-2">
|
||||
<Avatar
|
||||
gravatarFallbackMd5="teamMember"
|
||||
size="mdLg"
|
||||
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
|
||||
alt="owner-avatar"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex space-x-1">
|
||||
<p>{member.name || member.email || t("team_member")}</p>
|
||||
{/* Assume that the first member of the team is the creator */}
|
||||
{index === 0 && <Badge variant="green">{t("you")}</Badge>}
|
||||
{!member.accepted && <Badge variant="orange">{t("pending")}</Badge>}
|
||||
{member.role === "MEMBER" && <Badge variant="gray">{t("member")}</Badge>}
|
||||
{member.role === "ADMIN" && <Badge variant="default">{t("admin")}</Badge>}
|
||||
</div>
|
||||
{member.username ? (
|
||||
<p className="text-gray-600">{`${WEBAPP_URL}/${member.username}`}</p>
|
||||
) : (
|
||||
<p className="text-gray-600">{t("not_on_cal")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{member.role !== "OWNER" && (
|
||||
<Button
|
||||
data-testid="remove-member-button"
|
||||
StartIcon={Icon.FiTrash2}
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="h-[36px] w-[36px]"
|
||||
onClick={() => {
|
||||
removeMemberMutation.mutate({ teamId, memberId: member.id });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,129 @@
|
|||
import { useRouter } from "next/router";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Avatar, Button } from "@calcom/ui/components";
|
||||
import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
|
||||
|
||||
import { NewTeamFormValues } from "../lib/types";
|
||||
|
||||
export const CreateANewTeamForm = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const newTeamFormMethods = useForm<NewTeamFormValues>();
|
||||
|
||||
const createTeamMutation = trpc.useMutation(["viewer.teams.create"], {
|
||||
onSuccess: (data) => {
|
||||
router.push(`/settings/teams/${data.id}/onboard-members`);
|
||||
},
|
||||
});
|
||||
|
||||
const validateTeamSlugQuery = trpc.useQuery(
|
||||
["viewer.teams.validateTeamSlug", { slug: newTeamFormMethods.watch("slug") }],
|
||||
{
|
||||
enabled: false,
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const validateTeamSlug = async () => {
|
||||
await validateTeamSlugQuery.refetch();
|
||||
if (validateTeamSlugQuery.isFetched) return validateTeamSlugQuery.data || t("team_url_taken");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form form={newTeamFormMethods} handleSubmit={(v) => createTeamMutation.mutate(v)}>
|
||||
<div className="mb-8">
|
||||
<Controller
|
||||
name="name"
|
||||
control={newTeamFormMethods.control}
|
||||
defaultValue=""
|
||||
rules={{
|
||||
required: t("must_enter_team_name"),
|
||||
}}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<TextField
|
||||
className="mt-2"
|
||||
name="name"
|
||||
label={t("team_name")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
newTeamFormMethods.setValue("name", e?.target.value);
|
||||
if (newTeamFormMethods.formState.touchedFields["slug"] === undefined) {
|
||||
newTeamFormMethods.setValue("slug", slugify(e?.target.value));
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Controller
|
||||
name="slug"
|
||||
control={newTeamFormMethods.control}
|
||||
rules={{ required: t("team_url_required"), validate: async () => await validateTeamSlug() }}
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
className="mt-2"
|
||||
name="slug"
|
||||
label={t("team_url")}
|
||||
addOnLeading={`${WEBAPP_URL}/team/`}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
newTeamFormMethods.setValue("slug", 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>
|
||||
{createTeamMutation.isError && (
|
||||
<p className="mt-4 text-red-700">{createTeamMutation.error.message}</p>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,127 +1,145 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import React, { useState, SyntheticEvent, useMemo } from "react";
|
||||
import { Trans } from "next-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, TextField } from "@calcom/ui/components";
|
||||
import CheckboxField from "@calcom/ui/components/form/checkbox/Checkbox";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
import { Dialog, DialogContent, DialogFooter, Select } from "@calcom/ui/v2";
|
||||
|
||||
import { PendingMember } from "../lib/types";
|
||||
|
||||
type MemberInvitationModalProps = {
|
||||
isOpen: boolean;
|
||||
team: TeamWithMembers | null;
|
||||
currentMember: MembershipRole;
|
||||
onExit: () => void;
|
||||
onSubmit: (values: NewMemberForm) => void;
|
||||
members: PendingMember[];
|
||||
};
|
||||
|
||||
type MembershipRoleOption = {
|
||||
value: MembershipRole;
|
||||
label?: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
|
||||
export interface NewMemberForm {
|
||||
emailOrUsername: string;
|
||||
role: MembershipRoleOption;
|
||||
sendInviteEmail: boolean;
|
||||
}
|
||||
|
||||
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t, i18n } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
|
||||
const options = useMemo(() => {
|
||||
_options.forEach((option, i) => {
|
||||
_options[i].label = t(option.value.toLowerCase());
|
||||
});
|
||||
return _options;
|
||||
const options: MembershipRoleOption[] = useMemo(() => {
|
||||
return [
|
||||
{ value: "MEMBER", label: t("member") },
|
||||
{ value: "ADMIN", label: t("admin") },
|
||||
{ value: "OWNER", label: t("owner") },
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
const newMemberFormMethods = useForm<NewMemberForm>();
|
||||
|
||||
function inviteMember(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!props.team) return;
|
||||
|
||||
const target = e.target as typeof e.target & {
|
||||
elements: {
|
||||
role: { value: MembershipRole };
|
||||
inviteUser: { value: string };
|
||||
sendInviteEmail: { checked: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
inviteMemberMutation.mutate({
|
||||
teamId: props.team.id,
|
||||
language: i18n.language,
|
||||
role: target.elements["role"].value,
|
||||
usernameOrEmail: target.elements["inviteUser"].value,
|
||||
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
|
||||
});
|
||||
}
|
||||
const validateUniqueInvite = (value: string) => {
|
||||
return !(
|
||||
props.members.some((member) => member?.username === value) ||
|
||||
props.members.some((member) => member?.email === value)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
|
||||
<Dialog
|
||||
open={props.isOpen}
|
||||
onOpenChange={() => {
|
||||
props.onExit();
|
||||
newMemberFormMethods.reset();
|
||||
}}>
|
||||
<DialogContent
|
||||
type="creation"
|
||||
useOwnActionButtons
|
||||
title={t("invite_new_member")}
|
||||
description={
|
||||
<span className=" text-sm leading-tight text-gray-500">
|
||||
Note: This will <span className="font-medium text-gray-900">cost an extra seat ($12/m)</span> on
|
||||
your subscription if this invitee does not have a TEAM account.
|
||||
</span>
|
||||
IS_TEAM_BILLING_ENABLED ? (
|
||||
<span className=" text-sm leading-tight text-gray-500">
|
||||
<Trans i18nKey="invite_new_member_description">
|
||||
Note: This will <span className="font-medium text-gray-900">cost an extra seat ($15/m)</span>{" "}
|
||||
on your subscription.
|
||||
</Trans>
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
}>
|
||||
<form onSubmit={inviteMember}>
|
||||
<Form form={newMemberFormMethods} handleSubmit={(values) => props.onSubmit(values)}>
|
||||
<div className="space-y-4">
|
||||
<TextField
|
||||
label={t("email_or_username")}
|
||||
id="inviteUser"
|
||||
name="inviteUser"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
<Controller
|
||||
name="emailOrUsername"
|
||||
control={newMemberFormMethods.control}
|
||||
rules={{
|
||||
required: t("enter_email_or_username"),
|
||||
validate: (value) => validateUniqueInvite(value) || t("member_already_invited"),
|
||||
}}
|
||||
render={({ field: { onChange }, fieldState: { error } }) => (
|
||||
<>
|
||||
<TextField
|
||||
label={t("email_or_username")}
|
||||
id="inviteUser"
|
||||
name="inviteUser"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
onChange={onChange}
|
||||
/>
|
||||
{error && <span className="text-sm text-red-800">{error.message}</span>}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="role"
|
||||
control={newMemberFormMethods.control}
|
||||
defaultValue={options[0]}
|
||||
render={({ field: { onChange } }) => (
|
||||
<div>
|
||||
<label
|
||||
className="mb-1 block text-sm font-medium tracking-wide text-gray-700"
|
||||
htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={options[0]}
|
||||
options={options.filter((option) => option.value !== "OWNER")}
|
||||
id="role"
|
||||
name="role"
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 text-sm"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="sendInviteEmail"
|
||||
control={newMemberFormMethods.control}
|
||||
defaultValue={false}
|
||||
render={() => (
|
||||
<div className="relative flex items-start">
|
||||
<CheckboxField
|
||||
description={t("send_invite_email")}
|
||||
onChange={(e) => newMemberFormMethods.setValue("sendInviteEmail", e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={options[0]}
|
||||
options={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
|
||||
id="role"
|
||||
name="role"
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="sendInviteEmail"
|
||||
defaultChecked
|
||||
id="sendInviteEmail"
|
||||
className="rounded-sm border-gray-300 text-sm text-black"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-2 rtl:mr-2">
|
||||
<label htmlFor="sendInviteEmail" className="font-medium text-gray-700">
|
||||
{t("send_invite_email")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-700">
|
||||
<span className="font-bold">Error: </span>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
props.onExit();
|
||||
newMemberFormMethods.reset();
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
|
@ -132,7 +150,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
|||
{t("invite")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
|
@ -11,11 +12,11 @@ import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
|||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/v2/core/Dialog";
|
||||
import Dropdown, {
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownItem,
|
||||
} from "@calcom/ui/v2/core/Dropdown";
|
||||
import { Tooltip } from "@calcom/ui/v2/core/Tooltip";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
@ -72,7 +73,7 @@ export default function TeamListItem(props: Props) {
|
|||
<div className="ml-3 inline-block">
|
||||
<span className="text-sm font-bold text-neutral-700">{team.name}</span>
|
||||
<span className="block text-xs text-gray-400">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/team/{team.slug}
|
||||
{team.slug ? `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}` : "Unpublished team"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -95,7 +96,7 @@ export default function TeamListItem(props: Props) {
|
|||
teamInfo
|
||||
)}
|
||||
<div className="px-5 py-5">
|
||||
{isInvitee && (
|
||||
{isInvitee ? (
|
||||
<>
|
||||
<div className="hidden sm:block">
|
||||
<Button type="button" color="secondary" onClick={declineInvite}>
|
||||
|
@ -133,25 +134,26 @@ export default function TeamListItem(props: Props) {
|
|||
</Dropdown>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isInvitee && (
|
||||
) : (
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<TeamRole role={team.role} />
|
||||
<ButtonGroup combined>
|
||||
<Tooltip content={t("copy_link_team")}>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug
|
||||
);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
size="icon"
|
||||
StartIcon={Icon.FiLink}
|
||||
combined
|
||||
/>
|
||||
</Tooltip>
|
||||
{team.slug && (
|
||||
<Tooltip content={t("copy_link_team")}>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug
|
||||
);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
size="icon"
|
||||
StartIcon={Icon.FiLink}
|
||||
combined
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild className="radix-state-open:rounded-r-md">
|
||||
<Button type="button" color="secondary" size="icon" StartIcon={Icon.FiMoreHorizontal} />
|
||||
|
@ -167,15 +169,18 @@ export default function TeamListItem(props: Props) {
|
|||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
target="_blank"
|
||||
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`}
|
||||
StartIcon={Icon.FiExternalLink}>
|
||||
{t("preview_team") as string}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{!team.slug && <TeamPublishButton teamId={team.id} />}
|
||||
{team.slug && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
target="_blank"
|
||||
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`}
|
||||
StartIcon={Icon.FiExternalLink}>
|
||||
{t("preview_team") as string}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{isOwner && (
|
||||
<DropdownMenuItem>
|
||||
|
@ -241,3 +246,29 @@ export default function TeamListItem(props: Props) {
|
|||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const TeamPublishButton = ({ teamId }: { teamId: number }) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const publishTeamMutation = trpc.useMutation("viewer.teams.publish", {
|
||||
onSuccess(data) {
|
||||
router.push(data.url);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() => {
|
||||
publishTeamMutation.mutate({ teamId });
|
||||
}}
|
||||
StartIcon={Icon.FiGlobe}>
|
||||
{t("team_publish")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,52 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Alert } from "@calcom/ui/v2/core/Alert";
|
||||
import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
|
||||
|
||||
import SkeletonLoaderTeamList from "./SkeletonloaderTeamList";
|
||||
import TeamList from "./TeamList";
|
||||
|
||||
export function TeamsListing() {
|
||||
const { t } = useLocale();
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
|
||||
onError: (e) => {
|
||||
setErrorMessage(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
const teams = data?.filter((m) => m.accepted) || [];
|
||||
const invites = data?.filter((m) => !m.accepted) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
{invites.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
|
||||
<TeamList teams={invites} />
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <SkeletonLoaderTeamList />}
|
||||
{!teams.length && !isLoading && (
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiUsers}
|
||||
headline={t("no_teams")}
|
||||
description={t("no_teams_description")}
|
||||
buttonRaw={
|
||||
<Button color="secondary" href={`${WEBAPP_URL}/settings/teams/new`}>
|
||||
{t("create_team")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{teams.length > 0 && <TeamList teams={teams} />}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export { CreateANewTeamForm } from "./CreateANewTeamForm";
|
||||
export { TeamsListing } from "./TeamsListing";
|
|
@ -1,127 +0,0 @@
|
|||
import { Suspense, useState } from "react";
|
||||
|
||||
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||
|
||||
const AddNewTeamMemberSkeleton = () => {
|
||||
return (
|
||||
<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 { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: props.teamId }]);
|
||||
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
|
||||
onSuccess() {
|
||||
utils.invalidateQueries(["viewer.teams.get", { teamId: props.teamId }]);
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
},
|
||||
});
|
||||
|
||||
const [memberInviteModal, setMemberInviteModal] = useState(false);
|
||||
|
||||
if (isLoading) return <AddNewTeamMemberSkeleton />;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<AddNewTeamMemberSkeleton />}>
|
||||
<>
|
||||
<>
|
||||
<ul className="rounded-md border">
|
||||
{team?.members.map((member, index) => (
|
||||
<li
|
||||
key={member.id}
|
||||
className={classNames(
|
||||
"flex items-center justify-between p-6 text-sm",
|
||||
index !== 0 && "border-t"
|
||||
)}>
|
||||
<div className="flex space-x-2">
|
||||
<Avatar
|
||||
gravatarFallbackMd5="teamMember"
|
||||
size="mdLg"
|
||||
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
|
||||
alt="owner-avatar"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex space-x-1">
|
||||
<p>{member?.name || t("team_member")}</p>
|
||||
{/* Assume that the first member of the team is the creator */}
|
||||
{index === 0 && <Badge variant="green">{t("you")}</Badge>}
|
||||
{!member.accepted && <Badge variant="orange">{t("pending")}</Badge>}
|
||||
{member.role === "MEMBER" && <Badge variant="gray">{t("member")}</Badge>}
|
||||
{member.role === "ADMIN" && <Badge variant="default">{t("admin")}</Badge>}
|
||||
</div>
|
||||
{member.username ? (
|
||||
<p className="text-gray-600">{`${WEBAPP_URL}/${member?.username}`}</p>
|
||||
) : (
|
||||
<p className="text-gray-600">{t("not_on_cal")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{member.role !== "OWNER" && (
|
||||
<Button
|
||||
StartIcon={Icon.FiTrash2}
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="h-[36px] w-[36px]"
|
||||
onClick={() => removeMemberMutation.mutate({ teamId: props.teamId, memberId: member.id })}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="new-member-button"
|
||||
StartIcon={Icon.FiPlus}
|
||||
onClick={() => setMemberInviteModal(true)}
|
||||
className="mt-6 w-full justify-center">
|
||||
{t("add_team_member")}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
{team && (
|
||||
<MemberInvitationModal
|
||||
isOpen={memberInviteModal}
|
||||
onExit={() => setMemberInviteModal(false)}
|
||||
team={team}
|
||||
currentMember={team?.membership.role}
|
||||
/>
|
||||
)}
|
||||
|
||||
<hr className="my-6 border-neutral-200" />
|
||||
|
||||
<Button
|
||||
EndIcon={Icon.FiArrowRight}
|
||||
className="mt-6 w-full justify-center"
|
||||
href={`${WEBAPP_URL}/settings/teams/${props.teamId}/profile`}>
|
||||
{t("finish")}
|
||||
</Button>
|
||||
</>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNewTeamMembers;
|
|
@ -1,113 +0,0 @@
|
|||
import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Button, Avatar } from "@calcom/ui/components";
|
||||
import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
|
||||
|
||||
const CreateANewTeamForm = (props: { nextStep: () => void; setTeamId: (teamId: number) => void }) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
|
||||
onSuccess(data) {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
props.setTeamId(data.id);
|
||||
props.nextStep();
|
||||
},
|
||||
});
|
||||
|
||||
const formMethods = useForm();
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={formMethods}
|
||||
handleSubmit={(values) => {
|
||||
createTeamMutation.mutate({
|
||||
name: values.name,
|
||||
slug: values.slug || null,
|
||||
logo: values.logo || null,
|
||||
});
|
||||
}}>
|
||||
<div className="mb-8">
|
||||
<Controller
|
||||
name="name"
|
||||
control={formMethods.control}
|
||||
rules={{ required: { value: true, message: t("team_name_required") } }}
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
className="mt-2"
|
||||
name="name"
|
||||
label={t("team_name")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("name", e?.target.value);
|
||||
if (formMethods.formState.touchedFields["slug"] === undefined) {
|
||||
formMethods.setValue("slug", slugify(e?.target.value));
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Controller
|
||||
name="slug"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
className="mt-2"
|
||||
name="slug"
|
||||
label={t("team_url")}
|
||||
addOnLeading={`${WEBAPP_URL}/team/`}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="flex items-center">
|
||||
<Avatar alt="" imageSrc={value || null} gravatarFallbackMd5="newTeam" size="lg" />
|
||||
<div className="ml-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("update")}
|
||||
handleAvatarChange={(newAvatar: string) => {
|
||||
formMethods.setValue("avatar", newAvatar);
|
||||
}}
|
||||
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>
|
||||
|
||||
{createTeamMutation.isError && <p className="mt-4 text-red-700">{createTeamMutation.error.message}</p>}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateANewTeamForm;
|
|
@ -0,0 +1,52 @@
|
|||
import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const purchaseTeamSubscription = async (input: { teamId: number; seats: number; userId: number }) => {
|
||||
const { teamId, seats, userId } = input;
|
||||
const customer = await getStripeCustomerIdFromUserId(userId);
|
||||
return await stripe.checkout.sessions.create({
|
||||
customer,
|
||||
mode: "subscription",
|
||||
success_url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${WEBAPP_URL}/settings/profile`,
|
||||
locale: "en",
|
||||
line_items: [
|
||||
{
|
||||
/** We only need to set the base price and we can upsell it directly on Stripe's checkout */
|
||||
price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID,
|
||||
quantity: seats,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
teamId,
|
||||
},
|
||||
payment_method_types: ["card"],
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const cancelTeamSubscriptionFromStripe = async (teamId: number) => {
|
||||
try {
|
||||
const team = await prisma.team.findUniqueOrThrow({
|
||||
where: { id: teamId },
|
||||
select: { metadata: true },
|
||||
});
|
||||
const metadata = teamMetadataSchema.parse(team.metadata);
|
||||
if (!metadata?.subscriptionId)
|
||||
throw Error(
|
||||
`Couldn't cancelTeamSubscriptionFromStripe, Team id: ${teamId} didn't have a subscriptionId`
|
||||
);
|
||||
return await stripe.subscriptions.cancel(metadata.subscriptionId);
|
||||
} catch (error) {
|
||||
let message = "Unknown error on cancelTeamSubscriptionFromStripe";
|
||||
if (error instanceof Error) message = error.message;
|
||||
console.error(message);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
import { MembershipRole } from "@prisma/client";
|
||||
|
||||
export interface NewTeamFormValues {
|
||||
name: string;
|
||||
slug: string;
|
||||
temporarySlug: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
export interface PendingMember {
|
||||
name: string | null;
|
||||
email: string;
|
||||
id?: number;
|
||||
username: string | null;
|
||||
role: MembershipRole;
|
||||
avatar: string | null;
|
||||
sendInviteEmail?: boolean;
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { useRouter } from "next/router";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Icon } from "@calcom/ui";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
|
||||
const BillingView = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const returnTo = router.asPath;
|
||||
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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={billingHref} target="_blank" EndIcon={Icon.FiExternalLink}>
|
||||
{t("billing_portal")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
BillingView.getLayout = getLayout;
|
||||
|
||||
export default BillingView;
|
|
@ -0,0 +1,19 @@
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
|
||||
import { TeamsListing } from "../components";
|
||||
|
||||
const BillingView = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("teams")} description={t("create_manage_teams_collaborative")} />
|
||||
<TeamsListing />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
BillingView.getLayout = getLayout;
|
||||
|
||||
export default BillingView;
|
|
@ -7,7 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/components";
|
||||
import { Alert } from "@calcom/ui/v2/core";
|
||||
import { showToast } from "@calcom/ui/v2/core";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
|
||||
|
@ -15,20 +15,30 @@ import DisableTeamImpersonation from "../components/DisableTeamImpersonation";
|
|||
import MemberInvitationModal from "../components/MemberInvitationModal";
|
||||
import MemberListItem from "../components/MemberListItem";
|
||||
import TeamInviteList from "../components/TeamInviteList";
|
||||
import { UpgradeToFlexibleProModal } from "../components/UpgradeToFlexibleProModal";
|
||||
|
||||
const MembersView = () => {
|
||||
const { t } = useLocale();
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
const utils = trpc.useContext();
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const teamId = Number(router.query.id);
|
||||
|
||||
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
|
||||
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId }], {
|
||||
onError: () => {
|
||||
router.push("/settings");
|
||||
},
|
||||
});
|
||||
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
setShowMemberInvitationModal(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const isInviteOpen = !team?.membership.accepted;
|
||||
|
||||
|
@ -57,44 +67,6 @@ const MembersView = () => {
|
|||
]}
|
||||
/>
|
||||
)}
|
||||
{team.membership.role === MembershipRole.OWNER &&
|
||||
team.membership.isMissingSeat &&
|
||||
team.requiresUpgrade ? (
|
||||
<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 && (
|
||||
|
@ -131,9 +103,17 @@ const MembersView = () => {
|
|||
{showMemberInvitationModal && team && (
|
||||
<MemberInvitationModal
|
||||
isOpen={showMemberInvitationModal}
|
||||
team={team}
|
||||
currentMember={team.membership.role}
|
||||
members={team.members}
|
||||
onExit={() => setShowMemberInvitationModal(false)}
|
||||
onSubmit={(values) => {
|
||||
inviteMemberMutation.mutate({
|
||||
teamId,
|
||||
language: i18n.language,
|
||||
role: values.role.value,
|
||||
usernameOrEmail: values.emailOrUsername,
|
||||
sendEmailInvitation: values.sendInviteEmail,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -10,12 +10,14 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||
import objectKeys from "@calcom/lib/objectKeys";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Avatar, Button, Label, TextArea, Form, TextField } from "@calcom/ui/components";
|
||||
import { Dialog, DialogTrigger, LinkIconButton, showToast } from "@calcom/ui/v2/core";
|
||||
import { Avatar, Button, Form, Label, TextArea, TextField } from "@calcom/ui/components";
|
||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/v2/core/Dialog";
|
||||
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
|
||||
import LinkIconButton from "@calcom/ui/v2/core/LinkIconButton";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
interface TeamProfileValues {
|
||||
name: string;
|
||||
|
@ -65,8 +67,8 @@ const ProfileView = () => {
|
|||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
await utils.invalidateQueries(["viewer.teams.list"]);
|
||||
router.push(`/settings`);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
showToast(t("your_team_disbanded_successfully"), "success");
|
||||
router.push(`${WEBAPP_URL}/teams`);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -36,3 +36,10 @@ export const DEVELOPER_DOCS = "https://developer.cal.com";
|
|||
export const SEO_IMG_DEFAULT = `${WEBSITE_URL}/og-image.png`;
|
||||
export const SEO_IMG_OGIMG = `${CAL_URL}/api/social/og/image`;
|
||||
export const SEO_IMG_OGIMG_VIDEO = `${WEBSITE_URL}/video-og-image.png`;
|
||||
export const IS_STRIPE_ENABLED = !!(
|
||||
process.env.STRIPE_CLIENT_ID &&
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
|
||||
process.env.STRIPE_PRIVATE_KEY
|
||||
);
|
||||
/** Self hosted shouldn't checkout when creating teams */
|
||||
export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && !IS_SELF_HOSTED;
|
||||
|
|
|
@ -3,8 +3,10 @@ import { Prisma, UserPlan } from "@prisma/client";
|
|||
import prisma, { baseEventTypeSelect } from "@calcom/prisma";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { WEBAPP_URL } from "../../../constants";
|
||||
|
||||
export type TeamWithMembers = Awaited<ReturnType<typeof getTeamWithMembers>>;
|
||||
export async function getTeamWithMembers(id?: number, slug?: string) {
|
||||
export async function getTeamWithMembers(id?: number, slug?: string, userId?: number) {
|
||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
username: true,
|
||||
email: true,
|
||||
|
@ -22,6 +24,9 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
|
|||
hideBranding: true,
|
||||
members: {
|
||||
select: {
|
||||
accepted: true,
|
||||
role: true,
|
||||
disableImpersonation: true,
|
||||
user: {
|
||||
select: userSelect,
|
||||
},
|
||||
|
@ -41,26 +46,26 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
|
|||
},
|
||||
});
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: id ? { id } : { slug },
|
||||
const where: Prisma.TeamFindFirstArgs["where"] = {};
|
||||
|
||||
if (userId) where.members = { some: { userId } };
|
||||
if (id) where.id = id;
|
||||
if (slug) where.slug = slug;
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where,
|
||||
select: teamSelect,
|
||||
});
|
||||
|
||||
if (!team) return null;
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const members = team.members.map((obj) => {
|
||||
const membership = memberships.find((membership) => obj.user.id === membership.userId);
|
||||
return {
|
||||
...obj.user,
|
||||
isMissingSeat: obj.user.plan === UserPlan.FREE,
|
||||
role: membership?.role,
|
||||
accepted: membership?.accepted,
|
||||
disableImpersonation: membership?.disableImpersonation,
|
||||
role: obj.role,
|
||||
accepted: obj.accepted,
|
||||
disableImpersonation: obj.disableImpersonation,
|
||||
avatar: `${WEBAPP_URL}/${obj.user.username}/avatar.png`,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Team" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "metadata" JSONB,
|
||||
ALTER COLUMN "slug" DROP NOT NULL;
|
|
@ -202,12 +202,15 @@ model Team {
|
|||
/// @zod.min(1)
|
||||
name String
|
||||
/// @zod.min(1)
|
||||
slug String @unique
|
||||
slug String? @unique
|
||||
logo String?
|
||||
bio String?
|
||||
hideBranding Boolean @default(false)
|
||||
members Membership[]
|
||||
eventTypes EventType[]
|
||||
createdAt DateTime @default(now())
|
||||
/// @zod.custom(imports.teamMetadataSchema)
|
||||
metadata Json?
|
||||
}
|
||||
|
||||
enum MembershipRole {
|
||||
|
|
|
@ -574,6 +574,7 @@ async function main() {
|
|||
],
|
||||
},
|
||||
},
|
||||
createdAt: new Date(),
|
||||
},
|
||||
[
|
||||
{
|
||||
|
|
|
@ -199,6 +199,16 @@ export const userMetadata = z
|
|||
})
|
||||
.nullable();
|
||||
|
||||
export const teamMetadataSchema = z
|
||||
.object({
|
||||
requestedSlug: z.string(),
|
||||
paymentId: z.string(),
|
||||
subscriptionId: z.string().nullable(),
|
||||
subscriptionItemId: z.string().nullable(),
|
||||
})
|
||||
.partial()
|
||||
.nullable();
|
||||
|
||||
/**
|
||||
* Ensures that it is a valid HTTP URL
|
||||
* It automatically avoids
|
||||
|
|
|
@ -2,19 +2,16 @@ import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
|
|||
import { randomBytes } from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
addSeat,
|
||||
downgradeTeamMembers,
|
||||
ensureSubscriptionQuantityCorrectness,
|
||||
getTeamSeatStats,
|
||||
removeSeat,
|
||||
upgradeTeam,
|
||||
} from "@calcom/app-store/stripepayment/lib/team-billing";
|
||||
import { addSeat, getRequestedSlugError, removeSeat } from "@calcom/app-store/stripepayment/lib/team-billing";
|
||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||
import { sendTeamInviteEmail } from "@calcom/emails";
|
||||
import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import {
|
||||
cancelTeamSubscriptionFromStripe,
|
||||
purchaseTeamSubscription,
|
||||
} from "@calcom/features/ee/teams/lib/payments";
|
||||
import { HOSTED_CAL_FEATURES, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTeamWithMembers, isTeamAdmin, isTeamOwner, isTeamMember } from "@calcom/lib/server/queries/teams";
|
||||
import { getTeamWithMembers, isTeamAdmin, isTeamMember, isTeamOwner } from "@calcom/lib/server/queries/teams";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import {
|
||||
closeComDeleteTeam,
|
||||
|
@ -23,6 +20,7 @@ import {
|
|||
closeComUpsertTeamUser,
|
||||
} from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { availabilityUserSelect } from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
|
@ -35,9 +33,9 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
teamId: z.number(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const team = await getTeamWithMembers(input.teamId);
|
||||
if (!team?.members.find((m) => m.id === ctx.user.id)) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a member of this team." });
|
||||
const team = await getTeamWithMembers(input.teamId, undefined, ctx.user.id);
|
||||
if (!team) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
|
||||
}
|
||||
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
|
||||
|
||||
|
@ -59,59 +57,52 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
where: {
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
orderBy: { role: "desc" },
|
||||
});
|
||||
const teams = await ctx.prisma.team.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: memberships.map((membership) => membership.teamId),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return memberships.map((membership) => ({
|
||||
return memberships.map(({ team, ...membership }) => ({
|
||||
role: membership.role,
|
||||
accepted: membership.accepted,
|
||||
...teams.find((team) => team.id === membership.teamId),
|
||||
...team,
|
||||
}));
|
||||
},
|
||||
})
|
||||
.mutation("create", {
|
||||
input: z.object({
|
||||
name: z.string(),
|
||||
slug: z.string().optional().nullable(),
|
||||
logo: z.string().optional().nullable(),
|
||||
slug: z.string().transform((val) => slugify(val.trim())),
|
||||
logo: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => v || null),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (ctx.user.plan === "FREE") {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "You need a team plan." });
|
||||
}
|
||||
const { slug, name, logo } = input;
|
||||
|
||||
const slug = input.slug || slugify(input.name);
|
||||
|
||||
const nameCollisions = await ctx.prisma.team.count({
|
||||
where: {
|
||||
OR: [{ name: input.name }, { slug: slug }],
|
||||
},
|
||||
const nameCollisions = await ctx.prisma.team.findFirst({
|
||||
where: { OR: [{ name }, { slug }] },
|
||||
});
|
||||
|
||||
if (nameCollisions > 0)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." });
|
||||
if (nameCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." });
|
||||
|
||||
const createTeam = await ctx.prisma.team.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
slug: slug,
|
||||
logo: input.logo || null,
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.prisma.membership.create({
|
||||
data: {
|
||||
teamId: createTeam.id,
|
||||
userId: ctx.user.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
name,
|
||||
logo,
|
||||
members: {
|
||||
create: {
|
||||
userId: ctx.user.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
requestedSlug: slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -149,17 +140,31 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
},
|
||||
});
|
||||
|
||||
if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
|
||||
|
||||
const data: Prisma.TeamUpdateArgs["data"] = {
|
||||
name: input.name,
|
||||
logo: input.logo,
|
||||
bio: input.bio,
|
||||
hideBranding: input.hideBranding,
|
||||
};
|
||||
|
||||
if (
|
||||
input.slug &&
|
||||
IS_TEAM_BILLING_ENABLED &&
|
||||
/** If the team doesn't have a slug we can assume that it hasn't been published yet. */ !prevTeam.slug
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You cannot change the slug until you publish your team",
|
||||
});
|
||||
} else {
|
||||
data.slug = input.slug;
|
||||
}
|
||||
|
||||
const updatedTeam = await ctx.prisma.team.update({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
data: {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
logo: input.logo,
|
||||
bio: input.bio,
|
||||
hideBranding: input.hideBranding,
|
||||
},
|
||||
where: { id: input.id },
|
||||
data,
|
||||
});
|
||||
|
||||
// Sync Services: Close.com
|
||||
|
@ -173,9 +178,7 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
async resolve({ ctx, input }) {
|
||||
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
if (process.env.STRIPE_PRIVATE_KEY) {
|
||||
await downgradeTeamMembers(input.teamId);
|
||||
}
|
||||
if (IS_TEAM_BILLING_ENABLED) await cancelTeamSubscriptionFromStripe(input.teamId);
|
||||
|
||||
// delete all memberships
|
||||
await ctx.prisma.membership.deleteMany({
|
||||
|
@ -487,32 +490,6 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
);
|
||||
},
|
||||
})
|
||||
.mutation("upgradeTeam", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (!HOSTED_CAL_FEATURES)
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Team billing is not enabled" });
|
||||
return await upgradeTeam(ctx.user.id, input.teamId);
|
||||
},
|
||||
})
|
||||
.query("getTeamSeats", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
}),
|
||||
async resolve({ input }) {
|
||||
return await getTeamSeatStats(input.teamId);
|
||||
},
|
||||
})
|
||||
.mutation("ensureSubscriptionQuantityCorrectness", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
return await ensureSubscriptionQuantityCorrectness(ctx.user.id, input.teamId);
|
||||
},
|
||||
})
|
||||
.query("getMembershipbyUser", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
|
@ -562,4 +539,72 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.query("validateTeamSlug", {
|
||||
input: z.object({
|
||||
slug: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const team = await ctx.prisma.team.findFirst({
|
||||
where: {
|
||||
slug: input.slug,
|
||||
},
|
||||
});
|
||||
|
||||
return !team;
|
||||
},
|
||||
})
|
||||
.mutation("publish", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
const { teamId: id } = input;
|
||||
|
||||
const prevTeam = await ctx.prisma.team.findFirst({ where: { id }, include: { members: true } });
|
||||
|
||||
if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
|
||||
|
||||
const metadata = teamMetadataSchema.safeParse(prevTeam.metadata);
|
||||
|
||||
if (!metadata.success || !metadata.data?.requestedSlug)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" });
|
||||
|
||||
// if payment needed, responed with checkout url
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
const checkoutSession = await purchaseTeamSubscription({
|
||||
teamId: prevTeam.id,
|
||||
seats: prevTeam.members.length,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
if (!checkoutSession.url)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed retrieving a checkout session URL.",
|
||||
});
|
||||
return { url: checkoutSession.url };
|
||||
}
|
||||
|
||||
const { requestedSlug, ...newMetadata } = metadata.data;
|
||||
let updatedTeam: Awaited<ReturnType<typeof ctx.prisma.team.update>>;
|
||||
|
||||
try {
|
||||
updatedTeam = await ctx.prisma.team.update({
|
||||
where: { id },
|
||||
data: {
|
||||
slug: requestedSlug,
|
||||
metadata: { ...newMetadata },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const { message } = getRequestedSlugError(error, requestedSlug);
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message });
|
||||
}
|
||||
|
||||
// Sync Services: Close.com
|
||||
closeComUpdateTeam(prevTeam, updatedTeam);
|
||||
|
||||
return { url: `${WEBAPP_URL}/settings/teams/${updatedTeam.id}/profile` };
|
||||
},
|
||||
});
|
||||
|
|
|
@ -80,7 +80,7 @@ type DialogContentProps = React.ComponentProps<typeof DialogPrimitive["Content"]
|
|||
};
|
||||
|
||||
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
({ children, title, Icon, actionProps, ...props }, forwardedRef) => {
|
||||
({ children, title, Icon, actionProps, useOwnActionButtons, ...props }, forwardedRef) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
|
@ -122,7 +122,7 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!props.useOwnActionButtons && (
|
||||
{!useOwnActionButtons && (
|
||||
<DialogFooter>
|
||||
<div className="mt-2 flex space-x-2">
|
||||
<DialogClose asChild>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { MembershipRole, UserPermissionRole } from "@prisma/client";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ComponentProps, useEffect, useState } from "react";
|
||||
|
||||
|
@ -158,12 +159,16 @@ const SettingsSidebarContainer = ({ className = "" }) => {
|
|||
) : (
|
||||
<React.Fragment key={tab.href}>
|
||||
<div className={`${!tab.children?.length ? "mb-3" : ""}`}>
|
||||
<div className="group flex h-9 w-64 flex-row items-center rounded-md px-3 py-[10px] text-sm font-medium leading-none text-gray-600 hover:bg-gray-100 group-hover:text-gray-700 [&[aria-current='page']]:bg-gray-200 [&[aria-current='page']]:text-gray-900">
|
||||
{tab && tab.icon && (
|
||||
<tab.icon className="mr-[12px] h-[16px] w-[16px] stroke-[2px] md:mt-0" />
|
||||
)}
|
||||
<p className="text-sm font-medium leading-5">{t(tab.name)}</p>
|
||||
</div>
|
||||
<Link href={tab.href}>
|
||||
<a>
|
||||
<div className="group flex h-9 w-64 flex-row items-center rounded-md px-3 py-[10px] text-sm font-medium leading-none text-gray-600 hover:bg-gray-100 group-hover:text-gray-700 [&[aria-current='page']]:bg-gray-200 [&[aria-current='page']]:text-gray-900">
|
||||
{tab && tab.icon && (
|
||||
<tab.icon className="mr-[12px] h-[16px] w-[16px] stroke-[2px] md:mt-0" />
|
||||
)}
|
||||
<p className="text-sm font-medium leading-5">{t(tab.name)}</p>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
{teams &&
|
||||
teamMenuState &&
|
||||
teams.map((team, index: number) => {
|
||||
|
@ -243,6 +248,12 @@ const SettingsSidebarContainer = ({ className = "" }) => {
|
|||
textClassNames="px-3 text-gray-900 font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
<VerticalTabItem
|
||||
name={t("billing")}
|
||||
href={`/settings/teams/${team.id}/billing`}
|
||||
textClassNames="px-3 text-gray-900 font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
{HOSTED_CAL_FEATURES && (
|
||||
<VerticalTabItem
|
||||
name={t("saml_config")}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
import { noop } from "lodash";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { StepCard } from "@calcom/ui/v2/core/StepCard";
|
||||
import { Steps } from "@calcom/ui/v2/core/Steps";
|
||||
|
||||
export default function WizardLayout({
|
||||
children,
|
||||
maxSteps = 2,
|
||||
currentStep = 0,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
} & { maxSteps?: number; currentStep?: number }) {
|
||||
const [meta, setMeta] = useState({ title: "", subtitle: " " });
|
||||
const router = useRouter();
|
||||
const { title, subtitle } = meta;
|
||||
|
||||
useEffect(() => {
|
||||
setMeta({
|
||||
title: window.document.title,
|
||||
subtitle: window.document.querySelector('meta[name="description"]')?.getAttribute("content") || "",
|
||||
});
|
||||
}, [router.asPath]);
|
||||
|
||||
return (
|
||||
<div className="dark:bg-brand dark:text-brand-contrast min-h-screen text-black" data-testid="onboarding">
|
||||
<div>
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
<div className="mx-auto px-4 py-24">
|
||||
<div className="relative">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
|
||||
<div className="mx-auto sm:max-w-[520px]">
|
||||
<header>
|
||||
<p className="font-cal mb-3 text-[28px] font-medium leading-7">{title} </p>
|
||||
<p className="font-sans text-sm font-normal text-gray-500">{subtitle} </p>
|
||||
</header>
|
||||
<Steps maxSteps={maxSteps} currentStep={currentStep} navigateToStep={noop} />
|
||||
</div>
|
||||
<StepCard>{children}</StepCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getLayout = (page: React.ReactElement) => <WizardLayout>{page}</WizardLayout>;
|
|
@ -39,6 +39,7 @@
|
|||
"$STRIPE_PRO_PLAN_PRODUCT_ID",
|
||||
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
|
||||
"$STRIPE_FREE_PLAN_PRODUCT_ID",
|
||||
"$STRIPE_TEAM_MONTHLY_PRICE_ID",
|
||||
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
|
||||
"$NEXT_PUBLIC_WEBAPP_URL",
|
||||
"$NEXT_PUBLIC_WEBSITE_URL"
|
||||
|
|
Loading…
Reference in New Issue