diff --git a/packages/shared/src/components/modals/AchievementShowcaseModal.tsx b/packages/shared/src/components/modals/AchievementShowcaseModal.tsx new file mode 100644 index 0000000000..5b44c83b94 --- /dev/null +++ b/packages/shared/src/components/modals/AchievementShowcaseModal.tsx @@ -0,0 +1,204 @@ +import type { ReactElement } from 'react'; +import React, { useState, useMemo } from 'react'; +import classNames from 'classnames'; +import type { ModalProps } from './common/Modal'; +import { Modal } from './common/Modal'; +import { ModalClose } from './common/ModalClose'; +import { Button, ButtonVariant, ButtonSize } from '../buttons/Button'; +import { LazyImage } from '../LazyImage'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; +import { VIcon } from '../icons'; +import type { PublicProfile } from '../../lib/user'; +import { useShowcaseAchievements } from '../../hooks/profile/useShowcaseAchievements'; +import { useProfileAchievements } from '../../hooks/profile/useProfileAchievements'; +import { useToastNotification } from '../../hooks/useToastNotification'; + +const MAX_SHOWCASE = 3; + +export interface AchievementShowcaseModalProps extends ModalProps { + user: PublicProfile; +} + +export const AchievementShowcaseModal = ({ + user, + onRequestClose, + ...props +}: AchievementShowcaseModalProps): ReactElement => { + const { showcaseAchievements, setShowcase, isSetPending } = + useShowcaseAchievements(user); + const { achievements } = useProfileAchievements(user); + const { displayToast } = useToastNotification(); + + const initialSelectedIds = useMemo( + () => showcaseAchievements.map((sa) => sa.achievement.id), + [showcaseAchievements], + ); + + const unlockedAchievements = useMemo( + () => achievements?.filter((a) => a.unlockedAt !== null) ?? [], + [achievements], + ); + + const [selectedIds, setSelectedIds] = useState(initialSelectedIds); + + const sortedAchievements = useMemo(() => { + const selectedSet = new Set(initialSelectedIds); + return [...unlockedAchievements].sort((a, b) => { + const aSelected = selectedSet.has(a.achievement.id); + const bSelected = selectedSet.has(b.achievement.id); + if (aSelected !== bSelected) { + return aSelected ? -1 : 1; + } + return b.achievement.points - a.achievement.points; + }); + }, [unlockedAchievements, initialSelectedIds]); + + const toggleSelection = (achievementId: string) => { + setSelectedIds((prev) => { + if (prev.includes(achievementId)) { + return prev.filter((id) => id !== achievementId); + } + if (prev.length >= MAX_SHOWCASE) { + return prev; + } + return [...prev, achievementId]; + }); + }; + + const handleConfirm = async (e: React.MouseEvent | React.KeyboardEvent) => { + try { + await setShowcase(selectedIds); + displayToast('Achievement showcase updated'); + onRequestClose?.(e); + } catch { + displayToast('Failed to update showcase'); + } + }; + + const hasChanges = + JSON.stringify([...selectedIds].sort()) !== + JSON.stringify([...initialSelectedIds].sort()); + + return ( + + + + + Achievement Showcase + + + Select up to {MAX_SHOWCASE} unlocked achievements to feature on your + profile ({selectedIds.length}/{MAX_SHOWCASE} selected) + + + {sortedAchievements.length === 0 && ( + + No unlocked achievements yet. + + )} + + {sortedAchievements.length > 0 && ( +
+ {sortedAchievements.map((userAchievement) => { + const isSelected = selectedIds.includes( + userAchievement.achievement.id, + ); + const isDisabled = + !isSelected && selectedIds.length >= MAX_SHOWCASE; + + return ( + + ); + })} +
+ )} + + +
+
+ ); +}; + +export default AchievementShowcaseModal; diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index b22d5b9e0e..5e890583de 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -454,6 +454,13 @@ const CompareAchievementsModal = dynamic( ), ); +const AchievementShowcaseModal = dynamic( + () => + import( + /* webpackChunkName: "achievementShowcaseModal" */ './AchievementShowcaseModal' + ), +); + export const modals = { [LazyModal.SquadMember]: SquadMemberModal, [LazyModal.UpvotedPopup]: UpvotedPopupModal, @@ -528,6 +535,7 @@ export const modals = { [LazyModal.AchievementPicker]: AchievementPickerModal, [LazyModal.AchievementCompletion]: AchievementCompletionModal, [LazyModal.CompareAchievements]: CompareAchievementsModal, + [LazyModal.AchievementShowcase]: AchievementShowcaseModal, }; 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 eefd3bee76..5dc2743d83 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -98,6 +98,7 @@ export enum LazyModal { AchievementPicker = 'achievementPicker', AchievementCompletion = 'achievementCompletion', CompareAchievements = 'compareAchievements', + AchievementShowcase = 'achievementShowcase', } export type ModalTabItem = { diff --git a/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx b/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx new file mode 100644 index 0000000000..0d79ee2c4a --- /dev/null +++ b/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx @@ -0,0 +1,194 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { PublicProfile } from '../../../../lib/user'; +import { useShowcaseAchievements } from '../../../../hooks/profile/useShowcaseAchievements'; +import { useProfilePreview } from '../../../../hooks/profile/useProfilePreview'; +import { useProfileAchievements } from '../../../../hooks/profile/useProfileAchievements'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../../../components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { EditIcon, PlusIcon } from '../../../../components/icons'; +import { LazyImage } from '../../../../components/LazyImage'; +import HoverCard from '../../../../components/cards/common/HoverCard'; +import { formatDate, TimeFormatType } from '../../../../lib/dateFormat'; +import { + getAchievementRarityTier, + AchievementRarityTier, +} from './achievementRarity'; +import { useLazyModal } from '../../../../hooks/useLazyModal'; +import { LazyModal } from '../../../../components/modals/common/types'; + +interface ProfileAchievementShowcaseProps { + user: PublicProfile; +} + +export function ProfileAchievementShowcase({ + user, +}: ProfileAchievementShowcaseProps): ReactElement | null { + const { isOwner } = useProfilePreview(user); + const { showcaseAchievements } = useShowcaseAchievements(user); + const { achievements } = useProfileAchievements(user); + const { openModal } = useLazyModal(); + + const hasShowcase = showcaseAchievements.length > 0; + + const unlockedAchievements = + achievements?.filter((a) => a.unlockedAt !== null) ?? []; + + const handleOpenModal = () => { + openModal({ + type: LazyModal.AchievementShowcase, + props: { user }, + }); + }; + + if (!hasShowcase && !isOwner) { + return null; + } + + if (!hasShowcase && isOwner && unlockedAchievements.length === 0) { + return null; + } + + return ( +
+
+ + Achievement Showcase + + {isOwner && ( + + )} +
+ + {hasShowcase ? ( +
+ {showcaseAchievements.map((userAchievement) => { + const { achievement, unlockedAt } = userAchievement; + const rarityTier = getAchievementRarityTier(achievement.rarity); + const rarityLabel = + rarityTier === AchievementRarityTier.Emerald + ? '<1%' + : `${Math.round(achievement.rarity ?? 0)}%`; + + return ( + + + + } + > +
+
+ +
+ + {achievement.name} + + + {achievement.description} + +
+ + {achievement.points} + +
+ {unlockedAt && ( +
+ + Unlocked{' '} + {formatDate({ + value: unlockedAt, + type: TimeFormatType.Post, + })} + + {achievement.rarity != null && ( + + Earned by {rarityLabel} of users + + )} +
+ )} +
+
+ ); + })} +
+ ) : ( + isOwner && ( +
+ + Showcase your achievements on your profile + + +
+ ) + )} +
+ ); +} diff --git a/packages/shared/src/graphql/user/achievements.ts b/packages/shared/src/graphql/user/achievements.ts index b46cb87564..947a047365 100644 --- a/packages/shared/src/graphql/user/achievements.ts +++ b/packages/shared/src/graphql/user/achievements.ts @@ -76,6 +76,14 @@ export interface UntrackAchievementData { }; } +export interface ShowcaseAchievementsData { + showcaseAchievements: UserAchievement[]; +} + +export interface SetShowcaseAchievementsData { + setShowcaseAchievements: UserAchievement[]; +} + const ACHIEVEMENT_FRAGMENT = gql` fragment AchievementFragment on Achievement { id @@ -197,6 +205,36 @@ export const SYNC_ACHIEVEMENTS_MUTATION = gql` ${ACHIEVEMENT_FRAGMENT} `; +export const SHOWCASE_ACHIEVEMENTS_QUERY = gql` + query ShowcaseAchievements($userId: ID!) { + showcaseAchievements(userId: $userId) { + achievement { + ...AchievementFragment + } + progress + unlockedAt + createdAt + updatedAt + } + } + ${ACHIEVEMENT_FRAGMENT} +`; + +export const SET_SHOWCASE_ACHIEVEMENTS_MUTATION = gql` + mutation SetShowcaseAchievements($achievementIds: [ID!]!) { + setShowcaseAchievements(achievementIds: $achievementIds) { + achievement { + ...AchievementFragment + } + progress + unlockedAt + createdAt + updatedAt + } + } + ${ACHIEVEMENT_FRAGMENT} +`; + export const getAchievements = async (): Promise => { const result = await gqlClient.request(ACHIEVEMENTS_QUERY); return result.achievements; @@ -253,6 +291,26 @@ export const syncAchievements = async (): Promise => { return result.syncAchievements; }; +export const getShowcaseAchievements = async ( + userId: string, +): Promise => { + const result = await gqlClient.request( + SHOWCASE_ACHIEVEMENTS_QUERY, + { userId }, + ); + return result.showcaseAchievements; +}; + +export const setShowcaseAchievements = async ( + achievementIds: string[], +): Promise => { + const result = await gqlClient.request( + SET_SHOWCASE_ACHIEVEMENTS_MUTATION, + { achievementIds }, + ); + return result.setShowcaseAchievements; +}; + // Helper to get target count from achievement criteria export const getTargetCount = (achievement: Achievement): number => { return achievement.criteria?.targetCount ?? 1; diff --git a/packages/shared/src/hooks/profile/useShowcaseAchievements.ts b/packages/shared/src/hooks/profile/useShowcaseAchievements.ts new file mode 100644 index 0000000000..46939099b4 --- /dev/null +++ b/packages/shared/src/hooks/profile/useShowcaseAchievements.ts @@ -0,0 +1,54 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { PublicProfile } from '../../lib/user'; +import type { UserAchievement } from '../../graphql/user/achievements'; +import { + getShowcaseAchievements, + setShowcaseAchievements, +} from '../../graphql/user/achievements'; +import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query'; + +interface UseShowcaseAchievementsReturn { + showcaseAchievements: UserAchievement[]; + isPending: boolean; + setShowcase: (achievementIds: string[]) => Promise; + isSetPending: boolean; +} + +export function useShowcaseAchievements( + user: PublicProfile | null | undefined, +): UseShowcaseAchievementsReturn { + const queryClient = useQueryClient(); + + const queryKey = generateQueryKey( + RequestKey.ShowcaseAchievements, + user, + 'profile', + ); + + const { data, isPending } = useQuery({ + queryKey, + queryFn: () => { + if (!user?.id) { + throw new Error('Cannot load showcase achievements without a user id.'); + } + return getShowcaseAchievements(user.id); + }, + staleTime: StaleTime.Default, + enabled: !!user?.id, + }); + + const { mutateAsync: setShowcase, isPending: isSetPending } = useMutation({ + mutationFn: (achievementIds: string[]) => + setShowcaseAchievements(achievementIds), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + return { + showcaseAchievements: data ?? [], + isPending, + setShowcase, + isSetPending, + }; +} diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 0e974640a1..d7d6a32a1e 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -249,6 +249,7 @@ export enum RequestKey { UserAchievements = 'user_achievements', TrackedAchievement = 'tracked_achievement', AchievementSyncStatus = 'achievement_sync_status', + ShowcaseAchievements = 'showcase_achievements', } export const getPostByIdKey = (id: string): QueryKey => [RequestKey.Post, id]; diff --git a/packages/webapp/pages/[userId]/index.tsx b/packages/webapp/pages/[userId]/index.tsx index c8f19ef62e..8cec3717fc 100644 --- a/packages/webapp/pages/[userId]/index.tsx +++ b/packages/webapp/pages/[userId]/index.tsx @@ -9,6 +9,7 @@ import type { NextSeoProps } from 'next-seo/lib/types'; import ProfileHeader from '@dailydotdev/shared/src/components/profile/ProfileHeader'; import { AutofillProfileBanner } from '@dailydotdev/shared/src/features/profile/components/AutofillProfileBanner'; import { ProfileUserExperiences } from '@dailydotdev/shared/src/features/profile/components/experience/ProfileUserExperiences'; +import { ProfileAchievementShowcase } from '@dailydotdev/shared/src/features/profile/components/achievements/ProfileAchievementShowcase'; import { ProfileUserStack } from '@dailydotdev/shared/src/features/profile/components/stack/ProfileUserStack'; import { ProfileUserHotTakes } from '@dailydotdev/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes'; import { ProfileUserWorkspacePhotos } from '@dailydotdev/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos'; @@ -106,6 +107,7 @@ const ProfilePage = ({ )} {!shouldShowBanner &&
} +