feat: Organization branding in side menu (#9279)
* Desktop first banner, mobile pending * Removing dead code and img * WIP * Adds Email verification template+translations for organizations (#9202) * First step done * Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding * Step 2 done, avatar not working * Covering null on unique clauses * Onboarding admins step * Last step to create teams * Moving change password handler, improving verifying code flow * Clearing error before submitting * Reverting email testing api changes * Reverting having the banner for now * Consistent exported components * Remove unneeded files from banner * Removing uneeded code * Fixing avatar selector * Org branding provider used in shell sidebar * Using meta component for head/descr * Missing i18n strings * Feedback * Making an org avatar (temp) * Using org avatar (temp) * Not showing org logo if not set * User onboarding with org branding (slug) * Check for subteams slug clashes with usernames * Fixing create teams onsuccess * feedback * Feedback * Org public profile * Public profiles for team event types * Added setup profile alert * Using org avatar on subteams avatar * Making sure we show the set up profile on org only * Profile username availability rely on org hook * Update apps/web/pages/team/[slug].tsx Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> * Update apps/web/pages/team/[slug].tsx Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> --------- Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
This commit is contained in:
parent
174cf3a3e4
commit
dc1f1b5db9
|
@ -46,7 +46,10 @@ const BookingDescription: FC<Props> = (props) => {
|
|||
const { profile, eventType, isBookingPage = false, children } = props;
|
||||
const { date: bookingDate } = useRouterQuery("date");
|
||||
const { t } = useLocale();
|
||||
const { duration, setQuery: setDuration } = useRouterQuery("duration");
|
||||
const { duration, setQuery: setDuration } = useRouterQuery("duration", {
|
||||
// Only set duration query parameter when event type has multiple durations
|
||||
disabled: !eventType.metadata?.multipleDuration,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
@ -83,7 +86,9 @@ const BookingDescription: FC<Props> = (props) => {
|
|||
size="sm"
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="text-default mt-1 mb-2 break-words text-sm font-medium ">{profile.name}</h2>
|
||||
<h2 className="text-default mt-1 mb-2 break-words text-sm font-medium ">
|
||||
{eventType.team?.parent?.name} {profile.name}
|
||||
</h2>
|
||||
<h1 className="font-cal text-emphasis mb-6 break-words text-2xl font-semibold leading-none">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
|
|
|
@ -4,6 +4,7 @@ import type { RefCallback } from "react";
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import type z from "zod";
|
||||
|
||||
import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks";
|
||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -40,6 +41,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
|||
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
|
||||
const debouncedApiCall = useMemo(
|
||||
() =>
|
||||
|
|
|
@ -9,6 +9,8 @@ import type { NextRouter } from "next/router";
|
|||
import { useRouter } from "next/router";
|
||||
import type { ComponentProps, PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks";
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
|
@ -205,6 +207,11 @@ function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
|
|||
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
|
||||
}
|
||||
|
||||
function OrgBrandProvider({ children }: { children: React.ReactNode }) {
|
||||
const orgBrand = useOrgBrandingValues();
|
||||
return <OrgBrandingProvider value={orgBrand}>{children}</OrgBrandingProvider>;
|
||||
}
|
||||
|
||||
const AppProviders = (props: AppPropsWithChildren) => {
|
||||
const session = trpc.viewer.public.session.useQuery().data;
|
||||
// No need to have intercom on public pages - Good for Page Performance
|
||||
|
@ -222,7 +229,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
|||
isThemeSupported={props.Component.isThemeSupported}
|
||||
isBookingPage={props.Component.isBookingPage}>
|
||||
<FeatureFlagsProvider>
|
||||
<MetaProvider>{props.children}</MetaProvider>
|
||||
<OrgBrandProvider>
|
||||
<MetaProvider>{props.children}</MetaProvider>
|
||||
</OrgBrandProvider>
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useRouter } from "next/router";
|
||||
|
||||
export default function useRouterQuery<T extends string>(name: T) {
|
||||
export default function useRouterQuery<T extends string>(name: T, config?: { disabled: boolean }) {
|
||||
const router = useRouter();
|
||||
const existingQueryParams = router.asPath.split("?")[1];
|
||||
|
||||
|
@ -26,8 +26,12 @@ export default function useRouterQuery<T extends string>(name: T) {
|
|||
}
|
||||
|
||||
const setQuery = (newValue: string | number | null | undefined) => {
|
||||
router.replace({ query: { ...router.query, [name]: newValue } }, undefined, { shallow: true });
|
||||
router.replace({ query: { ...router.query, ...query, [name]: newValue } }, undefined, { shallow: true });
|
||||
// Only set query param if it is not disabled
|
||||
if (!config?.disabled) {
|
||||
router.replace({ pathname: router.asPath, query: { ...query, [name]: newValue } }, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return { [name]: query[name], setQuery } as {
|
||||
|
|
|
@ -82,17 +82,18 @@ const middleware: NextMiddleware = async (req) => {
|
|||
|
||||
if (isValidOrgDomain) {
|
||||
// Match /:slug to determine if it corresponds to org subteam slug or org user slug
|
||||
const [first, slug, ...rest] = url.pathname.split("/");
|
||||
const slugs = /^\/([^/]+)(\/[^/]+)?$/.exec(url.pathname);
|
||||
// In the presence of an organization, if not team profile, a user or team is being accessed
|
||||
if (first === "" && rest.length === 0) {
|
||||
if (slugs) {
|
||||
const [_, teamName, eventType] = slugs;
|
||||
// Fetch the corresponding subteams for the entered organization
|
||||
const getSubteams = await fetch(`${WEBAPP_URL}/api/organizations/${currentOrgDomain}/subteams`);
|
||||
if (getSubteams.ok) {
|
||||
const data = await getSubteams.json();
|
||||
// Treat entered slug as a team if found in the subteams fetched
|
||||
if (data.slugs.includes(slug)) {
|
||||
if (data.slugs.includes(teamName)) {
|
||||
// Rewriting towards /team/:slug to bring up the team profile within the org
|
||||
url.pathname = `/team/${slug}`;
|
||||
url.pathname = `/team/${teamName}${eventType ?? ""}`;
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import type { GetStaticPaths, GetStaticPropsContext } from "next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -19,6 +21,12 @@ export type AvailabilityPageProps = inferSSRProps<typeof getStaticProps> & Embed
|
|||
|
||||
export default function Type(props: AvailabilityPageProps) {
|
||||
const { t } = useLocale();
|
||||
const [isValidOrgDomain, setIsValidOrgDomain] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const { isValidOrgDomain } = orgDomainConfig(window.location.host ?? "");
|
||||
setIsValidOrgDomain(isValidOrgDomain);
|
||||
}, []);
|
||||
|
||||
return props.away ? (
|
||||
<div className="dark:bg-inverted h-screen">
|
||||
|
@ -50,6 +58,21 @@ export default function Type(props: AvailabilityPageProps) {
|
|||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : !isValidOrgDomain && props.organizationContext ? (
|
||||
<div className="dark:bg-darkgray-50 h-screen">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
||||
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
|
||||
{" " + t("unavailable")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-md">{t("user_belongs_organization")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
<AvailabilityPage {...props} />
|
||||
);
|
||||
|
@ -87,6 +110,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
|||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
metadata: true,
|
||||
organizationId: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
// Many-to-many relationship causes inclusion of the team events - cool -
|
||||
|
@ -108,6 +132,17 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
|||
schedulingType: true,
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
team: {
|
||||
select: {
|
||||
logo: true,
|
||||
parent: {
|
||||
select: {
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
|
@ -179,6 +214,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
|||
},
|
||||
// Dynamic group has no theme preference right now. It uses system theme.
|
||||
themeBasis: user.username,
|
||||
organizationContext: user?.organizationId !== null,
|
||||
away: user?.away,
|
||||
isDynamic: false,
|
||||
trpcState: ssg.dehydrate(),
|
||||
|
@ -230,6 +266,7 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
|||
defaultScheduleId: true,
|
||||
allowDynamicBooking: true,
|
||||
metadata: true,
|
||||
organizationId: true,
|
||||
away: true,
|
||||
schedules: {
|
||||
select: {
|
||||
|
@ -313,6 +350,7 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
|||
themeBasis: null,
|
||||
isDynamic: true,
|
||||
away: false,
|
||||
organizationContext: !users.some((user) => user.organizationId === null),
|
||||
trpcState: ssg.dehydrate(),
|
||||
isBrandingHidden: false, // I think we should always show branding for dynamic groups - saves us checking every single user
|
||||
},
|
||||
|
|
|
@ -7,6 +7,8 @@ import type { FC } from "react";
|
|||
import { useEffect, useState, memo } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks";
|
||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
||||
|
@ -46,6 +48,7 @@ import {
|
|||
Skeleton,
|
||||
Label,
|
||||
VerticalDivider,
|
||||
Alert,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
ArrowDown,
|
||||
|
@ -61,9 +64,11 @@ import {
|
|||
Trash,
|
||||
Upload,
|
||||
Users,
|
||||
User as UserIcon,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
import { withQuery } from "@lib/QueryCell";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
@ -196,6 +201,7 @@ const MemoizedItem = memo(Item);
|
|||
export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
|
||||
|
@ -364,7 +370,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||
<ul ref={parent} className="divide-subtle !static w-full divide-y" data-testid="event-types">
|
||||
{types.map((type, index) => {
|
||||
const embedLink = `${group.profile.slug}/${type.slug}`;
|
||||
const calLink = `${CAL_URL}/${embedLink}`;
|
||||
const calLink = `${
|
||||
orgBranding ? `${orgBranding.slug}.${subdomainSuffix()}` : CAL_URL
|
||||
}/${embedLink}`;
|
||||
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
|
||||
const isChildrenManagedEventType =
|
||||
type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
|
||||
|
@ -793,6 +801,34 @@ const Actions = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
|
||||
const { t } = useLocale();
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
|
||||
return (
|
||||
<Alert
|
||||
className="my-4"
|
||||
severity="info"
|
||||
title={t("set_up_your_profile")}
|
||||
message={t("set_up_your_profile_description", { orgName: orgBranding?.name })}
|
||||
CustomIcon={UserIcon}
|
||||
actions={
|
||||
<div className="flex gap-1">
|
||||
<Button color="minimal" className="text-sky-700 hover:bg-sky-100" onClick={closeAction}>
|
||||
{t("dismiss")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="border-sky-700 bg-sky-50 text-sky-700 hover:border-sky-900 hover:bg-sky-200"
|
||||
href="/getting-started">
|
||||
{t("set_up")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer as any);
|
||||
|
||||
|
@ -801,12 +837,24 @@ const EventTypesPage = () => {
|
|||
const router = useRouter();
|
||||
const { open } = useIntercom();
|
||||
const { query } = router;
|
||||
const { data: user } = useMeQuery();
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const [showProfileBanner, setShowProfileBanner] = useState(false);
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
|
||||
function closeBanner() {
|
||||
setShowProfileBanner(false);
|
||||
document.cookie = `calcom-profile-banner=1;max-age=${60 * 60 * 24 * 90}`; // 3 months
|
||||
showToast(t("we_wont_show_again"), "success");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (query?.openIntercom && query?.openIntercom === "true") {
|
||||
open();
|
||||
}
|
||||
setShowProfileBanner(
|
||||
!!orgBranding && !document.cookie.includes("calcom-profile-banner=1") && !user?.completedOnboarding
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
@ -821,6 +869,7 @@ const EventTypesPage = () => {
|
|||
heading={t("event_types_page_title")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
|
||||
beforeCTAactions={<Actions />}
|
||||
CTA={<CTA />}>
|
||||
<WithQuery
|
||||
|
|
|
@ -6,6 +6,7 @@ import { Controller, useForm } from "react-hook-form";
|
|||
import { z } from "zod";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
@ -80,6 +81,7 @@ const ProfileView = () => {
|
|||
const utils = trpc.useContext();
|
||||
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||
const { data: avatar, isLoading: isLoadingAvatar } = trpc.viewer.avatar.useQuery();
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
|
@ -223,6 +225,7 @@ const ProfileView = () => {
|
|||
onErrorMutation={() => {
|
||||
showToast(t("error_updating_settings"), "error");
|
||||
}}
|
||||
organization={orgBranding ?? null}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ AddNewTeamsPage.getLayout = (page: React.ReactElement, router: NextRouter) => (
|
|||
currentStep={5}
|
||||
maxSteps={5}
|
||||
isOptionalCallback={() => {
|
||||
router.push(`/getting-started`);
|
||||
router.push(`/event-types`);
|
||||
}}>
|
||||
{page}
|
||||
</WizardLayout>
|
||||
|
|
|
@ -5,8 +5,9 @@ import { useRouter } from "next/router";
|
|||
import { useEffect } from "react";
|
||||
|
||||
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
|
@ -15,6 +16,7 @@ import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
|||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Avatar, AvatarGroup, Button, EmptyScreen, HeadSeo } from "@calcom/ui";
|
||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
|
@ -27,7 +29,7 @@ import Team from "@components/team/screens/Team";
|
|||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
|
||||
function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }: TeamPageProps) {
|
||||
useTheme(team.theme);
|
||||
const showMembers = useToggleQuery("members");
|
||||
const { t } = useLocale();
|
||||
|
@ -49,8 +51,12 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
|
|||
<div className="m-8 flex items-center justify-center">
|
||||
<EmptyScreen
|
||||
avatar={<Avatar alt={teamName} imageSrc={getPlaceholderAvatar(team.logo, team.name)} size="lg" />}
|
||||
headline={t("team_is_unpublished", { team: teamName })}
|
||||
description={t("team_is_unpublished_description")}
|
||||
headline={t("team_is_unpublished", {
|
||||
team: teamName,
|
||||
})}
|
||||
description={t("team_is_unpublished_description", {
|
||||
entity: metadata?.isOrganization ? t("organization").toLowerCase() : t("team").toLowerCase(),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -71,7 +77,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
|
|||
<div className="px-6 py-4 ">
|
||||
<Link
|
||||
href={{
|
||||
pathname: `/team/${team.slug}/${type.slug}`,
|
||||
pathname: `${isValidOrgDomain ? "" : "/team"}/${team.slug}/${type.slug}`,
|
||||
query: queryParamsToForward,
|
||||
}}
|
||||
onClick={async () => {
|
||||
|
@ -106,6 +112,53 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
|
|||
</ul>
|
||||
);
|
||||
|
||||
const SubTeams = () =>
|
||||
team.children.length ? (
|
||||
<ul className="divide-subtle border-subtle bg-default !static w-full divide-y rounded-md border">
|
||||
{team.children.map((ch, i) => (
|
||||
<li key={i} className="hover:bg-muted w-full">
|
||||
<Link href={`/${ch.slug}`} className="flex items-center justify-between">
|
||||
<div className="flex items-center px-5 py-5">
|
||||
<Avatar
|
||||
size="md"
|
||||
imageSrc={getPlaceholderAvatar(ch?.logo, ch?.name as string)}
|
||||
alt="Team Logo"
|
||||
className="inline-flex justify-center"
|
||||
/>
|
||||
<div className="ms-3 inline-block truncate">
|
||||
<span className="text-default text-sm font-bold">{ch.name}</span>
|
||||
<span className="text-subtle block text-xs">
|
||||
{t("number_member", { count: ch.members.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AvatarGroup
|
||||
className="mr-6"
|
||||
size="sm"
|
||||
truncateAfter={4}
|
||||
items={ch.members.map(({ user: member }) => ({
|
||||
alt: member.name || "",
|
||||
image: `${WEBAPP_URL}/${member.username}/avatar.png`,
|
||||
title: member.name || "",
|
||||
}))}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="text-muted dark:text-inverted p-8 text-center">
|
||||
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
|
||||
{" " + t("no_teams_yet")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-md">{t("no_teams_yet_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeadSeo
|
||||
|
@ -118,8 +171,17 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
|
|||
/>
|
||||
<main className="dark:bg-darkgray-50 bg-subtle mx-auto max-w-3xl rounded-md px-4 pt-12 pb-12">
|
||||
<div className="mx-auto mb-8 max-w-3xl text-center">
|
||||
<Avatar alt={teamName} imageSrc={getPlaceholderAvatar(team.logo, team.name)} size="lg" />
|
||||
<p className="font-cal text-emphasis mb-2 text-2xl tracking-wider">{teamName}</p>
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
alt={teamName}
|
||||
imageSrc={getPlaceholderAvatar(team.parent ? team.parent.logo : team.logo, team.name)}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<p className="font-cal text-emphasis mb-2 text-2xl tracking-wider">
|
||||
{team.parent && `${team.parent.name} `}
|
||||
{teamName}
|
||||
</p>
|
||||
{!isBioEmpty && (
|
||||
<>
|
||||
<div
|
||||
|
@ -129,43 +191,49 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
|
||||
{!showMembers.isOn && team.eventTypes.length > 0 && (
|
||||
<div className="mx-auto max-w-3xl ">
|
||||
<EventTypes />
|
||||
{metadata?.isOrganization ? (
|
||||
<SubTeams />
|
||||
) : (
|
||||
<>
|
||||
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
|
||||
{!showMembers.isOn && team.eventTypes.length > 0 && (
|
||||
<div className="mx-auto max-w-3xl ">
|
||||
<EventTypes />
|
||||
|
||||
{!team.hideBookATeamMember && (
|
||||
<div>
|
||||
<div className="relative mt-12">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="border-subtle w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="dark:bg-darkgray-50 bg-subtle text-subtle dark:text-inverted px-2 text-sm">
|
||||
{t("or")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!team.hideBookATeamMember && (
|
||||
<div>
|
||||
<div className="relative mt-12">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="border-subtle w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="dark:bg-darkgray-50 bg-subtle text-subtle dark:text-inverted px-2 text-sm">
|
||||
{t("or")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="dark:text-inverted mt-8 flex justify-center text-center">
|
||||
<Button
|
||||
color="minimal"
|
||||
EndIcon={ArrowRight}
|
||||
className="dark:hover:bg-darkgray-200"
|
||||
href={{
|
||||
pathname: `/team/${team.slug}`,
|
||||
query: {
|
||||
members: "1",
|
||||
...queryParamsToForward,
|
||||
},
|
||||
}}
|
||||
shallow={true}>
|
||||
{t("book_a_team_member")}
|
||||
</Button>
|
||||
</aside>
|
||||
<aside className="dark:text-inverted mt-8 flex justify-center text-center">
|
||||
<Button
|
||||
color="minimal"
|
||||
EndIcon={ArrowRight}
|
||||
className="dark:hover:bg-darkgray-200"
|
||||
href={{
|
||||
pathname: `${isValidOrgDomain ? "" : "/team"}/${team.slug}`,
|
||||
query: {
|
||||
members: "1",
|
||||
...queryParamsToForward,
|
||||
},
|
||||
}}
|
||||
shallow={true}>
|
||||
{t("book_a_team_member")}
|
||||
</Button>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
|
@ -175,8 +243,19 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
|
|||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
|
||||
const { isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
|
||||
const team = await getTeamWithMembers(undefined, slug);
|
||||
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
|
||||
|
||||
// Taking care of sub-teams and orgs
|
||||
if (
|
||||
(isValidOrgDomain && team?.parent && !!metadata?.isOrganization) ||
|
||||
(!isValidOrgDomain && team?.parent) ||
|
||||
(!isValidOrgDomain && !!metadata?.isOrganization)
|
||||
) {
|
||||
return { notFound: true } as const;
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
const unpublishedTeam = await prisma.team.findFirst({
|
||||
|
@ -222,6 +301,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
themeBasis: team.slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
markdownStrippedBio,
|
||||
isValidOrgDomain,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
|
|
@ -76,7 +76,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
},
|
||||
},
|
||||
},
|
||||
|
||||
title: true,
|
||||
availability: true,
|
||||
description: true,
|
||||
|
@ -132,6 +131,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
},
|
||||
},
|
||||
},
|
||||
parent: {
|
||||
select: {
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -86,6 +86,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
parent: {
|
||||
select: {
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
|
|
|
@ -540,6 +540,8 @@
|
|||
"team_description": "A few sentences about your team. This will appear on your team's url page.",
|
||||
"members": "Members",
|
||||
"member": "Member",
|
||||
"number_member_one": "{{count}} member",
|
||||
"number_member_other": "{{count}} members",
|
||||
"owner": "Owner",
|
||||
"admin": "Admin",
|
||||
"administrator_user": "Administrator user",
|
||||
|
@ -1658,7 +1660,7 @@
|
|||
"show_on_booking_page":"Show on booking page",
|
||||
"get_started_zapier_templates": "Get started with Zapier templates",
|
||||
"team_is_unpublished": "{{team}} is unpublished",
|
||||
"team_is_unpublished_description": "This team link is currently not available. Please contact the team owner or ask them publish it.",
|
||||
"team_is_unpublished_description": "This {{entity}} link is currently not available. Please contact the {{entity}} owner or ask them publish it.",
|
||||
"team_member": "Team member",
|
||||
"a_routing_form": "A Routing Form",
|
||||
"form_description_placeholder": "Form Description",
|
||||
|
@ -1860,6 +1862,12 @@
|
|||
"duplicated_slugs_warning": "The following teams couldn't be created due to duplicated slugs: {{slugs}}",
|
||||
"team_names_empty": "Team names can't be empty",
|
||||
"team_names_repeated": "Team names can't be repeated",
|
||||
"user_belongs_organization": "User belongs to an organization",
|
||||
"no_teams_yet": "This organization has no teams yet",
|
||||
"no_teams_yet_description": "if you are an administrator, be sure to create teams to be shown here.",
|
||||
"set_up": "Set up",
|
||||
"set_up_your_profile": "Set up your profile",
|
||||
"set_up_your_profile_description": "Let people know who you are within {{orgName}}, and when they engage with your public link.",
|
||||
"sender_id_info": "Name or number shown as the sender of an SMS (some countries do not allow alphanumeric sender IDs)",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -72,6 +72,7 @@ export async function getServerSession(options: {
|
|||
image: `${CAL_URL}/${user.username}/avatar.png`,
|
||||
impersonatedByUID: token.impersonatedByUID ?? undefined,
|
||||
belongsToActiveTeam: token.belongsToActiveTeam,
|
||||
organizationId: token.organizationId,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -400,6 +400,7 @@ export const AUTH_OPTIONS: AuthOptions = {
|
|||
role: user.role,
|
||||
impersonatedByUID: user?.impersonatedByUID,
|
||||
belongsToActiveTeam: user?.belongsToActiveTeam,
|
||||
organizationId: user?.organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -437,6 +438,7 @@ export const AUTH_OPTIONS: AuthOptions = {
|
|||
role: existingUser.role,
|
||||
impersonatedByUID: token.impersonatedByUID as number,
|
||||
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||
organizationId: token?.organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -455,6 +457,7 @@ export const AUTH_OPTIONS: AuthOptions = {
|
|||
role: token.role as UserPermissionRole,
|
||||
impersonatedByUID: token.impersonatedByUID as number,
|
||||
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||
organizationId: token?.organizationId,
|
||||
},
|
||||
};
|
||||
return calendsoSession;
|
||||
|
|
|
@ -10,7 +10,7 @@ const teamIdschema = z.object({
|
|||
});
|
||||
|
||||
const auditAndReturnNextUser = async (
|
||||
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role">,
|
||||
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role" | "organizationId">,
|
||||
impersonatedByUID: number,
|
||||
hasTeam?: boolean
|
||||
) => {
|
||||
|
@ -38,6 +38,7 @@ const auditAndReturnNextUser = async (
|
|||
role: impersonatedUser.role,
|
||||
impersonatedByUID,
|
||||
belongsToActiveTeam: hasTeam,
|
||||
organizationId: impersonatedUser.organizationId,
|
||||
};
|
||||
|
||||
return obj;
|
||||
|
@ -79,6 +80,7 @@ const ImpersonationProvider = CredentialsProvider({
|
|||
role: true,
|
||||
name: true,
|
||||
email: true,
|
||||
organizationId: true,
|
||||
disableImpersonation: true,
|
||||
teams: {
|
||||
where: {
|
||||
|
|
|
@ -6,7 +6,7 @@ import z from "zod";
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar, Button, Form, ImageUploader, Alert, Label, TextAreaField } from "@calcom/ui";
|
||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
import { ArrowRight, Plus } from "@calcom/ui/components/icon";
|
||||
|
||||
const querySchema = z.object({
|
||||
id: z.string(),
|
||||
|
@ -60,7 +60,13 @@ export const AboutOrganizationForm = () => {
|
|||
<>
|
||||
<Label>{t("organization_logo")}</Label>
|
||||
<div className="flex items-center">
|
||||
<Avatar alt="" imageSrc={image || "/org_avatar.png"} size="lg" />
|
||||
<Avatar
|
||||
alt=""
|
||||
fallback={<Plus className="text-subtle h-6 w-6" />}
|
||||
asChild
|
||||
className="items-center"
|
||||
size="lg"
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
|
|
|
@ -43,10 +43,10 @@ export const AddNewTeamsForm = () => {
|
|||
if (data.duplicatedSlugs.length) {
|
||||
showToast(t("duplicated_slugs_warning", { slugs: data.duplicatedSlugs.join(", ") }), "warning");
|
||||
setTimeout(() => {
|
||||
router.push(`/getting-started`);
|
||||
router.push(`/event-types`);
|
||||
}, 3000);
|
||||
} else {
|
||||
router.push(`/getting-started`);
|
||||
router.push(`/event-types`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import { createContext, useContext, createElement } from "react";
|
||||
|
||||
/**
|
||||
* Organization branding
|
||||
*
|
||||
* Entries consist of the different properties that constitues a brand for an organization.
|
||||
*/
|
||||
export type OrganizationBranding =
|
||||
| {
|
||||
logo: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Allows you to access the flags from context
|
||||
*/
|
||||
const OrganizationBrandingContext = createContext<OrganizationBranding | null>(null);
|
||||
|
||||
/**
|
||||
* Accesses the branding for an organization from context.
|
||||
*
|
||||
* You need to render a <OrgBrandingProvider /> further up to be able to use
|
||||
* this component.
|
||||
*/
|
||||
export function useOrgBranding() {
|
||||
const orgBrandingContext = useContext(OrganizationBrandingContext);
|
||||
if (orgBrandingContext === null)
|
||||
throw new Error("Error: useOrganizationBranding was used outside of OrgBrandingProvider.");
|
||||
return orgBrandingContext as OrganizationBranding;
|
||||
}
|
||||
|
||||
/**
|
||||
* If you want to be able to access the flags from context using `useOrganizationBranding()`,
|
||||
* you can render the OrgBrandingProvider at the top of your Next.js pages, like so:
|
||||
*
|
||||
* ```ts
|
||||
* import { useOrgBrandingValues } from "@calcom/features/flags/hooks/useFlag"
|
||||
* import { OrgBrandingProvider, useOrgBranding } from @calcom/features/flags/context/provider"
|
||||
*
|
||||
* export default function YourPage () {
|
||||
* const orgBrand = useOrgBrandingValues()
|
||||
*
|
||||
* return (
|
||||
* <OrgBrandingProvider value={orgBrand}>
|
||||
* <YourOwnComponent />
|
||||
* </OrgBrandingProvider>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* You can then call `useOrgBrandingValues()` to access your `OrgBranding` from within
|
||||
* `YourOwnComponent` or further down.
|
||||
*
|
||||
*/
|
||||
export function OrgBrandingProvider<F extends OrganizationBranding>(props: {
|
||||
value: F;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return createElement(OrganizationBrandingContext.Provider, { value: props.value }, props.children);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
export function useOrgBrandingValues() {
|
||||
return trpc.viewer.organizations.getBrand.useQuery().data;
|
||||
}
|
|
@ -12,6 +12,7 @@ import dayjs from "@calcom/dayjs";
|
|||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookingBadge";
|
||||
import ImpersonatingBanner from "@calcom/features/ee/impersonation/components/ImpersonatingBanner";
|
||||
import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks";
|
||||
import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem";
|
||||
import { TeamsUpgradeBanner } from "@calcom/features/ee/teams/components";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
|
@ -31,6 +32,7 @@ import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
|
|||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Credits,
|
||||
Dropdown,
|
||||
|
@ -90,8 +92,14 @@ export const ONBOARDING_NEXT_REDIRECT = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
export const shouldShowOnboarding = (user: Pick<User, "createdDate" | "completedOnboarding">) => {
|
||||
return !user.completedOnboarding && dayjs(user.createdDate).isAfter(ONBOARDING_INTRODUCED_AT);
|
||||
export const shouldShowOnboarding = (
|
||||
user: Pick<User, "createdDate" | "completedOnboarding" | "organizationId">
|
||||
) => {
|
||||
return (
|
||||
!user.completedOnboarding &&
|
||||
!user.organizationId &&
|
||||
dayjs(user.createdDate).isAfter(ONBOARDING_INTRODUCED_AT)
|
||||
);
|
||||
};
|
||||
|
||||
function useRedirectToLoginIfUnauthenticated(isPublic = false) {
|
||||
|
@ -229,6 +237,7 @@ type LayoutProps = {
|
|||
// Gives the ability to include actions to the right of the heading
|
||||
actions?: JSX.Element;
|
||||
beforeCTAactions?: JSX.Element;
|
||||
afterHeading?: ReactNode;
|
||||
smallHeading?: boolean;
|
||||
hideHeadingOnMobile?: boolean;
|
||||
};
|
||||
|
@ -282,6 +291,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
|||
const { t } = useLocale();
|
||||
const { data: user } = useMeQuery();
|
||||
const { data: avatar } = useAvatarQuery();
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
|
@ -349,8 +359,8 @@ function UserDropdown({ small }: { small?: boolean }) {
|
|||
<span className="text-default truncate pb-1 font-normal">
|
||||
{user.username
|
||||
? process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com"
|
||||
? `cal.com/${user.username}`
|
||||
: `/${user.username}`
|
||||
? `${orgBranding && orgBranding.slug}cal.com/${user.username}`
|
||||
: `${orgBranding && orgBranding.slug}/${user.username}`
|
||||
: "No public page"}
|
||||
</span>
|
||||
</span>
|
||||
|
@ -790,6 +800,7 @@ function SideBarContainer({ bannersHeight }: SideBarContainerProps) {
|
|||
}
|
||||
|
||||
function SideBar({ bannersHeight }: SideBarProps) {
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
return (
|
||||
<div className="relative">
|
||||
<aside
|
||||
|
@ -798,7 +809,14 @@ function SideBar({ bannersHeight }: SideBarProps) {
|
|||
<div className="flex h-full flex-col justify-between py-3 lg:pt-6 ">
|
||||
<header className="items-center justify-between md:hidden lg:flex">
|
||||
<Link href="/event-types" className="px-2">
|
||||
<Logo small />
|
||||
{orgBranding ? (
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{orgBranding.logo && <Avatar alt="" imageSrc={orgBranding.logo} size="sm" />}
|
||||
<p className="text text-sm">{orgBranding.name}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Logo small />
|
||||
)}
|
||||
</Link>
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<button
|
||||
|
@ -906,6 +924,7 @@ export function ShellMain(props: LayoutProps) {
|
|||
</header>
|
||||
)}
|
||||
</div>
|
||||
{props.afterHeading && <>{props.afterHeading}</>}
|
||||
<div className={classNames(props.flexChildrenContainer && "flex flex-1 flex-col")}>
|
||||
{props.children}
|
||||
</div>
|
||||
|
|
|
@ -23,7 +23,7 @@ export const defaultAvatarSrc = function ({ email, md5 }: { md5?: string; email?
|
|||
* a name. It is used here to provide a consistent placeholder avatar for users
|
||||
* who have not uploaded an avatar.
|
||||
*/
|
||||
export function getPlaceholderAvatar(avatar: string | null | undefined, name: string | null) {
|
||||
export function getPlaceholderAvatar(avatar: string | null | undefined, name: string | null | undefined) {
|
||||
return avatar
|
||||
? avatar
|
||||
: "https://eu.ui-avatars.com/api/?background=fff&color=f9f9f9&bold=true&background=000000&name=" +
|
||||
|
|
|
@ -25,6 +25,29 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
|
|||
hideBranding: true,
|
||||
hideBookATeamMember: true,
|
||||
metadata: true,
|
||||
parent: {
|
||||
select: {
|
||||
name: true,
|
||||
logo: true,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
select: {
|
||||
name: true,
|
||||
logo: true,
|
||||
slug: true,
|
||||
members: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
members: {
|
||||
select: {
|
||||
accepted: true,
|
||||
|
|
|
@ -57,6 +57,17 @@ export const bookEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
|||
},
|
||||
},
|
||||
successRedirectUrl: true,
|
||||
team: {
|
||||
select: {
|
||||
logo: true,
|
||||
parent: {
|
||||
select: {
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const availiblityPageEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
|
@ -103,4 +114,15 @@ export const availiblityPageEventTypeSelect = Prisma.validator<Prisma.EventTypeS
|
|||
timeZone: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
logo: true,
|
||||
parent: {
|
||||
select: {
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -39,5 +39,6 @@ export const meHandler = async ({ ctx }: MeOptions) => {
|
|||
metadata: user.metadata,
|
||||
defaultBookerLayouts: user.defaultBookerLayouts,
|
||||
allowDynamicBooking: user.allowDynamicBooking,
|
||||
organizationId: user.organizationId,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -12,6 +12,7 @@ type OrganizationsRouterHandlerCache = {
|
|||
verifyCode?: typeof import("./verifyCode.handler").verifyCodeHandler;
|
||||
createTeams?: typeof import("./createTeams.handler").createTeamsHandler;
|
||||
setPassword?: typeof import("./setPassword.handler").setPasswordHandler;
|
||||
getBrand?: typeof import("./getBrand.handler").getBrandHandler;
|
||||
};
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: OrganizationsRouterHandlerCache = {};
|
||||
|
@ -98,4 +99,18 @@ export const viewerOrganizationsRouter = router({
|
|||
input,
|
||||
});
|
||||
}),
|
||||
getBrand: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.getBrand) {
|
||||
UNSTABLE_HANDLER_CACHE.getBrand = await import("./getBrand.handler").then((mod) => mod.getBrandHandler);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.getBrand) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.getBrand({
|
||||
ctx,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import { prisma } from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type VerifyCodeOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
export const getBrandHandler = async ({ ctx }: VerifyCodeOptions) => {
|
||||
const { user } = ctx;
|
||||
|
||||
if (!user.organizationId) return null;
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: user.organizationId,
|
||||
},
|
||||
select: {
|
||||
logo: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
const metadata = teamMetadataSchema.parse(team?.metadata);
|
||||
const slug = team?.slug || metadata?.requestedSlug;
|
||||
|
||||
return {
|
||||
...team,
|
||||
metadata,
|
||||
slug,
|
||||
};
|
||||
};
|
Loading…
Reference in New Issue
Block a user