From 34151e03c32e908c48aacff18f3f0836f08122b6 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 2 Feb 2026 21:47:08 +0100 Subject: [PATCH 1/2] decos innit --- .../ProfileMenu/ProfileMenuHeader.tsx | 6 +- .../components/cards/entity/EntityCard.tsx | 39 +++- .../cards/entity/UserEntityCard.tsx | 13 +- .../shared/src/components/modals/common.tsx | 8 + .../src/components/modals/common/types.ts | 1 + .../decorations/DecorationSelectionModal.tsx | 208 ++++++++++++++++++ .../shared/src/components/post/NewComment.tsx | 10 +- .../src/components/profile/ProfileHeader.tsx | 47 +++- .../components/profile/ProfileImageLink.tsx | 20 +- .../profile/ProfilePictureWithDecoration.tsx | 51 +++++ .../profile/ProfilePictureWithIndicator.tsx | 11 +- .../src/components/profile/UserShortInfo.tsx | 10 +- packages/shared/src/graphql/comments.ts | 2 + packages/shared/src/graphql/decorations.ts | 56 +++++ packages/shared/src/graphql/fragments.ts | 8 + packages/shared/src/graphql/users.ts | 5 + packages/shared/src/lib/user.ts | 4 + 17 files changed, 458 insertions(+), 41 deletions(-) create mode 100644 packages/shared/src/components/modals/decorations/DecorationSelectionModal.tsx create mode 100644 packages/shared/src/components/profile/ProfilePictureWithDecoration.tsx create mode 100644 packages/shared/src/graphql/decorations.ts diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx index dc615137a9..29084a01dc 100644 --- a/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx +++ b/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx @@ -7,7 +7,7 @@ import { TypographyColor, TypographyType, } from '../typography/Typography'; -import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; +import { ProfileImageSize } from '../ProfilePicture'; import { useAuthContext } from '../../contexts/AuthContext'; import { usePlusSubscription } from '../../hooks'; import { PlusUser } from '../PlusUser'; @@ -17,6 +17,7 @@ import type { WithClassNameProps } from '../utilities'; import { webappUrl } from '../../lib/constants'; import ConditionalWrapper from '../ConditionalWrapper'; import { IconSize } from '../Icon'; +import { ProfilePictureWithDecoration } from '../profile/ProfilePictureWithDecoration'; type Props = WithClassNameProps & { shouldOpenProfile?: boolean; @@ -43,11 +44,12 @@ export const ProfileMenuHeader = ({
- diff --git a/packages/shared/src/components/cards/entity/EntityCard.tsx b/packages/shared/src/components/cards/entity/EntityCard.tsx index 7c1ec810f6..db65f8dcba 100644 --- a/packages/shared/src/components/cards/entity/EntityCard.tsx +++ b/packages/shared/src/components/cards/entity/EntityCard.tsx @@ -5,7 +5,8 @@ import Link from '../../utilities/Link'; import { Image } from '../../image/Image'; export type EntityCardProps = { - image: string; + image?: string; + imageNode?: ReactNode; type?: 'user' | 'source' | 'squad'; children?: ReactNode; entityName?: string; @@ -22,10 +23,29 @@ const EntityCard = ({ className, actionButtons, image, + imageNode, type, entityName, permalink, }: EntityCardProps) => { + const renderImage = () => { + if (imageNode) { + return imageNode; + } + + return ( + { + ); + }; + return (
- - { + + {renderImage()}
{actionButtons}
diff --git a/packages/shared/src/components/cards/entity/UserEntityCard.tsx b/packages/shared/src/components/cards/entity/UserEntityCard.tsx index 31ce88ed4b..f77a95f3b8 100644 --- a/packages/shared/src/components/cards/entity/UserEntityCard.tsx +++ b/packages/shared/src/components/cards/entity/UserEntityCard.tsx @@ -32,6 +32,7 @@ import EntityDescription from './EntityDescription'; import useUserMenuProps from '../../../hooks/useUserMenuProps'; import useShowFollowAction from '../../../hooks/useShowFollowAction'; import type { MenuItemProps } from '../../dropdown/common'; +import { ProfilePictureWithDecoration } from '../../profile/ProfilePictureWithDecoration'; type Props = { user?: UserShortProfile; @@ -72,8 +73,7 @@ const UserEntityCard = ({ user, className }: Props) => { }, [user, openModal], ); - const { username, bio, name, image, isPlus, createdAt, id, permalink } = - user || {}; + const { username, bio, name, isPlus, createdAt, id, permalink } = user || {}; const options: MenuItemProps[] = [ { icon: , @@ -120,13 +120,20 @@ const UserEntityCard = ({ user, className }: Props) => { return ( + } actionButtons={ showActionBtns && ( <> diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 7620df5e20..c06681077c 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -413,6 +413,13 @@ const CandidateSignInModal = dynamic( ), ); +const DecorationSelectionModal = dynamic( + () => + import( + /* webpackChunkName: "decorationSelectionModal" */ './decorations/DecorationSelectionModal' + ), +); + export const modals = { [LazyModal.SquadMember]: SquadMemberModal, [LazyModal.UpvotedPopup]: UpvotedPopupModal, @@ -480,6 +487,7 @@ export const modals = { [LazyModal.SlackChannelConfirmation]: SlackChannelConfirmationModal, [LazyModal.RecruiterSeats]: RecruiterSeatsModal, [LazyModal.CandidateSignIn]: CandidateSignInModal, + [LazyModal.DecorationSelection]: DecorationSelectionModal, }; type GetComponentProps = T extends diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 155ee78030..2278c2899d 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -91,6 +91,7 @@ export enum LazyModal { SlackChannelConfirmation = 'slackChannelConfirmation', RecruiterSeats = 'recruiterSeats', CandidateSignIn = 'candidateSignIn', + DecorationSelection = 'decorationSelection', } export type ModalTabItem = { diff --git a/packages/shared/src/components/modals/decorations/DecorationSelectionModal.tsx b/packages/shared/src/components/modals/decorations/DecorationSelectionModal.tsx new file mode 100644 index 0000000000..6b5b50eaa2 --- /dev/null +++ b/packages/shared/src/components/modals/decorations/DecorationSelectionModal.tsx @@ -0,0 +1,208 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import classNames from 'classnames'; +import type { ModalProps } from '../common/Modal'; +import { Modal } from '../common/Modal'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { Button, ButtonVariant } from '../../buttons/Button'; +import { ProfilePictureWithDecoration } from '../../profile/ProfilePictureWithDecoration'; +import { ProfileImageSize } from '../../ProfilePicture'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { + DECORATIONS_BY_GROUP_QUERY, + SET_ACTIVE_DECORATION_MUTATION, +} from '../../../graphql/decorations'; +import type { Decoration, DecorationGroup } from '../../../graphql/decorations'; +import { gqlClient } from '../../../graphql/common'; +import { LockIcon, VIcon } from '../../icons'; +import { useToastNotification } from '../../../hooks/useToastNotification'; +import { Loader } from '../../Loader'; + +interface DecorationItemProps { + decoration: Decoration; + isSelected: boolean; + onSelect: (decoration: Decoration) => void; +} + +const DecorationItem = ({ + decoration, + isSelected, + onSelect, +}: DecorationItemProps): ReactElement => { + const isLocked = !decoration.isUnlocked; + + return ( + + ); +}; + +export default function DecorationSelectionModal({ + onRequestClose, + ...props +}: ModalProps): ReactElement { + const { user, updateUser } = useAuthContext(); + const { displayToast } = useToastNotification(); + const [selectedDecoration, setSelectedDecoration] = + useState( + user?.activeDecoration ? (user.activeDecoration as Decoration) : null, + ); + + const { data, isLoading } = useQuery({ + queryKey: ['decorationsByGroup'], + queryFn: async () => { + const result = await gqlClient.request<{ + decorationsByGroup: DecorationGroup[]; + }>(DECORATIONS_BY_GROUP_QUERY); + return result.decorationsByGroup; + }, + }); + + const { mutate: setActiveDecoration, isPending } = useMutation({ + mutationFn: async (decorationId: string | null) => { + const result = await gqlClient.request<{ + setActiveDecoration: { + id: string; + activeDecoration: Decoration | null; + }; + }>(SET_ACTIVE_DECORATION_MUTATION, { decorationId }); + return result.setActiveDecoration; + }, + onSuccess: (response) => { + updateUser({ + ...user, + activeDecoration: response.activeDecoration, + }); + displayToast('Avatar decoration updated'); + onRequestClose?.(null); + }, + onError: () => { + displayToast('Failed to update decoration'); + }, + }); + + const handleApply = () => { + setActiveDecoration(selectedDecoration?.id ?? null); + }; + + const handleClearDecoration = () => { + setSelectedDecoration(null); + }; + + const hasChanged = + (selectedDecoration?.id ?? null) !== (user?.activeDecoration?.id ?? null); + const isSelectedLocked = selectedDecoration && !selectedDecoration.isUnlocked; + + return ( + + + +
+ + {selectedDecoration && ( + + )} +
+ + {isLoading ? ( +
+ +
+ ) : ( + data?.map((group) => ( +
+ + {group.label} + +
+ {group.decorations.map((decoration) => ( + + ))} +
+
+ )) + )} +
+ + + + +
+ ); +} diff --git a/packages/shared/src/components/post/NewComment.tsx b/packages/shared/src/components/post/NewComment.tsx index 5d662f3440..fde2f061aa 100644 --- a/packages/shared/src/components/post/NewComment.tsx +++ b/packages/shared/src/components/post/NewComment.tsx @@ -8,11 +8,8 @@ import React, { } from 'react'; import classNames from 'classnames'; import { useRouter } from 'next/router'; -import { - getProfilePictureClasses, - ProfileImageSize, - ProfilePicture, -} from '../ProfilePicture'; +import { getProfilePictureClasses, ProfileImageSize } from '../ProfilePicture'; +import { ProfilePictureWithDecoration } from '../profile/ProfilePictureWithDecoration'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { Image } from '../image/Image'; import { fallbackImages } from '../../lib/config'; @@ -138,9 +135,10 @@ function NewCommentComponent( onClick={() => onCommentClick(Origin.StartDiscussion)} > {user ? ( - diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 8ac8673610..d528ebdbe6 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -7,13 +7,13 @@ import { TypographyColor, TypographyType, } from '../typography/Typography'; -import { DevPlusIcon, EditIcon } from '../icons'; +import { DevPlusIcon, EditIcon, SparkleIcon } from '../icons'; import type { PublicProfile } from '../../lib/user'; import type { UserStatsProps } from './UserStats'; import { UserStats } from './UserStats'; import JoinedDate from './JoinedDate'; import { Separator } from '../cards/common/common'; -import { Button, ButtonVariant } from '../buttons/Button'; +import { Button, ButtonVariant, ButtonSize } from '../buttons/Button'; import { webappUrl } from '../../lib/constants'; import Link from '../utilities/Link'; import { useAuthContext } from '../../contexts/AuthContext'; @@ -22,6 +22,8 @@ import { VerifiedCompanyUserBadge } from '../VerifiedCompanyUserBadge'; import { locationToString } from '../../lib/utils'; import { IconSize } from '../Icon'; import { fallbackImages } from '../../lib/config'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { LazyModal } from '../modals/common/types'; import { ElementPlaceholder } from '../ElementPlaceholder'; @@ -57,20 +59,47 @@ const ProfileHeader = ({ isSameUser: propIsSameUser, isPreviewMode, }: ProfileHeaderProps) => { - const { name, username, bio, image, cover, isPlus } = user; + const { name, username, bio, image, cover, isPlus, activeDecoration } = user; const { user: loggedUser } = useAuthContext(); + const { openModal } = useLazyModal(); const isSameUser = propIsSameUser ?? loggedUser?.id === user.id; + + const handleOpenDecorationModal = () => { + openModal({ type: LazyModal.DecorationSelection }); + }; + return (
Cover
- Avatar +
+ + {activeDecoration && ( + + )} + Avatar + + {isSameUser && !isPreviewMode && ( +