Even Better Teams (#1304)

- dropdown improvements
- Improve performance of team availability
- Fix default timezone
- Allow team admins to edit event types
- Change team availability slot input to dropdown select (15,30,60)
- Prevent teams from access if not pro user
This commit is contained in:
Jamie Pine 2021-12-16 16:16:59 -08:00 committed by GitHub
parent 4ce879e5dc
commit c21f0c2d49
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 141 additions and 73 deletions

View file

@ -39,7 +39,7 @@ import { useViewerI18n } from "./I18nLanguageHandler";
import Logo from "./Logo"; import Logo from "./Logo";
import Button from "./ui/Button"; import Button from "./ui/Button";
function useMeQuery() { export function useMeQuery() {
const meQuery = trpc.useQuery(["viewer.me"], { const meQuery = trpc.useQuery(["viewer.me"], {
retry(failureCount) { retry(failureCount) {
return failureCount > 3; return failureCount > 3;

View file

@ -17,7 +17,12 @@ import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button"; import Button from "@components/ui/Button";
import ModalContainer from "@components/ui/ModalContainer"; import ModalContainer from "@components/ui/ModalContainer";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown"; import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/Dropdown";
import MemberChangeRoleModal from "./MemberChangeRoleModal"; import MemberChangeRoleModal from "./MemberChangeRoleModal";
import TeamRole from "./TeamRole"; import TeamRole from "./TeamRole";
import { MembershipRole } from ".prisma/client"; import { MembershipRole } from ".prisma/client";
@ -91,13 +96,13 @@ export default function MemberListItem(props: Props) {
<DropdownMenuItem> <DropdownMenuItem>
<Link href={"/" + props.member.username}> <Link href={"/" + props.member.username}>
<a target="_blank"> <a target="_blank">
<Button color="minimal" StartIcon={ExternalLinkIcon} className="w-full"> <Button color="minimal" StartIcon={ExternalLinkIcon} className="w-full font-normal">
{t("view_public_page")} {t("view_public_page")}
</Button> </Button>
</a> </a>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{(props.team.membership.role === MembershipRole.OWNER || {(props.team.membership.role === MembershipRole.OWNER ||
props.team.membership.role === MembershipRole.ADMIN) && ( props.team.membership.role === MembershipRole.ADMIN) && (
<> <>
@ -106,10 +111,11 @@ export default function MemberListItem(props: Props) {
onClick={() => setShowChangeMemberRoleModal(true)} onClick={() => setShowChangeMemberRoleModal(true)}
color="minimal" color="minimal"
StartIcon={PencilIcon} StartIcon={PencilIcon}
className="flex-shrink-0 w-full"> className="flex-shrink-0 w-full font-normal">
{t("edit_role")} {t("edit_role")}
</Button> </Button>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
<DropdownMenuItem> <DropdownMenuItem>
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
@ -119,7 +125,7 @@ export default function MemberListItem(props: Props) {
}} }}
color="warn" color="warn"
StartIcon={UserRemoveIcon} StartIcon={UserRemoveIcon}
className="w-full"> className="w-full font-normal">
{t("remove_member")} {t("remove_member")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@ -151,9 +157,11 @@ export default function MemberListItem(props: Props) {
<TeamAvailabilityModal team={props.team} member={props.member} /> <TeamAvailabilityModal team={props.team} member={props.member} />
<div className="p-5 space-x-2 border-t"> <div className="p-5 space-x-2 border-t">
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button> <Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
<Link href={`/settings/teams/${props.team.id}/availability`}> {props.team.membership.role !== MembershipRole.MEMBER && (
<Button color="secondary">{t("Open Team Availability")}</Button> <Link href={`/settings/teams/${props.team.id}/availability`}>
</Link> <Button color="secondary">{t("Open Team Availability")}</Button>
</Link>
)}
</div> </div>
</ModalContainer> </ModalContainer>
)} )}

View file

@ -17,6 +17,7 @@ import Dropdown, {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@components/ui/Dropdown"; } from "@components/ui/Dropdown";
import TeamRole from "./TeamRole"; import TeamRole from "./TeamRole";
@ -123,23 +124,33 @@ export default function TeamListItem(props: Props) {
<DropdownMenuItem> <DropdownMenuItem>
<Link href={"/settings/teams/" + team.id}> <Link href={"/settings/teams/" + team.id}>
<a> <a>
<Button type="button" color="minimal" className="w-full" StartIcon={PencilIcon}> <Button
type="button"
color="minimal"
className="w-full font-normal"
StartIcon={PencilIcon}>
{t("edit_team")} {t("edit_team")}
</Button> </Button>
</a> </a>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && <DropdownMenuSeparator className="h-px bg-gray-200" />}
<DropdownMenuItem> <DropdownMenuItem>
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${team.slug}`} passHref={true}> <Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${team.slug}`} passHref={true}>
<a target="_blank"> <a target="_blank">
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}> <Button
type="button"
color="minimal"
className="w-full font-normal"
StartIcon={ExternalLinkIcon}>
{" "} {" "}
{t("preview_team")} {t("preview_team")}
</Button> </Button>
</a> </a>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{isOwner && ( {isOwner && (
<DropdownMenuItem> <DropdownMenuItem>
<Dialog> <Dialog>
@ -150,7 +161,7 @@ export default function TeamListItem(props: Props) {
}} }}
color="warn" color="warn"
StartIcon={TrashIcon} StartIcon={TrashIcon}
className="w-full"> className="w-full font-normal">
{t("disband_team")} {t("disband_team")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@ -164,6 +175,7 @@ export default function TeamListItem(props: Props) {
</Dialog> </Dialog>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{!isOwner && ( {!isOwner && (
<DropdownMenuItem> <DropdownMenuItem>
<Dialog> <Dialog>

View file

@ -87,7 +87,7 @@ export default function TeamSettings(props: Props) {
htmlFor="team-url" htmlFor="team-url"
Input={ Input={
<TextField <TextField
name="team-url" name="" // typescript requires name but we don't want component to render name label
id="team-url" id="team-url"
addOnLeading={ addOnLeading={
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm"> <span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">

View file

@ -109,8 +109,7 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
</Dialog> </Dialog>
)} )}
</div> </div>
{/* TODO: Team availability */} {props.team?.id && props.role !== MembershipRole.MEMBER && (
{props.team?.id && (
<Link href={`/settings/teams/${props.team.id}/availability`}> <Link href={`/settings/teams/${props.team.id}/availability`}>
<div className="mt-5 space-y-1"> <div className="mt-5 space-y-1">
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton> <LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>

View file

@ -33,7 +33,7 @@ export function Alert(props: AlertProps) {
<CheckCircleIcon className={classNames("h-5 w-5 text-gray-400")} aria-hidden="true" /> <CheckCircleIcon className={classNames("h-5 w-5 text-gray-400")} aria-hidden="true" />
)} )}
</div> </div>
<div className="ml-3 flex-grow"> <div className="flex-grow ml-3">
<h3 className="text-sm font-medium">{props.title}</h3> <h3 className="text-sm font-medium">{props.title}</h3>
<div className="text-sm">{props.message}</div> <div className="text-sm">{props.message}</div>
</div> </div>

View file

@ -8,7 +8,7 @@ import { trpc, inferQueryOutput } from "@lib/trpc";
import Avatar from "@components/ui/Avatar"; import Avatar from "@components/ui/Avatar";
import { DatePicker } from "@components/ui/form/DatePicker"; import { DatePicker } from "@components/ui/form/DatePicker";
import MinutesField from "@components/ui/form/MinutesField"; import Select from "@components/ui/form/Select";
import TeamAvailabilityTimes from "./TeamAvailabilityTimes"; import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
@ -22,8 +22,10 @@ interface Props {
export default function TeamAvailabilityModal(props: Props) { export default function TeamAvailabilityModal(props: Props) {
const utils = trpc.useContext(); const utils = trpc.useContext();
const [selectedDate, setSelectedDate] = useState(dayjs()); const [selectedDate, setSelectedDate] = useState(dayjs());
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(dayjs.tz.guess); const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
const [frequency, setFrequency] = useState<number>(30); localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
);
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
useEffect(() => { useEffect(() => {
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]); utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
@ -64,24 +66,25 @@ export default function TeamAvailabilityModal(props: Props) {
</div> </div>
<div> <div>
<span className="font-bold text-gray-600">Slot Length</span> <span className="font-bold text-gray-600">Slot Length</span>
<MinutesField <Select
id="length" options={[
label="" { value: 15, label: "15 minutes" },
required { value: 30, label: "30 minutes" },
min="10" { value: 60, label: "60 minutes" },
placeholder="15" ]}
defaultValue={frequency} isSearchable={false}
onChange={(e) => { classNamePrefix="react-select"
setFrequency(Number(e.target.value)); className="flex-1 block w-full min-w-0 border border-gray-300 rounded-sm react-select-container focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
}} value={{ value: frequency, label: `${frequency} minutes` }}
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
/> />
</div> </div>
</div> </div>
{props.team && props.member && ( {props.team && props.member && (
<TeamAvailabilityTimes <TeamAvailabilityTimes
className="overflow-scroll" className="overflow-scroll"
team={props.team} teamId={props.team.id}
member={props.member} memberId={props.member.id}
frequency={frequency} frequency={frequency}
selectedDate={selectedDate} selectedDate={selectedDate}
selectedTimeZone={selectedTimeZone} selectedTimeZone={selectedTimeZone}

View file

@ -9,22 +9,21 @@ import { trpc, inferQueryOutput } from "@lib/trpc";
import Avatar from "@components/ui/Avatar"; import Avatar from "@components/ui/Avatar";
import { DatePicker } from "@components/ui/form/DatePicker"; import { DatePicker } from "@components/ui/form/DatePicker";
import MinutesField from "@components/ui/form/MinutesField"; import Select from "@components/ui/form/Select";
import TeamAvailabilityTimes from "./TeamAvailabilityTimes"; import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
interface Props { interface Props {
team?: inferQueryOutput<"viewer.teams.get">; team?: inferQueryOutput<"viewer.teams.get">;
members?: inferQueryOutput<"viewer.teams.get">["members"];
} }
// type Member = inferQueryOutput<"viewer.teams.get">["members"][number];
export default function TeamAvailabilityScreen(props: Props) { export default function TeamAvailabilityScreen(props: Props) {
const utils = trpc.useContext(); const utils = trpc.useContext();
const [selectedDate, setSelectedDate] = useState(dayjs()); const [selectedDate, setSelectedDate] = useState(dayjs());
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(dayjs.tz.guess()); const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
const [frequency, setFrequency] = useState<number>(30); localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
);
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
useEffect(() => { useEffect(() => {
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]); utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
@ -32,14 +31,14 @@ export default function TeamAvailabilityScreen(props: Props) {
}, [selectedTimeZone, selectedDate]); }, [selectedTimeZone, selectedDate]);
const Item = ({ index, style }: { index: number; style: CSSProperties }) => { const Item = ({ index, style }: { index: number; style: CSSProperties }) => {
const member = props.members?.[index]; const member = props.team?.members?.[index];
if (!member) return <></>; if (!member) return <></>;
return ( return (
<div key={member.id} style={style} className="flex pl-4 border-r border-gray-200 "> <div key={member.id} style={style} className="flex pl-4 border-r border-gray-200 ">
<TeamAvailabilityTimes <TeamAvailabilityTimes
team={props.team as inferQueryOutput<"viewer.teams.get">} teamId={props.team?.id as number}
member={member} memberId={member.id}
frequency={frequency} frequency={frequency}
selectedDate={selectedDate} selectedDate={selectedDate}
selectedTimeZone={selectedTimeZone} selectedTimeZone={selectedTimeZone}
@ -86,17 +85,17 @@ export default function TeamAvailabilityScreen(props: Props) {
</div> </div>
<div> <div>
<span className="font-bold text-gray-600">Slot Length</span> <span className="font-bold text-gray-600">Slot Length</span>
<MinutesField <Select
id="length" options={[
label="" { value: 15, label: "15 minutes" },
required { value: 30, label: "30 minutes" },
min="10" { value: 60, label: "60 minutes" },
className="p-2.5" ]}
placeholder="15" isSearchable={false}
defaultValue={frequency} classNamePrefix="react-select"
onChange={(e) => { className="flex-1 block w-full min-w-0 border border-gray-300 rounded-sm react-select-container focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
setFrequency(Number(e.target.value)); value={{ value: frequency, label: `${frequency} minutes` }}
}} onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
/> />
</div> </div>
</div> </div>
@ -105,10 +104,10 @@ export default function TeamAvailabilityScreen(props: Props) {
{({ height, width }) => ( {({ height, width }) => (
<List <List
itemSize={240} itemSize={240}
itemCount={props.members?.length || 0} itemCount={props.team?.members?.length ?? 0}
className="List" className="List"
height={height} height={height}
direction="horizontal" layout="horizontal"
width={width}> width={width}>
{Item} {Item}
</List> </List>

View file

@ -5,13 +5,13 @@ import React from "react";
import { ITimezone } from "react-timezone-select"; import { ITimezone } from "react-timezone-select";
import getSlots from "@lib/slots"; import getSlots from "@lib/slots";
import { inferQueryOutput, trpc } from "@lib/trpc"; import { trpc } from "@lib/trpc";
import Loader from "@components/Loader"; import Loader from "@components/Loader";
interface Props { interface Props {
team: inferQueryOutput<"viewer.teams.get">; teamId: number;
member: inferQueryOutput<"viewer.teams.get">["members"][number]; memberId: number;
selectedDate: Dayjs; selectedDate: Dayjs;
selectedTimeZone: ITimezone; selectedTimeZone: ITimezone;
frequency: number; frequency: number;
@ -26,8 +26,8 @@ export default function TeamAvailabilityTimes(props: Props) {
[ [
"viewer.teams.getMemberAvailability", "viewer.teams.getMemberAvailability",
{ {
teamId: props.team.id, teamId: props.teamId,
memberId: props.member.id, memberId: props.memberId,
dateFrom: props.selectedDate.toString(), dateFrom: props.selectedDate.toString(),
dateTo: props.selectedDate.add(1, "day").toString(), dateTo: props.selectedDate.add(1, "day").toString(),
timezone: `${props.selectedTimeZone.toString()}`, timezone: `${props.selectedTimeZone.toString()}`,

View file

@ -1,5 +1,5 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useState } from "react"; import { useMemo, useState } from "react";
import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen"; import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen";
@ -7,15 +7,18 @@ import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { trpc } from "@lib/trpc"; import { trpc } from "@lib/trpc";
import Loader from "@components/Loader"; import Loader from "@components/Loader";
import Shell from "@components/Shell"; import Shell, { useMeQuery } from "@components/Shell";
import { Alert } from "@components/ui/Alert"; import { Alert } from "@components/ui/Alert";
import Avatar from "@components/ui/Avatar"; import Avatar from "@components/ui/Avatar";
export function TeamSettingsPage() { export function TeamAvailabilityPage() {
const router = useRouter(); const router = useRouter();
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const me = useMeQuery();
const isFreeUser = me.data?.plan === "FREE";
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], { const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
onError: (e) => { onError: (e) => {
@ -23,14 +26,20 @@ export function TeamSettingsPage() {
}, },
}); });
// prevent unnecessary re-renders due to shell queries
const TeamAvailability = useMemo(() => {
return <TeamAvailabilityScreen team={team} />;
}, [team]);
return ( return (
<Shell <Shell
showBackButton={!errorMessage} showBackButton={!errorMessage}
heading={team?.name} heading={!isFreeUser && team?.name}
flexChildrenContainer flexChildrenContainer
subtitle={team && "Your team's availability at a glance"} subtitle={team && !isFreeUser && "Your team's availability at a glance"}
HeadingLeftIcon={ HeadingLeftIcon={
team && ( team &&
!isFreeUser && (
<Avatar <Avatar
size={12} size={12}
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)} imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
@ -41,9 +50,17 @@ export function TeamSettingsPage() {
}> }>
{!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />} {!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />}
{isLoading && <Loader />} {isLoading && <Loader />}
{team && <TeamAvailabilityScreen team={team} members={team.members} />} {isFreeUser ? (
<Alert
className="-mt-24 border"
severity="warning"
title="This is a pro feature. Upgrade to pro to see your team's availability."
/>
) : (
TeamAvailability
)}
</Shell> </Shell>
); );
} }
export default TeamSettingsPage; export default TeamAvailabilityPage;

View file

@ -86,7 +86,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const isAuthorized = (function () { const isAuthorized = (function () {
if (event.team) { if (event.team) {
return event.team.members return event.team.members
.filter((member) => member.role === MembershipRole.OWNER) .filter((member) => member.role === MembershipRole.OWNER || member.role === MembershipRole.ADMIN)
.map((member) => member.userId) .map((member) => member.userId)
.includes(session.user.id); .includes(session.user.id);
} }

View file

@ -1,4 +1,5 @@
import { PlusIcon } from "@heroicons/react/solid"; import { PlusIcon } from "@heroicons/react/solid";
import classNames from "classnames";
import { useSession } from "next-auth/client"; import { useSession } from "next-auth/client";
import { useState } from "react"; import { useState } from "react";
@ -7,7 +8,7 @@ import { trpc } from "@lib/trpc";
import Loader from "@components/Loader"; import Loader from "@components/Loader";
import SettingsShell from "@components/SettingsShell"; import SettingsShell from "@components/SettingsShell";
import Shell from "@components/Shell"; import Shell, { useMeQuery } from "@components/Shell";
import TeamCreateModal from "@components/team/TeamCreateModal"; import TeamCreateModal from "@components/team/TeamCreateModal";
import TeamList from "@components/team/TeamList"; import TeamList from "@components/team/TeamList";
import { Alert } from "@components/ui/Alert"; import { Alert } from "@components/ui/Alert";
@ -19,6 +20,8 @@ export default function Teams() {
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false); const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const me = useMeQuery();
const { data } = trpc.useQuery(["viewer.teams.list"], { const { data } = trpc.useQuery(["viewer.teams.list"], {
onError: (e) => { onError: (e) => {
setErrorMessage(e.message); setErrorMessage(e.message);
@ -29,15 +32,34 @@ export default function Teams() {
const teams = data?.filter((m) => m.accepted) || []; const teams = data?.filter((m) => m.accepted) || [];
const invites = data?.filter((m) => !m.accepted) || []; const invites = data?.filter((m) => !m.accepted) || [];
const isFreePlan = me.data?.plan === "FREE";
return ( return (
<Shell heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}> <Shell heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
<SettingsShell> <SettingsShell>
{!!errorMessage && <Alert severity="error" title={errorMessage} />} {!!errorMessage && <Alert severity="error" title={errorMessage} />}
{isFreePlan && (
<Alert
severity="warning"
title={<>{t("plan_upgrade_teams")}</>}
message={
<>
{t("to_upgrade_go_to")}{" "}
<a href={"https://cal.com/upgrade"} className="underline">
{"https://cal.com/upgrade"}
</a>
</>
}
className="my-4"
/>
)}
{showCreateTeamModal && <TeamCreateModal onClose={() => setShowCreateTeamModal(false)} />} {showCreateTeamModal && <TeamCreateModal onClose={() => setShowCreateTeamModal(false)} />}
<div className="flex justify-end my-4"> <div className={classNames("flex justify-end my-4", isFreePlan && "opacity-50")}>
<Button type="button" className="btn btn-white" onClick={() => setShowCreateTeamModal(true)}> <Button
disabled={isFreePlan}
type="button"
className="btn btn-white"
onClick={() => setShowCreateTeamModal(true)}>
<PlusIcon className="group-hover:text-black text-gray-700 w-3.5 h-3.5 mr-2 inline-block" /> <PlusIcon className="group-hover:text-black text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
{t("new_team")} {t("new_team")}
</Button> </Button>

View file

@ -445,6 +445,7 @@
"hidden": "Hidden", "hidden": "Hidden",
"readonly": "Readonly", "readonly": "Readonly",
"plan_upgrade": "You need to upgrade your plan to have more than one active event type.", "plan_upgrade": "You need to upgrade your plan to have more than one active event type.",
"plan_upgrade_teams": "You need to upgrade your plan to create a team.",
"plan_upgrade_instructions": "To upgrade, go to <a href=\"https://cal.com/upgrade\" className=\"underline\">https://cal.com/upgrade</a>", "plan_upgrade_instructions": "To upgrade, go to <a href=\"https://cal.com/upgrade\" className=\"underline\">https://cal.com/upgrade</a>",
"event_types_page_title": "Event Types", "event_types_page_title": "Event Types",
"event_types_page_subtitle": "Create events to share for people to book on your calendar.", "event_types_page_subtitle": "Create events to share for people to book on your calendar.",

View file

@ -44,6 +44,7 @@ async function getUserFromSession({
avatar: true, avatar: true,
twoFactorEnabled: true, twoFactorEnabled: true,
brandColor: true, brandColor: true,
plan: true,
credentials: { credentials: {
select: { select: {
id: true, id: true,

View file

@ -1,4 +1,4 @@
import { BookingStatus, Prisma } from "@prisma/client"; import { BookingStatus, MembershipRole, Prisma } from "@prisma/client";
import _ from "lodash"; import _ from "lodash";
import { z } from "zod"; import { z } from "zod";
@ -57,6 +57,7 @@ const loggedInViewerRouter = createProtectedRouter()
completedOnboarding, completedOnboarding,
twoFactorEnabled, twoFactorEnabled,
brandColor, brandColor,
plan,
} = ctx.user; } = ctx.user;
const me = { const me = {
id, id,
@ -72,6 +73,7 @@ const loggedInViewerRouter = createProtectedRouter()
completedOnboarding, completedOnboarding,
twoFactorEnabled, twoFactorEnabled,
brandColor, brandColor,
plan,
}; };
return me; return me;
}, },
@ -231,7 +233,7 @@ const loggedInViewerRouter = createProtectedRouter()
}, },
metadata: { metadata: {
membershipCount: membership.team.members.length, membershipCount: membership.team.members.length,
readOnly: membership.role !== "OWNER", readOnly: membership.role === MembershipRole.MEMBER,
}, },
eventTypes: membership.team.eventTypes, eventTypes: membership.team.eventTypes,
})) }))

View file

@ -58,6 +58,10 @@ export const viewerTeamsRouter = createProtectedRouter()
name: z.string(), name: z.string(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
if (ctx.user.plan === "FREE") {
throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a pro user." });
}
const slug = slugify(input.name); const slug = slugify(input.name);
const nameCollisions = await ctx.prisma.team.count({ const nameCollisions = await ctx.prisma.team.count({