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:
Leo Giovanetti 2023-06-12 13:46:56 -03:00 committed by GitHub
parent 174cf3a3e4
commit dc1f1b5db9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 471 additions and 65 deletions

View File

@ -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>

View File

@ -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(
() =>

View File

@ -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>

View File

@ -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 {

View File

@ -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);
}
}

View File

@ -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
},

View File

@ -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

View File

@ -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>
}

View File

@ -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>

View File

@ -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;
};

View File

@ -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,
},
},
},
},
},

View File

@ -86,6 +86,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
theme: true,
brandColor: true,
darkBrandColor: true,
parent: {
select: {
logo: true,
name: true,
},
},
},
},
users: {

View File

@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -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,
},
};

View File

@ -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;

View File

@ -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: {

View File

@ -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"

View File

@ -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) => {

View File

@ -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);
}

View File

@ -0,0 +1,5 @@
import { trpc } from "@calcom/trpc/react";
export function useOrgBrandingValues() {
return trpc.viewer.organizations.getBrand.useQuery().data;
}

View File

@ -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>

View File

@ -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=" +

View File

@ -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,

View File

@ -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,
},
},
},
},
});

View File

@ -39,5 +39,6 @@ export const meHandler = async ({ ctx }: MeOptions) => {
metadata: user.metadata,
defaultBookerLayouts: user.defaultBookerLayouts,
allowDynamicBooking: user.allowDynamicBooking,
organizationId: user.organizationId,
};
};

View File

@ -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,
});
}),
});

View File

@ -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,
};
};