feat: organizations sidebar (#9395)

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Efraín Rochín <roae.85@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
Leo Giovanetti 2023-06-15 07:42:47 -03:00 committed by GitHub
parent 955de3133c
commit b4fa9826f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 204 additions and 125 deletions

View File

@ -239,7 +239,7 @@
"all_done": "All done!",
"all_apps": "All",
"all": "All",
"yours":"Yours",
"yours": "Yours",
"available_apps": "Available Apps",
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
"finish": "Finish",
@ -250,8 +250,8 @@
"set_availability": "Set your availability",
"continue_without_calendar": "Continue without calendar",
"connect_your_calendar": "Connect your calendar",
"connect_your_video_app":"Connect your video apps",
"connect_your_video_app_instructions":"Connect your video apps to use them on your event types.",
"connect_your_video_app": "Connect your video apps",
"connect_your_video_app_instructions": "Connect your video apps to use them on your event types.",
"connect_your_calendar_instructions": "Connect your calendar to automatically check for busy times and new events as theyre scheduled.",
"set_up_later": "Set up later",
"current_time": "Current time",
@ -392,10 +392,10 @@
"create_webhook": "Create Webhook",
"booking_cancelled": "Booking Cancelled",
"booking_rescheduled": "Booking Rescheduled",
"recording_ready":"Recording Download Link Ready",
"recording_ready": "Recording Download Link Ready",
"booking_created": "Booking Created",
"booking_rejected":"Booking Rejected",
"booking_requested":"Booking Requested",
"booking_rejected": "Booking Rejected",
"booking_requested": "Booking Requested",
"meeting_ended": "Meeting Ended",
"form_submitted": "Form Submitted",
"event_triggers": "Event Triggers",
@ -926,7 +926,7 @@
"duplicate": "Duplicate",
"offer_seats": "Offer seats",
"offer_seats_description": "Offer seats for booking. This automatically disables guest & opt-in bookings.",
"seats_available_one":"Seat available",
"seats_available_one": "Seat available",
"seats_available_other": "Seats available",
"number_of_seats": "Number of seats per booking",
"enter_number_of_seats": "Enter number of seats",
@ -1290,7 +1290,7 @@
"download_responses_description": "Download all responses to your form in CSV format.",
"download": "Download",
"download_recording": "Download Recording",
"recording_from_your_recent_call":"A recording from your recent call on {{appName}} is ready for download",
"recording_from_your_recent_call": "A recording from your recent call on {{appName}} is ready for download",
"create_your_first_form": "Create your first form",
"create_your_first_form_description": "With Routing Forms you can ask qualifying questions and route to the correct person or event type.",
"create_your_first_webhook": "Create your first Webhook",
@ -1478,7 +1478,7 @@
"fixed_round_robin": "Fixed round robin",
"add_one_fixed_attendee": "Add one fixed attendee and round robin through a number of attendees.",
"calcom_is_better_with_team": "{{appName}} is better with teams",
"the_calcom_team":"The {{companyName}} team",
"the_calcom_team": "The {{companyName}} team",
"add_your_team_members": "Add your team members to your event types. Use collective scheduling to include everyone or find the most suitable person with round robin scheduling.",
"booking_limit_reached": "Booking Limit for this event type has been reached",
"duration_limit_reached": "Duration Limit for this event type has been reached",
@ -1663,7 +1663,7 @@
"booking_confirmation_failed": "Booking confirmation failed",
"not_enough_seats": "Not enough seats",
"form_builder_field_already_exists": "A field with this name already exists",
"show_on_booking_page":"Show on booking page",
"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 {{entity}} link is currently not available. Please contact the {{entity}} owner or ask them publish it.",
@ -1828,9 +1828,9 @@
"disable_host_confirmation_emails": "Disable default confirmation emails for host",
"disable_host_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the host when the event is booked.",
"add_an_override": "Add an override",
"import_from_google_workspace":"Import users from Google Workspace",
"connect_google_workspace":"Connect Google Workspace",
"google_workspace_admin_tooltip":"You must be a Workspace Admin to use this feature",
"import_from_google_workspace": "Import users from Google Workspace",
"connect_google_workspace": "Connect Google Workspace",
"google_workspace_admin_tooltip": "You must be a Workspace Admin to use this feature",
"first_event_type_webhook_description": "Create your first webhook for this event type",
"create_for": "Create for",
"setup_organization": "Setup an Organization",
@ -1873,6 +1873,8 @@
"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.",
"my_profile": "My Profile",
"my_settings": "My Settings",
"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

@ -273,7 +273,7 @@ export const KBarTrigger = () => {
<button
color="minimal"
onClick={query.toggle}
className="text-default hover:bg-subtle lg:hover:bg-emphasis lg:hover:text-emphasis group flex rounded-md py-2 px-3 text-sm font-medium lg:p-1">
className="text-default hover:bg-subtle lg:hover:bg-emphasis lg:hover:text-emphasis group flex rounded-md px-3 py-2 text-sm font-medium lg:px-2">
<Search className="h-4 w-4 flex-shrink-0 text-inherit" />
</button>
</Tooltip>

View File

@ -1,4 +1,4 @@
import type { User } from "@prisma/client";
import type { User as UserAuth } from "next-auth";
import { signOut, useSession } from "next-auth/react";
import dynamic from "next/dynamic";
import Link from "next/link";
@ -26,6 +26,7 @@ import getBrandColours from "@calcom/lib/getBrandColours";
import { useIsomorphicLayoutEffect } from "@calcom/lib/hooks/useIsomorphicLayoutEffect";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
import type { User } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
import useAvatarQuery from "@calcom/trpc/react/hooks/useAvatarQuery";
import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
@ -49,6 +50,7 @@ import {
Tooltip,
showToast,
useCalcomTheme,
ButtonOrLink,
} from "@calcom/ui";
import {
ArrowLeft,
@ -66,11 +68,13 @@ import {
Map,
Moon,
MoreHorizontal,
MoreVertical,
ChevronDown,
Copy,
Settings,
Slack,
Users,
Zap,
User as UserIcon,
} from "@calcom/ui/components/icon";
import FreshChatProvider from "../ee/support/lib/freshchat/FreshChatProvider";
@ -287,12 +291,14 @@ export default function Shell(props: LayoutProps) {
);
}
function UserDropdown({ small }: { small?: boolean }) {
interface UserDropdownProps {
small?: boolean;
}
function UserDropdown({ small }: UserDropdownProps) {
const { t } = useLocale();
const { data: user } = useMeQuery();
const { data: avatar } = useAvatarQuery();
const orgBranding = useOrgBrandingValues();
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
@ -329,43 +335,36 @@ function UserDropdown({ small }: { small?: boolean }) {
<Dropdown open={menuOpen}>
<div className="ltr:sm:-ml-5 rtl:sm:-mr-5">
<DropdownMenuTrigger asChild onClick={() => setMenuOpen((menuOpen) => !menuOpen)}>
<button className="radix-state-open:bg-emphasis hover:bg-emphasis group mx-0 flex w-full cursor-pointer appearance-none items-center rounded-full p-2 text-left outline-none focus:outline-none focus:ring-0 sm:mx-2.5 sm:pl-3 md:rounded-none lg:rounded lg:pl-2">
<button
className={classNames(
"hover:bg-emphasis group mx-0 ml-7 flex cursor-pointer appearance-none items-center rounded-full text-left outline-none focus:outline-none focus:ring-0 md:rounded-none lg:rounded",
small ? "p-2" : "px-2 py-1"
)}>
<span
className={classNames(
small ? "h-6 w-6 md:ml-3" : "h-8 w-8 ltr:mr-2 rtl:ml-2",
small ? "h-4 w-4" : "h-6 w-6 ltr:mr-2 rtl:ml-2",
"relative flex-shrink-0 rounded-full bg-gray-300 "
)}>
{
// eslint-disable-next-line @next/next/no-img-element
<img
className="rounded-full"
src={avatar?.avatar || WEBAPP_URL + "/" + user.username + "/avatar.png"}
alt={user.username || "Nameless User"}
/>
}
{!user.away && (
<div className="border-muted absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 bg-green-500" />
)}
{user.away && (
<div className="border-muted absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 bg-yellow-500" />
)}
<Avatar
size={small ? "xs" : "sm"}
imageSrc={avatar?.avatar || WEBAPP_URL + "/" + user.username + "/avatar.png"}
alt={user.username || "Nameless User"}
/>
<span
className={classNames(
"border-muted absolute -bottom-1 -right-1 rounded-full border-2 bg-green-500",
user.away ? "bg-yellow-500" : "bg-green-500",
small ? "-bottom-0.5 -right-0.5 h-2.5 w-2.5" : "bottom-0 right-0 h-3 w-3"
)}
/>
</span>
{!small && (
<span className="flex flex-grow items-center truncate">
<span className="flex-grow truncate text-sm leading-none">
<span className="text-emphasis mb-1 block truncate font-medium">
{user.name || "Nameless User"}
</span>
<span className="text-default truncate pb-1 font-normal">
{user.username
? process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com"
? `${orgBranding && orgBranding.slug}cal.com/${user.username}`
: `${orgBranding && orgBranding.slug}/${user.username}`
: "No public page"}
</span>
<span className="flex flex-grow items-center">
<span className="line-clamp-1 flex-grow text-sm leading-none">
<span className="text-emphasis block font-medium">{user.name || "Nameless User"}</span>
</span>
<MoreVertical
className="group-hover:text-subtle text-muted h-4 w-4 flex-shrink-0 ltr:mr-2 rtl:ml-2 rtl:mr-4"
<ChevronDown
className="group-hover:text-subtle text-muted h-4 w-4 flex-shrink-0 rtl:mr-4"
aria-hidden="true"
/>
</span>
@ -377,6 +376,7 @@ function UserDropdown({ small }: { small?: boolean }) {
<DropdownMenuPortal>
<FreshChatProvider>
<DropdownMenuContent
align="start"
onInteractOutside={() => {
setMenuOpen(false);
setHelpOpen(false);
@ -386,6 +386,26 @@ function UserDropdown({ small }: { small?: boolean }) {
<HelpMenuItem onHelpItemSelect={() => onHelpItemSelect()} />
) : (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon={(props) => (
<UserIcon className={classNames("text-default", props.className)} aria-hidden="true" />
)}
href="/settings/my-account/profile">
{t("my_profile")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon={(props) => (
<Settings className={classNames("text-default", props.className)} aria-hidden="true" />
)}
href="/settings/my-account/general">
{t("my_settings")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
@ -400,34 +420,6 @@ function UserDropdown({ small }: { small?: boolean }) {
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
{user.username && (
<>
<DropdownMenuItem>
<DropdownItem
target="_blank"
rel="noopener noreferrer"
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}`}
StartIcon={ExternalLink}>
{t("view_public_page")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon={LinkIcon}
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(
`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}`
);
showToast(t("link_copied"), "success");
}}>
{t("copy_public_page_link")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem
StartIcon={(props) => <Slack strokeWidth={1.5} {...props} />}
@ -458,12 +450,6 @@ function UserDropdown({ small }: { small?: boolean }) {
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem type="button" href="/settings/my-account/profile" StartIcon={Settings}>
{t("settings")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
@ -484,6 +470,8 @@ function UserDropdown({ small }: { small?: boolean }) {
export type NavigationItemType = {
name: string;
href: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>;
target?: HTMLAnchorElement["target"];
badge?: React.ReactNode;
icon?: SVGComponent;
child?: NavigationItemType[];
@ -495,7 +483,7 @@ export type NavigationItemType = {
isChild,
router,
}: {
item: NavigationItemType;
item: Pick<NavigationItemType, "href">;
isChild?: boolean;
router: NextRouter;
}) => boolean;
@ -590,11 +578,6 @@ const navigation: NavigationItemType[] = [
href: "/insights",
icon: BarChart,
},
{
name: "settings",
href: "/settings/my-account/profile",
icon: Settings,
},
];
const moreSeparatorIndex = navigation.findIndex((item) => item.name === MORE_SEPARATOR_NAME);
@ -606,9 +589,12 @@ const { desktopNavigationItems, mobileNavigationBottomItems, mobileNavigationMor
// We filter out the "more" separator in` desktop navigation
if (item.name !== MORE_SEPARATOR_NAME) items.desktopNavigationItems.push(item);
// Items for mobile bottom navigation
if (index < moreSeparatorIndex + 1 && !item.onlyDesktop) items.mobileNavigationBottomItems.push(item);
// Items for the "more" menu in mobile navigation
else items.mobileNavigationMoreItems.push(item);
if (index < moreSeparatorIndex + 1 && !item.onlyDesktop) {
items.mobileNavigationBottomItems.push(item);
} // Items for the "more" menu in mobile navigation
else {
items.mobileNavigationMoreItems.push(item);
}
return items;
},
{ desktopNavigationItems: [], mobileNavigationBottomItems: [], mobileNavigationMoreItems: [] }
@ -642,7 +628,7 @@ function useShouldDisplayNavigationItem(item: NavigationItemType) {
}
const defaultIsCurrent: NavigationItemType["isCurrent"] = ({ isChild, item, router }) => {
return isChild ? item.href === router.asPath : router.asPath.startsWith(item.href);
return isChild ? item.href === router.asPath : item.href ? router.asPath.startsWith(item.href) : false;
};
const NavigationItem: React.FC<{
@ -785,10 +771,11 @@ type SideBarContainerProps = {
type SideBarProps = {
bannersHeight: number;
user?: UserAuth | null;
};
function SideBarContainer({ bannersHeight }: SideBarContainerProps) {
const { status } = useSession();
const { status, data } = useSession();
const router = useRouter();
// Make sure that Sidebar is rendered optimistically so that a refresh of pages when logged in have SideBar from the beginning.
@ -796,29 +783,83 @@ function SideBarContainer({ bannersHeight }: SideBarContainerProps) {
// Though when logged out, app store pages would temporarily show SideBar until session status is confirmed.
if (status !== "loading" && status !== "authenticated") return null;
if (router.route.startsWith("/v2/settings/")) return null;
return <SideBar bannersHeight={bannersHeight} />;
return <SideBar bannersHeight={bannersHeight} user={data?.user} />;
}
function SideBar({ bannersHeight }: SideBarProps) {
const getOrganizationUrl = (slug: string) =>
`${slug}.${process.env.NEXT_PUBLIC_WEBSITE_URL?.replace?.(/http(s*):\/\//, "")}`;
function SideBar({ bannersHeight, user }: SideBarProps) {
const { t, isLocaleReady } = useLocale();
const router = useRouter();
const orgBranding = useOrgBrandingValues();
const publicPageUrl = orgBranding?.slug ? getOrganizationUrl(orgBranding?.slug) : "";
const bottomNavItems: NavigationItemType[] = [
...(user?.username
? [
{
name: "view_public_page",
href: !!user?.organizationId
? publicPageUrl
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}`,
icon: ExternalLink,
target: "__blank",
},
{
name: "copy_public_page_link",
href: "",
onClick: (e: { preventDefault: () => void }) => {
e.preventDefault();
navigator.clipboard.writeText(
!!user?.organizationId
? publicPageUrl
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}`
);
showToast(t("link_copied"), "success");
},
icon: Copy,
},
]
: []),
{
name: "settings",
href: user?.organizationId
? `/settings/teams/${user.organizationId}/profile`
: "/settings/my-account/profile",
icon: Settings,
},
];
return (
<div className="relative">
<aside
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
className="desktop-transparent bg-muted border-muted fixed left-0 hidden h-full max-h-screen w-14 flex-col overflow-y-auto overflow-x-hidden border-r dark:bg-gradient-to-tr dark:from-[#2a2a2a] dark:to-[#1c1c1c] md:sticky md:flex lg:w-56 lg:px-4">
className="desktop-transparent bg-muted border-muted fixed left-0 hidden h-full max-h-screen w-14 flex-col overflow-y-auto overflow-x-hidden border-r dark:bg-gradient-to-tr dark:from-[#2a2a2a] dark:to-[#1c1c1c] md:sticky md:flex lg:w-56 lg:px-3">
<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">
{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">
{orgBranding ? (
<Link href="/event-types" className="px-2">
{orgBranding ? (
<div className="flex items-center gap-2 font-medium">
{orgBranding.logo && <Avatar alt="" imageSrc={orgBranding.logo} size="sm" />}
<p className="text line-clamp-1 text-sm">
<span>{orgBranding.name}</span>
</p>
</div>
) : (
<Logo small />
)}
</Link>
) : (
<div data-testid="user-dropdown-trigger">
<span className="hidden lg:inline">
<UserDropdown />
</span>
<span className="hidden md:inline lg:hidden">
<UserDropdown small />
</span>
</div>
)}
<div className="flex space-x-1 rtl:space-x-reverse">
<button
color="minimal"
onClick={() => window.history.back()}
@ -831,6 +872,11 @@ function SideBar({ bannersHeight }: SideBarProps) {
className="desktop-only hover:text-emphasis text-subtle group flex text-sm font-medium">
<ArrowRight className="group-hover:text-emphasis text-subtle h-4 w-4 flex-shrink-0" />
</button>
{!!orgBranding && (
<div data-testid="user-dropdown-trigger" className="flex items-center">
<UserDropdown small />
</div>
)}
<KBarTrigger />
</div>
</header>
@ -847,14 +893,45 @@ function SideBar({ bannersHeight }: SideBarProps) {
<div>
<Tips />
<div data-testid="user-dropdown-trigger">
<span className="hidden lg:inline">
<UserDropdown />
</span>
<span className="hidden md:inline lg:hidden">
<UserDropdown small />
</span>
</div>
{bottomNavItems.map(({ icon: Icon, ...item }) => (
<Tooltip side="right" content={t(item.name)} className="lg:hidden" key={item.name}>
<ButtonOrLink
href={item.href || undefined}
aria-label={t(item.name)}
target={item.target}
className={classNames(
"text-left",
"[&[aria-current='page']]:bg-emphasis text-default group flex items-center rounded-md py-2 px-3 text-sm font-medium",
"[&[aria-current='page']]:text-emphasis mt-0.5 text-sm",
isLocaleReady ? "hover:bg-emphasis hover:text-emphasis" : ""
)}
aria-current={
defaultIsCurrent && defaultIsCurrent({ item: { href: item.href }, router })
? "page"
: undefined
}
onClick={item.onClick}>
{!!Icon && (
<Icon
className="mr-2 h-4 w-4 flex-shrink-0 ltr:mr-2 rtl:ml-2 [&[aria-current='page']]:text-inherit"
aria-hidden="true"
aria-current={
defaultIsCurrent && defaultIsCurrent({ item: { href: item.href }, router })
? "page"
: undefined
}
/>
)}
{isLocaleReady ? (
<span className="hidden w-full justify-between lg:flex">
<div className="flex">{t(item.name)}</div>
</span>
) : (
<SkeletonText style={{ width: `${item.name.length * 10}px` }} className="h-[20px]" />
)}
</ButtonOrLink>
</Tooltip>
))}
<Credits />
</div>
</aside>

View File

@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import prisma, { baseEventTypeSelect } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { WEBAPP_URL } from "../../../constants";
@ -111,7 +111,7 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
}));
return { ...team, eventTypes, members };
return { ...team, metadata: teamMetadataSchema.parse(team.metadata), eventTypes, members };
}
// also returns team

View File

@ -24,12 +24,12 @@ export type AvatarProps = {
};
const sizesPropsBySize = {
xs: "w-4 h-4", // 16px
sm: "w-6 h-6", // 24px
md: "w-8 h-8", // 32px
mdLg: "w-10 h-10", //40px
lg: "w-16 h-16", // 64px
xl: "w-24 h-24", // 96px
xs: "w-4 h-4 min-w-4 min-h-4", // 16px
sm: "w-6 h-6 min-w-6 min-h-6", // 24px
md: "w-8 h-8 min-w-8 min-h-8", // 32px
mdLg: "w-10 h-10 min-w-10 min-h-10", //40px
lg: "w-16 h-16 min-w-16 min-h-16", // 64px
xl: "w-24 h-24 min-w-24 min-h-24", // 96px
} as const;
export function Avatar(props: AvatarProps) {
@ -48,7 +48,7 @@ export function Avatar(props: AvatarProps) {
alt={alt}
className={classNames("aspect-square rounded-full", sizesPropsBySize[size])}
/>
<AvatarPrimitive.Fallback delayMs={600} asChild={props.asChild}>
<AvatarPrimitive.Fallback delayMs={600} asChild={props.asChild} className="flex items-center">
<>
{props.fallback && !gravatarFallbackMd5 && props.fallback}
{gravatarFallbackMd5 && (