feat: User Avatar and Org Logo (#10700)
* Pass organization name & logo * Overflow hidden * Show org icon on public page * Add org logo to large user avatars * Clean up * Add org name and logo to context * Get org logo from /avatar.png endpoint * Do not query for logo * Remove name and logo from session middleware * Type fix * Set user onboarding org logo * feat: organization avatar component (#10788) Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> * Type fixes * Type fix * Transition to org slug for organization avatar * Address feedback * Clean up * Clean up * Type fix * fix: set avatar cache control (#11163) * test: Integration tests for handleNewBooking (#11044) Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> * fix: booking_paid webhook and added new payment metadata (#11093) * app store improvements, logos, dark mode, added screenshots, fixed author names (#11164) * fix: mobile event types and avatars (#11184) * New Crowdin translations by Github Action * fix: updateProfile metadata overwrite (#11188) Co-authored-by: alannnc <alannnc@gmail.com> * New Crowdin translations by Github Action --------- Co-authored-by: Sean Brydon <sean@cal.com> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com> Co-authored-by: alannnc <alannnc@gmail.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Leo Giovanetti <hello@leog.me> Co-authored-by: Crowdin Bot <support+bot@crowdin.com>pull/11158/head
parent
5324ec8051
commit
5c0da23b97
|
@ -3,12 +3,13 @@ import type { FormEvent } from "react";
|
|||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar, Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
type FormData = {
|
||||
|
@ -98,7 +99,14 @@ const UserProfile = () => {
|
|||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
||||
{user && <Avatar alt={user.username || "user avatar"} size="lg" imageSrc={imageSrc} />}
|
||||
{user && (
|
||||
<OrganizationAvatar
|
||||
alt={user.username || "user avatar"}
|
||||
size="lg"
|
||||
imageSrc={imageSrc}
|
||||
organizationSlug={user.organization?.slug}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
|
|
|
@ -254,6 +254,10 @@ const nextConfig = {
|
|||
source: "/org/:slug",
|
||||
destination: "/team/:slug",
|
||||
},
|
||||
{
|
||||
source: "/org/:orgSlug/avatar.png",
|
||||
destination: "/api/user/avatar?orgSlug=:orgSlug",
|
||||
},
|
||||
{
|
||||
source: "/team/:teamname/avatar.png",
|
||||
destination: "/api/user/avatar?teamname=:teamname",
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
|
@ -25,7 +26,7 @@ import prisma from "@calcom/prisma";
|
|||
import type { EventType, User } from "@calcom/prisma/client";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Avatar, HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
import { Verified, ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
@ -96,7 +97,12 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
|||
"max-w-3xl px-4 py-24"
|
||||
)}>
|
||||
<div className="mb-8 text-center">
|
||||
<Avatar imageSrc={profile.image} size="xl" alt={profile.name} />
|
||||
<OrganizationAvatar
|
||||
imageSrc={profile.image}
|
||||
size="xl"
|
||||
alt={profile.name}
|
||||
organizationSlug={profile.organizationSlug}
|
||||
/>
|
||||
<h1 className="font-cal text-emphasis mb-1 text-3xl" data-testid="name-title">
|
||||
{profile.name}
|
||||
{user.verified && (
|
||||
|
@ -218,6 +224,7 @@ export type UserPageProps = {
|
|||
theme: string | null;
|
||||
brandColor: string;
|
||||
darkBrandColor: string;
|
||||
organizationSlug: string | null;
|
||||
allowSEOIndexing: boolean;
|
||||
};
|
||||
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
|
||||
|
@ -321,6 +328,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
organizationSlug: user.organization?.slug ?? null,
|
||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
||||
};
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ const querySchema = z
|
|||
.object({
|
||||
username: z.string(),
|
||||
teamname: z.string(),
|
||||
orgSlug: z.string(),
|
||||
/**
|
||||
* Allow fetching avatar of a particular organization
|
||||
* Avatars being public, we need not worry about others accessing it.
|
||||
|
@ -19,7 +20,7 @@ const querySchema = z
|
|||
.partial();
|
||||
|
||||
async function getIdentityData(req: NextApiRequest) {
|
||||
const { username, teamname, orgId } = querySchema.parse(req.query);
|
||||
const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
@ -59,7 +60,23 @@ async function getIdentityData(req: NextApiRequest) {
|
|||
org,
|
||||
name: teamname,
|
||||
email: null,
|
||||
avatar: team?.logo || getPlaceholderAvatar(null, teamname),
|
||||
avatar: getPlaceholderAvatar(team?.logo, teamname),
|
||||
};
|
||||
}
|
||||
if (orgSlug) {
|
||||
const org = await prisma.team.findFirst({
|
||||
where: getSlugOrRequestedSlug(orgSlug),
|
||||
select: {
|
||||
slug: true,
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
org: org?.slug,
|
||||
name: org?.name,
|
||||
email: null,
|
||||
avatar: getPlaceholderAvatar(org?.logo, org?.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { Controller, useForm } from "react-hook-form";
|
|||
import { z } from "zod";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -14,10 +15,10 @@ import turndown from "@calcom/lib/turndownService";
|
|||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
|
@ -223,6 +224,7 @@ const ProfileView = () => {
|
|||
key={JSON.stringify(defaultValues)}
|
||||
defaultValues={defaultValues}
|
||||
isLoading={updateProfileMutation.isLoading}
|
||||
userOrganization={user.organization}
|
||||
onSubmit={(values) => {
|
||||
if (values.email !== user.email && isCALIdentityProvider) {
|
||||
setTempFormValues(values);
|
||||
|
@ -364,11 +366,13 @@ const ProfileForm = ({
|
|||
onSubmit,
|
||||
extraField,
|
||||
isLoading = false,
|
||||
userOrganization,
|
||||
}: {
|
||||
defaultValues: FormValues;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
extraField?: React.ReactNode;
|
||||
isLoading: boolean;
|
||||
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
@ -406,7 +410,12 @@ const ProfileForm = ({
|
|||
name="avatar"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Avatar alt="" imageSrc={value} size="lg" />
|
||||
<OrganizationAvatar
|
||||
alt={formMethods.getValues("username")}
|
||||
imageSrc={value}
|
||||
size="lg"
|
||||
organizationSlug={userOrganization.slug}
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import classNames from "@calcom/lib/classNames";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
import type { AvatarProps } from "@calcom/ui";
|
||||
|
||||
type OrganizationAvatarProps = AvatarProps & {
|
||||
organizationSlug: string | null | undefined;
|
||||
};
|
||||
|
||||
const OrganizationAvatar = ({ size, imageSrc, alt, organizationSlug, ...rest }: OrganizationAvatarProps) => {
|
||||
return (
|
||||
<Avatar
|
||||
size={size}
|
||||
imageSrc={imageSrc}
|
||||
alt={alt}
|
||||
indicator={
|
||||
organizationSlug ? (
|
||||
<div
|
||||
className={classNames("absolute bottom-0 right-0 z-10", size === "lg" ? "h-3 w-3" : "h-10 w-10")}>
|
||||
<img
|
||||
src={`/org/${organizationSlug}/avatar.png`}
|
||||
alt={alt}
|
||||
className="flex h-full items-center justify-center rounded-full ring-2 ring-white"
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationAvatar;
|
|
@ -64,6 +64,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
|
|||
id: true,
|
||||
slug: true,
|
||||
metadata: true,
|
||||
name: true,
|
||||
members: {
|
||||
select: { userId: true },
|
||||
where: {
|
||||
|
|
|
@ -7,7 +7,6 @@ import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
|||
|
||||
import type { Maybe } from "@trpc/server";
|
||||
|
||||
import { Check } from "../icon";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
export type AvatarProps = {
|
||||
|
@ -20,6 +19,7 @@ export type AvatarProps = {
|
|||
fallback?: React.ReactNode;
|
||||
accepted?: boolean;
|
||||
asChild?: boolean; // Added to ignore the outer span on the fallback component - messes up styling
|
||||
indicator?: React.ReactNode;
|
||||
};
|
||||
|
||||
const sizesPropsBySize = {
|
||||
|
@ -34,12 +34,13 @@ const sizesPropsBySize = {
|
|||
} as const;
|
||||
|
||||
export function Avatar(props: AvatarProps) {
|
||||
const { imageSrc, size = "md", alt, title, href } = props;
|
||||
const { imageSrc, size = "md", alt, title, href, indicator } = props;
|
||||
const rootClass = classNames("aspect-square rounded-full", sizesPropsBySize[size]);
|
||||
let avatar = (
|
||||
<AvatarPrimitive.Root
|
||||
className={classNames(
|
||||
"bg-emphasis item-center relative inline-flex aspect-square justify-center overflow-hidden rounded-full",
|
||||
"bg-emphasis item-center relative inline-flex aspect-square justify-center rounded-full",
|
||||
indicator ? "overflow-visible" : "overflow-hidden",
|
||||
props.className,
|
||||
sizesPropsBySize[size]
|
||||
)}>
|
||||
|
@ -57,17 +58,7 @@ export function Avatar(props: AvatarProps) {
|
|||
{props.fallback ? props.fallback : <img src={AVATAR_FALLBACK} alt={alt} className={rootClass} />}
|
||||
</>
|
||||
</AvatarPrimitive.Fallback>
|
||||
{props.accepted && (
|
||||
<div
|
||||
className={classNames(
|
||||
"text-inverted absolute bottom-0 right-0 block rounded-full bg-green-400 ring-2 ring-white",
|
||||
size === "lg" ? "h-5 w-5" : "h-2 w-2"
|
||||
)}>
|
||||
<div className="flex h-full items-center justify-center p-[2px]">
|
||||
{size === "lg" && <Check />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{indicator}
|
||||
</>
|
||||
</AvatarPrimitive.Root>
|
||||
);
|
||||
|
|
|
@ -11,7 +11,6 @@ export type AvatarGroupProps = {
|
|||
href?: string;
|
||||
}[];
|
||||
className?: string;
|
||||
accepted?: boolean;
|
||||
truncateAfter?: number;
|
||||
};
|
||||
|
||||
|
@ -36,7 +35,6 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
|
|||
imageSrc={item.image}
|
||||
title={item.title}
|
||||
alt={item.alt || ""}
|
||||
accepted={props.accepted}
|
||||
size={props.size}
|
||||
href={item.href}
|
||||
/>
|
||||
|
|
Loading…
Reference in New Issue