Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -43,11 +44,12 @@ export const ProfileMenuHeader = ({
<div
className={classNames('relative flex items-center gap-2', className)}
>
<ProfilePicture
<ProfilePictureWithDecoration
user={user}
nativeLazyLoading
eager
size={profileImageSize}
decoration={user.activeDecoration}
className="!rounded-10 border-background-default"
/>

Expand Down
39 changes: 28 additions & 11 deletions packages/shared/src/components/cards/entity/EntityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,10 +23,29 @@ const EntityCard = ({
className,
actionButtons,
image,
imageNode,
type,
entityName,
permalink,
}: EntityCardProps) => {
const renderImage = () => {
if (imageNode) {
return imageNode;
}

return (
<Image
className="h-full w-full object-cover"
src={image}
alt={
type === 'user'
? `${entityName}'s user avatar`
: `${entityName}'s image`
}
/>
);
};

return (
<div
className={classNames(
Expand All @@ -35,16 +55,13 @@ const EntityCard = ({
>
<div className="flex w-full items-start gap-2">
<Link href={permalink}>
<a className={classNames(className?.image, 'overflow-hidden')}>
<Image
className="h-full w-full object-cover"
src={image}
alt={
type === 'user'
? `${entityName}'s user avatar`
: `${entityName}'s image`
}
/>
<a
className={classNames(
className?.image,
!imageNode && 'overflow-hidden',
)}
>
{renderImage()}
</a>
</Link>
<div className="ml-auto flex items-center gap-2">{actionButtons}</div>
Expand Down
13 changes: 10 additions & 3 deletions packages/shared/src/components/cards/entity/UserEntityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: <BlockIcon />,
Expand Down Expand Up @@ -120,13 +120,20 @@ const UserEntityCard = ({ user, className }: Props) => {
return (
<EntityCard
permalink={permalink}
image={image}
type="user"
className={{
image: 'size-16 rounded-20',
container: className?.container,
}}
entityName={username}
imageNode={
<ProfilePictureWithDecoration
user={user}
size={ProfileImageSize.XXXLarge}
decoration={user?.activeDecoration}
nativeLazyLoading
/>
}
actionButtons={
showActionBtns && (
<>
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/components/modals/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,13 @@ const CandidateSignInModal = dynamic(
),
);

const DecorationSelectionModal = dynamic(
() =>
import(
/* webpackChunkName: "decorationSelectionModal" */ './decorations/DecorationSelectionModal'
),
);

const FeedbackModal = dynamic(
() => import(/* webpackChunkName: "feedbackModal" */ './FeedbackModal'),
);
Expand Down Expand Up @@ -484,6 +491,7 @@ export const modals = {
[LazyModal.SlackChannelConfirmation]: SlackChannelConfirmationModal,
[LazyModal.RecruiterSeats]: RecruiterSeatsModal,
[LazyModal.CandidateSignIn]: CandidateSignInModal,
[LazyModal.DecorationSelection]: DecorationSelectionModal,
[LazyModal.Feedback]: FeedbackModal,
};

Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/components/modals/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export enum LazyModal {
SlackChannelConfirmation = 'slackChannelConfirmation',
RecruiterSeats = 'recruiterSeats',
CandidateSignIn = 'candidateSignIn',
DecorationSelection = 'decorationSelection',
Feedback = 'feedback',
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
className={classNames(
'relative flex flex-col items-center gap-2 rounded-12 border p-3 transition-colors',
isSelected
? 'border-accent-cabbage-default bg-surface-float'
: 'border-border-subtlest-tertiary hover:border-border-subtlest-secondary',
isLocked && 'opacity-60',
)}
onClick={() => onSelect(decoration)}
>
<div className="relative">
<img
src={decoration.media}
alt={decoration.name}
className="size-16 object-contain"
/>
{isLocked && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-overlay-tertiary-pepper">
<LockIcon className="text-text-secondary" />
</div>
)}
{isSelected && (
<div className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-accent-cabbage-default">
<VIcon className="size-3 text-white" />
</div>
)}
</div>
<Typography
type={TypographyType.Footnote}
color={isLocked ? TypographyColor.Quaternary : TypographyColor.Primary}
className="text-center"
>
{decoration.name}
</Typography>
{isLocked && decoration.unlockCriteria && (
<Typography
type={TypographyType.Caption2}
color={TypographyColor.Tertiary}
className="text-center"
>
{decoration.unlockCriteria}
</Typography>
)}
</button>
);
};

export default function DecorationSelectionModal({
onRequestClose,
...props
}: ModalProps): ReactElement {
const { user, updateUser } = useAuthContext();
const { displayToast } = useToastNotification();
const [selectedDecoration, setSelectedDecoration] =
useState<Decoration | null>(
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 (
<Modal
kind={Modal.Kind.FlexibleCenter}
size={Modal.Size.Medium}
onRequestClose={onRequestClose}
{...props}
>
<Modal.Header title="Avatar Decoration" />
<Modal.Body className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-4 overflow-visible p-4">
<ProfilePictureWithDecoration
user={user}
size={ProfileImageSize.XXXXLarge}
decoration={selectedDecoration}
/>
{selectedDecoration && (
<Button
variant={ButtonVariant.Tertiary}
onClick={handleClearDecoration}
>
Remove decoration
</Button>
)}
</div>

{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader />
</div>
) : (
data?.map((group) => (
<div key={group.group} className="flex flex-col gap-3">
<Typography type={TypographyType.Body} bold>
{group.label}
</Typography>
<div className="grid grid-cols-3 gap-3 tablet:grid-cols-4">
{group.decorations.map((decoration) => (
<DecorationItem
key={decoration.id}
decoration={decoration}
isSelected={selectedDecoration?.id === decoration.id}
onSelect={setSelectedDecoration}
/>
))}
</div>
</div>
))
)}
</Modal.Body>
<Modal.Footer>
<Button variant={ButtonVariant.Tertiary} onClick={onRequestClose}>
Cancel
</Button>
<Button
variant={ButtonVariant.Primary}
onClick={handleApply}
disabled={!hasChanged || isPending || isSelectedLocked}
loading={isPending}
>
Apply
</Button>
</Modal.Footer>
</Modal>
);
}
10 changes: 4 additions & 6 deletions packages/shared/src/components/post/NewComment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -138,9 +135,10 @@ function NewCommentComponent(
onClick={() => onCommentClick(Origin.StartDiscussion)}
>
{user ? (
<ProfilePicture
<ProfilePictureWithDecoration
user={user}
size={size}
decoration={user.activeDecoration}
nativeLazyLoading
className={pictureClasses}
/>
Expand Down
Loading