diff --git a/packages/shared/src/components/DataTile.tsx b/packages/shared/src/components/DataTile.tsx index 0ef463ea9c3..e54f3ddc870 100644 --- a/packages/shared/src/components/DataTile.tsx +++ b/packages/shared/src/components/DataTile.tsx @@ -16,6 +16,7 @@ interface DataTileProps { valueClassName?: string; className?: { container?: string; + label?: string; }; } @@ -36,7 +37,9 @@ export const DataTile: React.FC = ({ )} > - {label} + + {label} + diff --git a/packages/shared/src/components/badges/TopReaderBadgeCompact.tsx b/packages/shared/src/components/badges/TopReaderBadgeCompact.tsx new file mode 100644 index 00000000000..a8bc4f6db0a --- /dev/null +++ b/packages/shared/src/components/badges/TopReaderBadgeCompact.tsx @@ -0,0 +1,51 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { DevCardTheme, themeToLinearGradient } from '../profile/devcard'; +import type { TopReader } from './TopReaderBadge'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; +import { formatDate, TimeFormatType } from '../../lib/dateFormat'; + +export const TopReaderBadgeCompact = ({ + issuedAt, + keyword, +}: Pick): ReactElement => { + const formattedDate = formatDate({ + value: issuedAt, + type: TimeFormatType.TopReaderBadge, + }); + + return ( +
+ + Top reader + + + + {formattedDate} + + +
+ + {keyword.flags?.title || keyword.value} + +
+
+ ); +}; diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx new file mode 100644 index 00000000000..e4ea48cd5aa --- /dev/null +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -0,0 +1,162 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../typography/Typography'; +import { ProgressBar } from '../fields/ProgressBar'; +import { gameCenterLevelBackground } from '../../lib/image'; + +type HudStatProps = { + label: string; + value: string; +}; + +const HudStat = ({ label, value }: HudStatProps): ReactElement => ( +
+ + {label} + + + {value} + +
+); + +// Off-token purples, so these stay inline rather than fighting the +// no-custom-color rule with arbitrary classes. +const levelPanelStyle = { + backgroundColor: '#2A0B3D', + backgroundImage: [ + // A dark wash over the art, so the level, bar and readout keep their + // contrast wherever the artwork happens to be bright. + 'linear-gradient(100deg, rgba(42,11,61,0.94) 0%, rgba(42,11,61,0.70) 42%, rgba(42,11,61,0.42) 100%)', + `url("${gameCenterLevelBackground}")`, + ].join(', '), + backgroundSize: 'auto, cover', + backgroundPosition: 'center, 46% 50%', +}; + +// Frosted glass over the patterned panel: a translucent white wash, a +// lit top edge and a soft border, so the panel's pattern shows through. +const levelBadgeStyle = { + background: + 'linear-gradient(145deg, rgba(255,255,255,0.42), rgba(255,255,255,0.10))', + border: '1px solid rgba(255,255,255,0.45)', +}; + +export interface LevelHudProps { + name: string; + level: number; + levelProgress: number; + totalXp: number; + xpInLevel: number; + xpToNextLevel: number; + currentStreak: number; + longestStreak: number; + badges?: number; + achievements?: { unlocked: number; total: number }; + isPending: boolean; +} + +export const LevelHud = ({ + name, + level, + levelProgress, + totalXp, + xpInLevel, + xpToNextLevel, + currentStreak, + longestStreak, + badges, + achievements, + isPending, +}: LevelHudProps): ReactElement => { + const streakValue = isPending ? '...' : `${currentStreak.toLocaleString()}d`; + const longestValue = isPending ? '...' : `${longestStreak.toLocaleString()}d`; + + const stats: HudStatProps[] = [ + { + label: 'Streak', + value: streakValue, + }, + { + label: 'Longest', + value: longestValue, + }, + ...(badges === undefined + ? [] + : [{ label: 'Badges', value: badges.toLocaleString() }]), + ...(achievements + ? [ + { + label: 'Achievements', + value: `${achievements.unlocked}/${achievements.total}`, + }, + ] + : []), + { + label: 'Total XP', + value: isPending ? '...' : totalXp.toLocaleString(), + }, + ]; + + return ( +
+
+
+ + {level} + +
+ + {name}, here's how you're doing. + +
+ +
+ +
+ + {xpInLevel.toLocaleString()} /{' '} + {(xpInLevel + xpToNextLevel).toLocaleString()} + +
+
+ +
+ {stats.map((stat) => ( + + ))} +
+
+ ); +}; diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx new file mode 100644 index 00000000000..c43350e696a --- /dev/null +++ b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx @@ -0,0 +1,272 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { UserAchievement } from '../../../../graphql/user/achievements'; +import { getTargetCount } from '../../../../graphql/user/achievements'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../../components/typography/Typography'; +import { + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { LazyImage } from '../../../../components/LazyImage'; +import { ProgressBar } from '../../../../components/fields/ProgressBar'; +import CloseButton from '../../../../components/CloseButton'; +import { Modal } from '../../../../components/modals/common/Modal'; +import { + ModalKind, + ModalSize, +} from '../../../../components/modals/common/types'; +import { + AchievementRarityTier, + getAchievementRarityTier, +} from './achievementRarity'; + +interface AchievementShelfCardProps { + userAchievement: UserAchievement; + isOwner?: boolean; + isTracked?: boolean; + isTrackPending?: boolean; + onTrack?: (achievementId: string) => Promise; + onUntrack?: () => Promise; + isUntrackPending?: boolean; +} + +const fallbackImage = 'https://daily.dev/default-achievement.png'; + +// The slab always spells the unlock date out as "Aug 2". The shared post +// formatter zero-pads the day and swaps in "Today"/"Yesterday", so it can't be +// reused here. +const formatUnlockedAt = (value: string): string => { + const date = new Date(value); + const now = new Date(); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }), + }); +}; + +// The slab treatment only distinguishes two rarity bands; the shared four-tier +// scale collapses onto them so the other achievement surfaces keep their tiers. +const isEmerald = (tier: AchievementRarityTier | null) => + tier === AchievementRarityTier.Emerald; + +// Gold is reserved for the sub-1% band, so it means something when it shows +// up; every other tier states its number on a plain dark chip. +const slabPillClasses: Record<'gold' | 'plain', string> = { + gold: 'bg-[#efab27] text-[#08110c]', + plain: 'bg-[rgba(8,10,13,0.72)] text-white', +}; + +export function AchievementShelfCard({ + userAchievement, + isOwner = false, + isTracked = false, + isTrackPending = false, + onTrack, + onUntrack, + isUntrackPending = false, +}: AchievementShelfCardProps): ReactElement { + const [isExpanded, setIsExpanded] = useState(false); + const { achievement, progress, unlockedAt } = userAchievement; + const targetCount = getTargetCount(achievement); + const isUnlocked = unlockedAt !== null; + const progressPercentage = Math.min((progress / targetCount) * 100, 100); + const rarityTier = isUnlocked + ? getAchievementRarityTier(achievement.rarity) + : null; + const slabTier = rarityTier + ? ((isEmerald(rarityTier) ? 'gold' : 'plain') as 'gold' | 'plain') + : null; + const rarityLabel = isEmerald(rarityTier) + ? '<1%' + : `${Math.round(achievement.rarity ?? 0)}%`; + const canTrack = !isUnlocked && isOwner && !!onTrack; + const progressLabel = `${progress.toLocaleString()} / ${targetCount.toLocaleString()}`; + + return ( + <> +
+ {/* `absolute` has to come from the prop: LazyImage appends its own + `relative` after our classes, and that wins in the compiled CSS. */} + + +
+ + {/* Covers the slab so the whole card opens the detail modal, without + nesting the track control inside another button. */} + + )} + +
+ + {achievement.name} + + + {achievement.description} + + + {isUnlocked ? ( + + Completed {formatUnlockedAt(unlockedAt)} + + ) : ( + <> + + {progressLabel} + + + + )} +
+
+ + {isExpanded && ( + setIsExpanded(false)} + kind={ModalKind.FlexibleCenter} + size={ModalSize.XSmall} + className="overflow-hidden" + > +
+
+ {achievement.name} + {slabTier && ( + + {rarityLabel} rare + + )} + setIsExpanded(false)} + /> +
+
+ + {achievement.name} + + + {achievement.description} + + + {isUnlocked ? ( + + Completed {formatUnlockedAt(unlockedAt)} + + ) : ( + <> + + {progressLabel} + + + + )} +
+
+
+ )} + + ); +} diff --git a/packages/shared/src/lib/image.ts b/packages/shared/src/lib/image.ts index 6fc885c972a..d7098af221b 100644 --- a/packages/shared/src/lib/image.ts +++ b/packages/shared/src/lib/image.ts @@ -290,6 +290,9 @@ export const purchaseCoinsCheckoutVideo = export const purchaseCoinsCheckoutVideoPoster = 'https://media.daily.dev/image/upload/s--A_4rXIh7--/f_auto/v1741779750/public/Giving%20cores'; +export const gameCenterLevelBackground = + 'https://media.daily.dev/image/upload/s--NCILTqRq--/f_auto,q_auto/v1785661216/public/daily.dev%20-%20main%20image'; + export const featuredAwardImage = 'https://media.daily.dev/image/upload/s--10Rf2kyK--/f_auto/v1743595864/public/Default'; diff --git a/packages/shared/src/styles/base.css b/packages/shared/src/styles/base.css index beb4d42a9e0..de4e0bbc9ca 100644 --- a/packages/shared/src/styles/base.css +++ b/packages/shared/src/styles/base.css @@ -1075,6 +1075,126 @@ meter::-webkit-meter-bar { will-change: transform, opacity, filter; } + @keyframes quest-claim-shine { + 0% { + left: -60%; + } + + 22% { + left: 130%; + } + + 100% { + left: 130%; + } + } + + .quest-claim-shine { + position: relative; + overflow: hidden; + isolation: isolate; + } + + .quest-claim-shine::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -60%; + width: 45%; + transform: skewX(-18deg); + pointer-events: none; + background: linear-gradient( + 100deg, + transparent, + rgb(255 255 255 / 0.75), + transparent + ); + animation: quest-claim-shine 2.6s ease-in-out infinite; + } + + @media (prefers-reduced-motion: reduce) { + .quest-claim-shine::after { + display: none; + } + } + + /* Copy is not selectable content here, it is chrome: the I-beam over every + label reads as though the page were a document. */ + .pointer-default, + .pointer-default * { + cursor: default; + } + + .pointer-default :is(a, button, [role='button'], summary) { + cursor: pointer; + } + + .pointer-default :is(input, textarea, [contenteditable='true']) { + cursor: text; + } + + .pointer-default :is(:disabled, [aria-disabled='true']) { + cursor: not-allowed; + } + + @keyframes level-badge-sheen { + from { + left: -80%; + } + + to { + left: 160%; + } + } + + .level-badge-glass { + position: relative; + overflow: hidden; + isolation: isolate; + transition: transform 300ms ease, box-shadow 300ms ease; + } + + .level-badge-glass:hover { + transform: translateY(-2px) scale(1.04) rotate(-2deg); + } + + /* A single wipe across the glass on hover, rather than a loop that would + pull the eye while the page is being read. */ + .level-badge-glass::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -80%; + width: 55%; + transform: skewX(-18deg); + pointer-events: none; + opacity: 0; + background: linear-gradient( + 100deg, + transparent, + rgb(255 255 255 / 0.7), + transparent + ); + } + + .level-badge-glass:hover::after { + opacity: 1; + animation: level-badge-sheen 900ms ease-out; + } + + @media (prefers-reduced-motion: reduce) { + .level-badge-glass, + .level-badge-glass:hover { + transform: none; + } + + .level-badge-glass::after { + display: none; + } + } + @keyframes float { 0%, 100% { transform: translateY(0); diff --git a/packages/storybook/stories/components/LevelHud.stories.tsx b/packages/storybook/stories/components/LevelHud.stories.tsx new file mode 100644 index 00000000000..7481e7ee0e6 --- /dev/null +++ b/packages/storybook/stories/components/LevelHud.stories.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { LevelHud } from '@dailydotdev/shared/src/components/quest/LevelHud'; + +const meta: Meta = { + title: 'Components/Quest/LevelHud', + component: LevelHud, + args: { + name: 'Tomer', + level: 14, + levelProgress: 70, + totalXp: 3420, + xpInLevel: 1400, + xpToNextLevel: 600, + currentStreak: 12, + longestStreak: 28, + badges: 4, + achievements: { unlocked: 9, total: 24 }, + isPending: false, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NewPlayer: Story = { + args: { + level: 1, + levelProgress: 5, + totalXp: 40, + xpInLevel: 25, + xpToNextLevel: 475, + currentStreak: 0, + longestStreak: 0, + achievements: { unlocked: 0, total: 24 }, + }, +}; + +export const MaxedOutStreak: Story = { + args: { + level: 42, + levelProgress: 95, + totalXp: 128400, + xpInLevel: 11400, + xpToNextLevel: 600, + currentStreak: 365, + longestStreak: 365, + achievements: { unlocked: 24, total: 24 }, + }, +}; + +export const WithoutAchievements: Story = { + args: { + achievements: undefined, + }, +}; + +export const Loading: Story = { + args: { + isPending: true, + }, +}; diff --git a/packages/storybook/stories/components/TopReaderBadgeCompact.stories.tsx b/packages/storybook/stories/components/TopReaderBadgeCompact.stories.tsx new file mode 100644 index 00000000000..0c1e6beb195 --- /dev/null +++ b/packages/storybook/stories/components/TopReaderBadgeCompact.stories.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react-vite'; +import { TopReaderBadgeCompact } from '@dailydotdev/shared/src/components/badges/TopReaderBadgeCompact'; + +const badges = [ + { issuedAt: new Date('2026-04-01'), keyword: { value: 'github-actions', flags: { title: 'GitHub Actions' } } }, + { issuedAt: new Date('2026-06-01'), keyword: { value: 'clickhouse', flags: { title: 'ClickHouse' } } }, + { issuedAt: new Date('2026-05-01'), keyword: { value: 'content-creation', flags: { title: 'Content Creation' } } }, + { issuedAt: new Date('2026-03-01'), keyword: { value: 'rust', flags: { title: 'Rust' } } }, + { issuedAt: new Date('2026-02-01'), keyword: { value: 'react', flags: { title: 'React' } } }, +]; + +const meta: Meta = { + title: 'components/TopReaderBadgeCompact', + component: TopReaderBadgeCompact, + render: () => { + return ( +
+
+
+ {badges.map((badge) => ( +
+ +
+ ))} +
+
+
+ ); + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Row: Story = {}; diff --git a/packages/storybook/stories/pages/BadgeTrophyDesigns.stories.tsx b/packages/storybook/stories/pages/BadgeTrophyDesigns.stories.tsx new file mode 100644 index 00000000000..4a77ae7e6a8 --- /dev/null +++ b/packages/storybook/stories/pages/BadgeTrophyDesigns.stories.tsx @@ -0,0 +1,345 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { TopReaderBadgeCompact } from '@dailydotdev/shared/src/components/badges/TopReaderBadgeCompact'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { DataTile } from '@dailydotdev/shared/src/components/DataTile'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + CoreIcon, + MedalBadgeIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { + DevCardTheme, + themeToLinearGradient, +} from '@dailydotdev/shared/src/components/profile/devcard'; +import type { AwardWithRarity } from '../../../webapp/lib/gameCenter'; +import { TrophyGrid } from '../../../webapp/components/game-center/TrophyGrid'; + +const badges = [ + { + issuedAt: new Date('2026-06-01'), + keyword: { value: 'clickhouse', flags: { title: 'ClickHouse' } }, + }, + { + issuedAt: new Date('2026-05-01'), + keyword: { value: 'rust', flags: { title: 'Rust' } }, + }, + { + issuedAt: new Date('2026-04-01'), + keyword: { value: 'github-actions', flags: { title: 'GitHub Actions' } }, + }, + { + issuedAt: new Date('2026-03-01'), + keyword: { value: 'react', flags: { title: 'React' } }, + }, +]; + +const awards: AwardWithRarity[] = [ + { id: 'diamond', name: 'Diamond', image: '', count: 1, value: 5000 }, + { id: 'crown', name: 'Crown', image: '', count: 2, value: 2000 }, + { id: 'medal', name: 'Medal', image: '', count: 4, value: 800 }, + { id: 'rocket', name: 'Rocket', image: '', count: 9, value: 300 }, + { id: 'fire', name: 'Fire', image: '', count: 14, value: 120 }, + { id: 'clap', name: 'Clap', image: '', count: 41, value: 20 }, +].map((award) => ({ ...award, imageGlow: null })) as AwardWithRarity[]; + +const SectionTitle = ({ children }: { children: string }) => ( + + {children} + +); + +const statTile = { + container: '!flex-row items-center gap-2 !border-0 !p-0', + label: '!typo-subhead', +}; + +const Counts = () => ( +
+ } + className={statTile} + /> + } + className={statTile} + /> +
+); + +const Header = ({ children }: { children?: React.ReactNode }) => ( +
+ Badges & Trophies + {children} +
+); + +const Frame = ({ label, children }: { label: string; children: React.ReactNode }) => ( +
+ + {label} + + {children} +
+); + +/** Design 1 — split holder: badges rail left, trophy grid right. */ +const SplitHolder = () => ( + +
+
+ +
+
+
+ + Top reader badges + +
+ {badges.map((badge) => ( +
+ + {badge.keyword.flags.title} + + + + Top reader + + +
+ ))} +
+
+
+ + Awards + + +
+
+
+ +); + +/** Design 2 — one wall: badges and awards as equal cells in a single grid. */ +const UnifiedWall = () => ( + +
+
+ +
+
+
+ {badges.map((badge) => ( +
+ + + + + {badge.keyword.flags.title} + + + Top reader + +
+ ))} + {awards.map((award) => ( +
+ + + + + {award.name} + + + ×{award.count} + +
+ ))} +
+
+
+ +); + +/** Design 3 — one holder, a segmented toggle picks the collection. */ +const TabbedCase = () => { + const [tab, setTab] = useState<'badges' | 'awards'>('badges'); + const tabs = [ + { id: 'badges' as const, label: `Badges (${badges.length})` }, + { id: 'awards' as const, label: `Awards (${awards.length})` }, + ]; + + return ( + +
+
+ +
+
+
+ {tabs.map((item) => ( + + ))} +
+ {tab === 'badges' ? ( +
+
+ {badges.map((badge) => ( +
+ +
+ ))} +
+
+ ) : ( + + )} +
+
+ + ); +}; + +const queryClient = new QueryClient(); + +const meta: Meta = { + title: 'Pages/Badge & Trophy Designs', + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( + // react-modal binds to #__next, which Next.js renders but Storybook + // does not. + +
+ +
+
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Design1SplitHolder: Story = { + render: () => ( +
+ +
+ ), +}; + +export const Design2OneWall: Story = { + render: () => ( +
+ +
+ ), +}; + +export const Design3TabbedCase: Story = { + render: () => ( +
+ +
+ ), +}; + +export const CompareAll: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/storybook/stories/pages/CommunityPulseDesigns.stories.tsx b/packages/storybook/stories/pages/CommunityPulseDesigns.stories.tsx new file mode 100644 index 00000000000..c4620768ad1 --- /dev/null +++ b/packages/storybook/stories/pages/CommunityPulseDesigns.stories.tsx @@ -0,0 +1,808 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { DataTile } from '@dailydotdev/shared/src/components/DataTile'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + ArrowIcon, + BookmarkIcon, + CoreIcon, + MedalBadgeIcon, + ReadingStreakIcon, + ReputationLightningIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; +import { CommunityPulse } from '../../../webapp/components/game-center/CommunityPulse'; + +/* ── shared mock data (real entries from the leaderboard API) ───────── */ + +type Person = { + name: string; + username: string; + image: string; + rep: number; + quests: number; +}; + +const PEOPLE: Person[] = [ + { + name: 'Bobby Iliev', + username: 'bobbyiliev', + image: 'https://avatars3.githubusercontent.com/u/21223421?v=4', + rep: 76550, + quests: 328, + }, + { + name: 'Joud Awad', + username: 'joudawad', + image: + 'https://media.daily.dev/image/upload/s--dOB9RaXY--/f_auto/v1773320801/avatars/avatar_iaC4JsBU0lV8wBsc85fSh', + rep: 74620, + quests: 210, + }, + { + name: 'Randy', + username: 'randy', + image: + 'https://media.daily.dev/image/upload/s--UjV4-KkB--/f_auto/v1708097210/avatars/avatar_HXYbbGcBO38Rfv7RrCBdA', + rep: 69050, + quests: 198, + }, + { + name: 'Ole-Martin', + username: 'ombratteng', + image: 'https://avatars.githubusercontent.com/u/1681525?v=4', + rep: 65260, + quests: 176, + }, + { + name: 'Denis Bolkovskis', + username: 'denisb0', + image: + 'https://media.daily.dev/image/upload/s--PGCuYx85--/f_auto,q_auto/v1/avatars/avatar_yRuVFf6IbfTylBjx9Dzvt', + rep: 56520, + quests: 155, + }, + { + name: 'OrcDev', + username: 'orcdev', + image: 'https://avatars.githubusercontent.com/u/7549148?v=4', + rep: 56390, + quests: 149, + }, + { + name: 'Anja P', + username: 'anjapcodes', + image: + 'https://media.daily.dev/image/upload/s--M_c0s8Ky--/f_auto/v1721658650/avatars/avatar_WVJSfJtDe63PxQFAsmXFO', + rep: 51350, + quests: 141, + }, + { + name: 'Chris Bongers', + username: 'dailydevtips', + image: + 'https://media.daily.dev/image/upload/s--9gxFz1e7--/f_auto/v1705902590/avatars/avatar_JUNiIGCV-', + rep: 51285, + quests: 138, + }, + { + name: 'Isaac de Andrade', + username: 'andradei', + image: 'https://avatars.githubusercontent.com/u/2653546?v=4', + rep: 51080, + quests: 132, + }, + { + name: 'Fabian Letsch', + username: 'fabianletsch', + image: + 'https://lh3.googleusercontent.com/a/ACg8ocKR6BVy_wn23EoOKq7-BlszlcXcLmASlnb7l-GtS-q1bePnkaJf=s96-c', + rep: 49620, + quests: 127, + }, +]; + +const VIEWER = { + name: 'Tomer Redlich', + username: 'tomer', + image: + 'https://media.daily.dev/image/upload/s--qsFuKGv_--/t_logo,f_auto/public/noProfile', + rep: 12420, + quests: 63, +}; + +const TRENDING = [ + { + name: 'To the back of the queue', + desc: 'Bookmark 1 post', + count: 15871, + when: 'All time', + }, + { + name: "I'll Get to It Any Day Now", + desc: 'Bookmark 3 posts', + count: 534, + when: 'This week', + }, +]; + +const compact = (n: number) => + n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}K` : `${n}`; + +const medalColor = [ + 'text-accent-cheese-default', + 'text-text-secondary', + 'text-accent-bun-default', +]; + +/* ── small shared pieces ────────────────────────────────────────────── */ + +const Avatar = ({ + person, + size = 40, +}: { + person: Person | typeof VIEWER; + size?: number; +}) => ( + {person.name} +); + +const Frame = ({ + title, + desc, + action, + children, +}: { + title: string; + desc: string; + action?: React.ReactNode; + children: React.ReactNode; +}) => ( +
+
+
+ + {title} + + + {desc} + +
+ {action} +
+ {children} +
+); + +const OpenAll = () => ( + +); + +const Card = ({ + children, + className = '', +}: { + children: React.ReactNode; + className?: string; +}) => ( +
+ {children} +
+); + +/* ══ 1 · Where you stand ════════════════════════════════════════════ */ + +const RankRow = ({ + rank, + person, + metric, + isViewer, +}: { + rank: number; + person: Person | typeof VIEWER; + metric: string; + isViewer?: boolean; +}) => ( +
+ + #{rank} + + +
+ + {isViewer ? 'You' : person.name} + +
+ + {metric} + +
+); + +const StandingCard = ({ + title, + unit, + viewerRank, + total, + rows, +}: { + title: string; + unit: string; + viewerRank: number; + total: number; + rows: { + rank: number; + person: Person | typeof VIEWER; + metric: string; + isViewer?: boolean; + }[]; +}) => { + const percentile = Math.max(1, Math.round((viewerRank / total) * 100)); + return ( + +
+ + {title} + + + top {percentile}% + +
+ + #{viewerRank.toLocaleString()} + + + of {total.toLocaleString()} by {unit} + +
+
+
+
+ {rows.map((r) => ( + + ))} +
+ + ); +}; + +const DesignOne = () => ( + } + > +
+ + +
+ + + 90K quests + completed all-time · most completed{' '} + + To the back of the queue + {' '} + (15,871) + + + +); + +/* ══ 2 · Merge and compact ══════════════════════════════════════════ */ + +const QuestRow = ({ q }: { q: (typeof TRENDING)[number] }) => ( +
+ + + +
+ + {q.name} + + + {q.desc} + +
+
+ + {q.count.toLocaleString()} + + + {q.when} + +
+
+); + +const MiniBoard = ({ + title, + rows, +}: { + title: string; + rows: { person: Person; metric: string }[]; +}) => ( +
+ + {title} + +
+ {rows.map((r, i) => ( +
+ + + + {r.person.name} + + + {r.metric} + +
+ ))} +
+
+); + +const DesignTwo = () => ( + + + } + className={{ + container: '!flex-row items-center gap-2 !border-0 !p-0', + }} + /> + +
+ } + > +
+ + + Trending quests + +
+ {TRENDING.map((q) => ( + + ))} +
+
+ + ({ + person: p, + metric: compact(p.rep), + }))} + /> + ({ + person: p, + metric: `${p.quests}`, + }))} + /> + +
+ +); + +/* ══ 3 · One card, tabbed ═══════════════════════════════════════════ */ + +const TABS = [ + { + key: 'rep', + label: 'Reputation', + unit: 'reputation', + icon: ReputationLightningIcon, + }, + { key: 'quests', label: 'Quests', unit: 'quests', icon: CoreIcon }, + { + key: 'streak', + label: 'Streak', + unit: 'day streak', + icon: ReadingStreakIcon, + }, +] as const; + +const DesignThree = () => { + const [tab, setTab] = useState<(typeof TABS)[number]['key']>('rep'); + const metricFor = (p: Person) => + tab === 'rep' + ? compact(p.rep) + : tab === 'quests' + ? `${p.quests}` + : `${Math.round(p.quests / 2)}d`; + const viewerMetric = + tab === 'rep' + ? compact(VIEWER.rep) + : tab === 'quests' + ? `${VIEWER.quests}` + : '31d'; + + return ( + } + > + +
+ {TABS.map((t) => ( + + ))} +
+ +
+ {PEOPLE.slice(0, 5).map((p, i) => ( +
+ + {i < 3 ? ( + + ) : ( + + {i + 1} + + )} + + +
+ + {p.name} + + + @{p.username} + +
+ + {metricFor(p)} + +
+ ))} + +
+ + 412 + + + + You + + + {viewerMetric} + +
+
+ +
+ + 90K quests + completed all-time · most completed{' '} + + To the back of the queue + {' '} + (15,871) + +
+
+ + ); +}; + +/* ══ 4 · Ambient pulse ══════════════════════════════════════════════ */ + +const Counter = ({ value, label }: { value: string; label: string }) => ( +
+ + {value} + + + {label} + +
+); + +const DesignFour = () => ( + } + > + +
+ + + +
+ +
+ + Top readers + +
+ {PEOPLE.map((p, i) => ( + + + {p.name} + {i < 3 && ( + + {i + 1} + + )} + + + ))} +
+
+
+ +); + +/* ── storybook wiring ───────────────────────────────────────────────── */ + +const queryClient = new QueryClient(); + +const meta: Meta = { + title: 'Pages/Community Pulse Designs', + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( + +
+
+ +
+
+
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const OneWhereYouStand: Story = { render: () => }; +export const TwoMergeAndCompact: Story = { render: () => }; +export const ThreeOneCardTabbed: Story = { render: () => }; +export const FourAmbientPulse: Story = { render: () => }; + +// The real component, wired the way the page wires it. +export const FourAsShipped: Story = { + render: () => ( + } + > + ({ + score: p.rep, + user: { id: `r${i}`, ...p }, + })) as never + } + mostQuestsCompleted={ + PEOPLE.map((p, i) => ({ + score: p.quests, + user: { id: `q${i}`, ...p }, + })) as never + } + /> + + ), +}; + +export const CompareAll: Story = { + render: () => ( +
+ {[ + ['1 · Where you stand', ], + ['2 · Merge and compact', ], + ['3 · One card, tabbed', ], + ['4 · Ambient pulse', ], + ].map(([label, node]) => ( +
+ + {label as string} + + {node as React.ReactNode} +
+ ))} +
+ ), +}; diff --git a/packages/storybook/stories/pages/CommunityPulseIdeas.stories.tsx b/packages/storybook/stories/pages/CommunityPulseIdeas.stories.tsx new file mode 100644 index 00000000000..b8fa6b29f58 --- /dev/null +++ b/packages/storybook/stories/pages/CommunityPulseIdeas.stories.tsx @@ -0,0 +1,588 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + MedalBadgeIcon, + ReputationLightningIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; + +/* ── mock data (shapes match the leaderboard API) ───────────────────── */ + +type Person = { + name: string; + username: string; + image: string; + rep: number; + quests: number; +}; + +const PEOPLE: Person[] = [ + { + name: 'Bobby Iliev', + username: 'bobbyiliev', + image: 'https://avatars3.githubusercontent.com/u/21223421?v=4', + rep: 76550, + quests: 328, + }, + { + name: 'Joud Awad', + username: 'joudawad', + image: + 'https://media.daily.dev/image/upload/s--dOB9RaXY--/f_auto/v1773320801/avatars/avatar_iaC4JsBU0lV8wBsc85fSh', + rep: 74620, + quests: 210, + }, + { + name: 'Randy', + username: 'randy', + image: + 'https://media.daily.dev/image/upload/s--UjV4-KkB--/f_auto/v1708097210/avatars/avatar_HXYbbGcBO38Rfv7RrCBdA', + rep: 69050, + quests: 188, + }, + { + name: 'Nimrod Kramer', + username: 'nimrod', + image: + 'https://media.daily.dev/image/upload/s--qsFuKGv_--/t_logo,f_auto/public/noProfile', + rep: 54300, + quests: 164, + }, + { + name: 'Ido Shamun', + username: 'idoshamun', + image: + 'https://media.daily.dev/image/upload/s--qsFuKGv_--/t_logo,f_auto/public/noProfile', + rep: 48900, + quests: 151, + }, + { + name: 'Chris Bongers', + username: 'dailydevtips', + image: + 'https://media.daily.dev/image/upload/s--qsFuKGv_--/t_logo,f_auto/public/noProfile', + rep: 41200, + quests: 143, + }, +]; + +const VIEWER = { + name: 'Tomer Redlich', + username: 'tomer', + image: + 'https://media.daily.dev/image/upload/s--qsFuKGv_--/t_logo,f_auto/public/noProfile', + rep: 12420, + quests: 63, + rank: 412, +}; + +const TOTAL_PARTICIPANTS = 90000; +const TOTAL_QUESTS = 90000; + +/** Weekly completions, oldest first — a plain series, no API for it yet. */ +const WEEKLY = [320, 410, 380, 520, 610, 570, 780, 690, 860, 940, 880, 1020]; + +const compact = (n: number) => + n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}K` : `${n}`; + +const podiumRing = [ + 'ring-accent-cheese-default', + 'ring-text-secondary', + 'ring-accent-bun-default', +]; + +const Avatar = ({ + person, + size = 40, + ring, +}: { + person: Person | typeof VIEWER; + size?: number; + ring?: string; +}) => ( + + {person.name} + +); + +const Frame = ({ + label, + note, + children, +}: { + label: string; + note: string; + children: React.ReactNode; +}) => ( +
+
+ + {label} + + + {note} + +
+ + Community pulse + + {children} +
+); + +const Cell = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => ( +
+ {children} +
+); + +const Counter = ({ value, label }: { value: string; label: string }) => ( +
+ + {value} + + + {label} + +
+); + +/* ── Idea 1 — Podium ────────────────────────────────────────────────── */ + +const Podium = () => { + const [first, second, third] = PEOPLE; + const steps = [ + { person: second, height: 'h-16', place: 2 }, + { person: first, height: 'h-24', place: 1 }, + { person: third, height: 'h-12', place: 3 }, + ]; + + return ( + + +
+ {steps.map(({ person, height, place }) => ( +
+ + + {person.name} + + + {compact(person.rep)} + +
+ + {place} + +
+
+ ))} +
+
+ {PEOPLE.slice(3).map((person, index) => ( +
+ + {index + 4} + + + + {person.name} + + + {compact(person.rep)} + +
+ ))} +
+
+ + ); +}; + +/* ── Idea 2 — You are here ──────────────────────────────────────────── */ + +const YouAreHere = () => { + const percentile = Math.max( + 1, + Math.round((VIEWER.rank / TOTAL_PARTICIPANTS) * 100), + ); + + return ( + + +
+ +
+ + #{VIEWER.rank.toLocaleString()} + + + Top {percentile}% by reputation + +
+ +
+ + {/* Position along the whole field, leaders pinned at the top end. */} +
+
+
+ +
+
+ + You + + + {compact(PEOPLE[0].rep)} — {PEOPLE[0].name} + +
+
+ +
+ + Ahead of you + +
+ {PEOPLE.slice(0, 5).map((person) => ( + + ))} +
+
+ + + ); +}; + +/* ── Idea 3 — Ticker ────────────────────────────────────────────────── */ + +const FEED = [ + { person: PEOPLE[0], quest: 'To the back of the queue', when: '2m' }, + { person: PEOPLE[3], quest: 'Read 100 posts', when: '11m' }, + { person: PEOPLE[1], quest: "I'll Get to It Any Day Now", when: '24m' }, + { person: PEOPLE[4], quest: 'Maintain a 7-day streak', when: '38m' }, + { person: PEOPLE[2], quest: 'Upvote 200 posts', when: '1h' }, +]; + +const Ticker = () => ( + + +
+ + + Live + +
+
+ {FEED.map((row, index) => ( +
+ + + {row.person.name} completed {row.quest} + + + {row.when} + +
+ ))} +
+
+ +); + +/* ── Idea 4 — Two races ─────────────────────────────────────────────── */ + +const RaceColumn = ({ + title, + icon, + people, + metric, +}: { + title: string; + icon: React.ReactNode; + people: Person[]; + metric: (p: Person) => number; +}) => { + const max = Math.max(...people.map(metric)); + + return ( + +
+ {icon} + + {title} + +
+
+ {people.slice(0, 5).map((person, index) => ( +
+ + {index + 1} + + +
+
+ + {person.name} + + + {compact(metric(person))} + +
+
+
+
+
+
+ ))} +
+ + ); +}; + +const TwoRaces = () => ( + +
+ + } + people={PEOPLE} + metric={(p) => p.rep} + /> + + } + people={[...PEOPLE].sort((a, b) => b.quests - a.quests)} + metric={(p) => p.quests} + /> +
+ +); + +/* ── Idea 5 — Momentum band ─────────────────────────────────────────── */ + +const MomentumBand = () => { + const max = Math.max(...WEEKLY); + const [hovered, setHovered] = useState(null); + + return ( + + +
+
+ + + +16% this week + +
+ {/* Weekly completions. No endpoint for this series yet. */} +
+ {WEEKLY.map((value, index) => ( + + setHovered(index)} + onMouseLeave={() => setHovered(null)} + className={`flex-1 rounded-4 ${ + hovered === index + ? 'bg-accent-cabbage-bolder' + : 'bg-accent-cabbage-default' + }`} + style={{ height: `${(value / max) * 100}%` }} + /> + + ))} +
+
+
+ {[...PEOPLE, ...PEOPLE, ...PEOPLE].map((person, index) => ( + + ))} + + and millions of developers + +
+
+ + ); +}; + +/* ── stories ────────────────────────────────────────────────────────── */ + +const queryClient = new QueryClient(); + +const meta: Meta = { + title: 'Pages/Community Pulse Ideas', + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const wrap = (node: React.ReactNode) => ( +
{node}
+); + +export const Idea1Podium: Story = { render: () => wrap() }; +export const Idea2YouAreHere: Story = { render: () => wrap() }; +export const Idea3Ticker: Story = { render: () => wrap() }; +export const Idea4TwoRaces: Story = { render: () => wrap() }; +export const Idea5MomentumBand: Story = { + render: () => wrap(), +}; + +export const CompareAll: Story = { + render: () => ( +
+ + + + + +
+ ), +}; diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx new file mode 100644 index 00000000000..80782e8724c --- /dev/null +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -0,0 +1,638 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { LevelHud } from '@dailydotdev/shared/src/components/quest/LevelHud'; +import { SeeAllAchievementsCard } from '../../../webapp/components/game-center/SeeAllAchievementsCard'; +import { TopReaderBadgeCompact } from '@dailydotdev/shared/src/components/badges/TopReaderBadgeCompact'; +import { AchievementShelfCard } from '@dailydotdev/shared/src/features/profile/components/achievements/AchievementShelfCard'; +import type { UserAchievement } from '@dailydotdev/shared/src/graphql/user/achievements'; +import { AchievementType } from '@dailydotdev/shared/src/graphql/user/achievements'; +import type { UserQuest } from '@dailydotdev/shared/src/graphql/quests'; +import { + QuestRewardType, + QuestStatus, + QuestType, +} from '@dailydotdev/shared/src/graphql/quests'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { DataTile } from '@dailydotdev/shared/src/components/DataTile'; +import { + CoreIcon, + MedalBadgeIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { MilestoneQuestList } from '../../../webapp/components/game-center/MilestoneQuestList'; +import { CommunityPulse } from '../../../webapp/components/game-center/CommunityPulse'; +import { TrophyGrid } from '../../../webapp/components/game-center/TrophyGrid'; +import { + BadgePager, + BadgeTrophyCase, +} from '../../../webapp/components/game-center/BadgeTrophyCase'; +import type { AwardWithRarity } from '../../../webapp/lib/gameCenter'; + +const SectionHeader = ({ title }: { title: string }) => ( + + {title} + +); + +const quest = ( + id: string, + name: string, + description: string, + eventType: string, + progress: number, + targetCount: number, + overrides: Partial = {}, +): UserQuest => ({ + userQuestId: `${id}-user`, + rotationId: `${id}-rotation`, + progress, + status: QuestStatus.InProgress, + completedAt: null, + claimedAt: null, + locked: false, + claimable: false, + rewards: [ + { type: QuestRewardType.Cores, amount: 500 }, + { type: QuestRewardType.Xp, amount: 120 }, + ], + quest: { + id, + name, + description, + type: QuestType.Milestone, + eventType, + targetCount, + }, + ...overrides, +}); + +const milestoneQuests: UserQuest[] = [ + quest( + 'bookmarks', + 'First 10 bookmarks', + 'Goal reached and reward collected.', + 'bookmark_post', + 10, + 10, + { + status: QuestStatus.Claimed, + claimedAt: new Date('2026-08-01'), + rewards: [{ type: QuestRewardType.Cores, amount: 250 }], + }, + ), + quest( + 'comments', + 'Comment on 25 posts', + 'Share your take across the feed.', + 'comment_create', + 18, + 25, + ), + quest( + 'streak', + 'Maintain a 7-day streak', + 'You did it. Claim before it resets.', + 'reading_streak', + 7, + 7, + { + claimable: true, + status: QuestStatus.Completed, + rewards: [ + { type: QuestRewardType.Cores, amount: 1000 }, + { type: QuestRewardType.Xp, amount: 250 }, + ], + }, + ), + quest( + 'reads', + 'Read 100 posts', + 'Keep reading to reach the milestone.', + 'read_post', + 65, + 100, + { + rewards: [ + { type: QuestRewardType.Cores, amount: 500 }, + { type: QuestRewardType.Reputation, amount: 50 }, + ], + }, + ), + quest( + 'level', + 'Reach level 10', + 'Unlock with daily.dev Plus.', + 'level_up', + 4, + 10, + { + locked: true, + rewards: [{ type: QuestRewardType.Cores, amount: 5000 }], + }, + ), + quest( + 'upvotes', + 'Upvote 200 posts', + 'Milestone complete. Reward waiting.', + 'post_upvote', + 200, + 200, + { + claimable: true, + status: QuestStatus.Completed, + rewards: [ + { type: QuestRewardType.Cores, amount: 2000 }, + { type: QuestRewardType.Reputation, amount: 100 }, + ], + }, + ), +]; + +// Real entries from the achievements catalogue, so the shelf shows the +// actual artwork and rarity spread rather than one repeated placeholder. +const achievement = ( + name: string, + description: string, + image: string, + unit: string | null, + progress: number, + targetCount: number, + rarity: number | null, + unlockedAt: string | null, +): UserAchievement => ({ + achievement: { + id: name, + name, + description, + image, + type: AchievementType.Milestone, + criteria: { targetCount }, + points: 100, + rarity, + unit, + }, + progress, + unlockedAt, + createdAt: null, + updatedAt: null, +}); + +const achievements: UserAchievement[] = [ + achievement( + 'Committed', + 'Reach a 50-day reading streak', + 'https://media.daily.dev/image/upload/v1770222887/achievements/Comitted.png', + 'day streak', + 50, + 50, + 1.98, + '2026-08-02T00:00:00.000Z', + ), + achievement( + 'Boosted', + 'Boost a post', + 'https://media.daily.dev/image/upload/v1770222884/achievements/Boosted.png', + null, + 1, + 1, + 0.06, + '2026-08-09T00:00:00.000Z', + ), + achievement( + 'Organized', + 'Create a bookmark folder', + 'https://media.daily.dev/image/upload/v1770222923/achievements/Organized.png', + null, + 1, + 1, + 0.09, + '2026-07-28T00:00:00.000Z', + ), + achievement( + 'Curator', + 'Have 10 different shared links clicked', + 'https://media.daily.dev/image/upload/v1770222887/achievements/Curator.png', + 'shared links clicked', + 10, + 10, + 0.49, + '2026-07-14T00:00:00.000Z', + ), + achievement( + 'Good stuff, buddy!', + 'Receive your first upvote', + 'https://media.daily.dev/image/upload/v1770222888/achievements/Good_stuff_buddy.png', + 'upvotes received', + 1, + 1, + 5.79, + '2026-06-30T00:00:00.000Z', + ), + achievement( + "You're the cool kid!", + 'Receive 100 upvotes', + 'https://media.daily.dev/image/upload/v1770222932/achievements/You_re_the_cool_kid.png', + 'upvotes received', + 63, + 100, + 0.53, + null, + ), + achievement( + 'In the big league', + 'Gain 10000 reputation', + 'https://media.daily.dev/image/upload/v1770222928/achievements/In_the_big_league.png', + 'reputation', + 6420, + 10000, + 0.05, + null, + ), + achievement( + 'Touch grass', + 'Earn 10 top reader badges', + 'https://media.daily.dev/image/upload/v1770222937/achievements/Touch_grass.png', + 'badges', + 9, + 10, + 1.44, + null, + ), + achievement( + 'Upvote economy', + 'Upvote 100 posts', + 'https://media.daily.dev/image/upload/s--yaK6lPac--/c_fill,h_512,q_auto,w_512/v1770800203/achievements/upvote_economy.png', + 'posts upvoted', + 71, + 100, + 1.29, + null, + ), + achievement( + 'Prophet', + 'Gain 100 followers', + 'https://media.daily.dev/image/upload/v1770222920/achievements/Prophet.png', + 'followers', + 38, + 100, + 0.27, + null, + ), + achievement( + 'User feedback', + 'Upvote 50 comments', + 'https://media.daily.dev/image/upload/s--1A72LwN2--/q_auto/v1770765983/achievements/User_feedback.png', + 'comments upvoted', + 12, + 50, + 0.43, + null, + ), + achievement( + 'I took "daily dev" literally', + 'Reach a 365-day reading streak', + 'https://media.daily.dev/image/upload/v1770224984/achievements/I_took_dailydev_literally.png', + 'day streak', + 50, + 365, + 0.28, + null, + ), +]; + +// Same order the page applies: completed first, rarest leading, then the +// in-progress ones with the closest to done on the left. +const shelfOrder = [...achievements].sort((left, right) => { + const ratio = (item: UserAchievement) => + Math.min(item.progress / (item.achievement.criteria?.targetCount ?? 1), 1); + const difference = ratio(right) - ratio(left); + + if (difference !== 0) { + return difference; + } + + return ( + (left.achievement.rarity ?? Number.POSITIVE_INFINITY) - + (right.achievement.rarity ?? Number.POSITIVE_INFINITY) + ); +}); + +// Real logos, resolved from the sources library by keyword. GitHub Actions +// has no matching source, so it exercises the initial fallback. +const logo = (slug: string) => + `https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/${slug}`; + +const badges = [ + { + issuedAt: new Date('2026-06-01'), + keyword: { value: 'clickhouse', flags: { title: 'ClickHouse' } }, + image: logo('clickhouse') as string | undefined, + }, + { + issuedAt: new Date('2026-05-01'), + keyword: { value: 'rust', flags: { title: 'Rust' } }, + image: logo('8fb725c4025846578f65c8eada2fc5b8'), + }, + { + issuedAt: new Date('2026-04-01'), + keyword: { value: 'github-actions', flags: { title: 'GitHub Actions' } }, + }, + { + issuedAt: new Date('2026-03-01'), + keyword: { value: 'react', flags: { title: 'React' } }, + image: logo('react_js'), + }, + { + issuedAt: new Date('2026-02-01'), + keyword: { value: 'typescript', flags: { title: 'TypeScript' } }, + }, + { + issuedAt: new Date('2026-01-01'), + keyword: { value: 'postgresql', flags: { title: 'PostgreSQL' } }, + }, + { + issuedAt: new Date('2025-12-01'), + keyword: { value: 'kubernetes', flags: { title: 'Kubernetes' } }, + }, + { + issuedAt: new Date('2025-11-01'), + keyword: { value: 'go', flags: { title: 'Go' } }, + }, + { + issuedAt: new Date('2025-10-01'), + keyword: { value: 'webassembly', flags: { title: 'WebAssembly' } }, + }, +]; + +// The real catalogue art, so the grid shows the spread of awards rather +// than the same default for every one. +const awardArt = (name: string) => + `https://media.daily.dev/image/upload/s--10Rf2kyK--/f_auto/v1743595864/public/${name}`; + +const awards: AwardWithRarity[] = [ + { id: 'bug', name: 'Bug', image: awardArt('Bug'), count: 1, value: 750 }, + { id: 'duck', name: 'Duck', image: awardArt('Duck'), count: 2, value: 600 }, + { + id: 'terminal', + name: 'Terminal', + image: awardArt('Terminal'), + count: 4, + value: 400, + }, + { id: 'cash', name: 'Cash', image: awardArt('Cash'), count: 9, value: 250 }, + { id: 'pizza', name: 'Pizza', image: awardArt('Pizza'), count: 14, value: 150 }, + { + id: 'hotdog', + name: 'Hotdog', + image: awardArt('Hotdog'), + count: 21, + value: 125, + }, + { id: 'star', name: 'Star', image: awardArt('Star'), count: 33, value: 100 }, + { + id: 'coffee', + name: 'Coffee', + image: awardArt('Coffee'), + count: 41, + value: 75, + }, +].map((award) => ({ ...award, imageGlow: null })) as AwardWithRarity[]; + +const awardsByCount = [...awards].sort((left, right) => right.count - left.count); + +// Live rows from the two leaderboards, so the magnitudes are real: +// achievement points sit in the hundreds, not the tens of thousands. +const pointsLeaders = [ + [ + 'Ole-Martin', + 'ombratteng', + 'https://avatars.githubusercontent.com/u/1681525?v=4', + 1275, + ], + [ + 'Ante Baric', + 'capjavert', + 'https://media.daily.dev/image/upload/v1679300599/avatars/avatar_LJSkpBexOSCWc8INyu3Eu.jpg', + 1235, + ], + [ + 'Bobby Iliev', + 'bobbyiliev', + 'https://avatars3.githubusercontent.com/u/21223421?v=4', + 1220, + ], + [ + 'Serdarcan Buyukdereli', + 'serdarbuyukdereli', + 'https://media.daily.dev/image/upload/s--tTV8hAPq--/f_auto/v1778701721/avatars/avatar_Su5HqluAE4wLRb1naHjtv', + 1180, + ], + [ + 'Hadil Ben Abdallah', + 'hadilbenabdallah', + 'https://media.daily.dev/image/upload/s--qsFuKGv_--/t_logo,f_auto/public/noProfile', + 1135, + ], +] as const; + +const questLeaders = [ + [ + 'Laszlo Szabo', + 'lezli01', + 'https://lh3.googleusercontent.com/a/ACg8ocKj3v7EFYJoXUUuro6ALF9fD3RTRATiBpBOVcOzqro4fy6bWqMm=s96-c', + 398, + ], + [ + 'Jay', + 'finallyjay', + 'https://lh3.googleusercontent.com/a/ACg8ocL5i3hkxSSWLLoubyZSkPBN6T_N7QRlwpPOOyQWeyJV53Q=s96-c', + 352, + ], + [ + 'Bobby Iliev', + 'bobbyiliev', + 'https://avatars3.githubusercontent.com/u/21223421?v=4', + 335, + ], + [ + 'Keith Solomon', + 'ksolomon', + 'https://avatars.githubusercontent.com/u/251996?v=4', + 315, + ], + [ + 'Keshav Ashiya', + 'keshavashiya', + 'https://avatars0.githubusercontent.com/u/20239068?v=4', + 308, + ], +] as const; + +const board = (byQuests: boolean) => + (byQuests ? questLeaders : pointsLeaders).map( + ([name, username, image, score], i) => ({ + score, + user: { id: `${byQuests ? 'q' : 'p'}${i}`, name, username, image }, + }), + ) as never; + +// PLACEHOLDER, matching the page: no leaderboard exists for badge counts. +const topReaderBoard = [ + ['Ole-Martin', 'ombratteng', 24], + ['Bobby Iliev', 'bobbyiliev', 19], + ['Ante Baric', 'capjavert', 17], + ['Jay', 'finallyjay', 15], + ['Keith Solomon', 'ksolomon', 12], +].map(([name, username, score], i) => ({ + score, + user: { id: `t${i.toString()}`, name, username, image: '' }, +})) as never; + +const communityStats = { + totalCount: 90000, + allTimeLeader: { + questId: '1', + questName: 'To the back of the queue', + questDescription: 'Bookmark 1 post', + count: 15871, + }, + weeklyLeader: { + questId: '2', + questName: "I'll Get to It Any Day Now", + questDescription: 'Bookmark 3 posts', + count: 534, + }, +}; + + +const GameCenterRedesign = () => ( +
+
+ +
+ +
+ + undefined} + + /> +
+ + + + +
+ +
+ {shelfOrder.map((item) => ( + undefined} + onUntrack={async () => undefined} + /> + ))} + +
+
+ + +
+ + ({ + ...badge, + id: badge.keyword.value, + total: badges.length - index, + user: { name: 'Tomer', username: 'tomer', image: '' }, + }))} + /> + } + badgeStats={[ + { label: 'Topics mastered', value: badges.length.toString() }, + ]} + awards={} + awardStats={[ + { label: 'Total awards', value: '87' }, + { + label: 'Total earned', + icon: ( + + ), + value: awards + .reduce((total, award) => total + award.value * award.count, 0) + .toLocaleString(), + }, + ]} + /> +
+ +
+ + +
+
+); + +const queryClient = new QueryClient(); + +const meta: Meta = { + title: 'Pages/Game Center Redesign', + component: GameCenterRedesign, + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( + // react-modal binds to #__next, which Next.js renders but Storybook + // does not; without it the achievement detail modal throws on open. + +
+ +
+
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const AllSections: Story = {}; diff --git a/packages/storybook/stories/pages/ProgressHeroDesigns.stories.tsx b/packages/storybook/stories/pages/ProgressHeroDesigns.stories.tsx new file mode 100644 index 00000000000..0c08a02ea0c --- /dev/null +++ b/packages/storybook/stories/pages/ProgressHeroDesigns.stories.tsx @@ -0,0 +1,338 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { ProgressBar } from '@dailydotdev/shared/src/components/fields/ProgressBar'; +import { gameCenterLevelBackground } from '@dailydotdev/shared/src/lib/image'; + +/* ── shared bits ────────────────────────────────────────────────────── */ + +const LEVEL = 14; +const PROGRESS = 70; +const XP_IN = 1400; +const XP_TARGET = 2000; + +const STATS = [ + { label: 'Streak', value: '12d' }, + { label: 'Longest', value: '28d' }, + { label: 'Badges', value: '9/24' }, + { label: 'Total XP', value: '3,420' }, +]; + +// The shipped panel's treatment: the marketing hero under a purple wash +// that lifts on the right, where nothing overlaps it. +const panelStyle = { + backgroundColor: '#2A0B3D', + backgroundImage: [ + 'linear-gradient(100deg, rgba(42,11,61,0.94) 0%, rgba(42,11,61,0.70) 42%, rgba(42,11,61,0.42) 100%)', + `url("${gameCenterLevelBackground}")`, + ].join(', '), + backgroundSize: 'auto, cover', + backgroundPosition: 'center, 46% 50%', +}; + +const glassStyle = { + background: + 'linear-gradient(145deg, rgba(255,255,255,0.42), rgba(255,255,255,0.10))', + border: '1px solid rgba(255,255,255,0.45)', +}; + +const glassTileStyle = { + background: + 'linear-gradient(145deg, rgba(255,255,255,0.16), rgba(255,255,255,0.05))', + border: '1px solid rgba(255,255,255,0.18)', +}; + +const LevelBadge = ({ size = 'size-18' }: { size?: string }) => ( +
+ + {LEVEL} + +
+); + +const Bar = () => ( + +); + +const XpReadout = () => ( + + {XP_IN.toLocaleString()} / {XP_TARGET.toLocaleString()} + +); + +const GlassStat = ({ label, value }: { label: string; value: string }) => ( +
+ + {label} + + + {value} + +
+); + +const Frame = ({ + label, + note, + children, +}: { + label: string; + note: string; + children: React.ReactNode; +}) => ( +
+
+ + {label} + + + {note} + +
+ {children} + {/* what the page would show underneath */} +
+ + Milestone quests + +
+
+); + +/* ── Hero 1 — Wide banner ───────────────────────────────────────────── */ + +const WideBanner = () => ( + +
+
+
+ +
+ + Tomer, here's how you're doing. + + + 600 XP to level {LEVEL + 1} + +
+
+ + +
+
+ {STATS.map((stat) => ( + + ))} +
+
+ +); + +/* ── Hero 2 — Stacked hero ──────────────────────────────────────────── */ + +const StackedHero = () => ( + +
+
+
+ +
+ + Level {LEVEL} + + + Tomer, here's how you're doing. + +
+
+
+ +
+ + + 600 XP to level {LEVEL + 1} + +
+
+
+
+ {STATS.map((stat) => ( +
+ + {stat.label} + + + {stat.value} + +
+ ))} +
+
+ +); + +/* ── Hero 3 — HUD bar ───────────────────────────────────────────────── */ + +const HudBar = () => ( + +
+
+ +
+ + Tomer + + + Level {LEVEL} + +
+
+ +
+ +
+ + {XP_IN.toLocaleString()} / {XP_TARGET.toLocaleString()} + + + 600 XP to level {LEVEL + 1} + +
+
+ +
+ {STATS.map((stat) => ( +
+ + {stat.label} + + + {stat.value} + +
+ ))} +
+
+ +); + +/* ── stories ────────────────────────────────────────────────────────── */ + +const queryClient = new QueryClient(); + +const meta: Meta = { + title: 'Pages/Progress Hero Designs', + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Hero1WideBanner: Story = { render: () => }; +export const Hero2StackedHero: Story = { render: () => }; +export const Hero3HudBar: Story = { render: () => }; + +export const CompareAll: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/storybook/tailwind.config.ts b/packages/storybook/tailwind.config.ts index cdc29928af3..b516555c525 100644 --- a/packages/storybook/tailwind.config.ts +++ b/packages/storybook/tailwind.config.ts @@ -7,6 +7,9 @@ export default { './src/**/*.{ts,tsx}', './stories/**/*.{ts,tsx}', './node_modules/@dailydotdev/shared/src/**/*.{ts,tsx}', + // Stories that render webapp-only components need their classes generated + // too, otherwise the preview silently drops whatever shared never uses. + '../webapp/components/**/*.{ts,tsx}', ], safelist: [ { diff --git a/packages/webapp/__tests__/GameCenterStaticProps.spec.ts b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts index dee394ed971..687112e579d 100644 --- a/packages/webapp/__tests__/GameCenterStaticProps.spec.ts +++ b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts @@ -7,7 +7,7 @@ import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/L import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import type { QuestCompletionStats } from '@dailydotdev/shared/src/graphql/leaderboard'; import { - HIGHEST_REPUTATION_QUERY, + MOST_ACHIEVEMENT_POINTS_QUERY, MOST_QUESTS_COMPLETED_QUERY, QUEST_COMPLETION_STATS_QUERY, } from '@dailydotdev/shared/src/graphql/leaderboard'; @@ -42,6 +42,9 @@ jest.mock('@tanstack/react-query', () => { return { ...actual, useQuery: jest.fn(), + // The offer cards mutate on mount, and these tests render without a + // QueryClientProvider. + useMutation: jest.fn(() => ({ mutate: jest.fn(), isPending: false })), }; }); @@ -177,8 +180,8 @@ describe('game center static props', () => { it('should include quest completion stats when the schema supports them', async () => { mockRequest.mockImplementation((query: string) => { - if (query === HIGHEST_REPUTATION_QUERY) { - return Promise.resolve({ highestReputation }); + if (query === MOST_ACHIEVEMENT_POINTS_QUERY) { + return Promise.resolve({ mostAchievementPoints: highestReputation }); } if (query === MOST_QUESTS_COMPLETED_QUERY) { @@ -205,8 +208,8 @@ describe('game center static props', () => { it('should keep leaderboards when quest completion stats are not yet in the schema', async () => { mockRequest.mockImplementation((query: string) => { - if (query === HIGHEST_REPUTATION_QUERY) { - return Promise.resolve({ highestReputation }); + if (query === MOST_ACHIEVEMENT_POINTS_QUERY) { + return Promise.resolve({ mostAchievementPoints: highestReputation }); } if (query === MOST_QUESTS_COMPLETED_QUERY) { @@ -692,7 +695,7 @@ describe('game center client gating', () => { ).toBeInTheDocument(); }); - it('should render all milestone quests in a two-column grid without a show more toggle', () => { + it('should render every milestone quest as a stacked card without a show more toggle', () => { mockUseConditionalFeature.mockReturnValue({ value: false, isLoading: false, @@ -746,15 +749,31 @@ describe('game center client gating', () => { }), ); - const milestoneGrid = screen.getByText('Milestones').nextElementSibling; + const milestoneSection = document.getElementById( + gameCenterMilestoneSectionId, + ); - expect(milestoneGrid).toBeInTheDocument(); - expect(screen.getByText('Milestone quest 4')).toBeInTheDocument(); - expect( - within(milestoneGrid as HTMLElement).getByText('Milestone quest 5'), - ).toBeInTheDocument(); + expect(milestoneSection).toBeInTheDocument(); - expect(milestoneGrid).toHaveClass('grid', 'tablet:grid-cols-2'); + const renderedNames = Array.from( + within(milestoneSection as HTMLElement).getAllByRole('heading', { + level: 4, + }), + ).map((heading) => heading.textContent); + + // Every quest here is claimable, so the closest-to-target one leads. + expect(renderedNames).toEqual([ + 'Milestone quest 5', + 'Milestone quest 4', + 'Milestone quest 3', + 'Milestone quest 2', + 'Milestone quest 1', + ]); + expect( + within(milestoneSection as HTMLElement).getAllByRole('button', { + name: 'Claim', + }), + ).toHaveLength(5); expect( screen.queryByRole('button', { name: /Show (more|less)/ }), ).not.toBeInTheDocument(); diff --git a/packages/webapp/components/game-center/BadgeTrophyCase.tsx b/packages/webapp/components/game-center/BadgeTrophyCase.tsx new file mode 100644 index 00000000000..df9aef7933c --- /dev/null +++ b/packages/webapp/components/game-center/BadgeTrophyCase.tsx @@ -0,0 +1,230 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + ArrowIcon, + MedalBadgeIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { + DevCardTheme, + themeToLinearGradient, +} from '@dailydotdev/shared/src/components/profile/devcard'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import type { TopReader } from '@dailydotdev/shared/src/components/badges/TopReaderBadge'; +import { + formatDate, + TimeFormatType, +} from '@dailydotdev/shared/src/lib/dateFormat'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; + +export const BadgeRow = ({ + issuedAt, + keyword, + image, +}: Pick): ReactElement => { + const title = keyword.flags?.title || keyword.value; + + return ( +
+ {image ? ( + {`${title} + ) : ( + // The keyword carries no artwork of its own, so an initial stands in. + + {title.charAt(0).toUpperCase()} + + )} +
+ + {title} + + + {formatDate({ + value: issuedAt, + type: TimeFormatType.TopReaderBadge, + })} + +
+ + + + Top reader + + +
+ ); +}; + +export const badgePageSize = 4; + +type BadgePagerProps = { + badges: TopReader[]; +}; + +export const BadgePager = ({ badges }: BadgePagerProps): ReactElement => { + const [page, setPage] = useState(0); + const pageCount = Math.ceil(badges.length / badgePageSize); + const start = page * badgePageSize; + const visible = badges.slice(start, start + badgePageSize); + + return ( +
+ {visible.map((badge) => ( + + ))} + + {/* A short last page would otherwise change the column's height and + shift everything below it. */} + {Array.from({ length: badgePageSize - visible.length }, (_, index) => ( +
+ +
+ ))} + + {pageCount > 1 && ( +
+ + {start + 1}-{start + visible.length} of {badges.length} + +
+
+
+ )} +
+ ); +}; + +type PaneStat = { + label: string; + value: string; + icon?: ReactNode; +}; + +// A faint gold wash and a glint above it, giving the top reader pane the +// vibe of the gold chips it holds. +const goldPaneStyle = { + backgroundImage: [ + 'radial-gradient(120% 90% at 78% 8%, rgba(255,214,102,0.20), transparent 62%)', + 'radial-gradient(80% 70% at 12% 96%, rgba(214,158,46,0.14), transparent 58%)', + ].join(', '), +}; + +const Pane = ({ + stats, + children, + isGold, +}: { + stats: PaneStat[]; + children: ReactNode; + isGold?: boolean; +}): ReactElement => ( +
+ {children} + {/* Reads as one line under the content, matching the HUD stats. */} +
+ {stats.map((stat) => ( +
+ + {stat.label} + + {stat.icon} + + {stat.value} + +
+ ))} +
+
+); + +type BadgeTrophyCaseProps = { + badges: ReactNode; + badgeStats: PaneStat[]; + awards: ReactNode; + awardStats: PaneStat[]; +}; + +export const BadgeTrophyCase = ({ + badges, + badgeStats, + awards, + awardStats, +}: BadgeTrophyCaseProps): ReactElement => { + return ( +
+ + {badges} + + {awards} +
+ ); +}; diff --git a/packages/webapp/components/game-center/ClaimableGifts.tsx b/packages/webapp/components/game-center/ClaimableGifts.tsx new file mode 100644 index 00000000000..c7ce3e4cf25 --- /dev/null +++ b/packages/webapp/components/game-center/ClaimableGifts.tsx @@ -0,0 +1,219 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + ClaimedChip, + OfferLogo, + offerBadgeLabels, +} from '@dailydotdev/shared/src/components/streak/offers/common'; +import type { UserOffer } from '@dailydotdev/shared/src/graphql/offers'; +import { + confirmOffersDelivered, + OfferPlacement, + userOffersQueryOptions, +} from '@dailydotdev/shared/src/graphql/offers'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import { LogEvent, TargetType } from '@dailydotdev/shared/src/lib/log'; + +export const OfferCard = ({ + offer, + isClaimed, + onClaim, +}: { + offer: UserOffer; + isClaimed: boolean; + onClaim: (offer: UserOffer) => void; +}): ReactElement => { + const subtitle = [ + offer.advertiserName, + offer.perk ?? + offer.description ?? + (offer.badgeLabel && offerBadgeLabels[offer.badgeLabel]), + ] + .filter(Boolean) + .join(' · '); + + return ( +
+
+ + + Sponsored + +
+ + + {offer.title} + + + {subtitle && ( + + {subtitle} + + )} + +
+ {isClaimed ? ( + + Claimed + + ) : ( + + )} +
+
+ ); +}; + +type ClaimableGiftsSectionProps = { + offers: UserOffer[]; + claimedUids: Set; + onClaim: (offer: UserOffer) => void; +}; + +export const ClaimableGiftsSection = ({ + offers, + claimedUids, + onClaim, +}: ClaimableGiftsSectionProps): ReactElement => ( +
+
+ + Claimable gifts + + + Sponsored offers. No charge until a trial ends, cancel anytime. + +
+
+ {offers.map((offer) => ( + + ))} +
+
+); + +type ClaimableGiftsProps = { + currentStreak: number; +}; + +export const ClaimableGifts = ({ + currentStreak, +}: ClaimableGiftsProps): ReactElement | null => { + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const [claimedUids, setClaimedUids] = useState>(new Set()); + const deliveredUids = useRef>(new Set()); + const { mutate: confirmDelivered } = useMutation({ + mutationFn: confirmOffersDelivered, + }); + + const { data: offers = [] } = useQuery({ + ...userOffersQueryOptions({ + user, + placement: OfferPlacement.StreakMilestone, + }), + enabled: !!user?.id, + }); + + // Render-then-confirm, as the popup does: an offer counts as delivered once + // its card is on screen, and the set guards replays since Encore does not + // dedupe. + useEffect(() => { + const fresh = offers.filter( + (offer) => !deliveredUids.current.has(offer.impressionUid), + ); + + if (!fresh.length) { + return; + } + + fresh.forEach((offer) => deliveredUids.current.add(offer.impressionUid)); + confirmDelivered(fresh.map((offer) => offer.impressionUid)); + fresh.forEach((offer) => + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.StreakOffer, + target_id: offer.impressionUid, + extra: JSON.stringify({ + brand: offer.advertiserName, + streak: currentStreak, + }), + }), + ); + }, [confirmDelivered, currentStreak, logEvent, offers]); + + const onClaim = useCallback( + (offer: UserOffer) => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.StreakOffer, + target_id: offer.impressionUid, + extra: JSON.stringify({ + brand: offer.advertiserName, + streak: currentStreak, + }), + }); + window.open(offer.clickUrl, '_blank', 'noopener,noreferrer'); + setClaimedUids((current) => new Set(current).add(offer.impressionUid)); + }, + [currentStreak, logEvent], + ); + + // No offers means no section at all, header included. + if (!offers.length) { + return null; + } + + return ( + + ); +}; diff --git a/packages/webapp/components/game-center/CommunityPulse.tsx b/packages/webapp/components/game-center/CommunityPulse.tsx new file mode 100644 index 00000000000..dee7ef3d3cd --- /dev/null +++ b/packages/webapp/components/game-center/CommunityPulse.tsx @@ -0,0 +1,148 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { QuestCompletionStats } from '@dailydotdev/shared/src/graphql/leaderboard'; +import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/Leaderboard'; +import { + ProfileImageSize, + ProfilePicture, +} from '@dailydotdev/shared/src/components/ProfilePicture'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { ProgressBar } from '@dailydotdev/shared/src/components/fields/ProgressBar'; +import { formatDataTileValue } from '@dailydotdev/shared/src/lib/numberFormat'; + +const raceLength = 5; + +type RaceProps = { + title: string; + entries: UserLeaderboard[]; + unit: string; + viewerId?: string; +}; + +const Race = ({ title, entries, unit, viewerId }: RaceProps): ReactElement => { + const ranked = entries.slice(0, raceLength); + // The bars are relative to the leader, so the field reads as a race rather + // than as a set of unrelated numbers. + const top = ranked[0]?.score ?? 0; + + return ( +
+ + {title} + +
+ {ranked.map((entry, index) => { + const isViewer = !!viewerId && entry.user.id === viewerId; + + return ( +
+ + {index + 1} + + + + + + +
+
+ + {isViewer ? 'You' : entry.user.name} + + + {formatDataTileValue(entry.score)} + +
+ +
+
+ ); + })} +
+
+ ); +}; + +type CommunityPulseProps = { + stats: QuestCompletionStats | null; + highestReputation: UserLeaderboard[]; + mostQuestsCompleted: UserLeaderboard[]; + mostTopics?: UserLeaderboard[]; + viewerId?: string; +}; + +export const CommunityPulse = ({ + stats, + highestReputation, + mostQuestsCompleted, + mostTopics, + viewerId, +}: CommunityPulseProps): ReactElement => ( +
+
+ + + {mostTopics && ( + + )} +
+ + {stats && ( +
+ + quests completed all-time + + + {formatDataTileValue(stats.totalCount)} + +
+ )} +
+); diff --git a/packages/webapp/components/game-center/MilestoneQuestList.tsx b/packages/webapp/components/game-center/MilestoneQuestList.tsx new file mode 100644 index 00000000000..fba31bb0d82 --- /dev/null +++ b/packages/webapp/components/game-center/MilestoneQuestList.tsx @@ -0,0 +1,214 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ProgressBar } from '@dailydotdev/shared/src/components/fields/ProgressBar'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + CoreIcon, + ReputationIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { + getQuestStatusLabel, + getVisibleQuestRewards, +} from '@dailydotdev/shared/src/components/quest/QuestCard'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { ColorName } from '@dailydotdev/shared/src/styles/colors'; +import type { + QuestReward, + QuestType, + UserQuest, +} from '@dailydotdev/shared/src/graphql/quests'; +import { + QuestRewardType, + QuestStatus, +} from '@dailydotdev/shared/src/graphql/quests'; +import { sortMilestoneQuests } from '../../lib/gameCenter'; + +const RewardChipIcon = ({ type }: { type: QuestRewardType }): ReactElement => { + if (type === QuestRewardType.Cores) { + return ( + + ); + } + + if (type === QuestRewardType.Reputation) { + return ( + + ); + } + + return ( + + xp + + ); +}; + +const RewardChip = ({ reward }: { reward: QuestReward }): ReactElement => ( + + +{reward.amount.toLocaleString()} + +); + +type MilestoneQuestCardProps = { + quest: UserQuest; + showLevelSystem: boolean; + isClaiming: boolean; + onClaim: (userQuestId: string, questId: string, questType: QuestType) => void; +}; + +const MilestoneQuestCard = ({ + quest, + showLevelSystem, + isClaiming, + onClaim, +}: MilestoneQuestCardProps): ReactElement => { + const target = Math.max(quest.quest.targetCount, 1); + const value = Math.min(Math.max(quest.progress, 0), target); + const percentage = Math.min(100, Math.round((value / target) * 100)); + const isClaimed = quest.status === QuestStatus.Claimed; + const canClaim = quest.claimable && !!quest.userQuestId && !isClaimed; + const visibleRewards = getVisibleQuestRewards(quest.rewards, showLevelSystem); + const statusLabel = getQuestStatusLabel(quest); + + return ( +
+ + {quest.quest.name} + + + {quest.quest.description} + + + {visibleRewards.length > 0 && ( +
+ {visibleRewards.map((reward, index) => ( + + ))} +
+ )} + + {/* Pushes the claim button and progress bar to a shared baseline so + neighbouring cards in the scroller line up. */} +
+ {canClaim ? ( + + ) : ( + <> + +
+ + {value}/{target} + + + {statusLabel} + +
+ + )} +
+
+ ); +}; + +type MilestoneQuestListProps = { + quests: UserQuest[]; + showLevelSystem: boolean; + claimingQuestId?: string; + onClaim: (userQuestId: string, questId: string, questType: QuestType) => void; +}; + +export const MilestoneQuestList = ({ + quests, + showLevelSystem, + claimingQuestId, + onClaim, +}: MilestoneQuestListProps): ReactElement => { + const ordered = sortMilestoneQuests(quests); + + return ( +
+ {ordered.map((quest) => ( + + ))} +
+ ); +}; diff --git a/packages/webapp/components/game-center/SeeAllAchievementsCard.tsx b/packages/webapp/components/game-center/SeeAllAchievementsCard.tsx new file mode 100644 index 00000000000..abdd150b75c --- /dev/null +++ b/packages/webapp/components/game-center/SeeAllAchievementsCard.tsx @@ -0,0 +1,35 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; + +type SeeAllAchievementsCardProps = { + href: string; +}; + +export const SeeAllAchievementsCard = ({ + href, +}: SeeAllAchievementsCardProps): ReactElement => { + return ( + + + + + + + See all achievements + + + + ); +}; diff --git a/packages/webapp/components/game-center/TrophyGrid.tsx b/packages/webapp/components/game-center/TrophyGrid.tsx new file mode 100644 index 00000000000..991ae3b55ea --- /dev/null +++ b/packages/webapp/components/game-center/TrophyGrid.tsx @@ -0,0 +1,61 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { featuredAwardImage } from '@dailydotdev/shared/src/lib/image'; +import type { AwardWithRarity } from '../../lib/gameCenter'; + +type TrophyGridProps = { + awards: AwardWithRarity[]; +}; + +const Cell = ({ award }: { award: AwardWithRarity }): ReactElement => { + return ( + +
+ {award.name} + + {award.name} + + + ×{award.count.toLocaleString()} + +
+
+ ); +}; + +export const TrophyGrid = ({ awards }: TrophyGridProps): ReactElement => { + return ( +
+ {awards.map((award) => ( + + ))} +
+ ); +}; diff --git a/packages/webapp/lib/gameCenter.spec.ts b/packages/webapp/lib/gameCenter.spec.ts index 46a2cdfa432..d6913094c4c 100644 --- a/packages/webapp/lib/gameCenter.spec.ts +++ b/packages/webapp/lib/gameCenter.spec.ts @@ -2,7 +2,10 @@ import type { QuestDashboard, UserQuest, } from '@dailydotdev/shared/src/graphql/quests'; -import type { UserProductSummary } from '@dailydotdev/shared/src/graphql/njord'; +import type { + Product, + UserProductSummary, +} from '@dailydotdev/shared/src/graphql/njord'; import { QuestRewardType, QuestStatus, @@ -18,6 +21,7 @@ import { getMostProgressedQuest, getQuestSummary, getTopReaderTopicLabel, + sortMilestoneQuests, } from './gameCenter'; const createQuest = ( @@ -209,6 +213,49 @@ describe('game center helpers', () => { expect(mostProgressedQuest?.quest.id).toBe('ratio-winner'); }); + it('orders milestones claimable first, then closest to done, then claimed', () => { + const milestone = ( + questId: string, + overrides: Partial, + targetCount = 10, + ) => + createQuest({ + questId, + name: questId, + ...overrides, + quest: { + id: questId, + name: questId, + description: `${questId} description`, + type: QuestType.Milestone, + eventType: 'read_post', + targetCount, + }, + }); + + const ordered = sortMilestoneQuests([ + milestone('claimed', { + progress: 10, + status: QuestStatus.Claimed, + claimedAt: new Date('2025-02-01T00:00:00.000Z'), + }), + milestone('barely-started', { progress: 1 }), + milestone('almost-done', { progress: 9 }), + milestone('claimable', { + progress: 10, + claimable: true, + status: QuestStatus.Completed, + }), + ]); + + expect(ordered.map((quest) => quest.quest.id)).toEqual([ + 'claimable', + 'almost-done', + 'barely-started', + 'claimed', + ]); + }); + it('builds achievement summaries with deduped featured cards', () => { const tracked = createAchievement({ id: 'tracked', @@ -348,4 +395,26 @@ describe('game center helpers', () => { 'award-3', ]); }); + + it('orders the trophy grid rarest-first by award value', () => { + const awards: UserProductSummary[] = [ + { id: 'a', name: 'Cheap', image: 'a.png', count: 40 }, + { id: 'b', name: 'Pricey', image: 'b.png', count: 1 }, + { id: 'c', name: 'Mid', image: 'c.png', count: 5 }, + ]; + const catalog = [ + { id: 'a', value: 10 }, + { id: 'b', value: 500 }, + { id: 'c', value: 100 }, + ] as Product[]; + + const { awardsByRarity } = getAwardSummary(awards, catalog); + + // rarest (highest value) first, regardless of how many were earned + expect(awardsByRarity.map((award) => award.id)).toEqual(['b', 'c', 'a']); + }); + + it('has an empty trophy grid when there are no awards', () => { + expect(getAwardSummary([], []).awardsByRarity).toEqual([]); + }); }); diff --git a/packages/webapp/lib/gameCenter.ts b/packages/webapp/lib/gameCenter.ts index 1ab04e5eab8..5fffcc0f850 100644 --- a/packages/webapp/lib/gameCenter.ts +++ b/packages/webapp/lib/gameCenter.ts @@ -1,5 +1,8 @@ import type { TopReader } from '@dailydotdev/shared/src/components/badges/TopReaderBadge'; -import type { UserProductSummary } from '@dailydotdev/shared/src/graphql/njord'; +import type { + Product, + UserProductSummary, +} from '@dailydotdev/shared/src/graphql/njord'; import type { QuestBucket, QuestDashboard, @@ -130,6 +133,42 @@ export const getMostProgressedQuest = ( })[0]; }; +const MILESTONE_RANK_CLAIMABLE = 0; +const MILESTONE_RANK_IN_PROGRESS = 1; +const MILESTONE_RANK_CLAIMED = 2; + +const getMilestoneRank = (quest: UserQuest): number => { + if (quest.status === QuestStatus.Claimed) { + return MILESTONE_RANK_CLAIMED; + } + + if (quest.claimable) { + return MILESTONE_RANK_CLAIMABLE; + } + + return MILESTONE_RANK_IN_PROGRESS; +}; + +// A claimable milestone is a reward the user can collect right now, so it wins +// the top of the list over anything still running, and spent ones sink. +export const sortMilestoneQuests = (quests: UserQuest[]): UserQuest[] => + [...quests].sort((left, right) => { + const rankDifference = getMilestoneRank(left) - getMilestoneRank(right); + + if (rankDifference !== 0) { + return rankDifference; + } + + const ratioDifference = + getQuestProgressRatio(right) - getQuestProgressRatio(left); + + if (ratioDifference !== 0) { + return ratioDifference; + } + + return getQuestRewardTotal(right) - getQuestRewardTotal(left); + }); + export const getQuestSummary = ( dashboard?: QuestDashboard, ): GameCenterQuestSummary => { @@ -190,6 +229,9 @@ export type GameCenterAchievementSummary = { rarestUnlocked: UserAchievement | null; nextToUnlock: UserAchievement | null; featuredAchievements: UserAchievement[]; + // Everything unlocked or under way, for the shelf. Untouched achievements + // stay out: an empty progress bar says nothing about your history. + shelfAchievements: UserAchievement[]; }; export const getAchievementSummary = ( @@ -238,12 +280,38 @@ export const getAchievementSummary = ( return right.achievement.points - left.achievement.points; })[0] ?? null; + // The four are picked by role, then ordered by how far along they are, so + // the shelf reads left to right from unlocked to furthest away. const featuredAchievements = dedupeAchievements([ trackedAchievement?.unlockedAt ? null : trackedAchievement ?? null, nextToUnlock, latestUnlocked, rarestUnlocked, - ]); + ]).sort( + (left, right) => + getAchievementProgressRatio(right) - getAchievementProgressRatio(left), + ); + + const shelfAchievements = [...allAchievements] + .filter( + (achievement) => + achievement.unlockedAt !== null || achievement.progress > 0, + ) + .sort((left, right) => { + const ratioDifference = + getAchievementProgressRatio(right) - getAchievementProgressRatio(left); + + if (ratioDifference !== 0) { + return ratioDifference; + } + + // Everything completed ties at 100%, so rarity breaks it and the + // hardest-won achievement leads the row. + const leftRarity = left.achievement.rarity ?? Number.POSITIVE_INFINITY; + const rightRarity = right.achievement.rarity ?? Number.POSITIVE_INFINITY; + + return leftRarity - rightRarity; + }); return { unlockedCount: unlocked.length, @@ -256,6 +324,7 @@ export const getAchievementSummary = ( rarestUnlocked, nextToUnlock, featuredAchievements, + shelfAchievements, }; }; @@ -275,21 +344,75 @@ const sortAwardsByCount = ( }); }; +export type AwardWithRarity = UserProductSummary & { + value: number; + imageGlow?: string | null; +}; + export type GameCenterAwardSummary = { awards: UserProductSummary[]; + // Awards ordered rarest-first. + awardsByRarity: AwardWithRarity[]; + // Awards ordered by how many you hold, for the trophy grid. + awardsByCount: AwardWithRarity[]; totalAwards: number; + // What the collection is worth in Cores, counting duplicates. + totalAwardValue: number; uniqueAwards: number; favoriteAward: UserProductSummary | null; }; +const enrichAwardsWithRarity = ( + awards: UserProductSummary[], + products: Product[], +): AwardWithRarity[] => { + const byId = new Map(products.map((product) => [product.id, product])); + return awards.map((award) => { + const product = byId.get(award.id); + return { + ...award, + value: product?.value ?? 0, + imageGlow: product?.flags?.imageGlow ?? null, + }; + }); +}; + +// Rarest-first: an award's Cores value is the rarity signal, with the earned +// count and name as tie-breakers. +export const sortAwardsByRarity = ( + awards: AwardWithRarity[], +): AwardWithRarity[] => { + return [...awards].sort((left, right) => { + if (left.value !== right.value) { + return right.value - left.value; + } + if (left.count !== right.count) { + return right.count - left.count; + } + return left.name.localeCompare(right.name); + }); +}; + export const getAwardSummary = ( awards?: UserProductSummary[], + products?: Product[], ): GameCenterAwardSummary => { const allAwards = sortAwardsByCount(awards ?? []); + const awardsByRarity = sortAwardsByRarity( + enrichAwardsWithRarity(allAwards, products ?? []), + ); return { awards: allAwards, + awardsByRarity, + awardsByCount: [...awardsByRarity].sort( + (left, right) => right.count - left.count, + ), totalAwards: allAwards.reduce((total, award) => total + award.count, 0), + totalAwardValue: awardsByRarity.reduce( + (total, award) => total + award.value * award.count, + 0, + ), uniqueAwards: allAwards.length, favoriteAward: allAwards[0] ?? null, }; diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 6c0241f3c24..35f7bed7c6f 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -8,12 +8,12 @@ import { useQuery } from '@tanstack/react-query'; import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; import type { QuestCompletionStats } from '@dailydotdev/shared/src/graphql/leaderboard'; import { - HIGHEST_REPUTATION_QUERY, - LeaderboardType, + MOST_ACHIEVEMENT_POINTS_QUERY, MOST_QUESTS_COMPLETED_QUERY, QUEST_COMPLETION_STATS_QUERY, } from '@dailydotdev/shared/src/graphql/leaderboard'; import { + getProductsQueryOptions, ProductType, userProductSummaryQueryOptions, } from '@dailydotdev/shared/src/graphql/njord'; @@ -29,14 +29,11 @@ import { useHasAccessToCores } from '@dailydotdev/shared/src/hooks/useCoresFeatu import { useQuestDashboard } from '@dailydotdev/shared/src/hooks/useQuestDashboard'; import { shouldShowAchievementTracker } from '@dailydotdev/shared/src/lib/achievements'; import { gameCenterMilestoneSectionId } from '@dailydotdev/shared/src/lib/constants'; -import { - formatDate, - TimeFormatType, -} from '@dailydotdev/shared/src/lib/dateFormat'; import type { GraphQLError } from '@dailydotdev/shared/src/lib/errors'; import { featuredAwardImage } from '@dailydotdev/shared/src/lib/image'; import { achievementTrackingWidgetFeature } from '@dailydotdev/shared/src/lib/featureManagement'; import { fetchTopReaders } from '@dailydotdev/shared/src/lib/topReader'; +import { formatDataTileValue } from '@dailydotdev/shared/src/lib/numberFormat'; import { getFirstName } from '@dailydotdev/shared/src/lib/user'; import { generateQueryKey, @@ -47,7 +44,6 @@ import { LayoutHeader } from '@dailydotdev/shared/src/components/layout/common'; import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; import { - Divider, ResponsivePageContainer, pageBorders, } from '@dailydotdev/shared/src/components/utilities'; @@ -58,26 +54,20 @@ import { TypographyTag, TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; -import { ProgressBar } from '@dailydotdev/shared/src/components/fields/ProgressBar'; import { DataTile } from '@dailydotdev/shared/src/components/DataTile'; import { Image } from '@dailydotdev/shared/src/components/image/Image'; import { LazyImage } from '@dailydotdev/shared/src/components/LazyImage'; import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; import { Button, + ButtonIconPosition, ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; -import { AchievementCard } from '@dailydotdev/shared/src/features/profile/components/achievements/AchievementCard'; -import { TopReaderBadge } from '@dailydotdev/shared/src/components/badges/TopReaderBadge'; -import { - QuestLevelProgressCircle, - getQuestLevelProgress, -} from '@dailydotdev/shared/src/components/quest/QuestLevelProgressCircle'; -import { QuestSection } from '@dailydotdev/shared/src/components/quest/QuestButton'; -import type { QuestDestination } from '@dailydotdev/shared/src/components/quest/QuestButton'; +import { AchievementShelfCard } from '@dailydotdev/shared/src/features/profile/components/achievements/AchievementShelfCard'; +import { getQuestLevelProgress } from '@dailydotdev/shared/src/components/quest/QuestLevelProgressCircle'; +import { LevelHud } from '@dailydotdev/shared/src/components/quest/LevelHud'; import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/Leaderboard'; -import { UserTopList } from '@dailydotdev/shared/src/components/cards/Leaderboard'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { ArrowIcon, @@ -88,6 +78,14 @@ import { import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; import { getPageSeoTitles } from '../../components/layouts/utils'; +import { + BadgePager, + BadgeTrophyCase, +} from '../../components/game-center/BadgeTrophyCase'; +import { MilestoneQuestList } from '../../components/game-center/MilestoneQuestList'; +import { SeeAllAchievementsCard } from '../../components/game-center/SeeAllAchievementsCard'; +import { CommunityPulse } from '../../components/game-center/CommunityPulse'; +import { TrophyGrid } from '../../components/game-center/TrophyGrid'; import ProtectedPage from '../../components/ProtectedPage'; import { defaultOpenGraph } from '../../next-seo'; import { @@ -95,7 +93,6 @@ import { getAwardSummary, getBadgeSummary, getMostProgressedQuest, - getTopReaderTopicLabel, } from '../../lib/gameCenter'; type GameCenterPageProps = { @@ -106,11 +103,25 @@ type GameCenterPageProps = { type SectionProps = { title: string; - description: string; action?: ReactElement; }; -const dividerClassName = 'bg-border-subtlest-tertiary'; +// PLACEHOLDER. No leaderboard exists for top reader badge counts, so these +// rows are invented. Do not ship: wire a real query or drop the column. +const placeholderTopReaderBoard: UserLeaderboard[] = [ + { score: 24, user: { id: 't1', name: 'Ole-Martin', username: 'ombratteng' } }, + { + score: 19, + user: { id: 't2', name: 'Bobby Iliev', username: 'bobbyiliev' }, + }, + { score: 17, user: { id: 't3', name: 'Ante Baric', username: 'capjavert' } }, + { score: 15, user: { id: 't4', name: 'Jay', username: 'finallyjay' } }, + { + score: 12, + user: { id: 't5', name: 'Keith Solomon', username: 'ksolomon' }, + }, +] as UserLeaderboard[]; + const leaderboardLimit = 3; const isQuestCompletionStatsSchemaMissing = (error: GraphQLError): boolean => { @@ -121,33 +132,17 @@ const isQuestCompletionStatsSchemaMissing = (error: GraphQLError): boolean => { ); }; -const formatQuestCompletionCount = (count: number): string => { - return count === 1 ? '1 completion' : `${count.toLocaleString()} completions`; -}; - -const SectionHeader = ({ - title, - description, - action, -}: SectionProps): ReactElement => { +const SectionHeader = ({ title, action }: SectionProps): ReactElement => { return ( -
-
- - {title} - - - {description} - -
+
+ + {title} + {action}
); @@ -166,7 +161,7 @@ const EmptyStateCard = ({ {title} @@ -176,52 +171,6 @@ const EmptyStateCard = ({ ); }; -const StatPill = ({ - label, - value, -}: { - label: string; - value: string; -}): ReactElement => ( -
- - {label} - - - {value} - -
-); - -const TrophyCard = ({ - name, - image, - count, -}: { - name: string; - image: string; - count: number; -}): ReactElement => { - return ( - -
- - - x{count.toLocaleString()} - -
-
- ); -}; - const seoTitles = getPageSeoTitles('Game Center'); const seo: NextSeoProps = { title: seoTitles.title, @@ -303,7 +252,6 @@ function GameCenterPage({ const claimingMilestoneQuestId = isClaimQuestPending ? claimQuestVariables?.userQuestId : undefined; - const emptyQuestAnimationState = useMemo(() => new Set(), []); const topReaderQueryKey = generateQueryKey( RequestKey.TopReaderBadge, @@ -322,14 +270,7 @@ function GameCenterPage({ staleTime: StaleTime.OneHour, enabled: !!user?.id, }); - const badgeCaseBadges = useMemo( - () => topReaderBadges.slice(0, 3), - [topReaderBadges], - ); - const badgeSummary = useMemo( - () => getBadgeSummary(topReaderBadges), - [topReaderBadges], - ); + const { data: awardProducts = [], isPending: isAwardsPending, @@ -342,16 +283,24 @@ function GameCenterPage({ }), enabled: !!user?.id && hasCoresAccess, }); + const { data: awardCatalog } = useQuery({ + ...getProductsQueryOptions(), + enabled: !!user?.id && hasCoresAccess, + }); const awardSummary = useMemo( - () => getAwardSummary(awardProducts), - [awardProducts], + () => + getAwardSummary( + awardProducts, + awardCatalog?.edges?.map((edge) => edge.node), + ), + [awardProducts, awardCatalog], ); const levelProgress = questDashboard ? getQuestLevelProgress(questDashboard.level) : 0; const firstName = user?.name ? getFirstName(user.name) : 'there'; - const { featuredAchievements } = achievementSummary; + const { featuredAchievements, shelfAchievements } = achievementSummary; const [featuredAchievement] = featuredAchievements; const upcomingMilestoneQuest = useMemo( () => getMostProgressedQuest(milestoneQuests), @@ -360,15 +309,6 @@ function GameCenterPage({ const hasCommunityLeaderboards = highestReputation.length > 0 || mostQuestsCompleted.length > 0; const milestoneHash = `#${gameCenterMilestoneSectionId}`; - let mostEarnedBadgeSubtitle = - 'Read in a topic more than once to see a favorite'; - - if (badgeSummary.mostEarnedBadge) { - mostEarnedBadgeSubtitle = - badgeSummary.mostEarnedBadgeCount === 1 - ? 'earned once' - : `earned ${badgeSummary.mostEarnedBadgeCount.toLocaleString()} times`; - } const isFeaturedAchievementTrackable = shouldTrackAchievements && @@ -397,20 +337,6 @@ function GameCenterPage({ featuredAchievement.achievement.id, ); }; - const handleMilestoneDestinationClick = useCallback( - async (destination: QuestDestination) => { - if ('href' in destination) { - if (destination.openInNewTab) { - window.open(destination.href!, '_blank', 'noopener,noreferrer'); - return; - } - window.location.assign(destination.href!); - return; - } - await router.push(destination.path); - }, - [router], - ); const handleMilestoneClaim = useCallback( (userQuestId: string, questId: string, questType: QuestType) => { claimQuestReward({ @@ -447,17 +373,10 @@ function GameCenterPage({ ); } else if (milestoneQuests.length > 0) { milestoneQuestContent = ( - ); @@ -479,11 +398,11 @@ function GameCenterPage({ description="Your unlock history is on the way." /> ); - } else if (featuredAchievements.length > 0) { + } else if (shelfAchievements.length > 0) { achievementShelfContent = ( -
- {featuredAchievements.map((achievement) => ( - + {shelfAchievements.map((achievement) => ( + ))} + {user?.username && ( + + )}
); } else { @@ -516,6 +438,16 @@ function GameCenterPage({ ); } + // Heaviest reading first, so the strongest topics lead the column. + const sortedBadges = useMemo( + () => [...topReaderBadges].sort((left, right) => right.total - left.total), + [topReaderBadges], + ); + + const badgeCountLabel = isBadgesPending + ? '...' + : formatDataTileValue(getBadgeSummary(topReaderBadges).uniqueTopics); + let badgeCaseContent: ReactElement; if (isBadgesPending) { @@ -526,87 +458,7 @@ function GameCenterPage({ /> ); } else if (topReaderBadges.length > 0) { - badgeCaseContent = ( - <> -
- - {badgeSummary.latestBadge - ? formatDate({ - value: badgeSummary.latestBadge.issuedAt, - type: TimeFormatType.TopReaderBadge, - }) - : 'Read deeply to earn your first badge'} - - } - /> - - } - subtitle={ - - breadth of expertise - - } - /> - - {mostEarnedBadgeSubtitle} - - } - /> -
-
-
- {badgeCaseBadges.map((badge) => ( -
- -
- ))} -
-
- - ); + badgeCaseContent = ; } else { badgeCaseContent = ( 0; + const awardCountLabel = hasAwards + ? formatDataTileValue(awardSummary.totalAwards) + : '0'; + const awardValueLabel = hasAwards + ? formatDataTileValue(awardSummary.totalAwardValue) + : '0'; + let trophyCaseContent: ReactElement; if (!hasCoresAccess) { @@ -642,23 +506,7 @@ function GameCenterPage({ } else if (awardSummary.awards.length > 0) { trophyCaseContent = ( <> -
- - } - subtitle={ - - all-time collection - - } - /> +
unique trophies earned @@ -692,7 +540,7 @@ function GameCenterPage({ } subtitle={ @@ -701,22 +549,7 @@ function GameCenterPage({ } />
-
-
- {awardSummary.awards.map((award) => ( - - ))} -
-
+ ); } else { @@ -746,407 +579,181 @@ function GameCenterPage({ )} - -
-
-
-
-
-
-
-
+ +
+ {questDashboard ? ( + + ) : ( + showAchievements && ( +
- Progress snapshot + Personal highlight - - {firstName}, here's how you're doing. + + {achievementSummary.unlockedCount}/ + {achievementSummary.totalCount} - The Game Center pulls together your quest progress, - achievement milestones, recent badges, creator rewards, and - a few community benchmarks so you can see both momentum and - upside at a glance. + achievements unlocked so far
+ ) + )} -
- - - -
+
+
+ + Upcoming milestone + + + {upcomingMilestoneQuest?.quest.name ?? + 'No upcoming milestone yet'} + + + {upcomingMilestoneQuest + ? `${Math.min( + upcomingMilestoneQuest.progress, + upcomingMilestoneQuest.quest.targetCount, + )}/${upcomingMilestoneQuest.quest.targetCount} progress` + : 'Your next milestone will show up here.'} + +
-
-
+ {showAchievements && ( +
+
- Upcoming milestone - - - {upcomingMilestoneQuest?.quest.name ?? - 'No upcoming milestone yet'} - - - {upcomingMilestoneQuest - ? `${Math.min( - upcomingMilestoneQuest.progress, - upcomingMilestoneQuest.quest.targetCount, - )}/${ - upcomingMilestoneQuest.quest.targetCount - } progress` - : 'Your next milestone will show up here.'} + Closest achievement + {isFeaturedAchievementTrackable && ( + +
- - {showAchievements && ( -
-
- - Closest achievement - - {isFeaturedAchievementTrackable && ( - -
-
- {featuredAchievement && ( - - )} -
- - {featuredAchievement?.achievement.name ?? - 'No tracked achievement'} - - - {featuredAchievement - ? `${ - featuredAchievement.progress - }/${getTargetCount( - featuredAchievement.achievement, - )} progress` - : 'Once achievements load, your closest milestone shows here.'} - -
-
-
- )} -
-
- -
- {questDashboard ? ( - <> -
- + {featuredAchievement && ( + -
- - Current level - - - Level {questDashboard.level.level} - -
-
-
-
- - XP to next level - - - {questDashboard.level.xpToNextLevel.toLocaleString()} - -
- -
- - ) : ( - showAchievements && ( - <> + )} +
- Personal highlight - - - {achievementSummary.unlockedCount}/ - {achievementSummary.totalCount} + {featuredAchievement?.achievement.name ?? + 'No tracked achievement'} - achievements unlocked so far + {featuredAchievement + ? `${featuredAchievement.progress}/${getTargetCount( + featuredAchievement.achievement, + )} progress` + : 'Once achievements load, your closest milestone shows here.'} - - ) - )} -
+
+
+
+ )}
- -
- + {milestoneQuestContent}
- - -
- - - Open full leaderboards - - - - } - /> - {questCompletionStats && ( -
- - - {questCompletionStats.allTimeLeader?.questDescription ?? - 'Criteria will show once the first quest is completed'} - - - {questCompletionStats.allTimeLeader - ? formatQuestCompletionCount( - questCompletionStats.allTimeLeader.count, - ) - : 'Waiting on the first completion'} - -
- } - /> - - - {questCompletionStats.weeklyLeader?.questDescription ?? - 'Criteria will show once a quest is completed this week'} - - - {questCompletionStats.weeklyLeader - ? formatQuestCompletionCount( - questCompletionStats.weeklyLeader.count, - ) - : 'No completed quests yet this week'} - -
- } - /> - - all-time community total -
- } - /> -
- )} - {hasCommunityLeaderboards ? ( -
- {highestReputation.length > 0 && ( - - )} - {mostQuestsCompleted.length > 0 && ( - - )} -
- ) : ( - - )} - - {showAchievements && ( <> - -
- - View all achievements - - - + ) : undefined } /> @@ -1156,26 +763,57 @@ function GameCenterPage({ )} - -
- + + + ), + }, + ]} /> - - {badgeCaseContent}
- -
+ + Open full leaderboards + + + + } /> - - {trophyCaseContent} + {hasCommunityLeaderboards || questCompletionStats ? ( + + ) : ( + + )}
@@ -1197,8 +835,8 @@ export async function getStaticProps(): Promise< try { const [highestReputationRes, mostQuestsCompletedRes] = await Promise.all([ gqlClient.request<{ - highestReputation: UserLeaderboard[]; - }>(HIGHEST_REPUTATION_QUERY, { limit: leaderboardLimit }), + mostAchievementPoints: UserLeaderboard[]; + }>(MOST_ACHIEVEMENT_POINTS_QUERY, { limit: leaderboardLimit }), gqlClient.request<{ mostQuestsCompleted: UserLeaderboard[]; }>(MOST_QUESTS_COMPLETED_QUERY, { limit: leaderboardLimit }), @@ -1221,7 +859,7 @@ export async function getStaticProps(): Promise< return { props: { - highestReputation: highestReputationRes.highestReputation ?? [], + highestReputation: highestReputationRes.mostAchievementPoints ?? [], mostQuestsCompleted: mostQuestsCompletedRes.mostQuestsCompleted ?? [], questCompletionStats, },